shader 是什么:从 Draw Call 到像素的渲染管线
写 shader 时你写的其实是 GPU 流水线上某个 stage 的程序。不知道自己在哪个 stage,就解释不了大量现象:为什么透明物体会互相穿插、为什么顶点动画会连阴影一起动、为什么片元里的采样比计算还贵。这篇把整条管线画出来,标清 shader 的工作位置。
Draw Call 之后,GPU 做了什么
Draw Call 是 Unity 发给 GPU 的一条指令:「用这套材质和参数,把这个网格画出来」。GPU 收到后走一条固定流水线:
| 阶段 | 输入 → 输出 | 可编程性 |
|---|---|---|
| 输入装配(IA) | 顶点缓冲 + 索引 → 三角形列表 | 不可编程 |
| 顶点着色器 | 每个顶点 → 一个裁剪空间顶点 | 可编程:vertex shader |
| 曲面细分 / 几何着色器 | 细分或增删几何 | 可选,Unity 常规用法基本不碰 |
| 裁剪 + 屏幕映射 + 光栅化 | 三角形 → 它覆盖的像素集合 | 不可编程 |
| 片元着色器 | 每个被覆盖的像素 → 颜色和深度值 | 可编程:fragment shader |
| 输出合并(OM) | 颜色 + 深度 → 写入帧缓冲 | 不写代码,用渲染状态控制 |
你写的代码只出现在两个 stage:顶点着色器和片元着色器。输出合并阶段虽然不写代码,但可以用 Blend、ZWrite、ZTest 这些状态去配置它——见 /notes/unity/structure-3/。
顶点着色器:每个顶点跑一次
顶点着色器的输入是一个顶点的全部属性(位置、法线、UV……语义怎么绑定见 /notes/unity/structure-2/),输出至少包含一个裁剪空间位置。它的职责概括起来是三件事:
- 空间变换:把顶点从模型空间搬到裁剪空间(MVP 链,见 /notes/unity/principles-2/)。
- 准备插值数据:把法线、UV、颜色等塞进输出结构,交给光栅化器插值。
- 可选的几何修改:顶点动画、GPU 蒙皮、位移——改的是几何本身,所以阴影、深度都会跟着变(这正是「顶点动画要同步改 ShadowCaster」的原因,见 /notes/unity/effects-5/)。
关键性质:每个顶点恰好执行一次,与屏幕上占多少像素无关。
片元着色器:每个被覆盖的像素跑一次
三角形被光栅化成一堆像素覆盖后,片元着色器对每个像素跑一次,输出颜色和深度。它决定像素「长什么样」:采样贴图、算光照(见 /notes/unity/lighting-1/)、做溶解和扭曲(见 /notes/unity/effects-4/)。
关键性质:每个被覆盖的像素执行一次——一个铺满 1080p 屏幕的大三角形,片元程序要跑约两百万次。这就是性能账要算在片元里的原因(成本模型见 /notes/unity/performance-1/)。
一个三角形的完整旅程
把两者串起来,一个三角形从数据到屏幕:
- IA 按索引组装出 3 个顶点,各跑一次顶点着色器。
- 裁剪:完全在视野外的三角形整块丢弃。
- 光栅化:算出三角形覆盖了哪些像素(按像素中心判定,见 /notes/unity/principles-3/)。
- 每个覆盖像素跑一次片元着色器。
- 输出合并:深度测试决定要不要覆盖旧像素,混合决定新颜色怎么和旧颜色叠加。
一个具体数量级:一个铺满屏幕的四边形(2 个三角形、4 个顶点)在 1920×1080 下 ≈ 4 次顶点执行 + 约 207 万次片元执行。顶点程序里稍贵的计算往往无所谓,片元程序里每一次采样都要心疼——这是后面所有 shader 性能建议的出发点。
为什么透明必须排序:管线埋的坑
输出合并的混合是「新颜色和帧缓冲里已有的颜色」叠加。已有颜色取决于之前画过什么——所以透明物体必须从远到近画,否则叠加顺序错,结果就错。深度测试对透明也不够用(透明像素不该写深度挡住后面的透明),这些细节在 /notes/unity/principles-3/ 展开。
怎么亲眼看到这条管线
Unity 菜单 Window → Analysis → Frame Debugger 可以把一帧拆成逐 Draw Call 回放:每个 Draw Call 用了哪个 shader 的哪个 Pass、传了什么参数、状态是什么,全部可见。第一次学管线时开一次,比任何图都直观——工具用法见 /notes/unity/performance-2/。
自检三问
- 一个铺满屏幕的 UI 大图,顶点程序和片元程序各执行多少次?
- 溶解效果用
discard丢弃像素,发生在哪个 stage?(提示:片元之后、输出合并之前) - 为什么改顶点动画会连影子一起变?
三问都能答上,就可以进入下一篇的空间变换。