从”普通程序员能听懂”到”能改 UE 源码”——一份基于源码逐行考证的 Nanite 深度讲解

  • 文档版本:1.0(2026-08-16)
  • 源码快照:本地仓库 d:\Project\GameDevelop\UnrealEngine,UE 5.9 移动优化 fork,HEAD 2c7bb82d
  • 写作原则:文中所有”源码考古”块引用的文件路径、行号、函数名均在本仓库中实际验证过;行号只用于定位,请以 路径:函数名 为准
  • 符号说明=Cluster,=Page,层级节点=Hierarchy Node,可见性缓冲=Visibility Buffer,RasterBin/ShadingBin 不翻译

第 0 章 导读:这份文档怎么读

TL;DR|这份文档把 Nanite(虚幻引擎 5 的虚拟几何体系统)从”它是什么”一路讲到”源码长什么样”。
全程用普通程序员能懂的类比开路(乐高积木、地图缩放、虚拟内存),每条关键结论都能在
UE 源码里找到落点。有三个深度档位:30 秒看懂、3 小时学会、1 天读源码。

0.1 目标读者与前置知识

这份文档的目标读者是普通程序员:会写 C++、用过或听说过 UE 的资产管线、知道”渲染管线”四个字但不必是图形学专家。如果你想听懂”深度缓冲””延迟渲染”这类词但一时想不起细节,文档会在第一次出现时用一句话解释。

真正的前置知识只有三个,都非常”程序员”:

  1. 数据要放在显存里才能被 GPU 读——显存有限,和内存有限是一个道理;
  2. 三角形是 3D 图形的最小单元——一个模型 = 一堆三角形;三角形越小、越多,模型越精细;
  3. 每帧都要重新决定画什么——游戏每秒渲染 60 帧,每帧的”画什么、怎么画”都要重新计算。

0.2 三条阅读路径

通道 读什么 耗时 读完能做什么
🚀 30 秒通道 每章开头的 TL;DR 块 + 类比 5 分钟 跟人解释 Nanite 是什么、四大支柱、为什么快
📚 标准通道 TL;DR + 正文 + 图 3~4 小时 理解完整数据流,会看 r.Nanite.ShowStats 调优、会排查常见问题
🔬 源码考古通道 全部,尤其每章末尾的 源码考古 1~2 天 敢打开 UE 源码对照着读,能定位 Nanite 任意子系统的实现

0.3 块标记约定

文档里你会反复看到四种”标记块”,HTML 版中它们有不同外观:

  • **TL;DR**(每章开头,引用块):30 秒读完本章。规则:3~5 句、必须含一个类比名、不出现源码路径。
  • **类比实验室**(正文小节):用一个普通程序员已经懂的东西讲清楚一个陌生的概念。
  • **源码考古**(每章末尾,折叠块):贴真实源码(≤25 行/段),逐行翻译”这段代码在干什么”。引用格式:路径:行号(函数名)
  • **避坑**(正文穿插的警示块):版本差异、易错点、公开资料与当前代码不一致的地方。

0.4 一张图看懂全局

下面这张图是全文档的”地图”,我们花一整篇文档把它展开讲。请先花 30 秒记住它的形状:离线构建 → 打包数据 → 按需流送 → GPU 渲染 → 分材质着色,五个环节,数据单向流动,只有流送环节有”回环”(GPU 需要什么 → 通知 CPU 加载)。

图 F1 Nanite 全链路数据流总览(此图贯穿全文,后文各章是对它的展开)
① 构建期(离线) 高模网格(千万三角形) FMeshNaniteSettings 设置 NaniteBuilder(简化+编码) 产出 FResources RootData 根页(常驻) StreamablePages 可流送页 HierarchyNodes 层级数组 ② 运行时流送 FStreamingManager(单例) 虚拟页表 + LRU 淘汰 异步读盘(后台任务) FFixupChunk 修正引用 scatter 上传 GPU 大缓冲 ③ GPU 渲染(每帧,全 GPU 驱动) 实例剔除(视锥+HZB 遮挡) 层级节点/簇剔除(LOD 选择) RasterBin 分桶(按材质/深度) HW 光栅化 SW 光栅化 写 VisBuffer64(可见性缓冲) 导出深度/模板 ④ 分材质着色 ShadeBinning(像素按材质分桶) 每个材质一个计算着色器 按 indirect args 间接分派 读 VisBuffer 取三角形/属性 插值+计算光照 写入 GBuffer(UAV)

GPU 剔除发现”缺页”(FGPUStreamingRequest)

⑤ 流送请求回环(虚拟内存式缺页)

第 46 章
第 78 章
第 9~11 章
第 12 章

读图方法:把上图从左到右读一遍,再问自己三个问题——“数据是谁造出来的?”(构建期)、”数据放在哪、怎么变出需要的那部分?”(流送)、”每帧这些数据怎么变成屏幕上的像素?”(渲染+着色)。这三问,就是本文的三篇正文。

0.5 章节地图

章节 一句话
开篇 第 0~1 章 Nanite 是什么,为什么需要它
概念篇 第 2~3 章 五大核心概念 + 设计哲学(为什么是三角形)
构建篇 第 4~6 章 离线:高模 → 簇 → 页(乐高积木装箱)
数据篇 第 7~8 章 运行时数据结构 + 虚拟内存式流送
渲染篇 第 9~12 章 GPU 剔除 → 光栅化 → 可见性缓冲 → 着色
应用篇 第 13~15 章 编辑器怎么用、怎么调优、有什么局限
附录 A~D 源码地图、术语表、参考资料、自测题

第 1 章 Nanite 是什么:一场几何体的”虚拟化”革命

TL;DR|Nanite 是 UE5 的虚拟几何体系统:它把”显存放不下、每帧画不完”的电影级高模,
变成”按需加载 + 每帧自动选 LOD + 全部由 GPU 自己剔除”的虚拟几何体,让你像放纹理一样放模型,
不用手动做 LOD、不用烘焙法线贴图、不用担心 draw call。
它的做法可以概括为四大支柱:Cluster 切分、层级 LOD、GPU 驱动剔除、页面流送
心智模型是”虚拟纹理的几何版”——本 章用虚拟内存类比给你搭好全书的地基。

1.1 传统管线的痛点:游戏美术的”三座大山”

先看看 Nanite 出现之前,游戏要放一个”精细模型”有多麻烦。

第一座山:多边形预算。 游戏引擎是实时渲染的,每帧只有几毫秒的渲染时间。一个 100 万三角形的模型在 1080p 屏幕上能跑,但 100 个这样的模型就不行了。于是美术需要”控制面数”:角色脸部 5 万面、枪 3 万面……每个资产都有一份”预算表”。

第二座山:手工 LOD。 模型离镜头近时用高模、远时用低模。传统做法是美术手工做 3~5 个递减精度的版本(LOD 0/1/2/3),引擎按距离切换。多一个资产就多几份 LOD 资产,而且切换瞬间可能”跳变”——远处山体上你能看见多边形轮廓”唰”地变少。

第三座山:法线贴图烘焙。 高模细节太多跑不动?那就把它烘焙成一张法线贴图贴到低模上。烘焙是离线流程,需要人工调 UV、调参数,贴图分辨率有限,放大看会糊。

这三座山本质上是同一个问题的三个侧面:几何体无法按需取用。要么一次全放(显存放不下),要么提前做好几个粗糙版本(浪费创作自由)。

1.2 虚拟化的心智模型:像虚拟纹理虚拟化纹理一样,虚拟化几何体

Epic 做 Nanite 时反复引用过一个自家成功案例:虚拟纹理(Virtual Texture)

虚拟纹理解决的是”纹理太大放不下”的问题——以前一张 8K 大地图纹理要整张进显存;虚拟纹理把它切成很多”页(Page)”,GPU 只把当前画面上用得到的那几页加载进来,用不到的在磁盘上躺着。画面上出现新区域时,GPU 说”我缺这几页”,后台异步加载。这就是操作系统虚拟内存的纹理版:按需调页、透明缺页、LRU 淘汰

Nanite 把同样的思想用到几何体上:

虚拟纹理 Nanite 虚拟几何体
被虚拟化的东西 纹理像素 三角形
最小单位 页(Page) 页(Page,内含簇)
细分单位 纹理图块(Tile) 簇(Cluster,128 三角形)
缺页请求 像素访问未加载页 GPU 剔除访问未加载页
淘汰策略 LRU LRU
自动选分辨率 Mip 层级 LOD 层级(更狠:逐簇选择)

类比实验室:虚拟内存
想象你的程序请求了一块 10GB 的数组,而机器只有 4GB 内存——操作系统会”假装”这块数组存在,
你访问哪个页它才把哪个页从磁盘调入,长期不用的页换出。程序无感知,性能接近全部驻留。
Nanite 的模型就是那块”虚拟数组”:美术想放多少三角形都行,显存只驻留画面上需要的部分,
而且这一切对美术透明。记住这个类比——第 8 章会把它讲成完整的技术实现。

1.3 一句话定义与四根支柱

Nanite = 以”簇(Cluster)”为单位、以”页(Page)”为流送粒度、以”层级 DAG”为 LOD 结构、完全由 GPU 驱动剔除与光栅化的虚拟几何体系统。

拆开看就是四根支柱,也是全书的主干:

  1. Cluster 切分:任何模型,无论多大,构建时都被切成约 128 三角形一个的”簇”。簇是构建、剔除、光栅化、流送的原子单位(像把巨型雕像拆成一箱乐高积木)。
  2. 层级 LOD(DAG):簇按空间邻近关系组织成一棵”金字塔树”——底层是原始高模,上一层是简化了一半的父簇,再上一层再简化……运行时不是 3~5 档固定 LOD 切换,而是每个簇独立按屏幕误差选档,远看整个模型用几百个粗簇,近看局部用几百万个细簇,中间没有可见接缝(第 5 章讲如何做到)。
  3. GPU 驱动剔除:每帧,剔除、LOD 选择、绘制参数生成全部在 GPU 上用计算着色器完成,CPU 几乎零参与。一个场景可能只有几条间接绘制命令,而不是几十万个 draw call。
  4. 页面流送:层级树和簇数据按”页”打包存放。GPU 剔除过程中发现”画这个簇需要的数据还没进显存”,就发一个请求,CPU 端流送管理器异步从磁盘加载——缺多少补多少,宁可显示略粗糙的父簇也绝不卡帧(第 8 章)。

1.4 一点历史:从 2020 到 5.9

  • 2020.05:UE5 首次公开演示《Lumen in the Land of Nanite》(山谷场景,数亿三角形直接渲染),震撼业界。
  • 2021.08:Epic 的 Brian Karis 等在 SIGGRAPH 2021《Advances in Real-Time Rendering》课程上做了完整的架构演讲《A Deep Dive into Nanite Virtualized Geometry》,并配 155 页讲义——这是目前最权威的公开资料,本文多处引用(后文简称”论文”)。
  • 2022.04:UE 5.0 正式版,Nanite 上线,仅支持静态网格(Static Mesh)。
  • 5.1~5.8:陆续加入世界位置偏移(WPO)、骨骼网格(实验性)、曲面细分(实验性)、半透明(实验性)、光追支持(实验性)等。
  • 本仓库(5.9 移动优化 fork):在官方 5.9 基础上扩展了 Voxel、曲线(Curve)光栅化、装配(Assembly)等通道——注意,这些扩展在公开版 UE 上不一定存在或默认关闭,文中凡涉及都会用 避坑 块注明。

避坑:本文的源码引用基于本仓库(5.9 fork,HEAD 2c7bb82d),与你在 GitHub 看到的官方 5.x 分支可能有差异
(比如 UE 5.7 把 FNaniteStreamingManager 改名为 FStreamingManager)。写这篇文档时我们会逐处核对当前仓库,
但你自己读代码时如果行号对不上,先搜函数名——源码才是最终权威。

1.5 传统方案 vs Nanite:一张图看懂差距

图 F2 传统实时渲染管线 vs Nanite 管线(同一个"山谷场景"的两种命运)
┌─────────────────────── 传统方案(UE4 时代) ───────────────────────┐
│ 百万面高模 ──烘焙──▶ 法线贴图 ──贴──▶ 低模(5万面)  ◀──美术手工 LOD0/1/2/3
│                                    │
│                                    ▼
│          每帧 CPU:剔除全部实例 → 生成几万个 draw call → GPU 逐模型绘制
│          多边形多了 → 掉帧;LOD 切换 → 跳变;每个资产都要手调预算
└──────────────────────────────────────────────────────────────────────┘

┌─────────────────────── Nanite 方案(UE5) ──────────────────────────┐
│ 千万面高模 ──一键启用──▶ 自动切成簇 → 自动生成层级 LOD → 自动打包成页
│ │
│ ▼
│ 每帧 GPU:自己剔除(视锥+遮挡)→ 自己选 LOD → 自己光栅化
│ 需要更多细节 → 流送请求 → 后台自动加载更细的页
│ 多边形数量几乎不影响帧率;质量只由屏幕分辨率决定
└──────────────────────────────────────────────────────────────────────┘

核心差异一句话:传统方案在”内容生产”环节消灭多边形(人工优化),Nanite 在”运行环节”消灭多边形的影响(自动优化)

1.6 小结

这一章你只需要记住三句话:

  1. Nanite 的目标是消灭手工优化:不手做 LOD、不烘焙法线、不算面数预算。
  2. 它的手段是虚拟化:像虚拟内存/虚拟纹理一样,按需把几何体调入显存。
  3. 它的载体是四根支柱:Cluster 切分、层级 LOD、GPU 剔除、页面流送——后面每一章都在讲其中一根。

下一章,我们用五个类比把这四根支柱的每一个概念都”翻译”成你已经懂的东西。

第 2 章 五大核心概念速览(每个概念 = 一个类比)

TL;DR|Nanite 的五个核心概念,用五个你早就懂的东西类比:Cluster(簇)= 乐高积木
层级 LOD = 地图缩放可见性缓冲 = 先记”是谁”再问”是什么”页面流送 = 虚拟内存缺页
两遍遮挡剔除 = 先画糊的再画清楚的。这一章只建立直觉,不给代码——每个概念在源码里的
落点会在后续章节展开,本章末尾给一张”概念 → 源码落点”预告表。

2.1 Cluster(簇)= 乐高积木

定义:Cluster 是 Nanite 的原子单位——一组约 128 个三角形的小三角片集合。任何模型在构建时都会被切成很多个 Cluster,每个 Cluster 自带:顶点数据、三角形索引、包围球(SphereBounds)、简化误差(LODError)等元信息。

为什么是 128? 因为 GPU 处理的”最优三角形数量”就在这个量级:太小则每个 Cluster 的元数据开销占比过高;太大则剔除粒度太粗(一个 1 万个三角形的簇,即使只露出 10 个三角形也要全画)。128 是 Epic 实测后的折中——它不是魔法数字,而是 NANITE_MAX_CLUSTER_TRIANGLES = 128,写在 Engine/Shaders/Shared/NaniteDefinitions.h:21-22,C++ 和着色器共用。

图 F3 乐高积木类比:一尊雕像 = 许多 128 粒的积木块;每块可以单独搬运(剔除)、单独换新版(LOD)、单独送货(流送)
        高模(1,000,000 三角形)
        ┌──────────────────────────────┐
        │      ★    ★                  │  构建时切分(按空间邻近 + 图划分)
        │     ★★★  ★★                 │
        │    ★★★★★★★                 │
        │     ★★★★  ★★★              │
        │      ★★    ★★               │
        └──────────────────────────────┘
                        │  切分(Split)
                        ▼
        ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
        │Cluster │ │Cluster │ │Cluster │ │Cluster │  每个 ≤128 三角形
        │  #1    │ │  #2    │ │  #3    │ │  #4    │  自带包围球/误差/材质区间
        │ 128▽   │ │ 128▽   │ │ 96▽    │ │ 128▽   │
        └────────┘ └────────┘ └────────┘ └────────┘
              ▲          ▲
              └────┬─────┘
                   │  空间邻近的簇组成"组"(Group)→ 组再简化成父簇
                   ▼
              ┌────────┐
              │父 Cluster│  ← 上层 LOD:256 三角形的表面简化成 ~128 三角形
              │  #7    │     仍然盖住同样的区域
              └────────┘

它在这条流水线里的位置:构建期——切分;剔除期——被测试的最小单位;光栅化期——被绘制的最小单位;流送期——页内数据的最小单位。一切以簇为边界,这就是”原子单位”的意思。

2.2 层级 LOD(DAG)= 地图缩放

定义:把”每个 Cluster”按空间关系组织成一棵金字塔树(严格说是有向无环图 DAG,因为一个父簇可能被两个组共享,后面细说):树根是最粗的 LOD(整个模型几个簇),越往下越细,树叶是原始高模的簇。构建时自动生成,不需要美术参与。

类比实验室:地图缩放

打开手机地图:你拉到全国视角,只看到几个城市名(粗 LOD);放大到城市,街道出现了;
再放大,每栋楼都有了(细 LOD)。Nanite 做的是同一张图——它不会在”全国视角”用一张
简化全国图、放大后整张换掉,而是逐区域独立选择精度:屏幕中央放大看细节,屏幕边缘
依然用粗精度。这正是 Nanite 与传统 LOD 最大的区别:LOD 不是整模型切换,而是逐簇选择

运行时如何选? 每帧 GPU 从树根开始往下走,对每个簇问一个问题:**”如果我换成它的父簇(更粗),屏幕上的误差会超过 1 个像素吗?”** 不会 → 停止下降,用父簇;会 → 继续下降到子簇。这个过程叫”遍历层级并选出一个切面(Cut)”。于是:

  • 远处的模型:只用几个粗簇 → 快
  • 近处的模型:用几千个细簇 → 精细
  • 同一模型:近处部分细、远处部分粗 → 效率与质量兼得

为什么是”误差 < 1 像素”而不是”距离 < X 米”? 因为 1 像素是人眼几乎无法察觉的极限。这个标准是视角自适应的:同一个模型在 4K 屏上比在 720p 屏上需要更多细节(更多像素 = 更小的投影误差 = 需要更细的 LOD)。传统 LOD 按距离切档,而 Nanite 按”屏幕空间误差”选择,天然适配任何分辨率、任何 FOV。

图 F4 层级 LOD 树(DAG)与运行时"切面"(Cut)——绿线以上是这一帧实际被绘制的簇
Root(最粗) 整模型 2 簇 LOD 层 1 LOD 层 1 层 2 层 2 层 2 层 2 原始簇 原始簇 原始簇 原始簇 原始簇 这一帧的"切面"(Cut)

粗簇的投影误差 < 1 像素 → 停在这里
(近处部分)
误差超过阈值 → 继续向下细分
(近处部分)
切面以下 = 这帧被绘制
切面以上 = 这帧被跳过

避坑:图 F4 是”一棵树”,但严格说是 DAG(有向无环图)——相邻的组可能共享同一个父簇,
一个簇可能有多个父节点。共享是为了省内存(大平面场景的簇复用),代价是遍历时要做去重。
我们讲概念时先按”树”理解,第 5 章再讲真实的 DAG 结构。

它在这条流水线里的位置:构建期——建树;运行时——GPU 遍历选切面;切面选择的结果就是”这一帧要画哪些簇”的清单。

2.3 可见性缓冲(Visibility Buffer)= 先记”是谁”再问”是什么”

定义:Nanite 光栅化阶段不直接算颜色,而是每像素只写一个 64 位的”身份证号”:深度、簇 ID、三角形 ID、实例 ID。这一步只回答”这个像素被哪个几何体覆盖”,不回答”它是什么颜色”。等全部几何体都画完了,再根据身份证号逐个像素查询材质、计算颜色

为什么要多此一举? 传统渲染是”边画边算”:每画一个模型,像素着色器马上算它的材质。问题是:

  1. 场景有几十种材质 → 每次材质切换都是 GPU 状态切换,几百上千次;
  2. 像素会被多个三角形覆盖(遮挡)→ 后画的覆盖先画的,先画的材质白算了;
  3. 微型三角形(比像素还小)→ GPU 的像素着色器以 2×2 四边形为单位工作,大量浪费。

先记身份、后算颜色,把”画几何”和”算材质”彻底解耦:几何阶段只关心”挡没挡住”,材质阶段只处理”最终可见的像素”——一个像素只被着色一次

类比实验室:先发名单,再发名片
想象一个会场(屏幕),很多人要入场(三角形)。传统做法:每来一个人就立刻给他发名片(算材质),
但后来的人会挡在前面的人,前面人的名片白发了。Nanite 的做法:门口只登记”来了谁”(写 VisBuffer),
最后只给留下的人补发名片(着色)。名单 = 64 位 ID,名片 = 材质计算。名单和名片彻底分开,
名片可以慢慢发(第 12 章讲)。

