
第四章我已经读了好几遍每次以为自己把 Transforms 搞懂了过一阵子再回头看又发现理解得更深一层。Real-Time Rendering 这本教材的厉害之处在于它不满足于告诉你“矩阵长什么样”而是逼你回答“为什么是矩阵以及这一切在渲染管线里到底怎么用”。这篇笔记就是我在读第四章时反复咀嚼的重点整理包括基本变换的矩阵推导、旋转表示的选择、法线变换、投影变换和坐标空间链路。如果你刚接触图形学或者工作中经常被矩阵顺序、万向锁、法线翻转这类问题折磨这篇笔记应该能帮你省下不少硬啃原书的时间。1. 先理一条主线Transforms 到底在渲染管线里承担什么角色1.1 从顶点到像素变换是贯穿全程的“坐标翻译器”建模软件里一个顶点有它的局部坐标比如一个立方体顶点在模型空间里是 (0.5, 0.5, 0.5)。但渲染时它要经历一系列“搬家”先放进世界场景中再相对相机摆好然后投影到裁剪空间最终映射到屏幕像素。每搬一次家对应的就是一次矩阵乘法。我第一次看第四章时容易把它当成一堆公式的堆砌后来才意识到这一章的真正主线是“坐标空间之间的翻译”。模型矩阵把模型空间翻译到世界空间视图矩阵把世界空间翻译到观察空间投影矩阵把观察空间翻译到裁剪空间最后视口变换把 NDC 翻译成像素坐标。读这一章的时候脑子里始终挂着这根链条公式就不再是孤立的了。1.2 两种必不可少的直觉物体在动还是坐标轴在动同一个变换矩阵可以有两种理解方式。第一种是“物体在动”矩阵把顶点从一个位置搬到另一个位置。第二种是“坐标系在动”矩阵把整个坐标系换成另一套基向量。这两种直觉是等价的但场景不同好用的程度不同。做物体旋转时第一种更直觉构造视图矩阵时第二种更关键因为相机不动而是整个世界在相机坐标系下的坐标变换。读书时如果把这两种视角都留着后面推法线变换、推视图矩阵会顺很多。我自己的经验是遇到一个不明所以的矩阵先不要急着背元素先问一个问题——这步变换要实现的是“哪个空间到哪个空间的切换”。凡是能回答出这一个问题的矩阵用法基本就不会错。2. 基本变换的矩阵推导平移、旋转、缩放、剪切是怎么落成矩阵的2.1 为什么 4x4 矩阵是底线齐次坐标拆掉了“平移”这道坎线性变换有个严格定义变换后的每个分量都必须是原始分量的线性组合也就意味着原点永远映射到原点。旋转、缩放、剪切都满足这一点唯独平移不满足因为平移把原点移走了。为了让平移也能放进矩阵乘法里图形学引入了齐次坐标给三维向量多加了第 4 个分量 w。当 w 1 时向量被当做一个点平移会对它生效当 w 0 时向量被当做一个方向平移对方向无效。这个设计非常符合直觉方向只有朝向没有位置所以平移当然管不到它。换句话说齐次坐标不只是“为了凑矩阵而多出来的一维”它用 w 区分了点和方向这是理解变换语义的关键。2.2 旋转矩阵从向量投影推出来绕某个轴旋转是最容易推导也最容易记住的矩阵。以绕 z 轴旋转 θ 为例点 (x, y, 0) 绕 z 轴转 θ 后得到 (x, y, 0)。用极坐标来推x r cosαy r sinα。旋转后角度变成 α θ所以x r cos(α θ) x cosθ - y sinθ y r sin(α θ) x sinθ y cosθ于是绕 z 轴的旋转矩阵就是R_z(θ) [cosθ -sinθ 0 0] [sinθ cosθ 0 0] [ 0 0 1 0] [ 0 0 0 1]绕 x 轴和绕 y 轴的矩阵同理区别只是把被旋转的平面换成 yz 平面和 zx 平面。推导一次之后这三套矩阵完全不需要死记随手就能写出来。旋转矩阵有个很重要的性质它是正交矩阵行向量之间互相垂直而且每个行向量长度是 1。正交矩阵的逆等于转置所以旋转的逆变换非常便宜转置一下就行。2.3 缩放、剪切和负缩放比想象中更有信息量缩放矩阵很简单就是对角矩阵S(sx, sy, sz) [sx 0 0 0] [ 0 sy 0 0] [ 0 0 sz 0] [ 0 0 0 1]但负缩放值得单独说。sx -1 时这个矩阵沿着 x 轴做了反射物体像是照了镜子坐标系的手性变了。手性变化不是小事它会让三角形绕序翻转从正面看变成背面看同时法线方向也会出问题。剪切矩阵稍微冷门一些但偶尔会遇到。它的特点是让一个轴的分量注入到另一个轴的分量里。一个典型的例子是沿 x 方向按 y 值做剪切H_xy(s) [1 s 0 0] [0 1 0 0] [0 0 1 0] [0 0 0 1]肉眼看看效果x x syy y。也就是说y 值越大沿 x 方向平移得越多一个正方形会被压成平行四边形。2.4 复合变换的顺序从右往左读从物体往世界写使用列向量约定时逻辑是“从右往左执行”。比如一个模型要先缩放再旋转再平移对应的矩阵就是M T × R × S顶点按 v M v 变换时先应用的是最右边的 S然后是 R最后是 T。这个顺序是第四章里最核心、也最容易出错的地方。一个很经典的案例是“围绕物体中心旋转”和“围绕原点旋转”的差别。如果你先把物体平移到某个位置再旋转旋转就会绕着原点转而不是绕物体自身的中心转正确的做法是先旋转再平移。顺序反了物体就会像一个行星一样绕原点公转而不是在原地自转。我早期写渲染器时最常发生的 bug 就是把 T×R 和 R×T 写反现象非常明显物体出现在错误的位置并且绕着一个根本不存在的轴打转。为了排查这种问题我后来养成了一个习惯写复合变换前先在心里默念一遍“要使哪种变换最先生效那个矩阵就要放在最右边”。3. 旋转的三种表示法矩阵、欧拉角、四元数什么时候用谁3.1 矩阵旋转规范但冗余用矩阵表示旋转最符合直觉也最方便和其他变换级联。要旋转一个向量直接乘就行了。但矩阵表示旋转有很大问题9 个数里只有 3 个独立自由度剩下 6 个是冗余约束。这在长时间连续旋转中尤其麻烦因为浮点误差会慢慢破坏矩阵的正交性矩阵会从“合法旋转矩阵”退化成一个近似旋转的仿射变换物体逐渐被拉伸。要修复就得定期做正交化也就是说把矩阵的三个行向量重新施密特正交化。这是可行的但很脏。此外从矩阵里提取旋转角度或者对两个旋转做插值都相当不直观。3.2 欧拉角的直观性以及万向锁这个老朋友欧拉角用三个角度表示绕 x、y、z 三个轴的依次旋转读起来非常直观非常适合人类调试。但它有两个问题一是旋转顺序很重要绕 z 再绕 x 和绕 x 再绕 z 的结果不同二是万向锁。万向锁用数学说就是当中间那个轴旋转到与最外层轴重合时会失去一个自由度。我用一个不那么严谨但很好理解的比喻想象你先后绕着三个嵌套的环旋转中间那层环转到一定程度会和最外层环共面于是你再怎么转外层环效果都被中间那层吞掉了三维旋转退化成了二维旋转。插值问题是更致命的。对欧拉角逐个分量做插值角度跳变时会出现剧烈的异常摆动。所以工程上欧拉角基本只用来显示和调试不拿来内部计算。3.3 四元数的逻辑和插值slerp 是怎么来的四元数可以理解为“轴角”的加强版用一个单位向量 u 表示旋转轴再用一个角度 θ四元数记作 q (cos(θ/2), u sin(θ/2))。为什么要除以 2因为四元数对向量做旋转时是双侧操作v q v q⁻¹。角度被用了两次所以构造时要用半角。四元数有两个极其重要的工程性质。第一它是单位四元数时避免了大部分浮点漂移问题第二q 和 -q 表示同一个旋转因为旋转角加 360 度后效果一样这个双倍覆盖性质在插值时要小心处理。旋转插值是四元数的看家本领。两个旋转之间要做平滑过渡做法是 slerp球面线性插值slerp(q₀, q₁, t) (q₀ sin((1 - t)Ω) q₁ sin(tΩ)) / sinΩ其中 Ω 是 q₀ 和 q₁ 在四维球面上的夹角。因为用的是“球面最短路径”而不是直线上硬插所以能保证旋转过程中物体的朝向是均匀变化的。相比之下普通线性插值会导致角速度忽快忽慢。3.4 项目里的分工存储、计算、调试该怎么分配看书本上每个方案都说得通一到工程里就要做取舍。我目前的习惯是内部存储和累积旋转用四元数避免误差漂移也方便做插值需要人来观察、修改、报告数值的时候转成欧拉角给调试界面看最终轮换交给 GPU 的时候把四元数转成旋转矩阵。这套分层的思路并不新鲜但能省掉很多莫名其妙的 bug。早期我项目里所有旋转都直接存矩阵结果每次物体连续旋转几分钟后再回来就明显变形用四元数存储之后这类问题几乎绝迹。4. 法线变换为什么模型矩阵不能直接拿来变换法线4.1 从“法线与切线垂直”这一条推到逆转置这是第四章里最反直觉、也最实用的一节。很多人第一次写光照时会把法线当作普通向量乘上模型矩阵就直接用了结果光照在非等比缩放时完全错乱。问题出在法线的定义上。我们要求变换后法线依然垂直于变换后的表面。假设某条切线向量是 t对应的法线是 n变换矩阵是 M。切向量变换后是 t M t这只是两点之差平移完全不影响。我们希望找到矩阵 N让 n N n且 n 与 t 垂直nᵀ t (N n)ᵀ (M t) nᵀ Nᵀ M t目标是让它等于 0。已知原始条件下 nᵀ t 0。如果 Nᵀ M I即 N (M⁻¹)ᵀ那表达式就恒等于 0。所以法线变换矩阵是模型矩阵的逆转置这就是传说中的 (M⁻¹)ᵀ。4.2 哪些情况下可以偷懒旋转、平移、等比缩放逆转置听起来很繁琐但工程上有几个特例可以偷懒。如果 M 只包含旋转和平移那么 M 的左上 3x3 部分是正交矩阵正交矩阵的逆等于转置于是逆转置又变回 M 本身。也就是说刚体变换下法线可以直接用模型矩阵变换。如果包含等比缩放M⁻¹ 也只需要除以一个统一缩放系数折算后归一化即可无伤大雅。真正危险的是非等比缩放和剪切这两种变换会实实在在地改变法线方向你的材质球此时一定必须用逆转置矩阵。4.3 法线变换出错时的典型症状我在实际渲染时遇到的法线 bug通常长这样一个圆柱体被非等比缩放后光照在高低光交界处出现一条条奇怪的带状暗区表面看起来像是在“漏光”。因为法线方向错了高光位置和应有的位置对不上。排查方法也简单在片元着色器里把法线可视化输出直接看颜色分布。如果法线明明是圆滑的圆柱面颜色却出现剧烈突变那就是变换矩阵用错了。只要换成逆转置矩阵症状立刻消失。这个经验后来成了我的固定套路凡是新增一个带缩放的模型渲染第一件事就是检查法线变换。5. 投影变换的几何本质把视锥“压”成标准立方体5.1 视锥、裁剪空间、NDC 这三个概念的关系很多初学者把投影矩阵理解成“把 3D 点变成 2D 点”这是不对的。投影矩阵做的是“把视锥体变成标准立方体”也就是说它依然是一个三维到三维的变换。视锥由近裁剪面、远裁剪面、fov 和宽高比共同定义。投影矩阵把这个视锥体映射到裁剪空间裁剪空间的分量再除以 w 得到 NDC到这一步才真正去掉了透视效果。把“投影”理解成一个完整的复合过程比单纯记矩阵要清晰得多。5.2 透视投影是从相似三角形推出来的透视投影的核心是相似三角形。假设相机在原点看向 -z 方向近裁剪面在 z -n。视锥里任意一点 (x, y, z)投影到近裁剪面上的坐标 (x, y, -n) 满足x / n x / (-z) y / n y / (-z)也就是 x nx / (-z)y ny / (-z)。注意这里分母是 -z因为 z 是负的-z 才是正的距离。既然希望透视除法之后得到 (x, y)裁剪坐标的 w 分量就应该承载这个 -z。投影矩阵把 w_clip 设置成 -z_view就是这个原因。一个典型的透视投影矩阵右手系、NDC 深度范围 -1 到 1可以写成P [f/aspect 0 0 0 ] [0 f 0 0 ] [0 0 (fn)/(n-f) 2fn/(n-f)] [0 0 -1 0 ]其中 f 1 / tan(fov/2)aspect 是宽高比n 是近裁剪面距离f 是远裁剪面距离。代入 near 平面上的点验证z_ndc 正好是 -1代入 far 平面上的点z_ndc 正好是 1。这一步值得自己手算一遍比背任何矩阵都管用。5.3 正交投影只是透视投影的“不透视”版本正交投影没有远处变小本质上就是一个平移加缩放。把视锥的左、右、上、下、近、远六个边界映射到标准立方体的六个面即可。所以正交投影矩阵是线性变换w 分量保持为 1不需要透视除法。它和透视投影的区别不在于矩阵长得不一样而在于投影矩阵“要不要在 w 里写入距离信息”。理解到这一层两种矩阵就统一成了一件事。5.4 深度是角度的函数不是距离的函数投影矩阵除了负责把 x 和 y 映射到屏幕坐标还同时把 z 映射到深度缓冲区。但透视投影下的深度映射不是线性的它和 1/z 相关这正是“近处精度高、远处精度低”的原因。实际操作中如果 near 设得特别小far 设得特别大远处物体的深度值会挤在一小块区域内深度冲突就会反复出现。我曾经遇到过远处模型疯狂闪烁排查了半天最后发现是 near 值设成了 0.01far 设成了 10000。把 near 稍微调大到 0.1问题立刻缓解。这个片段原书虽然只是一带而过但实战价值极高。6. 模型、世界、视图、裁剪、屏幕一整套坐标空间链路怎么串6.1 五个坐标空间的职责划分把一章的变换全部串起来用的就是这套链路模型空间顶点从建模软件里带出来的原始坐标。世界空间所有模型摆到同一个大场景后共享的坐标。视图空间以相机为中心相机朝向 -z 方向。裁剪空间 / NDC把视锥变成标准立方体为裁剪做准备。屏幕空间NDC 经过视口变换映射到像素坐标。每两个相邻空间之间就是一个矩阵这些矩阵可以预先合成一个 MVP 矩阵但划分清晰对调试非常重要。项目里我强烈建议保留单独的小工具能查看每个阶段的坐标否则出 bug 时你只能抓瞎。6.2 视图矩阵用基向量构造观察坐标系视图矩阵的构造思路很干净。给定相机位置 eye、目标点 target 和上方向 up先算前方向 zAxis normalize(eye - target)因为相机看的是 -z 方向。再算右方向 xAxis normalize(cross(up, zAxis))最后补算 yAxis cross(zAxis, xAxis)。这样我们就得到了一组正交基。视图矩阵的旋转部分由这三个基向量组成平移部分则是把相机移回原点View [xAxis.x xAxis.y xAxis.z -dot(xAxis, eye)] [yAxis.x yAxis.y yAxis.z -dot(yAxis, eye)] [zAxis.x zAxis.y zAxis.z -dot(zAxis, eye)] [ 0 0 0 1 ]我第一次推这个矩阵时纠结了很久为什么平移项是 -dot(axis, eye)而不是直接写 -eye。后来才想明白旋转部分已经把坐标系换成了相机基此时相机位置 eye 在新坐标系里的分量正好是每个基向量和 eye 做点积。所以先把坐标轴换掉再把“换了坐标的相机位置”减掉一步到位。6.3 一个顶点从建模到屏幕的完整矩阵链假设模型空间里有一个点 p_model先乘模型矩阵 M 到世界空间再乘视图矩阵 V 到视图空间再乘投影矩阵 P 到裁剪空间也就是 p_clip P × V × M × p_model。到这里GPU 会自动完成透视除法 p_ndc p_clip / p_clip.w把 w 分量归一化到 1。然后视口变换把 NDC 的 [-1, 1] 区间映射到 [0, width] × [0, height] 的像素坐标。这套链路里最容易混淆的是不同图形 API 的约定。有的 NDC 深度范围是 [-1, 1]有的是 [0, 1]有的相机看向 -z有的看向 z。我每次切换环境都容易在这上面栽跟头。一个折中的办法是自己写一个小函数把矩阵打印出来手工对几个已知点验算比盯着文档猜要快很多。7. 我在实践中踩过的坑变换顺序、坐标系约定与验证方法7.1 旋转中心不对的经典 bug顺序写反了有一次我给一个机械臂模型写旋转期望它绕自己的关节轴转结果它绕着世界原点转。排查时发现我在构造矩阵时写成了 M R × T把平移放在左边了。按照列向量右乘规则物体先被平移走再被旋转旋转中心自然变成了原点。改成 M T × R 之后旋转立刻回到关节轴。这个 bug 的隐蔽之处在于它不会让画面“崩掉”只是行为看起来和预期差一口气。很多新手会以为是物理模拟算错了根本想不到矩阵顺序。所以我现在写任何复合变换都会先写清楚是从哪个空间到哪个空间再动手写代码。7.2 坐标系约定不一致引发的混乱导入第三方模型时最常碰到的问题就是 y-up 和 z-up 的差异。模型在建模软件里明明是正立的进入渲染器之后整个躺倒。如果模型矩阵里没有预先加一个坐标轴交换矩阵后面所有旋转都会乱套。这个问题特别容易发生在“用自己的工具加载外部资产”的场景里。成熟的引擎通常内置了完整的坐标轴转换流程但一旦自己实现渲染器就会踩到。我的处理方式是在资产导入阶段做一次统一的坐标系归一化而不是在运行时每个模型临时处理。这样后续所有代码都只面对一种坐标约定省心很多。7.3 验证变换正确性的土办法把点打出来把法线可视化读书笔记写得再多不如跑一次实验来得踏实。我的验证手段主要有两种。第一种取一个已知坐标的点比如 (1, 0, 0)经过某个矩阵变换后手工计算期望结果再打印实际输出对比是否一致。第二种对法线做可视化调试直接用颜色表示法线方向如果非等比缩放后画面颜色分布合理法线变换大概率没问题。这些办法听起来很初级但实际排查效率极高。复杂的数学库调用和 GPU 管线中间过程都很容易黑盒化越早把中间量暴露出来看bug 定位越快。第四章的笔记整理到这里关于顶点混合、蒙皮和更进阶的变形部分我还没整理完打算后续单独写一篇。这次选出来的内容都是我在实际项目里反复用到、反复踩坑的知识点。变换这个东西表面上是矩阵乘法实际上是坐标系思维一旦想通了后面看光照、阴影、动画都会顺手很多。