元宇宙、图形学、可视化、webgl中的数学基础知识(一)

763 阅读29分钟

原文连接

前言

平时用了 threejs、cocos3D、layaair、unity,遇到很多相通的数学知识,整理到一起。 查阅了一些图形学和游戏开发的经典书籍,尝试用最通俗易懂的语言和图片来讲明白一些数学知识;

大纲

  • 坐标系
  • 向量
  • 矩阵
  • 四元素
  • 使用案例

一.坐标系

坐标系有许多种,下面内容主要围绕笛卡儿坐标系(CartesianCoordinateSystem\color{red}{笛卡儿坐标系(Cartesian Coordinate System)}展开。

1-1.jpg (摘自《游戏引擎架构》4.2.1 章节)

1.1 左手坐标系与右手坐标系

3D 坐标系中存在两种坐标系:左手坐标系和右手坐标系。

伸出你的双手,让拇指(x)、食指(y)和中指(z)\color{red}{拇指(x)、食指(y)和中指(z)}相互垂直,就构成了相应的坐标系。

1-2.jpg

OpenGL 中的坐标系大体是右手坐标系,而 Direct3D 中大体是左手坐标系。

右手坐标系:OpenGL(shader)、webgl、threejs、Cocos Creator、LayaAir、Maya、blender 等

左手坐标系:DirectX、pbrt、PRMan 、unity 等

1.1.1 webgl 库的右手坐标系

Cocos Creator 3.0、threejs 等 webgl 库的世界坐标系采用的是笛卡尔右手坐标系

默认 x 向右,y 向上,z 向外,同时使用 -z 轴为正前方朝向,如上图右边; 进行屏幕映射的坐标系,原点在左下角;

注意,laya 也是笛卡尔右手坐标系,3D 坐标一样,默认也是 x 向右,y 向上,z 向外,使用 -z 轴为正前方朝向;但是它屏幕映射的坐标系,原点放在左上角;

1-9.jpg

1.1.2 unity 等的左手坐标系

unity 是笛卡尔左手坐标系,默认 x 向右,y 向上,z 向里,使用 +z 轴为正前方朝向,如上面图左边;

所以 3D 空间中跟 cocos、threejs 等标准 webgl 库相比,要翻转 Z 轴,即 z * -1; 左手坐标系进行屏幕映射的坐标系,原点在左上角;

1.1.3 左右手坐标系相互转换

左右手坐标系可以相互转换,只需翻转一个轴的符号,看下面的 gif 图;

1-4.GIF

1.1.4 坐标系影响旋转

旋转也遵循对应左右手法则,伸出双手,做一个点赞 👍 的手势,拇指指向朝向\color{red}{朝向},四指握着的方向为正。

1-3.jpg

1.2 世界坐标系

世界坐标系(World Coordinate)是一个特殊的坐标系,它建立了我们所关心的最大的空间。

世界坐标系建立了描述其他坐标系所需要的参考框架。也就是说,能够用世界坐标系描述其他坐标系的位置,而不能用更大的、外部的坐标系来描述世界坐标系。

1-5.jpg (摘自龙书第五章)

世界坐标系也被广泛称作全局坐标系、宇宙坐标系、绝对坐标系。

1.2.1 关于世界坐标系的典型问题

基本都是关于初始位置和环境:

  • 每个物体的位置和方向
  • 摄像机的位置和方向
  • 世界中每一个点的地形是什么
  • 各个物体从哪里来,到哪里去(NPC 运动策略)

1.3 物体坐标系

也叫:物体坐标系、本地坐标系、模型坐标系、局部坐标系

物体坐标系(Local Coordinate)是和特定物体相关的坐标系。每个物体都有它们独立的坐标系,当物体移动或改变方向时,和物体相关的坐标系也会改变。

某些情况下,物体坐标系也称为模型坐标系,因为模型顶点的坐标是在模型坐标中描述的。

1-6.jpg

1.3.1 物体坐标系中可能遇到的问题

  • 周围有相互作用的物体吗?
  • 哪个方向?在我的前面后面?左边?右边?

1.3.2 注意

1.一般渲染引擎中,节点之间有父子关系的层级结构,通过修改节点的 Position 属性设定的节点位置是该节点相对于父节点的 本地坐标系,而非世界坐标系。

2.3D 美工在构筑 3D 对象时是基于模型坐标系的。

他们并不会在全局场景坐标系(即世界空间,也译为世界坐标系,world space)中构建物体的几何形状,而是相对于局部坐标系(局部空间,也译为局部坐标系,local space)来创建物体。

1.4 摄像机坐标系

摄像机坐标系可被看作是一种特殊的物体坐标系\color{red}{特殊的物体坐标系},该物体坐标系就定义在摄像机的屏幕可见区域。

为摄像机赋予一个局部坐标系(这被称作观察空间(view space),也译作观察坐标系、视图空间、视觉空间(eye space)或摄像机空间(camera space))

1-7.jpg

1.4.1 关于摄像机坐标系的一些典型问题:

  • 3D 空间中的某个点是在摄像机的前方吗?
  • 3D 空间中的某个点是在屏幕上,还是超出了摄像机平截面锥体的边界?(裁剪坐标系)
  • 某个物体是否在屏幕上?是部分在,还是全部不在?
  • 两个物体,谁在前面?(可见性检测)

1.4.2 可视范围

相机的可视范围是通过 6 个平面组成一个视锥体(Frustum\color{red}{视锥体(Frustum)} 构成,近裁剪面(Near Plane) 和 远裁剪面(Far Plane) 用于控制近处和远处的可视距离与范围,同时它们也构成了视口的大小。

1-8.jpg

1.5 坐标系转换

坐标变换:知道某一点的坐标,怎样在另一个坐标系中描述该点。该点并没有真正移动,而是在不同的坐标系中描述它的位置。

通常,坐标系间的变化会用矩阵\color{red}{矩阵} 表示,下面第三大点会详细介绍;

将局部坐标系内的坐标转换到全局场景坐标系中的过程叫作世界变换(worldtransform\color{red}{世界变换(world transform)} ,所使用的变换矩阵名为世界矩阵(worldmatrix\color{red}{世界矩阵(world matrix)}

相反的,从全局坐标转到局部坐标,就用世界矩阵的逆!

1-10.jpg

由世界空间至观察空间的坐标变换称为取景变换\color{red}{取景变换}(view transform,也译作观察变换、视图变换等),此变换所用的矩阵则称为观察矩阵\color{red}{观察矩阵}(view matrix,亦译作视图矩阵)。

摄像机也是属于局部坐标的一种,也就是说,观察矩阵 等同于 相机的世界矩阵的逆!

1-11.jpg

1.5.1 举个例子 🌰

场景中的有两个节点 A、B,他们各自有一个子节点 A0、B0,求其中一个子节点 B0 在另一个子节点 A0 的局部坐标系中的坐标。

分三步走,解决上述例子:

  • 求出 B0 的世界坐标
  • 求出 A0 的世界矩阵的逆
  • 将上面两步的结果相乘

1.6 复平面

扩展一下,在复平面中,有一个实轴和一个虚轴,如下图所示。

1.复数 a+bi ,有一个实部和一个虚部,看做复平面的点(a,b)

复数会在下面第四大点-四元素中用到;

2.复数也可以看做一个列向量\color{red}{列向量}

[a b] \begin{bmatrix} a \\\ b \end{bmatrix}

这样复数乘积的几何意义,可以理解为是旋转与缩放,会在下面第三大点矩阵中详细介绍。

1-12.jpg

二.向量

2.1 基本定义

向量(vector)描述了方向和大小。

向量在不同学科眼里是不同的东西,甚至于它可以是任何东西,只要保证其 相加和数乘 有意义即可,因为只需要这 2 种运算,就可以到达空间内的任何一点。

2-0-1.jpg

2-0-2.jpg

2.2 相加

向量相加的含义是,路径沿 P1\vec{P1}P2\vec{P2} 进行绘画,则 P2 坐标点为 P1\vec{P1}P2\vec{P2} 两个向量相加,为[x1+x2, y1+y2]。如下图

2-0-4.jpg

2.3 相减

如图

2-0-5.jpg

2.4 点乘

得到结果是标量\color{red}{标量},也叫数量积或内积

2.4.1 公式

2-0-6.jpg

三角函数公式表示:

2-0-7.jpg

2.4.2 点积的几何意义

1)a\vec{a} 向量乘以 b\vec{b} 向量在 a\vec{a} 向量上的投影分量,如下图所示

2)向量归一化(长度为 1)后,可以利用点乘求得向量之间的夹角\color{red}{夹角}

2-0-8.jpg

2.5 叉乘

结果是向量\color{red}{向量},也叫向量积或外积

只有 3D 向量的叉积才有定义,不存在 2D 向量叉积,2D 向量是把 Z 分量设置为 0;

2.5.1 公式

2-0-9.jpg

公式很难记,有个口诀,摘自《游戏编程与算法》

2-0-10.jpg

2.5.2 反交换律

a\vec{a} X b\vec{b} = - b\vec{b} X a\vec{a}

2.5.3 叉乘的模长

|a X b| = |a||b| sinθ

2.5.4 叉积的方向

右手坐标系,伸开右手掌,除了拇指之外 4个手指指向a矢量\color{red}{4 个手指指向 a 矢量}的方向,把他们向内曲指向b矢量\color{red}{向内曲指向 b 矢量}的方向,拇指方向\color{red}{拇指方向}就是叉积的方向;

注意: 如果下图 ab 向量的夹角在0180\color{red}{0 到 180 度 }之间,叉积方向向上\color{red}{向上},即叉积的 Z坐标大于0\color{red}{Z 坐标大于 0}; 如果夹角在 180 度到 360 度,叉积方向向下,即叉积的 Z 坐标小于 0;

2-0-11.jpg

2.5.5 叉积几何意义

1)三维空间中:

1.通过两个向量的外积,生成第三个垂直于 a 和 b 的法向量,从而构建 X、Y、Z 坐标系;

2.在三维空间中,每一个物体都有三个互相垂直的方向向量,即前方向量(Forward)、上方向量(Up)、右方向量(Right),通过两个根据叉乘法则可以求得第三个;

2)二维空间中:

叉积的模长,为两向量组成的面积,如下图所示。

2-0-12.jpg

2.6 线性组合、张成空间与基

在描述向量时,有一个我们用到了但是没注意的东西:向量的基\color{red}{向量的基}

我们都是默认了向量的基坐标为平面坐标系上 x 轴方向上的 i 和 y 方向上的 j。

2-0-3.jpg

通过这 2 个基,再利用数乘和相加\color{red}{数乘和相加},可以得到平面上的任何向量(三维可同理类推),这些向量称为基向量的线性组合,整个二维平面尽在掌握。

2.6.1 基坐标

那改变基坐标,是不是每一对基坐标都可以掌握整个平面呢?

不是。当 2 个基向量正好共线时,它们的线性组合永远都只能是一条线; 而当 2 个向量都为零向量时,它们的线性组合永远只能待在原点。

换成术语,我们将所有可以表示为给定基向量线性组合的向量的集合称为给定向量所张成的空间,对于大部分二维向量,它们张成的空间是整个二维平面,小部分情况下也可能是一条直线或者一个点。

2.6.2 如何选取基向量?

如果一个向量 c,可以由平面上 2 个向量 a 和 b 经过数乘和相加得到,那它就不会对张成的空间做出任何贡献,也就是 a、b、c 中至少有一个是可以去掉的,这时我们就会说 a、b、c 是线性相关\color{red}{线性相关}的; 而同时我们可以得出结论

