CH 01 · 总览与全流程

渲染管线:从 CPU 提交到屏幕像素

这一篇只回答一个问题:我们在场景里摆的那些模型、材质、灯光,到底经过了哪些步骤,才变成屏幕上的像素?

把这条链路完整走一遍之后,再看 Shader 报错、材质发灰、半透明排序错乱这类问题,就能大致判断出它卡在哪一段。

读完这篇,你应该能回答
  • 渲染管线的两大段分别由谁执行、各自负责什么
  • 一个模型从场景数据到像素,中间要经过几次坐标变换
  • 为什么半透明物体必须从后往前画,而不透明物体相反
  • 片元(fragment)里到底带了哪些数据
  • 深度测试、Early-Z、混合各自在解决什么问题

1.1 一条把 3D 变成 2D 的流水线

渲染管线(Rendering Pipeline)是一条流水线:输入是场景里的三维数据,输出是屏幕上的二维像素。 它由两个完全不同的执行者接力完成 —— 前半段跑在 CPU 上,叫"应用程序阶段";后半段跑在 GPU 上,叫"渲染管线"。

CPU 应用程序端 剔除 · 排序 · 打包数据 绘制调用(Draw Call) 提交 GPU 渲染管线 顶点处理 → 图元装配及光栅化 → 片元处理 → 输出合并 ↓↓↓ 帧缓冲 FrameBuffer

之所以强调"接力",是因为这两段的工作性质完全不同:

阶段 执行者 在做什么 是否可编程
应用程序阶段 CPU 决定"这一帧要画哪些物体、按什么顺序、用什么参数" 完全由你的逻辑控制
几何阶段 GPU 把三维顶点变换、裁剪、装配成屏幕上的图元 顶点 Shader 可编程
光栅化阶段 GPU 把图元拆成一堆片元(候选像素) 固定硬件,不可编程
像素处理阶段 GPU 给每个片元算颜色,再决定它能不能写进画面 片元 Shader 可编程
先记住这个定位

所谓的"性能优化",本质上是在这条链路上找到那个最慢的环节。 CPU 端的瓶颈通常是 Draw Call 数量(提交指令的开销),GPU 端的瓶颈通常是填充率(像素数量 × 每个像素的着色开销)。 同一个画面,瓶颈可能在两端中的任意一端 —— 所以优化前必须先确认瓶颈在哪。

1.2 一帧画面的完整旅程

把所有环节串起来,一帧的流程大致是这样。这张图从上往下读:左边一列是 CPU 端的四步 (①~④,正好对应第 2 章的四个小节),下面一行是 GPU 端的四段(对应第 4~7 章), 中间那条箭头就是两者的分界线。

注意 ①~④ 是被"循环"罩住的 —— 每个可见物体都要从头走一遍,所以这几步的开销要乘以物体数量, 这正是 CPU 端优化的重点。另外留意 ④ 的位置:它在 CPU 这一侧,做的是下达命令, Shader 代码真正跑起来是在下面的 GPU 那一段里。

调用 Render() 每帧一次 CPU · 应用程序阶段 ① 剔除 视锥 · 遮挡 · 层级 2.1 节 ② 排序 渲染队列 + 距离 2.2 节 ③ 打包数据 材质参数 · 矩阵 · 光源 2.3 节 ④ 调用 Shader(提交绘制) SetPassCall / DrawCall 2.4 节 下一个物体 循环次数 = 这一帧的物体数 提交(一个物体的命令) ④ 只是下达命令,不执行 Shader 代码 GPU · 渲染管线(第 3 ~ 7 章) 顶点处理 每个顶点执行一次 vert 第 4 章 图元装配及光栅化 裁剪 · 剔除 · 生成片元 第 5 章 片元处理 每个片元执行一次 frag 第 6 章 输出合并 测试 · 混合 · 写入 第 7 章 帧缓冲 FrameBuffer 颜色 / 深度 / 模板 三块缓冲 后处理(调色 · Bloom)→ 渲染目标 → 屏幕

帧缓冲画完之后,通常还要再走一遍后处理(Post Processing):把整张画面当成一张纹理, 做调色、Bloom、景深、抗锯齿之类的处理,最后才送到显示器。这一篇主要讲主流程,后处理留到后面单独展开。

注意一个容易混淆的地方

"渲染管线"这个词有狭义和广义之分。狭义指 GPU 上的那一段(顶点 → 光栅化 → 片元 → 输出合并); 广义则包含 CPU 应用程序端。本文用的是广义,因为实际优化时两段都得看。

2.1 剔除:先把看不见的扔掉

一个场景里可能有成千上万个物体,但摄像机一帧里能看到的往往只有几百个。 如果把这些看不见的物体也提交给 GPU,等于白白浪费了整条链路的开销 —— 所以应用程序阶段的第一件事就是剔除(Culling)。

相机 视锥体 View Frustum 保留:进入下一步 剔除:不提交 剔除

