webgl开发第一道坎——矩阵与坐标变换

一、齐次坐标

在3D世界中表示一个点的方式是:(x, y, z);然而在3D世界中表示一个向量的方式也是:(x, y, z);如果我们只给一个三元组(x, y, z)鬼知道这是向量还是点,毕竟点与向量还是有很大区别的,点只表示位置,向量没有位置只有大小和方向。为了区分点和向量我们给它加上一维,用(x, y, z, w)这种四元组的方式来表达坐标,我们规定(x, y, z, 0)表示一个向量,(x, y, z, 1)或(x', y', z', 2)等w不为0时来表示点。这种用n+1维坐标表示n维坐标的方式称为齐次坐标。

齐次坐标除了能够区分点和向量,在3D图形学中还有重要的意义。齐次坐标系使得我们可以在一中特殊的方程组中求出解,这个方程组中每一个方程都表示一个与系统中其他直线平行的直线。我们知道在欧几里得空间中,对这种方程组是无解的,因为他们没有交点。然而在现实世界中我们是可以看到两条平行线相交的。

两条平行的铁路最终相较于无穷远处。这就说明人眼看到的世界并不是欧几里得空间,而是在一个名为透视空间中的世界。所以要在2D屏幕上表示3D世界,我们需要一个数学工具来承担这项任务,而齐次坐标很完美的承担了这项任务。

如果我们知道一个三维点的齐次坐标为(X, Y, Z, w),那么它的3D空间坐标为:

x = X / w

y = Y / w

z = Z / w

我们可以看到齐次坐标(1, 2, 3, 1)与(2, 4, 6, 2)表示的都是3d空间中的点(1, 2, 3);所以通常在程序设计中我们都取w为1.

现在我们再来看一下上面说的齐次坐标在一组平行线中求解,有两条直线:

Ax + By + Cz + D = 0

Ax + By + Cz + d = 0;

D不等于d;根据解析几何知识我们可以知道这是两条在欧几里得空间中这是两条相交的平行线,它们不可能有交点。如果d = D两条直线会重合。现在我们把他们用齐次坐标来表示:

A(X/w) + B(Y/w) + C(Z/w) + D = 0;

A (X / w) + B (Y/w) + C (Z/w) + d = 0;

方程组两边同时乘以w得到:

AX + BY + CZ + Dw = 0;

AX + BY + CZ + dw = 0;

所以在齐次空间中对于四元组(X, Y, Z, w)(想一下极限的概念)当w无限趋近于0时,欧几里得空间中的两条平行线有无穷多个解(X, Y, Z, 0);他们再无穷远处相交了。如同我们人眼看到的现实世界中两条平行线相交一样。

二、矩阵迷宫

我们先来看一下在2d中将一个点(x, y)绕原点旋转 α 角度得到(x', y')的过程:

对于点x,y的极坐标表示为:

x = r * cosβ

y = r * sinβ

旋转后的坐标x', y'为:

x' = r * cos(α + β) = rcosβcosα- rsinαsinβ = xcosα - ysinα

y' = r * sin(α + β) = rcosαsinβ + rsinαcosβ = ycosα + xsinα

那么我们用n+1为齐次坐标并结合矩阵表示为:

我在学习webgl过程中经常有一个疑问,为什么矩阵可以表示空间变换,有一个大牛告诉我,表示空间变换的并不是矩阵本身,而是一系列数学公式,就像上面用到的三角函数公式一样,而矩阵的运算法则能够指把公式的运算结果很好的表达出来。要想搞明白这些矩阵表示的空间变换需要自己手动的把这些变换结果推导出来。

另外有看过opengl相关矩阵运算的同学一定会发现上文中的运算用的是行向量的形式,而opengl用的是列向量形式,从行向量到列向量只需要转支一下即可。这里也正是我想重点强调的,对于初学者来说,行向量/列向量、行存储/列存储、以及平移旋转的表达顺序,这三者糅杂在一起很容易把人绕晕,因为它有2*2*2=8中情况。尤其是不同的书籍他们使用的表达方式、存储方式、以及对运算顺序的表达是完全相反的(比如《3D数学基础》跟《opengl权威指南》就是完全相反的),为了统一起见,我建议大家按照这样的方式来思考问题:

1)webgl中使用的是列向量,对应的缩放、平移、旋转矩阵为:

2)webgl使用的是列存储