它在这条流水线里的位置:光栅化输出(第 11 章)→ 深度导出 + 材质着色(第 12 章)之间的桥梁。

2.4 页面流送(Page Streaming)= 虚拟内存缺页

定义:构建好的全部簇数据,按”页(Page)”打包存放——根页面(含最粗 LOD)随资产常驻显存,其余页面在磁盘上。GPU 剔除时如果发现”需要的簇所在页面还没加载”,就发一个流送请求,CPU 端 FStreamingManager 异步加载后把数据散写进 GPU 的大缓冲,并修正引用。

图 F5 页面流送循环(虚拟内存式缺页)——第 8 章会展开成完整的五阶段时序
                    ┌─────────────────────────────────────────┐
                    │           GPU(每帧)                    │
                    │   剔除遍历层级树                          │
                    │   选中簇 #A ──需要页面 P5 ──┐             │
                    └────────────────────────────┼─────────────┘
                                                 ▼
                     ┌───────────────────────────────────────┐
                     │  FGPUStreamingRequest(GPU 写)        │
                     └───────────────┬───────────────────────┘
                                     ▼
  磁盘/DDC ──异步读取──▶ FStreamingManager(CPU)──安装──▶ GPU 大缓冲
  (StreamablePages)      ·优先级排序 ·LRU 淘汰   ·FFixupChunk 修正引用
                          ·每帧带宽预算        ·scatter 上传

关键点:流送是”透明”的——模型永远有东西可画(先画粗的根页面),更细的页面到了就自动替换。这就是”虚拟几何体”名字的由来:数据不是全部存在于显存,而是按需虚拟化。像素级细节在页面到位前可能”糊”一两帧,但永远不会出现”模型消失”或卡顿。

它在这条流水线里的位置:横跨数据层(第 7 章的数据格式)与渲染层(第 10 章 GPU 剔除时的请求产生),完整机制在第 8 章。

2.5 两遍遮挡剔除 = 先画糊的再画清楚的

定义:遮挡剔除就是”被挡住的东西不画”。Nanite 用层级深度缓冲(HZB, Hierarchical Z-Buffer)做遮挡测试——把当前画面的深度信息做成一张金字塔(逐级变粗的深度图),测试一个簇的包围球时,从粗层级开始查:如果包围球在粗层级就完全被挡住,就不用再查细层级了。

两遍(Two-Pass)的原因:要遮挡测试,得先有深度图;但 Nanite 的几何体本身就是深度图的来源——先有鸡还是先有蛋?答案:用上一帧的深度图测这一帧的几何

  • Main Pass(主遍):用上一帧的 HZB 剔除所有实例和簇,画出可见的;
  • Post Pass(后遍):上帧被挡住、本帧可能露出来的几何(比如你在墙后面走动,墙壁本帧才移开),再用本帧刚生成的深度图补测一遍,画上”刚刚露头”的。
图 F6 "先画糊的再画清楚的"——两遍遮挡剔除的帧间关系(第 10 章细讲)
帧 N-1 画完 ──▶ 得到帧 N-1 的深度 ──▶ 构建 HZB(N-1)(金字塔)
                                         │
帧 N:                                  ▼
  Main Pass:用 HZB(N-1) 测所有簇 → 画"肯定可见"的(绝大多数)
  Post Pass :用本帧 HZB(N) 测"上帧被挡、本帧可能露出"的 → 画"刚露头"的
                                         │
                                         ▼
帧 N 画完 ──▶ 得到帧 N 的深度 ──▶ 构建 HZB(N) ──▶ 给帧 N+1 用

为什么用上一帧的深度也不会错? 会错,但错得很”便宜”:上一帧的深度可能漏掉”本帧新出现的遮挡物”,导致本帧多画了几个被挡住的簇(浪费一点性能),但绝不会少画(正确性不受影响)。多画是”保守”的浪费,少画才是 bug。所以两遍剔除是保守且正确的。

它在这条流水线里的位置:第 10 章——GPU 剔除的第一道大闸,配合视锥剔除把 99% 的簇挡在光栅化之前。

2.6 五个概念串起来:一帧的旅程

把 2.1~2.5 串成一个完整的”一帧”故事:

  1. 开始:CPU 只做粗粒度预判(哪些材质可能可见),其余全交给 GPU;
  2. 剔除:GPU 用上一帧的 HZB + 视锥剔除所有实例(2.5),再逐层遍历 LOD 树选切面(2.2),遇到未加载的页面 → 发流送请求(2.4);
  3. 光栅化:选中的簇(2.1)被画进 VisBuffer——只写 64 位身份 ID(2.3);
  4. 深度导出:VisBuffer 的深度合并成场景深度,同时更新 HZB 供下一帧用;
  5. 着色:按像素身份 ID 查材质、算颜色,写进 GBuffer(2.3)。

2.7 概念 → 源码落点预告表

概念 构建期源码 运行时源码
Cluster(128 三角形) NaniteBuilder/Private/Cluster.h(FCluster,static const uint32 ClusterSize = 128 Engine/Public/Rendering/NaniteResources.h:108(FPackedCluster)
层级 LOD(DAG) NaniteBuilder/Private/ClusterDAG.h(FClusterDAG/FClusterGroup) NaniteResources.h:59(FPackedHierarchyNode)
可见性缓冲 Engine/Shaders/Private/Nanite/NaniteRasterizer.ush(PlotPixel)
页面流送 NaniteBuilder/Private/Encode/NaniteEncodePageAssignment.cpp Engine/Public/Rendering/NaniteStreamingManager.h(FStreamingManager)
两遍遮挡剔除 Engine/Shaders/Private/Nanite/NaniteCullingCommon.ush(FBoxCull::HZB)

第 3 章 设计哲学与取舍:为什么是三角形

TL;DR|Nanite 的第一性问题是”用什么表示虚拟几何体”,Epic 比较了体素、细分曲面、置换贴图、
点云四种方案后全部否决,选了最”无聊”也最正确的答案:三角形 + 离线简化
然后立下三条铁律保证质量:每簇 128 三角形、锁定共享边界防裂缝、同组强制一致 LOD 决策。
代价是明显的:只支持不透明/遮罩材质、不能蒙皮、需要 SM6 平台——有取舍才叫架构

3.1 目标列表:先想清楚要什么

SIGGRAPH 2021 论文开篇就给了 Nanite 的目标清单(这是理解一切设计决策的钥匙):

  1. 自动 LOD:任何距离都无损、无人工干预;
  2. 常数时间渲染:渲染成本只随屏幕分辨率变化,不随场景三角形总数变化
  3. GPU 驱动:剔除与 LOD 选择在 GPU 上完成,CPU 几乎零参与;
  4. 压缩存储:比传统资产格式更小(目标 10~20 字节/三角形);
  5. 保留创作自由:艺术家直接导入 ZBrush 百万面雕刻、摄影测量扫描,不用优化;
  6. 虚拟化:像虚拟纹理一样按需流送,不受显存限制。

任何候选方案都要同时满足这六条——这正是 3.2 节”全军覆没”的原因。

3.2 被拒绝的方案:体素、细分曲面、置换、点云

图 F7 候选方案决策树(Epic 在论文中的取舍,本文按源码与论文整理)
                    目标:自动LOD / 常数时间 / GPU驱动 / 压缩 / 创作自由 / 虚拟化
                                    │
        ┌───────────────┬───────────┼───────────┬───────────────┐
        ▼               ▼           ▼           ▼               ▼
    体素(Voxel)     细分曲面    置换贴图      点云(PointCloud)  三角形(Triangle)
        │               │           │           │               │
   ✗ 数据量巨大     ✗ 只能细分     ✗ 不能增加   ✗ 表面重建       ✓ 六条全满足
     (x³ 增长)       放大,无法     拓扑复杂度  难、孔洞难补     (选它!)
   ✗ 与现有DCC     到达更低LOD     ✗ 美术负担   ✗ 过绘制严重
     工作流割裂     ✗ 基网格(cage)  大          ✗ 依赖表面法线
                    限制细节        ✗ 只能单一   估算
                    上限             表面位移
                    └─ 都因为"无法在保真的前提下按需降级"而被否

体素:把模型变成格子。问题一,数据量是体积增长(精度的三次方),一个大雕像的体素比三角形多几个数量级;问题二,体素化会破坏与 DCC 工具(Maya/ZBrush)的兼容性,美术的世界是曲面。不选。

细分曲面:贝塞尔/细分曲面只能”由粗到细”(细分放大),不能”由细到粗”(简化缩小)——也就是说它做不了 LOD 的低端部分,网格必须手工做基础网格(cage),且只能表示单一闭合曲面,拓扑限制大。不选。

置换贴图:把一个网格沿法线位移,需要高精度法线方向和基础网格;多个物体的表面不能互相交叉(不能增加拓扑复杂度,比如桥洞、拱门);美术要维护贴图与网格两套数据。不选。

点云:每个像素存一大段点的数据,无需三角形拓扑。致命伤是表面重建:点之间没有连接关系,光栅化时没法知道”这个像素属于哪块表面”;而且过绘制严重(点云没有遮挡关系可剔除)。不选。

结论:三角形虽然”无聊”,但它是唯一同时满足六条目标的表示。渲染三角形是 GPU 最擅长的事,简化三角形是学术界几十年的积累(QEM 等),压缩三角形有成熟的条带化技术——选三角形 = 站在 CPU/GPU 硬件与学术成果的肩上前进。

3.3 两条路线:运行时简化 vs 离线简化

论文里还有一个隐性抉择:LOD 树怎么生成?

  • 运行时实时简化:每帧动态抽稀网格(像 Nanite 的某些竞品)。好处是不占磁盘;坏处是质量不可控、CPU/GPU 每帧开销、误差无法精细度量。
  • 离线预简化:构建期把层级树完整算好存起来(Nanite 的做法)。好处是简化质量可控(可以花几小时算误差)、运行期零简化开销;代价是磁盘空间(但 3.4 的压缩把空间压回来了)。

Nanite 的哲学:能用离线算的绝不让运行期算。 这与”预烘焙光照 vs 实时光照”是同一种思维——把昂贵但可预见的计算挪到构建期。

3.4 三条铁律:128 三角形、锁定边界、分组一致

层级 LOD 最著名的难题是裂缝(Crack):相邻两个簇,一个选了细 LOD、一个选了粗 LOD,边界处的顶点不重合 → 屏幕上出现裂缝或 T 型衔接。Epic 用三条铁律解决:

铁律一:每簇 ≤ 128 三角形NANITE_MAX_CLUSTER_TRIANGLES = 128)。太大则剔除粒度粗、简化误差大;太小则元数据开销高。128 是硬件友好的量级(缓存、原子操作、波前/波束宽度)。

铁律二:简化时锁定共享边界。 构建期简化一个簇时,与邻居共享的边界顶点必须保留(不能合并掉),这样相邻簇无论选哪级 LOD,边界处的顶点集合始终是父簇的子集,衔接处完全重合 → 不可能裂缝。

铁律三:同组强制一致 LOD 决策。 只锁定边界还不够——边界顶点会像”碎布条”一样逐层累积(父簇的边界又成为祖父簇的边界……),导致简化率无法保持 2×。解决办法:把相邻的几个簇组成一个组(Group),组内所有簇共享同一个 LOD 误差值,要么一起细、要么一起粗(遍历时把它们当成一个整体测试)。组与组之间的边界每层交替位置,避免任何边界穿透所有层级。

类比实验室:团队同步打卡
铁律三像公司团建:每个小组(Group)规定”一起上班一起下班”(一致的 LOD 决策),
组内成员步调统一就不会出岔子;但组与组的边界人(边界顶点)每年换岗(交替边界),
防止同一个”熟人”永远卡在边界上传话传歪。第 5 章会看到它在源码里怎么实现
ConstrainClusters + 交替 Group 边界)。

3.5 代价与边界:Nanite 不是什么都能干

工程就是取舍,Nanite 用”质量”换了”自由”。它明确不支持

不支持 原因 怎么办
半透明材质(Translucent 需要排序,与 GPU 驱动管线冲突(本 fork 已实验性支持) 用不透明/遮罩材质,或走传统网格
蒙皮动画(骨骼顶点动画) 顶点每帧变化,LOD 树与流送失效(本 fork 已实验性支持) 骨骼网格走传统管线或 fallback
MSAA、前向渲染、VR 可见性缓冲方案与这些路径不兼容 用 TAA/TSR 代替 MSAA
非 SM6 平台(如 D3D11) 需要 mesh shader / compute 光栅等特性 自动回退到 fallback 网格(引擎会自动做)
深度/法线贴图置换 见 3.2 高模直接上,不需要置换
网格变形(世界位置偏移 WPO 除外) 同上 用 fallback 或传统管线

还有两个经常被误解的点:

  1. “Nanite 不需要显存”是错的。 只是显存占用随”屏幕需要”而不是随”资产总量”增长。Epic 在 PS5 演示场景(Valley of the Ancients)给出的数据:几何数据 26GB 原始 → 压缩后 4.61GB 磁盘 → 运行期约 7.6GB 显存/内存(论文数据,PS5 场景,不代表所有场景)。磁盘上平均 ~14.4 字节/输入三角形。这些数字的语境是演示场景,不是 API 承诺。
  2. “Nanite 是无损的”是错的。 位置量化(2^-21 厘米级别)和 1 像素误差阈值都是有损的,只是人眼在正常观感下察觉不到。

3.6 小结

这一章回答”为什么是三角形”:因为只有三角形能同时满足六条目标。三条铁律(128、锁边界、分组一致)保证了”自动 LOD 无裂缝”。代价清单(材质、蒙皮、平台)决定了 Nanite 的适用范围。

下一章进入构建篇:看高模如何在 FBuilderModule::BuildInternal 里走完”切分 → 简化 → 编码 → 打包”的完整旅程。

第 4 章 构建期总览:离线到底干了什么

TL;DR|Nanite 把”重活”全部留在离线:导入高模 → NaniteBuilder 切成簇、建成 LOD 树、
编码压缩、打包成页 → 产出 FResources 资产(根页面常驻 + 可流送页面 + 层级数组)。
运行时只做轻活:剔除、选 LOD、按需流送。类比:构建期是出版社排版印刷(一次性重投入),
运行时是书店按需进货(轻量高频)
。本章先看整条流水线的形状,细节在第 5、6 章。

4.1 触发链:谁在什么时候调用 NaniteBuilder

在编辑器里勾选静态网格的 Nanite 开关FMeshNaniteSettings.bEnabled)并保存资产后,构建就开始了。调用链是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
保存资产 / 重新构建


MeshBuilder 模块(StaticMeshBuilder / SkeletalMeshBuilder)
│ 调用 IBuilderModule 接口

NaniteBuilder 模块(FBuilderModule::Build → BuildInternal)

├─▶ BuildIntermediateResources:切簇 + 建 DAG(第 5 章)
├─▶ BuildFallbackMeshFromIntermediate:生成兜底网格(传统管线用)
└─▶ Encode:编码 + 分页 + 层级 + 修复块(第 6 章)


FResources 序列化进资产 bulk 数据 / DDC(缓存)

源码落点:FBuilderModule::BuildInternal 是唯一入口,定义在 NaniteBuilder.cpp:936Engine/Source/Developer/NaniteBuilder/Private/NaniteBuilder.cpp),模块接口 IBuilderModule(含 Build()BuildAssemblyPart()BuildFallbackMesh())在 NaniteBuilder.h。调用方是网格构建器 StaticMeshBuilder.cpp / SkeletalMeshBuilder.cpp

4.2 构建期与运行时的分工哲学

构建期(离线) 运行时(每帧/每资产)
干的事 简化、切簇、编码、压缩、分页、依赖分析 剔除、LOD 选择、流送、光栅化、着色
时间预算 几分钟~几小时(可容忍) 几毫秒(每帧硬预算)
计算位置 多线程 CPU(编辑器/烘焙机) GPU + 少量 CPU 线程
做错代价 重新构建 掉帧 / 花屏

关键推论:因为离线不差钱,Nanite 可以做”逐三角形误差度量、最优分页、精细压缩”这种贵算法;运行期只消费结果。论文原话精神:*”We can afford to compute things offline that we could never compute at runtime.”*(离线能算的,绝不留给运行时。)

4.3 输入与输出

输入

  1. 高模三角形网格——顶点、索引、材质索引、UV、法线/切线,以及(本 fork 的)曲线(Curve)输入;
  2. **FMeshNaniteSettings**——每资产配置:bEnabled(总开关)、PositionPrecision(位置量化精度)、bExplicitTangentsbLerpUVsKeepPercentTriangles(保留三角形比例)、TargetMinimumResidencyInKB(常驻内存预算)等,定义在 EngineTypes.h:3281

输出:一个 FResources 对象(旧名 FNaniteResource,5.7+ 改名),序列化进资产,包含:

成员 内容 何时被使用
RootData 根页面(最粗 LOD)数据 资产加载时直接进显存,永不卸载
StreamablePages 可流送页面(bulk 数据) 按需加载(第 8 章)
HierarchyNodes 扁平化的 LOD 树节点数组 GPU 剔除遍历(第 10 章)
PageStreamingStates 每页的磁盘偏移/大小/依赖 流送管理器
PageDependencies / PageRangeLookup 页面依赖与索引 流送 + fixup

4.4 构建与烘焙:何时触发、是否增量

  • 何时触发:资产保存时(编辑器内);或命令行烘焙(cook)时。构建结果会进 DDC(Derived Data Cache),相同的输入+设置不会重复构建;
  • 增量:修改几何或 Nanite 设置都会使缓存失效并触发重建;
  • Fallback 网格BuildInternal同时生成一个”兜底网格”(OutFallbackMeshData),给不支持 Nanite 的平台/管线用(详见 13 章)。所以一个启用了 Nanite 的静态网格资产,内部其实有两份几何数据:Nanite 数据 + Fallback 网格。

4.5 源码考古:BuildInternal 主流程

源码考古:想看真东西的人进。

主入口 FBuilderModule::BuildInternalNaniteBuilder.cpp:936。它干了三件事:① 构建中间资源(切簇+建 DAG);② 生成 fallback 网格;③ 调 Encode 生成最终资源。下面这段是骨架(已精简),完整版在源码里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
bool FBuilderModule::BuildInternal(
FResources* Resources,
IBuilderModule::FInputMeshData& InputMeshData,
IBuilderModule::FOutputMeshData* OutFallbackMeshData,
...
const FMeshNaniteSettings& Settings,
FInputAssemblyData* InputAssemblyData)
{
const bool bIsAssembly = InputAssemblyData != nullptr && InputAssemblyData->IsValid();
// 判断 fallback 是否需要"减面"(若 Nanite 主网格本身会减面,fallback 也得减)
const bool bFallbackIsReduced = bIsAssembly ||
Settings.FallbackPercentTriangles < 1.0f || ...;

// ① 构建中间资源:切簇 + 建 DAG(第 5 章的主角)
FIntermediateResources Intermediate;
if (bNeedIntermediate && !BuildIntermediateResources(Intermediate, InputMeshData, InputAssemblyData, Settings, bCanFreeInputMeshData, false))
{
return false;
}

// ② 生成 fallback 网格(给不支持 Nanite 的平台)
if (OutFallbackMeshData != nullptr)
{
BuildFallbackMeshFromIntermediate(Intermediate, InputMeshData, InputAssemblyData, Settings, bFallbackIsReduced, *OutFallbackMeshData);
}
...
// ③ 编码:FClusterDAG → FResources(第 6 章的主角)
Resources->NumInputTriangles = Intermediate.NumInputTriangles;
Resources->NumInputCurves = Intermediate.NumInputCurves;
Resources->NumInputVertices = Intermediate.NumInputVertices;
Resources->ResourceFlags = Intermediate.ResourceFlags;
...
const bool bSuccess = Encode(*Resources, Intermediate.ClusterDAG, Settings, NumMeshes, &TotalGPUSize);
if (!bSuccess) { ... return false; }
...
}

这段代码在干什么:一句话——**”切好簇(中间资源)→ 顺手造一份兜底网格 → 把簇编码成 GPU 格式”**。
注意第 ① 步与第 ③ 步的分离:Intermediate(FClusterDAG)是”构建期内存格式”,FResources
“运行期 GPU 格式”,两者中间隔着 Encode。这个两段式结构让构建可以缓存中间结果,也把
“算法”(切簇/简化)与”格式”(编码/压缩)解耦——改压缩格式不用重写简化算法,反之亦然。