Unity 里常用的剔除手段有这么几类,它们会叠加生效:

  • 视锥体剔除(Frustum Culling) —— 用物体的包围盒与相机视锥做相交测试,完全在视锥外的直接排除。这是引擎自动做的,不需要你写代码。
  • 层级剔除(Layer Culling Mask) —— 通过 Layer 和相机的 Culling Mask 匹配,手工控制"这个相机不看某类物体"。适合做小地图相机、UI 相机这类特殊视角。
  • 遮挡剔除(Occlusion Culling) —— 判断物体是否被前面的墙体完全挡住。需要预先烘焙遮挡数据,对室内场景收益很大,对开阔场景往往没效果。
关于遮挡剔除

遮挡剔除需要在编辑器里烘焙 Occlusion Culling 数据,且运行时有一定 CPU 开销。 它不是万能的:如果场景里没有明确的遮挡关系(比如一望无际的野外),开了也白开。 判断依据是 Profiler 里 OcclusionCulling 的开销和它省下的 Draw Call 是否划算。

2.2 排序:决定先画谁后画谁

剔除之后,剩下的物体要排个序。排序规则由两个东西决定:渲染队列(RenderQueue)和与相机的距离。

最关键的是一道分水岭:2500。

渲染队列 RenderQueue 不透明队列 RenderQueue < 2500 1 2 3 4 近 远 从前到后 近处物体先写深度,远处片元直接被挡掉 半透明队列 RenderQueue > 2500 4 3 2 1 远 近 从后到前 远处先画,近处后叠加,颜色才能正确混合
为什么两队的方向是相反的

不透明物体最后只会留下一个像素值,谁在前谁赢。所以让近处的先画,它写入深度之后,远处那些片元在深度测试阶段就被挡掉了,像素着色根本不执行 —— 这是纯粹的省算力。

半透明物体要和背后的画面做混合(Blending),混合是有顺序的: 结果 = 前景色 × α + 背景色 × (1 − α)。 必须先把远处的画上,再用近处的往上叠加,颜色才符合直觉。反过来画就会得到错误的结果。

2.3 打包数据:把指令准备好

排好序之后,CPU 要为每个物体准备一份"绘制指令包"。这一包里装的东西大致分四类:

类别 具体内容 说明
模型信息 顶点坐标、法线、UV、切线、顶点色、索引列表 来自网格资源,决定"形状长什么样"
变换矩阵 世界变换矩阵、VP 矩阵(View × Projection) VP 矩阵由相机位置与 fov 等参数算出,一帧内基本不变
光照与材质参数 光源信息、材质参数(颜色、贴图、数值) 就是你在 Inspector 里调的那些值
渲染状态 使用哪个 Shader、混合模式、深度写入开关等 这类状态一旦变化,就会打断合批

这里有个容易被忽略的点:打包数据的成本本身就和物体数量成正比。 哪怕某个物体最终只占屏幕几个像素,这份"包"该准备还是得准备 —— 这就是为什么减少物体数量(而不是减少像素)能直接优化 CPU 端。

2.4 绘制调用:交给 GPU

数据准备好后,CPU 通过图形 API 把指令提交给 GPU。这里有两个经常被混着说的概念:

  • SetPass Call —— 切换 Shader / 渲染状态的次数。切换一次,GPU 就要重新配置一遍管线状态,开销较大。
  • Draw Call —— 提交一次"画这个网格"的调用。数量直接影响 CPU 的提交开销。
渲染循环的简化结构(伪代码)
// 一帧的大致结构
Render() {
  // ① 剔除:拿到这一帧真正要画的物体列表
  var visible = Culling(camera);

  // ② 排序:按 RenderQueue 和距离排好序
  Sort(visible);

  // ③ + ④ 逐个物体打包并提交
  foreach (var obj in visible) {
    SetPass(obj.material.shader);   // 状态变了就产生一次 SetPass Call
    SetupUniforms(obj.matrixVP,      // 变换矩阵
                  obj.material,      // 材质参数
                  lights);           // 光源信息
    DrawCall(obj.mesh);              // 提交绘制
  }
}

所以"优化 Draw Call"并不是指某个 API 很慢,而是指上面这个循环每转一圈的固定开销。 合批(Batching)的意义就是让多个物体共用一次提交,把循环圈数降下来。

3.1 GPU 管线的数据流

数据交到 GPU 手上之后,还要再走几道工序。这一段的特点是:顺序由硬件写死, 能写代码的地方只有两处 —— 顶点处理与片元处理,中间夹着一段完全由硬件执行的固定功能。

3D 场景数据 顶点处理 图元装配 及光栅化 片元处理 输出合并 2D 像素 可编程 固定功能 可编程 固定功能 模型空间顶点 Model Space 裁剪空间坐标 顶点 Shader · MVP 屏幕坐标 透视除法 · 视口变换 图元 → 片元 图元装配 · 光栅化 像素颜色 片元着色 · 写入帧缓冲