在实际编程语言中,我们使用的一维数组来存储4x4矩阵的16个元素。所谓的行存储和列存储的区分就在于数组的前四个元素存储的是矩阵的第一列还是第一行;表示列的称为列存储,表示行的成为行存储。如下图数组的前四个元素对应矩阵中的第一列,所以是列存储。

3)webgl中矩阵的运算顺序是从右往左进行的

当矩阵相乘时,在最右边的矩阵是第一个与向量相乘的,所以你应该从右向左读这个乘法。所以先旋转后平移的的矩阵操作是TRV而不是RTV。我发现在跟同事讨论问题时,往往就是大家在这个地方有分歧而说到两个不同的方向最后吵起来。因为有的同事看到TRV他按照左往右读的说法是先平移后旋转,然后大家就在先平移后旋转还是先旋转后平移里争执。(跟人讨论矩阵运算顺序时一定要写在纸上)

矩阵乘法是不遵守交换律的,这意味着它们的顺序很重要。建议您在组合矩阵时,先进行缩放操作,然后是旋转,最后才是位移,否则它们会(消极地)互相影响。比如,如果你先位移再缩放,位移的向量也会同样被缩放(比如向某方向移动2米,2米也许会被缩放成1米)!

确定矩阵运算顺序后,接下来要确定矩阵操作类库的api的调用顺序。向glmatrix这种类库提供的api,对先旋转后平移这种矩阵操作的实现方式是:(看api的很容易让人认为是先平移后旋转)

所以在webgl中一般api的调用顺序都是跟矩阵的运算顺序相反的,这点与opengl一致。

另外注意一下:有很多书籍会告诉你一个矩阵的某些元素代表旋转,某几个元素代表平移,比如左上角9个元素代表旋转,12-15代表平移,实际这些都不一定,一旦矩阵有了组合操作,那么这些都可能改变。

三、模型矩阵与模型视图矩阵

现实世界中我们可以建立各种坐标系,如果我们以一个物体原点(自己任意指定)来建立坐标系,并且这个坐标系在初始时与世界坐标系重合,那么这个物体上的所有点的坐标都是相对这个局部坐标系来。如果我们移动或者旋转缩放物体,我们会使用一个矩阵来编码这些变换。这个矩阵称为模型矩阵。在我们用模型矩阵乘以我们对象中的顶点就得到一系列新的坐标,这些坐标就是物体在世界坐标系中的顶点位置。

我们在2D屏幕上显示三维物体,就像用相机拍摄图像一样。在三维世界中有一个假想的相机,我们在屏幕上看到的场景都是在相机坐标系下表示的,要把世界坐标系中的点转化成相机坐标系的点,我们就需要一个变换矩阵。这个矩阵称为模型视图矩阵,而模型视图矩阵就是相机的模型矩阵的逆矩阵。

我们想要看到世界中的任何场景只要控制相机的移动和旋转即可。用户控制相机的过程主要是两个事情:朝向和位置。只要这两个属性确定了,相机的模型矩阵以及模型视图矩阵都可以得到了。用3D变换的角度来说就是旋转和平移。可以想象对于任意一个3d场景我们都可以将相机做一个旋转然后平移到一个位置来观察到它的任意细节。

现在我们先改变相机的朝向然后平移到一个位置,这个模型矩阵为:

C = TR

T代表平移变换,R代表旋转变换(R的前三列代表相机旋转后的三个坐标轴),那么这时候的模型视图矩阵为:

这个C-1就是我们要的模型视图矩阵,上面说到相机旋转后的三个轴是互相垂直的,也就是正交的,而正交矩阵的逆矩阵等于矩阵的转置矩阵。所以C-1最终变为:

而T的逆矩阵很简单:

最终的模型视图矩阵为:

而我们在三维开发中常用的求模型视图矩阵的方法lookAt用的就是这个原理。

这个函数主要需要三个参数:eye代表相机位置、target代表相机的目标点、up代表相机的上方向。我们称相机模型矩阵的第一列代表相机的x轴,我们称为right向量;第二列代表相机的y轴,我们称为up向量,第三列代表相机的z轴,我们称为相机轴(相机轴并不是相机的朝向,而是相机朝向的负方向,另外这里我们的相机的模型矩阵统一使用的右手系,有的资料里面用的是左手系)。

