矩阵重启,你就是MVP

Posted by lamyoung on February 10, 2022

The Matrix Resurrections

前言

这是白玉无冰记录3D数学第三篇章,矩阵!往期目录如下:

在开始唠嗑前,先简单介绍一下标题与配图的含义。

  • 矩阵重启:重新捡起矩阵的知识
  • MVP:三个矩阵的简称
  • Matrix 4: 3D 游戏开发中,常用的是4X4矩阵

白玉无冰打算围绕 Cocos Creator 3.4 中的源码,展开认识其中用到的矩阵。

开始

直观感受

矩阵就是一组数字摆成矩形的阵法。

$\begin{pmatrix} 0 & 4 & 8 & 12
1 & 5 & 9 & 13
2 & 6 & 10 & 14
3 & 7 & 11 & 15
\end{pmatrix}$

矩阵就是映射!矩阵就是映射!矩阵就是映射!

摘录《程序员的数学3线性代数》中的内容感受一下矩阵!

矩阵就是映射

矩阵就是映射! mxn 的矩阵是 n维-> m维 的映射!

n维-> m维

矩阵就是映射! 矩阵的乘积就是映射的叠加!

矩阵的乘积就是映射的叠加

矩阵就是映射! 逆矩阵就是逆映射!好比把水变成冰的过程看作一个矩阵,那么逆矩阵就是冰变成水的过程。

并不是所有的矩阵都有逆矩阵,类似把水果榨成果汁可以做到,但是把果汁还原成水果就不行了。

逆映射

Mat4

engine/cocos/core/math/mat4.tsCocos Creator 引擎表示四维(4x4)矩阵(Mathematical 4x4 matrix.)。

mat4.ts 构造了一个形如$\begin{pmatrix} m_{00} & m_{04} & m_{08} & m_{12}
m_{01} & m_{05} & m_{09} & m_{13}
m_{02} & m_{06} & m_{10} & m_{14}
m_{03} & m_{07} & m_{11} & m_{15}
\end{pmatrix}$的矩阵。

为何下标是按照列主序构建的呢?因为 Cocos Effect 使用的是 GLSL 语言,矩阵是按照列主序存在数组中的。

列主序

Cocos Effect 是一种基于 YAML 和 GLSL 的单源码嵌入式领域特定语言(single-source embedded domain-specific language),YAML 部分声明流程控制清单,GLSL 部分声明实际的 shader 片段,这两部分内容上相互补充,共同构成了一个完整的渲染流程描述。

顺便翻一下《WebGL编程指南》,把GLSL矩阵部分截取下来,一并作为参考与思考。

GLSL矩阵语法

engine/editor/assets/chunks/particle-common.chunk 中的代码为例,复习一下矩阵的元素访问。

矩阵的元素访问

SRT 与 MV

先看看每个字母的全称:

  • Scaling 缩放
  • Rotation 旋转
  • Translation 位移
  • Model 模型
  • View 观察

白玉无冰不打算讲每一个推导,我们直接上号看结果!

开始前,先准备一小段代码,当然这段不是很重要,可以快速滑过代码部分,放出来是为了方便复制到自己的工程预览。

import { _decorator, Component, Node, mat4, Mat4 } from 'cc';
const { ccclass, property, executeInEditMode } = _decorator;

const __temp_mat4 = mat4();
@ccclass('NodeMatrixInfo')
@executeInEditMode
export class NodeMatrixInfo extends Component {

    @property({ readonly: true, visible: true, displayName: '世界矩阵' })
    __txt_matrix: string[] = [];

    @property({ readonly: true, visible: true, displayName: '三角函数' })
    __txt_sin_cos: string[] = [];

    @property({ readonly: true, visible: true, displayName: '世界矩阵的逆' })
    __txt_matrixInvert: string[] = [];

    @property({ readonly: true, visible: true, displayName: '欢迎关注' })
    __txt_info: string = '白玉无冰';

    start() {
        this.node.on(Node.EventType.TRANSFORM_CHANGED, this.onNodeTransFormChange, this);
        this.onNodeTransFormChange();
    }

