剔除解决"画哪些三角形",流送解决"显存里有哪些三角形"。Nanite 敢把几亿三角形的场景装进一个关卡,靠的是把几何切成定长的 page 按需加载——这块代码在 Engine/Source/Runtime/Renderer/Private/Nanite/NaniteStreaming.cpp(UE5.3)。实测大场景项目里 Stat Nanite 的 streaming 一节长期有 request 排队,问题多半出在对这套机制的理解上,这篇按请求的生成、预算的分配、驻留的维护三个环节拆开。
为什么按 page 而不是按 cluster 流送
上一篇讲过,DAG 的节点是 cluster,但流送单元不是 cluster。原因在两处:
- IO 效率。磁盘和 IOStore 容器的读取都有最小高效粒度,几 KB 的 cluster 逐个读会把吞吐浪费在寻址和请求调度上。Nanite 把一批相邻(空间邻近、误差层级相邻)的 cluster 打包成定长 page(常量
PageSize级别的块,KB 量级),一次请求搬一整页。 - 请求聚合。剔除输出的可见 cluster 列表里,相邻 LOD 层的 cluster 大概率来自同一批 page。逐 cluster 请求会产生大量重复的"这一页我要"信号,按页聚合后去重,请求数直接降一两个数量级。
代价是粒度变粗带来的过送(over-fetch):只要一个 cluster 被选中的 DAG 切割面需要某页,整页都要驻留,即使页内一半的 cluster 当前不可见。这个权衡和虚拟纹理(VT)当年面对的一模一样——VT 也是按 page 而非按 texel 流送。Nanite 的 page 在内部叫 FPage(NaniteStreaming.h),每页带一个 PageSize 固定槽位,FPageKey 由 (Resource, PageIndex) 唯一确定。
请求链路:从剔除到 IO
一帧内请求的完整路径:
- GPU 侧。剔除 pass 选中的 cluster 对应
FPackedCluster里有PageIndex字段。NodeAndClusterCull里对每个选中的 cluster 走AddStreamingRequest——注意它只写请求,不做任何同步等待。请求攒在 GPU buffer 里,整帧算完后一次回读。 - 回读与聚合。
NaniteStreaming.cpp的FStreamingManager::Update把 GPU buffer 回读到 staging buffer(r.Nanite.Streaming.BufferRHIReadback相关路径,一轮FRHIGPUMemoryReadback),然后按 page key 去重合并,得到本帧真正需要的 page 集合。 - 请求裁剪。需要的集合减去已驻留的集合,再减去"已在飞行中(in-flight)"的集合,才是要新发的请求。已驻留信息记在驻留表(resident page table)里,in-flight 的记在 pending 队列里——这两步裁剪是防重复加载的关键,相机不动时每帧的增量请求应趋近于零。
- IO 下发。剩余请求转成对 bulk data 的异步读(IOStore 容器),数据回来后拷进常驻显存池,更新 GPU 端 page table,下一帧剔除就能命中新 page。
GPU Cull ──选中 cluster──▶ AddStreamingRequest (写请求位)
│
▼ 帧末回读
FStreamingManager::Update
│ 聚合去重 → 减驻留表 → 减 in-flight → 新请求
▼
IOStore 异步读 ──▶ 拷入显存池 ──▶ 更新 GPU page table ──▶ 下帧可用
整个设计的一个关键性质是全程异步、无 GPU↔CPU 帧内往返。请求本帧发出,数据最早下帧才可用,中间这段时间剔除选中的 cluster 如果 page 不在显存里,渲染就必须有兜底。
未驻留的兜底:DAG 祖先回退
这是 Nanite 流送里最巧妙的一步,前提是剔除那篇讲过的 DAG 性质:父节点永远是子节点集合的合法简化版。渲染时某个选中 cluster 的 page 未驻留,剔除 pass 不走"跳过"也不走"黑块",而是沿着 DAG 向上找最近的一个已驻留祖先,画那个更粗的 cluster。
这意味着流送失败从不是一个正确性问题,只是一个质量问题:远处山体在快速推镜头时先以粗 LOD 出现,page 到位后悄悄变精细。用户看到的"糊一下再清晰"就是这个回退在起作用,它和传统 LOD 的"档位跳变"观感完全不同——DAG 切割面本身是连续的,回退只是切割面被迫停在更高的层级上。
这个机制的实现线索在 NaniteCulling.usf 的 node 遍历里:展开子节点前会检查子节点所在 page 的驻留位(GPU page table 里的标志),未驻留就不展开,用当前已驻留的祖先 cluster 提交。所以"剔除"和"流送"在 shader 里是同一个遍历循环里的两个判断:误差决定是否展开,驻留决定是否能展开。
显存预算与换出
驻留池不是无限大的,r.Nanite.Streaming.StreamingPoolSize(MB 为单位)控制上限,默认按项目配置走。池子满了又来新请求怎么办——答案和 CPU 的 page cache 一样:LRU 换出。FStreamingManager 维护驻留 page 的最近使用信息,需要腾地方时把最久没被剔除命中的 page 踢出。
这里有一个传统 LOD 系统没有的张力:DAG 祖先回退要求祖先尽量驻留,否则子节点到了也没意义(画不了比祖先更细的,因为更细的还没加载,画了也只是一帧后又被换出的恶性循环——业内叫 thrashing)。Nanite 的解法是给 DAG 上层节点隐式的驻留优先级:请求聚合阶段,一个 cluster 被选中时,它到根路径上的中间节点也会受益,因为祖先 cluster 和后代常在同一页或相邻页。实测观察 thrashing 的信号是 Stat Nanite 里 streaming requests 持续非零且 visible cluster 数波动大,同时画面远处的几何有持续的"呼吸感"。处理手段按优先级:
- 调大
StreamingPoolSize(显存够的话这是首选); - 检查场景里是否有"灯塔问题"——某个极远处高细节物体(如塔尖、天线)每帧都在切割面上精准命中最高 LOD,强行拉高一整串 page 的驻留优先级,这类内容要么砍细节要么用
r.Nanite.Streaming局部降级; - 退一步降
r.Nanite.MaxPixelsPerEdge反向收紧切割面,让远处精度需求下降。
和 Virtual Texture 的对比
把 Nanite 流送和 VT 放在一起看,能看清 Epic 的设计取向:两者同为"page 化 + 异步 IO + GPU 页表",但 VT 的 page 驻留状态直接决定像素着色结果(纹理没到位就得显示模糊 fallback 或黑色),而 Nanite 的驻留状态决定的是几何 LOD 层级,有 DAG 祖先这个无损正确性的回退层。所以 VT 需要复杂的 residency feedback 和预取预测,Nanite 只需要"请求 + LRU",把智能全塞进了离线构建的 DAG 里。运行时系统越笨,离线数据越聪明——这是 Nanite 整体(剔除、流送、光栅化)贯穿的思路。
排查清单
- 推镜头时远处持续模糊:
Stat Nanite看 streaming requests 队列深度,配合r.Nanite.Streaming.StreamingPoolSize加大验证。 - 画面呼吸/闪烁:
Stat GPU对比NaniteCullRasterize的耗时波动,波动大且与相机移动强相关时基本是 thrashing。 - 显存占用超预期:page 池是固定上限,超了说明池子设大了或者场景内容密度超预算,用
r.Nanite.Streaming.MaxPendingPageRequest限并发能压峰值但会牺牲推镜头的响应速度。