FMeshNaniteSettings 关键字段EngineTypes.h:3281,注释翻译):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/** If true, Nanite data will be generated. */
uint8 bEnabled : 1; // 总开关:勾了才生成 Nanite 数据

/** Whether to interpolate UVs when simplifying.
* 简化时是否允许 UV 插值。建议开启——这样才能在简化新顶点时算出最优 UV。
* 若 UV 里存的是索引等"不可插值"的数据,则必须关闭。 */
uint8 bLerpUVs : 1;

/** Position Precision. Step size is 2^(-PositionPrecision) cm. MIN_int32 is auto. */
int32 PositionPrecision = MIN_int32; // 位置量化精度:2^-n 厘米,-1=自动
/** Normal Precision in bits. -1 is auto. */
int32 NormalPrecision = -1; // 法线量化位数,-1=自动

/** How much of the resource should always be resident (In KB).
* 0: Minimum size (single page). MAX_uint32: Entire mesh. */
uint32 TargetMinimumResidencyInKB = 0; // 常驻内存预算:0=只驻留根页,最大=全驻留

这段代码在干什么:这些字段决定了”构建质量与存储的权衡旋钮”——精度越高越占空间。PositionPrecision 的步长是 2^(-n) 厘米:默认自动值会把模型 1 厘米的精度量化到 2^-21 厘米(约 0.5 微米),远超人类观察极限,但省下来的位数很可观(见 6.2)。


第 5 章 简化与切分:把高模切成乐高

TL;DR|构建的第一大步在 FClusterDAG 里完成:AddMesh 把输入网格按邻接图切分成
≤128 三角形的簇;ReduceMesh/ReduceGroup 把相邻簇合并简化成父簇,逐层递归到根,生成整棵
LOD 树;SimplifyTriangles 用误差驱动的边折叠把簇减半;Split + GraphPartitioner
(METIS 图分割)保证每簇不超过 128 三角形。防裂缝三件套(锁边界、组一致、交替边界)
在这里落地。类比:简化=写摘要,切分=分乐高块,组=打包带

5.1 从 AddMesh 到 ReduceGroup:LOD 树的生长

构建期 DAG 的生成分两步(FClusterDAGClusterDAG.h:65):

1
2
3
4
5
6
7
8
9
10
11
AddMesh(顶点, 索引, 材质索引, 包围盒)     // 输入一个网格

├─ 1. 初始切分:按三角形邻接图 + 图划分 → 一批 ≤128 三角形的叶簇(LOD 0,原始精度)

└─ ReduceMesh(MeshIndex) // 逐层生成父级

└─ ReduceGroup(递归):
├─ 把空间相邻的簇组成组(Group),每组合并成一个父簇
├─ 父簇 = 组内子簇简化到 ~128 三角形(一半面积一半精度)
├─ 父簇之间再组组 → 再简化 → ……直到只剩一个根组(bRoot)
└─ 每层记录:包围球(LODBounds)、相对父级误差(ParentLODError)

关键设计:组是 DAG 的中间节点,簇是树叶FClusterGroupClusterDAG.h:33)持有:包围球、ParentLODError(对这个组里所有簇有效的 LOD 误差——这就是”组内一致 LOD 决策”的数据基础)、MipLevel、子簇引用列表。FindCut 则是在构建期按目标三角形数/误差找切面用的工具(运行期的”切面”由 GPU 每帧找,构建期的 FindCut 用于 fallback 网格生成和预算控制)。

类比实验室:写摘要
ReduceGroup 像给一篇长文写摘要:先分段落(簇),每段压成一句话(父簇),各段的话再压成
一段话(祖父簇)……每一层都是上一层的摘要,但共享的段落首句永远保留(边界锁定),
所以”摘要层”与”原文层”拼接起来不会出现内容断裂。

5.2 误差驱动的简化:SimplifyTriangles

父簇不是简单抽稀,而是误差驱动的边折叠简化。FCluster::SimplifyTrianglesCluster.cpp:955):

  1. 计算所有三角形的 UV 面积(UV 塌缩会导致贴图拉伸,误差里要算上);
  2. 对所有可折叠边按折叠代价排序(代价 = 折叠后几何误差 + 属性误差,含 UV/法线);
  3. 从最小代价开始折叠,直到达到目标三角形数(TargetNumTris)或目标误差(TargetError)。

折叠一个边 = 把两个顶点合并成一个,三角形数 -2。误差的意义:折叠后表面偏离原表面的最大距离。这个误差值最终被写入簇的 LODError / 组的 ParentLODError,GPU 遍历时拿它做”误差 < 1 像素”判断(第 10 章)。

图 F9 边折叠简化示意:折叠(合并)两条相邻边的端点,三角形减少,误差 ε 被记录下来
       原始网格(8 三角形)          折叠边 A─B(合并 B→A)          折叠边 C─D(合并 D→C)
       ┌───────┐                   ┌───────┐                      ┌───────┐
       │ A\   /│                   │ A\   /│                      │ A\   /│
       │   \ / │                   │   \ / │                      │   \ / │
       │ B─C─D │   ──折叠 B─C──▶   │ A─C─D │   ──折叠 C─D──▶      │ A─C═══│
       │   / \ │                   │   / \ │                      │   /   │
       │ E/   \F│                  │ E/   \F│                     │ E/    │
       └───────┘                   └───────┘                      └───────┘
        8 三角形                     6 三角形                        4 三角形
        误差 ε = 0                   ε = 折叠后与原始的最大距离        ε 累积
                                   (记录到 LODError,运行时用它判 LOD)

与普通网格简化(QEM)的差异:QEM 追求”全局最小误差”,Nanite 的简化在簇内部进行,并且额外满足两个约束——① 边界顶点不能折叠(铁律二);② 每簇结果 ≤128 三角形(铁律一)。此外 UV 误差、法线误差都会被计入折叠代价,防止”几何没变但贴图拉伸”的伪影。

5.3 切分:Split + GraphPartitioner

如果一个簇超过 128 三角形(比如简化后边界顶点多、或输入本身就是超密区域),就要切分FCluster::SplitCluster.cpp:1154)分三步:

  1. 并查集(DisjointSet):把共享边的三角形归到同一连通分量(物理上连着的三角形必须同簇,防止切出碎片);
  2. 建图:三角形作为图的节点,相邻三角形之间连边,边权 = 共享边数 + 材质相似度(BuildLocalityLinks,材质不同的三角形尽量不切到一起——避免一个簇里塞进太多材质区间);
  3. 递归二分FGraphPartitioner(封装 METIS 图分割,GraphPartitioner.h:12)把图递归切成两半,直到每块 ≤128 三角形。

类比实验室:分乐高块
乐高积木拼接有”咬合方向”(邻接关系)。切分要保证:① 咬合在一起的块不能拆散到不同袋子
(连通分量约束);② 每袋不超过 128 粒(数量约束);③ 颜色(材质)尽量一致的放一袋
(材质约束)。切出来的”袋子”就是 Cluster,袋子上写清楚”里面是什么”(包围球+误差+材质区间)。

5.4 防裂缝三件套:在源码里的落点

第 3 章的三条铁律,在构建期对应的实现:

铁律 实现位置 说明
128 三角形 FCluster::ClusterSize = 128Cluster.h:270)+ Split 构建与编码两侧都用 NANITE_MAX_CLUSTER_TRIANGLES 校验
锁定共享边界 SimplifyTriangles 边界顶点禁折叠 + ConstrainClustersNaniteEncode.cpp 中调用,第 6 章展开) 简化时 ExternalEdges 上的顶点标记为不可折叠
组内一致 LOD FClusterGroup::ParentLODErrorClusterDAG.h:36 GPU 遍历时按”组”测试,组内共享同一误差阈值

交替边界是论文里描述的机制(同一组边界不穿透所有层级),在构建期表现为:每层 GroupTriangleClusters 对相邻簇的分组方式不同——上一层的组边界不会在下一层对齐,从而避免”永久边界”累积过密顶点。

5.5 数据层级:Cluster → Group → DAG

图 F10 构建期的数据层级(FClusterDAG 的内存结构)——左:逻辑树;右:两个叶子组共享一个父簇(DAG 而非纯树)
        FClusterDAG(ClusterDAG.h:65)
        ├─ Clusters[]  ── 全部簇(叶节点):顶点/索引/材质区间/误差/包围球
        ├─ Groups[]    ── 全部组(中间节点):包围球/共享误差/子簇引用
        └─ MeshInput[] ── 每个输入网格的根簇引用列表

    逻辑结构(左侧是树,右侧出现共享 → 变成 DAG):
            [根组 R]                       [根组 R]
           /   |   \                      /   |   \
       [G1]   [G2] [G3]              [G1]   [G2] ──┐   ← 共享
       / \    / \   / \              / \    / \    |     子簇 C7
    [C1 C2][C3 C4][C5 C6]        [C1 C2][C3 C4][C5 C6 C7] ← C7 被两个组引用
       │          │                   │          │
    (叶簇=原始)  (叶簇=原始)        (叶簇=原始)  (叶簇=原始)

    注意:C7 被 G2、G3 同时引用 → 不再是树,是 DAG。
    GPU 遍历时需要去重(每个簇只处理一次)。

为什么允许 DAG 而非严格树? 省内存、省遍历:大平面场景里,相邻区域共享同一个”中间简化簇”是常见情况。允许共享后,同一精度级别的簇数量大幅减少。代价是遍历时每个簇可能被从两条路径到达——所以 GPU 剔除时用”已访问标志”去重(第 10 章会看到)。

5.6 源码考古:FCluster 与 FClusterGroup

源码考古:想看真东西的人进。

① 簇的数据结构 FClusterCluster.h:213(截取核心成员):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class FCluster
{
public:
static const uint32 ClusterSize = 128; // 每簇最多 128 三角形

uint32 NumTris = 0;
FVertexArray Verts; // 顶点数据(含 NumVerts)
TArray< uint32 > Indexes; // 三角形索引
TArray< int32 > MaterialIndexes; // 每三角形的材质索引
TArray< uint8 > ExternalEdges; // 与邻簇共享的边界顶点标记(锁边界)
uint32 NumExternalEdges = 0;

FBounds3f Bounds; // 几何包围盒
FSphere3f SphereBounds; // 包围球(视锥/遮挡剔除用)
FSphere3f LODBounds; // LOD 选择用的包围球
float LODError = 0.0f; // 本级简化误差
float ParentLODError = 0.f; // 父级简化误差(运行期 LOD 阈值用)
uint32 GroupIndex = MAX_uint32; // 所属组
uint32 PageIndex = MAX_uint32; // 所属页面(分页后填写)

TArray<FMaterialRange, TInlineAllocator<4>> MaterialRanges; // 材质区间(见下)
FStripDesc StripDesc; // 三角形条带描述(压缩用)
TArray<uint8> StripIndexData; // 条带化索引数据
};

这段代码在干什么:一个簇 = “顶点 + 索引 + 材质 + 误差 + 包围体 + 条带压缩”。注意 ExternalEdges
共享边顶点被标记,简化器看到它就不折叠——这就是铁律二的实现。LODBoundsParentLODError
是运行期 GPU 判 LOD 的全部依据(第 10 章)。

② 材质区间 FMaterialRangeCluster.h:193

1
2
3
4
5
6
7
struct FMaterialRange
{
uint32 RangeStart; // 区间起始三角形
uint32 RangeLength; // 区间长度(三角形数)
uint32 MaterialIndex; // 材质索引
TArray<uint8, TInlineAllocator<12>> BatchTriCounts; // 分批渲染的三角形数
};

这段代码在干什么:一个簇内可能混着多种材质(比如一个雕像 = 石料 + 金饰)。MaterialRanges
用”连续区间”描述”哪些三角形用哪个材质”——类似文件系统里的 extent(区段)。光栅化时按区间
分批,材质变化不用拆簇(第 11 章 Binning 会用到)。

③ 组与 DAG FClusterGroupClusterDAG.h:33

1
2
3
4
5
6
7
8
9
10
11
12
13
struct FClusterGroup
{
FSphere3f Bounds; // 组包围球
FSphere3f LODBounds; // LOD 选择包围球
float ParentLODError = 0.0f; // ← 组内共享的 LOD 误差(铁律三)
int32 MipLevel = 0; // 层级
uint32 MeshIndex = MAX_uint32; // 所属网格
uint32 AssemblyPartIndex = MAX_uint32;
bool bTrimmed = false;
bool bRoot = false; // 是否为根组
FPageRangeKey PageRangeKey; // 页面范围(流送用,第 6 章)
TArray< FClusterRef > Children; // 子簇引用
};

这段代码在干什么:组是”打包带”——把若干子簇绑成一捆,捆上写一个共享误差(ParentLODError)。
运行期 GPU 测”这捆够不够细”只需要测一次;捆内的簇共享命运(要么一起细要么一起粗),
因此组内边界永远不会裂缝PageRangeKey 是组与页面之间的桥梁:一个组的所有子簇如果
都落在同一页面范围,流送时整组一起请求(第 8 章)。

第 6 章 编码与打包:把乐高装箱

TL;DREncode() 是构建期最后一步:把构建期的 FClusterDAG(内存格式)翻译成
GPU 直接消费的 FResources(磁盘格式)。它做六件大事:清理顶点 → 按材质分段 → 约束检查 →
量化(精度换体积)→ 分页装箱 → 建层级数组 + 算依赖修复
。最终磁盘上每个输入三角形约
14.4 字节(论文的 PS5 演示数据),而普通高模格式要 100+ 字节。类比:装箱物流——货物是簇、
集装箱是页、装箱单是页头、装错位置后的修正单是 fixup 块

6.1 Encode() 流水线逐阶段拆解

EncodeNaniteEncode.cpp:1444)按顺序执行下列阶段(阶段名就是源码里 TRACE_CPUPROFILER_EVENT_SCOPE 的名字,可以和源码逐一对上):

图 F11 Encode() 内部流水线(与源码阶段名一一对应)
 输入:FClusterDAG + FMeshNaniteSettings
   │
   ├─ ① EncodeAssemblyData       装配数据编码(骨骼网格多零件装配,本 fork 特性)
   ├─ ② SanitizeVertexData       顶点数据清洗(NaN/非法值清理)
   ├─ ③ BuildCurveHierarchyData  曲线层级数据(本 fork 特性)
   ├─ ④ RemoveDegenerateTriangles 剔除退化三角形(面积为 0 的)
   ├─ ⑤ BuildMaterialRanges      每个簇按材质分段(FMaterialRange)
   ├─ ⑥ ConstrainClusters        约束检查:边界顶点/材质/128 上限(铁律落地的最后防线)
   ├─ ⑦ VerifyClusterConstraints DO_CHECK 下复核约束
   ├─ ⑧ BuildVertReuseBatches    跨页顶点复用批次(顶点在页间共享,省存储)
   ├─ ⑨ 量化                    位置(2^-n 厘米网格)+ 法线/切线/骨权(指定位数)
   ├─ ⑩ CalculateEncodingInfos   每个簇的编码元信息(位布局偏移等)
   ├─ ⑪ AssignClustersToPages    分页:簇 → 页(CalculateMaxRootPages 定根页数)
   ├─ ⑫ CalculateMeshBounds      网格总包围盒
   ├─ ⑬ BuildHierarchies         DAG → 扁平 FPackedHierarchyNode 数组
   ├─ ⑭ CalculatePageDependenciesAndFixups  页面依赖 + fixup 修复块
   ├─ ⑮ CalculateFinalPageHierarchyDepth    最终层级深度
   └─ ⑯ WritePages               写出根页数据 + 流送页数据(压缩)
   │
   ▼
 输出:FResources(RootData / StreamablePages / HierarchyNodes / …)

读法:①⑩ 是”数据准备”(让簇可以被压缩、被 GPU 解码),⑪⑯ 是”打包发货”(装页、建索引、算依赖、写盘)。

6.2 量化与位打包:丢了精度却看不见

量化(Quantization)是 Nanite 压缩的第一招:把浮点数据换成”够用就行的定点数据”。

  • 位置:每个簇有自己的量化网格。把簇包围盒的 XYZ 各切成 2^Bits 格,顶点坐标用格坐标表示,精度 = 包围盒边长 / 2^Bits。FMeshNaniteSettings::PositionPrecision 控制全局步长(2^(-n) 厘米),NaniteEncode.cppCalculateQuantizedPositionsUniformGrid 负责计算每个簇自己的最优位数。最大 21 位/轴(NANITE_MAX_POSITION_QUANTIZATION_BITS = 21NaniteDefinitions.h:162),21×3 = 63 位正好塞进 64 位整数——精心设计的位对齐
  • 法线:球面量化到八面体(octahedral)编码,15 位(NANITE_MAX_NORMAL_QUANTIZATION_BITS = 15)。
  • 切线:12 位 + 符号位。
  • UV:自定义浮点(1 符号 + 5 指数 + 14 尾数 = 20 位/分量),比 32 位 float 省 40%,精度在贴图采样尺度下无感。
  • 骨权/骨索引:16 位/项。
图 A1 位置量化位布局(每轴 21 位,三轴打包进 64 位整数)
 64 位 uint64(每簇自己的局部坐标网格)
 ┌──────────────────────┬──────────────────────┬──────────────────────┬────────┐
 │ X:21 位             │ Y:21 位             │ Z:21 位             │ 空 1 位 │
 └──────────────────────┴──────────────────────┴──────────────────────┴────────┘
  分辨率 = 簇包围盒边长 / 2^21
  例:包围盒 4 米 → 每格 ≈ 2 微米 —— 肉眼完全不可见,省掉 3×32 位 float

为什么敢量化? 因为量化误差发生在”微米级”,而 LOD 选择阈值是”1 像素”(毫米级)。误差预算的层次:量化误差(微米)≪ 简化误差(毫米~厘米)≪ 屏幕可见阈值(1 像素)。量化省下的空间没有任何可见代价——这是编码设计的核心思路:把误差预算分配给出问题的环节,不给没有问题的环节。

6.3 分页、条带化与压缩:页里的门道

分页(AssignClustersToPages,NaniteEncodePageAssignment.cpp:121:把全部簇装进页。规则:

  • 根页面:包含最粗的 LOD 层级(保证”永远有东西可画”),数量由 CalculateMaxRootPages(按 TargetMinimumResidencyInKB 预算)决定,写进 FResources::RootData,随资产加载、永不卸载;
  • 流送页面:其余簇按”组(Group)尽量不跨页”的原则装箱——一个组的所有子簇尽量在同一页,这样流送时整组一起到货,不会出现”组到了但子簇缺页”的半吊子状态;
  • 页大小固定:根页 32KB(NANITE_ROOT_PAGE_GPU_SIZE = 1<<15)、流送页 64KB(NANITE_STREAMING_PAGE_GPU_SIZE = 1<<17),见 NaniteDefinitions.h:52-58

三角形条带化(Triangle Strip):页内簇的索引不存”每三角形 3 索引”,而是存成条带(下一个三角形复用上两个顶点),FStripDesc 描述条带的顶点复用模式。索引数据量因此大幅下降(这也是”14.4 字节/三角形”能达成的主力之一)。NANITE_USE_STRIP_INDICES = 1NaniteDefinitions.h:39)。

压缩:页面数据再经过 LZ 压缩(论文:PS5 演示用 LZ 系压缩,26GB 原始 → 4.61GB 磁盘;本 fork 用引擎默认压缩器)。注意:GPU 端解压是”转码(Transcode)”——运行时把压缩的页面数据解压成 GPU 可读格式(FStreamingPageUploader 干这活,第 8 章)。

顶点复用批次(VertReuseBatches):相邻页面共享边界顶点,为避免重复存储,构建期把”被多个页面引用的顶点”单独抽出成批次(BuildVertReuseBatches),页面安装时通过 fixup 块引用它们(见 6.4)。

6.4 依赖与修正:为什么需要 FFixupChunk

页面之间不是独立的:父簇页面的数据(索引、顶点偏移)会引用子簇页面里的顶点(边界顶点共享)。当一个页面安装/卸载时,引用它的页面的 GPU 数据里的”偏移量”可能失效——因为新页面里的簇/顶点在缓冲中的位置是运行期才确定的。

解决:构建期生成 FFixupChunk(修复块)CalculatePageDependenciesAndFixupsNaniteEncodeFixup.cpp:16,运行期结构在 NaniteFixupChunk.h):

  • 依赖表(PageDependencies):每个页面记录”我引用了哪些页面”(父页面);
  • 修复块:当页面 X 安装时,它携带一份”写给引用我的页面”的修正数据——告诉那些页面:我页里的簇/顶点现在在 GPU 缓冲的什么位置,请把你的引用改过来。

