☰
Real-Time Rendering第四章变换精讲:矩阵、四元数与法线变换实战笔记
2026/10/13 2:17:59 网站建设 项目流程

1. 为什么我要啃《Real-Time Rendering》第四章

如果你正在做图形渲染、游戏引擎开发,或者单纯对“屏幕上那个三角形到底怎么动起来的”这件事感到好奇,那《Real-Time Rendering》这本书大概率已经躺在你的书单里了。这本书在实时渲染领域的地位不用我多吹,第四版更是把近十年的工业界实践和学术成果揉了个遍。但说实话,它的阅读门槛不低——公式密度大、术语密集、章节之间环环相扣,直接硬啃英文原版很容易从入门到放弃。

我这次做的项目,就是针对第四章“变换(Transforms)”做一份简单粗糙但能用的翻译和读书笔记。所谓“简单粗糙”,不是指内容敷衍,而是指我不打算逐字逐句复刻原书的学术腔调,而是用从业者能快速理解的方式,把这一章的核心逻辑、关键公式、以及实际写代码时真正会用到的部分抽出来,配上我自己的推导和踩坑记录。目标很明确:让一个刚接触图形学的开发者,读完这份笔记后能自己手写一个完整的变换管线,而不是只会调glm::rotate却说不清矩阵里每个元素从哪来。

这一章解决的核心问题是:如何用数学工具描述和计算三维空间中的位置、方向、缩放、旋转,以及这些操作如何组合成一套高效的渲染管线。它覆盖了从最基础的线性变换、仿射变换,到旋转的多种表示方法(欧拉角、旋转矩阵、四元数),再到法线变换、逆变换、以及顶点混合动画中的变换组合。适合的人群包括:图形学初学者、需要补数学基础的引擎开发者、以及想从“会用API”进阶到“理解底层”的技术美术。

我写这份笔记的时候,刻意避开了原书那种“先定义再定理再证明”的学术节奏,而是按照“为什么需要这个→它长什么样→怎么算→代码里怎么用→容易错在哪”的顺序来组织。下面就从整体设计思路开始拆。

2. 变换章节的整体设计与思路拆解

2.1 为什么变换是渲染管线的骨架

在实时渲染里,任何一个物体从建模到显示在屏幕上,都要经历一连串的坐标空间转换:模型空间→世界空间→观察空间→裁剪空间→屏幕空间。这一连串转换的本质,就是不断用矩阵去乘顶点的齐次坐标。你可以把变换理解成渲染管线的“骨架”——没有它,顶点就是一堆散落在各自局部坐标系里的孤立点,根本没法拼成场景。

原书第四章的编排逻辑很清晰:先讲最一般的线性变换和仿射变换,再聚焦到旋转这个最难表示的部分,然后讨论变换的组合、法线变换、逆变换,最后落到顶点混合和动画中的实际应用。这个顺序不是随便排的,它对应的是“从一般到特殊,从理论到实践”的认知路径。我在笔记里保留了这个主线,但在每个节点上都补了“为什么这么排”的解释。

比如,为什么先讲线性变换?因为平移不是线性变换(它不满足加性),但渲染里又离不开平移,所以必须引入仿射变换和齐次坐标。这个“从线性到仿射”的跳跃,是很多初学者卡住的地方。我在笔记里用了一个生活化类比:线性变换就像你把一张橡皮膜拉伸、旋转、剪切,但原点始终钉死;仿射变换则允许你把这张膜整体拎起来挪个位置。齐次坐标就是给每个点加一个“拎起来”的维度。

2.2 方案选型:为什么用矩阵而不是其他表示

原书在旋转表示上花了大量篇幅讨论欧拉角、旋转矩阵、四元数、轴角表示、指数映射等。我在笔记里没有平均用力,而是重点讲了旋转矩阵和四元数,因为这是实时渲染里最常用的两种。欧拉角虽然直观,但存在万向节死锁和插值不连续的问题,工业界更多把它当作UI输入,内部计算还是转成矩阵或四元数。

为什么矩阵是主力?因为矩阵可以统一表示旋转、缩放、剪切,而且多个变换可以通过矩阵乘法合并成一个矩阵,极大减少顶点着色器里的计算量。一个顶点如果有10个变换要应用,逐个乘就是10次矩阵-向量乘法;合并成一个矩阵后,只需要1次。这个性能差异在百万级顶点的场景里是致命的。