把两行对着看就清楚了:上面一行是谁在执行,下面一行是数据此刻待在哪个空间。 同一个三角形会在这条链路上被反复改写 —— 从一堆三维坐标,变成裁剪空间里的四个分量, 再变成屏幕上的二维位置,最后变成一片片的像素颜色。

一句话记住这条链路

顶点处理负责「把顶点摆到正确的位置」,片元处理负责「决定每个像素该是什么颜色」, 中间的光栅化负责「把图元拆成待着色的候选像素」。三段各管一件事,互不越界。

也正因为分工明确,出问题时可以直接按段排查:模型位置不对,看顶点处理; 颜色不对,看片元处理;锯齿、漏缝、边缘闪烁,看光栅化。

3.2 Shader 代码与管线的对应

上面这张图听起来抽象,但落到代码上其实非常具体:一个最简 Shader 里, 几乎每一行都能在链路上找到一个位置。下面这段只做一件事 —— 把贴图原样贴到模型上, 但它的结构是完整的。

一个最简 Shader 的骨架(Unity · ShaderLab)
Shader "Rain/StageMap" {
  Properties {
    _MainTex ("Texture", 2D) = "white" {}
  }

  SubShader {
    Tags { "RenderType" = "Opaque" }   // ⑥ CPU 端据此把它排进不透明队列

    Pass {
      CGPROGRAM
      #pragma vertex vert        // ① 指定顶点处理的入口
      #pragma fragment frag      // ② 指定片元处理的入口
      #include "UnityCG.cginc"

      struct appdata {           // ③ 顶点 Shader 的输入:来自顶点缓冲
        float4 vertex : POSITION;
        float2 uv     : TEXCOORD0;
      };

      struct v2f {                // ④ 顶点 Shader 的输出:会被插值后送给片元
        float4 pos : SV_POSITION;
        float2 uv  : TEXCOORD0;
      };

      v2f vert (appdata v) {
        v2f o;
        o.pos = UnityObjectToClipPos(v.vertex);  // ⑤ 模型空间 → 裁剪空间
        o.uv  = v.uv;                                // 自定义数据原样往下传
        return o;
      }

      fixed4 frag (v2f i) : SV_Target {          // ⑦ 逐片元执行
        return tex2D(_MainTex, i.uv);          // ⑧ 纹理采样:UV → 纹素颜色
      }
      ENDCG
    }
  }
}
代码里的东西 落在哪个阶段 它在做什么
#pragma vertex vert 顶点处理 声明顶点着色器入口,每个顶点执行一次
UnityObjectToClipPos() 顶点处理 内部就是乘上 MVP 矩阵,把顶点送到裁剪空间
struct v2f 顶点 → 光栅化 它的成员会由图元装配阶段按重心坐标插值
SV_POSITION 光栅化 语义标记:这个值要交给硬件换算成屏幕坐标
#pragma fragment frag 片元处理 声明片元着色器入口,每个片元执行一次
tex2D() 片元处理 用 UV 去采样贴图,得到这个片元的纹理颜色
Tags { "RenderType" }、Blend、ZWrite CPU 端 / 输出合并 决定它什么时候画、画完怎么和已有颜色合并
Q:为什么顶点 Shader 里传下去的 uv,到片元里就"自动"变成每个像素一份了?点开看答案

因为插值是光栅化阶段送的。三角形三个顶点各自的 uv 是已知的, 硬件在决定某个像素被这个三角形覆盖时,同时用这个像素在三角形内的重心坐标, 把三个顶点的 uv 混出一个属于该像素的 uv。

这也是为什么 v2f 里的成员可以在片元里直接当"当前像素的值"用 —— 不是它变了,而是每个片元都拿到了一份为自己算好的副本。 代价也随之而来:插值参数越多,光栅化阶段的数据搬运量越大。

4.1 三个矩阵与四个空间

顶点 Shader 里最常见的那一行 UnityObjectToClipPos(v.vertex), 其实是三个矩阵连乘。用照相机打比方最好理解 —— 想拍一张照片,你会做三件事:

  1. 把物体摆好(决定它在世界里的位置和朝向)→ 模型矩阵 M(Model)
  2. 把相机架好(决定从哪儿、朝哪儿看)→ 视图矩阵 V(View)
  3. 按下快门(决定画面范围与透视强弱)→ 投影矩阵 P(Projection)

三件事对应四次空间切换,顶点就是这样一步步被"搬运"到裁剪空间的:

模型矩阵 M 视图矩阵 V 投影矩阵 P 模型空间 顶点原始坐标 世界空间 统一的场景坐标系 相机空间 以相机为原点 裁剪空间 顶点 Shader 的输出 摆好物体 → 架好相机 → 按下快门,三次变换之后顶点才知道自己落在画面的哪个位置

为什么一个 4×4 矩阵就能同时装下旋转和平移?靠的是齐次坐标: 把顶点补一个 w = 1,平移量就能塞进矩阵的最后一行(或最后一列, 看 API 约定),乘法形式因此可以统一。举个最小的例子 —— 把顶点 (0, 2, 3) 平移 (10, 0, −3):