    private onNodeTransFormChange(evt?) {
        const {
            m00: m00, m04: m01, m08: m02, m12: m03,
            m01: m10, m05: m11, m09: m12, m13: m13,
            m02: m20, m06: m21, m10: m22, m14: m23,
            m03: m30, m07: m31, m11: m32, m15: m33
        } = this.node.getWorldMatrix(__temp_mat4);

        this.__txt_matrix[0] = `${m00.toFixed(2)},   ${m01.toFixed(2)},     ${m02.toFixed(2)},     ${m03.toFixed(2)}`;
        this.__txt_matrix[1] = `${m10.toFixed(2)},   ${m11.toFixed(2)},     ${m12.toFixed(2)},     ${m13.toFixed(2)}`;
        this.__txt_matrix[2] = `${m20.toFixed(2)},   ${m21.toFixed(2)},     ${m22.toFixed(2)},     ${m23.toFixed(2)}`;
        this.__txt_matrix[3] = `${m30.toFixed(2)},   ${m31.toFixed(2)},     ${m32.toFixed(2)},     ${m33.toFixed(2)}`;

        {
            const {
                m00: m00, m04: m01, m08: m02, m12: m03,
                m01: m10, m05: m11, m09: m12, m13: m13,
                m02: m20, m06: m21, m10: m22, m14: m23,
                m03: m30, m07: m31, m11: m32, m15: m33
            } = Mat4.invert(__temp_mat4, __temp_mat4);
            this.__txt_matrixInvert[0] = `${m00.toFixed(2)},   ${m01.toFixed(2)},     ${m02.toFixed(2)},     ${m03.toFixed(2)}`;
            this.__txt_matrixInvert[1] = `${m10.toFixed(2)},   ${m11.toFixed(2)},     ${m12.toFixed(2)},     ${m13.toFixed(2)}`;
            this.__txt_matrixInvert[2] = `${m20.toFixed(2)},   ${m21.toFixed(2)},     ${m22.toFixed(2)},     ${m23.toFixed(2)}`;
            this.__txt_matrixInvert[3] = `${m30.toFixed(2)},   ${m31.toFixed(2)},     ${m32.toFixed(2)},     ${m33.toFixed(2)}`;
        }


        {
            const eulerAngles = this.node.eulerAngles;
            const angle2rad = 1 / 180 * Math.PI;
            this.__txt_sin_cos = [
                `sin ${eulerAngles.x} = ${Math.sin(eulerAngles.x * angle2rad).toFixed(2)}`,
                `cos ${eulerAngles.x} = ${Math.cos(eulerAngles.x * angle2rad).toFixed(2)}`,
            ]
            if (eulerAngles.y != eulerAngles.x) {
                this.__txt_sin_cos.push(
                    `sin ${eulerAngles.y} = ${Math.sin(eulerAngles.y * angle2rad).toFixed(2)}`,
                    `cos ${eulerAngles.y} = ${Math.cos(eulerAngles.y * angle2rad).toFixed(2)}`);
            }
            if (eulerAngles.z != eulerAngles.y && eulerAngles.z != eulerAngles.x) {
                this.__txt_sin_cos.push(
                    `sin ${eulerAngles.z} = ${Math.sin(eulerAngles.z * angle2rad).toFixed(2)}`,
                    `cos ${eulerAngles.z} = ${Math.cos(eulerAngles.z * angle2rad).toFixed(2)}`);
            }
        }
    }
}

把上面的组件挂在场景中的一个子节点,开始观察!

Scaling

只对节点缩放,观察世界矩阵。

再来几个缩放,一起对比!

通过观察得出,缩放矩阵形如 $S=\begin{pmatrix} scaleX & 0 & 0 & 0
0 & scaleY & 0 & 0
0 & 0 & scaleZ & 0
0 & 0 & 0 & 1
\end{pmatrix}$。

顺便观察逆矩阵!缩放逆矩阵形如 $S^{-1}=\begin{pmatrix} 1/scaleX & 0 & 0 & 0
0 & 1/scaleY & 0 & 0
0 & 0 & 1/scaleZ & 0
0 & 0 & 0 & 1
\end{pmatrix}$。

最后看一眼引擎源码确认一下,确实如此。

Rotation

只对节点旋转,观察世界矩阵。

好像没看出什么东西?认真看看世界矩阵与逆矩阵。可以看出这两矩阵是转置关系。即${R^{-1} = R^T}$

也就是说,假设某个旋转矩阵是 $\begin{pmatrix} 0 & 4 & 8 & 12
1 & 5 & 9 & 13
2 & 6 & 10 & 14
3 & 7 & 11 & 15
\end{pmatrix}$
那么这个旋转矩阵的逆矩阵就会是 $\begin{pmatrix} 0 & 1 & 2 & 3
4 & 5 & 6 & 7
8 & 9 & 10 & 11
12 & 13 & 14 & 15
\end{pmatrix}$

🎈 正交矩阵是指其转置等于逆的矩阵。其行列式为±1,行(列)向量组为n维单位正交向量组。

那我们改一个旋转参数试试!