四元数则是为了解决旋转插值和累积误差问题。两个旋转矩阵做线性插值再正交化,计算量大且不稳定;四元数的球面线性插值(Slerp)既平滑又高效。我在笔记里补了一个实际案例:角色头部从看向A点转向看向B点,用四元数Slerp插值,中间帧不会出现“抽搐”或“绕远路”的现象。

2.3 避开原书里那些“劝退”的坑

原书有些地方写得过于数学化,比如用李群李代数解释旋转的指数映射,对初学者极不友好。我在笔记里做了取舍:保留结论和公式,但把推导过程简化成“你只需要知道这个公式能干什么、参数怎么填”。比如罗德里格斯公式,原书从轴角表示推导到旋转矩阵,中间用了不少向量运算技巧;我直接给出最终矩阵形式,然后配一个数值例子,让你看到代入轴和角度后矩阵长什么样。

另一个坑是原书对“法线变换”的讨论。很多初学者会直接用模型矩阵去变换法线,结果在非均匀缩放时法线方向就错了。原书给出了正确做法:用法线矩阵,即模型矩阵左上角3x3部分的逆转置。我在笔记里不仅写了这个结论,还补了一个具体数值例子,让你亲眼看到错误变换和正确变换的差异。

3. 核心细节解析与实操要点

3.1 线性变换、仿射变换与齐次坐标的三角关系

线性变换的定义是:保持向量加法和标量乘法的变换。用矩阵表示就是v' = M * v,其中M是3x3矩阵。旋转、缩放、剪切、反射都是线性变换。但平移不是,因为平移后原点变了,不满足线性性。

仿射变换是线性变换加平移,形式是v' = M * v + t。为了用统一的矩阵乘法表示,我们引入齐次坐标:把三维点(x, y, z)写成(x, y, z, 1),把三维向量写成(x, y, z, 0)。这样仿射变换就可以写成4x4矩阵:

[ M t ] [ v ] [ M*v + t ] [ 0 1 ] [ 1 ] = [ 1 ]

这个设计非常巧妙:点的w分量为1,所以平移会作用到它;向量的w分量为0,所以平移不会作用到它。这正好符合物理直觉——向量没有位置,只有方向和大小,平移不应该改变它。

注意:齐次坐标的w分量不只是0和1。在投影变换后,w分量会变成其他值,这时需要做透视除法(除以w)才能得到屏幕坐标。这是后续章节的内容,但第四章已经为它埋下了伏笔。

实操中,我建议你手写一个4x4矩阵类,至少实现矩阵乘法、矩阵-向量乘法、转置、求逆。不要一上来就用GLM或Eigen,自己写一遍才能理解每个元素的位置为什么那么排。我当初就是自己写了一遍,才发现平移分量放在第四列而不是第四行,是因为我们用的是列向量右乘矩阵的约定。

3.2 旋转的三种表示:欧拉角、矩阵、四元数

欧拉角用三个角度(rx, ry, rz)表示绕三个轴的旋转。它最直观,但有两个致命问题:一是万向节死锁,当第二个旋转角为±90度时,第一个和第三个旋转轴重合,失去一个自由度;二是插值不连续,从(0, 0, 0)到(360, 0, 0)线性插值会绕一大圈。

旋转矩阵用3x3正交矩阵表示,满足R^T * R = I且det(R) = 1。它没有死锁问题,可以直接作用于向量,多个旋转可以相乘合并。但矩阵有9个元素,实际只有3个自由度,存在冗余;而且数值误差累积后需要重新正交化。

四元数用四个数(w, x, y, z)表示旋转,其中w = cos(θ/2),(x, y, z) = sin(θ/2) * axis。它紧凑、无死锁、插值平滑,是动画系统的首选。但四元数的乘法不满足交换律,且理解起来需要一点复数基础。

我在笔记里给了一个对比表:

表示方法存储空间插值质量死锁风险计算效率典型用途
欧拉角3 floats差有高UI输入、简单旋转
旋转矩阵9 floats中无中顶点变换、组合旋转
四元数4 floats优无高动画插值、累积旋转

实操心得:在引擎里,我通常用四元数存储旋转,用矩阵做最终变换。每帧从四元数生成旋转矩阵,然后和缩放、平移矩阵合并成模型矩阵。这样既避免了矩阵累积误差,又保证了顶点着色器的计算效率。

3.3 法线变换:为什么不能直接用模型矩阵

法线是垂直于表面的向量,它描述的是表面的朝向。当模型发生非均匀缩放时,法线的变换不能用模型矩阵直接乘。举个例子:一个圆在x方向拉伸2倍,圆上某点的法线原本指向45度方向,拉伸后法线应该更偏向x轴,但直接用模型矩阵乘会得到错误方向。

