CH 01 · 总览与全流程
渲染管线:从 CPU 提交到屏幕像素
这一篇只回答一个问题:我们在场景里摆的那些模型、材质、灯光,到底经过了哪些步骤,才变成屏幕上的像素?
把这条链路完整走一遍之后,再看 Shader 报错、材质发灰、半透明排序错乱这类问题,就能大致判断出它卡在哪一段。
- 渲染管线的两大段分别由谁执行、各自负责什么
- 一个模型从场景数据到像素,中间要经过几次坐标变换
- 为什么半透明物体必须从后往前画,而不透明物体相反
- 片元(fragment)里到底带了哪些数据
- 深度测试、Early-Z、混合各自在解决什么问题
- 1.1 一条把 3D 变成 2D 的流水线
- 1.2 一帧画面的完整旅程
- 2.1 剔除:先把看不见的扔掉
- 2.2 排序:决定先画谁后画谁
- 2.3 打包数据:把指令准备好
- 2.4 绘制调用:交给 GPU
- 3.1 GPU 管线的数据流
- 3.2 Shader 代码与管线的对应
- 4.1 三个矩阵与四个空间
- 4.2 相机空间是怎么定的
- 5.1 硬件操作阶段:一串固定动作
- 5.2 光栅化到底输出了什么
- 6.1 纹理技术:从 UV 到纹素
- 6.2 光照:直接光与间接光
- 6.3 Phong 模型与它的三项分解
- 6.4 光照分析的基本框架
- 7.1 帧缓冲:三张画布叠在一起
- 7.2 深度测试与 Early-Z
- 7.3 混合:半透明的正确打开方式
1.1 一条把 3D 变成 2D 的流水线
渲染管线(Rendering Pipeline)是一条流水线:输入是场景里的三维数据,输出是屏幕上的二维像素。 它由两个完全不同的执行者接力完成 —— 前半段跑在 CPU 上,叫"应用程序阶段";后半段跑在 GPU 上,叫"渲染管线"。
之所以强调"接力",是因为这两段的工作性质完全不同:
| 阶段 | 执行者 | 在做什么 | 是否可编程 |
|---|---|---|---|
| 应用程序阶段 | CPU | 决定"这一帧要画哪些物体、按什么顺序、用什么参数" | 完全由你的逻辑控制 |
| 几何阶段 | GPU | 把三维顶点变换、裁剪、装配成屏幕上的图元 | 顶点 Shader 可编程 |
| 光栅化阶段 | GPU | 把图元拆成一堆片元(候选像素) | 固定硬件,不可编程 |
| 像素处理阶段 | GPU | 给每个片元算颜色,再决定它能不能写进画面 | 片元 Shader 可编程 |
所谓的"性能优化",本质上是在这条链路上找到那个最慢的环节。 CPU 端的瓶颈通常是 Draw Call 数量(提交指令的开销),GPU 端的瓶颈通常是填充率(像素数量 × 每个像素的着色开销)。 同一个画面,瓶颈可能在两端中的任意一端 —— 所以优化前必须先确认瓶颈在哪。
1.2 一帧画面的完整旅程
把所有环节串起来,一帧的流程大致是这样。这张图从上往下读:左边一列是 CPU 端的四步 (①~④,正好对应第 2 章的四个小节),下面一行是 GPU 端的四段(对应第 4~7 章), 中间那条箭头就是两者的分界线。
注意 ①~④ 是被"循环"罩住的 —— 每个可见物体都要从头走一遍,所以这几步的开销要乘以物体数量, 这正是 CPU 端优化的重点。另外留意 ④ 的位置:它在 CPU 这一侧,做的是下达命令, Shader 代码真正跑起来是在下面的 GPU 那一段里。
帧缓冲画完之后,通常还要再走一遍后处理(Post Processing):把整张画面当成一张纹理, 做调色、Bloom、景深、抗锯齿之类的处理,最后才送到显示器。这一篇主要讲主流程,后处理留到后面单独展开。
"渲染管线"这个词有狭义和广义之分。狭义指 GPU 上的那一段(顶点 → 光栅化 → 片元 → 输出合并); 广义则包含 CPU 应用程序端。本文用的是广义,因为实际优化时两段都得看。
2.1 剔除:先把看不见的扔掉
一个场景里可能有成千上万个物体,但摄像机一帧里能看到的往往只有几百个。 如果把这些看不见的物体也提交给 GPU,等于白白浪费了整条链路的开销 —— 所以应用程序阶段的第一件事就是剔除(Culling)。
Unity 里常用的剔除手段有这么几类,它们会叠加生效:
- 视锥体剔除(Frustum Culling) —— 用物体的包围盒与相机视锥做相交测试,完全在视锥外的直接排除。这是引擎自动做的,不需要你写代码。
- 层级剔除(Layer Culling Mask) —— 通过 Layer 和相机的 Culling Mask 匹配,手工控制"这个相机不看某类物体"。适合做小地图相机、UI 相机这类特殊视角。
- 遮挡剔除(Occlusion Culling) —— 判断物体是否被前面的墙体完全挡住。需要预先烘焙遮挡数据,对室内场景收益很大,对开阔场景往往没效果。
遮挡剔除需要在编辑器里烘焙 Occlusion Culling 数据,且运行时有一定 CPU 开销。
它不是万能的:如果场景里没有明确的遮挡关系(比如一望无际的野外),开了也白开。
判断依据是 Profiler 里 OcclusionCulling 的开销和它省下的 Draw Call 是否划算。
2.2 排序:决定先画谁后画谁
剔除之后,剩下的物体要排个序。排序规则由两个东西决定:渲染队列(RenderQueue)和与相机的距离。
最关键的是一道分水岭:2500。
不透明物体最后只会留下一个像素值,谁在前谁赢。所以让近处的先画,它写入深度之后,远处那些片元在深度测试阶段就被挡掉了,像素着色根本不执行 —— 这是纯粹的省算力。
半透明物体要和背后的画面做混合(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 手上之后,还要再走几道工序。这一段的特点是:顺序由硬件写死, 能写代码的地方只有两处 —— 顶点处理与片元处理,中间夹着一段完全由硬件执行的固定功能。
把两行对着看就清楚了:上面一行是谁在执行,下面一行是数据此刻待在哪个空间。 同一个三角形会在这条链路上被反复改写 —— 从一堆三维坐标,变成裁剪空间里的四个分量, 再变成屏幕上的二维位置,最后变成一片片的像素颜色。
顶点处理负责「把顶点摆到正确的位置」,片元处理负责「决定每个像素该是什么颜色」, 中间的光栅化负责「把图元拆成待着色的候选像素」。三段各管一件事,互不越界。
也正因为分工明确,出问题时可以直接按段排查:模型位置不对,看顶点处理; 颜色不对,看片元处理;锯齿、漏缝、边缘闪烁,看光栅化。
3.2 Shader 代码与管线的对应
上面这张图听起来抽象,但落到代码上其实非常具体:一个最简 Shader 里, 几乎每一行都能在链路上找到一个位置。下面这段只做一件事 —— 把贴图原样贴到模型上, 但它的结构是完整的。
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),
其实是三个矩阵连乘。用照相机打比方最好理解 —— 想拍一张照片,你会做三件事:
- 把物体摆好(决定它在世界里的位置和朝向)→ 模型矩阵 M(Model)
- 把相机架好(决定从哪儿、朝哪儿看)→ 视图矩阵 V(View)
- 按下快门(决定画面范围与透视强弱)→ 投影矩阵 P(Projection)
三件事对应四次空间切换,顶点就是这样一步步被"搬运"到裁剪空间的:
为什么一个 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 相机空间是怎么定的
投影矩阵的推导我们先放下,先补上中间那一步:视图矩阵是怎么把整个世界搬到"相机看到的样子"的。 它需要三样输入 —— 相机在哪、看哪里、以及"上"是哪一边:
换句话说是这样:视图矩阵做的事情,等价于先把世界旋转到相机基上,再把相机平移到原点。 旋转部分是把世界坐标投影到 right / up / forward 三个方向上的分量, 所以矩阵的左上 3×3 部分直接由这三根归一化向量拼成;平移部分则是 −EYE 在这个基下的坐标。
Q:数学上为什么"旋转矩阵的逆 = 它的转置"?点开看答案
因为正交基的列向量两两垂直且长度为 1,用它做成的矩阵是正交矩阵。
正交矩阵满足 R⁻¹ = Rᵀ,所以"把世界转到相机朝向"和
"把相机朝向转回世界"之间,只差一次转置 —— 这也是为什么视图矩阵可以写得那么便宜:
不需要求逆,转置一下就行。
实用结论:如果你自己实现了 lookAt,却忘了归一化 UP(或让 forward 与 UP 共线, 比如相机垂直向下看),叉乘结果会退化成零向量,画面直接黑掉或翻转。 相机俯仰角接近 ±90° 时的"万向节问题"就属于这一类。
观察空间的朝向约定和坐标系手性有关。Unity 用的是左手坐标系,相机在观察空间里朝 +Z 看,
UnityObjectToViewPos() 得到的 z 在相机前方为正;
而不少图形学教材(OpenGL 传统写法)用的是右手系,相机朝 −Z 看。
从教材照搬公式时如果发现"整个画面左右反了"或者"模型跑到相机背后了",多半就是这里差了符号。
5.1 硬件操作阶段:一串固定动作
顶点 Shader 吐出裁剪空间坐标之后,程序控制权就交回给硬件了。接下来这串动作全部由固定电路完成, 顺序也不能改:
| 步骤 | 输入 → 输出 | 它在解决什么问题 |
|---|---|---|
| 透视除法 | 裁剪空间 (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 换算成纹素地址,
需要一步缩放:
换算公式是 纹素地址 = 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 链的总大小约等于原图的 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, 它把颜色拆成三项相加,其中两项只跟光与表面的几何关系有关:
| 项 | 计算方式 | 依赖什么 | 直观理解 |
|---|---|---|---|
| 漫反射 Diffuse | max(N·L, 0) × 材质颜色 |
法线、光源方向 | 只要"朝向光源"就亮;与视线无关,所以转动相机不会改变亮度 |
| 镜面反射 Specular | pow(max(R·V, 0), smoothness) |
反射方向、视线、光滑度 | 高光斑:表面越光滑,亮点越小越锐,也越容易随视角抖动 |
| 环境光 Ambient | 常数,或从光照贴图 / 探针采样 | 间接光信息 | 兜底项,避免背光面死黑;廉价但容易让画面发灰 |
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; // ③ 环境项
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 测试 → 模板测试 → 深度测试 → 混合。全部通过之后,结果才会写进帧缓冲。
| 缓冲 | 存什么 | 谁在用 |
|---|---|---|
| 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 就是把深度测试提前到片元着色之前:
提前比较的前提是"深度值在着色前后不变"。一旦片元 Shader 里做了下面这些事, 硬件就无法提前预判,只能回退到传统顺序:
discard/clip()—— 片元可能被程序主动放弃,深度结果不可预知- 直接写深度输出(如 HLSL 的
SV_Depth)—— 深度不再由图元插值决定 - 某些平台下开启 Alpha 测试的着色器 —— 本质就是上一条的常见形式
所以 AlphaTest 用得越多,Early-Z 带来的收益越少。
想同时要"镂空效果"和"遮挡优化",可以走两步:先用不透明变体(带 AlphaTest)写深度,
再用深度相等(ZTest Equal、ZWrite Off)的变体补颜色。
这就是常见的深度预写入(Depth Prepass)套路。
7.3 混合:半透明的正确打开方式
不透明物体走到深度测试就结束了:赢了就覆盖,输了就丢弃。 半透明不行 —— 它必须和已经在画面上的颜色混在一起,所以多了"混合"这一步:
| 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 | 正片叠底:阴影、脏污、彩色玻璃 |
SubShader {
Tags { "Queue" = "Transparent" } // ① 排到 2500 之后的半透明队列
Pass {
ZWrite Off // ② 不写深度,避免挡住后面的半透明
Blend SrcAlpha OneMinusSrcAlpha // ③ 标准 Alpha 混合
// …片元着色…
}
}
队列在 2500 之后(进半透明队列,CPU 端会按距离从后到前排序)、 ZWrite 关闭(半透明不该参与深度写入,否则后面的半透明会被它挡掉)、 从后到前渲染(混合有顺序,顺序反了颜色就不对)。三条缺一条, 画面就会出现"玻璃后面看不见东西"或者"重叠处颜色乱掉"的经典 bug。
还有一个常见坑:同一个模型内部的半透明面之间也会互相排序。 一个卷起来的披风、一个玻璃杯,前后两面都在同一个网格里可能排不开, 这时只能靠拆网格或用深度剥离之类的技术补救。
CPU 端剔除、排序、打包、提交 → 顶点 Shader 做 MVP 变换 → 硬件完成透视除法、裁剪、 背面剔除、视口变换、图元装配、光栅化 → 片元 Shader 采样纹理并按光照模型上色 → 输出合并做测试与混合 → 写入帧缓冲。
下次画面出问题,先在这条链上定位"卡在哪一段",再决定看代码、看资源还是看渲染状态。 这条链走通之后,剩下的话题(合批、阴影、后处理、性能分析)都只是往某一段上叠细节。