Skip to main content

变换

上篇文章的最后我们渲染了一个正方形,但是只能看见它的正面。要看到它的其他面就需要对它进行变换处理了。

下面描述的各种变换中除了平移其他都是线性变换,任何线性变换都会将零矢量变换成零矢量,同时线性变换需要满足下方两个条件。

F(a+b)=F(a)+F(b)F(ka)=kF(a)F(a+b) = F(a) + F(b) \\ F(ka) = kF(a)

大家可以理解成线性变换不会使直线扭曲,变换后的平行线将继续平行。

仿射变换是线性变换的超集,仿射变换包含平移。

2D 变换模板#

在进行 3D 变换之前,我们先来看下 2D 的变换。我们会用下面这个模板代码执行多个不同的变换。

const canvas = document.createElement('canvas')canvas.width = canvas.height = 300;document.body.appendChild(canvas)const ctx = canvas.getContext('2d')
let transform = {下面的变换函数}
const points = [[0,0],[0,100],[100,100],[100,0]] // 正方形的 4 个顶点ctx.beginPath()points.forEach(p => ctx.lineTo(...transform(p))) // "transform" 函数为下方小节指定的变换函数// 下面的 "transform" 函数返回新的坐标位置ctx.closePath()ctx.fillStyle='rgba(0,255,255,1)'ctx.fill()

上面代码申明了一个正方形的 4 个顶点,然后使用下面个各种变换函数处理这 4 个顶点,返回新的顶点,最终处理渲染后的正方形。

2D 平移#

平移相信大家都知道怎么做,例如我们想把这个正方形沿 X 轴移动 10px,Y 轴 10px。

function translate([x, y], dx = 10, dy = 10) {  return [x + dx, y + dy]}

上面代码就实现了移动正方形的功能,在原有位置上加上指定的位移量。

2D 缩放#

要将一个元素,只需要将它的坐标乘上一个缩放因子就行了,比如这个正方形缩放 0.5 倍,它的新坐标就为 [[0,0],[0,50],[50,50],[50,0]]。

我们可以使用两个缩放因子来分别缩放 X 轴和 Y 轴。

function scale([x, y], xs = 1, ys = xs) {  return [x * xs, y * ys]}

2D 反射#

反射也叫镜像,它可以用来翻转物体,和照镜子效果一样。实现起来也比较简单使用上方缩放方法将 x 轴或 y 轴缩放 -1 倍(也就是垂直或水平镜像)就行了。

2D 错切#

错切可以用来倾斜物体,它会不均匀的拉伸物体,物体错切后它的面积和体积不会发生变化。

执行错切的一个思路是将一个坐标的倍数加到另一个坐标上,比如下面将 1 倍的 Y 坐标加到 X 坐标上。

function skew([x, y]) {  return [x + 1 * y, y]}

我们知道 CSS 里面也有错切方法,不过 CSS 中的 skew 函数支持设置倾斜角度。

div {  transform: skew(30deg);}

我们可以稍微改造下,来支持度数参数。

function skew([x, y], sx = 0, sy = 0) {  const rad = r => r * Math.PI / 180  return [x + Math.tan(rad(sx)) * y, y + Math.tan(rad(sy)) * x]}

我们首先把角度变为弧度,然后利用 tan 函数来计算具体倾斜量,因为 tan 等于对边比临边,再乘上临边就是需要的倾斜量了。

2D 旋转#

旋转是这几个变换中最难的一个。要知道如何旋转一个物体,我们可以从下面这幅图开始。

根据三角函数,我们知道 PP 点的 xx 为 r∗cos(a)r * cos(a) ,yy 为 r∗sin(a)r * sin(a) 。我们将 PP 点旋转 aa 得到 P′P' 点,同样可以知道 P′P' 点的坐标为 [r∗cos(a+b),r∗sin(a+b)][r * cos(a + b), r * sin(a + b)] 。

根据三角函数两角和公式。

sin(a±b)=sin(a)cos(b)±cos(a)sin(b)cos(a±b)=cos(a)cos(b)∓sin(a)sin(b)sin(a \pm b) = sin(a)cos(b) \pm cos(a)sin(b) \\ cos(a \pm b) = cos(a)cos(b) \mp sin(a)sin(b)

我们将 P′P' 点坐标分解为。

