上一节我们讲了 WebGL 坐标系的分类以及它们之间的转换方式,本节开始详细介绍坐标系基本变换的算法实现,图形学中实现变换的主要数学工具是矩阵
,所以在讲解坐标系变换之前,我们先温习一下矩阵。
温馨提示:
在学习矩阵变换时,一定要搞清楚以下三点:
- 所使用的向量是
行向量
还是列向量
。- 如果是
行向量
,按照数学领域
中矩阵相乘的规则,向量要放在左侧
相乘。 - 如果是
列向量
,向量要放在右侧
相乘。
- 如果是
- 矩阵是
行主序
还是列主序
。- 如果是行主序,内存存储矩阵的数组的前四个元素表示的是对应数学矩阵的
第一行
- 如果是列主序,内存存储矩阵的数组的前四个元素表示的是对应数学矩阵的
第一列
- 如果是行主序,内存存储矩阵的数组的前四个元素表示的是对应数学矩阵的
- 多个矩阵变换时的相乘顺序。
在多个矩阵变换时,不同的相乘顺序会导致不同的结果,所以我们要保证矩阵相乘的顺序是我们期望的。假设有三个变换矩阵:旋转矩阵 R,平移矩阵 T,缩放矩阵 S,以及顶点向量 P,那么 P 变换到 P1 的顺序一般是这样的:
$P1 = T \times R \times S \times P$
即先
缩放
,再旋转
,最后平移
。
矩阵到底代表什么?
3D 学习过程中的一大难点就是矩阵变换,我们经常看到矩阵左乘一个列向量就能够实现平移、旋转、缩放等效果。
那么,矩阵背后的神秘力量是什么呢?
其实矩阵并不神秘,只是矩阵可以对一些数字按照矩阵的规则执行一系列运算操作,简化了我们使用 +
、 -
、 *
、 /
进行变换运算的步骤而已。
一个矩阵可以理解为一种变换,多个矩阵相乘代表多个变换。
矩阵变换
常见的矩阵变换有如下几种:
- 平移
- 缩放
- 旋转
- 切变
但是在坐标系转换中,最常应用的是前三种。我们看一下如何用矩阵表示这些变换。在讲解矩阵变换之前,我们先从头捋一下点
和向量
的表示。
点和向量
前面章节我们讲了齐次坐标
,它用来区分点
和向量
,齐次坐标使用 N+1
维向量表示 N 维空间,第 N+1 维数字如果是 0 的话,则代表 N 维空间中的向量
,如下用 4 维向量表示 3 维空间中的一个向量:
$\vec{P} = (3, 2, 1, 0)$
第 N+1
维数字如果是非0数字
的话,则代表 N 维空间下的点
:
$\vec{P} = (3, 2, 1, 1)$
使用 N+1 维数字表示 N 维空间中的点或向量的方式就是齐次坐标。
齐次坐标除了能够区分点
和向量
,还有两大用处:
1、模拟透视投影效果。
模拟透视效果我们上一节已经介绍了,在裁剪坐标系中,w 值越大,经过透视除法后的坐标越小,于是也就有了近大远小的投影效果。
2、用矩阵来表示平移变换。
前面章节已经讲过,n 阶矩阵只能和 n 维列向量相乘,得到一个新的 n 维列向量。
$M_{ij} \times P_{j1} $
乘得的结果只能表示缩放和旋转变换,没有办法表示平移变换,因为平移是在原向量的基础上加上一个常量位移,属于加法操作,但是 n 阶矩阵和 n 维列向量相乘的话,每一步都是相乘操作,没有加法运算,所以无法用 n 阶矩阵和 n 维列向量表示 n 维列向量的平移。
要注意:上面所说的列向量指的是坐标,不是数学意义上的向量。
$$ \begin{pmatrix} a & b & c \ d & e & f \ g & h & i \end{pmatrix} \times \begin{pmatrix}x \ y \ z \end{pmatrix} = \begin{pmatrix} ax + by + cz \ dx + ey + fz \ gx + hy + iz \end{pmatrix} $$
可以看到 n 维矩阵和 n 维向量相乘,不能实现 n 维向量和一个常量进行加减的操作。
我们期待的是得到这样一个向量:
$(ax + by +cz + p)$
其中 p 是常数,代表平移的大小。
我们看一下齐次坐标是如何帮助我们解决这个问题的。
顶点 P 用齐次坐标表示如下:
$P = (x, y, z, 1)$
因为 3 维坐标用齐次坐标的话需要增加到 4 维,所以表示平移变换的矩阵也要相应地变成 4 阶矩阵,我们看下这个 4 阶矩阵如何构成:
$$ \begin{pmatrix} a & b & c & tx \ c & d & e & ty \ f & g & h & tz \ 0 & 0 & 0 & 1 \end{pmatrix} $$
在原来基础上增加一行和一列,其中第四行前三个分量为 0,第四个分量为 1,这样矩阵和向量的乘积得到的新的向量的第四个分量也是 1,所以也是代表点。
第四列tx、ty、tz分别代表沿 x 轴、y 轴、 z 轴方向上的平移量。
我们推算验证一下:
$$ \begin{pmatrix} a & b & c & tx \ d & e & f & ty \ g & h & i & tz \ 0 & 0 & 0 & 1 \end{pmatrix} \times \begin{pmatrix}x \ y \ z \ 1 \end{pmatrix} = \begin{pmatrix} ax + by + cz +tx \ dx + ey + fz + ty \ gx + hy + iz + tz \ 0 + 0 + 0 + 1 \end{pmatrix} $$
转换后的向量的每一个分量都实现了ax + by + cz + 常数
的格式,也就是说,向量可以通过乘以一个矩阵实现平移操作。
变换矩阵的推导
变换矩阵的求解思路
首先我们要知道,对物体(顶点)做平移、旋转、缩放的变换操作相当于对原来的坐标系做平移、旋转、缩放变换,得到一个新坐标系。了解了这一点,我们就可以学习一种求解变换矩阵的简单方式:
- 首先求出新坐标系的基向量 U 在原坐标系下的表示 U’,其中U =(Ux, Uy, Uz), U' = (Ux', Uy', Uz')。
- Ux:X轴基向量,由三个分量构成,
- Uxx, X轴分量。
- Uxy, Y轴分量。
- Uxz,Z轴分量。
- Uy:Y轴基向量,由三个坐标轴分量组成
- Uyx:X轴分量。
- Uyy:Y轴分量。
- Uyz:Z轴分量。
- Uz:Z轴基向量,由三个坐标轴分量组成
- Uzx:X轴分量。
- Uzy:Y轴分量。
- Uzz:Z轴分量。
- Ux:X轴基向量,由三个分量构成,
基向量是指坐标系中各个坐标轴正方向的单位向量,假设 Ux 代表 X 轴的单位向量,那么 Ux = (1, 0, 0),同理, Uy = (0, 1, 0),Uz = (0, 0, 1)。
- 其次求出新坐标系的坐标原点O(Ox, Oy, Oz)在原坐标系下的坐标O1(Ox1, Oy1, Oz1)
基向量是坐标系变换的基础,我们求解坐标变换矩阵关键就是要找到原坐标系的基向量在新坐标系中的表示。
- 最后,将上面求出的各个值代入下面的矩阵框架:
$$ \begin{pmatrix} U_{xx} & U_{yx} & U_{zx} & O_{x1}\ U_{xy} & U_{yy} & U_{zy} & O_{y1}\ U_{xz} & U_{yz} & U_{zz} & O_{z1}\ 0 & 0 & 0 & 1 \end{pmatrix} $$
这是一个简单易于理解的求解思路,掌握了这个思路,不管进行什么样的变换,我们都能很快地求出来变换矩阵,只需要找到这些值,然后将其代入矩阵框架
就行啦。
下面是一个坐标系变换的例子,坐标系 oxyz 绕 Z 轴旋转 β 角度后形成了新坐标系 ox'y'z':
大家一定要分清,新坐标系是 ox'y'z'
,原坐标系是 oxyz
,新坐标系的基向量
在原坐标系下的表示我们利用三角函数运算即可求出,如上图所示,所以按照求解思路的第一步,新坐标系的基向量在原坐标系表示为:
$U' = (Ux', Uy', Uz')$
$Ux' = (cos\beta, sin\beta, 0)$
$Uy' = (-sin\beta, cos\beta, 0)$
$Uz' = (0, 0, 1)$
原坐标系的坐标原点和新坐标系重合,所以新坐标系原点在原坐标系下的表示:
$O1 = (Ox1 ,Oy1, Oz1) = (0, 0, 0)$
代入通用矩阵框架后得出变换矩阵为:
$$ \begin{pmatrix} cos\beta & -sin\beta & 0 & O_{x1}\ sin\beta & cos\beta & 0 & O_{y1}\ 0 & 0 & 1 & O_{z1}\ 0 & 0 & 0 & 1 \end{pmatrix} $$
平移变换
按照上面的矩阵变换求解思路来寻找平移矩阵:
- tx 代表沿着 X 轴方向的位移量。
- ty 代表沿着 Y 轴方向的位移量。
- tz 代表沿着 Z 轴方向的位移量。
1、求出原坐标系的基向量在新坐标系的表示。
由于没有进行旋转和缩放操作,所以新坐标系的基向量和原坐标系一样:
$Ux = (1, 0, 0)$
$Uy = (0, 1, 0)$
$Uz = (0, 0, 1)$
2、新坐标系坐标原点的坐标:
$ Ox1 = Ox + t_x = t_x $
$ Oy1 = Oy + t_y= t_y $
$ Oz1 = Oz + t_z = t_z $
将这些值代入变换矩阵框架
$ \begin{pmatrix} 1 & 0 & 0 & tx \ 0 & 1 & 0 & ty \ 0 & 0 & 1 & tz \ 0 & 0 & 0 & 1 \end {pmatrix} $
算法实现
我们用 JavaScript 实现上述平移矩阵。
- 输入参数
- tx:沿 X 轴方向平移量。
- ty:沿 Y 轴方向平移量。
- tz:沿 Z 轴方向平移量。
- 输出结果
- 返回一个平移矩阵。
还记得吗?WebGL 矩阵是列主序的,每隔 4 个数代表一列。
function translation(tx, ty, tz, target){
target = target || new Float32Array(16);
// 第一列
target[0] = 1;
target[1] = 0;
target[2] = 0;
target[3] = 0;
// 第二列
target[4] = 0;
target[5] = 1;
target[6] = 0;
target[7] = 0;
// 第三列
target[8] = 0;
target[9] = 0;
target[10] = 1;
target[11] = 0;
// 第四列
target[12] = tx;
target[13] = ty;
target[14] = tz;
target[15] = 0;
return target;
}
平移矩阵的生成算法很简单,按照数学关于矩阵的定义,在指定位置设置正确的值即可。
之后我们就可以用该算法生成一个平移矩阵实现顶点的平移变换了。
平移矩阵的演示
我们绘制两个半径为 5 的球体,第一个球体在世界坐标系中心,第二个球体沿着 X 轴偏移 10 个单位,为了演示方便,我们先设置一个正射投影矩阵,左平面位于 -15 处,右平面位于 15 处,上平面位于 15 处,下平面位于 -15 处,远平面位于 1000,近平面位于 -1000。
//获取视口宽高比
var aspect = canvas.width / canvas.height;
//获取正射投影观察箱
var perMatrix = matrix.ortho(-aspect * 15, aspect * 15, -15, 15, 1000, -1000);
// 获取平移矩阵
var translationMatrix = matrix.translation(10, 0, 0);
// 将矩阵传往 GPU。
gl.uniformMatrix4fv(u_Matrix, translationMatrix);
1、gl.uniformMatrix4fv该方法的作用是 JavaScript 向着色器程序中的`u_Matrix`属性传递一个 4 阶`列主序`矩阵。
2、ortho 方法是生成正射投影矩阵的方法,讲到投影变换时我们再对它的实现做讲解。
右侧球体是平移后的效果:
可以看出,平移矩阵能够正常工作。
缩放
缩放是将组成物体的各个顶点沿着对应坐标轴缩小或者放大,一种方法是:使用顶点向量乘以缩放向量即可实现。请注意数学领域向量和向量只有点乘和叉乘,并没有一种运算可以实现向量与向量各个分量相乘得到一个新的向量。 不过在上一节我们使用 JavaScript 实现了这样一个算法,在这里就可以用到了:
Vector3 vec = new Vector3(3, 2, 1);
Vector3 scale = new Vector3(2, 2, 1);
vec = vec.multiply(scale);
$P \times S = (x, y, z) \times (2, 2, 1) = (2x, 2y, z, 1)$
但是这里我们要实现的是通过向量和矩阵相乘的方式来实现。
我们要构建一个缩放矩阵,缩放矩阵也比较简单,按照上面的求解思路:
1、新坐标系基向量在原坐标系下的表示:
沿着 X 轴缩放 sx 倍,相当于将原来的基向量放大了 sx 倍,所以新坐标系下一个单位的长度相当于原来坐标系下的 sx 个长度,以此类推,我们很容易地推导出 Y 轴和 Z 轴的基向量
$Ux = (sx, 0, 0)$
$Uy = (0, sy, 0)$
$Uz = (0, 0, sz)$
2、原坐标系原点在新坐标系下的坐标:
由于缩放操作没有改变原点位置,所以,原点坐标在新坐标系下仍然是(0,0,0)。
$ O1 = (0, 0, 0) $
将这些值代入变换矩阵框架,可以得出:
上面这个图就是一个典型的缩放矩阵:
- sx:沿着 X 轴方向的缩放比例
- sy:沿着 Y 轴方向的缩放比例
- sz:沿着 Z 轴方向的缩放比例
缩放矩阵算法实现:
function scale(sx, sy, sz, target){
target = target || new Float32Array(16);
// 第一列
target[0] = sx;
target[1] = 0;
target[2] = 0;
target[3] = 0;
// 第二列
target[4] = 0;
target[5] = sy;
target[6] = 0;
target[7] = 0;
// 第三列
target[8] = 0;
target[9] = 0;
target[10] = sz;
target[11] = 0;
// 第四列
target[12] = 0;
target[13] = 0;
target[14] = 0;
target[15] = 1;
return target;
}
调用该方法需要指定三个方向的缩放比例,但是有时我们可能只缩放某个方向,所以需要再衍生三个缩放函数
- 沿 X 轴缩放矩阵
- 沿 Y 轴缩放矩阵
- 沿 Z 轴缩放矩阵
function scaleX(sx){
return scale(sx, 1, 1);
}
function scaleY(sy){
return scale(1, sy, 1);
}
function scaleZ(sz){
return scale(1, 1, sz);
}
旋转
相比平移和缩放,旋转矩阵相对复杂一些,我们从 2D 平面上一个顶点的旋转说起。
点 P(x, y) 旋转 β 角度后,得到一个新的顶点 P1(x1, y1) , P1 和 P 之间的坐标关系:
P 点坐标:
$ x = r \times cos\alpha $
$ y = r \times sin\alpha $
旋转后的 P1 点坐标:
$ x1 = r \times cos(\alpha + \beta) = r \times cos\alpha cos\beta - r \times sin\alpha sin\beta $
$ y1 = r \times sin(\alpha + \beta) = r \times cos\alpha sin\beta + r \times sin\alpha cos\beta $
将 P 点坐标带入 P1点可以得到:
$ x1 = xcos\beta - ysin\beta $
$ y1 = xsin\beta + ycos\beta $
我们使用齐次坐标和矩阵表示:
$ P1 = \begin{pmatrix} cos\beta & -sin\beta & 0 \
sin\beta & cos\beta & 0 \
0 & 0 & 1 \end{pmatrix} \times \begin{pmatrix} x \ y \ 1 \end{pmatrix} =\begin{pmatrix} xcos\beta - ysin\beta \ xsin\beta + ycos\beta \ 1 \end{pmatrix} $
扩展到 3D 空间,我们同样能推导出下面三种旋转矩阵。
绕 X 轴旋转
JavaScript 的实现,相信大家已经熟记于心了,我们只需要在矩阵的各个位置指定对应数字即可。
function rotationX(angle, target){
target = target || new Float32Array(16);
let sin = Math.sin(angle);
let cos = Math.cos(angle);
target[0] = 1;
target[1] = 0;
target[2] = 0;
target[3] = 0;
target[4] = 0;
target[5] = cos;
target[6] = sin;
target[7] = 0;
target[8] = 0;
target[9] = -sin;
target[10] = cos;
target[11] = 0;
target[12] = 0;
target[13] = 0;
target[14] = 0;
target[15] = 1;
return target;
}
只要你理解了矩阵的运算规则,并推导出变换矩阵,之后只需将各个位置的元素赋值到一个类型化数组中即可。
绕 Y 轴旋转
算法和 X 轴旋转极其相似,就不在这里写了,具体实现请看这里。
绕 Z 轴旋转
具体实现请看这里。
请注意:以上每一种旋转都是单一旋转,但往往我们需要既沿 X 轴旋转,又要沿 Y 轴旋转,这种情况,我们只需要将旋转矩阵相乘,得到的新的矩阵就是包含了这两种旋转的变换矩阵。
绕任意轴旋转
上面三种是绕坐标轴进行旋转,但实际上我们往往需要绕空间中某一根轴旋转,绕任意轴旋转的矩阵求解比较复杂。
这里我们采用过原点的任意轴旋转,不考虑平移情况,如果是绕一个不过原点的任意轴旋转的话,我们可以利用一个旋转矩阵和一个平移矩阵来完成。
我们看下如何推导,如下图所示:
- C:空间中一点。
- A:坐标原点。
- 向量$\vec{AF}$:旋转轴单位向量 。
点 C 旋转 β 角之后,来到 C' 点。
假设这个变换矩阵为 M,那么 M 和 角度 β以及旋转轴 $\vec{AF}$ 有关,
$ \vec{M} = (β, \vec{AF}) $
如下图所示:
我们现在需要求得如何用点C、 旋转角β以及旋转轴 $\vec{AF}$ 表示 C'。
求解步骤
- 首先,求出$\vec{AC}$在旋转轴$\vec{AF}$上的分量$\vec{AB}$,同时向量$\vec{BC}$垂直于 $\vec{AB}$,
- 接着根据$\vec{BZ'}$ 和旋转角度 β,求出 $\vec{B}C'$,然后根据向量加法公式,求出 $\vec{AC'}$,即 C' 转换后的坐标。
用数学公式表示:
1、向量$\vec{AC}$点乘$\vec{AF}$,求出向量$\vec{AB}$ 。
$ \vec{AB} = (\vec{AC} \cdot \vec{AF}) \times \vec{AF} $
2、求出$\vec{BC}$
$ \vec{BC} = \vec{AC} - \vec{AB} $
3、通过向量$\vec{AF}$叉乘$\vec{BC}$ 求得$\vec{BZ'}$
$ \begin{aligned} \vec{BZ'} &= \vec{AF} \times \vec{BC} \
&=\vec{AF} \times \vec{AC} - \vec{AF} \times \vec{AB} \
& = \vec{AF} \times \vec{AC} - 0 \
& = \vec{AF} \times \vec{AC} \end{aligned} $
4、利用三角函数求出 $\vec{BC'}$
$\vec{BC'} = \vec{BZ'} sin\beta + \vec{BC} cos\beta$
5、利用向量加法求出 $\vec{AC'}$
$ \vec{AC'} = \vec{AB} + \vec{BC'} $
6、将1-4步代入第5步,得出:
$ \vec{AC'} = (\vec{AC} \cdot \vec{AF}) \times \vec{AF} + (\vec{AF} \times \vec{AC}) sin\beta + (\vec{AC} - (\vec{AC} \cdot \vec{AF}) \times \vec{AF}) cos\beta $
假设旋转轴向量表示为:
$ \vec{AF} = (t_x, t_y, t_z) $
新坐标系基向量U(Ux,Uy,Uz)在原坐标系中的坐标位置求解如下: $ \begin{aligned} Ux = (\begin{pmatrix} 1 \ 0 \ 0 \end{pmatrix} - (\begin{pmatrix} 1 \ 0 \ 0 \end{pmatrix} \cdot \begin{pmatrix} t_x \t_y \t_z \end{pmatrix}) \times \begin{pmatrix}t_x \t_y \t_z\end{pmatrix})cos\beta + ( \begin{pmatrix} t_x \ t_y \ t_z \end{pmatrix}\times \begin{pmatrix} 1 \ 0 \0 \end{pmatrix} )\times sin\beta + (\begin{pmatrix} 1 \ 0 \ 0 \end{pmatrix} \cdot \begin{pmatrix} t_x \ t_y \t_z \end{pmatrix}) \times \begin{pmatrix} t_x \ t_y \ t_z \end{pmatrix} \end{aligned} $
利用向量点乘、叉乘规则最终推导出:
$ Ux = \begin{pmatrix} t_x^2 (1-cos\beta) + cos\beta \
t_xt_y(1-cos\beta) + t_zsin\beta \
t_xt_z(1-cos\beta) - t_ysin\beta \end{pmatrix} $
同理,将Y 轴基向量 (0, 1, 0) 代入上面公式,推导可得:
$ Uy = \begin{pmatrix} t_xt_y (1-cos\beta) - t_zsin\beta \
t_y^2(1-cos\beta) + cos\beta \
t_yt_z(1-cos\beta) + t_xsin\beta \end{pmatrix} $
$ Uz = \begin{pmatrix} t_xt_z (1-cos\beta) + t_ysin\beta \
t_yt_z(1-cos\beta) - t_xsin\beta \
t_z^2(1-cos\beta) + cos\beta \end{pmatrix} $
这样我们就求出了新坐标系的基向量在原坐标系的表示。
接下来,我们找出新坐标系的原点在原坐标系下的坐标,因为是绕原点旋转,所以坐标不变,仍然是(0,0,0)。将这些值代入矩阵框架,得出绕任意旋转轴的变换矩阵:
$ \begin{pmatrix} t_x^2 (1-cos\beta) + cos\beta & t_xt_y(1-cos\beta) - t_zsin\beta & t_xt_z(1-cos\beta) + t_ysin\beta & 0 \
t_xt_y (1-cos\beta) + t_zsin\beta & t_y^2(1-cos\beta) + cos\beta & t_yt_z(1-cos\beta) - t_xsin\beta & 0 \
t_xt_z (1-cos\beta) - t_ysin\beta & t_yt_z(1-cos\beta) + t_xsin\beta & t_z^2(1-cos\beta) + cos\beta & 0 \
0 & 0 & 0 & 1 \end{pmatrix} $
有了变换矩阵,那么我们就可以实现JavaScript的任意轴旋转矩阵了:
function axisRotation(axis, angle, target){
var x = axis.x;
var y = axis.y;
var z = axis.z;
var l = Math.sqrt(x * x + y * y + z * z);
x = x / l;
y = y/ l;
z = z /l;
var xx = x * x;
var yy = y * y;
var zz = z * z;
var cos = Math.cos(angle);
var sin = Math.sin(angle);
var oneMCos = 1 - cos;
target = target || new Float32Array(16);
target[0] = xx + (1 - xx) * cos;
target[1] = x * y * oneMCos + z * sin;
target[2] = x * z * oneMcos - y * sin;
target[3] = 0;
target[4] = x * y * oneMCos - z * sin;
target[5] = yy + (1 - yy) * cos;
target[6] = y * z * oneMCos + x * sin;
target[7] = 0;
target[8] = x * z * oneMCos + y * sin;
target[9] = y * z * oneMCos - x * sin;
target[10] = zz + (1 - zz) * cos;
target[11] = 0;
target[12] = 0;
target[13] = 0;
target[14] = 0;
target[15] = 1;
return target;
}
以上就是绕任意轴进行旋转的矩阵。
任意轴旋转演示
我们利用上面的算法演示一下,使用四根旋转轴:
// 中间立方体绕 X 轴旋转。
var axisX = {x: 1, y: 0, z: 0}
//右边立方体绕 Y 轴旋转
var axisY = {x: 0, y: 1, z: 0}
// 左边立方体绕 Z 轴旋转
var axisZ = {x: 0, y: 0, z: 1}
// 上边立方体绕对角线轴旋转。
var axisXYZ = {x: 1, y: 1, z: 1}
效果如下:
绕任意轴旋转的推导比较复杂,涉及到立体几何以向量点乘叉乘等运算,不过它的使用方法还是很简单的。
回顾
本节主要讲解坐标系变换过程中涉及到的基本变换的原理与实现,涉及几何和三角函数的运算比较多,大家看一遍可能不能明白,不妨多看几遍,拿纸笔写写画画,很快就会豁然开朗。
虽然说这些 API 只要能看懂、会用就足够了,没有必要去掌握推导过程,但我仍然建议大家尝试推导一遍,掌握推导过程对巩固学过的数学知识很有帮助,也可以培养自己利用数学知识解决疑难问题的能力。
下一节我们学习如何利用这些基本变换实现各个坐标系之间的变换。