Reimplementing URP on CPU I: The Rasterization Pipeline
C# 导出器遍历场景后产出两类文件,按”文本/二进制”分家:
这种拆分在图形数据交换里很常见——和 glTF 把 .gltf(JSON 描述)与 .bin(二进制几何)分开是同一个思路。
JSON 是文本格式,一个 float 写成字符串平均占 8-10 字节;几十万顶点的网格直接膨胀成几十 MB 的纯文本,既慢又没法直接喂给 C++ 的顶点缓冲。改成二进制后,一个 vec3 就是固定 12 字节,加载器能 memcpy 整块读入内存,省去解析与转换。
二进制布局的代价是字节序和对齐必须写死。本项目跑在 x86/ARM 小端机上,导出器和加载器都按小端、紧凑排列(无 padding)约定,二者必须一致,否则顶点位置会乱掉。
每个顶点带的属性决定了后续管线能算什么:
| 属性 | 用途 |
|---|---|
| position(vec3) | 顶点变换、光栅化、深度 |
| normal(vec3) | 光照(Lambert 余弦、PBR) |
| tangent(vec4,xyz+手性) | 法线贴图变换到世界空间(TBN 矩阵) |
| uv(vec2) | 贴图采样 |
少一项对应的渲染特性就跑不起来——例如没有 tangent 就没法用切线空间法线贴图。导出时必须把 URP 顶点流的完整集合都抓出来。
albedo.png 这种相对路径,加载器按路径去磁盘读 PNG。把图片 base64 进 JSON 是反模式。场景描述文件是一份纯文本 JSON,记录”场景里有什么”。它的字段对应 Unity 场景的关键对象,是 CPU 渲染器的全部输入。
一份典型的场景 JSON:
{
"camera": {
"position": [0.0, 2.0, -5.0],
"rotation": [0.0, 0.0, 0.0],
"fov": 60.0,
"near": 0.3,
"far": 1000.0
},
"lights": [
{ "type": "directional", "direction": [...], "color": [...], "intensity": 3.0 },
{ "type": "spot", "position": [...], "direction": [...], "color": [...], "intensity": 8.0, "range": 15.0, "spotAngle": 60.0 }
],
"ambient": {
"shCoefficients": [ /* 27 个数 = 9 个 RGB */ ]
},
"fog": {
"mode": "exponential",
"color": [0.5, 0.6, 0.7],
"density": 0.02
},
"objects": [
{ "mesh": "assets/workbench.mesh", "transform": { "position": [...], "rotation": [...], "scale": [...] }, "material": { "albedo": "albedo.png", "normal": "normal.png", "metallic": 0.0, "roughness": 0.7 } }
]
}
| 字段 | 喂给渲染器的什么 |
|---|---|
camera | 视图矩阵 、投影矩阵 (见 urp1 B11) |
lights.directional | 直接光的 、颜色、强度(urp2 B04) |
lights.spot | 锥形阴影的透视投影(urp2 B13) |
ambient.shCoefficients | 27 个 SH 系数,喂 SampleSH(n)(urp2 B06) |
fog | 雾公式参数(urp2 B16) |
objects[].transform | 每个物体的模型矩阵 |
objects[].material | 基础色贴图、法线贴图、PBR 参数 |
JSON 一份纯文本就把 URP 那一帧需要的全部”可调参数”描述完了。任何一项对不齐 Unity,画面就和 Unity 对不上。
Unity 的 Transform 用欧拉角(度)+ 固定旋转顺序(ZXY)。JSON 里直接存这三个角,加载时按 Unity 的约定转成旋转矩阵或四元数。如果旋转顺序错了,同样的三个角会产生不同朝向——所以”旋转顺序”是隐性约定,必须在加载器里写死与 Unity 一致(见 urp1 B09)。
Mathf.Sin 等要弧度。JSON 存度数时加载器要乘 转弧度,常是”相机视角对不上”的根因。intensity 单位:URP 平行光的 lux、spot 的 candela 都不是裸 RGB 倍数。直接当 RGB 乘子会让画面整体偏亮或偏暗几倍,要按 URP 的单位换算(见 urp2 B07)。每个网格存成一个二进制 mesh 文件,里面是顶点位置、法线、切线、UV、三角形索引。加载器按字节读进来就是渲染器要的顶点缓冲。
顶点数据量大(几万到几十万顶点 × 多个属性),文本 JSON 里一个 float 平均 8-10 字节、还要解析;二进制里一个 vec3 固定 12 字节,加载器 fread 一整块、memcpy 进顶点缓冲,零解析开销。这是图形数据交换的标准做法(glTF 的 .bin 同理)。
文件头 + 顶点数组 + 索引数组:
+---------------------------+
| Header (固定大小) |
| magic, version |
| vertexCount, indexCount |
| (各属性 offset/stride) |
+---------------------------+
| Vertex Array |
| for each vertex: |
| position : float[3] | 12B
| normal : float[3] | 12B
| tangent : float[4] | 16B (xyz + 手性 w)
| uv : float[2] | 8B
| 小计 stride = 48 字节 |
+---------------------------+
| Index Array |
| uint32[indexCount] | 4B × indexCount
+---------------------------+
具体偏移和 stride 由 header 描述,加载器按 vertexStride * vertexCount 一次性读入顶点缓冲。
切线存 vec4 不是浪费——第 4 个分量 是手性符号(+1 或 -1),表示副法线(bitangent)该用 还是它的反方向。这是为了支持镜像 UV(左右对称的模型用同一张贴图、UV 翻转),副法线方向需要翻转。TBN 矩阵构造时:
漏掉 ,镜像模型的法线贴图光照会反(凹变凸)。
uint32 索引每 3 个一组表示一个三角形,三个索引指向顶点数组里的位置。绕序(顺/逆时针)决定正反面——Unity 左手系下正面是顺时针(见 urp1 B06)。绕序反了,背面剔除会把正面误剔除,模型看起来是”空壳”。
struct { vec3; vec3; vec4; vec2; } 直接强转,编译器可能在字段间插 padding 让 vec4 对齐 16 字节——结果 stride 和文件不匹配,顶点乱掉。要么 #pragma pack(1),要么手动按 offset 读。float 精度够,远离原点(如坐标几万)会出现抖动。UE 用双精度/相机相对(origin rebasing)解决,本项目场景小够用。indexCount 必须是 3 的倍数,否则最后半个三角形无意义。要把 CPU 复刻的画面逐像素和 URP 截图比,三处约定必须逐位一致——错一处整个对照就失去意义。
Unity 是 左手坐标系(DirectX 风格): 向右、 向上、 向前(远离观察者)。这与 OpenGL / smallpt 的右手系相反。
左手系 vs 叉乘:叉乘 在左手系里遵循左手定则。这意味着法线、切线叉乘出来的副法线方向,和右手系教程里的符号相反。直接照搬网上右手系的代码会得到翻转的切线空间。
判断手系最稳的方法不是记”哪个轴向前”,而是看叉乘:在 Unity 里 (因为 右、 上、 前),按左手定则成立。
Unity 用 列主序(column-major)矩阵存储,即 Matrix4x4.m[row][col] 中第一维是行、第二维是列,但内存里按列连续存储。
变换一个点 写成 ,即矩阵左乘列向量;多重变换按 从右往左作用:先模型、再视图、最后投影。这套顺序与 DirectX HLSL 的 mul(M, v) 约定一致,和 GLSL 的 mul(v, M) 相反。
NDC 范围是另一个易错点:
| API | 裁剪空间 范围(NDC) |
|---|---|
| OpenGL | |
| Direct3D / Unity |
Unity(含 URP)在 Shader 里输出 clip-space 坐标,硬件做透视除后 ,0 是近裁剪面、1 是远裁剪面。深度缓冲比较、自定义光栅化的深度插值都得用这个范围,否则深度测试反向、近远颠倒。
URP 的直接光亮度单位是辐射度(radiance),整个光照在线性空间算,最后才做 sRGB 编码输出。三处必须对齐:
每个顶点要经过三次矩阵乘法、四次”身份”切换:模型空间 → 世界空间 → 视图(相机)空间 → 裁剪空间 → 屏幕空间。
| 矩阵 | 作用 | 由什么决定 |
|---|---|---|
| (Model) | 模型空间 → 世界空间 | 物体的位置、旋转、缩放(Transform) |
| (View) | 世界空间 → 视图空间 | 相机的位置、朝向 |
| (Projection) | 视图空间 → 裁剪空间 | 透视/正交 + 视野角、宽高比、near/far |
完整变换:
由于矩阵左乘列向量,作用顺序从右往左:先模型、再视图、最后投影。CPU 实现里通常预先把 算好,每个顶点只乘一次。
Unity(左手系、NDC 、列主序)的透视投影矩阵,对垂直视野角 fov、宽高比 aspect、近远面 :
注意两个细节:
视图矩阵是把”世界变到相机眼里”,等价于把相机搬到原点、朝 看(Unity 相机本地空间 朝前,视图空间约定则视实现而定)。设相机右、上、前三个单位向量为 ,位置 :
\mathbf{r}^T & -\mathbf{r}\cdot\mathbf{c} \\ \mathbf{u}^T & -\mathbf{u}\cdot\mathbf{c} \\ \mathbf{f}^T & -\mathbf{f}\cdot\mathbf{c} \\ 0 & 1 \end{bmatrix}旋转部分(上 )是相机三个轴的转置,因为”把世界变到相机本地”是相机本地变到世界的逆变换;平移部分先把相机挪到原点。
裁剪空间是齐次坐标 。透视除法把前三项除以 :
NDC 范围 ,(Unity)。最后映射到屏幕像素:
注意 要翻转:NDC 的 朝上,而图像像素的行号朝下。这一步漏掉,画面会上下颠倒。
fov 是垂直角:URP Camera.fieldOfView 是垂直 FOV,水平 FOV 由 aspect = W/H 推出。混淆水平和垂直会把画面横向压扁或拉伸。裁剪(clipping)发生在裁剪空间、透视除法之前。目的是把伸出视锥体的三角形切干净,避免后面光栅化时除以 爆炸,也省掉看不见的像素。
视锥体在裁剪空间是个立方体,由六个不等式界定:
| 平面 | 条件 |
|---|---|
| 左 / 右 | |
| 下 / 上 | |
| 近 / 远 | (Unity NDC ) |
注意 ,所以这些边界是”距相机远近”的边界,不是固定数值——这正是用齐次坐标裁剪的精妙:远近裁剪面、视野张角都自动并入到 比较里。
对每个三角形,逐个用六个平面切一遍。经典算法是 Sutherland–Hodgman:对当前多边形的每条边,按”起点在内/外、终点在内/外”四种情况输出顶点:
for each 平面 P:
对当前多边形每条边 (S, E):
if E 在内:
if S 在外: 输出交点(S→P)
输出 E
elif S 在内:
输出交点(S→P) # 只在跨过边界时补一个点
一个三角形被某个平面切到,输出最多变成四边形,再三角化成两个三角形——切出来的缺口就是这样被补成几个小三角形的。逐平面迭代,三角形最多被切成七边形。
边上从 到 的交点参数 ,以左平面 为例:
解出 ,再线性插值位置、UV、颜色等所有顶点属性——属性必须和位置同步插值,否则贴图在裁剪边界处错位。硬件管线里这一步是免费的(硬件裁剪单元自动做),CPU 实现要自己写。
如果三角形三个顶点全在某个平面外,整个三角形不可见,直接丢弃,不进光栅化——这是裁剪的核心收益:避免对画面外的像素白做一遍覆盖测试和深度测试。
部分顶点在外的才走 Sutherland–Hodgman;全部在内的(最常见情况)原样通过。
光栅化(rasterization)回答一个问题:屏幕上的哪些像素中心点落在某个三角形内部?把这些像素找出来,就完成了”连续三角形 → 离散像素”的转换。
暴力遍历屏幕所有像素、逐个判断在不在三角形里,太慢。先算三角形的轴对齐包围盒(AABB):
只对 里的像素做覆盖测试。窄长条/小三角形的实际像素数远少于全屏,这一步通常省掉 95% 以上的判断。
判断”像素在不在三角形里”用的是像素中心点 ,不是像素左上角。这是硬件光栅化的标准约定(D3D/GL 都是)。漏掉 会让画面整体偏移半个像素、纹理对不齐。
判断点 在三角形哪一侧,用叉积的 分量。对边 :
三条边算出三个 值,同号即在内部(左手系下顺时针绕序的正面三角形,三个 )。某条边 表示点恰在边上。
bool inside(float px, float py, Vec2 v0, Vec2 v1, Vec2 v2) {
auto edge = [](Vec2 a, Vec2 b, Vec2 p){
return (b.x-a.x)*(p.y-a.y) - (b.y-a.y)*(p.x-a.x);
};
float e0 = edge(v0, v1, {px, py});
float e1 = edge(v1, v2, {px, py});
float e2 = edge(v2, v0, {px, py});
// 顺时针正面三角形:三个值同号(都 >=0)
return (e0>=0 && e1>=0 && e2>=0);
}
三个 值还顺便给出了重心坐标(见 B14),不用再算一次——这是边函数法相对其他判断法的隐藏好处。
相邻两个三角形共享一条边,按上面规则会都判定该边上的像素属于自己,导致共享边像素被画两次,混色出错。解决是 top-left rule:
满足 top-left 的边用 ,其余边用 (严格大于)。这样每条共享边只归属其中一个三角形,像素不重不漏。
>=0 会把背面也画进来。三角形只有三个顶点有数据(UV、颜色、法线等),中间成千上万像素的属性靠插值得到。直接线性插值会出错——必须做透视校正。
三角形内任一点 可写成三个顶点的仿射组合:
就是重心坐标,每个分量对应一个顶点的”权重”。它们和光栅化阶段算出的边函数 成正比:
除以三者之和归一化(和等于三角形面积的两倍)。所以拿到 就能直接插值,不用另求。
如果直接用屏幕空间的 去插值顶点的 UV:
近处的东西看起来大、远处小,这种线性平均忽略了透视收缩。直观后果:地面上一排笔直的地砖缝,会扭曲成”中间鼓、两端翘”——这就是地砖直线扭曲变形的根源。
正确做法来自齐次坐标的性质。设顶点 的属性 ,视图空间深度 ,则像素的属性应满足:
而像素的 也按同样权重插值:
最后相除还原:
也就是”先除以 插值,再乘回 “。深度本身也要透视校正插值——否则深度缓冲的数值不是真正的视图深度,深度测试和阴影会错。
// a0,a1,a2 是三个顶点的某属性,w0,w1,w2 是裁剪空间 w (=视图深度)
float invW = alpha/w0 + beta/w1 + gamma/w2;
float a = (alpha*a0/w0 + beta*a1/w1 + gamma*a2/w2) / invW;
float depth = (1.0f) / invW; // 像素的视图深度,写进深度缓冲
这是初学者最常踩的坑。直觉是”屏幕空间直线插值深度,能行吧?“——不行。透视投影把直线变成仍然直的屏幕线,但沿这条线的深度不是线性变化的:远处 变化慢、近处快。直接屏幕线性插值,会让深度缓冲在远处给出错误值,引发 z-fighting 或本该被遮挡的物体浮到前面。深度必须走透视校正公式。
normalize,否则光照亮度偏。同一个像素可能被多个三角形覆盖。决定最终显示哪个,靠一块和帧缓冲等大的深度缓冲(depth buffer / z-buffer)。
深度缓冲为每个像素存一个”到相机的距离”(严格说是视图空间深度 ,或 NDC 的 )。流程:
清屏时把整个深度缓冲填成最远值(Unity NDC 是 1)
对每个被光栅化覆盖的像素:
if 新片元的深度 < 缓冲里的深度: # "更近"在 Unity 是数值更小
写入新颜色
更新深度缓冲为新深度
else:
丢弃(被挡住了)
这种”画谁比一次远近、近的覆盖远的”使得绘制顺序无关——先画远处再画近处、或反过来,结果一样。这是 z-buffer 算法相比画家算法(按深度排序绘制)最大的优势:画家算法对相互穿插的三角形无法排序,z-buffer 可以。
不同实现存的不一样,常见两种:
| 存的内容 | 范围 | “更近” |
|---|---|---|
| NDC 的 | (Unity) | 数值更小 |
| 反向 Z(reversed-Z) | ,近=1 远=0 | 数值更大 |
URP 现代实现普遍用 reversed-Z:把 near 映射成 1、far 映射成 0。配合浮点深度缓冲(24/32 位),能把浮点精度在 上”近处稠密”的特性抵消掉透视投影”远处稠密”的不均,使整段深度精度更均衡,减少远处 z-fighting。CPU 复刻要和 URP 用同样的方向,否则比较符号反、画面全错。
经透视投影后 NDC 与视图空间 关系是:
这是非线性映射,远处 变化极慢。后果:
深度缓冲只适合不透明物体。透明物体(玻璃、半透明粒子)不能用”近的覆盖远的”粗暴策略,因为要保留后面物体的颜色做 alpha 混合:
URP 的渲染队列(Transparent vs Opaque)和这两阶段对应。
< 还是 > 取决于是否反向 Z。从 URP 抓出来的投影矩阵和深度比较函数必须配套。far),reversed-Z 是清成 0。LessEqual 是测试条件,On/Off 是是否更新缓冲,可独立设置。CPU 单线程跑光栅化太慢。本项目按横向条带把帧缓冲切成多块,每个线程负责一块。
图像是逐行存储的(行优先内存布局)。把屏幕按 切成若干连续行段,每段内存连续、互不重叠,线程各自读写自己那段不需要加锁:
线程 0: 行 [0, H/T)
线程 1: 行 [H/T, 2H/T)
...
任务划分就是”哪个线程画哪些行”。三角形遍历时,先算它屏幕 AABB 落在哪几条带的 范围内,只把这些条带分给对应的线程。
只要划分按”像素归属”做、且边界像素明确属于唯一线程,就不会有数据竞争。这是光栅化能高效并行的基础。
朴素等分条带会不均衡:天空区域里几个大三角形早早画完,工具密集区有大量小三角形和深度竞争,慢线程拖累整帧。常见改进:
硬件 GPU 的瓦片化渲染(tile-based rendering,移动端尤其)走的就是更细 tile 的思路,连片上缓存(tile memory)都用上了。
CPU 复刻的目标是”靠多核把一帧压到可接受时间”。这里没有 GPU 那种数千核的并行度,CPU 通常几十核,加上每个线程单像素成本远高于 GPU(无硬件插值单元、无硬件深度比较),所以 CPU 复刻是教学用,速度上不可能和 GPU 比。价值在于每一步都能停下来打印、单步、可视化中间量——GPU 调试很难做到。
[y_min, y_max) 半开区间,否则相邻条带的边界行被画两次或漏掉。