P′=[r∗cos(a+b),r∗sin(a+b)]=[r∗(cos(a)∗cos(b)−sin(a)∗sin(b)),r∗(sin(a)∗cos(b)+cos(a)∗sin(b))]=[P.x∗cos(b)−P.y∗sin(b),P.x∗sin(b)+P.y∗cos(b)]\begin{aligned} P'&=[r * cos(a + b), r * sin(a + b)] \\ &=[r * (cos(a) * cos(b) - sin(a) * sin(b)), r * (sin(a) * cos(b) + cos(a) * sin(b))] \\ &=[P.x * cos(b) - P.y * sin(b), P.x * sin(b) + P.y * cos(b)] \end{aligned}

现在我们得到了旋转公式,套用公式就可以写出旋转函数。

function rotate([x, y], deg = 0) {  const rad = deg * Math.PI / 180  return [     x * Math.cos(rad) - y * Math.sin(rad),      x * Math.sin(rad) + y * Math.cos(rad)  ]}

矩阵#

上面所有的变换公式我们还可以写成矩阵的形式。例如下方将缩放变换变成矩阵形式。

scaledPosition=[sx00sy][xy]=[sx∗xsy∗y]\begin{aligned} scaledPosition&=\begin{bmatrix} sx & 0 \\ 0 & sy \end{bmatrix} \begin{bmatrix} x \\ y \end{bmatrix} \\ &=\begin{bmatrix} sx * x \\ sy * y \end{bmatrix} \end{aligned}

同样的错切可以用这个矩阵。

skewMatrix=[1tan(θ)tan(θ)1]skewMatrix=\begin{bmatrix} 1 & tan(\theta) \\ tan(\theta) & 1 \end{bmatrix}

旋转可以用这个矩阵。

rotationMatrix=[cos(θ)−sin(θ)sin(θ)cos(θ)]rotationMatrix=\begin{bmatrix} cos(\theta) & -sin(\theta) \\ sin(\theta) & cos(\theta) \end{bmatrix}

需要注意我们使用的是列矢量,如果使用的是横矢量(也就是矢量在矩阵左边),需要将旋转矩阵转置下。

[xy][cos(θ)sin(θ)−sin(θ)cos(θ)]\begin{bmatrix} x & y \end{bmatrix}\begin{bmatrix} cos(\theta) & sin(\theta) \\ -sin(\theta) & cos(\theta) \end{bmatrix}

对于最简单的平移变换,我们没有办法使用 2 x 2 矩阵来完成。要实现平移矩阵我们需要提升一个维度。

齐次坐标#

齐次坐标就是将一个原本是 n 维的矢量用一个 n+1 维矢量来表示。一般将新加的分量使用 w 表示,对于二维坐标将变为 [x, y, w],三维将变成 [x, y, z, w]。

利用齐次坐标我们将二维点,表示在三维中 w = 1 的平面上,对于 w != 1 的点,需要将它的各个分量除以 w ([x / w, y / w, w / w]) 投影到 w = 1 的平面。所以齐次坐标实际上表示二维的点是 [x / w, y / w]。

当 w = 0 时,x / 0 是未定义的。我们可以将 w = 0 表示为矢量,w != 0 表示为位置。这和矢量中区分点和矢量一致。

有了齐次坐标我们就可以实现平移矩阵了。

translated=[10dx01dy001][xy1]=[x+dxy+dy1]\begin{aligned} translated&=\begin{bmatrix} 1 & 0 & dx \\ 0 & 1 & dy \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} \\ &=\begin{bmatrix} x + dx \\ y + dy \\ 1 \end{bmatrix} \end{aligned}

我们在 2 x 2 矩阵上加了一行和一列,在新增的一列中包含了平移的信息。如果我们对矢量进行平移变换。

translateMatrix * [x, y, 0] = [x, y, 0]

最终结果还是原来的矢量,这也说明了矢量是没有位置的,它不会发生平移。

终于我们通过矩阵实现了所有的变换了。

组合变换和逆变换#

可能有同学要问了,为啥非要用矩阵来表示这些变换,之前不挺好的吗?

用矩阵来实现,我们就可以利用矩阵的特性,实现非常强大的功能,比如我们可以将多个矩阵组合为一个单一矩阵,这个单一矩阵包含了所有的变换。

比如我们将一位置应用两个变换 A 和 B 矩阵。我们让 (B * A) 等于 C 矩阵。