空间向量的一组基是张成该空间的一个线性无关的向量集。

到这里我们可以发现,只要通过向量的数乘和相加,我们就可以张成整个对应维度的空间,也就是达到整个对应维度空间内的任意点。

2.6.3 特殊基向量

1.正交基:相互垂直条件;

2.正交规范基:单位长度;

三.矩阵

3.1 矩阵和函数

1.函数是将一个输入经过一些处理变为一个输出,如 y=f(x)。

2.向量的运算 a=L(b)也是如此

为什么我们不把向量的运算\color{red}{向量的运算}叫函数,而都是叫向量变换/矩阵变换\color{red}{向量变换/矩阵变换} 呢。

这提醒我们要用运动的思想看待向量的计算,或者换一种说法,矩阵/向量变换是对空间\color{red}{空间}的变换。

3.矩阵变换比函数简单

矩阵变换都是线性\color{red}{线性}变换,因为都是依赖向量数乘和相加\color{red}{向量数乘和相加}

3.2 线性变换

1.矩阵变换有两个特点

4-1.jpg

  • 原点不动
  • 直线依然是直线

2.下面这些都不是线性变换

4-2.jpg

4-3.jpg

注:仿射变换是线性变换的超集,是指线性变换后接着进行了平移;

商汤AR导航视觉建图和图商建图的转换矩阵就是这种;

4-4.jpg

3.3 基的线性变换

对于平面上的一个输入 b,我们该给计算机一个什么公式 L,才能得到 a=L(b)呢?

前面的 i、j 是构成二维平面最简单的一对基,关注对二维平面的变换时,只需要关注这对基的线性变换,我们就可以得到整个平面的线性变换,也就是公式 L。

3.3.1 变换 i 和 j

比如 i 从[1 0]变为[3 -2],j 从[0 1] 变为 [2 1],整个二维平面就发生了如下变化

4-5.jpg

我们将变化后的 2 个基向量按如下方式放入矩阵,这个矩阵 就表示了一次对二维空间的变换,也就是公式 L,其中的 2 列分别为经过变换后的 i 和 j。

4-6.jpg

理解下面这点至关重要,是理解所有后续操作的基础

矩阵是一次对空间的变换,且其中2列分别为经过变换后的基向量ij\color{red}{矩阵是一次对空间的变换,且其中 2 列分别为经过变换后的基向量 i 和 j}

3.4 矩阵向量相乘

如果你想知道一个向量 b,比如[5 7],经过线性变化后的位置,也就是 L(b)的位置,则只需要计算 5i + 7j 即可。

4-7.jpg

抽象后:

4-8.jpg

以上就得到了矩阵向量向量相乘的几何意义:

一个向量[x y]左乘一个矩阵A得到的值的几何意义是:一个向量 \begin{bmatrix} x \\\ y \end{bmatrix} 左乘一个矩阵 A 得到的值的几何意义是:

这个向量在新的空间(经过矩阵变换后的空间)里的位置。

3.5 矩阵乘法

矩阵是对一个空间的变换,那如果我们想相继对空间进行多次变换呢?

此时,我们直接描述整个平面的变换,如下图

先对平面进行M1变换,i变为[e g],j变为[f h]然后进行M2变换,M2i[a c],j[b d]先对平面进行 M1 变换,i 变为 \begin{bmatrix} e \\\ g \end{bmatrix} , j 变为 \begin{bmatrix} f \\\ h \end{bmatrix} 然后进行 M2 变换, M2 的 i 为 \begin{bmatrix} a \\\ c \end{bmatrix} , j 为 \begin{bmatrix} b \\\ d \end{bmatrix}

此时 i\vec{i} 变为 M2 * i\vec{i}, j\vec{j} 变为 M2 * j\vec{j}

4-9.jpg

4-10.jpg

以上的过程也就得到了矩阵的乘法定义,即

4-11.jpg

再也不用像大学那时候死记硬背了,理解背后的几何意义--对空间的多次相继变换,会对矩阵又更好的理解;

3.5.1 乘法结合律和交换律

  • 矩阵满足结合律,不满足交换律

3.5.1.1 结合律

M3M2M1 = M3(M2M1)

等式左边表示:先进行 M1 变换,再进行 M2 变换,再进行 M3 变换;

等式右边表示:先进行 M1 变换,再进行 M2 变换,再进行 M3 变换;

没有任何差别,显而易见

3.5.1.2 交换律

M2M1 != M1M2

等式左边表示:先进行 M1 变换,再进行 M2 变换;

等式右边表示:先进行 M2 变换,再进行 M1 变换;