正确做法是用法线矩阵:N = (M^{-1})^T,即模型矩阵左上角3x3部分的逆的转置。推导很简单:法线n和切线t满足n · t = 0。变换后切线变成M * t,法线变成N * n,要求(N * n) · (M * t) = 0。展开得n^T * N^T * M * t = 0。因为n^T * t = 0,所以只要N^T * M = I,即N = (M^{-1})^T。

我在笔记里补了一个数值例子:模型矩阵是diag(2, 1, 1),法线是(1, 1, 0)归一化后约(0.707, 0.707, 0)。直接用M乘得到(1.414, 0.707, 0),归一化后约(0.894, 0.447, 0);用法线矩阵得到(0.5, 1, 0),归一化后约(0.447, 0.894, 0)。后者才是正确的,因为x方向拉伸后,法线应该更偏向y轴。

注意:如果模型矩阵只包含旋转和平移(即正交矩阵),那么法线矩阵就等于模型矩阵的左上角3x3部分,不需要求逆转置。只有存在非均匀缩放时,才需要特殊处理。

3.4 变换的组合顺序:为什么先缩放再旋转再平移

多个变换组合时,顺序至关重要。矩阵乘法不满足交换律,R * S和S * R结果不同。在渲染里,标准的模型矩阵构造顺序是:先缩放,再旋转,最后平移。写成矩阵就是M = T * R * S。

为什么是这个顺序?因为顶点是列向量,右乘矩阵。M * v = T * R * S * v,所以先对v应用S(缩放),再应用R(旋转),最后应用T(平移)。这符合直觉:一个物体先在自己的局部坐标系里缩放和旋转,然后整体挪到世界空间的位置。

如果你反过来写成M = S * R * T,那么顶点会先被平移,然后旋转,最后缩放。缩放会放大平移量,导致物体位置偏移。我当初就犯过这个错,一个立方体明明设置位置在(10, 0, 0),结果缩放2倍后跑到了(20, 0, 0)。

实操心得:在代码里,我习惯写一个Transform类,内部维护位置、旋转(四元数)、缩放三个分量,然后提供一个GetMatrix()方法,按T * R * S的顺序合成矩阵。这样既直观又不容易出错。

4. 实操过程与核心环节实现

4.1 手写一个4x4变换矩阵库

为了真正理解第四章的内容,我建议你从零实现一个简单的矩阵库。下面是我当时写的核心部分,用Python演示(因为Python的列表操作直观,方便你看到每个元素的位置),实际引擎里用C++或Rust。

import math class Mat4: def __init__(self): # 初始化为单位矩阵 self.m = [[1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]] def __mul__(self, other): # 矩阵乘法 result = Mat4() for i in range(4): for j in range(4): result.m[i][j] = sum(self.m[i][k] * other.m[k][j] for k in range(4)) return result def transform_point(self, p): # 变换一个点 (x, y, z, 1) x, y, z = p w = self.m[3][0]*x + self.m[3][1]*y + self.m[3][2]*z + self.m[3][3] return ( (self.m[0][0]*x + self.m[0][1]*y + self.m[0][2]*z + self.m[0][3]) / w, (self.m[1][0]*x + self.m[1][1]*y + self.m[1][2]*z + self.m[1][3]) / w, (self.m[2][0]*x + self.m[2][1]*y + self.m[2][2]*z + self.m[2][3]) / w ) @staticmethod def translate(tx, ty, tz): m = Mat4() m.m[0][3] = tx m.m[1][3] = ty m.m[2][3] = tz return m @staticmethod def scale(sx, sy, sz): m = Mat4() m.m[0][0] = sx m.m[1][1] = sy m.m[2][2] = sz return m @staticmethod def rotate_x(theta): m = Mat4() c, s = math.cos(theta), math.sin(theta) m.m[1][1] = c m.m[1][2] = -s m.m[2][1] = s m.m[2][2] = c return m @staticmethod def rotate_y(theta): m = Mat4() c, s = math.cos(theta), math.sin(theta) m.m[0][0] = c m.m[0][2] = s m.m[2][0] = -s m.m[2][2] = c return m @staticmethod def rotate_z(theta): m = Mat4() c, s = math.cos(theta), math.sin(theta) m.m[0][0] = c m.m[0][1] = -s m.m[1][0] = s m.m[1][1] = c return m

