WebGL学习06-投影,视图和模型矩阵

3,120 阅读10分钟

前言

为了更好的模拟3D真实场景,引入了投影矩阵(Projection),视图矩阵(View),模型矩阵(Model)3个概念,通过在Vertex Shader中使用透视矩阵 * 视图矩阵 * 模型矩阵 * 顶点得出最终的顶点位置。

投影矩阵

投影矩阵的主要目的是将顶点从3D空间投影到2D空间,主要有2种投影方式,透视(Perspective)和 正交(Orthogonal),它们的区别主要在于顶点在z轴上的远近是否会影响其在xy平面上投影的位置,举例来说,顶点(1,2,1)和(1,2,10)在正交投影下对应的是2D空间同一个点,在透视投影下则不是同一个点。正交的应用场合一般来说是2D UI渲染,透视则应用于3D物体渲染,通过透视投影可以形成远小近大的真实世界视觉效果。下面分别详细介绍一下这两种矩阵

1. 正交投影矩阵

例子完整项目代码,在chapter2section3-0目录下

投影矩阵是一个4x4的矩阵,正交投影矩阵一般用来将基于像素的坐标转换成OpenGL的坐标,比如一个800 x 600的窗口,使用(400,200),(300,300),(500,300)这三个点绘制一个三角形。按照之前的思路,需要手动将坐标转换成-1 ~ 1的范围,再传递给OpenGL,但是如果使用正交投影矩阵,就可以直接使用基于像素坐标系的顶点了。现在我们使用glMatrix来构造一个正交投影矩阵并修改Shader使用这个矩阵

首先更换顶点数据

function prepareVertexData() {
    // Prepare Vertex Data
    let vertices = [        400, 200, 0,         300, 300, 0,         500, 300, 0,     ];

    // 在GPU上为顶点数据开辟空间
    vertexData = gl.createBuffer();
    // 将上面开辟的空间进入编辑模式
    gl.bindBuffer(gl.ARRAY_BUFFER, vertexData);
    // 向上面开辟的空间写入数据
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
}

使用glMatrix创建正交投影矩阵

let projection:mat4 = mat4.create();
mat4.ortho(projection, 0, 800, 600, 0, -100,100);

ortho方法的参数分别表示像素坐标系的左(left),右(right),下(bottom),上(top)的像素坐标,以及z轴上距离视点的最近(near)和最远距离(far),这里视点被设定为0,0,0点,这些约束组成了一个盒体,如下图所示

这个盒体内部的顶点通过投影矩阵会被映射到OpenGL的坐标系空间,注意,这里最近点和最远点的z轴取的是-near和-far,所以如果near和far取的是0到100,那么只有z轴是-100到0的顶点才能被投影。下图展示的是OpenGL的坐标系空间

最后通过对Vertex Shader进行修改完成矩阵的传递和应用

let vertexShaderCode = 
"attribute vec4 position;"+
"uniform mat4 projection;"+
"void main() {" +
    "gl_Position = projection * position;" +
"}";
let uniformLoc = gl.getUniformLocation(program, "projection");
gl.uniformMatrix4fv(uniformLoc, false, projection);

2. 透视投影矩阵

例子完整项目代码,在chapter2section3-1目录下

透视投影矩阵的用处就是模拟现实世界近大远小的视觉效果,先来看看glMatrix如何创建一个透视投影矩阵

let projection:mat4 = mat4.create();
mat4.perspective(projection, 60/180 * Math.PI, 800 / 600,0.001,1000);

一共有四个参数,分别是fovyaspectnearfarfovy代表视角,单位是弧度,aspect是画布的宽高比,nearfar同正交矩阵,只有z轴坐标在-near-far范围内顶点才会被投影到有效的OpenGL 坐标系中,不同的是这里的nearfar必须大于0。下面是透视矩阵对应的包围盒

这里的包围盒是一个梯形的锥体,内部的顶点通过透视投影矩阵转换到OpenGL坐标系空间,通过上图可以看出来,视角fovy越大,能够投影到OpenGL坐标系空间的顶点越多,也就是视野范围越大,靠近视点的平面和远离视点的平面宽高比都是aspect

