发布于 

《游戏引擎架构》笔记4:游戏所需的三维数学

本文是阅读《游戏引擎架构》第四章"游戏所需的三维数学”进行的归纳和总结,部分内容经过了AI的扩展。

  1. 点和矢量
  • 坐标系和基矢
  • 矢量运算:标量乘法、加法、减法、模、点乘、叉乘、插值。
  1. 矩阵
  • 矩阵运算:乘法、逆、转置
  1. 齐次变换
  • 齐次坐标
  • 基础变换矩阵:平移、旋转、缩放。
  • 坐标空间:模型空间、世界空间、观察空间。
  1. 变换坐标系和变换矢量问题(齐次坐标为列向量,和书不同)
  • 变换坐标系:不同坐标系同一点换参考系(点不动):坐标系A→坐标系B

Bp=ABTAp=[ABiABjABkABt0001]Ap\begin{aligned} {}^B\mathbf{p}&={}^B_A\mathbf{T}{}^A\mathbf{p} \\ &=\begin{bmatrix} {}_A^B\mathbf{i} & {}_A^B\mathbf{j} & {}_A^B\mathbf{k} & {}_A^B\mathbf{t} \\ 0 & 0 & 0 & 1 \end{bmatrix}{}^A\mathbf{p} \end{aligned}

其中,ABi{}_A^B\mathbf{i} 是坐标系A的x轴基矢在坐标系B中的表示,ABj{}_A^B\mathbf{j} 是坐标系A的y轴基矢在坐标系B中的表示,ABk{}_A^B\mathbf{k} 是坐标系A的z轴基矢在坐标系B中的表示,ABt{}_A^B\mathbf{t} 是坐标系A的原点在坐标系B中的表示(平移)。

  • 变换矢量:同一个坐标系点A变换到点B,等价于固定点然后把点A的坐标系逆变换到点B的坐标系。对法矢量则不同,需要乘以逆转置矩阵。
  1. 如何确定使用的引擎是使用行矢量还是列矢量,找变换矩阵中的平移向量是在第4行第1列(行矢量)还是第1行第4列(列矢量)。

  2. 四元数:q=[qVqS]=[asinθ2cosθ2]\mathbf{q} = \begin{bmatrix} \mathbf{q}_V & q_S \end{bmatrix} = \begin{bmatrix} \mathbf{a}\sin\frac{\theta}{2} & \cos\frac{\theta}{2} \end{bmatrix},其中,a\mathbf{a} 是旋转轴,θ\theta 是旋转角度。

  • 四元数之积:

q1q2=[pSqV+qSpV+pV×qVpSqSpVqV]\mathbf{q}_1\mathbf{q}_2 = \begin{bmatrix} p_S\mathbf{q}_V+ q_S\mathbf{p}_V + \mathbf{p}_V\times \mathbf{q}_V & p_Sq_S-\mathbf{p}_V\mathbf{q}_V \end{bmatrix}

  • 共轭四元数:

q=[qVqS]\mathbf{q}^* = \begin{bmatrix} -\mathbf{q}_V & q_S \end{bmatrix}

  • 逆四元数:

q1=qq2\mathbf{q}^{-1} = \frac{\mathbf{q}^*}{|\mathbf{q}|^2}

如果是单位四元数,那么

q1=q\mathbf{q}^{-1} = {\mathbf{q}^*}

  • 积的共轭及逆四元数:

(pq)=qp(\mathbf{p}\mathbf{q})^* = \mathbf{q}^*\mathbf{p}^*

(pq)1=q1p1(\mathbf{p}\mathbf{q})^{-1} = \mathbf{q}^{-1}\mathbf{p}^{-1}

  • 四元数旋转矢量:

v=rotate(q,v)=qvq1\mathbf{v}^\prime=\text{rotate}(\mathbf{q}, \mathbf{v})=\mathbf{q}\mathbf{v}\mathbf{q}^{-1}

如果是单元四元数,那么等价于:

v=rotate(q,v)=qvq\mathbf{v}^\prime=\text{rotate}(\mathbf{q}, \mathbf{v})=\mathbf{q}\mathbf{v}\mathbf{q}^{*}

  • 四元数变换旋转矩阵,若四元数q=[qVqS]=[xyzw]\mathbf{q}=\begin{bmatrix} \mathbf{q}_V & q_S \end{bmatrix}=\begin{bmatrix}x & y & z & w \end{bmatrix},则对应的旋转矩阵为:

R=[12y22z22xy2zw2xz+2yw2xy+2zw12x22z22yz2xw2xz2yw2yz+2xw12x22y2]\mathbf{R}=\begin{bmatrix} 1-2y^2-2z^2 & 2xy-2zw & 2xz+2yw \\ 2xy+2zw & 1-2x^2-2z^2 & 2yz-2xw \\ 2xz-2yw & 2yz+2xw & 1-2x^2-2y^2 \end{bmatrix}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void MatrixToQuaterion(
const float R[3][3],
float q[/*4*/]
)
{
float trace = R[0][0] + R[1][1] + R[2][2];
// 检测主轴
if (trace >= 0.0f)
{
float s = sqrt(trace + 1.0f);
q[3] = s * 0.5f;
float t = 0.5f / s;
q[0] = (R[2][1] - R[1][2]) * t;
q[1] = (R[0][2] - R[2][0]) * t;
q[2] = (R[1][0] - R[0][1]) * t;
}
else
{
int i = 0;
if (R[1][1] > R[0][0]) i = 1;
if (R[2][2] > R[i][i]) i = 2;
const int j = (i + 1) % 3;
const int k = (i + 2) % 3;
float s = sqrt(R[i][i] - R[j][j] - R[k][k] + 1.0f);
q[i] = s * 0.5f;
float t = 0.5f / s;
q[j] = (R[j][i] + R[i][j]) * t;
q[k] = (R[k][i] + R[i][k]) * t;
q[3] = (R[k][j] - R[j][k]) * t;
}
}
  • 四元数插值:

    • 线性插值,插值需要归一化,因为不保持矢量长度,旋转动画在两端慢,在中间会快:

    qLERP=LERP(qA,qB,t)=(1t)qA+tqB(1t)qA+tqB\mathbf{q}_{\text{LERP}}=\text{LERP}(\mathbf{q}_A, \mathbf{q}_B, t)=\frac{(1-t)\mathbf{q}_A+t\mathbf{q}_B}{|(1-t)\mathbf{q}_A+t\mathbf{q}_B|}

    • 球面线性插值,动画旋转匀速,但是计算代价昂贵,最好测试一下实现的效率,看是否值得使用:

qSLERP=SLERP(qA,qB,t)=wpqA+wqqB\mathbf{q}_{\text{SLERP}}=\text{SLERP}(\mathbf{q}_A, \mathbf{q}_B, t)=w_p\mathbf{q}_A+w_q\mathbf{q}_B

其中:

wp=sin(1t)θsinθ,wq=sintθsinθw_p=\frac{\sin(1-t)\theta}{\sin\theta},w_q=\frac{\sin t\theta}{\sin\theta}

θ\theta的计算为:

cosθ=qAqB=qAxqBx+qAyqBy+qAzqBz+qAwqBwθ=cos1(qAqB)\cos\theta=\mathbf{q}_A\cdot\mathbf{q}_B=q_{Ax}q_{Bx}+q_{Ay}q_{By}+q_{Az}q_{Bz}+q_{Aw}q_{Bw}\\ \theta=\cos^{-1}(\mathbf{q}_A\cdot\mathbf{q}_B)

  1. 各种旋转表达方式对比
  • 欧拉角:简单直观,单轴旋转容易插值,问题在于万向节死锁(旋转后和原来的轴重合失效),轴的旋转次序对结果有影响。
  • 旋转矩阵:独一无二地表示旋转,不直观,不容易插值,需要更多的存储空间。
  • 轴角:直观,不能简单插值,旋转不能直接施加在点或矢量,需要先转换为旋转矩阵或四元数。
  • 四元数:能串接旋转,把旋转直接施加在点和矢量,可以用LERP或SLERP进行旋转插值,存储小。
  • SQT变换:包括缩放因子,四元数和平移矢量,容易插值,插值时平移矢量和缩放因子使用LERP,四元数使用LERP或SLERP。
  • 对偶四元数:可以表示为两个普通四元数之和,所以有8个元素,分为非偶部和对偶部,q^=qA+εqB,ε2=0,ε0\hat{\mathbf{q}}=\mathbf{q}_A+\varepsilon\mathbf{q}_B,\varepsilon^2=0,\varepsilon\neq0
  1. 其他数学对象
  • 直线、光线和线段,沿直线方向的矢量参数方程表示
  • 球体:中心点坐标和半径四个参数表示
  • 平面:参数方程。
  • 轴对齐包围盒(AABB).
  • 定向包围盒(OBB)。
  • 平截头体。
  • 凸多面体区域。
  1. SIMD计算
  • 不要把FPU寄存器和SSE寄存器混用传输数据,会导致CPU流水线停顿性能下降。数学库尽可能把数据保存到SSE寄存器。
  • __m128数据类型,置于SSE寄存器中,用于SIMD计算。
    • gcc用vector float
      1
      vector float v = (vector float) {1.0f, 2.0f, 3.0f, 4.0f};
    • Visual Studio用_mm_set_ps初始化:
      1
      __m128 v = _mm_set_ps(1.0f, 2.0f, 3.0f, 4.0f);
  • 注意__m128数据类型需要16字节内存对齐。
  • SSE内部函数的编码:
    • cpp需要#include <xmmintrin.h>
    • 例如addps可以有内联汇编和内部代码两种实现:
      • 内联汇编代码:
      1
      2
      3
      4
      5
      __m128 addWithAssembly(const __m128 a, const __m128 b)
      {
      // 不需要return, a和b分别存储在xmm0和xmm1寄存器中
      __asm addps xmm0, xmm1
      }
      • 内部函数代码:
      1
      2
      3
      4
      __m128 addWithIntrinsics(const __m128 a, const __m128 b)
      {
      return _mm_add_ps(a, b);
      }
    • 内联汇编需要依靠编译器的约定知识,编写函数困难,而且编译器不能做出任何假设,限制编译器的优化能力,最后的代码不可移植,内部函数直观清晰可移植,方便编译器优化代码。所以尽量使用内部函数。
    • float数组载入__m128变量要用__declspec(align(16))对齐,否则可能程序崩溃或性能降低。为了加强理解,可以做一下书上的198-199页的例子,另外书本还给了SSE实现矢量和矩阵相乘的例子,可以复现一下。
  1. 随机数