newPosition = B * (A * position)            = (B * A) * position            = C * position

我们利用矩阵乘法可结合的性质,将 A 变换和 B 变换组合成了 C 变换。这样只用将位置乘上这个 C 矩阵就行了。我们可以拿这个 C 矩阵对其他点进行同样的变换了,而不需要每个点都重新计算下 B * A。

caution

需要注意,我们首先是应用的 A 变换,然后再是 B 变换。但是由于我们使用的是列矢量,所以是 B * A * position,B 在 A 的前面。

旋转和平移#

下面 A 是旋转变换,B 是平移变换。

C=B∗A=[10dx01dy001][cos(θ)−sin(θ)0sin(θ)cos(θ)0001]=[cos(θ)−sin(θ)dxsin(θ)cos(θ)dy001]\begin{aligned} C&=B*A \\ &=\begin{bmatrix} 1 & 0 & dx \\ 0 & 1 & dy \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} cos(\theta) & -sin(\theta) & 0 \\ sin(\theta) & cos(\theta) & 0 \\ 0 & 0 & 1 \end{bmatrix} \\ &=\begin{bmatrix} cos(\theta) & -sin(\theta) & dx \\ sin(\theta) & cos(\theta) & dy \\ 0 & 0 & 1 \end{bmatrix} \end{aligned}

可以发现,C 矩阵是将旋转和平移组合起来,最右那一列是平移部分。也就是我们可以将一个矩阵变成线性变换部分和平移部分。

逆变换#

使用矩阵的另一个好处是可以对一个变换做它的逆变换,用于撤销原始变换。比如向右旋转 90 度,那么它的逆变换就是向左旋转 90 度。

F−1(F(a))=F(F−1(a))=aF^{-1}(F(a)) = F(F^{-1}(a)) = a

对一个映射 FF 是可逆的需要存在一个逆运算 F−1F^{-1} ,满足上方式子。

我们可以发现上方介绍的变换都是可逆的,例如平移变换。

matrix=[10dx01dy001]matrix = \begin{bmatrix} 1 & 0 & dx \\ 0 & 1 & dy \\ 0 & 0 & 1 \end{bmatrix}
invertMatrix=[10−dx01−dy001]invertMatrix = \begin{bmatrix} 1 & 0 & -dx \\ 0 & 1 & -dy \\ 0 & 0 & 1 \end{bmatrix}

将它平移部分变为负的即可。

绕中心旋转#

我们旋转一个物体时,一般希望绕它的中心旋转,而不是其他地方。要实现这个效果,我们可以对物体进行三次变换。

  1. 首先我们可以用平移矩阵 TT 将物体移动到原点。
  2. 再使用旋转矩阵 RR 旋转物体
  3. 最后使用第一次平移矩阵的逆矩阵 T−1T^{-1} 将物体移回原处
T=[10−dx01−dy001]T = \begin{bmatrix} 1 & 0 & -dx \\ 0 & 1 & -dy \\ 0 & 0 & 1 \end{bmatrix}
R=[cos(θ)−sin(θ)0sin(θ)cos(θ)0001]R = \begin{bmatrix} cos(\theta) & -sin(\theta) & 0 \\ sin(\theta) & cos(\theta) & 0 \\ 0 & 0 & 1 \end{bmatrix}
T−1=[10dx01dy001]T^{-1} = \begin{bmatrix} 1 & 0 & dx \\ 0 & 1 & dy \\ 0 & 0 & 1 \end{bmatrix}

根据上方旋转和平移中得出的矩阵 T−1∗RT^{-1}*R 等于。

[cos(θ)−sin(θ)dxsin(θ)cos(θ)dy001]\begin{bmatrix} cos(\theta) & -sin(\theta) & dx \\ sin(\theta) & cos(\theta) & dy \\ 0 & 0 & 1 \end{bmatrix}

我们将这些矩阵组合起来。

M=T−1∗R∗T=[cos(θ)−sin(θ)−dx∗cos(θ)+dy∗sin(θ)+dxsin(θ)cos(θ)−dx∗sin(θ)−dy∗cos(θ)+dy001]\begin{aligned} M&=T^{-1}*R*T \\ &=\begin{bmatrix} cos(\theta) & -sin(\theta) & -dx * cos(\theta) + dy * sin(\theta) + dx \\ sin(\theta) & cos(\theta) & -dx * sin(\theta) -dy * cos(\theta)+dy \\ 0 & 0 & 1 \end{bmatrix} \end{aligned}