类比实验室:装错位置后的修正单
想象仓库(GPU 缓冲)里货架位置是运行期临时分配的:新到货(页面)不保证放在预期货架。
所以每个集装箱(页)里附带一叠”修正单”(FixupChunk):货一到,仓库管理员按修正单
把其他集装箱上的标签(引用)改成实际货架号。这就是为什么页面之间不能完全独立、
为什么流送系统必须知道依赖关系。

代价:页面的安装/卸载不是纯追加,需要改写其他页面的数据(ApplyFixups)。流送管理器在”每帧可安装页面数”上有预算(r.Nanite.Streaming.MaxPageInstallsPerFrame),就是给这个”改写成本”设的上限。

6.5 压缩效果:数字说话

论文(SIGGRAPH 2021,PS5 演示场景 Valley of the Ancients)给出的数据:

指标 数值 说明
磁盘几何数据 26GB 原始 → 4.61GB 压缩 LZ 系压缩
每输入三角形 ~14.4 字节 含量化+条带化+压缩
运行期内存 ~7.6GB 流送驻留 + 根页

对比传统做法:一个 150 万三角形 + 4 档 LOD 的静态网格资产约 149MB,而同等质量的 Nanite 资产(含 fallback)约 20MB——Nanite 连磁盘都更省(论文数据)。这些数字是演示场景语境,不是 API 承诺;你的场景取决于几何复杂度与设置。

6.6 源码考古:Encode() 与分页

源码考古:想看真东西的人进。

① Encode() 主流水线NaniteEncode.cpp:1444(节选,阶段名与图 F11 对应):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
bool Encode(FResources& Resources, FClusterDAG& ClusterDAG,
const FMeshNaniteSettings& Settings, uint32 NumMeshes, uint32* OutTotalGPUSize)
{
EncodeAssemblyData( ClusterDAG, Resources );
for (FCluster& Cluster : ClusterDAG.Clusters) { Cluster.Verts.Sanitize(); }
BuildCurveHierarchyData(ClusterDAG);
RemoveDegenerateTriangles( ClusterDAG.Clusters );
BuildMaterialRanges( ClusterDAG.Clusters );
ConstrainClusters( ClusterDAG.Groups, ClusterDAG.Clusters );
BuildVertReuseBatches(ClusterDAG.Clusters);

// 量化:位置用均匀网格(自动算每个簇的最优位数)
Resources.PositionPrecision = CalculateQuantizedPositionsUniformGrid( ClusterDAG.Clusters, Settings );
// 法线/切线/骨权精度:-1 = 自动
Resources.NormalPrecision = (Settings.NormalPrecision < 0) ? 8 : FMath::Clamp(Settings.NormalPrecision, 0, NANITE_MAX_NORMAL_QUANTIZATION_BITS);
Resources.TangentPrecision = (Settings.TangentPrecision < 0) ? 7 : FMath::Clamp(Settings.TangentPrecision, 0, NANITE_MAX_TANGENT_QUANTIZATION_BITS);
...
CalculateEncodingInfos(EncodingInfos, ClusterDAG.Clusters, ...);

// 分页:先算根页上限,再装箱
const uint32 MaxRootPages = CalculateMaxRootPages(Settings.TargetMinimumResidencyInKB);
if (!AssignClustersToPages(ClusterDAG, Resources.PageRangeLookup, EncodingInfos, Pages, GroupParts, MaxRootPages))
{ return false; }
Resources.NumRootPages = FMath::Min((uint32)Pages.Num(), MaxRootPages);

CalculateMeshBounds(ClusterDAG, Pages, GroupParts, Resources.MeshBounds);
BuildHierarchies(Resources, ClusterDAG, Pages, GroupParts, EncodingInfos, NumMeshes); // DAG → 扁平数组
CalculatePageDependenciesAndFixups(Resources, PageFixups, Pages, ClusterDAG, GroupParts); // 依赖 + 修复块
CalculateFinalPageHierarchyDepth(Resources, Pages);
WritePages(Resources, Pages, ClusterDAG, GroupParts, EncodingInfos, PageFixups, ClusterDAG.bHasSkinning, OutTotalGPUSize);
return true;
}

这段代码在干什么:一屏看完全部编码阶段——先”洗数据”(Sanitize/去退化),再”贴标签”
(材质区间),再”上约束”(ConstrainClusters),再”压缩精度”(量化),最后”装箱发货”
(分页 → 层级 → 依赖 → 写盘)。注意每个阶段都是一个独立函数——单函数单职责
改量化算法不会碰分页代码,这就是架构上的解耦。

② 分页入口NaniteEncodePageAssignment.cpp:121

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool AssignClustersToPages(FClusterDAG& ClusterDAG, TArray<FPageRangeKey>& PageRangeLookup,
const TArray<FEncodingInfo>& EncodingInfos, TArray<FPage>& Pages,
TArray<FClusterGroupPart>& Parts, const uint32 MaxRootPages)
{
...
const uint32 NumClusterGroups = ClusterGroups.Num();
Pages.AddDefaulted(); // 页 0 先占位
SortGroupClusters(ClusterDAG); // 组排序(空间连续)
TArray<uint32> ClusterGroupPermutation = CalculateClusterGroupPermutation(ClusterGroups);

for (uint32 i = 0; i < NumClusterGroups; i++)
{
uint32 GroupIndex = ClusterGroupPermutation[i];
FClusterGroup& Group = ClusterGroups[GroupIndex];
if (Group.bTrimmed) continue; // 被裁剪的组跳过
...
for (FClusterRef Child : Group.Children) { ... } // 组内簇尽量装进同一页
}
}

这段代码在干什么:分页的”贪心装箱”——按组为单位遍历,把组的子簇尽量塞进同一页。
这样流送时整组到货,GPU 遍历时组里的簇不会因为”缺页”而被迫用父簇。

③ 依赖与修复块NaniteEncodeFixup.cpp:16

1
2
3
4
5
6
7
8
9
void CalculatePageDependenciesAndFixups(FResources& Resources,
TArray<FPageFixups>& PageFixups, const TArray<FPage>& Pages,
const FClusterDAG& ClusterDAG, const TArray<FClusterGroupPart>& Parts)
{
...
// GroupToParts / GroupToParentParts:组 ↔ 页 / 组 ↔ 父页 的映射
// PageDependencies:每页记录了它依赖的页面列表
// PartToPartFixup:每页安装时要改写的"引用修正"列表
}

这段代码在干什么:构建期把”页面间引用关系”完整算好,存成运行期能直接消费的表。
页面安装/卸载时的修正逻辑(ApplyFixups)就是在读这张表——把昂贵的依赖分析放到离线,
运行期只做查表
,再一次印证第 4 章的分工哲学。

第 7 章 运行时数据结构:GPU 眼中的世界

TL;DR|构建期产出的 FResources 是”GPU 眼中的世界”:根页面数据(常驻)、可流送页面(磁盘)、
扁平化层级节点数组、页面元信息。其中两个结构是核心中的核心:**FPackedCluster(128 字节的簇)——
用位打包塞进光栅化/剔除/材质所需的全部字段;
FPackedHierarchyNode(层级节点)——每 4 个子节点
一组打包,GPU 遍历时逐组读。另外,C++ 与 HLSL 共享同一个常量头 NaniteDefinitions.h
128 三角形、4 扇出这些数字两边一致,绝不漂移。读这一章的心态:
像读网络协议文档——先看布局再看字段位**。

7.1 FResources:一个 Nanite 资产的全部家当

FResourcesNaniteResources.h:455,旧名 FNaniteResource)分两部分:持久状态(构建期写入、序列化到磁盘)与运行时状态(加载后由引擎填写)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct FResources
{
// ---- 持久状态(构建期写入,序列化进资产) ----
TArray< uint8 > RootData; // 根页面数据:加载即驻留,永远有东西可画
FByteBulkData StreamablePages; // 其余页面:磁盘上,按需流送
TArray< FPackedHierarchyNode > HierarchyNodes; // 扁平化的层级树节点数组
TArray< uint32 > HierarchyRootOffsets; // 每个网格的树根偏移
TArray< FPageStreamingState > PageStreamingStates; // 每页的磁盘偏移/大小/依赖范围
TArray< uint16 > PageDependencies; // 页面依赖列表
TArray< FPageRangeKey > PageRangeLookup; // 页面范围字典(流送请求与 fixup 用)
uint32 NumRootPages = 0; // 根页面数量
uint32 NumInputTriangles = 0; // 输入三角形总数(统计用)
uint32 NumClusters = 0; // 簇总数
...
// ---- 运行时状态(GPU 池分配后填写) ----
uint32 RuntimeResourceID = MAX_uint32; // 在 GPU 资源池中的槽位
uint32 HierarchyOffset = MAX_uint32; // 层级数组中的偏移(Primitive 用它定位自己的树根)
uint32 NumResidentClusters = 0; // 当前驻留簇数(流送质量指标)
...
};

每个字段一句话RootData + NumRootPages = “永远画得出来的保底层”;StreamablePages = “按需进货的仓库”;HierarchyNodes + HierarchyRootOffsets = “GPU 每帧遍历的树”;PageStreamingStates/PageDependencies/PageRangeLookup = “流送系统的元数据”。

运行时状态怎么填? 资产被加载(首次渲染)时,渲染线程调用 InitResources(同文件),向 FStreamingManager 注册资源、在 GPU 池里分配槽位(RuntimeResourceID)并安装根页面。卸载时 ReleaseResources 反向操作。注意:GPU 池是全局共享的——所有 Nanite 网格的数据都装进 FStreamingManager 的两个大缓冲(7.5 节),FResources 本身只是”货单”。

7.2 FPackedCluster:128 字节装下整个世界

FPackedClusterNaniteResources.h:108)是 GPU 端簇的最终格式——固定大小(8 个 float4 = 128 字节),所以 GPU 可以按索引直接寻址(ClusterPageData[PageIndex][ClusterIndex])。所有字段都经位打包:

图 A2 FPackedCluster 位布局(uint32 字段内的位段划分)
dword0  NumVerts(14) | PositionOffset(18)
dword1  NumTris(8)   | IndexOffset(24)
dword2  ColorMin(顶点色范围,量化用)
dword3  ColorBits(16) | GroupIndex(16,仅调试用)
dword4~6  PosStart(量化位置原点,3×int32)
dword7  BitsPerIndex(4) | PosPrecision(5) | PosBitsX(5) | PosBitsY(5) | PosBitsZ(5) | NormalPrecision(4) | TangentPrecision(4)
        └────────────────── 位置量化信息:每轴 21 位能塞下,是因为轴位各 5 位编码 ──┘
dword8~11  LODBounds(包围球,剔除+LOD 用)
dword12~14 BoxBoundsCenter + LODErrorAndEdgeLength(简化误差与边长)
dword15~17 BoxBoundsExtent + Flags(4) | NumClusterBoneInfluences(5)
dword18  AttributeOffset(22) | BitsPerAttribute(10)        ← 顶点属性数据位置
dword19  DecodeInfoOffset(22) | HasTangents(1) | Skinning(1) | NumUVs(3) | ColorMode(2)
dword20  UVBitOffsets(4 个 UV 集的位偏移,各 8 位)
dword21  PackedMaterialInfo(材质信息)
dword22  ExtendedDataOffset(22) | Num(10)                  ← 扩展数据(曲线等)
dword23  BrickDataOffset(22) | Num(10)                     ← Voxel 砖块数据
dword24  NumCurves(8) | NumPointsPerCurve(8) | CurveRadiusScale(10) | CRBits(4) | HasCR(1)
dword25  ParentLODError(16) | ParentSphereBoundRadius(16)  ← 父级误差(LOD 阈值)
dword26~29 VertReuseBatchInfo[4](跨页顶点复用批次)
(图 A2 为概念示意,精确字段以 NaniteResources.h:108 为准)

读法提示(普通程序员视角):

  • 光栅化字段NumVerts/PositionOffset/NumTris/IndexOffset/BitsPerIndex —— GPU 光栅化时先定位”这个簇的顶点数据在我这个页的什么偏移”,再按量化信息解码位置(GetPosPrecision() 返回全局精度基准,GetPosBitsX/Y/Z() 返回每簇自己算出的最优位宽——这就是”每簇独立量化”的运行期表现);
  • 剔除字段LODBounds(包围球)+ ParentLODError(父级误差)——视锥/遮挡剔除测包围球,LOD 判据读误差(第 10 章的 SmallEnoughToDraw 直接消费它们);
  • 材质字段PackedMaterialInfoAttributeOffset_BitsPerAttributeDecodeInfoOffsetUVBitOffsets —— 着色阶段解码三角形属性用(第 12 章)。

类比实验室:身份证
FPackedCluster 像一张身份证:所有”你能想起这个人的信息”都压缩在固定大小的卡片里——
编号(索引)、住址(页内偏移)、身高体重(包围球)、照片压缩格式(量化信息)。
固定大小让 GPU 可以”按编号取卡”,不用翻页扫描。

7.3 FPackedHierarchyNode:4 个孩子一组打包

FPackedHierarchyNodeNaniteResources.h:59)是 GPU 端 BVH 节点。**关键设计:不是”一个节点一条记录”,而是”4 个扇出子节点合成一个 60 dword 的切片”**(NANITE_MAX_BVH_NODE_FANOUT = 4NANITE_HIERARCHY_NODE_SLICE_SIZE_DWORDS = 60,两者在 NaniteDefinitions.h:103-111):

1
2
3
4
5
6
7
8
struct FPackedHierarchyNode
{
FVector4f LODBounds[NANITE_MAX_BVH_NODE_FANOUT]; // 每个子节点的 LOD 包围球
struct { FVector3f BoxBoundsCenter; uint32 MinLODError_MaxParentLODError; } Misc0[4];
struct { FVector3f BoxBoundsExtent; uint32 ChildStartReference; } Misc1[4]; // 子节点引用
struct { uint32 ResourcePageRangeKey; uint32 GroupPartSize_AssemblyPartIndex;
uint32 NumBones_BoneIndicesOffset; } Misc2[4]; // 页面范围/装配信息
};

为什么 4 个一组? 这是 SIMD 友好的”宽度”:GPU 一次加载 4 个节点的数据正好对齐 128 字节缓存行,遍历时 4 个子节点一起测试、一起决定”访问谁”。ChildStartReference 指向子切片在 HierarchyNodes 数组中的位置(或者指向簇数据,表示”这里是叶子”)。ResourcePageRangeKey页面引用——GPU 遍历到未驻留页面时,就是靠它发出流送请求(第 8 章)。

图 F12 HierarchyNodes 扁平数组与"4 子节点切片"结构(子切片编号 = 数组内连续区间)
HierarchyNodes[](扁平数组,FResources::HierarchyNodes)
┌─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│切片 0    │切片 1    │切片 2    │切片 3    │切片 4    │ ...     │
│ 4 个子  │ 4 个子  │ 4 个子  │ 4 个子  │ 4 个子  │         │
│ 节点    │ 节点    │ 节点    │ 节点    │ 节点    │         │
└────┬────┴────┬────┴────┬────┴────┬────┴────┬────┴─────────┘
     │         │         │         │         │
     └────┬────┘         │         │         │
          ▼              ▼         ▼         ▼
       切片 0 内部:child[0].ChildStartReference → 切片 1
                   child[1].ChildStartReference → 切片 2
                   child[2].ChildStartReference → 簇数据(叶子)
                   child[3] 无效(EnableMask 位标记)

每个”切片” = 60 dword = 240 字节(SIMD 友好)
每个网格从 HierarchyRootOffsets[i] 出发开始遍历(由 Primitive 的 NaniteHierarchyOffset 定位)

7.4 共享常量头:C++ 与 HLSL 的同一份真相

NaniteDefinitions.hEngine/Shaders/Shared/NaniteDefinitions.h)是C++ 与 HLSL 共用的常量头——#if defined(__cplusplus) 处理两侧差异(比如 uint vs unsigned int),其余 #define 完全相同。这保证:C++ 写缓冲区时用的位布局,和 GPU 着色器读缓冲区时用的位布局,永远一致,不会出现”两边各自定义 128”然后某天有人改了 C++ 侧忘了改着色器。

这个文件值得通读一遍——它几乎就是 Nanite 的”宪法”:

常量 含义
NANITE_MAX_CLUSTER_TRIANGLES 128 每簇最大三角形
NANITE_MAX_CLUSTER_VERTICES 256 每簇最大顶点
NANITE_MAX_BVH_NODE_FANOUT 4 层级节点扇出
NANITE_ROOT_PAGE_GPU_SIZE 32 KB 根页面大小
NANITE_STREAMING_PAGE_GPU_SIZE 64 KB 流送页面大小
NANITE_MAX_GPU_PAGES 131072 GPU 页表最大页数
NANITE_MAX_INSTANCES 2^24 场景最大实例数
NANITE_MAX_POSITION_QUANTIZATION_BITS 21 位置量化最大位数/轴
NANITE_NUM_DEPTH_BUCKETS_PER_BLOCK 256 深度桶数量(第 11 章)
NANITE_MATERIAL_*_FLAGS 位标志 材质可编程分类(第 12 章)
NANITE_VISUALIZE_* 0~41 可视化模式 ID(第 13 章)

避坑:网上很多老资料(包括 UE 5.0/5.1 时代的博客)说这个头在 Engine/Source/... 里——
不对,它在 Engine/Shaders/Shared/ 下,C++ 通过 shader 共享头机制引入。行号引用时以
当前仓库为准(本文快照:2026-08-16,HEAD 2c7bb82d)。

7.5 两大 GPU 缓冲:ClusterPageData 与 Hierarchy

所有 Nanite 网格的 GPU 数据最终都装进 FStreamingManager两个全局大缓冲NaniteStreamingManager.h:69 内部):

缓冲 内容 访问模式
ClusterPageData 全部簇(FPackedCluster)+ 每簇的几何数据(索引/位置/UV/切线) 页表间接寻址:PageIndex → ClusterIndex
Hierarchy 全部层级切片(FPackedHierarchyNode) 直接偏移寻址:HierarchyOffset + NodeIndex
  • 为什么是全局缓冲? 流送的本质是”页面在运行时动态装卸”,如果每个网格一块独立 GPU 内存,页面安装/卸载就要重新分配内存、碎片化;全局缓冲 + 页表 = 固定大小的虚拟地址空间,页表项指到哪就是哪(和操作系统的”物理页帧 + 页表”一模一样)。
  • 谁写谁读? 流送管理器(CPU 侧 scatter 上传)写 ClusterPageData/Hierarchy 的新页面;GPU 剔除(第 10 章)读 Hierarchy 遍历树、读 ClusterPageData 取簇;着色(第 12 章)读簇内几何数据解码三角形。

7.6 源码考古:FPackedCluster 的”身份证”字段

源码考古:想看真东西的人进。