这一过程我们无需数值证明,只需要在脑海里想象一下,就可以知道交换律不成立,也就是空间变换的顺序不是随意的。

当然,数值计算下也可以很简单的得出这一结论:

4-12.jpg

注意: 矩阵的运算顺序是从右往左\color{red}{从右往左},可能不太符合常规习惯。不过想一想前面提到的,矩阵运算其实就是对空间进行函数运算,再结合复合 f(g(x))\color{red}{f(g(x))}的写法:先计算 g(x),再计算 f(g(x)),就不难理解了。

3.5.2 左乘右乘

1.应用场景 相对于\color{red}{静}坐标系(变化过程中参考的坐标系始终不变):使用 左乘。

相对于\color{red}{动}坐标系(新坐标系,或者以叫以自身为参考系):使用 右乘;

机器人正向运动学的 D-H变换矩阵就是相对运动坐标系,使用的是右乘;

2.行变换、列变换

对矩阵 A 而言

BxA(左乘)是对 A 进行\color{red}{行} 变换

AxB(右乘)是对 A 进行 \color{red}{列}变换

3.5.3 行主序和列主序

在计算机存储中,矩阵有两种存储方式,一种是“行主序(row-major order)/行优先”,另一种就是“列主序(column-major order)/列优先”。

Direct3D 是采用行主序进行存储的,而 OpenGL、webGl 则是采用列主序进行存储;

列主序,使用右乘(列变换)的效率更高;

行主序,使用左乘(行变换)的效率更高;

注意: Threejs 是列主序,Laya 是行主序,但是不影响使用,api 和源码中对矩阵的操作处理也是没什么比较大的差别,具体后面“使用案例”模块进行了对比;

4-20-0.jpg

4-20.jpg

下面摘自《WebGL 编程指南》 GLSL 矩阵部分

4-21.jpg

3.6 行列式

行列式的计算规则为det([ab cd])=adbc,这一值代表着什么呢?行列式的计算规则为 det( \begin{bmatrix} a & b\\\ c & d \end{bmatrix} ) = ad-bc,这一值代表着什么呢?

3.6.1 先假设 c 和 b 均为 0

矩阵对应的空间变换为:i 变为 ai,j 变为 dj,行列式的值是 ad,看起来表示空间上一个单位面积被缩放的倍数。

4-13.jpg

3.6.2 再恢复 b 不为 0

i 变为 ai, j 变为 bi + dj, 行列式的值依然是 ad,是平行四边形的面积

4-14.jpg

3.6.3 再恢复 c 不为 0

i 变为 ai+cj, j 变为 bi + dj, 变换如下图,计算下

4-15.jpg

行列式公式 = ad-bc

变换之后基向量围成的面积 = (a+b)(c+d)-ac-bd-2bc = ad-bc

3.6.4 行列式的几何意义

矩阵的行列式表示的是:

二维空间中,经过矩阵变换后,平面单位面积\color{red}{面积}的缩放倍数。

三维空间中,经过矩阵变换后,空间单位体积\color{red}{体积}的缩放倍数。

3.6.5 扩展

det(M2M1) = det(M2)det(M1)

经过 M1 变换后,空间被缩放了 det(M1)倍,紧接着经过 M2 变换,(假设将此时的空间想成单位空间),那么变换后的空间不就是缩放了 1* det(M2)倍,而这里的 1 是原本其实是 det(M1),也就是 det(M2M1)=det(M2)det(M1),似乎也是显而易见,不需要任何证明。

3.7 非方阵

先说结论:mn的矩阵是n>m维的映射\color{red}{ m*n 的矩阵是 n 维 -> m 维 的映射}

3.7.1 【2X3 两行三列】

三列表名输入空间有 3 个基向量(3 维),两行表明每一个基向量在变换后都用 2 个独立的坐标描述(2 维),是 3 维到 2 维的映射

4-18.jpg

4-19.jpg

3.7.2 【3X2 三行两列】

同理 3X2,两列表名输入空间有两个基向量(2 维),三行表明每一个基向量在变换后都用三个独立的坐标描述(3 维),是 2 维到 3 维的映射

4-17.jpg

四.四元素

4.1 四元素由来

1.欧拉角表示旋转,会有万像锁\color{red}{万像锁}问题,丢失一个自由度

3-1.jpg

(摘自《游戏编程算法与技巧》)

2.矩阵表示旋转的一些缺陷

(1)矩阵需要 9 个浮点值表示旋转,显然是冗余\color{red}{冗余}的,因为旋转只有 3 个自由度(DOF)--偏航角、俯仰角、滚动角

(2)用矢量矩阵乘法来旋转矢量,需要 3 个点积,即共 9 个乘数和 6 个加数(注意区分 被乘数、被加数)。希望找到一个新的旋转表示方式,加快\color{red}{加快}旋转运算

2-0-6.jpg

(3)游戏和图形学中, 已知两个旋转,想要平滑旋转\color{red}{平滑旋转}过度,需要找出很多中间旋转,使用矩阵比较困难

3.四元素其实是复数的推广和扩展(坐标系模块最后讲了一下)

爱尔兰数学家 威廉哈密顿(willliam Hamilton)最开始一直致力于寻找一种方法把复数从 2D 扩展到 3D。他认为新的复数应该有 1 个实部和俩个虚部。然而一直没有办法创造出一种两个虚部且有意义的复数。在 1843 年他去皇家爱尔兰学院演讲的路上,突然意识到应该有3个虚部\color{red}{3 个虚部}而不是 2 个,就把定义公式刻在桥上,四元素就诞生了。四元素最初是用于解决力学中的问题。 (摘自《3D数学基础:图形与游戏开发》)