旋转部分还是没有,只改变了平移的部分,在仿射变换中额外的平移只改变平移部分(最后一列)。

3D 变换#

上面展示的都是 2D 变换,我们只需稍微扩展就可以将这边变换变成三维变换。

平移矩阵#

[100dx010dy001dz0001]\begin{bmatrix} 1 & 0 & 0 & dx \\ 0 & 1 & 0 & dy \\ 0 & 0 & 1 & dz \\ 0 & 0 & 0 & 1 \end{bmatrix}

缩放矩阵#

[sx0000sy0000sz00001]\begin{bmatrix} sx & 0 & 0 & 0 \\ 0 & sy & 0 & 0 \\ 0 & 0 & sz & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

任意方向缩放#

除了 X,Y,Z 轴方向,我们还可以实现任意方向缩放。假设我们任意方向是单位矢量 N。

上图中将矢量 VV ,沿着单位矢量 NN 进行缩放,缩放比例为 k,得到矢量 V′V' 。

将矢量 VV 分解为 V∥V\| 和 V⊥V\bot ,使得 V∥V\| 平行 NN , V⊥V\bot 垂直于 V∥V\| ,并且 V=V∥+V⊥V=V\| + V\bot 。同样的将 V′V' 也分为 V′∥V'\| 和 V′⊥V'\bot 。

由于 V⊥V\bot 垂直 NN ,所以它不会受到缩放操作影响,对 VV 的缩放也就是对 V∥V\| 的缩放。

我们可以发现 V∥V\| 等于 (V⋅N)∗N(V \cdot N) * N,那么变换后 V′V' 就为。

V=V∥+V⊥V=V\| + V\bot
V∥=(V⋅N)∗NV\| = (V \cdot N) * N
V′⊥=V⊥=V−V∥=V−(V⋅N)∗N\begin{aligned} V'\bot &= V\bot \\ &= V-V\| \\ &= V-(V \cdot N) * N \end{aligned}
V′∥=kV∥=k∗(V⋅N)∗N\begin{aligned} V'\| &= kV\| \\ &= k * (V \cdot N) * N \end{aligned}
V′=V′⊥+V′∥=V−(V⋅N)∗N+k∗(V⋅N)∗N=V+(k−1)∗(V⋅N)∗N\begin{aligned} V' &= V'\bot + V'\| \\ &= V - (V \cdot N) * N + k * (V \cdot N) * N \\ &= V + (k - 1) * (V \cdot N) * N \end{aligned}

求出了 V′V' ,我们就可以得到在任意单位矢量 NN 方向,缩放 kk 的缩放矩阵。

[1+(k−1)Nx2(k−1)NxNy(k−1)NxNz0(k−1)NxNy1+(k−1)Ny2(k−1)NxNz0(k−1)NxNz(k−1)NyNz1+(k−1)Nz200001]\begin{bmatrix} 1+(k-1)N_x^2 & (k-1)N_xN_y & (k-1)N_xN_z & 0 \\ (k-1)N_xN_y & 1+(k-1)N_y^2 & (k-1)N_xN_z & 0 \\ (k-1)N_xN_z & (k-1)N_yN_z & 1+(k-1)N_z^2 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

旋转矩阵#

对于三维旋转,我们可以绕 X、Y 和 Z 轴旋转,每个旋转对应一个旋转矩阵。

我们还需要确认哪个方向是旋转正方向,我们这里用坐标系中提到的右手坐标系。

绕 X 轴旋转,X 轴坐标不变。

Rx=[10000cos(θ)−sin(θ)00sin(θ)cos(θ)00001]R_x = \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & cos(\theta) & -sin(\theta) & 0 \\ 0 & sin(\theta) & cos(\theta) & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

绕 Y 轴旋转,Y 轴坐标不变。

Ry=[cos(θ)0sin(θ)00100−sin(θ)0cos(θ)00001]R_y = \begin{bmatrix} cos(\theta) & 0 & sin(\theta) & 0 \\ 0 & 1 & 0 & 0 \\ -sin(\theta) & 0 & cos(\theta) & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

绕 Z 轴旋转,Z 轴坐标不变。