调整顶点坐标,让三角形在视野范围内

let vertices = [
    0, 0.5, -3, 
    -0.5, -0.5, -3, 
    0.5, -0.5, -3, 
];

这里z轴使用了-3,在-0.001-1000之间,你可以尝试改变z轴的值,来看看有什么不同的效果。

视图矩阵

例子完整项目代码,在chapter2section3-2目录下

视图矩阵模拟了真实世界的摄像机,它的工作原理是将顶点从世界坐标系转换到视图坐标系,可以通过下面的图来简单理解,假设在一维坐标系中有一个观测点和一个被观测物体

视图矩阵的作用就是将被观测物体的坐标转换到以观测点为原点的坐标系中。

在投影矩阵中我们设定视点在(0,0,0)点,所以我们需要在顶点交给投影矩阵处理之前,通过视图矩阵将顶点坐标转换到视点是(0,0,0)点的坐标系中。 回到三维坐标系,假设虚拟摄像机的变换矩阵是CameraMatrix,我们要做的就是求出当摄像机变换矩阵是单位矩阵时,顶点应该是什么变换矩阵

假设顶点变换矩阵是 ModelMatrix
假设顶点在摄像机变换矩阵是单位矩阵的坐标系中变换矩阵是 ModelMatrix_camera
那么有以下等式

ModelMatrix_camera = inverse(CameraMatrix) * ModelMatrix

inverse代表求CameraMatrix的逆矩阵

视图矩阵实际就是对虚拟摄像机变换矩阵的求逆。

接下来通过glMatrix来实践视图矩阵,首先创建视图矩阵

let view:mat4 = mat4.create();
mat4.lookAt(view, [0,0,1], [0,0,0], [0,1,0]);

lookAt直接创建的就是视图矩阵,并不需要自行求逆。方法的第二个参数eye表示虚拟摄像机的位置,第三个参数center表示摄像机的前方指向的坐标,第四个参数up表示摄像机朝上的方向。得到视图矩阵后,需要将它传递给Vertex Shader,然后和投影矩阵相乘

let vertexShaderCode = 
"attribute vec4 position;"+
"uniform mat4 projection;"+
"uniform mat4 view;"+
"void main() {" +
    "gl_Position = projection * view * position;" +
"}";

注意这里的乘法顺序projection * view * positionview * position将顶点坐标转换到视点为0,0,0的坐标系中,然后再通过projection投影到OpenGL坐标系。由于矩阵乘法遵循结合律,所以可以理解为(projection * (view * position))

模型矩阵

模型矩阵很好理解,就是顶点本身的变换矩阵,包括平移,缩放,旋转,对Vertex Shader进行简单的修改便可以支持

let vertexShaderCode = 
"attribute vec4 position;"+
"uniform mat4 projection;"+
"uniform mat4 view;"+
"uniform mat4 model;"+
"void main() {" +
    "gl_Position = projection * view * model * position;" +
"}";

顶点先经过模型矩阵model的变换,再经过projection * view变换到OpenGL坐标系,一般来说每个独立的模型会有一个模型矩阵,所有模型会共享视图和投影矩阵,通过这种方式模拟了真实世界物体和摄像机的运动形式。

模型浏览器

例子完整项目代码,在chapter2section3-3目录下

下面通过一个模型浏览器的例子来巩固一下三大矩阵的知识,为了更好的展示例子的效果,需要将渲染的物体进行升级,从三角形升级到立方体

渲染cube

一个立方体有6个面,将每个面分割成2个三角形,就是12个三角形,假设立方体的长宽高都是1,可以得到它的顶点数据