4.2 四元素定义

有序实数四元组 q=(x,y,z,w)=(q1q_1,q2q_2,q3q_3,q4q_4)即为一个四元数。 通常简记作 q=(u\vec{u} ,w)=(x,y,z,w),称 u\vec{u}=(x,y,z)为虚部向量\color{red}{虚部向量},w 为实部\color{red}{实部}

4.3 四元素的几何意义

单位\color{red}{单位}四元数视为三维旋转

单位四元数即:q12q_1^2+q22q_2^2+q32q_3^2+q42q_4^2 = 1

单位四元素可以视觉化为三维矢量+第四维的标量坐标。

矢量部分 qvq_v 是旋转的单位轴 乘以 旋转半角\color{red}{半角}的正弦;

标量部分 qsq_s 是旋转半角的余弦。

所以单位四元素可写成

q=[qvqs]=[asin(θ/2)cos(θ/2)] q = \begin{bmatrix} q_v & q_s \end{bmatrix} = \begin{bmatrix} \vec{a} * sin(θ/2) & cos(θ/2) \end{bmatrix}

其中 a\vec{a} 是旋转轴方向的单位矢量,而 θ 为旋转角度。

也可以将 q 写成简单的 4 个元素矢量(引擎源码的一般表示方法):

q=[qxqyqzqw] q = \begin{bmatrix} q_x & q_y & q_z & q_w \end{bmatrix}

其中

qxq_x = q * vxv_x = axa_x * sin(θ/2)

qyq_y = q * vyv_y = aya_y * sin(θ/2)

qzq_z = q * vzv_z = aza_z * sin(θ/2)

qwq_w = q * vwv_w = cos(θ/2)

4.4 四元素的运算

因为四元素是复数的扩展,所以复数的基本运算它都有,比如:乘法、共轭、逆、模、叉乘、点乘等

不再具体展开了

4.5 各种旋转表达方式的对比

表达旋转的方式有很多,比如

欧拉角、3X3 矩阵、轴角、四元数、SQT 变换、对偶四元素、旋转和自由度等

下面以最常见的 矩阵、欧拉角、四元数, 三种方式进行对比

3-5.jpg

(摘自《3D 数学基础:图形与游戏开发》)

4.6 选择旋转方式的建议

1.欧拉角最易使用

能大大简化人机交互,包括键盘输入、调试效果; 不要以“优化”的名义牺牲掉易用性。

2.需要在坐标系之间进行向量转换

优先选择矩阵形式; 当然也可以用欧拉角作为方位主拷贝,同时维护一个旋转矩阵,同步更新。

3.需要大量保存方位数据,如动画的时候,使用欧拉角或四元数

欧拉角可以减少 25%的内存,但是它在转换到矩阵的时候要稍微慢一点; 如果动画数据嵌套在坐标系之间的连接,四元数应该是最好的选择。

4.平滑插值只能用四元数完成

如果选其他方式,需要转成四元素再进行插值,完成后再转回原来的形式;

4.7 构造四元素

4.7.1 旋转轴和旋转角

有了旋转轴和旋转角,就可以表示旋转了,四元数也可以通过这个构造出来。

3-2.jpg

/**
* @zh 根据旋转轴和旋转弧度计算四元数
*/
public static fromAxisAngle<Out extends IQuatLike, VecLike extends IVec3Like> (out: Out, axis: VecLike, rad: number) {
    rad = rad * 0.5; // 半角:数学公式推导出来的 
    const s = Math.sin(rad);
    out.x = s * axis.x;
    out.y = s * axis.y;
    out.z = s * axis.z;
    out.w = Math.cos(rad);
    return out;
}

4.7.2 本地坐标轴

根据该物体本地坐标轴(即物体坐标系)也能确定旋转。

/**
* @zh 根据本地坐标轴朝向计算四元数,默认三向量都已归一化且相互垂直
*/
public static fromAxes<Out extends IQuatLike, VecLike extends IVec3Like> (out: Out, xAxis: VecLike, yAxis: VecLike, zAxis: VecLike) {
    Mat3.set(m3_1,
        xAxis.x, xAxis.y, xAxis.z,
        yAxis.x, yAxis.y, yAxis.z,
        zAxis.x, zAxis.y, zAxis.z,
    );
    return Quat.normalize(out, Quat.fromMat3(out, m3_1));
}

4.7.3 视口和上方向

根据视口的前方向和上方向,先计算本地坐标轴的右向量,再算出本地坐标的上向量,最后再构造成四元数。

/**
* @zh 根据视口的前方向和上方向计算四元数
* @param view 视口面向的前方向,必须归一化
* @param up 视口的上方向,必须归一化,默认为 (0, 1, 0)
*/
public static fromViewUp<Out extends IQuatLike, VecLike extends IVec3Like> (out: Out, view: VecLike, up?: Vec3) {
    Mat3.fromViewUp(m3_1, view, up);
    return Quat.normalize(out, Quat.fromMat3(out, m3_1));
}

4.7.4 两向量间的最短路径旋转

可以用一个四元数表示两向量旋转的最短路径

3-3.jpg

4.7.5 矩阵/欧拉角

可以通过其他表示方法转换为四元数,比如矩阵、欧拉角

fromMat3、fromEuler

4.8 获取四元素相关信息