只改 z(绕Z轴旋转):

仔细观察,大胆假设绕z轴旋转的矩阵应该是: $R_z=\begin{pmatrix} \cos{\theta} & -\sin{\theta} & 0 & 0
\sin{\theta} & \cos{\theta} & 0 & 0
0 & 0 & 1 & 0
0 & 0 & 0 & 1
\end{pmatrix}$

瞅瞅引擎源码。

只改 y(绕Y轴旋转):

y轴旋转的矩阵应该是: $R_y=\begin{pmatrix} \cos{\theta} & 0 & \sin{\theta} & 0
0 & 1 & 0 & 0
-\sin{\theta} & 0 & \cos{\theta} & 0
0 & 0 & 0 & 1
\end{pmatrix}$

只改 x(绕X轴旋转):

x轴旋转的矩阵应该是: $R_x=\begin{pmatrix} 1 & 0 & 0 & 0
0 & \cos{\theta} & -\sin{\theta} & 0
0 & \sin{\theta} & \cos{\theta} & 0
0 & 0 & 0 & 1
\end{pmatrix}$

🎈 编辑器中的Rotation指的是欧拉角,节点代码中存的是四元数。

我们把旋转矩阵一直出现的部分提出来 $\begin{pmatrix} \cos{\theta} & -\sin{\theta}
\sin{\theta} & \cos{\theta}
\end{pmatrix}$正好是二维中的旋转矩阵。

将其相乘两次 $\begin{pmatrix} \cos{\theta} & -\sin{\theta}
\sin{\theta} & \cos{\theta} \end{pmatrix}^{2}
=\begin{pmatrix} \cos^{2}{\theta}-\sin^{2}{\theta} & -2\sin{\theta}\cos{\theta}
2\sin{\theta}\cos{\theta} & \cos^{2}{\theta}-\sin^{2}{\theta} \end{pmatrix}^{2}
= \begin{pmatrix} \cos{2\theta} & -\sin{2\theta}
\sin{2\theta} & \cos{2\theta} \end{pmatrix}$

大胆推测 $\begin{pmatrix} \cos{\theta} & -\sin{\theta}
\sin{\theta} & \cos{\theta} \end{pmatrix}^{n} = \begin{pmatrix} \cos{n\theta} & -\sin{n\theta}
\sin{n\theta} & \cos{n\theta} \end{pmatrix}$,旋转$n$次的$\theta$,与旋转$n\theta$的结果是相同的。

忘了在哪看到复数可以用矩阵表示 $a+b\bold{i} \iff \begin{pmatrix} a & -b
b & a \end{pmatrix}$

把 $\sin{n\theta}$ 和 $\cos{n\theta}$ 代入上面的式子得到 $\cos{n\theta}+\bold{i}\sin{n\theta} \iff \begin{pmatrix} \cos{n\theta} & -\sin{n\theta}
\sin{n\theta} & \cos{n\theta} \end{pmatrix}$

再根据欧拉公式(可由泰勒展开推出) $e^{\bold{i}\theta}=\cos{\theta}+\bold{i}\sin{\theta}$

可以推出

$\begin{pmatrix} \cos{\theta} & -\sin{\theta}
\sin{\theta} & \cos{\theta} \end{pmatrix}^{n}
\iff
(\cos{\theta}+\bold{i}\sin{\theta})^n
= (e^{\bold{i}\theta})^n
= e^{\bold{i}(n\theta)}
= \cos{n\theta}+\bold{i}\sin{n\theta}
\iff
\begin{pmatrix} \cos{n\theta} & -\sin{n\theta}
\sin{n\theta} & \cos{n\theta} \end{pmatrix}$

这段扯远了,让我们回归正题吧。

旋转矩阵实际上还能与本地坐标系的轴对上!

所以,旋转矩阵也可写成相互垂直的单位向量。

$R=\begin{pmatrix} xAxis.x & yAxis.x & zAxis.x & 0
xAxis.y & yAxis.y & zAxis.y & 0
xAxis.z & yAxis.z & zAxis.z & 0
0 & 0 & 0 & 1
\end{pmatrix}$

其逆矩阵为

$R^{-1}=R^T=\begin{pmatrix} xAxis.x & xAxis.y & xAxis.z & 0
yAxis.x & yAxis.y & yAxis.z & 0
zAxis.x & zAxis.y & zAxis.z & 0
0 & 0 & 0 & 1
\end{pmatrix}$

Translation

只对节点移动,观察世界矩阵。

