投影、视图、模型变换
撰写时间:2023-08-19
修订时间:2026-04-22
在本章中,我们加入投影变换。投影变换其实就是确定一个如下图所示的视锥体(viewing volumn)的过程。

视锥体范围(透明部分)内的所有物体将得到渲染,而视锥体范围之外的所有物体则不会被渲染。并且,视锥体范围内的众多物体存在近大远小、被遮挡的关系。
着色器
顶点着色器代码如下:
之前只有一个视图矩阵或一个模型矩阵参加与aPosition的乘法运算,而这次运算还加入了投影矩阵。
尽管只有一行代码,但这里涉及到矩阵与向量相乘、矩阵之间相乘的运算顺序与规则,以及应如何正确依序应用各种矩阵变换的问题。这也正是3D编程的重点及难点所在。
在后续章节中,我们先系统地阐述这些相关的知识点。而一旦掌握了这些知识点,正如下面所见,客户端的代码将非常简单。
矩阵与向量的相乘
当一个矩阵乘以一个向量时,要求矩阵的列数必须等于向量的维数。而根据向量的种类,算法分为2种。
矩阵左乘列向量
\[ \begin{bmatrix} a & b & c & d \\ e & f & g & h \\ i & j & k & l \\ m & n & o & p \\ \end{bmatrix} \begin{pmatrix} x \\ y \\ z \\ w \\ \end{pmatrix} = \begin{pmatrix} ax + by + cz + dw \\ ex + fy + gz + hw \\ ix + jy + kz + lw \\ mx + ny + oz + pw \\ \end{pmatrix} \]
当向量以列的方式排列时,称为列向量。
此时,矩阵应放在左边,向量应放在右边。因矩阵位于左边,故称矩阵左乘列向量。
一个简化的2阶例子:
\[ \begin{bmatrix} 8 & 6 \\ 2 & 4 \end{bmatrix} \begin{pmatrix} 3 \\ 5 \end{pmatrix} = \begin{pmatrix} 8 \times 3 + 6 \times 5 \\ 2 \times 3 + 4 \times 5 \end{pmatrix} = \begin{pmatrix} 24 + 30 \\ 6 + 20 \end{pmatrix} = \begin{pmatrix} 54 \\ 26 \end{pmatrix} \]
若用颜色来标识,则更清晰:
\[ \begin{bmatrix} {\color{orange} 8} & {\color{#0A0} 6} \\ {\color{orange} 2} & {\color{#0A0} 4} \end{bmatrix} \begin{pmatrix} {\color{orange} 3} \\ {\color{#0A0} 5} \end{pmatrix} = \begin{pmatrix} {\color{orange} 8} \times {\color{orange} 3} + {\color{#0A0} 6} \times {\color{#0A0} 5} \\ {\color{orange} 2} \times {\color{orange} 3} + {\color{#0A0} 4} \times {\color{#0A0} 5} \end{pmatrix} = \begin{pmatrix} 24 + 30 \\ 6 + 20 \end{pmatrix} = \begin{pmatrix} 54 \\ 26 \end{pmatrix} \]
即,矩阵左乘列向量时,有以下规则:
- 结果为一个列向量。
- 结果列向量中各行的值等于矩阵各行的元素分别乘以列向量中所对应的元素的积之和。
- 矩阵中的橙色元素只能乘以列向量中的橙色元素,矩阵中的绿色元素只能乘以列向量中的绿色元素。
- 规则3中可抽象表述为:矩阵中各列对应于列向量中的各行。
下面结合一个具体例子,可以快速地理解矩阵左乘列向量的运算规则。
甲乙均为水果商贩,且只卖橙子与葡萄。今日,甲共卖出8个橙子与6个葡萄,乙共卖出2个橙子与4个葡萄。今日的市场价中,橙子单价为3 元/个,葡萄单价为5 元/个。下面使用矩阵,自动算出他们今日各自的总营业额。
\[ \begin{pmatrix} 水果商贩 \\ 甲 \\ 乙 \end{pmatrix} \begin{bmatrix} 橙 & 葡萄 \\ {\color{orange} 8} & {\color{#0A0} 6} \\ {\color{orange} 2} & {\color{#0A0} 4} \end{bmatrix} \begin{pmatrix} 水果单价 \\ {\color{orange} 3} \\ {\color{#0A0} 5} \end{pmatrix} = \begin{pmatrix} 自动计算的过程 \\ {\color{orange} 8} \times {\color{orange} 3} + {\color{#0A0} 6} \times {\color{#0A0} 5} \\ {\color{orange} 2} \times {\color{orange} 3} + {\color{#0A0} 4} \times {\color{#0A0} 5} \end{pmatrix} = \begin{pmatrix} 总营业额 \\ 54 \\ 26 \end{pmatrix} \]
矩阵第一行为甲所卖出的橙与葡萄的数量,矩阵第二行为乙所卖出的橙与葡萄的数量,将水果单价依序列在列向量中,则我们可以快速地以列表的方式得到他们的总营业额。
当此模型确定下来后,若要增加统计更多的水果商贩,则增加矩阵的行数;若要增加统计更多的水果,则增加矩阵的列数。
矩阵右乘行向量
\[ \begin{pmatrix} x & y & z & w \end{pmatrix} \begin{bmatrix} a & b & c & d \\ e & f & g & h \\ i & j & k & l \\ m & n & o & p \\ \end{bmatrix} = \begin{pmatrix} xa + ye + zi + wm, & xb + yf + zj + wn, & xc + yg + zk + wo, & xd + yh + zl + wp \end{pmatrix} \]
当向量以行的方式排列时,称为行向量。
此时,矩阵应放在右边,向量应放在左边。因矩阵位于右边,故称矩阵右乘行向量。
一个简化的2阶例子:
\[ \begin{pmatrix} 3 & 5 \end{pmatrix} \begin{bmatrix} 8 & 6 \\ 2 & 4 \end{bmatrix} = \begin{pmatrix} 3 \times 8 + 5 \times 2, & 3 \times 6 + 5 \times 4 \end{pmatrix} = \begin{pmatrix} 24 + 10, & 18 + 20 \end{pmatrix} = \begin{pmatrix} 34, & 38 \end{pmatrix} \]
列主序约定
从上面的简单例子可见,矩阵左乘列向量的结果,不等于矩阵右乘行向量的结果。即:
`M * v \ne v * M`
WebGL及GLSL均约定,向量是列主序(column majored),即向量应采用列向量的方式。因此在WebGL中,当一个矩阵需与一个向量相乘时,应采用矩阵左乘列向量的方式,即:
`M * v`
因此,之前我们将一个模型矩阵与gl_Position相乘时,其对应的代码为:
相对应的,glMatrix也遵循了列主序的约定。下面调用glMatrix相应方法来计算上面简单的矩阵与向量相乘的结果。
mat2是专用于计算2阶矩阵的模块,调用该模块的fromValues函数来初始化一个2阶矩阵。该函数的4个参数中,采用了列主序的顺序,即先指定第0列中的2个元素,再指定第1列中的2个元素。但在直接打印变量matrix时,却发现其在内存中按列主序参数顺序直接存储为一维数组。此时,如果按一维数组的元素顺序拆分为用以表示矩阵的二维数组,则矩阵将变成行主序的矩阵!因此,下面将通过一个函数来专门处理此问题。
接着,调用vec2的fromValues函数来创建了一个值为(3, 5)的向量。
再次,调用vec2的transformMat2函数,将matrix与vector相乘,将其值存储于变量resultVector,并打印其值。
从打印结果来看,与上面所说的矩阵左乘列向量的结果一致,因此可确定,glMatrix也遵循了列主序的约定。
函数printColMajoredMatrix用于格式化输出一个代表列主序的矩阵的Float32Array实例。在函数内,先调用mat2的transpose函数,将Float32Array的数据调整为行主序后,依数组元素顺序拆分为二维数组,再分别打印其内存状态及其代表的矩阵。
PageConsole的logMatrix方法
上述函数功能已集成到PageConsole的logMatrix方法中,可用于方便地格式化打印矩阵的信息。支持2阶、3阶及4阶矩阵,且可通过参数来指定该矩阵是列主序还是行主序,打印结果随之而异。
当指定为列主序时,方法内部自动将矩阵进行转置后再格式化输出结果。
按矩阵字面符直观地生成列主序的矩阵
现在,假设我们需要生成一个以下的矩阵:
\[ \begin{bmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \\ 7 & 8 & 9 \end{bmatrix} \]
编写以下代码:
出问题了。当我们照着矩阵的字面符顺序来编写代码:
其结果,却是生成了矩阵:
\[ \begin{bmatrix} 1 & 4 & 7 \\ 2 & 5 & 8 \\ 3 & 6 & 9 \end{bmatrix} \]
正确的代码顺序应是:
这是因为我们在编写代码时是习惯于逐行往下编写的,而由于glMatrix的mat3的fromValues方法中的参数必须以列主序的顺序在每行上依序指定,因此我们须自行在脑海中进行转换:在第一行代码先依序写第一列的值1, 4, 7,接着在第二行代码依序写第二列的值2, 5, 8,最后在第三行代码依序写第三列的值3, 6, 9。这很容易让人犯晕,且易出错。
鉴此,我们设计了一个glMatrixUtils类,调用其方法FromLiteralValues,可完全照抄矩阵的字面符来生成对应的矩阵。
这样,可确保代码中的矩阵顺序与实际生成的矩阵顺序完全一致。
这种方式,当我们需要通过编写代码来指定一个特定值的矩阵时,特别方便。
矩阵与矩阵相乘
矩阵乘以矩阵,结果为矩阵。
运算规则
下面是2个2阶矩阵相乘的例子。先列出最终运算的结果。
\[ \begin{bmatrix} 1 & 3 \\ 2 & 4 \end{bmatrix} \begin{bmatrix} 5 & 7 \\ 6 & 8 \end{bmatrix} = \begin{bmatrix} 1 \times 5 + 3 \times 6 & 1 \times 7 + 3 \times 8 \\ 2 \times 5 + 4 \times 6 & 2 \times 7 + 4 \times 8 \end{bmatrix} = \begin{bmatrix} 5 + 18 & 7 + 24 \\ 10 + 24 & 14 + 32 \end{bmatrix} = \begin{bmatrix} 23 & 31 \\ 34 & 46 \end{bmatrix} \]
运算规则有点复杂,但我们可以化繁为简。
矩阵与矩阵相乘,可将右边的矩阵拆分为多个列向量,然后左边的矩阵与各个列向量依序相乘的结果,依序作为结果矩阵中的各列的值。
第1步,将右边的矩阵
\[ \begin{bmatrix} 5 & 7 \\ 6 & 8 \end{bmatrix} \]
先拆分为
\[ \begin{pmatrix} 5 \\ 6 \end{pmatrix} 及 \begin{pmatrix} 7 \\ 8 \end{pmatrix} \]
共2个列向量。
第2步,左边矩阵乘以所拆分的第1个列向量。
\[ \begin{bmatrix} 1 & 3 \\ 2 & 4 \end{bmatrix} \begin{pmatrix} 5 \\ 6 \end{pmatrix} = \begin{pmatrix} 1 \times 5 + 3 \times 6 \\ 2 \times 5 + 4 \times 6 \end{pmatrix} = \begin{pmatrix} 5 + 18 \\ 10 + 24 \end{pmatrix} = \begin{pmatrix} 23 \\ 34 \end{pmatrix} \]
将结果记为结果矩阵中第1列的数据。
第3步,左边矩阵乘以所拆分的第2个列向量。
\[ \begin{bmatrix} 1 & 3 \\ 2 & 4 \end{bmatrix} \begin{pmatrix} 7 \\ 8 \end{pmatrix} = \begin{pmatrix} 1 \times 7 + 3 \times 8 \\ 2 \times 7 + 4 \times 8 \end{pmatrix} = \begin{pmatrix} 7 + 24 \\ 14 + 32 \end{pmatrix} = \begin{pmatrix} 31 \\ 46 \end{pmatrix} \]
将结果记为结果矩阵中第2列的数据。
第4步,将上面2个列向量组合为结果矩阵。
\[ \begin{bmatrix} 23 & 31 \\ 34 & 46 \end{bmatrix} \]
glMatrix的矩阵相乘
glMatrix中的mat2, mat3及mat4的mul函数可用于分别计算两个2阶,3阶, 4阶的矩阵相乘的结果。
前后顺序不可颠倒
对于矩阵M与矩阵N,有以下运算特点:
`M * N \ne N * M`
即,矩阵与矩阵相乘,前后顺序不可颠倒,否则将导致出现不同的结果。
多个矩阵相乘
本节考查当有多个矩阵相乘时的情况。即,拟求出下面3个矩阵相乘的结果:
\[ \begin{bmatrix} 1 & 3 \\ 2 & 4 \end{bmatrix} \begin{bmatrix} 5 & 7 \\ 6 & 8 \end{bmatrix} \begin{bmatrix} 9 & 10 \\ 11 & 12 \end{bmatrix} \]
下面先让第1个矩阵乘以第2个矩阵,再将乘积乘以第3个矩阵。
下面,先让第2个矩阵乘以第3个矩阵,再将第1个矩阵乘以该乘积。
两种算法结果完全一致。因此,当有矩阵M, 矩阵N, 矩阵O相乘时,
`M * N * O = (M * N) * O = M * (N * O)`
PVMv矩阵变换顺序
在客观物理世界中,将场景中的模型拍摄后并在屏幕上渲染,将依序涉及到以下几个步骤:
- 确定局部坐标系
每个模型均有其自己的局部坐标系。
例如,一辆小轿车模型,当车体的长宽高一旦确定下来,则车门、车轮的大体位置及比例也就基本确定下来了。
因此,局部坐标系用于规范一个模型内部各组件的相对位置及相对比例。
在建模阶段,根据客观物理世界的常识及业务需求,我们为各种不同的模型确定了不同的局部坐标系。
- 模型变换
当各个模型放进场景中,我们需要确定它们在世界坐标系的确切位置。
例如,两辆型号、大小完全一致的小轿车放进场景中,其中一辆可能需要放在场景的左边,另一辆可能需要放在场景的右边。这样,两个局部坐标系完全一样的模型在放进场景后,它们在世界坐标系的位置就产生了变化。
因此,模型变换用于确定各个模型在世界坐标系中的不同位置。
顶点着色器中对应的代码:
- 视图变换
摆放好模型后,为了能在计算机屏幕中渲染出不同角度的图像,我们需要对场景中所有模型统一应用特定的视图变换。
例如,当相机从上往下俯拍时,则要求场景中所有模型的顶点都应自动进行变换,以给观众一种鸟瞰的效果图;当相机围绕特定位置绕拍时,在绕拍过程中,各个不同模型的顶点的前后位置可能发生切换。
因此,在顶点着色器中,需要在上一步效果的基础上应用视图变换:
应用了视图变换后,各个模型所在的坐标系,已从之前的世界坐标系转变为eye space,中文一般称为观察者坐标系,或相机坐标系。
- 投影变换
在生成最终的渲染图像前,我们需要根据客观物理世界的常识,在一个特定的视锥体范围内,对各个模型进行近大远小、后面的模型应被遮挡、视野之外的模型应被裁剪掉的加工处理。此过程即为投影变换。
因此,在顶点着色器中,需要在上一步效果的基础上接着应用投影变换:
应用了投影变换后,各个模型所在的坐标系,已从之前的观察者坐标系转变为裁剪坐标系 (clip coordinates)。
前面已谈到,WebGL使用列向量的矩阵乘法的约定,则列向量aPosition必须作为公式的最右边的因子。且如上面指出,矩阵相乘时,公式中前后因子的顺序不可颠倒,因此,当需要同时、依序应用模型变换、视图变换及投影变换时,顶点着色器中涉及到矩阵变换的公式只能为:
我们可助记为PVMv矩阵变换。
如果公式因子顺序不依上面,不仅在逻辑上违背了上面所谈到的各种变换的必要先后顺序,且在数学运算中得出截然不同的结果,将可能导致模型消失、外形扭曲、产生空洞、前后穿帮等难以预料的后果。
JavaScript代码(第一版)
主干流程
projection-view-model.html的JavaScript主干代码如下:
与上一章相比,多了一个initProjectionMatrix,用于初始化投影矩阵。
initContext
同时引用顶点着色器中的uProjectionMatrix, uViewMatrix及uModelMatrix3个变量,且打开景深测试。
initProjectionMatrix
mat4的perspective函数用以创建一个视锥体。参数分别代表视野、宽高比、近平面(近平面离观察点的距离)及远平面。
将近平面设置为一个诸如0.01或0.001的较小的值,可避免由于眼睛离近平面距离过远而导致这段距离内的内容无意中被裁剪掉的情况发生。当远平面的值为null或Infinity时,可看至无穷远。
initViewMatrix
站在(3.0, 3.0, 5.0)的位置,望向(-0.5, -0.5, -1.0)的点。相机顶端朝上。
initMeshes
创建了分别代表坐标系、位于前方的蓝色三角形、位于后方的绿色三角形共3个网格对象。其中最后的三角形围绕Z轴旋转了180o。
render
每个网格对象均使用同一投影矩阵及视图矩阵。因这两个矩阵前面已经设置好,因此在渲染每个网格对象前,只须向顶点着色器传入其各自的模型矩阵modelMatrix。
运行网页。
这一版的代码,主要在于学习与掌握当我们需要同时应用投影变换、视图变换及模型变换时,最基本的程序结构及运行顺序。
第二版
第一版存在的问题
主要有2个问题。一是视口与投影矩阵的大小不一致。
我们按原来的代码运行的先后顺序来分析此问题。第一步,在WebGLUtils的getContext方法中,调用了updateViewport方法:
第二步,该方法的代码如下:
上面的viewport只取长方形的宽度与高度中最小值为视口宽高值,因此,宽高比恒为1:1。在宽屏显示器中,往往造成左右两边留白。
第三步,在projection-view-model.html的initProjectionMatrix函数中:
这里的perspective方法却是根据渲染缓冲区的整个区域来设置宽高比例。该方法的特点是,根据宽高比,在方法内部按所指定的比例来映射到整个渲染缓冲区。也即说,只要传入正确的宽高比,该方法能自动保留原有图像的比例,确保不变形。
我们之前的应用程序没有应用投影矩阵时,为确保保持比例,手工将视口的宽高比设为1:1。而现在,我们应用了投影矩阵后,通过mat4的perspective方法,只要传入整个渲染区域的宽高比,GLSL将确保所渲染的图像自动保持正确的比例。因此,相对应地,我们需要在上面updateViewport方法中将视口重新设置为整个渲染区域即可。
第二个问题,当canvas大小改变时,渲染区域的宽高比随之改变,但投影矩阵却未作变动。
原来的事件代码为:
上面的代码,当canvas大小改变时,只更新了视口,但投影矩阵未作更新。显然,在此也需要一个方法来重新设置projectionMatrix的值。
第二版的修改
客户端
projection-view-model-1.html主要有3个地方的修改。
一是导入WebGLUtils-v9.js文件。
二是主干程序删除了initProjectionMatrix方法。
该功能将移至WebGLUtils类中集中实现。
三是initContext函数中,在WebGLUtils类的构造方法中,传入表示视域的变量fov。
改动较大的地方从而集中到WebGLUtils类中。
WebGLUtils-v9.js
将WebGLUtils-v8.js复制为WebGLUtils-v9.js,对后者作如下修改。
首先,增加导入mat4及glMatrix模块的代码。
其次,修改构造方法。
第三,initWebEvents方法:
在canvas尺寸发生改变时,视口及投影矩阵均应随之而改变。
第四,修改updateViewport方法如下:
取消原来手工设置宽高比为1:1的代码,而是将整个渲染缓冲区的区域设置为视口区域。
第五,添加initProjectionMatrix及updateProjectionMatrix方法如下:
上面五步的环节可用两句话来概述:开始时,根据参数fov来创建一个投影矩阵。而当canvas的物理尺寸发生变化时,在resize事件中,须同时更新与其相关的视口及投影矩阵。
运行网页。经修改后,无论如何改变浏览器的大小,视口及投影矩阵均能使用canvas的全部区域,从而确保图像不会变形。
发现重构的需求
在实现第二版的过程中,我们发现视口、投影矩阵总是与canvas的尺寸紧紧地绑在一起,因此,有必要引入一个新类,专门集中处理视口、投影变换及视图变换的问题。
在下一章,我们将在本章所掌握的知识的基础上,实现多视口应用程序。