1.获取四元数的旋转轴和旋转弧度

getAxisAngle

2.获取 定义此四元数的坐标系 X、Y、Z 轴向量

toAxisX、toAxisY、toAxisZ

3.根据四元素计算欧拉角

(1)cocos 的 toEuler,返回角度 x, y 在 [-180, 180] 区间内, z 默认在 [-90, 90] 区间内,旋转顺序为 YZX

(2)laya 的 getYawPitchRoll

五.使用案例

5.1 向量

5.1.1 向量反射

已知: 入射向量,单位法线量,入射角与反射角相同

求: 反射向量

2-1.jpg

5.1.2 旋转 2D 角色

已知: 角色位置和朝向,目标位置

求: 角色往哪个方向旋转多少度可朝向目标位置

2-2.jpg

源码

Cocos Creator 中的 Vec2 使用 signAngle

// class Vec2
/**
* @en Get angle in radian between this and vector with direction.
* @zh 获取当前向量和指定向量之间的有符号角度。<br/>
* 有符号角度的取值范围为 (-180, 180],当前向量可以通过逆时针旋转有符号角度与指定向量同向。<br/>
* @param other specified vector
* @return The signed angle between the current vector and the specified vector (in radians); if there is a zero vector in the current vector and the specified vector, 0 is returned.
*/
public signAngle (other: Vec2) {
    const angle = this.angle(other);
    return this.cross(other) < 0 ? -angle : angle;
}

5.1.2.1 商汤 AR导航的 3DOF“指南针”箭头模型

2-2-1.jpg

1.挂在相机节点,固定在屏幕某个位置上,用了1个射线知识,其他都是向量计算;

2.旋转的逻辑,用的点乘和叉乘的知识;

5.1.3 判断多边形凹凸点

已知: 多边形的顶点坐标(逆时针,简易多边形)

求: 判断每个点的凹凸性

解: 巧用向量叉乘即可求解。在多边形裁剪图片中的切耳法用到了这个判断。

2-3.jpg

5.1.4 判断三角形内的点

已知: 三角形三个点,其中一个共面的点

求: 该点是否在三角形内

解: 可以通过叉乘计算点与线的位置关系判断出。

2-4.jpg

在 GAMES 103-02 中提到,也可用法向量判断。

2-5.jpg

5.1.5 前后左右

已知: 各个飞机的坐标和黑色飞机的朝向。

求: 其他飞机与黑色飞机前后左右的关系?

解答: 点乘 -> 前后,叉乘 -> 左右

2-6.jpg

5.1.6 折纸效果

在折纸效果中也涉及一些向量计算。

分割多边形的点。向量间的点积正好可以帮助我们判断夹角问题。

2-7.jpg

求对称点同样可以运用向量计算

1.求出该顶点与中点的向量

2.求出该点在触摸方向的单位向量的投影(点乘),这正好是距离的一半

3.求出对称点坐标(距离乘方向向量+起始点坐标)

2-8.jpg

5.1.7 矢量和平面

摘自《游戏编程精粹 2》中的 2.2 章节

已知:

起点 Pi 终点 Pf,平面单位法线向量 N 和面上的一个点 Ps

2-9.jpg

5.1.7.1 求:相对于面的高度(点乘)

2-10.jpg

5.1.7.2 求:直接与平面相交点

投影到法向量,相似三角形,已知 Pi 和 Pf

2-11.jpg

5.1.7.3 求:到交点的距离(两种方法)

2-12.jpg

5.1.7.4 计算反射点

2-13.jpg

5.2 四元素

5.2.1 角色朝向

问:已知当前点和下一个点,如何求出角色的朝向四元数?

解: 1.先算出前方向 2.根据视口上方向求出四元数

const cur_p = list[index - 1]; // 当前点
const next_p = list[index]; // 最终点
const quat_end = new Quat(); // 最终旋转四元数
const dir = next_p.clone().subtract(cur_p); // 前向量
Quat.fromViewUp(quat_end, dir.normalize(), v3(0, 1, 0)); // 根据视口的前方向和上方向计算四元数

5.2.2 平滑插值

已知:起始四元数和终点四元数,如何平滑旋转?

const tw = tween(this.node_bezier_role); // 使用tween动画
const quat_start = new Quat();
this.node_bezier_role.getRotation(quat_start); // 获取起始四元数
const quat_end = new Quat(); // 最终旋转四元数 假设已经算出
const quat_now = new Quat(); // 用一个中间变量
tw.to(
  0.2,
  {},
  {
    onUpdate: (target, ratio: number) => {
      // ratio : 0~1
      // 这里使用球面插值,旋转时不会出现变形
      quat_now.set(quat_start).slerp(quat_end, ratio);
      this.node_bezier_role.setRotation(quat_now);
    },
  }
);
tw.start();

5.2.3 触摸旋转

关键是求出旋转轴,下面示例处理的旋转轴在 xoy 这个平面上。

//  private onTouchMove(touch: Touch) {
const delta = touch.getDelta();

// 自转
// 这个物体模型‘锚点’在正中心效果比较好
// 垂直的轴,右手
//
//  旋转轴
//  ↑
//  -> 触摸方向
const axis = v3(-delta.y, delta.x, 0); //旋转轴,根据相似三角形求出
const rad = delta.length() * 1e-2; //旋转角度
const quat_cur = this.node_touch_rotation_role.getRotation(); //当前的四元数
Quat.rotateAround(this.__temp_quat, quat_cur, axis.normalize(), rad); //当面的四元数绕旋转轴旋转
this.node_touch_rotation_role.setRotation(this.__temp_quat);