平移矩阵与齐次坐标
// 顶点 p = (0, 2, 3),平移量 t = (10, 0, -3)
[ 1 0 0  10 ]   [ 0 ]   [ 10 ]
[ 0 1 0   0 ] · [ 2 ] = [  2 ]
[ 0 0 1  -3 ]   [ 3 ]   [  0 ]
[ 0 0 0   1 ]   [ 1 ]   [  1 ]

顶点里存的始终是它相对于自身原点的坐标,模型矩阵负责把它换算到场景里; 这一层分离很有用:同一个网格可以被不同物体复用,只是各自带一个不同的模型矩阵而已。

顺序不能换

完整的式子是 p_clip = P · V · M · p_model。 矩阵乘法不满足交换律,把 V 和 M 调个头,相机和物体就会各自乱跑。 顶点 Shader 里之所以能一行写完,是因为这三个矩阵在 CPU 端就已经乘好并作为 uniform 传进来了 (上文的 VP 矩阵 就是 P · V)。

4.2 相机空间是怎么定的

投影矩阵的推导我们先放下,先补上中间那一步:视图矩阵是怎么把整个世界搬到"相机看到的样子"的。 它需要三样输入 —— 相机在哪、看哪里、以及"上"是哪一边:

EYE 相机位置 AT 注视目标 UP 上方向 构造 正交基 forward = normalize(AT − EYE) right = normalize(cross(UP, forward)) up = cross(forward, right) 相机空间的 x / y / z 轴就是这三根向量,原点落在 EYE

换句话说是这样:视图矩阵做的事情,等价于先把世界旋转到相机基上,再把相机平移到原点。 旋转部分是把世界坐标投影到 right / up / forward 三个方向上的分量, 所以矩阵的左上 3×3 部分直接由这三根归一化向量拼成;平移部分则是 −EYE 在这个基下的坐标。

Q:数学上为什么"旋转矩阵的逆 = 它的转置"?点开看答案

因为正交基的列向量两两垂直且长度为 1,用它做成的矩阵是正交矩阵。 正交矩阵满足 R⁻¹ = Rᵀ,所以"把世界转到相机朝向"和 "把相机朝向转回世界"之间,只差一次转置 —— 这也是为什么视图矩阵可以写得那么便宜: 不需要求逆,转置一下就行。

实用结论:如果你自己实现了 lookAt,却忘了归一化 UP(或让 forward 与 UP 共线, 比如相机垂直向下看),叉乘结果会退化成零向量,画面直接黑掉或翻转。 相机俯仰角接近 ±90° 时的"万向节问题"就属于这一类。

坐标系的"手感"差异

观察空间的朝向约定和坐标系手性有关。Unity 用的是左手坐标系,相机在观察空间里朝 +Z 看, UnityObjectToViewPos() 得到的 z 在相机前方为正; 而不少图形学教材(OpenGL 传统写法)用的是右手系,相机朝 −Z 看。 从教材照搬公式时如果发现"整个画面左右反了"或者"模型跑到相机背后了",多半就是这里差了符号。

5.1 硬件操作阶段:一串固定动作

顶点 Shader 吐出裁剪空间坐标之后,程序控制权就交回给硬件了。接下来这串动作全部由固定电路完成, 顺序也不能改:

裁剪空间坐标 透视除法 视锥裁剪 NDC 背面剔除 视口变换 屏幕坐标 图元装配 光栅化 这一整段里没有一行代码是你写的,能改的只有"渲染状态"开关
步骤 输入 → 输出 它在解决什么问题
透视除法 裁剪空间 (x, y, z, w) → (x/w, y/w, z/w) 把"越远越小"真正做出来,w 同时承担了距离信息
视锥裁剪 立方体之外的图元 → 丢弃或沿边界切开 屏幕外、相机背后的东西不必再花算力处理
NDC 三个轴都归一到 −1 ~ 1 给后续所有步骤一个与分辨率无关的统一坐标系
背面剔除 图元 → 朝向背面的图元被丢掉 封闭物体约有一半三角形看不见,直接省掉
视口变换 NDC (−1 ~ 1) → 屏幕坐标(像素) 把数学坐标映射到实际分辨率与窗口区域
图元装配 顶点列表 → 点 / 线 / 三角形 让零散的顶点"组成"可以被光栅化的图元
光栅化 图元 → 片元列表 决定这个三角形到底覆盖了屏幕上哪些像素
为什么说这一段"不可编程"

因为这些操作的语义在所有 GPU 上都是一致的,硬件直接做进电路最划算。 你能做的是通过状态开关影响它的行为:Cull Back / Front / Off 控制剔不剔背面、 ZWrite / ZTest 控制深度怎么写怎么比、 Blend 控制最后怎么混合 —— 这也是"渲染状态"这个词的来历: 它们不改算法,只改参数。

