GPU Gems 3 · Chapter 22. Baking Normal Maps on the GPU
从低模 UV 空间投影出发,解释如何用 boundary cage、uniform grid 与 3D-DDA 在 GPU 上为低模烘焙高模法线。
学习目标
- 能解释低模 UV、投影 cage 与高模射线命中如何在 GPU 中生成每个纹理像素的法线
- 能修改 GPU Normal Map Baking Lab 的网格、遍历、pass、抗锯齿与精度选项,比较内存和访问取舍
- 能回答:为什么 uniform grid 配合 3D-DDA 能跳过空 cell,以及命中法线为什么必须转到 tangent space
先问:为什么高模细节不能直接跟着低模一起渲染
想象一件雕刻得很细的物体和一件只有几百个面的外壳。游戏运行时,我们希望只画轻量外壳,却保留雕刻留下的明暗变化。提前把这些变化写进一张图片,就能让轻量外壳看起来更复杂。
本章解决的是:怎样让每个图片小格独立地寻找高模表面,并把找到的方向写回图片。没有这条路径,制作人员只能依赖耗时的单线程工具;如果搜索每个高模三角形,又会把 GPU 的并行优势浪费在重复检查上。
1. 用低模 UV 逐像素投影高模法线
↡把高模表面的方向信息投影到低模 UV 图片上的过程;每个纹理像素都独立寻找一个高模命中点。低模是运行时真正提交的工作模型,高模是提供细节的参考模型。先把低模三角形光栅化到纹理空间,硬件插值器会为每个纹理像素补出表面位置、UV 和切线基。高模不需要自己的 UV,这正是投影式烘焙的优势:复杂参考网格可以只提供几何与法线。
随后,每个纹理像素从一个可控的起点向高模发射一条射线。若沿途有多个命中,选择离起点最近且有效的命中,取它的高模法线,再交给后面的坐标变换。
2. 用投影 cage 控制射线的起点和方向
↡围住低模与高模的外扩网格;它为每个像素提供射线起点和可编辑的投影范围,帮助避免错误穿透。投影 cage 通常由低模向外挤出。它不只是一个包围盒,而是艺术家可以局部调整的发射面:cage 越贴合两个模型,射线越容易先命中期待的那一层;在尖角、薄片或相邻表面附近,局部修改 cage 可以减少穿透和错选。
光栅化时,cage 位置与低模对应点之间的向量也会被插值到像素。它提供了每个像素的射线方向。理想情况下,cage 同时包住工作模型和参考模型,使“离 cage 最近的有效命中”成为稳定的选择规则。
3. 用 uniform grid 把高模搜索变成邻居遍历
↡把空间切成固定大小的三维格子;每个格子只记录其中可能相交的三角形列表,从而把全局搜索缩小到局部。直接让一条射线测试几十万甚至几百万个三角形,计算量会随参考模型规模爆炸。uniform grid 把空间分成等尺寸的 voxel。每个 voxel 只保存一个三角形列表的起始位置,射线进入某个 cell 后,只需要读取这个 cell 对应的候选几何。
这套结构特别适合本章,因为工作模型和参考模型大体占据同一片空间,射线通常只穿过很短的一串 cell。网格不必成为最通用的光线追踪结构;它只要在这种局部相干的投影任务上稳定地减少候选集合即可。
4. 用 3D-DDA 按顺序跨过 voxel
↡一种沿射线在三维网格中前进的算法;它保存三轴下一个边界的时间,选择最早到达的边界并进入相邻 cell。3D-DDA 不需要每一步重新猜一个三维位置。它维护三组状态:tmax 表示射线在每个轴上何时碰到下一个 cell 边界,step 表示沿该轴前进还是后退,delta 表示跨过一个 cell 需要增加多少射线参数。每次选择最小的 tmax,只推进对应的轴,再把该轴的 tmax 加上 delta。
因为 cell 按射线顺序访问,先命中的有效三角形可以遮挡后续 cell 的命中。空 cell 只做一次网格读取即可跳过;有几何的 cell 才进入三角形列表和求交测试。
5. 把网格、索引和几何塞进纹理资源
高模没有必要以传统顶点缓冲的形态被片元阶段直接访问。一个 3D 纹理保存网格 cell 到三角形列表的指针;另一个 2D 纹理把每个 cell 的三角形索引排成线性列表,用空指针结束;顶点位置和法线再分别放进 2D 纹理。
这样做需要用归一化立方坐标查询 3D 网格,并把一维列表索引折算成 2D 纹理坐标。由于索引是离散地址,采样器应关闭过滤并使用 clamp addressing。数据布局还受到显存和索引精度限制:网格分辨率越高,空 cell 可能更少,但 3D 纹理会迅速占用显存。
6. 把世界法线编码为可复用的切线空间颜色
↡以工作模型的切线、副切线和法线为坐标轴的局部空间;把高模方向写进这里,低模重用时就不依赖原来的世界朝向。求交得到的法线来自参考模型的世界空间。为了让低模在不同姿态、不同位置复用这张图,必须用低模自己的切线基做三个点积,把方向变到 tangent space。再把从负一到一的分量平移缩放到从零到一,写进 RGB;读取时再反变换,就能恢复用于着色的方向。
这一转换也是烘焙质量的检查点:切线、副切线、法线的约定必须和运行时材质一致。若烘焙器和渲染器使用不同的切线方向或绿色通道约定,图片看起来会像“反了”或在接缝处断裂,即使射线命中完全正确。
7. 用单遍、多遍和抗锯齿处理工程边界
基础实现可以一次绘制低模,把完整遍历放进片元阶段。旧式片元循环有迭代次数上限,复杂网格可能超过这个预算;多遍或 tiled rendering 可以把 cell、射线状态和 tmax 存进临时 render texture,每一遍只推进一段,最后再编码法线。
有两种直观路径:同一像素发射多条稍微偏移的射线并平均,或者重复渲染法线图、抖动低模顶点与 cage 再累积。样本数增加会近似线性增加成本,因此应先判断误差来自几何搜索还是边缘采样,再决定提高网格、增加 pass 还是增加射线。
float4 BakeNormal(float2 uv, float3 position, float3 cageDir) {
Ray ray = MakeRay(position, -cageDir);
Cell cell = EnterGrid(ray);
float3 hitNormal = 0.0;
while (InsideGrid(cell) && !HasHit(hitNormal)) {
hitNormal = FindNormalAtCell(cell, ray);
cell = Advance3DDDA(cell, ray);
}
if (!HasHit(hitNormal)) return float4(0.5, 0.5, 1.0, 1.0);
float3 tangentNormal = float3(
dot(hitNormal, Input.binormal),
dot(hitNormal, Input.tangent),
dot(hitNormal, Input.normal));
return float4(normalize(tangentNormal) * 0.5 + 0.5, 1.0);
}代码的主循环只关心三个状态:当前 cell、射线和最近命中的法线。FindNormalAtCell 内部读取三角形列表并保留离 cage 最近的命中;主循环随后沿 3D-DDA 前进。这个分层让“网格遍历”和“cell 内求交”可以分别优化或拆到多遍。
先把低模表面变成独立的 UV 像素
光栅化工作模型,插值位置、cage 方向和切线基;每个像素由此得到一条可以独立处理的高模投影射线。
GPU Normal Map Baking Lab
调整网格分辨率、遍历方法、渲染遍数、抗锯齿射线和数据精度,观察 GPU 法线烘焙的读取、内存与稳定性取舍。
本章小结
- 低模 UV 像素可以独立投影高模法线
- boundary cage 决定射线起点、方向和最近命中
- uniform grid 与 3D-DDA 跳过空区域并按顺序访问 cell
- 纹理资源把网格指针、三角形列表和几何数据映射给 GPU
- tangent space 编码让法线图能随低模姿态复用
练习
问题 1|修改 Demo 代码。 把 Lab 的 traversal mode 从 3D-DDA 切换到 linear cell scan,再把 grid resolution 调到 256;你预计访问 cell 和纹理内存如何变化?
问题 2|诊断法线接缝。 高模命中位置正确,但 normal map 在 UV 接缝或旋转后出现方向跳变,请按“基、编码、运行时”列出检查点。
问题 3|场景选型。 低模 UV 很大、参考网格有数百万三角形、显存又有限时,你会如何组合网格、精度与渲染 pass?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- normal-map baking
- boundary cage
- uniform grid
- 3D-DDA
- tangent space
- antialiasing