let vertices = [
        // X轴上的平面
        0.5,  -0.5,    0.5, 1,  0,  0,
        0.5,  -0.5,  -0.5, 1,  0,  0,
        0.5,  0.5,   -0.5, 1,  0,  0,
        0.5,  0.5,    -0.5, 1,  0,  0,
        0.5,  0.5,    0.5, 1,  0,  0,
        0.5,  -0.5,   0.5, 1,  0,  0,
    
        -0.5,  -0.5,    0.5, 1,  0,  0,
        -0.5,  -0.5,  -0.5, 1,  0,  0,
        -0.5,  0.5,   -0.5, 1,  0,  0,
        -0.5,  0.5,    -0.5, 1,  0,  0,
        -0.5,  0.5,    0.5, 1,  0,  0,
        -0.5,  -0.5,   0.5, 1,  0,  0,

        // Y 轴上的平面
        -0.5,  0.5,  0.5, 0,  1,  0,
        -0.5, 0.5, -0.5, 0,  1,  0,
        0.5, 0.5,  -0.5, 0,  1,  0,
        0.5,  0.5,  -0.5, 0,  1,  0,
        0.5, 0.5,   0.5, 0,  1,  0,
        -0.5, 0.5,  0.5, 0,  1,  0,

         -0.5, -0.5,   0.5, 0,  1,  0,
         -0.5, -0.5, -0.5, 0,  1,  0,
         0.5, -0.5,  -0.5, 0,  1,  0,
         0.5,  -0.5,  -0.5, 0,  1,  0,
         0.5, -0.5,   0.5, 0,  1,  0,
         -0.5, -0.5,  0.5, 0,  1,  0,

         // Z 轴上的平面
         -0.5,   0.5,  0.5,   0,  0,  1,
         -0.5,  -0.5,  0.5,  0,  0,  1,
         0.5,   -0.5,  0.5,  0,  0,  1,
         0.5,    -0.5, 0.5,   0,  0,  1,
         0.5,  0.5,  0.5,    0,  0,  1,
         -0.5,   0.5,  0.5,  0,  0,  1,
         -0.5,   0.5,  -0.5,   0,  0,  1,
         -0.5,  -0.5,  -0.5,  0,  0,  1,
         0.5,   -0.5,  -0.5,  0,  0,  1,
         0.5,    -0.5, -0.5,   0,  0,  1,
         0.5,  0.5,  -0.5,    0,  0,  1,
         -0.5,   0.5,  -0.5,  0,  0,  1,
    ];

每个顶点数据的前3个float是位置,后三个是颜色,每个面设定不同的颜色可以更好的观察视角的变化。 为了让立方体的面按照正确的z轴顺序渲染,需要开启深度测试功能

gl.enable(gl.DEPTH_TEST);

这一步在WebGL的绘制流程中有提及,它会将光栅化后的被遮挡像素剔除,这样就能保证各个面在z轴上的顺序看起来是正确的了。

修改Vertex Shader

Vertex Shader增加projectionviewmodel三个矩阵

let vertexShaderCode = 
"attribute vec4 position;"+
"attribute vec3 color;"+
"varying vec3 frag_color;"+
"uniform mat4 projection;"+
"uniform mat4 view;"+
"uniform mat4 model;"+
"void main() {" +
    "frag_color = color;" + 
    "gl_Position = projection * view * model * position;" +
"}";

设置投影矩阵

为了达到真实世界的透视效果,选择透视投影矩阵

let projection:mat4 = mat4.create();
mat4.perspective(projection, 60/180 * Math.PI, canvas.width / canvas.height,0.001,1000);

将透视投影矩阵设置给Vertex Shaderprojection

let uniformLoc = gl.getUniformLocation(program, "projection");
gl.uniformMatrix4fv(uniformLoc, false, projection);

控制视图矩阵

本例主要通过控制视图矩阵来改变视角,从而达到从各个角度浏览3D物体的目的。主要通过下面3个变量控制视角矩阵

var cameraAngleAroundY = 0;
var cameraAngleAroundX = 0;
var cameraDistanceFromTarget = 3;

虚拟摄像机从0,0,0点沿z轴正方向移动cameraDistanceFromTarget,再围绕X轴旋转cameraAngleAroundX弧度,最后围绕Y轴旋转cameraAngleAroundY弧度。就可以得到虚拟摄像机的位置,将3D物体保持在0,0,0点,摄像机朝向0,0,0点,就可以得到视图矩阵