顺带一提,剔除和排序这两件事在 CPU 端和 GPU 端都各有一份: CPU 端剔的是整个物体(视锥剔除、遮挡剔除),GPU 端剔的是三角形(背面剔除、视锥裁剪)。 前者省的是提交开销,后者省的是光栅化开销。

5.2 光栅化到底输出了什么

光栅化做的事情可以用一句话概括:拿三角形去覆盖像素网格,逐个判断像素中心有没有落在三角形里面, 落在里面的就算被覆盖,生成一个片元。它输出的不是"像素",而是"候选像素"的清单。

像素中心 落在三角形内 → 生成片元 像素中心 落在三角形外 → 不生成片元 被覆盖的像素中心各生成一个片元,没被覆盖的像素完全不参与着色

22 与 23 都不重要,重要的是这个换算关系:片元数量取决于覆盖面积,而不是取决于模型有多少顶点。 所以一个顶点数很少的大平面(比如整屏的地面、天空盒)反而可能成为性能杀手。

片元里带着什么

光栅化在生成片元的同时,会把顶点 Shader 传下来的 v2f 成员按重心坐标插值成属于这个像素的值。 常见的片元属性有:

  • 深度值 z —— 必须有。输出合并阶段的深度测试全靠它,缺了它遮挡关系就无从判断
  • 位置 —— 屏幕坐标(以及可选的世界坐标),做投影、雾效、折射时要用
  • 法线 —— 光照计算的主角,通常还要带切线以便做切线空间法线贴图
  • UV —— 采样贴图的地址,多重 UV(UV0 / UV1)常用于光照贴图与细节贴图
  • 顶点色 —— 便宜的遮罩与色调信息,常用于风格化渲染
  • 你自己加的自定义数据 —— 从顶点 Shader 一路插值下来的任何东西
片元 ≠ 像素

片元是"可能写进这个像素的一次候选"。它还要过输出合并那一关:Alpha 测试、模板测试、深度测试, 最后混合。通关了,它才真正变成屏幕上的一个像素;被挡住的片元不会留下任何痕迹 —— 但它的着色开销可能已经花掉了(这正是 Early-Z 想解决的问题)。

Q:既然片元可能被丢掉,为什么还先给它算颜色?点开看答案

因为传统管线里,片元着色发生在深度测试之前:硬件不知道这个片元最后能不能留下, 所以只能按顺序把颜色算完再说。这就产生了"过度绘制"(Overdraw)—— 同一个像素被着色了好几次,最后只有一次被留下。

理想的渲染顺序(不透明从前往后)能大幅缓解这个问题,而 Early-Z 更进一步: 把深度测试提前到片元着色之前,让被遮挡的片元连颜色都不用算。 具体条件我们放到 7.2 节说。

6.1 纹理技术:从 UV 到纹素

片元 Shader 的主要任务是给这个片元定一个颜色,而颜色通常来自两件事: 从贴图里取样(纹理技术),再按光照模型算一遍(光照计算)。同一个模型, 没贴图看起来是"灰模",贴上纹理再叠上光照,才有材质感。

先解决取样这件事。顶点里带的 UV 是一对 [0, 1] 范围内的归一化坐标, 而贴图上真正被读写的最小单位是纹素(texel)。把 UV 换算成纹素地址, 需要一步缩放:

v u u = 0.5,v = 0.5 × 尺寸 采样点 5 个纹素的贴图、u = 0.5:算出的纹素地址是 2.0,正好卡在两个纹素边界上

换算公式是 纹素地址 = u × 宽度 − 0.5(减 0.5 是因为纹素中心落在 (i + 0.5) / 宽度 上)。上面这张图里,地址算出来是 2.0 —— 一个边界值, 它不该只取某一个纹素,而是要看周围四个纹素中心的脸色。这就是过滤要解决的事。

纹素取样这件事,工程上要回答五个问题:

  • 怎么采样 —— UV 与纹素地址之间的换算,以及 UV 超出范围怎么办
  • 怎么过滤 —— 地址落在纹素之间时,取最近的那个,还是把周围几个加权平均
  • 远距离怎么办 —— Mipmap:贴图变小之后按哪一级采样
  • 怎么寻址 —— 平铺、截断、镜像还是填边框色
  • 怎么压缩 —— 显存和带宽有限,纹理格式怎么选

过滤:最近邻与双线性

最近邻(Nearest)直接取地址最近的那个纹素,速度最快,边缘会有明显锯齿, 像素风或需要保留硬边时反而是优点。双线性(Bilinear)取周围四个纹素中心, 按距离加权平均,视觉上平滑得多,代价是每个片元要做 4 次取样 + 3 次插值。

双线性只处理"同一级贴图内"的插值。如果把 Mipmap 的层级也一起算进来, 就是三线性过滤(Trilinear)—— 在两级 Mipmap 之间再做一次插值,代价是 8 次取样, 但能让远处的地面在移动时不再"跳变"。

Mipmap:用分辨率换稳定

