技术博客

Nanite Cluster 剔除流程:两级 HZB 与持久线程的源码走读

第 32 / 100 篇 · 2025-03-30 · ue5 · Nanite渲染源码分析

Nanite 把"每个像素一个三角形"变成了可行的事,核心不在光栅化,而在剔除——每帧从几亿三角形里只活下来的几百万个送进光栅器。这块代码在 Engine/Source/Runtime/Renderer/Private/Nanite/(UE5.3),入口是 NaniteCullRaster.cpp。下面按一帧的执行顺序拆开。

离线构建:Cluster DAG

剔除的前提是数据结构。导入网格时 NaniteBuilder(Engine/Private/NaniteBuilder)做的事:

  1. 三角形按 ~128 个一组切成 cluster;
  2. cluster 按空间邻近合并成 cluster group,组内做简化(保留边界锁定的 quadric 简化);
  3. 简化结果再切 cluster,递归直到只剩一个——形成一棵 DAG,每个节点存误差界(error bound):父节点是子节点集的简化版本,父子误差单调。

128 这个数不是拍脑袋。往下,cluster 越小 DAG 切割粒度越细,单 cluster 的投影误差越容易压进 1 像素阈值;往上,cluster 越大,每 cluster 的调度、加载、包围体测试的固定开销摊得越薄。实测取值的甜点位就在百级三角形。顺带一个配套约束:cluster 内顶点数受限,局部顶点索引可以压进 8-bit,配合 strip 化索引压缩,显存里的 cluster 数据才能做到"页式流送也扛得住"的密度。

简化算法本身用的是 QEM(Quadric Error Metric)的变体:给每个顶点维护一个二次误差矩阵 $Q = \sum_{p} K_p$,其中 $K_p = p p^T$ 是关联平面 $p=(a,b,c,d)$ 的外积矩阵;收缩一条边把两端点合并到 $\bar v$ 的几何代价是

$$\text{cost} = \bar v^T (Q_1 + Q_2)\, \bar v$$

取使代价最小的 $\bar v$。QEM 的好处是误差可累积——每次收缩的代价天然形成误差界,这正好喂给 DAG 节点要存的 error bound。group 边界上的顶点被锁定不收缩,是为了相邻 group 各自简化后接缝不错位:没有这个约束,DAG 切一刀时相邻 cluster group 的边界会裂开发丝缝。

运行时剔除的本质就是在这棵 DAG 上切一刀:从根往下走,当某个节点的屏幕空间投影误差 ≤ 1 像素时停,这个节点就是该画的粒度。每个 cluster 存的 LODError / MinLODError / MaxParentError 字段就是为这个判断服务的。

投影误差的换算

"世界空间误差 ≤ 1 像素"具体怎么算?世界空间误差 $e$(单位:世界单位)投影到屏幕上的像素高度近似为

$$e_{px} \approx \frac{e \cdot h}{2\, d \tan(\theta/2)}$$

其中 $d$ 是节点包围球球心到相机的距离,$\theta$ 是垂直视场角,$h$ 是视口像素高度。剔除条件就是 $e_{px} \le \text{MaxPixelsPerEdge}$(默认 1.0)。因为 DAG 上父子误差单调(父节点误差不小于子节点集),从根往下走遇到的第一个满足条件的节点集合,构成 DAG 上的一个合法切割面,而且这个切割面对任意相机位置是唯一确定的——这就是 Nanite"连续 LOD"在数学上成立的依据:LOD 不是几个离散档位,而是 DAG 上随相机位置连续滑动的一条割线。

运行时剔除流水线

NaniteCullRaster.cpp 里的 pass 序列(RDG 声明顺序):

InitArgs → InstanceCull → NodeAndClusterCull(pass 1)
→ Rasterize(pass 1)→ BuildHZB → NodeAndClusterCull(pass 2)→ Rasterize(pass 2)

两遍剔除的设计

为什么是两遍?遮挡剔除依赖 HZB(层级深度图),但当前帧的 HZB 要画完才有。Nanite 的做法:

  • Pass 1:用上一帧的 HZB 做遮挡测试。上一帧可见、这帧大概率还可见的实例/cluster 直接通过,先画一批。这批通常覆盖 95% 以上的可见几何。
  • Pass 1 画完后 BuildHZB 生成本帧的 HZB。
  • Pass 2:把 Pass 1 里被上一帧 HZB 判定为遮挡的节点用新 HZB 重测——相机移动后新露出的部分在这里补画。

两遍结构成立的前提是时域一致性:相邻帧的可见集合高度重叠。仔细分析两类误判:上一帧 HZB 说"可见"但本帧其实被挡住了(假阳性)——顶多多画一点,不产生错误;上一帧说"遮挡"但本帧露出来了(假阴性)——漏画,这才是要命的,pass 2 就是为它们存在的。也就是说 pass 1 用旧数据只承担"多画"的良性风险,所有"少画"的风险都被 pass 2 用本帧新 HZB 兜住。这个 two-pass 结构在 FCullingPass / Nanite::CullRasterize 里能直接看到,HZB test 的 shader 是 NaniteCulling.usf 里的 HZBCull 逻辑。实测相机高速旋转时偶尔有一帧的"漏画闪一下",就是 pass 2 补洞的时间差。

Nanite 一帧的剔除流水线:两遍 HZB 剔除与时域复用 InitArgs InstanceCull Cluster Cull P1 Rasterize P1 BuildHZB(N) 上一帧 HZB(帧 N-1) 假阳性=多画,无漏画风险 Cluster Cull P2 Rasterize P2 本帧新 HZB:重测 P1 判定为遮挡的节点 BuildHZB(N) 同时是帧 N+1 的 pass 1 输入 —— 时域一致性的赌注 P1 覆盖 ~95% 可见几何 P2 只补"新露出"的洞