Rz=[cos(θ)−sin(θ)00sin(θ)cos(θ)0000100001]R_z = \begin{bmatrix} cos(\theta) & -sin(\theta) & 0 & 0 \\ sin(\theta) & cos(\theta) & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}
info

旋转矩阵是正交矩阵,它的逆矩阵就等于它的转置矩阵。

绕任意过原点轴旋转#

除了绕 X、Y、Z 轴,还可以绕任意轴旋转(该轴穿过原点,不考虑位移的情况)。假设绕任意轴单位矢量 N。

和任意方向缩放中一样,上图中 V′V' 是矢量 VV 沿单位矢量 NN 旋转 θ\theta 度后的结果。要求出 V′V' 的位置,我们可以将 VV 和 V′V' 拆分成垂直和平行分量,其中平行分量平行于 NN 。

我们可以发现旋转是应用在垂直分量上的,因为平行分量于旋转方向 NN 平行,不受旋转影响。我们现在可以把目标放在 2 维平面上的垂直矢量 V⊥V\bot 和 V′⊥V'\bot 。

我们可以构造一个 WW 矢量, WW 垂直 V⊥V\bot ,长度于 V⊥V\bot 相等。 WW 矢量等于 NN 叉乘 V⊥V\bot (叉乘在矢量中有讲)。

WW、 V⊥V\bot 、 V′⊥V'\bot 都在一个平面上,并且 WW 与 V⊥V\bot 垂直,我们把 WW 和 V⊥V\bot 当成水平和垂直坐标轴,根据上方讲到的二维旋转,我们可以得到。

V′⊥=cos(θ)∗V⊥+sin(θ)∗WV'\bot=cos(\theta)*V\bot + sin(\theta)*W

那么 V′V' 就等于。

V∥=(V⋅N)NV\| =(V \cdot N)N
V⊥=V−V∥=V−(V⋅N)N\begin{aligned} V\bot &= V-V\| \\ &=V-(V \cdot N)N \end{aligned}
W=N×V⊥=N×(V−V∥)=N×V−N×V∥=N×V\begin{aligned} W &= N \times V\bot \\ &=N \times (V - V\|) \\ &=N \times V - N \times V\| \\ &=N \times V \end{aligned}
V′=V′⊥+V′∥=cos(θ)∗V⊥+sin(θ)∗W+(V⋅N)∗N=cos(θ)∗(V−(V⋅N)∗N)+sin(θ)∗(N×V)+(V⋅N)∗N\begin{aligned} V' &= V'\bot + V'\| \\ &=cos(\theta) * V\bot + sin(\theta) * W + (V \cdot N) * N \\ &=cos(\theta) * (V - (V \cdot N) * N) + sin(\theta) * (N \times V) + (V \cdot N) * N \end{aligned}

把坐标轴基矢量带入上方式子中,那么绕任意过原点轴旋转的矩阵如下。

[Nx2(1−cos(θ))+cos(θ)NxNy(1−cos(θ))−Nzsin(θ)NxNz(1−cos(θ))+Nysin(θ)0NxNy(1−cos(θ))+Nzsin(θ)Ny2(1−cos(θ))+cos(θ)NyNz(1−cos(θ))−Nxsin(θ)0NxNz(1−cos(θ))−Nysin(θ)NyNz(1−cos(θ))+Nxsin(θ)Nz2(1−cos(θ))+cos(θ)00001]\begin{bmatrix} N_x^2(1-cos(\theta))+cos(\theta) & N_xN_y(1-cos(\theta))-N_zsin(\theta) & N_xN_z(1-cos(\theta))+N_ysin(\theta) & 0 \\ N_xN_y(1-cos(\theta))+N_zsin(\theta) & N_y^2(1-cos(\theta))+cos(\theta) & N_yN_z(1-cos(\theta))-N_xsin(\theta) & 0 \\ N_xN_z(1-cos(\theta))-N_ysin(\theta) & N_yN_z(1-cos(\theta))+N_xsin(\theta) & N_z^2(1-cos(\theta))+cos(\theta) & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

总结#

这篇文章如何变换物体,以及使用矩阵来变换物体和使用矩阵的好处。这篇文章中的矩阵都是数学中的形式,如果要在 WebGL 中使用需要将矩阵转置一下,变成列主序矩阵。

知道了如何变换物体,下篇文章就让立方体旋转起来吧。