Mipmap 是一张贴图的"金字塔":第 0 级是原图,第 1 级是长宽各缩小一半的版本, 依次递减到 1×1。远处的表面投射到屏幕上可能只占几个像素, 此时按原图采样会出现严重的闪烁(摩尔纹)—— 硬件会根据这个像素在 UV 空间的覆盖范围(footprint)自动挑一个合适层级。

Mipmap 的显存账

整条 Mipmap 链的总大小约等于原图的 1.33 倍(1 + 1/4 + 1/16 + …)。 它是"用 33% 的显存换掉远处闪烁",通常值得;但对只在小范围显示、 或尺寸本来就小的贴图,也可以关掉,换来更紧凑的显存占用。

寻址模式:UV 跑到 [0, 1] 之外

模式 行为 典型用途
Repeat(重复) 取小数部分,图像无缝平铺 地面、墙面、栅格这类可平铺材质
Clamp(截断) 超出部分一律取边缘那一圈纹素 单张海报、需要"拉伸边缘"做保护的贴图
Mirror(镜像) 每跨越一次边界就翻转一次,再平铺 希望减弱平铺规律性的图案
Border(边框色) 超出部分返回指定的边框颜色 特殊遮罩、阴影贴图的越界保护

压缩:省下的是带宽,赔上的是精度

纹理压缩和 PNG / JPG 不是一回事:图片格式要在运行时解压成完整的 RGBA, 而 GPU 纹理格式是硬件直接随机读取的块压缩,块与块之间互不依赖。 所以它是"显存 + 带宽"的优化,而不是"文件体积"的优化。以一张 88 × 88 的贴图为例:

格式 单张体积 特点
RGBA32(未压缩) 约 30 KB 最精确,显存与带宽开销也最大
ASTC 4×4 约 7.6 KB 按 4×4 像素块压缩,质量与体积平衡好,移动端主流
ETC2(8 bit/像素) 约 7.6 KB 兼容性广;要求 2 的幂尺寸,例如 128 × 128
PVRTC(4 bit/像素) 约 8 KB(按 128 × 128 计) iOS 传统格式,必须 2 的幂且长宽相等

放到整张图上看更直观:一张未压缩的贴图可能占 17 MB,转成 ETC 之后约 3 MB, 换成更大压缩块的 ASTC 6×6 只有约 2.5 MB。反过来,格式选得太保守 (比如为了保精度全部不压缩),带宽压力会直接体现在帧率上。

纹理的账要算在"带宽"上

采样本身消耗的是显存带宽:采样次数 × 每次读取的字节数 × 像素数量。 这解释了三个常见做法的动机 —— 大贴图降分辨率、贴图压缩、减少采样次数 (比如别在同一张贴图上做三次 tex2D)。 当 GPU 端成为瓶颈时,先看纹理,往往比优化光照代码更有效。

6.2 光照:直接光与间接光

颜色有了纹理做底,接下来要靠光照把它"点亮"。这里先建立一个最重要的二分法: 光到表面的路径只有两种 —— 直接来的,和反弹来的。

光源 物体表面 相机 环境与其它表面 直接光:光源 → 表面 → 相机 间接光 反弹之后再进相机 (环境、其它物体、自身凹凸都在贡献)
光照类型 光线路径 实时渲染里怎么近似
直接光 光源 → 表面 → 相机,一次命中 逐像素用光源位置与法线算,就是 N·L 那一项
间接光 光源 → 其它表面 →(多次反弹)→ 表面 → 相机 预计算:光照贴图、光照探针、球谐、反射探针

差别在于成本:直接光可以实时算,间接光基本都要预计算或近似。 完整解一遍"无数条光线在场景里来回弹"就是全局光照(GI), 实时渲染做不到,于是有了后面 6.4 节那套"拆开近似"的框架。

6.3 Phong 模型与它的三项分解

有了光的方向,接下来要决定"表面被照亮之后是什么颜色"。最经典的模型是 Phong, 它把颜色拆成三项相加,其中两项只跟光与表面的几何关系有关:

表面 P N 法线 L 指向光源 V 指向相机 R 反射方向 θ α 漫反射看 θ(N 与 L 的夹角)| 镜面反射看 α(R 与 V 的夹角)
项 计算方式 依赖什么 直观理解
漫反射 Diffuse max(N·L, 0) × 材质颜色 法线、光源方向 只要"朝向光源"就亮;与视线无关,所以转动相机不会改变亮度
镜面反射 Specular pow(max(R·V, 0), smoothness) 反射方向、视线、光滑度 高光斑:表面越光滑,亮点越小越锐,也越容易随视角抖动
环境光 Ambient 常数,或从光照贴图 / 探针采样 间接光信息 兜底项,避免背光面死黑;廉价但容易让画面发灰
Phong 三项的逐像素写法(HLSL 片段)
float3 N = normalize(i.normal);
float3 L = normalize(_LightPos.xyz - i.worldPos);  // 表面 → 光源
float3 V = normalize(_WorldSpaceCameraPos - i.worldPos);  // 表面 → 相机
float3 R = reflect(-L, N);                      // L 的镜面反射方向

