资讯动态

游戏引擎中的空间变换与渲染流水线:从模型空间到屏幕像素

发布时间:2026/10/7 5:37:26 来源:尧图企业网站定制
写游戏引擎从零开始写的那种绕不开一个现象美术同事在Maya里摆好的模型导进引擎一看要么位置不对要么朝向混乱要么整个缩小了一圈。然后所有人都会告诉你一句话——检查一下坐标系调一下Transform。可Transform背后那些数值到底是怎么让一个模型从建模软件里的原点跑到世界坐标里的某个位置再跑到摄像机面前最后变成屏幕上的一串像素很多刚接触引擎源码的朋友在这里卡了很久。这篇是这个系列的第三篇。前两篇我们聊了引擎的整体架构也补了向量和矩阵的基础运算。这篇要解决的核心问题就两个一个是空间变换为什么三维场景要在好几个坐标系之间来回倒腾另一个是渲染流水线顶点从模型空间出发到底经历了什么才最终变成屏幕上的颜色。内容偏底层但尽量用大白话讲清楚。适合正在写软渲染器、想啃引擎渲染源码或者刚入门图形学但被各种坐标系绕晕的同学。1. 空间变换到底在解决什么问题先看一个最简单的情况。场景里有个方块模型虚拟摄像机对准它屏幕上要显示出这个方块。这中间至少有三位裁判在发表意见方块自己不在乎自己在哪建模软件里它只是以自己为中心的一组顶点坐标但引擎得知道它在世界里的什么位置、什么朝向摄像机只关心这个方块相对于我这个镜头落在视野的哪个方向显示器最后只关心它在画面的第多少行第多少列。每一方关心的坐标系都不一样空间变换干的事就是在这几个坐标系之间来回换算。生活里也有类似的事。你拿着一张手绘的咖啡馆地图上面标了一个你在这的红点红点的坐标是距北门50米距奶茶店20米。但你要告诉出租车司机目的地就得把它换算成街道名和门牌号因为司机使用的是城市坐标系统。到了咖啡馆门口你再把门牌号换算回往前走两步、左手边那家店因为你使用的是身体朝向坐标。同一家店在不同参考系下有完全不同的描述但它们指向同一个物理位置。三维引擎里的空间变换本质就是这个换算过程。那为什么不干脆全用一个坐标系比如所有模型都存世界坐标摄像机也存世界坐标渲染时直接在世界坐标里算不是免去一堆矩阵乘法吗理论上可以但工程上行不通。第一模型是复用的一个桌子模型会被摆到世界里的100个位置模型数据本身必须和摆放位置无关否则你调整一次桌子位置就得改一遍所有顶点数据。第二有些计算只在特定坐标系下才简单。拿光照来说法线在模型空间里存的是建模时的原始方向但光照方向是在世界空间里定义的你总得把法线变到世界空间才能做点积。再比如投影和裁剪几乎只能在摄像机相关的坐标系里高效完成。空间变换不是麻烦它是把每个阶段的运算放在最省事的参考系里做最后再用矩阵把这几个参考系无缝串起来。2. 五六个坐标系之间的关系为什么模型要倒腾这么多次实际游戏渲染里一个顶点从模型文件到屏幕像素至少要经过五六个坐标系。每个坐标系存在的理由都不一样一个个拆开看。2.1 模型空间建模软件的出厂设置模型空间是模型在建模软件里被编辑时所处的坐标系。一个角色的顶点坐标通常以它的脚底或中心为原点Z轴可能朝前也可能朝上完全看建模时怎么设定。这个空间的顶点数据是出厂状态在引擎里被反复实例化——同一个角色模型可能存在世界里50个不同的怪物身上但模型数据只有一份。这份数据的顶点坐标永远不需要改只需要在不同实例上套不同的变换矩阵。2.2 世界空间让所有物体摆在一起世界空间是引擎唯一的总坐标系。所有模型实例、灯光、摄像机最终都会被放入这个空间游戏逻辑里也是在这个空间里判断两辆车是否碰撞、怪物是否在攻击范围。模型到世界的变换矩阵一般叫World矩阵或Model矩阵它做的事就是把模型空间里的每个顶点按照某个实例的位置、朝向、缩放搬到世界空间里。一个很容易被忽略的点缩放也是在这里发生的。美术建模时很多时候不做最终缩放的调整而是在引擎里通过Scale把模型调整到合理尺寸。但我建议尽量缩放整个模型文件而不是挂在引擎层的Scale上。原因后面讲矩阵时会提到非等比缩放在法线变换上会埋雷光照结果可能出现偏差。2.3 视图空间摄像机的私人坐标系世界空间虽然统一但摄像机看东西的时候并不关心物体在世界坐标的哪个位置它只关心物体在我前方还是后方、偏左还是偏右。这就需要第二个变换把整个世界坐标系的原点移到摄像机位置把Z轴对准摄像机的视线方向。变换完成后所有顶点都在以摄像机为原点的坐标系里这个坐标系叫视图空间也叫摄像机空间。在OpenGL风格的惯例里视图空间的Z轴指向屏幕外也就是背后的方向所以物体在摄像机前方时它的Z值通常是负的这点经常成为新手调试的困惑来源。在DirectX风格里Z轴是反过来的指向屏幕内前方物体Z为正。引擎里通常用View矩阵或Camera矩阵表示这个变换Unity里叫viewMatrix自己写引擎时一般也从这里开始指定左手系还是右手系。2.4 裁剪空间为投影和剔除做准备视图空间还不够因为摄像机能看到的东西是有范围的上下左右有视场角限制近处有个近裁剪面太近了会穿透相机远处有远裁剪面太远了没必要渲染。这个范围在三维里是个截金字塔形状叫视锥体。投影矩阵的作用就是把视锥体压成一个立方体让判断顶点是否在视野内变成一组简单的比较。经过投影矩阵变换后的坐标叫裁剪空间坐标每个顶点除了x/y/z分量还有一个w分量。这个w在透视投影下会等于或与顶点在视图空间的深度成正比的数值。接下来GPU会检查x、y、z是否都在[-w,w]区间内只要有一个分量超出了这个区间这个顶点乃至整个图元就被判定为不可见直接剔除不再进入后面的渲染。这就是裁剪空间这个名字的由来——空间变换在这里天然完成了相当一部分的性能优化。2.5 NDC和屏幕空间从坐标读数到像素位置裁剪空间坐标再除以wx/y/z就被归一化到[-1,1]在DirectX里z是[0,1]得到NDC坐标。这一步叫透视除法是所有近大远小效果真正的数学来源——距离越远w越大除以w之后顶点离画面中心越近视觉上就越小。最后一步设置两个参数viewport位置和尺寸就能把NDC的[-1,1]映射到实际的屏幕像素坐标。比如NDC里的x-1表示屏幕最左边缘x1表示最右边缘中间加一个线性映射就得到了屏幕空间坐标。至此顶点坐标已经走完了从模型定义到屏幕位置的全程。但要注意这个全程里有两条路线在固定管线时代这些变换由GPU内部自动完成开发者只传参数在现代可编程管线里这些变换基本在顶点着色器里由我们自己手动乘矩阵完成。理解每个阶段在做什么才能看懂着色器里那一串矩阵乘法。3. 矩阵变换的关键点齐次坐标、复合变换和乘法顺序3.1 为什么要用4x4矩阵做三维变换三维空间里的旋转和缩放都能用3x3矩阵表示但平移不行。平移对应的是坐标加上一个偏移量写成矩阵乘法的形式就变成3x3矩阵乘3维向量结果里怎么都加不出一个常数偏置。数学上的解法是引入齐次坐标三维点用四维向量表示(x,y,z,1)平移矩阵在第四行或第四列存放偏移量。这样平移就能纳入统一的矩阵乘法体系。代价是多一维计算量但对GPU来说完全可接受换来的是平移、旋转、缩放、投影全都变成矩阵乘法代码和硬件都极大简化。齐次坐标还有个有用的特性w分量可以用来表示点和方向。点用(x,y,z,1)向量用(x,y,z,0)一个矩阵既能变换点也能变换方向。平移矩阵作用在w0的方向向量上不会产生移动这正好符合方向没有位置的直觉。3.2 复合矩阵的读法从右往左才是执行顺序把多个变换接在一起比如先旋转再平移到世界位置代码里通常是worldMatrix translationMatrix * rotationMatrix * scaleMatrix。这行代码看着简单含义却经常被搞反。矩阵乘法不满足交换律AB得到的结果和BA完全不同。标准图形的TRS顺序Scale先乘Rotation其次Translation最后在执行层面是对顶点先施加缩放再旋转再平移所以代码里的乘法顺序看起来和想要的顺序相反实际上乘以顶点时是最右的矩阵先发生作用。这个细节很容易成为第一个调试噩梦。我见过不少新手在引擎里写world rotate * translate想要先移动再旋转结果模型的旋转中心跑到了奇怪的位置。判断的方法很简单想象一个摆锤如果你希望它绕自身中心转动再摆放到某个位置必须先局部旋转再平移也就是用translate * rotate * scale的结果去乘顶点如果你希望模型绕世界原点旋转再用旋转后的位置偏置那才是rotate * translate。绝大多数游戏物体的摆放都属于前一种。3.3 旋转的三种表达方式矩阵、欧拉角、四元数旋转矩阵、欧拉角、四元数是引擎里最常见的三种工具。矩阵直观但占内存插值难欧拉角是人最容易理解的三个角度分别绕X/Y/Z轴旋转但会碰到后面要讲的万向节死锁四元数适合插值且无死锁但第一次看到四个分量的操作多半是懵的。在引擎底层矩阵构建时绕单个轴的旋转矩阵很简单一份标准参考就够用。重点是三个轴都旋转时绕轴的先后顺序Unity使用Z-X-Y或者说Y-X-Z细节取决于坐标约定Unreal使用的顺序也不同。这意味着同一个pitch, yaw, roll数值在两个引擎里可能旋转出不同的朝向。如果你的项目需要跨引擎导出数据这套顺序必须对齐。我在自己的引擎里明确采用先绕Z轴再绕X轴最后绕Y轴的约定并且把顺序写在构建矩阵的注释里因为三个月后自己一定会忘。3.4 一个实际的复合变换案例角色持剑拿一个具体的例子串一下前面这些概念。假设角色模型空间原点在脚底剑的模型空间原点在剑柄。要渲染角色手上拿着的剑需要两步先用一个局部矩阵把剑摆到角色手部对应的相对位置剑柄对齐手的节点这一步在角色模型空间内完成再用角色的世界矩阵把整个角色摆到世界坐标。GPU里的变换链路就是剑的顶点先乘以剑相对角色的矩阵再乘以角色的世界矩阵得到世界坐标继续乘以视图矩阵和投影矩阵得到裁剪坐标。用伪代码表达这个过程float4 worldPos mul(WorldMatrix, mul(LocalToParentMatrix, float4(vertex, 1.0))); float4 viewPos mul(ViewMatrix, worldPos); float4 clipPos mul(ProjectionMatrix, viewPos);这个例子看起来不难但它解释了很多引擎现实为什么动画系统输出的是一个相对父节点的矩阵为什么骨骼蒙皮要维护一整套节点层级矩阵为什么我刚说的模型空间在层级结构里其实又分成了好几级相对空间。空间变换从来不是单次乘法而是一串矩阵相乘每个环节的矩阵都来自一个更高层级的变换。4. 3D渲染流水线顶点从模型空间到屏幕的真实旅程前面把坐标系讲清楚了接下来把这些坐标系放进渲染流水线里看看一个三角形到底怎么变成像素。以实时渲染通用的GPU管线为例大致分成顶点处理、光栅化、片段处理、输出合并几个阶段。4.1 顶点着色器CPU设置好矩阵GPU批量执行乘法现代引擎渲染一个网格时CPU把所有顶点数据位置、法线、UV等打包进顶点缓冲GPU的顶点着色器逐顶点执行我们写的代码。这段代码里最常见的操作就是前面说的空间变换// GLSL示例 layout(location0) in vec3 aPos; uniform mat4 model; uniform mat4 view; uniform mat4 proj; void main() { gl_Position proj * view * model * vec4(aPos, 1.0); }注意这里CPU的工作量很小矩阵是由CPU计算好的每个顶点乘三个矩阵的工作全部并行在GPU上执行。这个阶段还经常做顶点法线变换世界空间下的法线等于模型变换矩阵的逆转置矩阵乘本地法线而不是直接用模型矩阵乘。原因是发生非等比缩放时法线方向会被矩阵破坏导致光照计算错误。这个坑我踩过一次模型拉伸之后高光方向全是乱的查了很久才发现是法线变换矩阵用错了。4.2 光栅化把三角形变成像素顶点着色器输出的是一堆裁剪空间坐标的顶点GPU把它们组装成三角形然后做裁剪和背面剔除接着进入光栅化阶段。光栅化说白了就是确定每个三角形覆盖了屏幕上哪些像素点并为每个像素计算出它在三角形内的插值信息。插值信息包括位置、法线、UV、顶点颜色等。GPU在三角形内部做重心坐标插值从而让每个像素拥有一个平滑过渡的属性值。这也是为什么三角形是渲染的基本单位——任意多边形都能拆成三角形且三角形内部天然可以做线性插值。光栅化阶段离引擎开发者的日常比较远一般由硬件完成但如果你在写软渲染器扫描线算法或重心坐标算法就是这个阶段的核心功课。写软渲染器时我强烈建议先做纯CPU版本的三角形光栅化哪怕跑不快也好过对着GPU管线和Shader盲猜。4.3 片段着色器决定像素的颜色光栅化之后每个被覆盖的像素都可能执行一次片段着色器现代GPU上往往等价于像素着色器。在这里我们采样纹理、计算光照模型、做各种后处理效果。注意它是逐像素执行的一个1080p的画面就有约两百万个像素着色器代码的每一行计算都会被放大两百万倍所以这里的代码写出fragile的数学运算性能影响非常直接。片段着色器里需要拿到的世界坐标、法线方向等等通常都靠顶点着色器输出的插值结果反推。这也是为什么需要顶点阶段把变换算对插值只能基于顶点输出顶点阶段错了插值出来的每个像素都是错的。4.4 深度测试谁遮挡了谁最后一步是把多个物体的像素合并到一张画面里。GPU维护了一张深度缓冲记录每个像素位置上当前最近的深度值。每次片段着色器算出一个结果先比较它的深度和已存深度如果更近就覆盖颜色并更新深度如果更远就丢弃。这套机制保证即使绘制顺序混乱画面遮挡关系依然正确。深度测试的一个经典坑和精度有关深度缓冲通常不是线性的近处的深度精度远高于远处。如果你的摄像机near/far比值不合理比如near设得很小、far设得很大远处的两个物体距离很远却可能拥有几乎相同的深度值最终出现闪烁和撕裂。这个问题的处理我放到第5章细说因为它和投影矩阵强相关。5. 透视投影矩阵远小近大背后的数学5.1 从针孔相机到视锥体拍照时远处的山小、近处的人大原因是光线从物体反射进入镜头时经过一个近似点的孔径后投射到成像平面上形成倒立的像。三维渲染的透视投影也是同一模型假设摄像机是针孔视锥体是从针孔出发向外发散的锥形区域近裁剪面到远裁剪面之间的部分可以被呈现外面则被剔除。投影矩阵把一个相机空间里近大远小的视锥体压成一个NDC立方体方便GPU做裁剪和除法。5.2 投影矩阵的参数推导构建透视投影矩阵需要四个参数垂直视场角FOV、宽高比aspect、近裁剪面距离near、远裁剪面距离far。其中FOV决定镜头的广角程度Unity/Unreal的Camera组件里这个参数叫Field of Viewaspect通常等于屏幕宽度除以高度。一个标准透视投影矩阵OpenGL风格NDC z范围[-1,1]长这样mat4 perspective(float fov, float aspect, float near, float far) { float f 1.0 / tan(fov / 2.0); return mat4( f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far near) / (near - far), 2 * far * near / (near - far), 0, 0, -1, 0 ); }初看这矩阵很抽象逐行拆开其实就三件事x、y方向上把视场角内的范围压缩到[-1,1]z方向把[near, far]的深度映射到[-1,1]把w分量设置成视图空间深度的相反数为透视除法做准备。投影矩阵不改变x/y的符号但会在透视除法里让远处的顶点向画面中心收缩这就是远小近大的来源。5.3 深度精度的工程问题投影矩阵出来后一个重要问题是深度缓冲精度。在透视投影下z在视图空间里到NDC的映射是非常不均匀的near附近一小段深度占用了NDC里一大段数值区间而far附近一大段深度只占用很窄的区间。结果就是远处物体深度值挤在一起深度测试时几乎分不清谁前谁后表现为远处的墙面和贴画疯狂闪烁俗称z-fighting。解决思路有两个方向。一个是尽量扩大near、缩小far把比例控制在一个合理范围经验上near/far不要小于0.001这个量级极端场景甚至建议near不小于0.01、far不大到离谱。另一个是现代引擎普遍采用的Reversed-Z方案把NDC里的深度方向反转让远处映射到0、近处映射到1配合浮点深度缓冲能够极大改善远处深度精度。这个方案在Unity High-Definition管线里是默认开启的自己写引擎时值得研究一下。5.4 和正交投影的对比还有一个经常被忽略的对比正交投影矩阵不改变w分量本质上是把视锥体当成一个长方体来处理所以没有近大远小。这在2D UI、小地图、编辑器线框模式里很常用。不要动不动就在3D场景里用正交投影否则玩家会以为游戏坏了。我见过一个项目把Camera的Projection从Perspective误切到Orthographic角色全变成平面纸片排查半天才想起来是这个参数。6. 我踩过的坑坐标系约定、矩阵顺序和万向节死锁6.1 左手系还是右手系先定规矩否则处处是坑游戏引擎最基本的约定之一就是坐标系的手性。左手坐标系里Z轴指向屏幕内Unity使用左手坐标系严格说Unity世界空间是左手OpenGL和多数图形学教材是右手坐标系Z轴指向屏幕外即摄像机前方为-Z。手性影响的不仅是Z轴朝向还有叉积的方向、正旋转的绕向、背面剔除的默认规则。我自己的引擎当初为了和OpenGL示例代码对齐选了右手系后来接游戏逻辑资产时美术导出的FBX是按左手系做的所有模型导入后都镜像翻转。最终定下的方案是引擎内部坚持右手系导入资产时在DCC工具里统一转坐标轴而不是在引擎里做镜像矩阵——因为镜像矩阵会改变三角形绕序影响背面剔除和法线牵一发动全身。6.2 矩阵乘法顺序谁先谁后直接影响旋转中心前面说了TRS的顺序这里再补充一个实操经验。当用代码调试模型位置时我习惯按这个顺序检查结果先用单位矩阵把模型放在世界原点然后单独设置缩放确认模型尺寸正常再单独设置旋转确认朝向正确最后加平移确认定位正确。每一步都用引擎的调试相机看着模型变化而不是一上来就把完整矩阵塞进去。有一次模型在世界坐标里歪了45度我以为是旋转矩阵参数搞错了最后发现是缩放矩阵里把X和Y轴数值写反模型被拉斜了。分步调试是定位这类问题最快的办法。6.3 万向节死锁欧拉角的隐性坑用欧拉角表示朝向时最经典的坑是万向节死锁旋转到某个特定姿态一般是俯仰角为±90度附近后两个旋转轴重合丢失一个自由度导致模型无法平滑旋转到某些方向。具体到代码层面当你在引擎里用Transform.eulerAngles做动画把X轴旋转到90度附近再转Y轴会发现模型的运动突然变得反常Y轴的旋转不再按预期生效反而影响了X轴的表现。这就是死锁现象。解决思路是用四元数做旋转计算只在最终输出矩阵时转成旋转矩阵。我在引擎里所有长期有效的朝向都用四元数保存欧拉角只用于编辑器的数值显示和人工输入涉及连续动画的旋转一律走四元数插值slerp。6.4 调试空间变换的小工具最后分享一个让我少熬几个夜的小工具写一段调试代码给定一个已知顶点比如模型空间原点附近的(1,0,0)分别输出乘以世界矩阵、视图矩阵、投影矩阵之后的中间坐标再手动验证NDC和屏幕坐标。这段代码放在一个独立的调试窗口里当作空间变换的单元测试。每次改动矩阵代码后跑一遍这个测试如果某个中间坐标不对马上能定位是哪个变换矩阵出问题。更实用的是写一个小脚本把引擎摄像机的位置朝向和一些已知场景物体的变换矩阵打印出来与场景编辑器显示的数字对比。比如把摄像机摆在(0,0,10)面朝原点理论上视图矩阵应该等价于一个坐标轴反转加平移的组合如果打印出来的矩阵和手算的不符那基本可以断定是矩阵乘法顺序或转置问题。这种手算验证虽然土却是理解空间变换最快的方式。结尾一点个人体会空间变换这块内容学的时候容易觉得不就是矩阵乘法嘛实际写引擎时才会发现坑全藏在坐标系约定和矩阵顺序里。我个人的建议是不要急着把渲染流水线里的矩阵全都推一遍先把模型空间→世界空间→视图空间→裁剪空间→屏幕空间这条链路上每个矩阵的作用和手性约定搞清楚再进着色器代码思路会顺很多。如果你正好在写自己的引擎或者刚接手渲染部分建议把上面那个单元测试小工具尽早实现。我见过太多同学在debug时靠肉眼对着屏幕猜猜来猜去效率极低。坐标变换不像游戏逻辑它是纯数学任何一步错了都能通过中间坐标精确定位到矩阵没有玄学余地。把这一步做好后面的光照、阴影、PBR都是在这个稳固的地基上往上盖越晚回头补这个基础成本越高。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