相信我们已经是个成熟的观察者了,容易得出位移矩阵: $T=\begin{pmatrix} 1 & 0 & 0 & t_x
0 & 1 & 1 & t_y
0 & 1 & 1 & t_z
0 & 0 & 0 & 1
\end{pmatrix}$

其逆矩阵为: $T^{-1}=\begin{pmatrix} 1 & 0 & 0 & -t_x
0 & 1 & 1 & -t_y
0 & 1 & 1 & -t_z
0 & 0 & 0 & 1
\end{pmatrix}$

我们还是需要走程序的,看看源码。

Model

Model是SRT的组合!

$ \begin{pmatrix} x’
y’
z’
1
\end{pmatrix}=M\begin{pmatrix} x
y
z
1
\end{pmatrix} =TRS\begin{pmatrix} x
y
z
1
\end{pmatrix} $

在编辑器中观察三者关系!

根据观察得出:

  • M 的第一列 = R的第一列乘上Sx
  • M 的第二列 = R的第二列乘上Sy
  • M 的第三列 = R的第三列乘上Sz
  • M 的第四列 = T的第四列

也就是说 Model 矩阵可写成

$ M = \begin{pmatrix} RS & T
0 & 1
\end{pmatrix}$

View

为何把 View 与 Model 放在一起讲?本质上来说他们是互为逆矩阵的关系。

View 矩阵的作用是将世界坐标映射到摄像机坐标。

摄像机坐标系

在 Cocos Creator 中,摄像机也属于一个节点,View 映射正好是相机节点的 Model 矩阵的反映射。

$ V = (M_{Camera})^{-1} = (TRS)^{-1} = S^{-1}R^{-1}T^{-1} $

一般相机不含缩放,所以

$ V = R^{-1}T^{-1} $

引擎源码也是直接用了节点的逆矩阵。

//engine\cocos\core\renderer\scene\camera.ts update
Mat4.invert(this._matView, this._node.worldMatrix);

这里也和 LookAt 矩阵相关,也有说是 UVN 坐标系,但本质上就是求逆矩阵的过程。

Projection

投影一般指高维向低维的映射,就像我们拍照一样,对现实中的3D物体拍照,在照片上呈现的是2D图像。

投影矩阵本质上是将视锥体映射到一个正方体上(NDC)。

Projection transformation matrix Mproj: maps World Coordinate values in view volume to Normalized Device Coordinates (NDC) in the range (-1, +1)

特别注意NDC坐标系是左手系,View中获得的是右手系,计算的时候要翻转Z轴。

对于透视投影(PERSPECTIVE)和正交投影(ORTHO)矩阵的推导,可参考下面的图。

构造透视投影矩阵常用的一种方式是视角(Field-of-View),只需要用三角变换转成视锥体即可。

//engine\cocos\core\renderer\scene\camera.ts update
// this._aspect 宽高比
if (this._proj === CameraProjection.PERSPECTIVE) {
   // 透视
   // out: IMat4Like, fov: number, aspect: number, near: number, far: number,
   //  isFOVY = true, minClipZ = -1, projectionSignY = 1, orientation = 0,
   Mat4.perspective(this._matProj, this._fov, this._aspect, this._nearClip, this._farClip,
      this._fovAxis === CameraFOVAxis.VERTICAL, this._device.capabilities.clipSpaceMinZ, projectionSignY, orientation);
} else {
   // 正交
   const x = this._orthoHeight * this._aspect;
   const y = this._orthoHeight;
   // out: IMat4Like, left: number, right: number, bottom: number, top: number, near: number, far: number,
   //  minClipZ = -1, projectionSignY = 1, orientation = 0,
   Mat4.ortho(this._matProj, -x, x, -y, y, this._nearClip, this._farClip,
      this._device.capabilities.clipSpaceMinZ, projectionSignY, orientation);
}

结束

矩阵就是映射,矩阵的乘积就是映射的叠加,逆矩阵就是逆映射!

参考资料

  • 《程序员的数学3线性代数》
  • 《WebGL编程指南》
  • https://www.cs.auckland.ac.nz/compsci372s2c/christofLectures/
  • 《Fundamentals of Computer Graphics, Fourth Edition》

如果你还没有明白,那么就算全世界的人都说‘明白了,很简单啊’,你仍然要鼓起勇气说‘不,我还不明白’。这一点很重要。就算别人再怎么明白,如果自己不明白,那也没有意义。要花时间来思考,思考到理解为止。这样得到的东西就一辈子都属于自己。谁也抢不走,认真学习,细心积累,会带给你自信。 《数学女孩》

往期目录:

更多精彩欢迎关注微信公众号


➡️【2021年原创精选】