持久线程(Persistent Threads)

cluster 数量是百万级的,每个 cluster 一个 thread 的 naive dispatch 调度开销就爆炸了。算一笔账:假设 200 万个候选 cluster,每 thread 处理一个,GPU 按 wave 调度,每 wave 几千线程,光是 wave 的派发和空转(大量 thread 测试完立即退出)就是纯浪费——剔除的工作量是数据依赖的,事先不知道每个节点要不要展开子节点,无法用静态 grid 匹配。

Nanite 的 NodeAndClusterCull 用的是持久线程模型:固定 dispatch 一组 thread(数量按 wave 算满 GPU),所有 cluster/node 放进一个全局工作队列,thread 通过原子操作抢任务,shader 里有个 while 循环反复 TryPopWorkItem。这样 GPU 占用率和候选数量解耦:thread 数固定,队列长短只决定每个 thread 循环几圈。

这个模型还顺手解决了另一个问题:node 剔除是树遍历,父节点处理完才知道要不要再派生子节点的活。传统 compute 模型里这得回 CPU 读回结果再做第二轮 dispatch,或者预分配最坏情况的间接参数;持久线程里处理完 node 可以直接把子 node push 回队列,不用回 CPU 做第二轮 dispatch。整个剔除在一次 dispatch 内完成整棵 DAG 的遍历,这是 Nanite 剔除快的结构性原因。后来 UE 把这个模式泛化成了 Work Graph API(5.x 实验性),源头就是 Nanite 这个手写版。

实例剔除 vs cluster 剔除

InstanceCull 先行:视锥剔除 + 上一帧 HZB 实例级遮挡,过不了的实例整棵 DAG 都不用走。然后 NodeAndClusterCull 对每个存活实例跑 DAG 切割。节点判定有两类输出:

  • IsLeaf/误差达标 → 这个 cluster 入选,写进 visible cluster 列表(QueueVisibleCluster);
  • 误差超标 → 展开子节点继续。

可见 cluster 列表写到 MainAndPostPassCluster buffer,后面光栅化 pass 直接消费,CPU 全程不参与——典型的 GPU-driven 管线。

与光栅化的衔接

剔除的输出喂给 VisBuffer 光栅(NaniteRasterizer.usf):小三角形走软件光栅(在 LDS 里算覆盖、原子写 64 位 visibility entry),大的走硬件光栅。软硬光栅的分界标准就是"三角形覆盖像素数小于某个阈值"——硬件光栅器的固定管线开销(三角形 setup、quad 粒度调度)在三角形小于一个 quad(2×2 像素)时全面亏本,软件光栅用计算换掉了这部分固定成本。VisBuffer 每个像素存的是 depth + visible cluster index + triangle index 的紧凑编码,材质信息完全不在里面——所以 Nanite 场景里几何和材质是解耦的,后面的 material classification pass 才按像素归组着色。

流送:剔除之外的另一半预算

剔除决定"画哪些",流送决定"内存里有哪些"。Nanite 把 cluster 数据按 page 组织(FStreamingManager,NaniteStreaming.cpp,page 是定长块),剔除 pass 选中的 cluster 如果 page 还没驻留,就走 AddStreamingRequest 异步加载,本帧先用 DAG 上更粗的父节点顶着——注意这个 fallback 之所以可能,恰恰因为 DAG 保证了"父节点是子节点的合法简化版本",任何时候退到祖先节点画面都是自洽的。这就是远处山体偶尔"糊一下再变清晰"的来源:不是 LOD 切换,是 page 未驻留时的 fallback 渲染。r.Nanite.Streaming 相关 cvar 可以调池子大小;显存吃紧的项目先查 Stat Nanite 里的 streaming 一节,requests 长期排队的场景要么减密度要么加池。

局限与实测注意事项

Nanite 不是免费午餐,两类内容要避开:

  • 变形/WPO 重的材质:cluster DAG 是离线按静态几何建的,World Position Offset 幅度大了会破坏误差界假设——误差界是对静态几何证明的,顶点一旦被材质搬走,$e_{px} \le 1$ 的保证就失效了。5.3 对 WPO 的支持是"有限可用"(r.Nanite.AllowWPO 一类开关下仍保守),植被摇摆幅度要小;
  • 半透明和 masked 大面积重叠:VisBuffer 每像素只存一个命中,多层半透不走 Nanite,foliage 的 masked 材质叠加会退化到昂贵路径,r.Nanite 0 对比测试是必做的回归项。

读源码的入口建议

  • 想看剔除主循环:NaniteCulling.usf 的 NodeAndClusterCull,配合 NaniteCullRaster.cpp 里 AddPass_NodeAndClusterCull。
  • 想看 DAG 数据布局:NaniteDataDecode.ush 里的 FPackedCluster / FCluster。
  • 想验证行为:控制台 r.Nanite 0/1 对比,Stat Nanite 看每帧候选/可见 cluster 数;r.Nanite.ShowStats 能直接看到 pass1/pass2 各画了多少 cluster,验证两遍剔除的分工。

Nanite 的启发不在"快",而在它把 LOD、遮挡、调度三件事全部收编进一个数据结构(DAG)加一个调度模型(持久线程)里,换来的是整条管线没有 CPU 往返。这个思路对做任何 GPU-driven 系统都有参考价值。