function updateViewMatrix() {

    // 计算摄像机所在的位置
    let xRot = mat4.create();
    mat4.identity(xRot);
    mat4.rotateX(xRot, xRot, cameraAngleAroundX);

    let yRot = mat4.create();
    mat4.identity(yRot);
    mat4.rotateY(yRot, yRot, cameraAngleAroundY);

    let translate = mat4.create();
    mat4.identity(translate);
    mat4.translate(translate, translate, [0, 0, cameraDistanceFromTarget]);
    
    let finalMatrix = mat4.create();
    mat4.multiply(finalMatrix, yRot, xRot);
    mat4.multiply(finalMatrix, finalMatrix, translate);

    let pos = vec4.create();
    vec4.set(pos, 0, 0, 0, 1);
    vec4.transformMat4(pos, pos, finalMatrix);


    // 沿着x轴多旋转1度,得到位置,和上面的位置求得摄像机近似的up向量
    let xRotPlus = mat4.create();
    mat4.identity(xRotPlus);
    mat4.rotateX(xRotPlus, xRotPlus, cameraAngleAroundX - 1 / 180.0 * Math.PI);
    
    let finalMatrixPlus = mat4.create();
    mat4.multiply(finalMatrixPlus, yRot, xRotPlus);
    mat4.multiply(finalMatrixPlus, finalMatrixPlus, translate);

    let posPlus = vec4.create();
    vec4.set(posPlus, 0, 0, 0, 1);
    vec4.transformMat4(posPlus, posPlus, finalMatrixPlus);

    let cameraUp: vec3 = vec3.create();
    vec3.set(cameraUp, posPlus[0] - pos[0], posPlus[1] - pos[1], posPlus[2] - pos[2]);
    vec3.normalize(cameraUp, cameraUp);

    mat4.lookAt(viewMatrix, [pos[0],pos[1],pos[2]], [0, 0, 0], cameraUp);
}

通过对2个旋转矩阵和平移矩阵相乘,可以计算出虚拟摄像机的位置,但是随着虚拟摄像机的旋转,我们希望它的up方向应该始终保持和围绕X轴的圆相切,所以使用沿着x轴多旋转1度的点和虚拟摄像机的位置求出up向量。

将视图投影矩阵设置给Vertex Shaderview

uniformLoc = gl.getUniformLocation(program, "view");
gl.uniformMatrix4fv(uniformLoc, false, viewMatrix);

设置模型矩阵

这里不需要通过模型矩阵控制模型的变换,所以设置一个单位矩阵即可

let model:mat4 = mat4.create();
mat4.identity(model);
uniformLoc = gl.getUniformLocation(program, "model");
gl.uniformMatrix4fv(uniformLoc, false, model);

使用鼠标控制视图矩阵相关变量

通过鼠标x,y的变化控制cameraAngleAroundYcameraAngleAroundX变量,通过鼠标滚轮控制cameraDistanceFromTarget变量,达到鼠标控制虚拟摄像机变化的效果

var lastX:number = 0, lastY: number = 0, isMouseDown = false;

window.onmousedown = (evt: MouseEvent) => {
    lastX = evt.x;
    lastY = evt.y;
    isMouseDown = true;
}

window.onmousemove = (evt: MouseEvent) => {
    if (isMouseDown) {
        let xdelta = evt.x - lastX;
        let ydelta = evt.y - lastY;
        lastX = evt.x;
        lastY = evt.y;
        cameraAngleAroundX -= ydelta / 100;
        cameraAngleAroundY -= xdelta / 100;
    }
}

window.onmouseup = () => {
    isMouseDown = false;
}

window.onmousewheel = (evt: any) => {
    let wheelDelta = evt["wheelDelta"];
    cameraDistanceFromTarget -= wheelDelta / 30.0;
    cameraDistanceFromTarget = Math.min(Math.max(1, cameraDistanceFromTarget), 10);
}

总结

本文主要介绍了3种重要的矩阵概念,通过这3种矩阵,我们可以更好的模拟真实的3D世界。透视投影矩阵提供了近大远小的视觉效果,视图矩阵模拟了真实世界的摄像机,模型矩阵赋予了每个物体各自不同的运动特性。

练习

  1. 为3D浏览器的例子增加前后左右移动模式,按w,s键摄像机前后移动,按a,d键摄像机左右移动,保持摄像机始终朝向z轴负方向