float ndl  = saturate(dot(N, L));                    // ① 漫反射系数
float spec = pow(saturate(dot(R, V)), _Smoothness); // ② 高光系数

float3 color = _BaseColor.rgb * ndl  * _LightColor.rgb
             + _SpecColor.rgb * spec * _LightColor.rgb
             + _AmbientColor.rgb;                          // ③ 环境项
两个 saturate 不是可省的花架子

dot 的结果可能是负数:N·L < 0 说明光在表面背面, R·V < 0 说明视线在高光的另一侧。这两种情况都应该得到 0, 而 pow() 无法把负号变成 0 —— 负数的奇次幂还是负的, 混进颜色就会出现黑斑或异色。所以先 saturate 再取幂是标准写法。

另外,_Smoothness 越大高光越"紧", 高光斑越小、越依赖精确的法线,也就越容易暴露模型精度不足的问题; 调参时如果高光一直在闪,多半应该往降低光滑度或提高法线贴图精度的方向走。

6.4 光照分析的基本框架

直接光与间接光、漫反射与镜面反射,这两组二分法一交叉,就得到了一张 2 × 2 的表格。 它是排查光照问题的第一张地图:任何一项光照,都落在这四个格子里。

漫反射 Diffuse 镜面反射 Specular
直接光 Direct 主光源的 N·L 项,实时可算 主光源的高光斑,实时可算
间接光 Indirect 环境漫反射:光照贴图 / 光照探针 / 球谐(SH) 环境镜面:反射探针 / 基于图像的照明(IBL)

这四个格子加起来,就是一张材质最基础的"光照账本":

从基础框架到成品光照
// 基础框架:四个格子相加
直接光漫反射 + 直接光镜面反射 + 间接光漫反射 + 间接光镜面反射

// 在它之上继续加细节
+ 环境光遮蔽 AO        // 压暗缝隙与夹角,让间接光"讲道理"
+ 反射 Reflection      // 屏幕空间反射 / 平面反射
+ 次表面散射 SSS       // 皮肤、玉石:光穿透后再溢出
+ More…               // 各项目按需叠加

= 最终颜色
遇到"光照不对"时的排查顺序

先看直接光两个格子:光源角度、强度、是否被阴影挡住 —— 这一层永远实时、最容易验证。 再看间接光漫反射:光照贴图有没有烘焙过、探针有没有覆盖到这个区域, 表现通常是"物体像贴纸一样浮在环境里"。 最后看间接光镜面:反射探针的分辨率与粗糙度是否匹配, 表现是"金属要么死黑,要么像镜子一样假"。

顺序很重要:先把直接光的几何关系理顺,再调间接光的氛围。 反过来做,很容易用一堆环境光把错误的法线或错误的光照方向"盖住", 越调越糊,最后无从下手。

Q:为什么游戏里光照常常"结构对了但气氛不对"?点开看答案

因为四个格子里,只有一个半是实时的。直接光准确,间接光则是各种近似的拼装: 光照贴图是静态的、探针是离散采样的、球谐是被截断的低频信息。 它们能保证"亮度大致合理",却很难表达多次反弹带来的柔和过渡。

所以美术上真正拉开差距的往往是间接光:把探针铺密一点、把光照贴图的分辨率与 光源烘焙范围规划好,比反复微调主光强度更有效。

7.1 帧缓冲:三张画布叠在一起

片元上完色之后,还有最后一道关卡:它到底能不能写进画面、怎么写进去。 这道关卡由输出合并阶段负责,它手里有四个动作,按固定顺序执行: Alpha 测试 → 模板测试 → 深度测试 → 混合。全部通过之后,结果才会写进帧缓冲。

帧缓冲 FrameBuffer:三块缓冲同时存在 Color Buffer · 颜色 这张才是你最终看到的画面 Depth Buffer · 深度 每个像素记录"目前最近的距离" Stencil Buffer · 模板 每个像素若干标记位,供模板测试读写 深度缓冲只在一次渲染通道内有效,通常每次绘制前都会被清空
缓冲 存什么 谁在用
Color Buffer RGB / RGBA 颜色值 混合阶段的读写目标,显示器的最终画面
Depth Buffer(z-buffer) 归一化后的深度值 深度测试与深度写入,决定遮挡关系
Stencil Buffer 每个像素若干位标记 轮廓描边、镜面区域限制、UI 遮罩等

拿两个三角形走一遍就很清楚:先画蓝三角,它的颜色写进 Color Buffer, 同时把每个像素的深度写进 Depth Buffer;再画红三角时, 每个片元先拿自己的深度和 Depth Buffer 里已有的值比 —— 更近就覆盖颜色并更新深度,更远就直接丢弃。遮挡关系就是这么"算"出来的。

深度值不是"距离"

