沈大侠已经考过我两次矩阵转换的问题了,然而我每次都答不上来……:joy: 虽然我每次事后都会去查资料,但是看过也就没进脑子,因为没什么感觉……模型变换,视图变换,投影变换,是啊,矩阵乘一乘嘛,咻咻咻就绘制好啦!这有什么好说的呢,简直不知道你要问什么嘛!

大侠当时的内心一定是这样的……

如果不是我在阿里真的有需求做基于原生 WebGL 写的项目,可能第三次被问到这样的问题还是只能回答“矩阵乘一乘”了……真的写原生 WebGL 就知道了,我确实没搞透彻,所以觉得没什么好说的,而我当年写 OpenGL 的时候,好歹还有 glut 之类的第三方库,至少不用我自己写矩阵的。但是不理解清楚这个概念,自己写转换矩阵的时候分分钟就暴露了。

前几天问大侠,我跟你本科毕业时的差距有多远?大侠直言不讳地说,图形学的话,还挺多的吧……!!!(虽然的确是实话啦……):joy:

好啦好啦,现在,来谈谈我现在对矩阵转换的理解吧。可能还有不对的地方,要批判地阅读本文!

我花了两个小时画这个图,也是边画边理解,画完我自己都震惊了……

下面一步步谈谈我的理解。

首先,在第一张图中的红色坐标系是模型坐标系,这意味着在这一步,程序员指定了模型的各个点的位置。每个点是 (x, y, z) 组成的数组,比如用 WebGL 的话,相关代码可能是类似这样的:

var vertices = [
    1, 0, 0.5,
    -0.5, 0, 0.5,
    0, 1, 0
];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);

后面的所有坐标变换都是 4x4 的齐次矩阵,所以这里除了每个点的 (x, y, z) 坐标之外,另外补上 1 组成 (x, y, z, 1) 的坐标。之所以使用齐次坐标,是为了将平移操作也统一成矩阵乘法的形式,如果用三维的坐标,就只能用两个矩阵向量相加表示,这样和旋转缩放就不统一了。

通过模型变换,把模型坐标转成世界坐标,就是第二张图中黑色的三角形相对于绿色的坐标系的坐标。世界坐标系就是所有模型共享的参考系,而模型坐标系是只对每个模型本身有意义的。为什么这么麻烦,不能直接在上一步写三个点的位置的时候,写成在绿色世界坐标系下的位置呢?想象一下,一个正三角形绕 X 轴转 90°,再绕 Y 轴转 30°,你能马上脑补出它旋转后每个点的坐标吗?即使可以,在更加复杂的场景下,这都会是一件非常困难的事。所以,模型变换解决的是,把物体在世界坐标系下的位置拆分成旋转、平移、缩放的表达方式。

视图变换的作用就更难理解了吧?它可以理解为,指定一个照相机的位置和角度,然后去观察世界坐标系下的物体。为什么不能把照相机的位置定在世界坐标系原点,看向 Z 方向负方向?当然也不是不能这样,只是很多时候做动画效果,场景中的物体可能是不变的,变的只是观察的位置和方向。这样的话,如果脑补观察位置改变后,模型所在的世界坐标,就像上面说的脑补在世界坐标系下的位置一样不直观。另外,既然世界上那么多模型是不动的,那么之前的其他矩阵的乘积也能被存下来复用,只要做一次乘以视图变换矩阵的操作,而非上图中的四次变换矩阵,减少矩阵乘法操作使得算法效率更高。

模型变换和视图变换都是通过旋转、平移、缩放的矩阵相乘实现的,具体矩阵参见:World, View and Projection Transformation Matrices。其实这里都是各种参考系的概念,就像我们在物理课上学到的相对的概念,坐在开动的车上的人会觉得是外部的世界在倒退,我们很容易想到,把照相机往一个方向上移动,相当于把观察对象往另外一个方向上移动。所以就有了模型视图矩阵的概念,本质上就是模型变换矩阵和视图矩阵相乘,但是如果把这个相乘结果存起来,对模型的点做批处理的话,效率就会比每个点去乘两次矩阵高得多。

投影变换就比较有意思了,它是把前面在三维空间中的坐标投影到二维屏幕坐标,但是计算结果也是一个三维坐标(严格来说是四维的,还有个齐次的 1),除了屏幕的横纵坐标之外,另一个维度就是垂直屏幕方向上的深度坐标,就是之后可以写入深度缓冲器的值。至于如何将三维坐标转换到二维屏幕,主要分为正交投影和透视投影,都是用相似三角形算比例,不同在于前者的实景体是一个长方体,而后者是一个梯台。需要注意的是,照相机的位置和旋转等值是在视图变换中就已经指定的,而投影变换只是三维到二维的过程。在第四张图中表现为紫色的三角形在橙色的坐标系下的位置。这里有非常好的通过相似三角形计算投影后的位置的文章,建议有兴趣的看一下。

最后就是视口变换了,到这一步其实已经很简单了!相比前面又是旋转平移缩放,又是相似三角形计算的,这里只是一个非常简单的 XoY 平面上的缩放,它决定了最终渲染到平面的哪一块,所以用之前的缩放同样处理就能得到相应矩阵了。甚至你都不用写矩阵,即使在 WebGL 里,也有直接设定视口(View Port)的命令。

gl.viewport(0, 0, this._canvas.width, this._canvas.height);

终于要大功告成了!(写到这里,我抬头看了一眼,同事什么时候都走光了,说好的双十一忙碌呢!)接下来就是把这些矩阵拼起来了!(终于到了我能说“矩阵乘一乘啊”的时候了!)

变换后的坐标 = 视口矩阵 x 投影矩阵 x 视图矩阵 x 模型矩阵 x 模型点坐标

这里除了模型点坐标变换后的坐标1x4 的向量之外,其他矩阵都是 4x4 的。虽然按理说是从右边到左边计算的,但很多时候左边的几个矩阵都是不变的,这时候可以把这些矩阵缓存起来,下次就直接成结果的一个矩阵就好。

说这么多,我也不知道对的有几句……已经可以预见大侠在评论区回复长长的错误列表了……

然后说到那个在阿里的 WebGL 需求,搞清楚这么多呢,终于能做出这样的效果了……只是这是什么东西呢,我是不会告诉你们的!机密!!

参考资料