NaniteResources.h:108(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Packed Cluster as it is used by the GPU
struct FPackedCluster
{
// Members needed for rasterization
uint32 NumVerts_PositionOffset; // NumVerts:14, PositionOffset:18
uint32 NumTris_IndexOffset; // NumTris:8, IndexOffset: 24
...
// Members needed for culling
FSphere3f LODBounds;
FVector3f BoxBoundsCenter;
uint32 LODErrorAndEdgeLength;
...
uint32 ParentLODError_ParentSphereBoundRadius; // ParentLODError:16, ParentSphereBoundRadius:16

uint32 GetNumVerts() const { return GetBits(NumVerts_PositionOffset, 14, 0); }
uint32 GetPositionOffset() const { return GetBits(NumVerts_PositionOffset, 18, 14); }
uint32 GetNumTris() const { return GetBits(NumTris_IndexOffset, 8, 0); }
uint32 GetIndexOffset() const { return GetBits(NumTris_IndexOffset, 24, 8); }
uint32 GetPosPrecision() const { return (int32)GetBits(..., 6, 3) + NANITE_MIN_POSITION_PRECISION; }
uint32 GetPosBitsX() const { return GetBits(..., 5, 9); }
...
uint32 GetFlags() const { return GetBits(Flags_NumClusterBoneInfluences, 4, 0); }
uint32 GetNumClusterBoneInfluences() const { return GetBits(Flags_NumClusterBoneInfluences, 5, 4); }
};

这段代码在干什么GetBits(Word, NumBits, Offset) 是从一个 uint32 里”切位段”的通用工具。
整个结构体没有一个”浪费”的位——NumVertsPositionOffset 挤在同一个 dword 里,
用 14 位装顶点数(≤256 够用)、18 位装偏移(页内偏移上限 64KB 足够)。这种”两个小字段
拼一个 dword”的模式全文皆是
——读 Nanite 源码时看到这种名字(X_Y)就知道是位打包。
GetPosPrecision 返回的是”全局基准 + 每簇偏移”:位置精度 = 2^(-20 + PosPrecision) 厘米级,
NaniteDefinitions.h:159-160
NANITE_MIN/MAX_POSITION_PRECISION(-20~43)。


第 8 章 页面流送:几何体的虚拟内存

TL;DRFStreamingManager(全局单例 GStreamingManager)把 Nanite 的 GPU 数据管成一块
“虚拟内存”:固定大小的全局缓冲 + 页表 + LRU。每帧闭环是:GPU 剔除发现缺页 → 写请求 →
CPU 回读 → 按优先级排序 → 异步读盘 → 安装 + 修正引用(fixup)→ scatter 上传

根页面永远驻留,所以任何时刻都有东西可画。类比:虚拟内存缺页处理——请求 = page fault,
安装 = 调页,fixup = 更新页表,LRU = 内存回收

8.1 设计目标:把”显存放不下”变成”显存刚刚好”

Nanite 流送的目标清单(对照虚拟内存的设计目标,几乎一一对应):

  1. 透明:渲染代码不需要知道页面是否驻留——缺页只是”先用粗 LOD 顶着”,不是错误;
  2. 按需:只有”画面需要”的页面才加载(GPU 每帧遍历树时精确知道需要哪些);
  3. 可驱逐:显存是共享的,页面按 LRU 淘汰,把显存让给新需求;
  4. 带宽可控:每帧加载/安装的量有预算(r.Nanite.Streaming.BandwidthLimit 等),防止卡帧;
  5. 正确性优先:宁可多画粗 LOD,绝不错画/漏画。

为什么虚拟化在此刻成为可能? 因为 LOD 树本身天然支持”降级渲染”:页面缺失时,遍历树会停在该页面的父节点上——那是已驻留(或根页)的粗 LOD。数据到达后,下一帧遍历自动选择更细的簇。**流送就是 LOD 树的”缺页异常处理器”**。

8.2 FStreamingManager:流送系统的”操作系统内核”

FStreamingManagerNaniteStreamingManager.h:69,**注意:UE 5.7 前叫 FNaniteStreamingManager**)是全局单例(TGlobalResource,生命周期随渲染资源,:413)。它的核心成员按职责分四组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class FStreamingManager : public FRenderResource
{
// ── 每帧生命周期 ──
ENGINE_API void BeginAsyncUpdate(FRDGBuilder& GraphBuilder); // 每帧开始(NaniteStreamingManager.cpp:2212)
ENGINE_API void EndAsyncUpdate(FRDGBuilder& GraphBuilder); // 渲染前完成(同文件 :2975)
// 内部:AsyncUpdate()(:2758)—— CPU 端异步更新的主体

// ── GPU 全局缓冲(7.5 节的两块地) ──
FHeapBuffer ClusterPageData; // 全部簇数据(FPackedCluster + 几何数据)
FHeapBuffer Hierarchy; // 全部层级切片

// ── 页表与 LRU ──
FSpanAllocator VirtualPageAllocator; // 虚拟页号分配器
TArray<FRegisteredVirtualPage> RegisteredVirtualPages; // 虚拟页 → 资源页映射
TArray<FResidentPage> ResidentPages; // 物理驻留页
TArray<uint32> RegisteredPageIndexToLRU; // LRU 链表(双向索引)
TArray<FResidentVirtualPage> ResidentVirtualPages;

// ── 请求与安装 ──
TArray<FNewPageRequest> RequestedNewPages; // 本帧新增请求
TArray<FPendingPage> PendingPages; // 待加载页面(读盘中)
TArray<uint8> PendingPageStagingMemory; // 暂存内存(解压/转码用)
TPimplPtr<FStreamingPageUploader> PageUploader; // 页面 → GPU 上传器
TPimplPtr<FReadbackManager> ReadbackManager; // GPU 请求回读器
TPimplPtr<FQualityScalingManager> QualityScalingManager; // 质量自适应
...
};

8.3 请求闭环:一帧内的五个阶段

图 F13 页面流送完整时序(五阶段泳道:GPU 剔除 → 回读 → CPU 处理 → 磁盘 → 上传)
GPU 渲染(每帧) CPU 流送线程 磁盘 / DDC GPU 缓冲(页表+数据) ① 遍历层级树,发现缺页 写 FGPUStreamingRequest(带优先级) 着色器:NaniteHierarchyTraversal.ush ② FReadbackManager 回读请求 GPU → CPU(多缓冲环形,不阻塞渲染) NaniteReadbackManager.cpp ③ CPU:优先级排序 + LRU 淘汰 加入父页依赖;预算控制 BeginAsyncUpdate → AsyncUpdate ④ 异步读盘 + 转码解压 后台任务从 StreamablePages / DDC 读取 解压为 GPU 可读格式(暂存内存) StreamablePages(资产 bulk 数据) 压缩的页面数据(~64KB/页) ⑤ 安装:Fixup 修正引用 FFixupChunk 修正父页引用 → EndAsyncUpdate 前 scatter 上传 ClusterPageData / Hierarchy 两大缓冲 FStreamingPageUploader scatter 写入 下一帧:遍历树选到新页面 → 更细的簇上场

帧 N(渲染开始时 EndAsyncUpdate 已把所有已就绪页面安装完)

逐阶段说明(与源码对应):

  1. 请求产生(GPU)NaniteHierarchyTraversal.ush 在遍历层级树时,遇到 ResourcePageRangeKey 指向未驻留页面的节点 → 写一条 FGPUStreamingRequest(含优先级)到 GPU 缓冲。渲染标志 NANITE_RENDER_FLAG_OUTPUT_STREAMING_REQUESTSNaniteDefinitions.h:209)控制是否输出。
  2. 回读(GPU→CPU)FReadbackManagerNaniteReadbackManager.h:15)维护环形 readback 缓冲:QueueReadback 排队、LockLatest 取回最新一批请求,不阻塞渲染管线
  3. CPU 处理FStreamingManager::BeginAsyncUpdateNaniteStreamingManager.cpp:2212)→ AsyncUpdate(:2758):AddPendingGPURequests + 加父页依赖(请求一个页必须连带其父页——否则父簇数据缺失无法渲染)→ 优先级排序(屏幕空间误差大者优先)→ LRU 淘汰(预算内换出最久未用的页)。
  4. 读盘:后台任务从 StreamablePages(资产 bulk 数据)异步读取页面数据到暂存内存,转码(解压成 GPU 格式)。
  5. 安装 + 上传InstallReadyPages 分配驻留页槽 → **ApplyFixups**(用 FFixupChunk 改写引用它的页面的 GPU 数据,6.4 节讲过)→ EndAsyncUpdate(:2975)前通过 FStreamingPageUploader::ResourceUploadTo + FOrderedScatterUpdater 把页面数据 scatter 写进 ClusterPageData/Hierarchy

帧序约束BeginAsyncUpdate 在每帧 Nanite 渲染前调用,EndAsyncUpdate 在渲染开始前完成——保证渲染时看到的数据一致(页表、数据、fixup 全部就绪)。

8.4 优先级、预算与质量自适应

机制 实现 说明
优先级 FGPUStreamingRequest 的 priority 字段 + NANITE_MAX_PRIORITY_BEFORE_PARENTSNaniteDefinitions.h:123 屏幕误差大的页先加载;父页请求优先级恒高于子页
带宽预算 r.Nanite.Streaming.BandwidthLimit 每帧磁盘读取量上限
安装预算 r.Nanite.Streaming.MaxPageInstallsPerFrame 每帧安装页数上限(fixup 改写有成本)
常驻 根页面(RootData)在 InitResources 时安装,永不淘汰 “永远有东西可画”的保底
质量自适应 FQualityScalingManagerQualityScaleFactor 驻留率低时降低 LOD 尺度,减少请求压力

8.5 与虚拟纹理/虚拟内存对照表

概念 虚拟内存 虚拟纹理 Nanite 流送
页粒度 4KB 128×128 像素 64KB(流送页)/ 32KB(根页)
页表 页表(Page Table) 页表纹理 RegisteredVirtualPages / GPU 页表
缺页事件 Page Fault 页面缺失像素 GPU 遍历遇未驻留页
调页 内核读盘 后台加载 FStreamingManager 异步读盘
页表更新 改页表项 更新页表纹理 ApplyFixups 修正引用
淘汰 LRU/Clock LRU LRU
写回 脏页写盘 无(几何只读)

唯一的”虚拟内存没有、Nanite 必须有”的东西:Fixup(依赖修正)——因为 Nanite 的页面之间存在数据引用(父页引用子页顶点),而虚拟内存的页是独立的。这就是为什么构建期要花一章的篇幅生成依赖表和修复块。

8.6 源码考古:流送管理器的关键行

源码考古:想看真东西的人进。

① 全局单例NaniteStreamingManager.h:413

1
extern ENGINE_API TGlobalResource< FStreamingManager > GStreamingManager;

这段代码在干什么TGlobalResource 是 UE 的”渲染资源单例”模板——生命周期由渲染
线程管(InitRHI/ReleaseRHI 随 RHI 初始化/销毁),任何渲染代码 Nanite::GStreamingManager
直接访问。这是 Nanite 数据”全局唯一”的体现:所有网格共享一套页表、LRU、缓冲。

② 每帧两锚点NaniteStreamingManager.cpp:2212:2975

1
2
3
4
5
6
7
void FStreamingManager::BeginAsyncUpdate(FRDGBuilder& GraphBuilder)
// "Should be called at least once per frame. Must be called before any Nanite rendering
// when new meshes are added." —— 每帧至少一次,新增网格时必须先于渲染

void FStreamingManager::EndAsyncUpdate(FRDGBuilder& GraphBuilder)
// "Must be called after BeginAsyncUpdate and before any Nanite rendering."
// —— 在 Begin 之后、渲染之前,把本帧所有就绪页面安装完毕

这段代码在干什么:这两个函数的注释本身就是协议——**”Begin 收请求,End 发货”**。
渲染器在 DeferredShadingRenderer 每帧流程里调用它们(Nanite::GStreamingManager.BeginAsyncUpdate(...)
→ 渲染 → EndAsyncUpdate(...)),确保 GPU 每帧看到的页表/数据都是”上一批请求结算完”的一致状态。

③ 内部核心:2758 AsyncUpdate()(伪代码化后的职责划分):

1
2
3
4
5
6
7
8
9
10
11
void FStreamingManager::AsyncUpdate()
{
// 1. AddPendingGPURequests:GPU 请求 → RequestedNewPages
// 2. AddParentRequests:给每个请求补上父页依赖
// 3. 优先级排序:SelectHighestPriorityPagesAndUpdateLRU
// 4. 预算内发起读盘(后台任务,解压/转码)
// 5. DetermineReadyOrSkippedPages → InstallReadyPages
// → ApplyFixups(FFixupChunk)
// → 页表登记 + LRU 更新
// 6. UninstallResidentPage:超出预算时按 LRU 卸载
}

这段代码在干什么:一帧流送的”操作系统内核”。六步与你熟悉的虚拟内存缺页处理一一对应
(收集缺页 → 补依赖 → 调度 → 读盘 → 装页 → 换出)。注意第 5 步的 ApplyFixups——
页面安装不只是”拷贝数据”,还要修正引用它的页面(6.4 节),这是 Nanite 流送区别于
普通资源流送的核心成本,也是 r.Nanite.Streaming.MaxPageInstallsPerFrame 存在的理由。

第 9 章 渲染总览与材质预分拣

TL;DR|每帧的 Nanite 渲染分四大块:CPU 材质预判 → GPU 剔除+光栅化(写 VisBuffer)→
深度导出 → 分材质着色
。本仓库的架构事实:没有独立的 FNaniteVisibilityPass 类,
一切核心在 FRenderer::DrawGeometry(一个 C++ 类把剔除、分桶、光栅化全部编排完)。
材质在 GPU 光栅之前就按”bin”(桶)分好类:光栅 bin 决定怎么画,着色 bin 决定怎么算。
类比:拍电影先做分镜预演(CPU 预判),正式拍摄才上 GPU

9.1 一帧的完整形状

图 F14 单帧 Nanite 全景时序(泳道:CPU 任务 / GPU 剔除 / GPU 光栅 / GPU 着色 / 流送线程)
CPU 渲染线程 GPU 剔除(计算着色器) GPU 光栅化 GPU 着色(计算着色器) 后台线程(流送) FNaniteVisibility::BeginVisibilityQuery 材质 bin 可见性预判(异步任务) 准备 FPackedView 打包视图/矩阵/LOD 尺度 InstanceCull 实例层级/暴力剔除 NodeAndClusterCull(逐层) 视锥+HZB+误差判 LOD,写流送请求 CalculateSafeRasterizerArgs 生成间接分派参数 RasterBin Binning 簇/三角形按材质分桶 HW Rasterize 图形管线(VS/PS/MS) SW Rasterize(异步) 计算着色器微三角形 写 VisBuffer64 深度+簇+三角形+实例 ID EmitDepthTargets VisBuffer → 场景深度/模板 ShadeBinning(像素分桶) ShadingBinBuild → Reserve 每材质计算着色器写 GBuffer 按 indirect args 分派(DispatchBasePass) FStreamingManager:回读请求 → 读盘 → 安装 → fixup(第 8 章)

两遍遮挡开启时(r.Nanite.Culling.TwoPass):
MainPass → BuildPreviousOccluderHZB
→ PostPass 重测(本章 F16 展开)
深度导出与着色解耦:
r.Nanite.ExportDepth 可把深度导出
挪到计算着色器(第 12 章)

读图要点:CPU 只干一件事(材质预判 + 打包视图),其余全在 GPU;光栅化与着色之间隔着一个 VisBuffer;着色阶段每个材质一个计算着色器(不切换 PSO),用间接分派。

9.2 架构事实:FRenderer::DrawGeometry 是绝对核心

本仓库(5.9 fork)里没有 FNaniteVisibilityPass 类——老的博客/资料里那个名字属于 UE 5.0~5.2 的旧架构。现在的核心是 FRendererNaniteCullRaster.cpp:6818FRenderer::DrawGeometry),一个对象把一帧的所有剔除/光栅化阶段编排完。调用它的渲染器主流程(DeferredShadingRenderer.cpp 中):

1
2
3
4
5
6
渲染一帧(DeferredShadingRenderer::Render):
├─ FNaniteVisibility::BeginVisibilityQuery(...) // CPU 预判(9.3)
├─ Nanite::DrawGeometry(...) // 剔除+光栅,输出 VisBuffer(第 10、11 章)
├─ Nanite::EmitDepthTargets(...) // VisBuffer → 深度/模板(第 12 章)
├─ Nanite::DispatchBasePass(...) // 分材质着色写 GBuffer(第 12 章)
└─ CustomDepth / Shadow / Lumen / 半透明等辅助 Pass(复用同一 FRenderer,不同 ERasterPipeline)

复用是重点:同一个 FRenderer 以不同的”管线枚举”(ERasterPipeline:Primary/Shadows/Lumen/HitProxy/MaterialCache)跑不同用途——阴影图也是”剔除+光栅+VisBuffer”(只是输出目标不同),Lumen Mesh Card、编辑器拾取同理。一套流水线,多种产出,这是 GPU-Driven 架构的典型红利。

9.3 材质预分拣:RasterBin 与 ShadingBin

为什么要预分拣? 光栅化需要知道”这个簇用哪个光栅管线”(HW/SW、双面、WPO……),着色需要知道”这些像素归哪个材质”——但簇和像素都是动态的,每帧才知道。所以 Nanite 在构建材质 bin 表FNaniteRasterPipelines/FNaniteShadingPipelines,见 NaniteShared.h:618FNaniteRasterPipeline)之后,每帧做两类”分桶”:

  • RasterBin(光栅桶):一个材质段 → 一个”怎么光栅化”的桶。桶由管线特征(双面?WPO?蒙皮?Voxel?)哈希决定,桶数远小于材质数——大量材质共享同一个”固定函数”桶(NANITE_FIXED_FUNCTION_BIN*NaniteDefinitions.h:248-254);
  • ShadingBin(着色桶):一个材质段 → 一个”怎么着色”的桶。每个材质一个着色桶(含材质参数、GBuffer 写掩码)。

每帧 CPU 端先跑一个异步预判FNaniteVisibility::BeginVisibilityQueryNaniteVisibility.cpp:352):对每个 primitive 的包围盒做视锥测试,把”肯定不可见的材质桶”预先标记掉——这样 GPU 分桶时直接跳过,省掉无效分派。这个预判是粗粒度的(包围盒级),精确剔除还是 GPU 的事。

每个材质段对应的 bin 落在哪? FNaniteMaterialSlotNaniteMaterials.h:15):

1
2
3
4
5
6
7
8
struct FNaniteMaterialSlot
{
uint16 TriangleShadingBin; // 三角形着色桶
uint16 VoxelShadingBin; // Voxel 着色桶(本 fork 特性)
uint16 CurveShadingBin; // 曲线着色桶(本 fork 特性)
uint16 RasterBin; // 光栅桶
uint16 FallbackRasterBin; // 回退光栅桶(可编程材质降级用)
};

避坑:CPU 预判只是”粗筛”,别把它当剔除——真正的剔除在 GPU(第 10 章)。另外 GNaniteMaterialVisibility(CVar r.Nanite.MaterialVisibility)为 0 时预判直接跳过,全走 GPU 路径。

9.4 源码考古:DrawGeometry 的骨架

源码考古:想看真东西的人进。

NaniteCullRaster.cpp:6818(节选,已压缩中间分配代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void FRenderer::DrawGeometry(FNaniteRasterPipelines& RasterPipelines, ...)
{
RDG_EVENT_SCOPE(GraphBuilder, "Nanite::DrawGeometry");

// 实例层级剔除驱动(本仓库新特性,NaniteCullRaster.cpp:3259)
InstanceHierarchyDriver.Init(GraphBuilder, true, Configuration.bTwoPassOcclusion,
SharedContext.ShaderMap, SceneInstanceCullingQuery, InViewDrawRanges);

// ① 清空队列状态 / 初始化间接参数(NaniteClusterCulling.usf: FInitArgs_CS)
FComputeShaderUtils::AddPass(GraphBuilder, RDG_EVENT_NAME("InitArgs"), ...);

// ② 每视图 primitive 过滤(r.Nanite.FilterPrimitives)
AddPass_PrimitiveFilter();

// ③ 准备光栅管线(HW 路径选择:VS/PS/MS/WorkGraph)
PrepareRasterizerPasses(DispatchContext, GetRasterHardwarePath(...), ...);

// ④ Main Pass:实例剔除 + 层级/簇剔除 + 光栅化(输出 VisBuffer)
{
RDG_EVENT_SCOPE(GraphBuilder, "MainPass");
AddPass_InstanceHierarchyAndClusterCull(CULLING_PASS_OCCLUSION_MAIN);
MainPassBinning = AddPass_Rasterize(DispatchContext, SafeMainRasterizeArgsSWHW, ...);
}

// ⑤ 两遍遮挡时:构建上一帧遮挡物 HZB,Post Pass 重测被挡实例
if (Configuration.bTwoPassOcclusion)
{
BuildHZBFurthest(...); // 生成 PreviousOccluderHZB
RDG_EVENT_SCOPE(GraphBuilder, "PostPass");
AddPass_InstanceHierarchyAndClusterCull(CULLING_PASS_OCCLUSION_POST);
PostPassBinning = AddPass_Rasterize(DispatchContext, SafePostRasterizeArgsSWHW, ...);
}

// ⑥ 统计(r.Nanite.ShowStats)
if (Configuration.bExtractStats) { ExtractStats(MainPassBinning, PostPassBinning); }
}

这段代码在干什么:一帧剔除+光栅的全部编排,只占几十行——因为每步都是”准备好参数 → 丢给
计算着色器/图形管线 → 拿回间接分派参数”。注意两个关键词:CULLING_PASS_OCCLUSION_MAIN
CULLING_PASS_OCCLUSION_POST——同一套剔除代码用不同 Pass 枚举跑两遍,这是”两遍遮挡”
的 C++ 侧形态(着色器侧差异见第 10 章 FBoxCull::HZB)。


第 10 章 实例与层级剔除:GPU 自己决定画什么

TL;DR|剔除是一道三道闸的漏斗:实例级(视锥+HZB)→ 节点级(BVH 逐层)→ 簇级(LOD 判定)
核心判据两个:FBoxCullFrustum() 视锥 + HZB() 遮挡)和 SmallEnoughToDraw
(投影边长 × 误差 < 1 像素,同时决定走 HW 还是 SW 光栅)。两遍遮挡用上一帧 HZB 测主遍、
本帧 HZB 测后遍,保守且正确。最终产出不是 draw call,而是间接分派参数
类比:看地图找路——先看省(视锥),再看城区(HZB),最后放大到街道(LOD)