mat4.lookAt = function (eye, center, up, dest) {

        if (!dest) { dest = mat4.create(); }

        var x0, x1, x2, y0, y1, y2, z0, z1, z2, len,

            eyex = eye[0],

            eyey = eye[1],

            eyez = eye[2],

            upx = up[0],

            upy = up[1],

            upz = up[2],

            centerx = center[0],

            centery = center[1],

            centerz = center[2];

        if (eyex === centerx && eyey === centery && eyez === centerz) {

            return mat4.identity(dest);

        }

        //vec3.direction(eye, center, z);

  // 首先根据观察点和相机位置求得相机轴向量

        z0 = eyex - centerx;

        z1 = eyey - centery;

        z2 = eyez - centerz;

        // normalize (no check needed for 0 because of early return)

  // 对相机轴做标准化

        len = 1 / Math.sqrt(z0 \* z0 + z1 \* z1 + z2 \* z2);

        z0 \*= len;

        z1 \*= len;

        z2 \*= len;

        //vec3.normalize(vec3.cross(up, z, x));

  // up向量叉乘z轴得到x轴,即我们说的right向量

        x0 = upy \* z2 - upz \* z1;

        x1 = upz \* z0 - upx \* z2;

        x2 = upx \* z1 - upy \* z0;

        len = Math.sqrt(x0 \* x0 + x1 \* x1 + x2 \* x2);

        if (!len) {

            x0 = 0;

            x1 = 0;

            x2 = 0;

        } else {

            len = 1 / len;

            x0 \*= len;

            x1 \*= len;

            x2 \*= len;

        }

        //vec3.normalize(vec3.cross(z, x, y));

  // 然后根据z轴叉乘x轴得到相机的y轴

        y0 = z1 \* x2 - z2 \* x1;

        y1 = z2 \* x0 - z0 \* x2;

        y2 = z0 \* x1 - z1 \* x0;

        len = Math.sqrt(y0 \* y0 + y1 \* y1 + y2 \* y2);

        if (!len) {

            y0 = 0;

            y1 = 0;

            y2 = 0;

        } else {

            len = 1 / len;

            y0 \*= len;

            y1 \*= len;

            y2 \*= len;

        }

  // 最终得到的模型视图矩阵为:R^T \* T^-1

        dest[0] = x0;

        dest[1] = y0;

        dest[2] = z0;

        dest[3] = 0;

        dest[4] = x1;

        dest[5] = y1;

        dest[6] = z1;

        dest[7] = 0;

        dest[8] = x2;

        dest[9] = y2;

        dest[10] = z2;

        dest[11] = 0;

        dest[12] = -(x0 \* eyex + x1 \* eyey + x2 \* eyez); // -x轴点乘eye向量

        dest[13] = -(y0 \* eyex + y1 \* eyey + y2 \* eyez); // -y轴点乘eye向量

        dest[14] = -(z0 \* eyex + z1 \* eyey + z2 \* eyez); // -z轴点乘eye向量

        dest[15] = 1;

        return dest;

    };

这里讲的都是通过先改变相机朝向然后改变相机位置的方式来观察三维场景中的物体,实际上也通过别的方式比如先将相机平移到一个位置,然后绕世界坐标系旋转的方式来观察场景,这种算法的效果就像是将相机固定在轨道上一样(我们通过先改变朝向在平移也能达到这种效果),在有的资料中它把这种先平移后旋转方式称为轨道相机,把先旋转后平移称为跟踪相机。

四、透视矩阵

通过模型视图变换,3d场景中的物体已经能够用相机空间坐标来表达,接下来我们处理的是如何来模拟人眼的近大远小效果。相机坐标系中的物体还是处于3d世界中,要做出近大远小的效果还需要继续变换。这个变换被称为透视投影,它的特点是所有投影线都从空间一点投射,离视点近的物体投影大,离视点小的物体投影小,小到极点称为灭点。

一般将屏幕放在观察者和物体之间。投影线与屏幕的焦点就是物体点上的透视投影。这里我们的观察点就是相机的位置。

大家对透视投影有了基本认识,现在我们来说一些透视除法也叫视锥体裁切,什么意思呢?大家想一下我们人眼是不是只能看到一部分的世界内容,而不是全部,我们视野范围之外的内容已经被过滤掉了,所以在3d图形学模拟人眼的过程中也有一步就是将多余内容裁切掉。

在3d图形学中我们模拟透视投影是通过一个六面体构造出投影矩阵来做透视效果: