累积矩阵 Accumulating Matrices

讨论层级矩阵累积、非均匀缩放导致的 skew,以及矩阵分解修正方案。

累积矩阵 Accumulating Matrices

从概念上来说,累积变换并最终得到 世界变换 最简单的方式就是链式的一路递归相乘它们各自父节点的变换矩阵,经典的图形程序大多都是这样操作的

Matrix ToMatrix(Transform transform) {
    // First, extract the rotation basis of the transform
    // 也可以直接使用 四元数公式展开法 直接得到旋转矩阵
    Vector x = Vector(1, 0, 0) * transform.rotation; // Vec3 * Quat (right vector)
    Vector y = Vector(0, 1, 0) * transform.rotation; // Vec3 * Quat (up vector)
    Vector z = Vector(0, 0, 1) * transform.rotation; // Vec3 * Quat (forward vector)
    
    // Next, scale the basis vectors
    // 如果上一步使用了 公式展开 此处应该构造缩放矩阵
    x = x * transform.scale.x; // Vector * float
    y = y * transform.scale.y; // Vector * float
    z = z * transform.scale.z; // Vector * float
 
    // Extract the position of the transform
    Vector t = transform.position;
 
    // Create matrix
    return Matrix(
        x.x, x.y, x.z, 0, // X basis (& Scale)
        y.x, y.y, y.z, 0, // Y basis (& scale)
        z.x, z.y, z.z, 0, // Z basis (& scale)
        t.x, t.y, t.z, 1  // Position
    );
}
 
Matrix GetWorldMatrix(Transform t) {
    Matrix local = ToMatrix(t);       // 由 pos/rot/scale 组装
    if(t.parent)                      // 递归到根
        return local * GetWorldMatrix(t.parent);
    return local;
}

注意,这里使用了 行向量-左乘体系 row vector / left multiply

约定向量写法组合规则(局部 → 世界)
列向量 / 右乘
v' = M · v
世界矩阵 = Parent · Local先算父节点,再右乘子节点
行向量 / 左乘
v' = v · M
世界矩阵 = Local · Parent先算子节点,再左乘父节点

常见列向量的变换组合顺序是 T · R · S;行向量是 S · R · T 但是本质上都是先 缩放,再 旋转,最后 位移

从表面上看,该代码按预期运行,但一旦引入 非均匀缩放,马上就会产生问题,例如如果我们有如下层级关系的变换

  • Transform A
    • position: (0, 0, 0)
    • rotation: (0, 0, 0)
    • scale: (2, 1, 1) 非均匀缩放
    • Transform B
      • position: (2, 2, 0)
      • rotation: (0, 0, 38)
      • scale: (1, 1, 1)
  • Transform C
    • position: (0, 0, 0)
    • rotation: (0, 0, 0)
    • scale: (1, 1, 1)

当我们旋转 B,本该绕 Z 轴转,却变成 倾斜 / 剪切(Skew)

父节点的非均匀缩放会把子节点的局部正交基带入非正交世界基

Animation showing a child rectangle rotating normally before nonuniform parent scale makes the same rotation appear skewed.
父节点开启非均匀缩放后,同一个子长方形旋转会从刚体旋转变成剪切。

这是因为 A非均匀缩放 (2,1,1) 将它的 局部矩阵33基向量 拉伸成不等长,它的子对象 B 在计算自己的 世界变换 时,拿到的就是这样的 非正交矩阵,这将导致 “旋转 + 剪切” 参杂在一起,而如果 B 还有更多的后代,那么任何后代节点都会继承这个 非正交基向量,动作越多畸变越严重

解决 Skew

不存在 “便宜” 的 one-liner 修复法,两个常见的手段是

  • 纯 TRS 链路
    不直接存储 4×44\times 4 矩阵,每帧用 position / quaternion / scale 重新拼矩阵,这并不是简单的分开存储它们(我们已经这么做了),而是在整条递归链路上分开累积它们,并最终在送往 GPU 时才将它们合并为矩阵(因为 GPU 需要矩阵)
  • 矩阵分解
    将父变换的 4×44\times 4 矩阵进行 矩阵分解,重新组合
传统方案(矩阵链乘)纯 TRS 方案
层级累积每级都把 T·R·S 先拼成 M_local, 然后 M_world = parent M_world × M_local每级分别把 pos / quat / scale 递推到世界:
worldPos = pPos + pRot*(pScale ⊙ pos)
worldQuat = pQuat × quat
worldScale = pScale ⊙ scale
链路中保存始终是 4 × 4 矩阵(T+R+S+Shear 都混在一起)始终是 三个独立分量流,链路里从不出现 4×4
GPU 前一步直接把父级递推得到的 M_world 写进 UBO / SSBO / constant buffer到达渲染阶段才一次性
M_world = T(worldPos)·R(worldQuat)·S(worldScale)
副作用父级若含非均匀缩放,再被子级旋转→易产生 Skew / Shear几乎不产生剪切;数值稳定;读取/写入局部 TRS 便宜

也就是说,矩阵链式相乘

Mworld=MparentMchild=TpRpSpTcRcSc\mathbf{M}_{\text{world}} = \mathbf{M}_{\text{parent}}\mathbf{M}_{\text{child}} = \mathbf{T}_{\text{p}}\mathbf{R}_{\text{p}}\mathbf{S}_{\text{p}}\mathbf{T}_{\text{c}}\mathbf{R}_{\text{c}}\mathbf{S}_{\text{c}}

TRS 链

  1. 整条层级链只递推 世界平移 PwP_w世界旋转 RwR_w世界缩放 SwS_w

    Pw=Pp+Rp(SpPc)Rw=RpRcSw=SpSc\begin{aligned} P_w &= P_p + R_p \,(S_p \odot P_c)\\ R_w &= R_p \, R_c\\ S_w &= S_p \odot S_c \end{aligned}

    这里的 \odot 是按分量相乘(Hadamard)。

  2. 最后一刻才把这三样重新排成

    Mworld=T(Pw)  R(Rw)  S(Sw)(3)M_{\text{world}} = T(P_w)\;R(R_w)\;S(S_w) \tag{3}

可以发现,它是 分量递推 的形式,与 矩阵链式相乘 再分解是等价的

步骤纯 TRS 递推乘-分解
局部→世界平移Pw=Pp+Rp(Sp ⁣ ⁣Pc)P_w = P_p + R_p(S_p\!\odot\!P_c)乘完再取分解的 T 项,结果相同(分解把平移直接读出)
局部→世界旋转Rw=RpRcR_w = R_p R_c剪切被投影掉,分解后的 R 即为正交矩阵 RpRcR_p R_c
局部→世界缩放Sw=Sp ⁣ ⁣ScS_w = S_p\!\odot\!S_c分解提取的对角块就是各向缩放,同样为分量相乘

它们都丢弃了 剪切变换

矩阵分解 Matrix Decomposition

直接把父子矩阵 链式相乘 的这种方法虽然很省事,但一旦父节点有 非均匀缩放,子节点做旋转就将导致 剪切伪影,子元素的旋转变换会导致其 倾斜 (skew) 而不是旋转

解决这个 非均匀缩放 问题的唯一方法是通过 矩阵分解,其思路是,将最终存在 倾斜 skew 问题 的矩阵分解为 位置旋转缩放倾斜 分量,分解完所有这些分量后,忽略 倾斜,创建一个仅包含 位置旋转缩放 的新矩阵

这种类型的分解在 ShoemakeDuff 的论文 Matrix Animation and Polar Decomposition 以及 ShoemakeGraphics Gems IV 的章节 Polar Matrix Decomposition 中均有描述

显然矩阵分解是一项昂贵的操作,对性能有重大影响

这种方法拥有 开源代码,它的接口看起来像这样

typedef struct {
    HVect t;  /* Translation components */
    Quat  q;  /* Essential rotation   */
    Quat  u;  /* Stretch rotation   */
    HVect k;  /* Stretch factors    */
    float f;  /* Sign of determinant    */
} AffineParts;
 
void decomp_affine(HMatrix A, AffineParts *parts);

我们可以通过调用 decomp_affine 函数从变换矩阵中得到不含 剪切 shearing位移 t旋转 q缩放 k,这些值可以用于构建一个新的变换矩阵

Matrix GetWorldMatrix(Transform transform) {
    Matrix localMatrix = ToMatrix(transform);
    Matrix worldMatrix = localMatrix;
    
    if (transform.parent != NULL) {
        Matrix parentMatrix = GetWorldMatrix(transform.parent);
        worldMatrix = localMatrix * parentMatrix;
 
        if (ContainsNonUniformScale(transform.parent)) {
            AffineParts decomp = decomp_affine(worldMatrix, &decomp);
 
            Transform temp;
            temp.parent = NULL;
            temp.position = decomp.t;
            temp.scale = decomp.k;
            temp.rotation = decomp.q;
        
            worldMatrix = ToMatrix(temp);  
        }  
    }
 
    return worldMatrix;
}

使用上述代码,我们可以修复 倾斜伪影,使旋转按预期工作

Animation comparing a direct affine matrix with the same transform reconstructed from shear, scale, and rotation.
直接使用矩阵与按 shear、scale、rotation 分解后重组,得到的是同一个线性作用。

decomp_affine 函数的内容是这样的,我们来看看它如何将 4×44\times 4齐次变换矩阵 分解为独立的几部分

void decomp_affine(HMatrix A, AffineParts *parts) {
    HMatrix Q, S, U;
    Quat p;
    float det;
    parts->t = Qt_(A[X][W], A[Y][W], A[Z][W], 0);
    det = polar_decomp(A, Q, S);
    if (det<0.f) {
        mat_copy(Q,=,-Q,3);
        parts->f = -1;
    } 
    else {
        parts->f = 1;
    }
    parts->q = Qt_FromMatrix(Q);
    parts->k = spect_decomp(S, U);
    parts->u = Qt_FromMatrix(U);
    p = snuggle(parts->u, &parts->k);
    parts->u = Qt_Mul(parts->u, p);
}
代码行作用结果写进 AffineParts 的字段
parts->t = Qt_(A[X][W] … )直接把矩阵最后一列(平移分量)拷出来t(平移向量)
det = polar_decomp(A, Q, S)对原矩阵做极分解:
 • Q 得到正交矩阵(含旋转+可能的翻转)
 • S 得到对称矩阵(含缩放+剪切)
同时返回行列式 det
-
det<0行列式 < 0 说明 Q 带镜像翻转,需要把它整反(乘以 –1)并记 f = –1;否则 f = 1f(是否带翻转)
parts->q = Qt_FromMatrix(Q)把整理好的正交矩阵 Q 转成四元数q(纯旋转)
parts->k = spect_decomp(S, U)S特征分解,剥离剪切:
 • 得到本征值 → 各轴缩放向量 k
 • 得到本征向量矩阵 U → “附加旋转”
k(缩放)
parts->u = Qt_FromMatrix(U)U 转为四元数u(附加旋转)
p = snuggle(u,&k); u = Qt_Mul(u,p)ku 这对值有 24 种等价写法,snuggle 调整它们,使后续插值连续稳定;u 最后再右乘一次校正用的四元数 pu、修正后的 k

极分解 把任意可逆矩阵拆成「正交 * 对称」两部分,是去掉剪切的常用办法

  • 对称矩阵: 继续执行 特征分解缩放(特征值) 与剩余剪切分开,并把 缩放 值挤进向量 k
  • 正交矩阵:通过检测其 行列式正负性 来判定是否需要把旋转 反转,如果需要反转,则把符号记录到 f

最终还需要经过 snuggle 调整,因为 ku 不是唯一的,需要选一组“连贯”的值,避免动画插值时突然跳轴或符号颠倒,snuggle 直接修改 k 并返回一个需要与 u 相乘的 四元数,不过在我们的用例里,我们并不关心 u,也不会对它进行插值,所以当 k 被矫正以后,我们就得到了所有想要的

最终我们得到

  • t —— 纯平移
  • q —— 纯旋转(四元数,已经排除了镜像)
  • k —— XYZ 三轴缩放系数
  • u —— 与缩放相关的补偿旋转(通常在做插值时一起参与计算)
  • f —— ±1,记录有没有镜像翻转

这个过程在动画、骨骼绑定、形变空间插值(Dual Quaternion / DQS 等)里都很有用

分解过程的数学分析

假设我们想进行上述 仿射变换 affinate transformation 分解并将它存储至矩阵 M\mathbf{M},我们可以将 M\mathbf{M} 分解为一个 线性变换 linear transformation 和一个 位移 translation 的形式

分解位移

分解位移是最容易的,首先设 T=M\mathbf{T} = \mathbf{M},然后将 T\mathbf{T} 除了最后一列之外全部置零,然后设 X=M\mathbf{X} = \mathbf{M},但是这次我们只将最后一列代表 位移 的部分置零(和之前正好相反),如此一来,我们得到分解

M=TX\mathbf{M} = \mathbf{T}\mathbf{X}

列主序(column-major)+ 列向量后乘(post-multiplication),OpenGL/GLM 最常用的约定,4×44\times 4 齐次变换矩阵平移分量 必须放在最右侧那一列

典型环境向量写法乘法顺序位移在几列(行)
列向量 / 后乘OpenGL、GLM、Shader GLSLv=[xyz1]\mathbf{v}=\begin{bmatrix}x\\y\\z\\1\end{bmatrix}v=Mv\mathbf{v}'=M\mathbf{v}最后一列 (Songho)
行向量 / 先乘DirectX(旧)、某些游戏引擎v=[xyz1]\mathbf{v}=\begin{bmatrix}x&y&z&1\end{bmatrix}v=vM\mathbf{v}'=\mathbf{v}M最后一行 (Stack Overflow)

二者在数学结果上等价;只是把“基向量”和“位移向量”存成列还是行,及乘法顺序互为转置而已

我们得到只含 位移平移矩阵 T\mathbf{T},以及不含 平移 矩阵的 X\mathbf{X}

极分解 Polar Decomposition

接着,我们对上一步得到 X\mathbf{X}极分解

Polar decomposition diagram showing an input basis transformed first by the symmetric stretch and shear S, then by the orthogonal matrix Q to reconstruct X equals Q S.
极分解把线性部分写成先对称形变,再正交变换:X = Q S。
X=QS\mathbf{X} = \mathbf{Q}\mathbf{S}
  • Q\mathbf{Q}正交矩阵,描述了 旋转 或者 旋转+镜像翻转
  • S\mathbf{S}对称正定矩阵,描述了 缩放和剪切,也就是 形变

X\mathbf{X} 极分解 结果代入上一步,得到

M=TX=TQS\mathbf{M} = \mathbf{T}\mathbf{X} = \mathbf{T}\mathbf{Q}\mathbf{S}

Q\mathbf{Q} 中识别镜像翻转

我们可以根据 行列式 来判断 旋转矩阵 Q\mathbf{Q} 中是否含有 镜像翻转

detQ>0    纯旋转detQ<0    旋转+镜像\begin{align*} \det{\mathbf{Q}} > 0 &\implies \text{纯旋转} \\ \det{\mathbf{Q}} < 0 &\implies \text{旋转} + \text{镜像} \end{align*}

所以我们可以对 Q\mathbf{Q} 执行分解

Q=FR\mathbf{Q} = \mathbf{F}\mathbf{R}
  • F=detQI\mathbf{F} = \det{\mathbf{Q}\mathbf{I}},所以 F=±I\mathbf{F} = \pm\mathbf{I} 表示 不翻转/翻转
  • R\mathbf{R}纯旋转

再次回代,得到

M=TQS=TFRS\mathbf{M} = \mathbf{T}\mathbf{Q}\mathbf{S} = \mathbf{T}\mathbf{F}\mathbf{R}\mathbf{S}

谱分解 (特征分解) Spectral / Eigen Decomposition

对称矩阵 S\mathbf{S} 包含了 缩放剪切,我们对它进行 特征分解

S=UKUT\mathbf{S} = \mathbf{U}\mathbf{K}\mathbf{U}^T
  • K\mathbf{K}对角矩阵,表示 各轴缩放
  • U\mathbf{U}正交矩阵 (因为 S\mathbf{S} 是一个 对称矩阵,所以它的 特征向量 矩阵是 正交矩阵),表示包含 剪切附加旋转

我们一般使用 QR 算法 进行 特征分解

这里的分解结果 U\mathbf{U}K\mathbf{K} 并非唯一的,对于同一个 S\mathbf{S},它的特征分解结果一共有 2424 种等价组合 (轴重排 × 轴符号 × 旋转 );若要执行 跨帧插值,则需要为每帧挑选一组彼此 “最接近” 的 U\mathbf{U},也就是枚举 2424 种合法的组合,计算将 U1\mathbf{U}_1 变换到 U2\mathbf{U}_2 的旋转 U1U2\mathbf{U}_1^\top\mathbf{U}_2,选择 旋转角最小 的组合,这就是代码 snuggle() 的工作

我们再次进行回代

M=TFRS=TFRUKUT\mathbf{M} = \mathbf{T}\mathbf{F}\mathbf{R}\mathbf{S} = \mathbf{T}\mathbf{F}\mathbf{R}\mathbf{U}\mathbf{K}\mathbf{U}^T

最终

我们最终得到完整的分解

M=TQS=TFRS=TFRUKUT\mathbf{M} = \mathbf{T}\mathbf{Q}\mathbf{S} = \mathbf{T}\mathbf{F}\mathbf{R}\mathbf{S} = \mathbf{T}\mathbf{F}\mathbf{R}\mathbf{U}\mathbf{K}\mathbf{U}^T

我们只需要使用 T,R,K\mathbf{T},\mathbf{R},\mathbf{K} 三个部分来构建我们 去除剪切 的变换矩阵,将其他不需要的完全忽略

Mwanted=TRK\mathbf{M}_{\text{wanted}} = \mathbf{T}\mathbf{R}\mathbf{K}
  • 通常将翻转矩阵 F\mathbf{F} 放进缩放矩阵 K\mathbf{K}

分解位移

一个代表 3D 仿射变换的 4×44\times 4 矩阵可以被分解为一个 3D 线性变换 和一个 位移 的形式

struct FactorTranslationResult {
    Matrix T; // Translation
    Matrix X; // Linear transformation
};
 
FactorTranslationResult FactorTranslation(Matrix M) {
    FactorTranslationResult result;
 
    result.T = Matrix4();
    result.T[12] = M[12];
    result.T[13] = M[13];
    result.T[14] = M[14];
 
    result.X = M;
    result.X[12] = 0;
    result.X[13] = 0;
    result.X[14] = 0;
 
    return result;
}

取决于矩阵在内存里的“主序”(row-major vs column-major)

4 × 4 矩阵的一维下标column-major(OpenGL/GLM 默认)row-major(C 数组 / 大多数 DX 示例)
0‥3第 0 列 (r0c0‥r3c0)第 0 行 (r0c0‥r0c3)
4‥7第 1 列第 1 行
8‥11第 2 列第 2 行
12‥14第 3 列的前三个元素 → Tx, Ty, Tz第 3 行的前三个元素 → Tx, Ty, Tz
15r3c3 (= 1)r3c3 (= 1)
  • 列主序(column-major)下标 12-14 落在 最后一列,对应逻辑坐标 (0,3),(1,3),(2,3)(0,3),(1,3),(2,3)。这是 OpenGL-style 的“位移列”。
  • 行主序(row-major)下标 12-14 则落在 最后一行,对应 (3,0),(3,1),(3,2)(3,0),(3,1),(3,2)。这是 DirectX-style 的“位移行”。

因为这两个布局把同一批浮点排在同样的 12-14 槽位,只看下标无法区分主序;必须结合库/API 文档或乘法写法来判断:

vec4 v1 = M * v0;   // 常见于列向量-后乘体系(OpenGL)
vec4 v1 = v0 * M;   // 常见于行向量-先乘体系(传统 DirectX)

所以,若你的矩阵库采用列主序(GLM、Eigen 默认设置等),T[12]~T[14] 是“最后一列”;若采用行主序,它们才是“最后一行”。

极分解

由于 3×33\times 34×44\times 4 矩阵的求逆成本很低 (40 – 60 flop) ,所以我们一般使用 迭代算法 来做它们的 极分解

X=QS\mathbf{X} = \mathbf{Q}\mathbf{S}
  • Q\mathbf{Q}正交矩阵
  • S\mathbf{S}对称正定 矩阵

首先我们初始化 Q0\mathbf{Q}_0

Q0=X\mathbf{Q}_0 = \mathbf{X}

然后反复 取平均

Qi+1=12(Qi+(Qi1))\mathbf{Q}_{i+1} = \frac{1}{2}(\mathbf{Q}_i + (\mathbf{Q}_i^{-1})^\top)

将矩阵与自己的 逆转置 求平均,能逐步消除不正交的成分,迭代若干次 (一般上界是 20\leq 20 步) 或者当 Qi+1Qi\|\mathbf{Q}_{i+1} - \mathbf{Q}_i \| 足够小就收敛

struct PolarDecompResult {
    Matrix Q; // 旋转/翻转矩阵
    Matrix S; // 对称矩阵(缩放+剪切)
};
 
bool PolarDecompositionEarlyOut(Matrix Q, Matrix Qprev) {
    return (Q - Qprev); // 全部分量足够接近 0
}
 
PolarDecompResult PolarDecomposition(Matrix X) {
    Matrix Q = X;                       // Q0
    for (int i = 0; i < 20; ++i) {      // 最多 20 次
        Matrix Qprev = Q;
        Matrix Qit   = Inverse(Transpose(Q)); // Q_i^{-T}
        Q = 0.5f * (Q + Qit);                // 取平均
        if (PolarDecompositionEarlyOut(Q, Qprev))
            break;                           // 收敛就退出
    }
    result.Q = Q;
    // result.S = Inverse(Q) * X;
    // 可以用正交矩阵的转置来替代其逆 计算更快
    result.S = Transpose(Q) * X;
    // 或者,使用 “正交性” 容差,在未成功收敛到正交时时兜底
    // if (fabs(det(Q) - 1.0f) < 1e-3f && 
    //     norm(Q.transpose()*Q - I) < 1e-3f)
    //     S = Q.transpose() * X;
    // else
    //     S = inverse(Q) * X;   // 退化或未收敛时兜底
 
    return result;
}

Inverse(Transpose(Q)) 就是 (Q1)(\mathbf{Q}^{-1})^\top,有了它我们可以很容易的计算 S\mathbf{S}

S=Q1X=QX\mathbf{S} = \mathbf{Q}^{-1}\mathbf{X} = \mathbf{Q}^\top\mathbf{X}

因为最终 Q\mathbf{Q}正交矩阵,所以 Q1=Q\mathbf{Q}^{-1} = \mathbf{Q}^\top

我们还需要从 Q\mathbf{Q} 中去除 镜像 部分,从而得到 纯旋转,这需要检查 detQ\det{Q}

struct FactorRotationResult {
    Matrix F; // ±I,det<0 时为 -I
    Matrix R; // 纯旋转矩阵
};
 
FactorRotationResult FactorRotation(Matrix Q) {
    float f = 1.0f;
    if (Det(Q) < 0) {   // 含镜像
        f = -1.0f;
        Q = Q * -1;     // 元素逐分量取负,去掉翻转
    }
    result.F = diag(f,f,f,1); // ±I
    result.R = Q;            // 现在 det(R)=+1
    return result;
}

detQ>0\det{\mathbf{Q}} > 0Q\mathbf{Q} 已经是 纯旋转,否则要把 Q\mathbf{Q} 全取负号,相当于提出一个翻转矩阵 F=I\mathbf{F} = -\mathbf{I},余下的 R=Q\mathbf{R} = -\mathbf{Q} 就是 正交旋转

对于 3×33\times 3 矩阵的 极分解,也有 借助四元数 的方案

QR 分解

QR 分解 将一个矩阵 A\mathbf{A} 分解为

A=QR\mathbf{A} = \mathbf{Q}\mathbf{R}
  • Q\mathbf{Q}正交矩阵
  • R\mathbf{R}上三角矩阵

QR 分解QR 算法(求 特征值/特征向量)以及数值稳定线性求解的基础,求解大型矩阵的 QR 分解 往往采用 Givens 旋转House Holder 反射,但是可能最朴素简单的方法是 Gram-Schmidt 正交化

A\mathbf{A} 的三列向量 x,y,z\mathbf{x},\mathbf{y},\mathbf{z},令

yyprojx(y)zzprojx(z)projy(z)\begin{align*} \mathbf{y} &\gets \mathbf{y} - \mathrm{proj}_{\mathbf{x}}(\mathbf{y}) \\ \mathbf{z} &\gets \mathbf{z} - \mathrm{proj}_{\mathbf{x}}(\mathbf{z}) - \mathrm{proj}_{\mathbf{y}}(\mathbf{z}) \end{align*}

再分别 Normalize 三个向量,得到的 x,y,z\mathbf{x}',\mathbf{y}',\mathbf{z}' 就是 Q\mathbf{Q} 的列,虽然 Gram-Schmidt 正交化 的效率不高,但是对于小型矩阵,它的开销是可以接受的

而且当矩阵尺寸很小时,Gram-Schmidt 的实现最简单、寄存器占用最少,也易于在 GPU/ SIMD 里展开流水线。唯一需要注意的是数值稳定性:若输入列向量接近线性相关,需要改用 “改进 Gram-Schmidt” 或做一次交叉积+归一化的快速基构造

步骤浮点操作 (FLOPs)
两次向量投影(点乘+标量乘+减)≈ 24 FLOP (MIT DSpace)
三次向量归一化(点乘+rsqrt+乘)≈ 30 FLOP (MIT DSpace, 维基百科)
合计≈ 50–60 FLOP

若矩阵几乎奇异,可先做一次快速 cross() 重建,也可以看看 Pixar 的这篇 论文

Vector x(A[0], A[1], A[2]);
Vector y(A[4], A[5], A[6]);
Vector z(A[8], A[9], A[10]);
 
y = y - ProjectionV3(x, y);
z = (z - Projection(x, z)) - Projection(y, z);
 
x = Normalize(x);
y = Normalize(y);
z = Normalize(z);
 
// 写回 result.Q 的 0~2、4~6、8~10 元素

由于 Q\mathbf{Q}正交性,求 R\mathbf{R} 很简单

A=QR    R=QA\mathbf{A} = \mathbf{Q}\mathbf{R} \implies \mathbf{R} = \mathbf{Q}^\top\mathbf{A}

也就是

result.R = Transpose(result.Q) * A;

完整代码

struct QRDecompResult {
    Matrix Q; // Q is an orthogonal matrix
    Matrix R; // R os an upper triangular matrix
}
 
QRDecompResult QRDecomposition(Matrix A) {
    QRDecompResult result;
 
    Vector x(A[0], A[1], A[2]);
    Vector y(A[4], A[5], A[6]);
    Vector z(A[8], A[9], A[10]);
 
    y = y -  ProjectionV3(x, y); // y minus projection of y onto x
    z = (z - Projection(x, z)) - Projection(y, z);
 
    x = Normalize(x);
    y = Normalize(y);
    z = Normalize(z);
 
    result.Q[0] = x[0]; resuly.Q[1] = x[1]; result.Q[2] = x[2];
    result.Q[4] = y[0]; result.Q[5] = y[1]; result.Q[6] = y[2];
    result.Q[8] = z[0]; result.Q[9] = z[1]; result.Q[10]= z[2];
 
    result.R = Transpose(result.Q) * A;
 
    return result;
}

谱分解 Spectral Decomposition (特征分解)

我们需要把 极分解 得到的 对称矩阵 S\mathbf{S}特征分解 来分解得到

S=UKU\mathbf{S} = \mathbf{U}\mathbf{K}\mathbf{U}^\top
  • U\mathbf{U}正交矩阵,列向量都是 S\mathbf{S}特征向量
  • K\mathbf{K}对角矩阵,对角线元素是对应的 特征值
  • U\mathbf{U}^\topU\mathbf{U}转置 (等于 )

这种分解是 不唯一 的,U\mathbf{U}K\mathbf{K} 的行可以被任意重新排列,甚至取反符号,最终得到的结果仍然相同

计算 实对称矩阵特征值/特征向量 也可以使用 迭代方法,具体来说,是使用 QR-分解 进行迭代

首先,初始化 A0\mathbf{A}_0

A0S\mathbf{A}_0 \gets \mathbf{S}

然后对 A0\mathbf{A}_0QR 分解

A0=Q0R0\mathbf{A}_0 = \mathbf{Q}_0\mathbf{R}_0

A1=R0Q0\mathbf{A}_1 = \mathbf{R}_0\mathbf{Q}_0,并把本轮迭代得到的 Q\mathbf{Q} 连乘累积进 U\mathbf{U},进行反复迭代,直到 A\mathbf{A}下三角 足够接近 00 (或者到达 迭代上限,一般也是 2020 次),此时迭代收敛,收敛后

  • A\mathbf{A}对角线 即为 特征值,将它写入 K\mathbf{K}
  • 累乘得到的 U\mathbf{U} 就是全部 特征向量
struct SpectralDecompositionResult{
    Matrix U;   // 特征向量矩阵
    Matrix K;   // 对角存特征值
    Matrix Ut;  // U 的转置
};
 
bool SpectralDecompositionEarlyOut(Matrix A){
    int idx[]{4,8,9,12,13,14};   // 下三角的 6 个元素
    for(auto i: idx)
        if(!EpsilonCompare(A[i],0)) return false;
    return true;                 // 都接近 0 说明已趋近对角
}
 
SpectralDecompositionResult SpectralDecomposition(Matrix S){
    result.U = Identity();
    Matrix A = S;
 
    for(int i=0;i<20;++i){
        auto decomp = QRDecomposition(A); // 第 1 步
        result.U = result.U * decomp.Q;   // 第 2 步:累积特征向量
        A        = decomp.R * decomp.Q;   // 第 3 步:A ← RQ
 
        if(SpectralDecompositionEarlyOut(A)) break;
    }
 
    result.Ut = Transpose(result.U);      // 保存 U^T
 
    result.K = Identity();
    result.K[0]=A[0]; result.K[1]=A[1]; result.K[2]=A[2]; // 对角=特征值
 
    return result;
}

此处 QR分解 使用普通的 Gram-Schmidt 正交化,后续 snuggle 步骤会在 2424 种等价组合里挑一组最 “平滑” 的 K\mathbf{K}U\mathbf{U}

Spectral Adjustment 谱调整

因为 特征分解 S=UKU\mathbf{S} = \mathbf{U}\mathbf{K}\mathbf{U}^\top 得到的 U,K\mathbf{U},\mathbf{K} 并不唯一,同一个 S\mathbf{S} 至少有 2424 种可能的 (U,K)(\mathbf{U},\mathbf{K}) 组合,如果我们想在两帧之间平滑插值矩阵,就必须从这 2424 个组合里挑选一组,使得当前帧的 U2\mathbf{U}_2 与上一帧的 U1\mathbf{U}_1 尽可能的接近 (旋转角最小)

之所有有 2424 种有效组合可能,是因为对于 3×33\times 3实对称 矩阵:

  • 轴交换:将 特征向量 的分量排列顺序任意交换 → 66

    (x,y,z)(y,z,x)(x,z,y)(z,x,y)(y,x,z)(z,y,x)\begin{align*} (x,y,z) \quad (y,z,x) \\ (x,z,y) \quad (z,x,y) \\ (y,x,z) \quad (z,y,x) \\ \end{align*}
  • 成对翻转符号:一次性改变两个分量的符号,也不会改变结果

    组合编号X 轴Y 轴Z 轴行列式
    +X+Y+Z+1
    −X−Y+Z(−1)·(−1)·(+1) = +1
    −X+Y−Z(−1)·(+1)·(−1) = +1
    +X−Y−Z(+1)·(−1)·(−1) = +1

    可以发现,行列式结果不变,我们依然位于 右手系

将两种操作的可能数相乘,我们得到 6×4=246\times 4 = 24,这些操作不会改变 S=UKU\mathbf{S} = \mathbf{U}\mathbf{K}\mathbf{U}^\top 的正确性,却会让 U\mathbf{U} 旋转到完全不同的方向,于是插值时可能发生 “跳轴” 或 180° 翻面

我们的目标是让相邻帧的 U\mathbf{U} 差异最小,给定两帧的形变矩阵 S1\mathbf{S}_1S2\mathbf{S}_2 (假设 S1\mathbf{S}_1 是上一帧的,S2\mathbf{S}_2 是当前帧的)

  • 先各自做 特征分解 得到 U1,K1\mathbf{U}_1,\mathbf{K}_1U2,K2\mathbf{U}_2,\mathbf{K}_2

  • 枚举 U2\mathbf{U}_22424变体 U2\mathbf{U}_2'
    我们对于每一个 变体 U2\mathbf{U}_2' 都计算 U1U2\mathbf{U}_1 \to \mathbf{U}_2' 所需的 角度,也就是从上一帧的 姿态 U1\mathbf{U}_1 旋转到这一帧的 U2\mathbf{U}_2' 候选姿态 所需旋转的 角度,然后选取所需 角度 最小的那个 U2\mathbf{U}_2' 作为最终的 U2\mathbf{U}_2

    我们知道,如果将 非零向量 v\vec{v}U1\mathbf{U}_1 旋转到 世界坐标,则可以得到 v\vec{v}U1\mathbf{U}_1 下的 姿态 w1\vec{w}_1

    w1=U1v\vec{w}_1 = \mathbf{U}_1\vec{v}

    同理,将 v\vec{v}U2\mathbf{U}_2' 也旋转到 世界坐标,得到 v\vec{v}U2\mathbf{U}_2' 下的 姿态 w2\vec{w}_2

    w2=U2v\vec{w}_2 = \mathbf{U}_2'\vec{v}

    然后,我们需要找到一个 相对旋转 R\mathbf{R},将 姿态 w1\vec{w}_1 旋转到 姿态 w2\vec{w}_2

    w2=Rw1\vec{w}_2 = \mathbf{R}\vec{w}_1

    显然 姿态 w1\vec{w}_1 是上一帧数据已经决定了的,无法改变,但是不同的 候选旋转矩阵 U2\mathbf{U}_2' 将得到不同的 姿态 w2\vec{w}_2,从而使得进行 w1w2\vec{w}_1 \to \vec{w}_2 的姿态变化所需的 相对旋转 R\mathbf{R} 需要旋转的 角度 不同,我们希望这个角度最小,那么首先要计算 相对旋转 R\mathbf{R}

      w1=U1v,w2=U2v,w2=Rw1  w2=Rw1=RU1v=U2v  RU1v=U2v    (U2RU1)v=0  v0  U2RU1=0U2=RU1R=U2U1\begin{align*} \because \ \ \vec{w}_1 = \mathbf{U}_1\vec{v}, \quad \vec{w}_2 &= \mathbf{U}_2'\vec{v}, \quad \vec{w}_2 = \mathbf{R}\vec{w}_1 \\ \therefore \ \ \quad \vec{w}_2 = \mathbf{R}\vec{w}_1 &= \mathbf{R}\mathbf{U}_1\vec{v} = \mathbf{U}_2'\vec{v} \\ \therefore \ \ \mathbf{R}\mathbf{U}_1\vec{v} = \mathbf{U}_2'\vec{v} &\implies (\mathbf{U}_2' - \mathbf{R}\mathbf{U}_1)\vec{v} = 0 \\ \because \ \ \vec{v} &\neq 0 \\ \therefore \ \ \mathbf{U}_2' - \mathbf{R}\mathbf{U}_1 &= 0 \\ \mathbf{U}_2' &= \mathbf{R}\mathbf{U}_1 \\ \mathbf{R} &= \mathbf{U}_2'\mathbf{U}_1^\top \end{align*}

    然后我们可以将 R\mathbf{R} 转换为 四元数 来比较 旋转角,四元数的 ww 分量大则旋转角度小,也就是说找到使得 相对旋转 对应 四元数 ww 分量最大的那个 U2\mathbf{U}_2',即为我们最后选定最佳 U2\mathbf{U}_2

    q=(cosθ2,sinθ2n^)q = (\cos{\frac{\theta}{2} },\sin{\frac{\theta}{2} }\hat{n})
    • θ\theta 越小,cosθ2\cos{\frac{\theta}{2} } 越接近 11
  • 最后,同步 重排/翻转 K2\mathbf{K}_2 的主对角元素,以匹配选择的 U2\mathbf{U}_2

最终得到的 (U2,K2)(\mathbf{U}_2',\mathbf{K}_2')(U2,K2)(\mathbf{U}_2,\mathbf{K}_2) 在插值时就不会突然大角度跳变,这就是之前代码中的 snuggle 函数

// ❶ 24 个 3×3 置换-翻符号矩阵预先写在 permutations[24]
Matrix U1 = input.U;        // 上一帧或当前基准
Matrix U2 = input.Ut;       // 本帧原始 U (待调整)
 
// ❷ 遍历全部 24 组合
for(i=0;i<24;++i){
    Matrix U2p = permutations[i] * U2;        // 应用置换/翻符号
    Quaternion q = ToQuat(  U2p * U1ᵀ );       // 相对四元数
    if(q.w > best) { best=q.w; save=i; }      // cos(θ/2) 最大 = 旋转角最小
}
 
// ❸ 选中最佳组合
Matrix U2_best = permutations[save] * U2;
Matrix Ut_best = U2_bestᵀ;
Matrix K_best  = diag( K 原值按同样置换/翻符号重排 );
 
// ❹ 输出
return { U2_best, K_best, Ut_best };

只要对称矩阵连贯变化,遍历 24 种离散候选就足以找到最平滑的一条轨迹,并且对于 3×33\times 3 尺度,枚举+比较 的代价微乎其微

struct SpectralAdjustmentResult {
    Matrix U; // Each basis vector is an eigenvector
    Matrix K; // Contains eigenvalues on main diagonal
    Matrix Ut; // Transpose of U
}
 