10.1 三级剔除漏斗

图 F15 剔除决策漏斗:每级剔除掉一批,最后剩下的才进光栅化
全场景实例(最多 2^24 个) FInstanceCull_CS / Instance Hierarchy Culling NaniteInstanceCulling.usf:177 ① 实例剔除 包围球:视锥 + 上一帧 HZB 遮挡 被挡的写 OccludedInstances(两遍遮挡) ② 层级节点剔除(逐层) NodeAndClusterCull_CS(NaniteClusterCulling.usf:968) 沿树下降:Frustum + HZB + LOD 误差 ③ 簇剔除 + LOD 判定 SmallEnoughToDraw(NaniteClusterCulling.usf:287) 可见?→ 写 FVisibleCluster + 决定 SW/HW 输出 FVisibleCluster[] + RasterizeArgs(间接分派参数) → 第 11 章 Binning 消费

✗ 视锥外 / 被挡
✗ 父级已足够细
✗ 误差已达标
每一级剔除后,
数量级下降

① 实例剔除:先粗筛”这个模型实例本身要不要画”——包围球不在视锥内?被 HZB 挡住?都不要。本仓库有两套路径:Instance Hierarchy Culling(新,FInstanceHierarchyDriverNaniteCullRaster.cpp:3259,对 GPUScene 实例按 BVH 分层批量剔除)与普通路径(FInstanceCull_CSNaniteInstanceCulling.usf:177)。两遍遮挡模式下,被挡实例写入 OccludedInstances,Post Pass 时重测。

② 节点剔除:对幸存实例,从它的树根开始逐层下降。NodeAndClusterCullNaniteClusterCulling.usf:968)按 CULLING_TYPE 分发到 NodeCull/ClusterCull/PersistentNodeAndClusterCull 三个模板实现。每个节点的测试 = 包围球视锥测试 + 包围球 HZB 遮挡测试 + 误差阈值测试(”这个节点已足够细”就停,否则访问子节点)。树遍历本质是 BFS 队列:节点测试通过才把子节点入队。

③ 簇剔除 + LOD 判定:到达叶子(簇)时,SmallEnoughToDraw 做最后判定(10.3 节)——通过则把簇写入 FVisibleCluster[]NaniteDataDecode.ush:48 中的可见簇格式,96/64 位打包),并标记走 HW 还是 SW 光栅。这个”簇列表”就是第 11 章光栅化的输入

10.2 FBoxCull:Frustum 与 HZB 两把刀

剔除的几何测试集中在 FBoxCullNaniteCullingCommon.ush:232):

  • **Frustum()**(:527):把包围盒变换到裁剪空间,对 6 个裁剪面做经典”盒 vs 锥”测试;同时检测是否跨越近/远平面(跨近平面 = 需要裁剪标记)。VSM(虚拟阴影图)下还有动态深度范围裁剪逻辑(本 fork 特性)。
  • HZB()(:595):先把盒投影成屏幕矩形,再在 HZB 金字塔上逐级查询——从最粗层开始,如果矩形在该层完全被”更近的深度”覆盖,直接判为被遮挡(不用查细层)。这就是”层级”Z 缓冲的意义:常数时间近似的遮挡查询。

HZB 是什么? 一张深度图的金字塔:第 0 层是原始深度,第 n 层是”每 2^n×2^n 像素里取最远(最大)深度”的缩小图。测试包围盒时从粗层查起,能快速否决”深藏不露”的物体。

两遍遮挡的具体机制r.Nanite.Culling.TwoPassNaniteCullRaster.cpp:309):

图 F16 两遍遮挡:Main 用上一帧 HZB,Post 用本帧 HZB;漏网的只多画不画错
帧 N-1 光栅化完成 ──▶ HZB(N-1)(上一帧深度金字塔)
                        │
帧 N:                  ▼
  MainPass:剔除时 FBoxCull::HZB() 用 HZB(N-1)   ← 上帧被挡的簇在此被剔掉(写 OccludedInstances)
       │
       ▼
  MainPass 光栅化完成 ──▶ BuildPreviousOccluderHZB ──▶ 本帧 HZB(N)
                          ("上一帧可见物"的深度,用作遮挡物)
       │
       ▼
  PostPass:对 OccludedInstances 用 HZB(N) 重测
       │  通过?→ 画(刚露头的物体)
       │  仍被挡?→ 不画(节省)
       ▼
  帧 N 的 VisBuffer 完成 ──▶ 下一帧用它构建 HZB(N+1)

正确性论证:Main 用上一帧 HZB 测”上帧可见/不可见”——漏网的两种情况:① 上帧被挡、本帧露出 → Post 补测本帧 HZB 抓回来;② 上帧可见、本帧被新遮挡物挡住 → 多画了(浪费一点,无正确性问题)。所以两遍遮挡只可能多画,不可能少画——保守正确。这也是为什么叫”Main + Post”而不是”Pre + Pass”。

10.3 LOD 选择:SmallEnoughToDraw 的数学

SmallEnoughToDrawNaniteClusterCulling.usf:287)是”簇级 LOD 判定 + SW/HW 分流”的一体化判据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bool SmallEnoughToDraw(FNaniteView NaniteView, FInstanceSceneData InstanceData,
FInstanceDynamicData DynamicData, FNodeCullingBounds Bounds,
float LODError, float EdgeLength, inout bool bUseHWRaster)
{
// 投影边长(像素)= 包围球直径在屏幕上的投影 × LODScale
float ProjectedEdgeScale = GetProjectedEdgeScales(NaniteView, InstanceData, DynamicData, Bounds.Sphere).x;
float UniformScale = Bounds.MeshMinDeformScale * min3(InstanceData.NonUniformScale.x, ...);

// LOD 判定:投影边长 > 误差 × 阈值 → 说明"这个簇的误差在屏幕上超过 1 像素" → 不画(要继续细分)
bool bVisible = ProjectedEdgeScale > UniformScale * LODError * NaniteView.LODScale;

if (RenderFlags & NANITE_RENDER_FLAG_FORCE_HW_RASTER)
{
bUseHWRaster = true;
}
else
{
// SW/HW 分流:投影边长 < 硬件阈值 → 微三角形,走软件光栅
float HWEdgeScale = InstanceData.NonUniformScale.w * Bounds.NodeMaxDeformScale;
bUseHWRaster |= ProjectedEdgeScale < HWEdgeScale * abs(EdgeLength) * NaniteView.LODScaleHW;
}
return bVisible;
}

逐行翻译

  • LODError = 这个簇的简化误差(构建期算好,第 5 章);ProjectedEdgeScale = 簇的包围球直径投影到屏幕的像素数;
  • 判定式 ProjectedEdgeScale > LODError * LODScale:把”世界空间误差”换算成”屏幕像素误差”——**投影边长是”这个簇在屏幕上多大”的度量,乘以 LODError 就得到”这个误差在屏幕上占多少像素”**;如果它大于阈值(1 像素),说明细看会看出简化痕迹 → 返回 false(不画,继续细分);反之 → 画它;
  • LODScale 来自 FPackedView::UpdateLODScales,由 r.Nanite.MaxPixelsPerEdge(默认 1.0)驱动——这就是”误差 < 1 像素”旋钮:调大 MaxPixelsPerEdge → 更早停 → 更粗的 LOD(性能↑质量↓);
  • 第二段:bUseHWRaster 判定——投影边长小于 MinPixelsPerEdgeHW(默认 32 像素)× 边长 → 三角形比 32 像素还小 → 软件光栅更高效(第 11 章)。

GetProjectedEdgeScales 的隐藏知识:它用包围球的投影直径而非三角形实际尺寸——所以”簇大小”统一用包围球近似,GPU 遍历不需要解出三角形。

10.4 输出:不是 draw call,是间接分派参数

传统:CPU 每帧生成”可见模型列表”→ 每个模型一次 DrawCall → 几千~几万次状态切换。

Nanite:GPU 剔除后把可见簇写入紧凑缓冲,随后 CalculateSafeRasterizerArgsNaniteCullRaster.cpp:4902)为每个 RasterBin 生成间接分派/绘制参数DispatchIndirect 用的参数块)。渲染端只有”每 bin 一条间接调用”——CPU 全程零逐模型调用。

避坑:论文和早期资料说的”整个场景一个 DrawIndirect 画完”是简化表述。本仓库的实际
形态是:每个 RasterBin 一条 indirect 参数(默认固定函数桶就十几个),由 binning 阶段
(第 11 章)决定哪些 bin 活跃。准确说法是”CPU 零 draw call、GPU 侧一次提交多条 indirect
调用”——几十条,不是几十万条。理解这个区别对读 stats 很有用(NumMainPassIndirections)。

10.5 源码考古:剔除的三个关键点

源码考古:想看真东西的人进。

① NodeAndClusterCull 入口NaniteClusterCulling.usf:968

1
2
3
4
5
6
7
8
9
10
11
[numthreads(NANITE_PERSISTENT_CLUSTER_CULLING_GROUP_SIZE, 1, 1)]
void NodeAndClusterCull(uint GroupID : SV_GroupID, uint GroupIndex : SV_GroupIndex)
{
#if CULLING_TYPE == NANITE_CULLING_TYPE_NODES
NodeCull<FNaniteTraversalClusterCullCallback>(GroupID, GroupIndex, QueueStateIndex);
#elif CULLING_TYPE == NANITE_CULLING_TYPE_CLUSTERS
ClusterCull<FNaniteTraversalClusterCullCallback>(GroupID, GroupIndex, QueueStateIndex);
#elif CULLING_TYPE == NANITE_CULLING_TYPE_PERSISTENT_NODES_AND_CLUSTERS
PersistentNodeAndClusterCull<FNaniteTraversalClusterCullCallback>(GroupIndex, QueueStateIndex);
#endif
}

这段代码在干什么:一个入口,三种剔除模式(仅节点 / 仅簇 / 持久线程节点簇合一),
用 C++ 编译期排列(permutation)切换——同一份 shader 三份特化,避免运行时分支。
QueueStateIndex 是”待处理队列”的游标:GPU 上维护一个候选簇队列,剔除线程从队列取、
把”还需要细分的”子节点写回队列——这就是 GPU 上的 BFS。

② HZB 遮挡测试NaniteCullingCommon.ush:595(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void HZB(FFrustumCullData FrustumCull, bool bClampToPageLevel)
{
// 把屏幕矩形换算到 HZB 空间
FScreenRect Rect = GetScreenRect(HZBRect, FrustumCull, 4);
bIsVisible = bIsVisible && Rect.bOverlapsPixelCenter;
...
#if (CULLING_PASS == CULLING_PASS_NO_OCCLUSION && VIRTUAL_TEXTURE_TARGET) || CULLING_PASS == CULLING_PASS_OCCLUSION_MAIN
// 主遍:用上一帧 HZB 测试(OCCLUSION_MAIN)
if (bIsVisible && !bSkipCullHZB && bViewHZB)
{
// 把盒变换到上一帧的裁剪空间,再用上一帧 HZB 查
FFrustumCullData PrevFrustumCull = BoxCullFrustum(..., NaniteView.PrevTranslatedWorldToClip, ...);
if (PrevFrustumCull.bIsVisible && !PrevFrustumCull.bCrossesNearPlane)
{
bIsVisible = IsVisibleHZB(...); // ← 上一帧 HZB 查询
}
}
#endif
// (OCCLUSION_POST 分支:用本帧 HZB 重测——Post Pass 的行为,见 10.2 的 F16)
}

这段代码在干什么:注意 NaniteView.PrevTranslatedWorldToClip——FPackedViewNaniteShared.h:58)里打包了”上一帧”和”当前帧”两组矩阵,剔除 shader 直接换矩阵再测一次,就实现了”上一帧 HZB 测本帧几何”。一个 pass 枚举 + 一组矩阵 = 两遍遮挡的全部实现——架构上是同一份代码,只是”测谁”不同。

③ 间接分派参数生成CalculateSafeRasterizerArgsNaniteCullRaster.cpp:4902,函数体在 NaniteClusterCulling.usf:982 附近)在实例/簇剔除之后运行,把”每 bin 的可见簇数”折算成线程组数,并做上限钳制(防止 GPU 分配爆炸)——这就是”安全(Safe)”二字的含义:MaxVisibleClusters/MaxCandidateClusters 等 CVar(NaniteShared.cpp)就是这些预算的旋钮。

第 11 章 光栅化与遮挡:从三角形到像素

TL;DR|可见簇先被 RasterBin Binning 按材质/深度分桶(保证数据局部性),然后两条路并行:
HW 光栅化(图形管线,画”正常大小”的三角形)与 SW 光栅化(计算着色器,画比像素还小的
微三角形——此时软件比硬件快)。两者都只写 64 位 VisBuffer64(深度+簇 ID+三角形 ID+实例 ID),
不碰材质。本 fork 还支持曲面细分(PatchSplit)与深度分桶优化。
类比:HW=流水线工厂(批量快),SW=手工小作坊(单件精确),Binning=分拣中心

11.1 RasterBin Binning:先分拣再开工

第 10 章剔除产出的 FVisibleCluster[] 是无序的。直接光栅化会怎样?GPU 需要每画一个簇就切换管线状态(材质/双面/WPO 不同 → PSO 不同)。所以先做 BinningRasterBinBuildNaniteRasterBinning.usf:519):

  1. 对每个可见簇的每个三角形,查 GetRemappedRasterBinFromIndex → 得到它属于哪个 RasterBin(材质段 + 管线特征哈希出桶号);
  2. 计数(RasterBinCount)→ 预留(RasterBinReserve,原子累加)→ scatter:把簇按 bin 顺序写入紧凑的 RasterBinData
  3. RasterBinFinalize 生成每 bin 的间接分派参数 + 组元数据(FNaniteRasterBinMeta/FNaniteRasterGroupMetaNaniteDefinitions.h:596-617)。

收益:SW 与 HW 光栅化都消费这份 bin 数据——同一批分拣结果喂两条流水线;深度分桶(r.Nanite.DepthBucketingNaniteCullRaster.cpp:177)把簇按深度范围再分桶,光栅化时前到后画,配合”近深度桶写快速路径”进一步省带宽。

11.2 HW 光栅化:图形管线出场

HW 路径用传统图形管线(VS → PS,或 Mesh Shader MS / Primitive Shader),管线枚举 ERasterHardwarePathNaniteShared.h:356)在运行期选:VertexShader / PrimitiveShader / MeshShaderWrapped / MeshShaderNV / MeshShader。判定走哪条:r.Nanite.MeshShaderRasterization / r.Nanite.PrimShaderRasterizationNaniteShared.cpp:102/109)+ 平台能力。

可编程 vs 固定函数r.Nanite.ProgrammableRasterNaniteCullRaster.cpp:88)开启时,顶点着色器支持 WPO/蒙皮等(VS 读取簇数据解码顶点);关闭时走”固定函数”光栅(NANITE_FIXED_FUNCTION_BIN*),更省。像素着色器只做一件事:把三角形 ID + 深度打包写进 VisBuffer64

为什么小三角形要躲开 HW? GPU 光栅化按 2×2 像素四边形(quad)工作:三角形只有 0.5 个像素大,也得占用一个 quad 的全部 4 个 lane——利用率 1/8 甚至更低,加上固定开销(三角形设置、属性插值设置),微三角形的硬件效率崩盘。这就是 SW 光栅存在的理由。

11.3 SW 光栅化:计算着色器手搓微三角形

SW 路径(FMicropolyRasterizeCSNaniteCullRaster.cpp:1517,入口 ClusterRasterizeNaniteRasterizer.ush:500)用计算着色器逐三角形做增量式光栅化RasterizeTri_Rect(:132)以三角形包围矩形为界,用三个边函数(edge function)做覆盖测试——每行每列只需一次加法和三次比较:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template< typename FWritePixel >
void RasterizeTri_Rect( FRasterTri Tri, FWritePixel WritePixel )
{
float CY0 = Tri.C0; float CY1 = Tri.C1; float CY2 = Tri.C2; // 三个边函数的当前值
int y = Tri.MinPixel.y;
while (true)
{
int x = Tri.MinPixel.x;
if (min3(CY0, CY1, CY2) >= 0) WritePixel( uint2(x,y), float3(CY0,CY1,CY2), Tri );
if (x < Tri.MaxPixel.x)
{
// 水平增量:边函数沿 x 每 +1,减去固定的 Edge.y
float CX0 = CY0 - Tri.Edge12.y; ...
x++;
while (true) { if (min3(CX0,CX1,CX2) >= 0) WritePixel(...); if (x >= Tri.MaxPixel.x) break; CX0 -= Tri.Edge12.y; ...; x++; }
}
if (y >= Tri.MaxPixel.y) break;
// 垂直增量:沿 y 每 +1,加上固定的 Edge.x
CY0 += Tri.Edge12.x; ...
y++;
}
}

为什么快? 三个边函数的值是增量更新的(每行每列一次加/减),覆盖测试只是一次 min3 >= 0;写入是 PlotPixelNaniteRasterizer.usf:1168)——原子写 VisBuffer 像素,没有状态切换、没有 quad 浪费。三种变体各有场景:RasterizeTri_Rect(通用)、RasterizeTri_RectSingle(单像素特化)、RasterizeTri_Scanline(扫描线)、RasterizeTri_Adaptive(自适应细分)。r.Nanite.ComputeRasterizationNaniteCullRaster.cpp:81)关掉 → 全部走 HW。

调度r.Nanite.AsyncRasterization(:53)让 SW 光栅在异步队列上与 HW 光栅并行(HardwareAndSoftwareOverlap);ERasterSchedulingNaniteCullRaster.h:25)三态:HardwareOnly / HardwareThenSoftware / HardwareAndSoftwareOverlap

细分(Tessellation,本 fork 已支持)r.Nanite.Tessellation(:95)+ AddPass_PatchSplitNaniteSplit.usfPatchSplit)+ 静态查找表 GTessellationTableTessellationTable.cpp)——把大三角形按 r.Nanite.DicingRate 细分成微多边形后走 SW patch 光栅(PatchRasterize)。这是”论文没有、5.x 后加”的能力(避坑:老资料说 Nanite 不支持细分,那是 5.0 时代的事)。

11.4 VisBuffer64:像素的”身份证”

HW 与 SW 殊途同归——都写 VisBuffer64(64 位/像素的 R32G32_UINT 纹理):

图 A3 VisBuffer64 像素格式(R32G32_UINT,按位拆解;精确布局见 NaniteRasterizer.usf 的 FVisBufferPixel)
R32(高 32 位)                G32(低 32 位)
┌──────────────────────────┐ ┌──────────────────────────┐
│ 深度(24~25 位,浮点前序) │ │ 可见簇索引(~21 位)     │
│                          │ ├──────────────────────────┤
│                          │ │ 三角形索引(~10 位)     │
│                          │ ├──────────────────────────┤
│                          │ │ 实例索引(~1 位?打包在  │
│                          │ │ 可见簇索引间接寻址里)    │
└──────────────────────────┘ └──────────────────────────┘
 深度+ID 一次原子写,后写的被深度测试淘汰(Near Plane 优化)
 (概念示意:簇索引 → FVisibleCluster[] → 实例 ID 在可见簇记录里)

关键设计簇索引进缓冲,实例信息不进——因为同一簇可能被多个实例复用(实例化),像素只记”簇+三角形”这个几何身份,着色阶段查 FVisibleCluster[] 才解析出具体实例(每帧的可见簇表本来就是帧内稳定的)。深度用”前序编码”让后写的更近像素能原子覆盖——写 VisBuffer 本身就是一次”深度测试”。

类比实验室:先发名单再发名片(复习 2.3)
VisBuffer64 就是那张”名单”:每个像素一行”X 号簇的第 Y 号三角形”。名单上不写”他穿什么”
(材质)也不写”他在哪个公司”(实例)——那些后面查。名单越短(64 位/像素),发名单
(光栅化)越快;名片(着色)按名单逐个补发,一个像素只发一次

11.5 源码考古:PlotPixel

源码考古:想看真东西的人进。