这段代码里,平移矩阵的平移分量放在第四列(索引3),旋转矩阵的符号遵循右手定则。你可以用Mat4.translate(10, 0, 0) * Mat4.rotate_z(math.pi/2) * Mat4.scale(2, 2, 2)来构造一个模型矩阵,然后变换点(1, 0, 0),看看结果是不是先缩放成(2, 0, 0),再旋转成(0, 2, 0),最后平移成(10, 2, 0)。

4.2 四元数的实现与Slerp插值

四元数的核心操作包括:从轴角构造、乘法、归一化、转旋转矩阵、球面线性插值。下面是我实现的精简版:

class Quat: def __init__(self, w, x, y, z): self.w, self.x, self.y, self.z = w, x, y, z def __mul__(self, other): # 四元数乘法 return Quat( self.w*other.w - self.x*other.x - self.y*other.y - self.z*other.z, self.w*other.x + self.x*other.w + self.y*other.z - self.z*other.y, self.w*other.y - self.x*other.z + self.y*other.w + self.z*other.x, self.w*other.z + self.x*other.y - self.y*other.x + self.z*other.w ) def normalize(self): norm = math.sqrt(self.w**2 + self.x**2 + self.y**2 + self.z**2) return Quat(self.w/norm, self.x/norm, self.y/norm, self.z/norm) def to_matrix(self): # 转成3x3旋转矩阵 w, x, y, z = self.w, self.x, self.y, self.z return [ [1-2*(y*y+z*z), 2*(x*y-w*z), 2*(x*z+w*y)], [2*(x*y+w*z), 1-2*(x*x+z*z), 2*(y*z-w*x)], [2*(x*z-w*y), 2*(y*z+w*x), 1-2*(x*x+y*y)] ] @staticmethod def from_axis_angle(axis, theta): axis = normalize(axis) half = theta / 2 s = math.sin(half) return Quat(math.cos(half), axis[0]*s, axis[1]*s, axis[2]*s) @staticmethod def slerp(q1, q2, t): # 球面线性插值 dot = q1.w*q2.w + q1.x*q2.x + q1.y*q2.y + q1.z*q2.z if dot < 0: q2 = Quat(-q2.w, -q2.x, -q2.y, -q2.z) dot = -dot if dot > 0.9995: # 夹角很小,直接线性插值再归一化 result = Quat( q1.w + t*(q2.w-q1.w), q1.x + t*(q2.x-q1.x), q1.y + t*(q2.y-q1.y), q1.z + t*(q2.z-q1.z) ) return result.normalize() theta_0 = math.acos(dot) theta = theta_0 * t sin_theta = math.sin(theta) sin_theta_0 = math.sin(theta_0) s0 = math.cos(theta) - dot * sin_theta / sin_theta_0 s1 = sin_theta / sin_theta_0 return Quat( s0*q1.w + s1*q2.w, s0*q1.x + s1*q2.x, s0*q1.y + s1*q2.y, s0*q1.z + s1*q2.z )

这里有几个关键点:四元数乘法不满足交换律,所以q1 * q2和q2 * q1结果不同,对应旋转组合的顺序不同。Slerp里处理了dot < 0的情况,因为四元数q和-q表示同一个旋转,但插值路径不同,取反可以保证走短弧。dot > 0.9995时退化为线性插值,避免除以零。

注意:四元数的w分量在有些库(如Unity)里放在最后,有些库(如GLM)里放在最前。写代码时一定要确认约定,否则旋转方向会反。我当初就因为这个问题调了一下午,最后发现是w分量位置搞错了。

4.3 法线矩阵的计算与验证

法线矩阵是模型矩阵左上角3x3部分的逆转置。在代码里,我通常这样计算:

def normal_matrix(model_matrix): # 提取左上角3x3 m3 = [[model_matrix.m[i][j] for j in range(3)] for i in range(3)] # 求逆 inv = invert_3x3(m3) # 转置 return transpose_3x3(inv)

为了验证正确性,我构造了一个非均匀缩放的模型矩阵diag(2, 1, 1),法线(1, 1, 0)。直接用模型矩阵变换得到(2, 1, 0),归一化后约(0.894, 0.447, 0);用法线矩阵变换得到(0.5, 1, 0),归一化后约(0.447, 0.894, 0)。在渲染一个被拉伸的球体时,前者会导致光照在x方向过亮,后者才是正确的。