5.2.4 绕轴旋转

问:已知旋转点、旋转轴、旋转角度,求旋转后的位置和朝向。

解: 朝向计算和触摸旋转类似,不细说。 讲讲如何计算旋转后的坐标。

1.先计算旋转点和当前位置点的向量(起始向量)

2.计算旋转四元数

3.计算起始向量旋转后的向量

4.计算旋转后的坐标点

3-4.jpg

//  private onTouchMove(touch: Touch) {
const delta = touch.getDelta();
// 绕轴转
// 这里选取轴朝上
const axis2 = Vec3.UP; //旋转轴
const rad2 = 1e-2 * delta.x; //旋转角度
// 计算坐标
const point = this.node_axi.worldPosition; //旋转点
const point_now = this.node_touch_axi_role.worldPosition; // 当前点的位置
// 算出坐标点的旋转四元数
Quat.fromAxisAngle(this.__temp_quat, axis2, rad2);
// 计算旋转点和现有点的向量
Vec3.subtract(this.__temp_v3, point_now, point);
// 计算旋转后的向量
Vec3.transformQuat(this.__temp_v3, this.__temp_v3, this.__temp_quat);
// 计算旋转后的点
Vec3.add(this.__temp_v3, point, this.__temp_v3);
this.node_touch_axi_role.setWorldPosition(this.__temp_v3);

// 计算朝向
// 这么旋转会按原始的朝向一起旋转
const quat_now = this.node_touch_axi_role.worldRotation;
Quat.rotateAround(this.__temp_quat, quat_now, axis2, rad2);
Quat.normalize(this.__temp_quat, this.__temp_quat);
this.node_touch_axi_role.setWorldRotation(this.__temp_quat);

5.3 矩阵 -- transform 中的 matrix

transform 里有 matrix 这个用法,大部分人应该都不理解里面每个参数的意义,从而导致死记很难记住。当然这也并不影响我们日常开发,因为我们几乎用不到。

不过理解了以上矩阵的变换,会发现根本不需要记,每个参数都很好理解,而一些我们常用的属性:rotate、translate 其实都是一次矩阵变换,都是对 matrix 的封装。

5.3.1 【基本格式】

transform: matrix(a, b, c, d, e, f);

其对应的矩阵为 A =

[ace bdf 001] \begin{bmatrix} a & c & e \\\ b & d & f \\\ 0 & 0 & 1 \end{bmatrix}

假设平面上的一点为(x,y),则经过变换后为

[ace bdf 001][x y 1]=[ax+cy+e bx+dy+f 1] \begin{bmatrix} a & c & e \\\ b & d & f \\\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\\ y \\\ 1 \end{bmatrix} = \begin{bmatrix} ax+cy+e \\\ bx+dy+f \\\ 1 \end{bmatrix}

回顾下,这里 A 为对平面(坐标系)的变换,其中(a,b)是变换后的基向量 i,(c,d)是变换后的基向量 j。

基于上面的理解,我们知道一个2D平面的变换,用 2X2 的矩阵就可以,也就是

[ac bd] \begin{bmatrix} a & c \\\ b & d \end{bmatrix}

为啥会多出 e 和 f 形成了 3X3 的矩阵,这个和 translate 有关,因此我们先来看下 translate。

5.3.2【translate】

translate 属性表示为元素的位移,也就是不涉及到对坐标系的变换\color{red}{不涉及到对坐标系的变换} ,因此改变参数中的 a、b、c、d 是没法做到的,我们需要增加一个单纯的位移维度,也就是对(x,y)平移(e,f)后坐标要变为(x+e,y+f),且同时我们要保持 2X2 矩阵功能不变;

因此保持 i 和 j 不变,我们可以得到如下计算

[10e 01f 001][x y 1]=[x+e y+f 1] \begin{bmatrix} 1 & 0 & e \\\ 0 & 1 & f \\\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\\ y \\\ 1 \end{bmatrix} = \begin{bmatrix} x+e \\\ y+f \\\ 1 \end{bmatrix}

也就得到了变换后的点(x+e,y+f),加上对平面的变换后,我们就可以得到以上的通用变换矩阵了

[ace bdf 001] \begin{bmatrix} a & c & e \\\ b & d & f \\\ 0 & 0 & 1 \end{bmatrix}

小结:translate 仅需用到参数 e 和 f,translate(e,f)对应 matrix(1,0,0,1,e,f)。

5.3.3【scale】

scale 属性表示对元素的缩放,也就是坐标系缩放了,但是不会变形; 即只进行矩阵的数乘变换,i 变为 mi,j 变为 nj,其中 m 和 n 为标量。 对应到矩阵上,即

[10 01]变换为[m0 0n]也就是[a0 0d] \begin{bmatrix} 1 & 0 \\\ 0 & 1 \end{bmatrix} 变换为 \begin{bmatrix} m & 0 \\\ 0 & n \end{bmatrix} 也就是 \begin{bmatrix} a & 0 \\\ 0 & d \end{bmatrix}

小结:scale 仅需用到参数 a 和 d,scale(a, d)对应 matrix(a,0,0,d,0,0)。

5.3.4【rotate】

rotate 属性表示对元素的旋转,这里就涉及到三角函数。 下面以右手坐标系为例;

2D平面默认 绕 Z 轴旋转 θ

i\vec{i} 向量 变为(cosθ,sinθ),j\vec{j} 向量 变为(-sinθ, cosθ) 对应到矩阵上,即

[10 01]变换为[cosθsinθ sinθcosθ] \begin{bmatrix} 1 & 0 \\\ 0 & 1 \end{bmatrix} 变换为 \begin{bmatrix} cosθ & -sinθ \\\ sinθ & cosθ \end{bmatrix}

4-16.jpg

小结:rotate 需用到参数 a、b、c 和 d,rotateZ(θ)对应 matrix(cosθ,sinθ,-sinθ,cosθ,0, 0)

5.3.5【组合使用】

问题:想要将元素缩小为 0.5 倍,且右移 50px,下面两种写法有区别吗?

.element {
  transform: translate(50px, 0) scale(0.5);
}
.element {
  transform: scale(0.5) translate(50px, 0);
}

上面我们提到,每次矩阵左乘是一次变换,多次变换就多次左乘即可。

假设 Translate 的矩阵为 T,Scale 的矩阵为 S,元素上的点矩阵为 E。

5.3.5.1 STE

先 T 再 S,对应写法 transform: translate(50px, 0) scale(0.5); 表示对元素先 Translate,后 Scale。

5.3.5.2 TSE

先 S 再 T,对应写法 transform: scale(0.5) translate(50px, 0); 表示对元素先 Scale,后 Translate。

上面说了矩阵乘法不满足交换律\color{red}{不满足交换律},也就是不相等,那说明 transform: translate(50px, 0) scale(0.5); 和 transform: scale(0.5) translate(50px, 0);是不一样的。

(1)先使用 scale 缩小到 0.5 倍之后,后续的 translate 的 50px 也缩小为了 0.5 倍,即 50*0.5=25px;

(2)先使用 translate,对于平面没有影响,因此对后续的 scale 没有影响

小结:

所以只要记住,transform 变换是针对当前元素所在的整个平面\color{red}{整个平面}的变换,而不是仅针对当前元素,我们就能正确的组合使用了;

而且组合使用实际只是矩阵相乘\color{red}{矩阵相乘},也可以连续使用相同的属性(不一定需要使用 calc),比如: (1)transform: translate(100%, 0) translate(50px, 0);,先右移 100%,再右移 50px (2)transform: scale(2) scale(3);先放大 2 倍,再基于此放大 3 倍,共 2*3=6 倍,而不是 2+3=5 倍。

5.3.6 【SRT 与 MV】

先看看每个字母的全称:

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

4-22.jpg

下面用 cocos creator 来做个实验,直接验证矩阵数学推理的正确性

开始前,先准备一小段代码,这段不是很重要,也可以复制到自己的工程预览。

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 = '悬笔e绝';

    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)}`);
            }
        }
    }
}

创建一个节点挂到场景中,开始观察 NodeMatrixInfo 属性

4-23.jpg

5.3.6.1 Scaling

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

4-24.jpg

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

4-25.jpg

通过观察得出,缩放矩阵形如

4-26.jpg

顺便观察逆矩阵!缩放逆矩阵形如

4-27.jpg

去 layaAir 和 cocos 的源码确认下,确实如此

4-28.jpg

5.3.6.2 Translation

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

4-41.jpg

得出位移矩阵

4-42.jpg

源码

4-43.jpg

5.3.6.3 Rotation

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

5.3.6.3.1 绕 Z 轴旋转θ

对应前面 5.3.4节 数学推导出的公式,发现是一致的

4-29.jpg

4-30.jpg

去源码确认下,没问题!

4-31.jpg

5.3.6.3.2 绕 Y 轴旋转 θ

4-32.jpg

4-33-1.jpg

4-33.jpg

4-34.jpg

5.3.6.3.3 绕 X 轴旋转 θ

4-35.jpg

4-36-1.jpg

4-36.jpg

4-37.jpg

5.3.6.3.4 旋转矩阵与本地坐标系的轴对上

上面对矩阵的理解那一块有分析过

4-38.jpg

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

4-39.jpg

4-40.jpg

5.3.6.4 Model

4-44-0.jpg

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

4-44.jpg

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

4-45.jpg

也就是说 Model 矩阵可以写成

M=[RST 01] M = \begin{bmatrix} RS & T \\\ 0 & 1 \end{bmatrix}

5.3.6.5 View

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

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

4-46.jpg

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

4-47.jpg

一般相机不含缩放,所以

4-48.jpg

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

4-49.jpg

5.3.6.6 商汤AR导航

视觉定位后更新slam,产生的3D模型跳变问题 缓解方案中,有一种方案 用到的就是世界坐标、相机坐标、屏幕坐标互相之间的转换;

结语

图形学、webgl相关数学知识的第一部分,先写到这里,后续有新内容会另写一篇

最后放上最近做的数字人demo

4-50.jpg

连接

参考资料

《3D数学基础:图形与游戏开发》 《游戏编程算法与技巧》 《游戏引擎架构》 《WebGL 编程指南》 《DIRECTX 12 3D游戏开发实战》 《游戏编程精粹 2》

坐标系: mp.weixin.qq.com/s?__biz=MzI…

向量: mp.weixin.qq.com/s?__biz=MzI…

四元数: mp.weixin.qq.com/s?__biz=MzI…

矩阵: mp.weixin.qq.com/s/bi1gOmUK_… mp.weixin.qq.com/s/_Lhcra5E8…

欢迎关注我的个人微信公众号:“悬笔e绝”