NaniteRasterizer.usf:1168

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void PlotPixel(FRaster Raster, int2 PixelCoord, uint PixelValue, float DeviceZ)
{
FVisBufferPixel Pixel = CreateVisBufferPixel(PixelCoord, PixelValue, DeviceZ);
#if VISUALIZE
Pixel.VisualizeValues = GetVisualizeValues(); // 调试可视化附加数据
#endif
#if VIRTUAL_TEXTURE_TARGET && NANITE_LATE_VSM_PAGE_TRANSLATION
// VSM 下延迟页翻译(本 fork 特性,虚拟阴影图页面)
Pixel.PhysicalPosition.xy = Pixel.Position;
...
#endif
Pixel.WriteOverdraw(); // 过绘制统计(r.Nanite.Visualize.Overdraw 用)
Pixel.Write(); // 原子写 VisBuffer64 + 深度淘汰
}

这段代码在干什么:SW 光栅每覆盖一个像素,最终都汇聚到 PlotPixelCreateVisBufferPixel
把深度和 ID 打包成 64 位值;Pixel.Write() 内部做”更近才覆盖”的原子写——**这就是 SW 光栅的
全部”状态”**:没有混合、没有模板、没有材质,只有深度+身份。WriteOverdraw 顺带记录
过绘制计数(可视化 Overdraw 模式的数据来源)。VSM 分支展示了本 fork 的一个扩展点:
同一个 VisBuffer 概念在虚拟阴影图里的变体。


第 12 章 深度导出与着色:把”是谁”变成”长什么样”

TL;DR|光栅化只写了”名单”(VisBuffer64),本章处理”名单”的下游:EmitDepthTargets
把 VisBuffer 的深度导出成场景深度/模板(与着色解耦,可用计算着色器做);DispatchBasePass
先把屏幕像素按材质分桶(ShadeBinning),再每个材质一个计算着色器按间接参数写 GBuffer——
没有传统意义上的”材质切换”。核心思路:先可见性后材质,一个像素只着色一次
类比:名单发完,按名单逐个补发名片,同一公司的人一起发(按材质分桶)

12.1 EmitDepthTargets:VisBuffer → 场景深度

光栅化在 VisBuffer 里写的是”每像素深度+身份”,但渲染管线的其余部分(光照、后处理、遮挡物构建)需要的是场景深度缓冲(Scene Depth)。EmitDepthTargetsNaniteComposition.cpp:279)把两者接起来:

1
2
3
4
VisBuffer64 ──▶ EmitDepthTargets ──▶ SceneDepth(R32 深度)
(NaniteComposition.cpp:279) ──▶ Stencil(可写接收贴花等标记)
──▶ Velocity(可选,运动矢量)
──▶ ShadingMask(R32_UINT,着色掩码)

像素着色器版本EmitSceneDepthPSNaniteExportGBuffer.usf:70)——逐像素:解 VisBuffer → UnpackVisPixel(DepthInt, ClusterIndex, TriIndex) → 有簇?写 SV_Depth +(可选)速度/掩码。计算着色器版本r.Nanite.ExportDepth 开启时用 FDepthExportCSNaniteDepthExport.usf)批量导出,把像素工作换成内存搬运,更快。

为什么要多此一举? ① 场景深度是”最终可见表面”的深度,而 VisBuffer 可能被 Post Pass 增量更新过——导出是”结算”;② 深度/模板是其他 pass(光照、半透明、后处理)的输入,格式必须统一;③ 深度与着色解耦:深度可以早导出(光栅化后立即),着色可以晚做(比如半透明之后),甚至着色分辨率可与深度不同(配合软件 VRS)。

12.2 DispatchBasePass:按材质分桶着色

深度导出后,传统渲染会直接跑 BasePass 像素着色器。Nanite 的 BasePass 是计算着色器管线NaniteShading.cpp:1667):

  1. ShadeBinningNaniteShadeBinning.usf:941):ShadingBinBuildCS 把每个 2×2 像素 quad 按”它属于哪个材质的三角形”分桶(BinShadingQuad 读 VisBuffer → 查三角形材质 → 原子累加到桶计数);ShadingGroupCS/ShadingBinReserveCS 预留空间、scatter 生成每桶的线程组参数;
  2. 逐材质计算着色器:对每个活跃桶,用 ShadingDispatchArgs 间接分派一个计算着色器(该材质专属:UniformBuffer + GBuffer 写掩码 BoundTargetMask);
  3. 每个线程负责一个 quad/像素:读 VisBuffer → 取簇/三角形/实例 → 解码顶点属性(位置量化反解、UV、法线切线)→ 重心插值(三角形三顶点的属性按重心坐标插到像素)→ 算材质 → 写 GBuffer(UAV)

没有”材质切换”的含义:传统 BasePass 是”画三角形时附带材质”,场景 100 个材质 = 100 次 PSO 切换 × 每模型一次。Nanite 是”先分桶,再按桶批量上同一个着色器”——**切换次数 = 活跃材质桶数(每帧一次),而不是”三角形数 × 材质数”**。

GBuffer 写掩码BoundTargetMask):每个材质声明自己写哪些 GBuffer 目标(Albedo/法线/粗糙度/……),不写的目标零开销——这也是 FNaniteShadingBinMeta::MaterialFlagsNaniteDefinitions.h:637-657)打包的东西(低 24 位材质标志 + 高 8 位写掩码)。

图 F17 "名单 → 名片"两阶段与深度/着色解耦
                    ┌────────────────────────────────────────────────┐
                    │ 光栅化阶段(第 11 章)                            │
                    │ 每个可见三角形 → PlotPixel → VisBuffer64(64位/像素)│
                    └────────────────────────┬───────────────────────┘
                                             ▼
        ┌────────────────────────────────────────────────────────────┐
        │ EmitDepthTargets(NaniteComposition.cpp:279)                │
        │ VisBuffer ──▶ SceneDepth + Stencil(光照/遮挡物/HZB 消费)    │
        └────────────────────────────────────────────────────────────┘
        ┌────────────────────────────────────────────────────────────┐
        │ DispatchBasePass(NaniteShading.cpp:1667)                   │
        │ ① ShadeBinning:像素 quad 按材质分桶                          │
        │ ② 每个材质一个 CS,按 indirect args 分派                      │
        │ ③ 线程:读 VisBuffer → 解码三角形 → 插值 → 材质 → 写 GBuffer   │
        └────────────────────────────────────────────────────────────┘
                     深度先到、颜色后到 → 半透明/后处理无需等 BasePass

材质分类全景(呼应 3.5 与 9.3):

材质类型 判定(IsNaniteMaterial*ProgrammableNaniteDefinitions.h:421-434 路径
普通(不透明,无 WPO) bVertexProgrammable == false && bPixelProgrammable == false 固定函数光栅 + 标准着色桶
像素可编程(Masked/PDO) PIXEL_DISCARD/PIXEL_DEPTH_OFFSETNANITE_MATERIAL_PIXEL_PROGRAMMABLE_FLAGS 光栅时逐像素判(SW 或 HW 可编程 PS)
顶点可编程(WPO/蒙皮/细分) WPO/DISPLACEMENT/VERTEX_UVS/FIRST_PERSON_LERPNANITE_MATERIAL_VERTEX_PROGRAMMABLE_FLAGS HW 可编程光栅(VS/MS 解码+变形)或 SW compute
Voxel / Curve(本 fork) NANITE_MATERIAL_FLAG_VOXEL/CURVE 专用 ShadingBin + 专用光栅路径

避坑:Masked 材质在 Nanite 里”昂贵”(需要在光栅阶段逐像素求值),r.Nanite.Visualize.PixelProgrammable 模式可以一眼看出哪些材质走了可编程路径——调优时优先把它们换成普通材质。

12.3 与延迟渲染的关系:无缝衔接

Nanite 的 BasePass 产出标准 GBufferFSceneTextures 的 GBufferA/B/C/……),所以后续光照(Lumen/SSR/反射探针)、后处理、半透明全部复用传统延迟管线。Nanite 只是”几何与材质的制造者”换了实现,场景纹理与光照管线不变——这是它架构成功的关键:它不是新渲染器,而是新”几何入口”。

12.4 源码考古:DispatchBasePass 与 ShadeBinning

源码考古:想看真东西的人进。

① 着色入口NaniteShading.cpp:1667(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void DispatchBasePass(FRDGBuilder& GraphBuilder, FNaniteShadingCommands& ShadingCommands, ...)
{
RDG_EVENT_SCOPE(GraphBuilder, "Nanite::BasePass");

// 等待 CPU 预构建的着色命令(并行于 GPU 渲染构建)
ShadingCommands.SetupTask.Wait();
ShadingCommands.BuildCommandsTask.Wait();

if (ShadingCommands.NumCommands == 0u) return; // 没有任何像素需要着色

// 校验材质 UniformBuffer 是最新的(防止材质参数改了一半)
if (GNaniteValidateShadingBinUB && ShadingCommands.bBindless) { ... }

// VisBuffer 与调试缓冲
FRDGTextureRef VisBuffer64 = RasterResults.VisBuffer64;
...
}

这段代码在干什么SetupTask/BuildCommandsTask 是 CPU 侧并行预构建的着色命令表
(材质桶 → 着色器 → 参数的映射),GPU 提交时只需等待+分派。NumCommands == 0 提前返回——
没有任何 Nanite 像素时的零开销路径。着色命令表的构建与渲染管线并行,是
r.Nanite.ParallelBasePassBuild 的成果。

② 分桶内核NaniteShadeBinning.usf:941

1
2
3
4
5
6
7
8
9
10
11
12
[numthreads(SHADING_BIN_TILE_THREADS, 1, 1)]
void ShadingBinBuildCS(uint ThreadIndex : SV_GroupIndex, uint2 GroupId : SV_GroupID)
{
uint2 Coord = GroupId.xy * SHADING_BIN_TILE_SIZE; // tile 坐标
Coord += ZOrder2D(ThreadIndex, 3); // Z 序(莫顿码)访问 → 局部性好

// 单 wave 与多 wave 两种变体(编译器常量分支)
const bool bSingleWave = WaveGetLaneCount() >= SHADING_BIN_TILE_THREADS;
BRANCH
if (bSingleWave) BinShadingQuad<true>(Coord, ThreadIndex);
else BinShadingQuad<false>(Coord, ThreadIndex);
}

这段代码在干什么ZOrder2D 让线程按莫顿序(Z 曲线)扫描屏幕——同一 tile 内的像素在
内存中连续,读 VisBuffer 时缓存命中率高。BinShadingQuad 内部:读 quad 的 4 个 VisBuffer
像素 → 查三角形材质 → InterlockedAdd 桶计数 → scatter。这就是”先可见性后材质”的
分拣机
:把”谁该被着色”(VisBuffer 有值的像素)与”用什么着色”(材质桶)配对。

③ 深度导出NaniteExportGBuffer.usf:70(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void EmitSceneDepthPS(in float4 SvPosition : SV_Position, ...)
{
uint2 PixelPos = (uint2)SvPosition.xy;
UlongType VisPixel = VisBuffer64[PixelPos]; // 读"名单"

uint DepthInt = 0; uint VisibleClusterIndex = 0; uint TriIndex = 0;
UnpackVisPixel(VisPixel, DepthInt, VisibleClusterIndex, TriIndex); // 解出身份

if (IsPixelInRect(PixelPos, ViewRect) && VisibleClusterIndex != 0xFFFFFFFF)
{
// 有簇覆盖 → 输出深度(SV_Depth),可选:速度/着色掩码
...
}
}

这段代码在干什么:深度导出的像素着色器只有 10 行核心逻辑——解 VisBuffer、写深度
注意 VisibleClusterIndex != 0xFFFFFFFF:VisBuffer 里”没有簇”的像素(背景)不写深度,
场景深度在导出前已被 Nanite::EmitDepthTargets 之外的系统清除逻辑处理(FastTileClear 等)。
深度/速度/掩码三个输出可在同一 pass 里组合(VELOCITY_EXPORT/SHADING_MASK_EXPORT 排列),
一个 pass 三份产出,GPU 并行效率高。

第 13 章 编辑器与内容制作:把开关打开之后

TL;DR|在静态网格资产的 Nanite 设置面板里勾选 Enable Nanite Support,构建后资产里
就有两份几何:Nanite 数据 + Fallback 网格(给不支持 Nanite 的平台)。设置面板对应
FMeshNaniteSettings(bExplicitTangents/bLerpUVs/精度/常驻预算等)。编辑器提供
43 种可视化模式VMI_VisualizeNanite)——从 Triangles/Clusters 到 Pages/Hierarchy,
都是”体检报告”,帮你诊断每个像素走了什么路径。
类比:可视化模式 = 体检报告,每个模式一项检查

13.1 启用与设置:面板上每个选项是什么

静态网格资产的 Details → Nanite Settings 面板(FNaniteStaticMeshLayoutStaticMeshEditorTools.cpp 的自定义布局)直接映射 FMeshNaniteSettingsEngineTypes.h:3281):

面板选项 字段 实用含义
Enable Nanite Support bEnabled 总开关。勾选触发构建
Explicit Tangents bExplicitTangents 显式存切线(体积↑)。默认关——隐式推导够用
Interpolate UVs bLerpUVs UV 简化插值。UV 存了非插值数据(如索引)时必须关
Position Precision PositionPrecision 位置量化步长 2^(-n) cm。默认自动
Normal/Tangent Precision NormalPrecision/TangentPrecision 法线/切线量化位数。-1 = 自动
Target Minimum Residency TargetMinimumResidencyInKB 常驻内存预算(KB)。0=只驻根页,MAX=全驻留
Keep Percent Triangles KeepPercentTriangles 源网格保留比例(1.0 = 不减面)
Generate Fallback Mesh GenerateFallback 生成兜底网格:PlatformDefault 让引擎按平台决定
Fallback Target/Percent/Error FallbackTarget 兜底网格的减面目标

Fallback 网格到底是什么? BuildInternalBuildFallbackMeshFromIntermediateNaniteBuilder.cpp:983,函数定义 :779)从同一份中间资源生成传统网格(带 LOD),用于:① 不支持 Nanite 的平台/管线;② 渲染器需要传统几何的场合(如自定义深度、某些后期)。启用 Nanite 不等于放弃传统网格——资产里两者共存,引擎自动选择。

骨骼网格(Skeletal Mesh)USkeletalMesh 也有 Nanite 设置(SkeletalMesh.h),本 fork 已支持蒙皮路径(SkeletalRenderNanite.cpp),启用方式同静态网格。

13.2 可视化模式:43 项”体检报告”

视口菜单 Lit → Nanite Visualization →(命令 VMI_VisualizeNaniteEditorViewportClient.cpp:3298)提供了本仓库的 43 种模式FNaniteVisualizationData::InitializeNaniteVisualizationData.cpp:27-79):

Standard 组(13 种):Mask、Triangles、Patches、Clusters、Primitives、Instances、Overdraw、LightmapUV、EvaluateWPO、PixelProgrammable、Tessellation、RasterBins、ShadingBins

Advanced 组(29 种):ShadowCasters、FarShadowCasters、Picking、Groups、Pages、Hierarchy、RasterMode、SceneZMin/Max/Delta/Decoded、MaterialCount/Mode/Index、HitProxyID、LightmapUVIndex/DataIndex、PositionBits、ShadingWriteMask、NoDerivativeOps、FastClearTiles、DisplacementScale、VertexColor、MeshPaintTexture、Voxels、Assemblies、Skinning、Curves + Overview

常用模式怎么读

  • Triangles/Clusters:看”当前用的 LOD 精度”——颜色越亮越密。近处亮、远处暗是正常梯度;如果远处也亮 = LOD 没生效(检查 MaxPixelsPerEdge);
  • Overdraw:看”同一个像素被画了几次”——红色 = 过绘制高(贴脸镜头常见),帮助判断是否需要调 r.Nanite.MaxPixelsPerEdge
  • Pages:看”页面驻留状态”——红/蓝区分缺页与驻留,诊断流送瓶颈(配合 r.Nanite.ShowStats 的 Pages 统计);
  • RasterBins/ShadingBins:看材质分桶情况——bin 数量爆炸 = 材质种类太多或可编程材质过多;
  • PixelProgrammable:找出走”逐像素可编程”路径的材质(Masked 等)——它们是性能热点候选;
  • Hierarchy:看层级树遍历深度,调试”远处也在疯狂细分”的问题。

13.3 编辑器构建与校验

  • 触发:保存资产或手动 “Apply Changes”;构建结果进 DDC;
  • 失败排查:构建失败通常是”内部限制超限”(BuildInternalEncode 失败会打警告日志)——常见原因:簇数/页面数超预算、输入网格非法(退化三角形过多、顶点属性缺失);
  • 脚本 APIStaticMeshEditorSubsystemStaticMeshEditorSubsystem.h)提供 GetNaniteSettings()ImportNaniteHiResMesh() 等——程序化批量启用的入口。

13.4 源码考古:可视化模式注册

源码考古:想看真东西的人进。