SpectralAdjustmentResult SpectralDecompositonAdjustment(SpectralDecompositionResult input) {
  Matrix U1 = input.U;
  Matrix U1t = input.Ut;
 
  Matrix[24][9] m_permutations = [
    // Permutation 0: x, y, z
    [ 1, 0, 0,  0, 1, 0,  0, 0, 1 ],
    [-1,-0,-0, -0,-1,-0,  0, 0, 1 ],
    [ 1, 0, 0, -0,-1,-0, -0,-0,-1 ],
    [-1,-0,-0,  0, 1, 0, -0,-0,-1 ],
    // Permutation 1: x, z, y
    [-1,-0,-0,  0, 0, 1,  0, 1, 0 ],
    [-1,-0,-0, -0,-0,-1, -0,-1,-0 ],
    [ 1, 0, 0,  0, 0, 1, -0,-1,-0 ],
    [ 1, 0, 0, -0,-0,-1,  0, 1, 0 ],
    // Permutation 2: y, x, z
    [-0,-1,-0,  1, 0, 0,  0, 0, 1 ],
    [-0,-1,-0, -1,-0,-0, -0,-0,-1 ],
    [ 0, 1, 0,  1, 0, 0, -0,-0,-1 ],
    [ 0, 1, 0, -1,-0,-0,  0, 0, 1 ],
    // Permutation 3: y, z, x
    [ 0, 1, 0,  0, 0, 1,  1, 0, 0 ],
    [-0,-1,-0, -0,-0,-1,  1, 0, 0 ],
    [ 0, 1, 0, -0,-0,-1, -1,-0,-0 ],
    [-0,-1,-0,  0, 0, 1, -1,-0,-0 ],
     // Permutation 4: z, x, y
    [ 0, 0, 1,  1, 0, 0,  0, 1, 0 ],
    [-0,-0,-1, -1,-0,-0,  0, 1, 0 ],
    [ 0, 0, 1, -1,-0,-0, -0,-1,-0 ],
    [-0,-0,-1,  1, 0, 0, -0,-1,-0 ],
    // Permutation 5: z, y, x
    [-0,-0,-1,  0, 1, 0,  1, 0, 0 ],
    [-0,-0,-1, -0,-1,-0, -1,-0,-0 ],
    [ 0, 0, 1,  0, 1, 0, -1,-0,-0 ],
    [ 0, 0, 1, -0,-1,-0,  1, 0, 0],
  ];
 
  float x = input.K[0];
  float y = input.K[5];
  float z = input.K[10];
 
  Vector[6][3] eigen_value_permutations = [
    [ x,  y,  z], // Permutation 0
    [ x,  z,  y], // Permutation 1
    [ y,  x,  z], // Permutation 2
    [ y,  z,  x], // Permutation 3
    [ z,  x,  y], // Permutation 4
    [ z,  y,  x], // Permutation 5
  ];
 
  int saved_index = -1
  int saved_value = -1
 
  // The rotation taking U1 into U2 is U1t * U2
  for (int i = 0; i < 24; ++i) {
    Matrix U2 = m_permutations[i];
    Matrix U12 = U1t * U2;
 
    Quaternion QU12 = ToQuaternion(U12);
    
    // Optimize for largest w, which is smallest angle of rotation
    if (saved_index == -1 || QU12.w > saved_value) {
      saved_value = QU12.w
      saved_index = i
    }
  }
 
  Matrix U2t = Transpose(m_permutations[saved_index])
  var index = saved_index/4; // Integer division (floor this)
 
  SpectralAdjustmentResult result;
  result.U = Mul3(U1, U2t);
  result.K = Matrix(
    eigen_value_permutations[index][0], 0, 0, 0,
    0, eigen_value_permutations[index][1], 0, 0,
    0, 0, eigen_value_permutations[index][2], 0,
    0, 0, 0, 1
  );
  result.Ut = Transpose(result.U)
 
  return result;
}

这段代码使用了 行向量矩阵右乘,而不是我们推导中使用的 列向量矩阵左乘

仿射分解 Affine Decomposition

我们已经拥有了所有所需的函数,接下来只需要组装最终的 仿射分解

阶段函数作用产物
FactorTranslation拆出平移 T,并留下 3 × 3 线性部分 XT, X
PolarDecomposition把 X 分成正交矩阵 Q(旋转或翻转)和对称矩阵 S(缩放+剪切)Q, S
FactorRotation再把 Q 拆成
 • ±I(翻转标记 F)
 • 纯旋转 R
F, R
SpectralDecomposition对称矩阵 S → 特征分解
S=UKU ⁣S = U K U^{\!\top}
U, K, Uᵀ
SpectralDecompositionAdjustment在 24 组置换/符号里挑一组,使新 U 与前一帧 U 的旋转角最小(snuggle)调整后的 U, K, Uᵀ

AffineDecomposition 函数看起来像下面这样

AffineDecompositionResult AffineDecomposition(Matrix M) {
    // ① 平移
    auto trans  = FactorTranslation(M);          // 得 T, X
    // ② 极分解
    auto polar  = PolarDecomposition(trans.X);   // 得 Q, S
    // ③ 抽翻转 & 纯旋转
    auto rot    = FactorRotation(polar.Q);       // 得 F, R
    // ④ 特征分解
    auto spec   = SpectralDecomposition(polar.S);// 得 U, K, Ut
    // ⑤ snuggle 调整
    auto snug   = SpectralDecompositionAdjustment(spec);
 
    AffineDecompositionResult res;
    res.T  = trans.T;        // 平移
    res.F  = rot.F;          // ±1 翻转标记
    res.R  = rot.R;          // 纯旋转矩阵
    res.U  = snug.U;         // stretch-rotation
    res.K  = snug.K;         // 缩放对角
    res.Ut = snug.Ut;        // Uᵀ
    return res;
}

Shoemake 在 Graphics Gems IV 用的是

字段意义
T平移向量
F行列式符号 (±1)
q纯旋转四元数 (R)
ustretch-rotation 四元数 (U)
k伸缩向量(K 的三对角)
ShoemakeResult ConvertToShoemake(AffineDecompositionResult a){
    ShoemakeResult s;
    s.T = Vector3(a.T[12], a.T[13], a.T[14]);
    s.F = a.F[0];                      // ±1
    s.q = ToQuaternion(a.R);           // R → 四元数
    s.u = ToQuaternion(a.U);           // U → 四元数
    s.k = Vector3(a.K[0], a.K[5], a.K[10]);
    return s;
}