资讯动态

渲染流水线与空间变换:从顶点数据到屏幕像素的底层拆解

发布时间:2026/10/7 1:57:08 来源:尧图企业网站定制
说真的如果让我在游戏引擎里挑一个最容易让人一头撞墙的概念空间变换和渲染流水线绝对排前两名。很多刚接触引擎底层的同学要么在坐标系里绕晕要么对着渲染流程满脸问号顶点数据到底经过了哪些阶段才变成屏幕上的像素模型旋转、位移的矩阵乘法顺序为什么偶尔会搞出奇奇怪怪的形变这篇是引擎原理与实践系列的第三篇我把空间变换和 3D 渲染流水线的主线完整拆开讲一遍。不管你以后是想写自己的渲染器、在 Unity/UE 里排查渲染问题还是纯粹想搞懂显卡到底在替人类做什么这都值得仔细看一遍。内容上不会堆公式堆到劝退每个关键点我都会结合实操讲清楚“为什么”顺手把那些我一路上踩过、调过、摔过的坑也一并交代了。1. 渲染流水线全景从顶点数据到屏幕像素1.1 从数据到像素渲染流水线的完整旅程先建立一个整体的画面感。你可以把渲染流水线想象成一条快递分拣流水线CPU 在后面准备包裹顶点数据GPU 在前面拆包裹、贴标签、装车光栅化、着色、输出。一条标准的实时渲染流水线按顺序大概长这样输入装配Input Assembly从顶点缓冲、索引缓冲里读取原始顶点数据按照索引或图元类型把它们组合成三角形、线段等几何图元。顶点着色器Vertex Shader对每一个顶点执行用户写的程序。最常见的工作就是把顶点从模型空间一路变换到裁剪空间顺便处理顶点动画、蒙皮、法线变换之类的事情。图元组装Primitive Assembly把顶点着色器输出的顶点按照图元信息组装成三角形为后面的裁剪和光栅化做准备。裁剪与背面剔除把视锥体外面的部分裁掉把背对摄像机的面直接扔掉减少后续光栅化的工作量。光栅化Rasterization把数学意义上的三角形离散成一个个覆盖到的像素/片段。这一阶段不执行用户代码是硬件固定完成的。片段着色器Fragment Shader对每个被覆盖的片段执行用户代码贴图采样、光照计算、各种后处理效果基本都在这做。输出合并Output Merger深度测试、模板测试、颜色混合都在这发生最后把像素颜色写入颜色缓冲生成你眼前看到的画面。把这套流程走一遍一个 3D 模型就完成了从顶点数据到屏幕像素的旅程。注意一个关键认知顶点着色器是逐顶点执行片段着色器是逐像素执行。顶点数通常远小于像素数所以复杂特效的成本大头往往压在片段着色器上。1.2 可编程与固定功能现代 GPU 的开与关老一代图形 API 的固定渲染管线里几乎所有阶段都由硬件写死开发者能改的只有少数状态开关。现代 APIDX11 以上、OpenGL 3.0 以上、Vulkan、Metal把顶点着色器和片段着色器做成了可编程阶段输入装配、裁剪、光栅化、输出合并这些仍然由硬件固定完成。阶段是否可编程主要负责什么输入装配否按顶点格式和索引组织数据顶点着色器可编程空间变换、顶点动画、蒙皮图元组装与裁剪否生成三角形、裁剪超出视锥体的部分光栅化否三角形离散成像素片段片段着色器可编程光照、纹理采样、颜色计算输出合并否深度、模板、混合、写入帧缓冲理解这一点的意义在于定位渲染问题时要立刻知道该去改哪一部分。画面几何位置不对多半在顶点着色器和矩阵上颜色和材质不对多半在片段着色器上物体边缘异常、遮挡关系不对多半在光栅化状态和深度测试上。2. 空间变换物体是怎么“进入”屏幕的2.1 五大坐标空间在 3D 渲染里同一个物体从建模到显示会依次出现在五个坐标系里模型空间Model Space3D 美术在建模软件里建模时的坐标原点一般在模型中心或脚底。Blender 和 Maya 这类软件默认轴向不一样导入引擎时经常需要做旋转校正。世界空间World Space把模型摆进游戏场景后所有物体共享同一个世界坐标系。位置、旋转、缩放都通过一个世界矩阵也叫模型矩阵来描述。视图空间View Space以摄像机为原点建立的空间。摄像机朝哪看哪里就是它的前方。世界空间里的物体换到视图空间后相当于从摄像机的“视角”来描述场景。裁剪空间Clip Space经过投影矩阵变换后的空间。视锥体在这里变成一个标准立方体不同 API 范围不一样超出立方体的顶点会被裁剪掉。屏幕空间Screen Space透视除法加视口变换之后坐标就变成了屏幕上真正的像素坐标。你可以这样理解模型空间是“模型自己的本地坐标”世界空间是“整个游戏世界的地图坐标”视图空间是“摄像机眼里看到的坐标”裁剪空间是“摄像机取景框里的坐标”屏幕空间就是“屏幕上真实呈现的坐标”。2.2 用矩阵完成变换齐次坐标的由来为什么要引入 4x4 矩阵和四个分量x, y, z, w因为 3x3 矩阵只能表示线性变换也就是旋转、缩放这类操作没法表示平移。平移是仿射变换光靠 3x3 矩阵搞不定所以引入了齐次坐标用 4x4 矩阵统一处理平移、旋转和缩放。缩放矩阵长这样Sx 0 0 0 0 Sy 0 0 0 0 Sz 0 0 0 0 1平移矩阵长这样1 0 0 Tx 0 1 0 Ty 0 0 1 Tz 0 0 0 1绕 x 轴旋转 θ 角度的矩阵1 0 0 0 0 cosθ -sinθ 0 0 sinθ cosθ 0 0 0 0 1向量的 w 分量很关键w 为 1 表示这是一个“点”平移对它有影响w 为 0 表示这是一个“方向”平移对它没有影响。变换矩阵的最后一列/最后一行正是用来区分点和方向的。在现代图形 API 的主流数学库里约定的是“列向量左乘矩阵”也就是 v M * v。连续变换读起来是从右往左读先旋转再平移代码写 T * R * v。如果你用的是行向量的数学库方向会反过来并且矩阵要转置。这个坑我在工程里见过不止一次Unity 的某些接口、老 D3DX 库、还有一些自研数学库容易把这套约定搞混结果就是模型歪斜或者镜像。2.3 MVP 矩阵的乘法顺序把模型摆进屏幕的核心组合是 MVP 矩阵M模型矩阵、V视图矩阵、P投影矩阵。顶点从模型空间变换到裁剪空间的完整公式是v_clip P * V * M * v_local顺序不能乱。先乘 M 进入世界空间再乘 V 进入视图空间最后乘 P 进入裁剪空间。很多新手写成 MVP M * V * P结果坐标全乱。正确的合矩阵应该是 P * V * M代码里可以用一个组合矩阵直接传给着色器。如果模型发生了非均匀缩放比如把一个模型拉伸成瘦长条法线不能直接乘 M一个常见的坑。正确的做法是用 M 的逆转置矩阵inverse transpose去变换法线否则光照会算错模型表面会出现明暗条纹。这个细节当年折腾了我很久属于典型的“画面看起来没坏但总感觉哪里不对劲”的问题。3. 视图与投影摄像机的“眼眶”和“焦距”3.1 视图矩阵的构造视图矩阵的作用就是把世界空间变换到以摄像机为原点的视图空间。给定摄像机位置 eye、注视点 target、上方向 up构造一个标准 LookAt 矩阵一般是三步计算摄像机的三个正交基向量。右手坐标系下z 轴朝后z normalize(eye - target)x 轴朝右x normalize(cross(up, z))y 轴朝上y cross(z, x)。把这三个向量放进旋转矩阵的分量位置。在第四行/列写入平移部分平移量是这些基向量与 eye 的点积取负数。伪代码大概是这样的右手系、列主序、glm 风格Mat4 LookAt(Vec3 eye, Vec3 target, Vec3 up) { Vec3 z normalize(eye - target); Vec3 x normalize(cross(up, z)); Vec3 y cross(z, x); Mat4 m; m[0][0] x.x; m[0][1] y.x; m[0][2] z.x; m[0][3] 0; m[1][0] x.y; m[1][1] y.y; m[1][2] z.y; m[1][3] 0; m[2][0] x.z; m[2][1] y.z; m[2][2] z.z; m[2][3] 0; m[3][0] -dot(x, eye); m[3][1] -dot(y, eye); m[3][2] -dot(z, eye); m[3][3] 1; return m; }注意如果是 DirectX 这类左手坐标系第三步的 z 方向要反过来z normalize(target - eye)。这一正一反的差别是很多模型“前后反了”、旋转方向相反的根源所在。我之前写引擎调试工具时有一次摄像机天上飞到一半画面开始抽搐找了半天发现就是 up 向量跟视线方向几乎平行叉积结果接近零向量视图矩阵退化成了奇异矩阵。解决方法是做一次垂直性检查当 up 和视线夹角太小时把 up 替换为另一个参考向量。这种问题平时很隐蔽一旦出现就非常难查。3.2 透视投影矩阵透视投影的核心是“近大远小”。透视矩阵把视锥体映射成 NDC 立方体同时把深度信息写进 w 分量让后面的透视除法产生近大远小效果。一个典型的 OpenGL 风格透视矩阵右手系、NDC 深度范围 [-1, 1]f/aspect 0 0 0 0 f 0 0 0 0 (farnear)/(near-far) (2*far*near)/(near-far) 0 0 -1 0其中 f 1 / tan(fov/2)fov 是纵向视场角aspect 是屏幕宽高比。注意矩阵第三行第四列是 -1这个 -1 会让变换后的 w 分量等于视图空间深度的负值也就是 w -view.z。接下来硬件会对所有顶点做透视除法v_ndc v_clip / v_clip.w。这个除法让更远的物体 w 更大映射到 NDC 上数值更小于是有了近大远小。实际工程里构建透视矩阵时直接用现成数学库是最省事的但我建议每个做引擎的人至少手推一遍这个矩阵因为很多深度精度问题、相机抖动问题到最后都绕不开对投影矩阵的理解。比如 near 设得特别小、far 设得特别大时深度缓冲的精度会被严重拉低远处的物体就会闪烁出现 z-fighting。3.3 正交投影与 NDC 细节正交投影不产生近大远小它把视锥体直接压成一个长方体。适合做 UI、2D 渲染、编辑器的线框预览、以及某些需要等距效果的场景。矩阵形式是2/(r-l) 0 0 -(rl)/(r-l) 0 2/(t-b) 0 -(tb)/(t-b) 0 0 -2/(f-n) -(fn)/(f-n) 0 0 0 1r、l、t、b、f、n 分别是右、左、上、下、远、近六个平面的坐标。正交矩阵的 w 分量恒为 1不会产生透视效果。关于 NDC不同图形 API 的深度范围有差异OpenGL 传统上是 [-1, 1]DirectX 是 [0, 1]。如果你在 DX 里移植一个 OpenGL 的阴影采样或者深度贴图代码不去改这个范围阴影会整个错位。金属 Vulkan 的范围又是 [0, 1]而且 y 轴朝向和 OpenGL 相反。这些细节不搞清楚跨 API 移植就是一场噩梦。4. 光栅化与片段处理三角形到像素的最后一公里4.1 三角形如何变成像素顶点着色器算完裁剪坐标之后硬件做两步先做裁剪和透视除法再把 NDC 坐标映射到屏幕像素坐标视口变换然后光栅化器开始判断每个像素到底在不在三角形内部。光栅化用的常见做法是边缘函数Edge Function。一条边的方程可以写成 E(x, y) (x - x0) * dy (y - y0) * dx 的形式不同实现符号不一样如果某个点对三角形的三条边都落在同一侧那这个点就在三角形内部。硬件会遍历覆盖这个三角形包围盒的所有像素逐点判断归属。现代 GPU 并不是逐像素扫描这么简单而是把屏幕分成许多小块tile多个三角形并行处理遇到覆盖率低的小块可以直接跳过。但理解边缘函数和重心坐标的模型足够你写一个软渲染器或者调 bug 了。我强烈建议新手用 CPU 写一个最简单的软光栅化器不需要 GPU就画一堆彩色三角形你会瞬间明白顶点到像素之间到底发生了什么。4.2 插值的陷阱透视校正插值光栅化不仅要判断像素在不在三角形内还要把顶点着色器输出的属性颜色、UV、法线等插值到片段上。最直观的插值是重心坐标线性插值已知三角形三个顶点的属性值 A、B、C像素点的重心坐标是λ1, λ2, λ3那么该像素的属性 λ1A λ2B λ3*C。问题来了如果直接在屏幕空间做线性插值透视投影下会出严重的失真纹理看起来会像贴着三角形滑动这就是“透视畸变”。因为屏幕空间的线性关系对应到 3D 空间是非线性的。解法是透视校正插值先按 1/w 做插值再除以 1/w 的和。伪代码denom lambda1/w1 lambda2/w2 lambda3/w3 interpolated_attr (lambda1*attr1/w1 lambda2*attr2/w2 lambda3*attr3/w3) / denomGPU 的硬件光栅化器默认会做这一步所以平时感觉不到。但你在自己写软渲染、做某些自定义插值的 Shader 时忘了这一步就会看到贴图扭成麻花。这个坑属于典型的“原理不熟就看不见原因”的问题。4.3 深度测试与背面剔除深度测试是所有遮挡关系的核心。GPU 维护一张深度缓冲z-buffer每个像素记录目前最近的深度值。新片元写入前先和深度缓冲比一比离摄像机更近才允许写入否则直接丢弃。现代 GPU 还会做 early-z 剔除在片段着色器之前就先做深度判断减少无效计算。背面剔除是通过顶点绕序来判断三角形是否朝向摄像机。左手坐标系下通常逆时针为正面右手坐标系下通常顺时针为正面但这个约定在不同 API 里会反过来不同引擎里也会反过来。如果模型出现“正面透明、背面实体”的诡异状态十有八九是绕序判断反了排查时可以直接把背面剔除关掉看看三角形是不是还在。关闭后正常但打开后消失就说明是绕序问题这时翻转索引顺序或者调整剔除模式都行。5. 常见渲染问题的现场排查5.1 模型不显示、黑屏、闪烁新手最常遇到的就是明明代码写完了屏幕上什么都没有。按优先级排查三角形绕序是否正确。先把背面剔除关掉如果模型立刻出现就是绕序反了。裁剪空间是否正常。检查顶点着色器输出的 w 是否有负数或接近 0检查矩阵是否乘反。打印一下 MVP 矩阵看一眼别嫌土。视口是否设置。没有设置视口的话三角形会被映射到屏幕之外或者干脆被裁掉这个问题经常忽略。深度测试状态。没有启用深度测试或深度缓冲没 attach 上的时候远处物体可能挡住近处物体画面就会乱七八糟。闪烁z-fighting最常见的原因是 near/far 比例太大深度精度不够。比如 near0.0001、far10000 这种组合远处的深度值几乎没有任何区分度。解决方法是把 near 尽量调大far 只要能覆盖场景就行不要贪大。5.2 矩阵、坐标系和深度精度矩阵相关的问题有三大来源行主序/列主序、左手/右手、NDC 深度范围。我整理一个速查表遇到“模型反了、旋转反了、深度反了”时可以快速对照症状可能原因检查方向模型左右镜像行列主序转换忘了转置检查上传矩阵时是否调用 transpose旋转方向反了左右手坐标系混用检查视图矩阵 z 轴方向深度测试全乱NDC 范围不匹配 API检查投影矩阵的深度范围模型被拉长/压扁宽高比设置不对检查 aspect 是否等于窗口宽高比画面消失在远处far 设置过近或矩阵 w 异常检查投影矩阵参数5.3 调试工具与好习惯调渲染相关的 bug最忌讳的就是同时改很多变量。我自己的经验是只用最简单的几何体比如一个 Cube去定位问题把材质改成纯色把光照关掉先确认几何位置和深度关系再逐渐叠加复杂内容。第二是善用可视化。把法线、UV、世界坐标、深度值作为颜色输出到屏幕上一眼就能看出数据是不是合理的。我早期调法线方向时直接把法线当颜色输出看到模型的法线数值全是负数立刻意识到是变换矩阵出了问题省了大量时间。第三是 CPU 端模拟。用同样的矩阵在 CPU 上做一次变换把变换后的坐标打印出来和 GPU 结果对比。很多矩阵问题靠这种方式可以迅速定位因为在 Shader 里加日志太麻烦但 CPU 上可以随意断点和打印。最后聊两句这些年我最大的感受是渲染管线里的坑绝大多数不是代码写错而是坐标系、矩阵顺序、深度范围这些“看不见的约定”出了问题。很多开发者的数学基础其实不差差的是没把脑子里的坐标系和硬件里的坐标系打通。另一个让我印象深刻的经验是view 矩阵的 up 向量和视线方向平行时整个矩阵会退化画面会出现一种特别诡异的“看不见但也没报错”的状态。这是我做编辑器相机时真实遇到的排查了两天才意识到是 lookAt 的数值边界问题。做引擎的人总会在这种你以为永远不会出问题的地方翻车。如果你现在正被矩阵乘法顺序、投影参数、透视校正插值这些事情搞得头大不用焦虑这些都是正常的阶段。把基础流程理一遍用一个小项目亲手跑一遍很多东西自然就通了。后面这个系列我还会继续写光照、纹理采样和 GPU 高效渲染相关的内容但空间变换和渲染流水线始终是所有这些内容的地基。

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

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

免费获取报价 →
↑