实操心得:如果你的引擎只支持均匀缩放,那么法线矩阵就等于模型矩阵的旋转部分,不需要求逆转置。但一旦支持非均匀缩放(比如做角色拉伸动画),就必须用法线矩阵。我建议在顶点着色器里传入法线矩阵作为uniform,而不是在着色器里现算,因为求逆转置对每个顶点都做太浪费。

5. 常见问题与排查技巧实录

5.1 变换顺序错了导致物体位置偏移

这是最常见的坑。症状是:物体明明设置了位置(10, 0, 0),但渲染出来在(20, 0, 0)或者(0, 10, 0)。原因通常是矩阵乘法顺序写反了。记住:M = T * R * S,顶点是列向量右乘。如果你用的是行向量左乘的约定(比如DirectX的某些旧教程),顺序要反过来写成M = S * R * T。

排查方法:拿一个单位立方体,只应用平移,看位置对不对;再加旋转,看旋转中心对不对;最后加缩放,看缩放是否影响位置。逐步排查,很快就能定位。

5.2 四元数插值出现“绕远路”或“抽搐”

症状是:两个旋转之间的插值动画不走最短路径,而是绕了一大圈,或者中间帧突然跳变。原因通常是四元数的双重覆盖:q和-q表示同一个旋转,但插值路径不同。解决方法是在Slerp前检查dot,如果为负,把其中一个四元数取反。

另一个可能原因是四元数没有归一化。数值误差累积后,四元数长度偏离1,导致旋转矩阵出现缩放。解决方法是在每次乘法或插值后归一化。

5.3 法线变换错误导致光照异常

症状是:物体在非均匀缩放下,光照方向明显不对,高光位置偏移。原因就是直接用了模型矩阵变换法线。排查方法:在着色器里输出法线颜色,对比正确和错误的结果。正确法线应该始终垂直于变换后的表面。

5.4 矩阵求逆失败或结果不稳定

症状是:法线矩阵计算出来是NaN,或者物体变换后消失。原因可能是矩阵不可逆(行列式为0),比如缩放因子为0。解决方法是在求逆前检查行列式,如果接近0就跳过或报错。另外,浮点精度问题也可能导致求逆不稳定,建议用双精度计算后再转单精度。

问题现象可能原因排查方法解决方案
物体位置偏移变换顺序错误逐步应用T/R/S按TRS顺序合成
插值绕远路四元数双重覆盖检查dot符号dot<0时取反
光照异常法线变换错误输出法线颜色用法线矩阵
矩阵求逆NaN行列式为0检查行列式避免零缩放
旋转方向反了四元数w位置错误对比已知旋转确认库的约定

实操心得:我习惯在引擎里加一个调试模式,把模型矩阵、法线矩阵、四元数都打印出来,和手算结果对比。一开始觉得麻烦,但后来发现这是定位变换问题最快的方法。尤其是四元数,肉眼很难看出对错,打印出来和预期值一比就清楚了。

6. 从第四章延伸出去:还能怎么用

第四章的变换知识不只是理论,它在实际项目里有很多延伸应用。比如顶点混合动画:一个角色模型有多个骨骼,每个骨骼有自己的变换矩阵,顶点根据权重混合多个骨骼的变换。这本质上是第四章变换组合的扩展——每个顶点的最终位置是多个矩阵加权求和的结果。

再比如,法线变换的知识在切线空间法线贴图里也会用到。法线贴图存储的是切线空间下的法线,需要用法线矩阵、切线矩阵、副切线矩阵组成的TBN矩阵变换到世界空间。如果法线矩阵算错了,法线贴图的光照就会完全乱掉。

还有,四元数在相机控制里也很常用。第一人称相机的俯仰和偏航可以用两个四元数相乘来表示,避免欧拉角的死锁问题。我试过用欧拉角做相机,当俯仰角接近90度时,偏航就失效了;换成四元数后,怎么转都不会出问题。

这个内容后续还可以这样扩展:把第四章的变换和第五章的着色基础结合起来,写一个完整的Phong光照模型,看看变换矩阵如何影响光照计算;或者把变换和第七章的阴影映射结合起来,理解光源空间的变换矩阵怎么构造。变换是渲染的基石,这块啃透了,后面很多章节都会顺畅很多。

最后再分享一个小技巧:如果你觉得矩阵乘法太抽象,可以拿一张纸画一个2D正方形,手动算一下每个顶点经过缩放、旋转、平移后的坐标,和代码输出对比。我当初就是这么干的,画了十几张纸之后,矩阵里每个元素的位置就刻在脑子里了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询