它存的是投影变换、透视除法之后归一化过的值,和相机距离之间不是线性关系: 近处精度高、远处精度低。所以相机近裁剪面设得太小(比如 0.01)而远裁剪面又拉到 10000, 远处就容易出现"深度精度不够、两个面互相闪烁"的 z-fighting。 调整 near / far 的比值,比给整个场景加细节更能解决这类闪烁。

7.2 深度测试与 Early-Z

深度相关的行为可以拆成两个独立的开关: 深度测试(ZTest,比不比)和深度写入(ZWrite,通过之后要不要把新深度存下来)。 测试用的是一个比较函数,常用的几种是:

比较函数 通过条件 典型用途
LEqual(默认) 新深度 ≤ 已有深度 不透明物体的标准选择,允许同深度覆盖
Less 新深度 < 已有深度 需要严格避免同深度重复绘制的场合
Greater / GEqual 新深度 ≥ 已有深度 深度预写入(先画深度再补颜色)等技巧
Equal 新深度 = 已有深度 配合深度预写入,只给已经占位的像素上色
Always 总是通过 关闭遮挡判断,例如部分 UI 与描边

但这里藏着一个浪费:按传统顺序,深度测试发生在片元着色之后。 也就是说,那些最终会被挡住的片元,颜色已经算完了 —— 白白花了算力。 Early-Z 就是把深度测试提前到片元着色之前:

传统顺序:先算颜色,再比深度 Early-Z:先比深度,再算颜色 顶点处理 片元着色(可能白算) 深度测试 通过才写入帧缓冲 顶点处理 深度测试(提前) 只给存活的片元着色 写入帧缓冲
什么情况下 Early-Z 会失效

提前比较的前提是"深度值在着色前后不变"。一旦片元 Shader 里做了下面这些事, 硬件就无法提前预判,只能回退到传统顺序:

  • discard / clip() —— 片元可能被程序主动放弃,深度结果不可预知
  • 直接写深度输出(如 HLSL 的 SV_Depth)—— 深度不再由图元插值决定
  • 某些平台下开启 Alpha 测试的着色器 —— 本质就是上一条的常见形式

所以 AlphaTest 用得越多,Early-Z 带来的收益越少。 想同时要"镂空效果"和"遮挡优化",可以走两步:先用不透明变体(带 AlphaTest)写深度, 再用深度相等(ZTest Equal、ZWrite Off)的变体补颜色。 这就是常见的深度预写入(Depth Prepass)套路。

7.3 混合:半透明的正确打开方式

不透明物体走到深度测试就结束了:赢了就覆盖,输了就丢弃。 半透明不行 —— 它必须和已经在画面上的颜色混在一起,所以多了"混合"这一步:

结果 = 前景色 × α + 背景色 × (1 − α) + = 背景 DestColor 前景 SrcColor × α 混合后的像素 混合是有顺序的:先画的成为背景,后画的成为前景
Blend 写法 等效公式 效果
Blend SrcAlpha OneMinusSrcAlpha Src × α + Dest × (1 − α) Alpha 混合,最常见的半透明
Blend SrcAlpha One Src × α + Dest × 1 柔光叠加(Soft Additive):亮部更亮,暗部保留
Blend One One Src + Dest 纯叠加:火焰、光效、粒子
Blend DstColor Zero Src × Dest 正片叠底:阴影、脏污、彩色玻璃
一个半透明 Shader 的关键状态
SubShader {
  Tags { "Queue" = "Transparent" }   // ① 排到 2500 之后的半透明队列

  Pass {
    ZWrite Off                            // ② 不写深度,避免挡住后面的半透明
    Blend SrcAlpha OneMinusSrcAlpha       // ③ 标准 Alpha 混合
    // …片元着色…
  }
}
半透明"看起来对"的三个条件

队列在 2500 之后(进半透明队列,CPU 端会按距离从后到前排序)、 ZWrite 关闭(半透明不该参与深度写入,否则后面的半透明会被它挡掉)、 从后到前渲染(混合有顺序,顺序反了颜色就不对)。三条缺一条, 画面就会出现"玻璃后面看不见东西"或者"重叠处颜色乱掉"的经典 bug。

还有一个常见坑:同一个模型内部的半透明面之间也会互相排序。 一个卷起来的披风、一个玻璃杯,前后两面都在同一个网格里可能排不开, 这时只能靠拆网格或用深度剥离之类的技术补救。


把这一篇收成一条链

CPU 端剔除、排序、打包、提交 → 顶点 Shader 做 MVP 变换 → 硬件完成透视除法、裁剪、 背面剔除、视口变换、图元装配、光栅化 → 片元 Shader 采样纹理并按光照模型上色 → 输出合并做测试与混合 → 写入帧缓冲。

下次画面出问题,先在这条链上定位"卡在哪一段",再决定看代码、看资源还是看渲染状态。 这条链走通之后,剩下的话题(合批、阴影、后处理、性能分析)都只是往某一段上叠细节。

返回渲染管线专栏 返回目录