NaniteVisualizationData.cpp:27(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void FNaniteVisualizationData::Initialize()
{
if (bIsInitialized) return;
bIsInitialized = true;
...
AddVisualizationMode("Overview", FModeType::Overview, ...); // :31

// Standard
AddVisualizationMode("Mask", FModeType::Standard, ...); // :33
AddVisualizationMode("Triangles", FModeType::Standard, ...);
AddVisualizationMode("Clusters", FModeType::Standard, ...);
AddVisualizationMode("Overdraw", FModeType::Standard, ...);
...
// Advanced
AddVisualizationMode("Pages", FModeType::Advanced, ...); // :46
AddVisualizationMode("Hierarchy", FModeType::Advanced, ...);
AddVisualizationMode("Voxels", FModeType::Advanced, ...);
...
}

这段代码在干什么AddVisualizationMode 把模式名注册进一张表,每个模式对应
NANITE_VISUALIZE_* 枚举值(NaniteDefinitions.h:298-339
本仓库共 42 个枚举值,0 = Overview)。模式本质是传给渲染器的调试标志:光栅化/着色 shader
#if VISUALIZE 分支决定”这个像素显示什么”。所以可视化模式是”零成本开关”——不开时
着色器根本没有这些分支的代码路径。


第 14 章 平台支持与性能调优

TL;DR|Nanite 需要 SM6(SM5 一律不支持,自动回退 fallback);平台开关在
DataDrivenPlatformInfo.inibSupportsNanite。调优三板斧:r.Nanite.ShowStats 看统计 →
可视化模式定位问题 → 对症调 CVar
。最常用的旋钮:MaxPixelsPerEdge(质量↔性能)、
MinPixelsPerEdgeHW(SW/HW 分流)、Culling.TwoPass(遮挡严格度)、Streaming.*(带宽)。

14.1 平台开关:bSupportsNanite

引擎用数据驱动配置决定平台是否支持 Nanite(Engine/Config/<Platform>/DataDrivenPlatformInfo.inibSupportsNanite):

平台 配置位置 状态
Windows D3D12(PCD3D_SM6) Windows/DataDrivenPlatformInfo.ini ✅ 支持(SM6 段 true;SM5 段 false)
Windows D3D11(PCD3D_SM5) 同上 ❌ 回退 fallback
Vulkan PC(SM6) VulkanPC/DataDrivenPlatformInfo.ini ✅ 支持(SM5 段 false)
Mac(Metal SM6) Mac/DataDrivenPlatformInfo.ini ✅ 支持(SM5 段 false)
iOS(Metal SM6,A14+) IOS/DataDrivenPlatformInfo.ini ✅ 实验性支持(本 fork)
其他 各平台 ini 未声明 = 回退 fallback

避坑bSupportsNanite = true 不等于”自动启用”——资产仍需勾选 Nanite 设置;另外
本 fork 是移动优化版(README 记录 Phase 3 为”Nanite 移动端适配、VRS 启用”),
你在公开版 UE 里看到的行为可能与此不同。以你自己仓库的 ini 为准。

14.2 CVar 速查表

(定义位置均为本仓库快照;全部 r.Nanite.* 声明集中在 NaniteCullRaster.cpp:53-379NaniteShared.cpp

LOD 与质量

CVar 默认 作用
r.Nanite.MaxPixelsPerEdge 1.0 LOD 选择阈值(像素/边)。调大 → 更粗(快),调小 → 更细(慢)
r.Nanite.MinPixelsPerEdgeHW 32 边长小于此值切 SW 光栅
r.Nanite.DicingRate 2.0 细分目标(像素/微多边形)

光栅路径

CVar 默认 作用
r.Nanite.ComputeRasterization 1 SW 光栅总开关(0 = 全 HW)
r.Nanite.ProgrammableRaster 1 可编程光栅(WPO/蒙皮等);0 = 固定函数
r.Nanite.AsyncRasterization 1 SW 与 HW 异步重叠(.ShadowDepths/.CustomPass/.LumenMeshCards 子开关)
r.Nanite.Tessellation 1 曲面细分总开关
r.Nanite.MeshShaderRasterization / .PrimShaderRasterization 1/1 HW 路径选择(Mesh Shader / Primitive Shader)
r.Nanite.DepthBucketing 1 深度分桶优化

剔除

CVar 默认 作用
r.Nanite.Culling.HZB 1 HZB 遮挡剔除总开关
r.Nanite.Culling.TwoPass 1 两遍遮挡(Main+Post);0 = 单遍(快但漏遮挡多)
r.Nanite.Culling.Frustum / .DrawDistance / .MinLOD 1 视锥 / 距离 / 最小 LOD 剔除
r.Nanite.PersistentThreadsCulling 0 持久线程剔除模式(实验)
r.Nanite.StaticGeometryInstanceCull 0 静态几何专用实例剔除路径

着色

CVar 默认 作用
r.Nanite.ExportDepth 1 深度用计算着色器导出(vs 像素着色器)
r.Nanite.FastVisBufferClear 1 VisBuffer 快速清除(tile 级)
r.Nanite.Bundle.Shading 0 Shader Bundle 合并着色分派(Bindless)
r.Nanite.SoftwareVRS 1 软件可变速率着色

流送NaniteStreamingManager.cpp):r.Nanite.Streaming.MaxPendingPages.Async.BandwidthLimit.MaxPageInstallsPerFrame——见第 8 章。

统计与调试r.Nanite.ShowStats(ShaderPrint 输出每帧统计)、r.Nanite.StatsFilterr.Nanite.Visualize.*(各可视化模式参数)、r.Nanite.MaxVisibleClusters / MaxCandidateClusters / MaxNodes(容量预算)。

14.3 调优工作流:三板斧

1
2
3
4
5
6
7
8
9
10
11
12
1. 看数字    r.Nanite.ShowStats 1
关键统计:NumVisibleClusters / NumNanitePixels / NumShadedPixels
Main/Post 的 VisitedNodes、Indirections、页面驻留率
→ 判断瓶颈在剔除(visited 太多)还是光栅(pixels 太多)还是着色(shaded 太多)
2. 看画面 Lit → Nanite Visualization → 对症模式
- 掉帧且过绘制高(Overdraw 红)→ 调大 MaxPixelsPerEdge
- 远处跳变(Triangles 突变)→ 检查流送驻留(Pages 模式)+ 加大常驻预算
- 材质热点(PixelProgrammable 亮)→ 换普通材质
3. 调参数 每次只动一个旋钮,用 ShowStats 前后对比
- 剔除过重 → Culling.TwoPass 0 或 Culling.HZB 0(先确认遮挡不重)
- 流送卡顿 → Streaming.BandwidthLimit 上调 + TargetMinimumResidencyInKB 加大
- SW 负担重 → MinPixelsPerEdgeHW 调小(更多走 HW)

14.4 常见坑清单

  1. 材质不支持Translucent/Masked 混用导致部分路径失效——用可视化模式确认哪些材质走了可编程路径;
  2. UV 精度问题bLerpUVs 误关(UV 可插值却关了)→ 简化误差不含 UV → 贴图拉伸;
  3. 远距离闪烁/跳变:流送带宽不足(页面跟不上)→ 加大常驻预算或调 MaxPixelsPerEdge
  4. 显存/内存预算rhi.dumpresourcememory summary name=Nanite(BaseEngine.ini 里注册的调试命令)看 Nanite 实际占用;
  5. 与 TAA/TSR 配合:VisBuffer 方案依赖时域抗锯齿(TSR/TAA)——关掉会看到闪烁(1 像素误差的 LOD 切换无时域平滑);
  6. Fallback 网格质量GenerateFallbackPlatformDefault 时,非 Nanite 平台看到的模型是 fallback——记得检查它的质量。

第 15 章 局限、替代方案与展望

TL;DR:Nanite 不是银弹:只适合静态/刚性几何(蒙皮与变形靠实验性支持),材质面窄
(不透明/遮罩为主),需要 SM6。但它的”虚拟化+GPU 驱动+离线重投入”架构方向已经改写了
实时渲染的规则——从 5.0 到本 fork,它在不断吃掉更多的边界(细分、蒙皮、半透明、Voxel)。
阅读进阶:论文 → Into the Bytecode 系列 → 直接读源码(附录 A 指路)。

15.1 局限与”我该怎么办”

局限 症状 对策
材质仅 Opaque/Masked(半透明实验性) 玻璃/粒子效果不能用 Nanite 半透明物体走传统网格;本 fork 已有 Translucency 实验路径
不能蒙皮动画(实验性支持) 角色动画崩坏 角色走传统 Skeletal Mesh;静态道具/场景用 Nanite
无 MSAA/VR/前向 抗锯齿方案不同 用 TSR/TAA;VR 项目整体评估
需要 SM6 老显卡/老平台回退 fallback 网格自动顶上(记得检查质量)
流送依赖磁盘带宽 高速移动时远山”糊” 加大 TargetMinimumResidencyInKB;对重要资产全驻留
内存随”可见细节”增长 大世界仍可能爆显存 配合 World Partition/Level Streaming 做粗粒度剔除
编辑/构建时间长 修改高模后等待 用 DDC;控制输入精度(PositionPrecision 别过猛)

15.2 演进时间线(论文 vs 本 fork)

版本 能力变化
UE 5.0(论文基线) 静态网格,无变形、无细分、无半透明
5.1 World Position Offset、程序化光栅
5.2 曲面细分(实验)、骨骼网格(实验)
5.3~5.8 半透明(实验)、光追支持(实验)、装配(Assembly)、实例层级剔除、持久线程剔除、Shader Bundle
本 fork(5.9 移动优化版) Voxel 光栅、曲线(Curve)光栅、更完整的蒙皮/装配、移动端适配——许多能力是公开版没有或未完成的

避坑:讨论 Nanite 时先说明版本——“Nanite 不支持 X”这句话在 5.0 成立、在 5.9 可能已经
不成立。本文所有”支持/不支持”描述均基于本仓库快照(2026-08-16)。

15.3 阅读进阶路线

  1. 论文:《A Deep Dive into Nanite Virtualized Geometry》(SIGGRAPH 2021 Advances in Real-Time Rendering,Brian Karis / Rune Stubbe / Graham Wihlidal)—— 155 页,最权威的架构解释;论文的”单 DrawIndirect”等表述与当前代码有差异(见 10.4 避坑);
  2. Into the Bytecode 系列(Marco Giustino):按主题深挖(Cluster、Culling、流送、Rasterizer),与本文各章可对照阅读(注意:它基于 UE 5.0~5.2,名字与结构有差异);
  3. Epic 官方文档:Nanite Technical Details、Nanite Virtualized Geometry 概述;
  4. 源码:从附录 A 的源码地图出发——先读 NaniteDefinitions.h(宪法),再读 DrawGeometry(骨骼),再按兴趣钻入各子系统;
  5. 自测:做完附录 D 的练习,再去 r.Nanite.ShowStats 1 里验证你的每一个猜测。

附录 A 源码地图

全部路径基于本仓库快照(2026-08-16,HEAD 2c7bb82d)。行号只用于定位;代码演进后以函数名为准。
阅读顺序建议:先 NaniteDefinitions.h(常量宪法)→ NaniteResources.h(数据结构)→
NaniteBuilder.cpp:BuildInternal(构建)→ NaniteCullRaster.cpp:DrawGeometry(渲染)。

A.1 构建期(Developer/NaniteBuilder)

文件 关键内容
Public/NaniteBuilder.h IBuilderModule 接口(Build/BuildAssemblyPart/BuildFallbackMesh)
Private/NaniteBuilder.cpp BuildInternal :936——构建总编排(Ch4)
Private/Cluster.h FMaterialRange :193、FCluster :213(ClusterSize=128)、FVertexArray
Private/Cluster.cpp SimplifyTriangles :955、SimplifyCurves :809、Split :1154、BuildMaterialRanges :2289、Bound/Voxelize
Private/ClusterDAG.h FClusterGroup :33FClusterDAG :65(AddMesh/ReduceMesh/ReduceGroup/FindCut)
Private/ClusterDAG.cpp 分组与递归简化实现(GroupTriangleClusters 等)
Private/GraphPartitioner.h/.cpp METIS 图分割封装(NewGraph/AddLocalityLinks/Partition)
Private/Encode/NaniteEncode.cpp Encode() :1444——编码总流水线(Ch6)
Private/Encode/NaniteEncodePageAssignment.cpp AssignClustersToPages :121、CalculateMaxRootPages
Private/Encode/NaniteEncodeHierarchy.cpp BuildHierarchies :838(DAG → 扁平 FPackedHierarchyNode)
Private/Encode/NaniteEncodeFixup.cpp CalculatePageDependenciesAndFixups :16
Private/Encode/NaniteEncodeGeometryData.cpp 几何/材质/条带/顶点复用/蒙皮/RT 编码子阶段
Private/NaniteIntermediateResources.h 构建中间资源(FClusterDAG 快照,DDC 缓存用)

A.2 运行时数据与流送(Runtime/Engine)

文件 关键内容
Public/Rendering/NaniteResources.h FPackedHierarchyNode :59FPackedCluster :108FResources :455(Ch7)
Public/Rendering/NaniteResources.cpp InitResources/ReleaseResources/Serialize、层级安装
Public/Rendering/NaniteStreamingManager.h FStreamingManager :69、GStreamingManager :413(Ch8)
Private/Rendering/NaniteStreamingManager.cpp BeginAsyncUpdate :2212、AsyncUpdate :2758、EndAsyncUpdate :2975
Internal/Nanite/NaniteFixupChunk.h FFixupChunk(页面修复块,Ch6/8)
Private/Nanite/NaniteStreamingPageUploader.cpp/.h FStreamingPageUploader(页面转码上传)
Private/Nanite/NaniteReadbackManager.h FReadbackManager(GPU 请求回读,环形缓冲)
Private/Nanite/NaniteStreamingShared.h FGPUStreamingRequest(GPU→CPU 请求格式)
Private/Nanite/NaniteOrderedScatterUpdater.cpp/.h FOrderedScatterUpdater(有序 scatter 写 GPU 缓冲)
Public/NaniteSceneProxy.h FNaniteSceneProxy(Primitive 与层级绑定的桥,NaniteHierarchyOffset)
Public/NaniteVertexFactory.h 顶点工厂(运行时从打包数据解码顶点)
Private/NaniteVisualizationData.cpp 43 种可视化模式注册(:27-79)
Private/SkeletalRenderNanite.cpp 骨骼网格 Nanite 渲染路径
Private/NaniteAssemblyDataBuilder.cpp 装配(Assembly)数据构建

A.3 渲染期(Runtime/Renderer/Private/Nanite)

文件 关键内容
NaniteCullRaster.cpp FRenderer::DrawGeometry :6818、FInstanceHierarchyDriver :3259、FMicropolyRasterizeCS :1517、CVar 定义 :53-379、CalculateSafeRasterizerArgs :4902
NaniteShared.h FPackedView :58、ERasterHardwarePath :356、FNaniteRasterPipeline :618
NaniteVisibility.cpp FNaniteVisibility::BeginVisibilityQuery :352(CPU 材质预判)
NaniteMaterials.h FNaniteMaterialSlot :15(RasterBin/ShadingBin 映射)
NaniteShading.cpp DispatchBasePass :1667、ShadeBinning、DispatchLumenMeshCapturePass
NaniteComposition.cpp EmitDepthTargets :279、FinalizeCustomDepthStencil :731
NaniteRasterBinning.cpp/NaniteRasterizer.cpp Binning 与 SW 光栅的 C++ 侧
NaniteDrawList.cpp PSO 预缓存收集器
NaniteTranslucency.cpp 半透明管线(实验)
NaniteRayTracing.cpp 光追 BLAS 构建与遍历
NaniteVisualize.cpp 可视化/拾取调试
NaniteEditor.cpp 编辑器模式(异步 DrawList 更新)
Voxel.cpp Voxel 支持(本 fork 特性)
NaniteCurveRaster.inl 曲线平铺光栅化(本 fork 特性)

A.4 着色器(Engine/Shaders)

文件 关键内容
Shared/NaniteDefinitions.h 常量宪法(128/4/页大小/位预算/标志/可视化 ID),C++/HLSL 共用
Private/Nanite/NaniteClusterCulling.usf SmallEnoughToDraw :287、NodeAndClusterCull :968、CalculateSafeRasterizerArgs :982
Private/Nanite/NaniteInstanceCulling.usf InstanceCull :177
Private/Nanite/NaniteInstanceHierarchyCulling.usf 实例层级剔除(新特性)
Private/Nanite/NaniteHierarchyTraversal.ush PersistentNodeAndClusterCull、NodeCull、ClusterCull、流送请求生成
Private/Nanite/NaniteCullingCommon.ush FBoxCull(Frustum :527 / HZB :595)
Private/Nanite/NaniteHZBCull.ush HZB 采样(IsVisibleHZB)
Private/Nanite/NaniteRasterBinning.usf RasterBinBuild :519
Private/Nanite/NaniteRasterizer.usf/.ush FMicropolyRasterizeCS、ClusterRasterize :500、RasterizeTri_Rect :132、PlotPixel :1168
Private/Nanite/NaniteShadeBinning.usf ShadingBinBuildCS :941
Private/Nanite/NaniteExportGBuffer.usf EmitSceneDepthPS :70、EmitShadowMapPS :229
Private/Nanite/NaniteDepthExport.usf FDepthExportCS(计算深度导出)
Private/Nanite/NaniteDataDecode.ush FVisibleCluster :48(可见簇格式)、簇/属性/深度解码
Private/Nanite/NaniteSplit.usf PatchSplit(曲面细分)
Private/Nanite/NaniteStreaming.usf/.ush 流送请求/页表
Private/Nanite/NaniteVisualize.usf 可视化着色器

附录 B 术语表(中英对照)

英文 中文(本文用词) 一句话解释 首次出现
Nanite (不译) UE5 的虚拟几何体系统 Ch1
Cluster ≤128 三角形的几何单元,构建/剔除/光栅/流送的原子单位 Ch2
Page 流送单位:根页 32KB、流送页 64KB Ch2
DAG 有向无环图 簇的 LOD 层级结构(父簇可被多组共享) Ch2
LOD 层级细节 按需选择不同精度的几何 Ch1
Cut(切面) 切面 运行时在 LOD 树上选出的”本帧绘制层” Ch2
LODError 简化误差 简化后表面偏离原表面的最大距离 Ch5
ParentLODError 父级误差 组的共享 LOD 阈值(铁律三) Ch5
LODBounds LOD 包围球 LOD 判定用的包围球 Ch5
Visibility Buffer 可见性缓冲 64 位/像素的”深度+身份”缓冲 Ch2
VisBuffer64 (同左) R32G32_UINT 的可见性缓冲 Ch11
HZB 层级深度缓冲 深度图金字塔,快速遮挡查询 Ch2
Frustum Culling 视锥剔除 视锥外的几何不画 Ch10
Occlusion Culling 遮挡剔除 被挡住的几何不画 Ch10
Two-Pass Occlusion 两遍遮挡 上一帧 HZB 测主遍、本帧 HZB 测后遍 Ch2
RasterBin (不译) “怎么光栅化”的桶(管线特征哈希) Ch9
ShadingBin (不译) “怎么着色”的桶(每材质一个) Ch9
Fallback 兜底 不支持 Nanite 时用的传统网格 Ch4
FResources (不译) 一个 Nanite 资产的运行时资源 Ch4
FPackedCluster (不译) 128 字节的 GPU 簇格式 Ch7
FPackedHierarchyNode (不译) GPU 层级节点(4 子节点一组) Ch7
Streaming 流送 按需加载几何页面 Ch8
Fixup 修复块 页面装卸时修正引用关系的数据 Ch6
FGPUStreamingRequest (不译) GPU 写的流送请求 Ch8
LRU 最近最少使用 页面淘汰策略 Ch8
Quantization 量化 用定点位宽存数据(精度换体积) Ch6
Binning 分桶 按特征把数据归组 Ch9
SW/HW Rasterize 软件/硬件光栅化 计算着色器 vs 图形管线画三角形 Ch11
Triangle Strip 三角形条带 复用顶点的索引压缩 Ch6
Transcode 转码 页面从压缩格式解为 GPU 格式 Ch6
Mesh Shader (不译) 新一代 GPU 可编程几何管线 Ch11
PSO 管线状态对象 GPU 管线配置(着色器/状态组合) Ch9
Indirect Dispatch 间接分派 参数由 GPU 生成的分派 Ch10
GPUScene (不译) GPU 侧的场景实例/图元数据库 Ch10
WPO 世界位置偏移 顶点在着色器里被移动 Ch9
PDO 像素深度偏移 像素深度在着色器里被移动 Ch12
Masked 遮罩 按 alpha 裁剪的材质 Ch3
SM6 Shader Model 6 DX12 高级着色器模型(Nanite 的最低要求) Ch14
VSM 虚拟阴影图 虚拟化的阴影图(本 fork 特性) Ch10
DDC 派生数据缓存 构建结果缓存 Ch4
Fallback Mesh 兜底网格 传统网格副本 Ch13

附录 C 参考资料

权威论文(最推荐)

  1. Brian Karis, Rune Stubbe, Graham Wihlidal. A Deep Dive into Nanite Virtualized Geometry(SIGGRAPH 2021 Advances in Real-Time Rendering in Games)—— 演讲视频 + 155 页讲义 PDF,Nanite 架构的第一手权威来源。本文 Ch1/Ch3/Ch6 的决策与数字(26GB→4.61GB、14.4 字节/三角形)均出自此。
  2. Epic Games 官方文档:Nanite Virtualized GeometryNanite Technical Details(dev.epicgames.com,含 5.x 各版本的更新说明)。

博客与社区

  1. Marco Giustino, Into the Bytecode 系列(Nanite 主题深度剖析)—— 注意其基于 UE 5.0~5.2,类名与结构与本仓库有差异(如 FNaniteStreamingManager 已改名)。
  2. 社区中文解读系列(如 ttod 的《剖析虚幻渲染体系》相关章节)—— 适合对照阅读,细节请以本文的源码引用为准。

本仓库(第一手)

  1. Engine/Shaders/Shared/NaniteDefinitions.h —— 全部常量(先读它!)
  2. Engine/Source/Developer/NaniteBuilder/Private/NaniteBuilder.cpp:936 —— 构建入口
  3. Engine/Source/Runtime/Engine/Public/Rendering/NaniteResources.h —— 数据结构
  4. Engine/Source/Runtime/Renderer/Private/Nanite/NaniteCullRaster.cpp:6818 —— 渲染核心

附录 D 自测练习

入门级(30 秒通道毕业)

  1. 用三句话向同事解释 Nanite 是什么(要求用上”虚拟化”和”GPU 驱动”两个词)。
  2. 说出四大支柱,并为每一个配一个类比。
  3. r.Nanite.MaxPixelsPerEdge 调大和调小分别发生什么?为什么?

进阶级(标准通道毕业)

  1. 画一张”页面安装”时的完整流程,标注:请求产生 → 回读 → 排序 → 读盘 → fixup → 上传。
  2. 解释为什么两遍遮挡”只可能多画,不可能少画”,并用它推导 Post Pass 的输入是什么。
  3. 一个 100 万三角形的模型,启用 Nanite 后显存占用是”100 万 × 128 字节”吗?为什么?
  4. 为什么微三角形走软件光栅更快?用 2×2 quad 的利用率解释。
  5. bLerpUVs 在什么情况下必须关闭?关闭后 LOD 选择会发生什么变化?

源码级(源码考古通道毕业)

  1. SmallEnoughToDraw(NaniteClusterCulling.usf:287)里,找到”投影边长 > 误差 × LODScale”这一行,解释每个变量的来源。
  2. FPackedCluster(NaniteResources.h:108)里找出所有”位打包”字段,计算它们加起来用了多少个 dword,与 128 字节是否吻合。
  3. 追踪一次页面安装:从 FStreamingManager::AsyncUpdate(NaniteStreamingManager.cpp:2758)出发,列出”读盘 → 安装 → fixup → 上传”各自的函数名。
  4. 修改实验:把 r.Nanite.MinPixelsPerEdgeHW 设为 10000,观察 ShowStats 里 SW/HW 比例与帧耗时变化,解释原因。