从”普通程序员能听懂”到”能改 UE 源码”——一份基于源码逐行考证的 Nanite 深度讲解
- 文档版本:1.0(2026-08-16)
- 源码快照:本地仓库
d:\Project\GameDevelop\UnrealEngine,UE 5.9 移动优化 fork,HEAD2c7bb82d - 写作原则:文中所有”源码考古”块引用的文件路径、行号、函数名均在本仓库中实际验证过;行号只用于定位,请以
路径:函数名为准 - 符号说明:
簇=Cluster,页=Page,层级节点=Hierarchy Node,可见性缓冲=Visibility Buffer,RasterBin/ShadingBin不翻译
第 0 章 导读:这份文档怎么读
TL;DR|这份文档把 Nanite(虚幻引擎 5 的虚拟几何体系统)从”它是什么”一路讲到”源码长什么样”。
全程用普通程序员能懂的类比开路(乐高积木、地图缩放、虚拟内存),每条关键结论都能在
UE 源码里找到落点。有三个深度档位:30 秒看懂、3 小时学会、1 天读源码。
0.1 目标读者与前置知识
这份文档的目标读者是普通程序员:会写 C++、用过或听说过 UE 的资产管线、知道”渲染管线”四个字但不必是图形学专家。如果你想听懂”深度缓冲””延迟渲染”这类词但一时想不起细节,文档会在第一次出现时用一句话解释。
真正的前置知识只有三个,都非常”程序员”:
- 数据要放在显存里才能被 GPU 读——显存有限,和内存有限是一个道理;
- 三角形是 3D 图形的最小单元——一个模型 = 一堆三角形;三角形越小、越多,模型越精细;
- 每帧都要重新决定画什么——游戏每秒渲染 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 加载)。
读图方法:把上图从左到右读一遍,再问自己三个问题——“数据是谁造出来的?”(构建期)、”数据放在哪、怎么变出需要的那部分?”(流送)、”每帧这些数据怎么变成屏幕上的像素?”(渲染+着色)。这三问,就是本文的三篇正文。
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 驱动剔除与光栅化的虚拟几何体系统。
拆开看就是四根支柱,也是全书的主干:
- Cluster 切分:任何模型,无论多大,构建时都被切成约 128 三角形一个的”簇”。簇是构建、剔除、光栅化、流送的原子单位(像把巨型雕像拆成一箱乐高积木)。
- 层级 LOD(DAG):簇按空间邻近关系组织成一棵”金字塔树”——底层是原始高模,上一层是简化了一半的父簇,再上一层再简化……运行时不是 3~5 档固定 LOD 切换,而是每个簇独立按屏幕误差选档,远看整个模型用几百个粗簇,近看局部用几百万个细簇,中间没有可见接缝(第 5 章讲如何做到)。
- GPU 驱动剔除:每帧,剔除、LOD 选择、绘制参数生成全部在 GPU 上用计算着色器完成,CPU 几乎零参与。一个场景可能只有几条间接绘制命令,而不是几十万个 draw call。
- 页面流送:层级树和簇数据按”页”打包存放。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:一张图看懂差距
┌─────────────────────── 传统方案(UE4 时代) ───────────────────────┐ │ 百万面高模 ──烘焙──▶ 法线贴图 ──贴──▶ 低模(5万面) ◀──美术手工 LOD0/1/2/3 │ │ │ ▼ │ 每帧 CPU:剔除全部实例 → 生成几万个 draw call → GPU 逐模型绘制 │ 多边形多了 → 掉帧;LOD 切换 → 跳变;每个资产都要手调预算 └──────────────────────────────────────────────────────────────────────┘┌─────────────────────── Nanite 方案(UE5) ──────────────────────────┐
│ 千万面高模 ──一键启用──▶ 自动切成簇 → 自动生成层级 LOD → 自动打包成页
│ │
│ ▼
│ 每帧 GPU:自己剔除(视锥+遮挡)→ 自己选 LOD → 自己光栅化
│ 需要更多细节 → 流送请求 → 后台自动加载更细的页
│ 多边形数量几乎不影响帧率;质量只由屏幕分辨率决定
└──────────────────────────────────────────────────────────────────────┘
核心差异一句话:传统方案在”内容生产”环节消灭多边形(人工优化),Nanite 在”运行环节”消灭多边形的影响(自动优化)。
1.6 小结
这一章你只需要记住三句话:
- Nanite 的目标是消灭手工优化:不手做 LOD、不烘焙法线、不算面数预算。
- 它的手段是虚拟化:像虚拟内存/虚拟纹理一样,按需把几何体调入显存。
- 它的载体是四根支柱: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++ 和着色器共用。
高模(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 是”一棵树”,但严格说是 DAG(有向无环图)——相邻的组可能共享同一个父簇,
一个簇可能有多个父节点。共享是为了省内存(大平面场景的簇复用),代价是遍历时要做去重。
我们讲概念时先按”树”理解,第 5 章再讲真实的 DAG 结构。
它在这条流水线里的位置:构建期——建树;运行时——GPU 遍历选切面;切面选择的结果就是”这一帧要画哪些簇”的清单。
2.3 可见性缓冲(Visibility Buffer)= 先记”是谁”再问”是什么”
定义:Nanite 光栅化阶段不直接算颜色,而是每像素只写一个 64 位的”身份证号”:深度、簇 ID、三角形 ID、实例 ID。这一步只回答”这个像素被哪个几何体覆盖”,不回答”它是什么颜色”。等全部几何体都画完了,再根据身份证号逐个像素查询材质、计算颜色。
为什么要多此一举? 传统渲染是”边画边算”:每画一个模型,像素着色器马上算它的材质。问题是:
- 场景有几十种材质 → 每次材质切换都是 GPU 状态切换,几百上千次;
- 像素会被多个三角形覆盖(遮挡)→ 后画的覆盖先画的,先画的材质白算了;
- 微型三角形(比像素还小)→ GPU 的像素着色器以 2×2 四边形为单位工作,大量浪费。
先记身份、后算颜色,把”画几何”和”算材质”彻底解耦:几何阶段只关心”挡没挡住”,材质阶段只处理”最终可见的像素”——一个像素只被着色一次。
类比实验室:先发名单,再发名片
想象一个会场(屏幕),很多人要入场(三角形)。传统做法:每来一个人就立刻给他发名片(算材质),
但后来的人会挡在前面的人,前面人的名片白发了。Nanite 的做法:门口只登记”来了谁”(写 VisBuffer),
最后只给留下的人补发名片(着色)。名单 = 64 位 ID,名片 = 材质计算。名单和名片彻底分开,
名片可以慢慢发(第 12 章讲)。
它在这条流水线里的位置:光栅化输出(第 11 章)→ 深度导出 + 材质着色(第 12 章)之间的桥梁。
2.4 页面流送(Page Streaming)= 虚拟内存缺页
定义:构建好的全部簇数据,按”页(Page)”打包存放——根页面(含最粗 LOD)随资产常驻显存,其余页面在磁盘上。GPU 剔除时如果发现”需要的簇所在页面还没加载”,就发一个流送请求,CPU 端 FStreamingManager 异步加载后把数据散写进 GPU 的大缓冲,并修正引用。
┌─────────────────────────────────────────┐
│ 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(后遍):上帧被挡住、本帧可能露出来的几何(比如你在墙后面走动,墙壁本帧才移开),再用本帧刚生成的深度图补测一遍,画上”刚刚露头”的。
帧 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 串成一个完整的”一帧”故事:
- 开始:CPU 只做粗粒度预判(哪些材质可能可见),其余全交给 GPU;
- 剔除:GPU 用上一帧的 HZB + 视锥剔除所有实例(2.5),再逐层遍历 LOD 树选切面(2.2),遇到未加载的页面 → 发流送请求(2.4);
- 光栅化:选中的簇(2.1)被画进 VisBuffer——只写 64 位身份 ID(2.3);
- 深度导出:VisBuffer 的深度合并成场景深度,同时更新 HZB 供下一帧用;
- 着色:按像素身份 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 的目标清单(这是理解一切设计决策的钥匙):
- 自动 LOD:任何距离都无损、无人工干预;
- 常数时间渲染:渲染成本只随屏幕分辨率变化,不随场景三角形总数变化;
- GPU 驱动:剔除与 LOD 选择在 GPU 上完成,CPU 几乎零参与;
- 压缩存储:比传统资产格式更小(目标 10~20 字节/三角形);
- 保留创作自由:艺术家直接导入 ZBrush 百万面雕刻、摄影测量扫描,不用优化;
- 虚拟化:像虚拟纹理一样按需流送,不受显存限制。
任何候选方案都要同时满足这六条——这正是 3.2 节”全军覆没”的原因。
3.2 被拒绝的方案:体素、细分曲面、置换、点云
目标:自动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 或传统管线 |
还有两个经常被误解的点:
- “Nanite 不需要显存”是错的。 只是显存占用随”屏幕需要”而不是随”资产总量”增长。Epic 在 PS5 演示场景(Valley of the Ancients)给出的数据:几何数据 26GB 原始 → 压缩后 4.61GB 磁盘 → 运行期约 7.6GB 显存/内存(论文数据,PS5 场景,不代表所有场景)。磁盘上平均 ~14.4 字节/输入三角形。这些数字的语境是演示场景,不是 API 承诺。
- “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 | 保存资产 / 重新构建 |
源码落点:FBuilderModule::BuildInternal 是唯一入口,定义在 NaniteBuilder.cpp:936(Engine/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 输入与输出
输入:
- 高模三角形网格——顶点、索引、材质索引、UV、法线/切线,以及(本 fork 的)曲线(Curve)输入;
- **
FMeshNaniteSettings**——每资产配置:bEnabled(总开关)、PositionPrecision(位置量化精度)、bExplicitTangents、bLerpUVs、KeepPercentTriangles(保留三角形比例)、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::BuildInternal,NaniteBuilder.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 | /** If true, Nanite data will be generated. */ |
这段代码在干什么:这些字段决定了”构建质量与存储的权衡旋钮”——精度越高越占空间。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 的生成分两步(FClusterDAG,ClusterDAG.h:65):
1 | AddMesh(顶点, 索引, 材质索引, 包围盒) // 输入一个网格 |
关键设计:组是 DAG 的中间节点,簇是树叶。FClusterGroup(ClusterDAG.h:33)持有:包围球、ParentLODError(对这个组里所有簇有效的 LOD 误差——这就是”组内一致 LOD 决策”的数据基础)、MipLevel、子簇引用列表。FindCut 则是在构建期按目标三角形数/误差找切面用的工具(运行期的”切面”由 GPU 每帧找,构建期的 FindCut 用于 fallback 网格生成和预算控制)。
类比实验室:写摘要
ReduceGroup像给一篇长文写摘要:先分段落(簇),每段压成一句话(父簇),各段的话再压成
一段话(祖父簇)……每一层都是上一层的摘要,但共享的段落首句永远保留(边界锁定),
所以”摘要层”与”原文层”拼接起来不会出现内容断裂。
5.2 误差驱动的简化:SimplifyTriangles
父簇不是简单抽稀,而是误差驱动的边折叠简化。FCluster::SimplifyTriangles(Cluster.cpp:955):
- 计算所有三角形的 UV 面积(UV 塌缩会导致贴图拉伸,误差里要算上);
- 对所有可折叠边按折叠代价排序(代价 = 折叠后几何误差 + 属性误差,含 UV/法线);
- 从最小代价开始折叠,直到达到目标三角形数(
TargetNumTris)或目标误差(TargetError)。
折叠一个边 = 把两个顶点合并成一个,三角形数 -2。误差的意义:折叠后表面偏离原表面的最大距离。这个误差值最终被写入簇的 LODError / 组的 ParentLODError,GPU 遍历时拿它做”误差 < 1 像素”判断(第 10 章)。
原始网格(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::Split(Cluster.cpp:1154)分三步:
- 并查集(DisjointSet):把共享边的三角形归到同一连通分量(物理上连着的三角形必须同簇,防止切出碎片);
- 建图:三角形作为图的节点,相邻三角形之间连边,边权 = 共享边数 + 材质相似度(
BuildLocalityLinks,材质不同的三角形尽量不切到一起——避免一个簇里塞进太多材质区间); - 递归二分:
FGraphPartitioner(封装 METIS 图分割,GraphPartitioner.h:12)把图递归切成两半,直到每块 ≤128 三角形。
类比实验室:分乐高块
乐高积木拼接有”咬合方向”(邻接关系)。切分要保证:① 咬合在一起的块不能拆散到不同袋子
(连通分量约束);② 每袋不超过 128 粒(数量约束);③ 颜色(材质)尽量一致的放一袋
(材质约束)。切出来的”袋子”就是 Cluster,袋子上写清楚”里面是什么”(包围球+误差+材质区间)。
5.4 防裂缝三件套:在源码里的落点
第 3 章的三条铁律,在构建期对应的实现:
| 铁律 | 实现位置 | 说明 |
|---|---|---|
| 128 三角形 | FCluster::ClusterSize = 128(Cluster.h:270)+ Split |
构建与编码两侧都用 NANITE_MAX_CLUSTER_TRIANGLES 校验 |
| 锁定共享边界 | SimplifyTriangles 边界顶点禁折叠 + ConstrainClusters(NaniteEncode.cpp 中调用,第 6 章展开) |
简化时 ExternalEdges 上的顶点标记为不可折叠 |
| 组内一致 LOD | FClusterGroup::ParentLODError(ClusterDAG.h:36) |
GPU 遍历时按”组”测试,组内共享同一误差阈值 |
交替边界是论文里描述的机制(同一组边界不穿透所有层级),在构建期表现为:每层 GroupTriangleClusters 对相邻簇的分组方式不同——上一层的组边界不会在下一层对齐,从而避免”永久边界”累积过密顶点。
5.5 数据层级:Cluster → Group → 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
源码考古:想看真东西的人进。
① 簇的数据结构
FCluster,Cluster.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:
共享边顶点被标记,简化器看到它就不折叠——这就是铁律二的实现。LODBounds与ParentLODError
是运行期 GPU 判 LOD 的全部依据(第 10 章)。② 材质区间
FMaterialRange,Cluster.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
FClusterGroup,ClusterDAG.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;DR|
Encode()是构建期最后一步:把构建期的 FClusterDAG(内存格式)翻译成
GPU 直接消费的 FResources(磁盘格式)。它做六件大事:清理顶点 → 按材质分段 → 约束检查 →
量化(精度换体积)→ 分页装箱 → 建层级数组 + 算依赖修复。最终磁盘上每个输入三角形约
14.4 字节(论文的 PS5 演示数据),而普通高模格式要 100+ 字节。类比:装箱物流——货物是簇、
集装箱是页、装箱单是页头、装错位置后的修正单是 fixup 块。
6.1 Encode() 流水线逐阶段拆解
Encode(NaniteEncode.cpp:1444)按顺序执行下列阶段(阶段名就是源码里 TRACE_CPUPROFILER_EVENT_SCOPE 的名字,可以和源码逐一对上):
输入: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.cpp里CalculateQuantizedPositionsUniformGrid负责计算每个簇自己的最优位数。最大 21 位/轴(NANITE_MAX_POSITION_QUANTIZATION_BITS = 21,NaniteDefinitions.h:162),21×3 = 63 位正好塞进 64 位整数——精心设计的位对齐。 - 法线:球面量化到八面体(octahedral)编码,15 位(
NANITE_MAX_NORMAL_QUANTIZATION_BITS = 15)。 - 切线:12 位 + 符号位。
- UV:自定义浮点(1 符号 + 5 指数 + 14 尾数 = 20 位/分量),比 32 位 float 省 40%,精度在贴图采样尺度下无感。
- 骨权/骨索引:16 位/项。
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 = 1(NaniteDefinitions.h:39)。
压缩:页面数据再经过 LZ 压缩(论文:PS5 演示用 LZ 系压缩,26GB 原始 → 4.61GB 磁盘;本 fork 用引擎默认压缩器)。注意:GPU 端解压是”转码(Transcode)”——运行时把压缩的页面数据解压成 GPU 可读格式(FStreamingPageUploader 干这活,第 8 章)。
顶点复用批次(VertReuseBatches):相邻页面共享边界顶点,为避免重复存储,构建期把”被多个页面引用的顶点”单独抽出成批次(BuildVertReuseBatches),页面安装时通过 fixup 块引用它们(见 6.4)。
6.4 依赖与修正:为什么需要 FFixupChunk
页面之间不是独立的:父簇页面的数据(索引、顶点偏移)会引用子簇页面里的顶点(边界顶点共享)。当一个页面安装/卸载时,引用它的页面的 GPU 数据里的”偏移量”可能失效——因为新页面里的簇/顶点在缓冲中的位置是运行期才确定的。
解决:构建期生成 FFixupChunk(修复块)(CalculatePageDependenciesAndFixups,NaniteEncodeFixup.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 资产的全部家当
FResources(NaniteResources.h:455,旧名 FNaniteResource)分两部分:持久状态(构建期写入、序列化到磁盘)与运行时状态(加载后由引擎填写)。
1 | struct FResources |
每个字段一句话:RootData + NumRootPages = “永远画得出来的保底层”;StreamablePages = “按需进货的仓库”;HierarchyNodes + HierarchyRootOffsets = “GPU 每帧遍历的树”;PageStreamingStates/PageDependencies/PageRangeLookup = “流送系统的元数据”。
运行时状态怎么填? 资产被加载(首次渲染)时,渲染线程调用 InitResources(同文件),向 FStreamingManager 注册资源、在 GPU 池里分配槽位(RuntimeResourceID)并安装根页面。卸载时 ReleaseResources 反向操作。注意:GPU 池是全局共享的——所有 Nanite 网格的数据都装进 FStreamingManager 的两个大缓冲(7.5 节),FResources 本身只是”货单”。
7.2 FPackedCluster:128 字节装下整个世界
FPackedCluster(NaniteResources.h:108)是 GPU 端簇的最终格式——固定大小(8 个 float4 = 128 字节),所以 GPU 可以按索引直接寻址(ClusterPageData[PageIndex][ClusterIndex])。所有字段都经位打包:
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直接消费它们); - 材质字段:
PackedMaterialInfo、AttributeOffset_BitsPerAttribute、DecodeInfoOffset、UVBitOffsets—— 着色阶段解码三角形属性用(第 12 章)。
类比实验室:身份证
FPackedCluster 像一张身份证:所有”你能想起这个人的信息”都压缩在固定大小的卡片里——
编号(索引)、住址(页内偏移)、身高体重(包围球)、照片压缩格式(量化信息)。
固定大小让 GPU 可以”按编号取卡”,不用翻页扫描。
7.3 FPackedHierarchyNode:4 个孩子一组打包
FPackedHierarchyNode(NaniteResources.h:59)是 GPU 端 BVH 节点。**关键设计:不是”一个节点一条记录”,而是”4 个扇出子节点合成一个 60 dword 的切片”**(NANITE_MAX_BVH_NODE_FANOUT = 4,NANITE_HIERARCHY_NODE_SLICE_SIZE_DWORDS = 60,两者在 NaniteDefinitions.h:103-111):
1 | struct FPackedHierarchyNode |
为什么 4 个一组? 这是 SIMD 友好的”宽度”:GPU 一次加载 4 个节点的数据正好对齐 128 字节缓存行,遍历时 4 个子节点一起测试、一起决定”访问谁”。ChildStartReference 指向子切片在 HierarchyNodes 数组中的位置(或者指向簇数据,表示”这里是叶子”)。ResourcePageRangeKey 是页面引用——GPU 遍历到未驻留页面时,就是靠它发出流送请求(第 8 章)。
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.h(Engine/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,HEAD2c7bb82d)。
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 里”切位段”的通用工具。
整个结构体没有一个”浪费”的位——NumVerts和PositionOffset挤在同一个 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;DR|
FStreamingManager(全局单例GStreamingManager)把 Nanite 的 GPU 数据管成一块
“虚拟内存”:固定大小的全局缓冲 + 页表 + LRU。每帧闭环是:GPU 剔除发现缺页 → 写请求 →
CPU 回读 → 按优先级排序 → 异步读盘 → 安装 + 修正引用(fixup)→ scatter 上传。
根页面永远驻留,所以任何时刻都有东西可画。类比:虚拟内存缺页处理——请求 = page fault,
安装 = 调页,fixup = 更新页表,LRU = 内存回收。
8.1 设计目标:把”显存放不下”变成”显存刚刚好”
Nanite 流送的目标清单(对照虚拟内存的设计目标,几乎一一对应):
- 透明:渲染代码不需要知道页面是否驻留——缺页只是”先用粗 LOD 顶着”,不是错误;
- 按需:只有”画面需要”的页面才加载(GPU 每帧遍历树时精确知道需要哪些);
- 可驱逐:显存是共享的,页面按 LRU 淘汰,把显存让给新需求;
- 带宽可控:每帧加载/安装的量有预算(
r.Nanite.Streaming.BandwidthLimit等),防止卡帧; - 正确性优先:宁可多画粗 LOD,绝不错画/漏画。
为什么虚拟化在此刻成为可能? 因为 LOD 树本身天然支持”降级渲染”:页面缺失时,遍历树会停在该页面的父节点上——那是已驻留(或根页)的粗 LOD。数据到达后,下一帧遍历自动选择更细的簇。**流送就是 LOD 树的”缺页异常处理器”**。
8.2 FStreamingManager:流送系统的”操作系统内核”
FStreamingManager(NaniteStreamingManager.h:69,**注意:UE 5.7 前叫 FNaniteStreamingManager**)是全局单例(TGlobalResource,生命周期随渲染资源,:413)。它的核心成员按职责分四组:
1 | class FStreamingManager : public FRenderResource |
8.3 请求闭环:一帧内的五个阶段
逐阶段说明(与源码对应):
- 请求产生(GPU):
NaniteHierarchyTraversal.ush在遍历层级树时,遇到ResourcePageRangeKey指向未驻留页面的节点 → 写一条FGPUStreamingRequest(含优先级)到 GPU 缓冲。渲染标志NANITE_RENDER_FLAG_OUTPUT_STREAMING_REQUESTS(NaniteDefinitions.h:209)控制是否输出。 - 回读(GPU→CPU):
FReadbackManager(NaniteReadbackManager.h:15)维护环形 readback 缓冲:QueueReadback排队、LockLatest取回最新一批请求,不阻塞渲染管线。 - CPU 处理:
FStreamingManager::BeginAsyncUpdate(NaniteStreamingManager.cpp:2212)→AsyncUpdate(:2758):AddPendingGPURequests+ 加父页依赖(请求一个页必须连带其父页——否则父簇数据缺失无法渲染)→ 优先级排序(屏幕空间误差大者优先)→ LRU 淘汰(预算内换出最久未用的页)。 - 读盘:后台任务从
StreamablePages(资产 bulk 数据)异步读取页面数据到暂存内存,转码(解压成 GPU 格式)。 - 安装 + 上传:
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_PARENTS(NaniteDefinitions.h:123) |
屏幕误差大的页先加载;父页请求优先级恒高于子页 |
| 带宽预算 | r.Nanite.Streaming.BandwidthLimit |
每帧磁盘读取量上限 |
| 安装预算 | r.Nanite.Streaming.MaxPageInstallsPerFrame |
每帧安装页数上限(fixup 改写有成本) |
| 常驻 | 根页面(RootData)在 InitResources 时安装,永不淘汰 |
“永远有东西可画”的保底 |
| 质量自适应 | FQualityScalingManager → QualityScaleFactor |
驻留率低时降低 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 每帧看到的页表/数据都是”上一批请求结算完”的一致状态。③ 内部核心,
:2758AsyncUpdate()(伪代码化后的职责划分):
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 一帧的完整形状
读图要点:CPU 只干一件事(材质预判 + 打包视图),其余全在 GPU;光栅化与着色之间隔着一个 VisBuffer;着色阶段每个材质一个计算着色器(不切换 PSO),用间接分派。
9.2 架构事实:FRenderer::DrawGeometry 是绝对核心
本仓库(5.9 fork)里没有 FNaniteVisibilityPass 类——老的博客/资料里那个名字属于 UE 5.0~5.2 的旧架构。现在的核心是 FRenderer(NaniteCullRaster.cpp:6818 的 FRenderer::DrawGeometry),一个对象把一帧的所有剔除/光栅化阶段编排完。调用它的渲染器主流程(DeferredShadingRenderer.cpp 中):
1 | 渲染一帧(DeferredShadingRenderer::Render): |
复用是重点:同一个 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:618 的 FNaniteRasterPipeline)之后,每帧做两类”分桶”:
- RasterBin(光栅桶):一个材质段 → 一个”怎么光栅化”的桶。桶由管线特征(双面?WPO?蒙皮?Voxel?)哈希决定,桶数远小于材质数——大量材质共享同一个”固定函数”桶(
NANITE_FIXED_FUNCTION_BIN*,NaniteDefinitions.h:248-254); - ShadingBin(着色桶):一个材质段 → 一个”怎么着色”的桶。每个材质一个着色桶(含材质参数、GBuffer 写掩码)。
每帧 CPU 端先跑一个异步预判(FNaniteVisibility::BeginVisibilityQuery,NaniteVisibility.cpp:352):对每个 primitive 的包围盒做视锥测试,把”肯定不可见的材质桶”预先标记掉——这样 GPU 分桶时直接跳过,省掉无效分派。这个预判是粗粒度的(包围盒级),精确剔除还是 GPU 的事。
每个材质段对应的 bin 落在哪? FNaniteMaterialSlot(NaniteMaterials.h:15):
1 | struct FNaniteMaterialSlot |
避坑: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 判定)。
核心判据两个:FBoxCull(Frustum()视锥 +HZB()遮挡)和 SmallEnoughToDraw
(投影边长 × 误差 < 1 像素,同时决定走 HW 还是 SW 光栅)。两遍遮挡用上一帧 HZB 测主遍、
本帧 HZB 测后遍,保守且正确。最终产出不是 draw call,而是间接分派参数。
类比:看地图找路——先看省(视锥),再看城区(HZB),最后放大到街道(LOD)。
10.1 三级剔除漏斗
① 实例剔除:先粗筛”这个模型实例本身要不要画”——包围球不在视锥内?被 HZB 挡住?都不要。本仓库有两套路径:Instance Hierarchy Culling(新,FInstanceHierarchyDriver,NaniteCullRaster.cpp:3259,对 GPUScene 实例按 BVH 分层批量剔除)与普通路径(FInstanceCull_CS,NaniteInstanceCulling.usf:177)。两遍遮挡模式下,被挡实例写入 OccludedInstances,Post Pass 时重测。
② 节点剔除:对幸存实例,从它的树根开始逐层下降。NodeAndClusterCull(NaniteClusterCulling.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 两把刀
剔除的几何测试集中在 FBoxCull(NaniteCullingCommon.ush:232):
- **
Frustum()**(:527):把包围盒变换到裁剪空间,对 6 个裁剪面做经典”盒 vs 锥”测试;同时检测是否跨越近/远平面(跨近平面 = 需要裁剪标记)。VSM(虚拟阴影图)下还有动态深度范围裁剪逻辑(本 fork 特性)。 HZB()(:595):先把盒投影成屏幕矩形,再在 HZB 金字塔上逐级查询——从最粗层开始,如果矩形在该层完全被”更近的深度”覆盖,直接判为被遮挡(不用查细层)。这就是”层级”Z 缓冲的意义:常数时间近似的遮挡查询。
HZB 是什么? 一张深度图的金字塔:第 0 层是原始深度,第 n 层是”每 2^n×2^n 像素里取最远(最大)深度”的缩小图。测试包围盒时从粗层查起,能快速否决”深藏不露”的物体。
两遍遮挡的具体机制(r.Nanite.Culling.TwoPass,NaniteCullRaster.cpp:309):
帧 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 的数学
SmallEnoughToDraw(NaniteClusterCulling.usf:287)是”簇级 LOD 判定 + SW/HW 分流”的一体化判据:
1 | bool SmallEnoughToDraw(FNaniteView NaniteView, FInstanceSceneData InstanceData, |
逐行翻译:
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 剔除后把可见簇写入紧凑缓冲,随后 CalculateSafeRasterizerArgs(NaniteCullRaster.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——FPackedView(NaniteShared.h:58)里打包了”上一帧”和”当前帧”两组矩阵,剔除 shader 直接换矩阵再测一次,就实现了”上一帧 HZB 测本帧几何”。一个 pass 枚举 + 一组矩阵 = 两遍遮挡的全部实现——架构上是同一份代码,只是”测谁”不同。③ 间接分派参数生成:
CalculateSafeRasterizerArgs(NaniteCullRaster.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 不同)。所以先做 Binning(RasterBinBuild,NaniteRasterBinning.usf:519):
- 对每个可见簇的每个三角形,查
GetRemappedRasterBinFromIndex→ 得到它属于哪个 RasterBin(材质段 + 管线特征哈希出桶号); - 计数(
RasterBinCount)→ 预留(RasterBinReserve,原子累加)→ scatter:把簇按 bin 顺序写入紧凑的RasterBinData; RasterBinFinalize生成每 bin 的间接分派参数 + 组元数据(FNaniteRasterBinMeta/FNaniteRasterGroupMeta,NaniteDefinitions.h:596-617)。
收益:SW 与 HW 光栅化都消费这份 bin 数据——同一批分拣结果喂两条流水线;深度分桶(r.Nanite.DepthBucketing,NaniteCullRaster.cpp:177)把簇按深度范围再分桶,光栅化时前到后画,配合”近深度桶写快速路径”进一步省带宽。
11.2 HW 光栅化:图形管线出场
HW 路径用传统图形管线(VS → PS,或 Mesh Shader MS / Primitive Shader),管线枚举 ERasterHardwarePath(NaniteShared.h:356)在运行期选:VertexShader / PrimitiveShader / MeshShaderWrapped / MeshShaderNV / MeshShader。判定走哪条:r.Nanite.MeshShaderRasterization / r.Nanite.PrimShaderRasterization(NaniteShared.cpp:102/109)+ 平台能力。
可编程 vs 固定函数:r.Nanite.ProgrammableRaster(NaniteCullRaster.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 路径(FMicropolyRasterizeCS,NaniteCullRaster.cpp:1517,入口 ClusterRasterize,NaniteRasterizer.ush:500)用计算着色器逐三角形做增量式光栅化:RasterizeTri_Rect(:132)以三角形包围矩形为界,用三个边函数(edge function)做覆盖测试——每行每列只需一次加法和三次比较:
1 | template< typename FWritePixel > |
为什么快? 三个边函数的值是增量更新的(每行每列一次加/减),覆盖测试只是一次 min3 >= 0;写入是 PlotPixel(NaniteRasterizer.usf:1168)——原子写 VisBuffer 像素,没有状态切换、没有 quad 浪费。三种变体各有场景:RasterizeTri_Rect(通用)、RasterizeTri_RectSingle(单像素特化)、RasterizeTri_Scanline(扫描线)、RasterizeTri_Adaptive(自适应细分)。r.Nanite.ComputeRasterization(NaniteCullRaster.cpp:81)关掉 → 全部走 HW。
调度:r.Nanite.AsyncRasterization(:53)让 SW 光栅在异步队列上与 HW 光栅并行(HardwareAndSoftwareOverlap);ERasterScheduling(NaniteCullRaster.h:25)三态:HardwareOnly / HardwareThenSoftware / HardwareAndSoftwareOverlap。
细分(Tessellation,本 fork 已支持):r.Nanite.Tessellation(:95)+ AddPass_PatchSplit(NaniteSplit.usf 的 PatchSplit)+ 静态查找表 GTessellationTable(TessellationTable.cpp)——把大三角形按 r.Nanite.DicingRate 细分成微多边形后走 SW patch 光栅(PatchRasterize)。这是”论文没有、5.x 后加”的能力(避坑:老资料说 Nanite 不支持细分,那是 5.0 时代的事)。
11.4 VisBuffer64:像素的”身份证”
HW 与 SW 殊途同归——都写 VisBuffer64(64 位/像素的 R32G32_UINT 纹理):
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 光栅每覆盖一个像素,最终都汇聚到
PlotPixel。CreateVisBufferPixel
把深度和 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)。EmitDepthTargets(NaniteComposition.cpp:279)把两者接起来:
1 | VisBuffer64 ──▶ EmitDepthTargets ──▶ SceneDepth(R32 深度) |
像素着色器版本:EmitSceneDepthPS(NaniteExportGBuffer.usf:70)——逐像素:解 VisBuffer → UnpackVisPixel(DepthInt, ClusterIndex, TriIndex) → 有簇?写 SV_Depth +(可选)速度/掩码。计算着色器版本:r.Nanite.ExportDepth 开启时用 FDepthExportCS(NaniteDepthExport.usf)批量导出,把像素工作换成内存搬运,更快。
为什么要多此一举? ① 场景深度是”最终可见表面”的深度,而 VisBuffer 可能被 Post Pass 增量更新过——导出是”结算”;② 深度/模板是其他 pass(光照、半透明、后处理)的输入,格式必须统一;③ 深度与着色解耦:深度可以早导出(光栅化后立即),着色可以晚做(比如半透明之后),甚至着色分辨率可与深度不同(配合软件 VRS)。
12.2 DispatchBasePass:按材质分桶着色
深度导出后,传统渲染会直接跑 BasePass 像素着色器。Nanite 的 BasePass 是计算着色器管线(NaniteShading.cpp:1667):
- ShadeBinning(
NaniteShadeBinning.usf:941):ShadingBinBuildCS把每个 2×2 像素 quad 按”它属于哪个材质的三角形”分桶(BinShadingQuad读 VisBuffer → 查三角形材质 → 原子累加到桶计数);ShadingGroupCS/ShadingBinReserveCS预留空间、scatter 生成每桶的线程组参数; - 逐材质计算着色器:对每个活跃桶,用
ShadingDispatchArgs间接分派一个计算着色器(该材质专属:UniformBuffer + GBuffer 写掩码BoundTargetMask); - 每个线程负责一个 quad/像素:读 VisBuffer → 取簇/三角形/实例 → 解码顶点属性(位置量化反解、UV、法线切线)→ 重心插值(三角形三顶点的属性按重心坐标插到像素)→ 算材质 → 写 GBuffer(UAV)。
没有”材质切换”的含义:传统 BasePass 是”画三角形时附带材质”,场景 100 个材质 = 100 次 PSO 切换 × 每模型一次。Nanite 是”先分桶,再按桶批量上同一个着色器”——**切换次数 = 活跃材质桶数(每帧一次),而不是”三角形数 × 材质数”**。
GBuffer 写掩码(BoundTargetMask):每个材质声明自己写哪些 GBuffer 目标(Albedo/法线/粗糙度/……),不写的目标零开销——这也是 FNaniteShadingBinMeta::MaterialFlags(NaniteDefinitions.h:637-657)打包的东西(低 24 位材质标志 + 高 8 位写掩码)。
┌────────────────────────────────────────────────┐
│ 光栅化阶段(第 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*Programmable,NaniteDefinitions.h:421-434) |
路径 |
|---|---|---|
| 普通(不透明,无 WPO) | bVertexProgrammable == false && bPixelProgrammable == false |
固定函数光栅 + 标准着色桶 |
| 像素可编程(Masked/PDO) | PIXEL_DISCARD/PIXEL_DEPTH_OFFSET(NANITE_MATERIAL_PIXEL_PROGRAMMABLE_FLAGS) |
光栅时逐像素判(SW 或 HW 可编程 PS) |
| 顶点可编程(WPO/蒙皮/细分) | WPO/DISPLACEMENT/VERTEX_UVS/FIRST_PERSON_LERP(NANITE_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 产出标准 GBuffer(FSceneTextures 的 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 面板(FNaniteStaticMeshLayout,StaticMeshEditorTools.cpp 的自定义布局)直接映射 FMeshNaniteSettings(EngineTypes.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 网格到底是什么? BuildInternal 里 BuildFallbackMeshFromIntermediate(NaniteBuilder.cpp:983,函数定义 :779)从同一份中间资源生成传统网格(带 LOD),用于:① 不支持 Nanite 的平台/管线;② 渲染器需要传统几何的场合(如自定义深度、某些后期)。启用 Nanite 不等于放弃传统网格——资产里两者共存,引擎自动选择。
骨骼网格(Skeletal Mesh):USkeletalMesh 也有 Nanite 设置(SkeletalMesh.h),本 fork 已支持蒙皮路径(SkeletalRenderNanite.cpp),启用方式同静态网格。
13.2 可视化模式:43 项”体检报告”
视口菜单 Lit → Nanite Visualization →(命令 VMI_VisualizeNanite,EditorViewportClient.cpp:3298)提供了本仓库的 43 种模式(FNaniteVisualizationData::Initialize,NaniteVisualizationData.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;
- 失败排查:构建失败通常是”内部限制超限”(
BuildInternal里Encode失败会打警告日志)——常见原因:簇数/页面数超预算、输入网格非法(退化三角形过多、顶点属性缺失); - 脚本 API:
StaticMeshEditorSubsystem(StaticMeshEditorSubsystem.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.ini的bSupportsNanite。调优三板斧:r.Nanite.ShowStats 看统计 →
可视化模式定位问题 → 对症调 CVar。最常用的旋钮:MaxPixelsPerEdge(质量↔性能)、MinPixelsPerEdgeHW(SW/HW 分流)、Culling.TwoPass(遮挡严格度)、Streaming.*(带宽)。
14.1 平台开关:bSupportsNanite
引擎用数据驱动配置决定平台是否支持 Nanite(Engine/Config/<Platform>/DataDrivenPlatformInfo.ini 的 bSupportsNanite):
| 平台 | 配置位置 | 状态 |
|---|---|---|
| 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-379 与 NaniteShared.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.StatsFilter、r.Nanite.Visualize.*(各可视化模式参数)、r.Nanite.MaxVisibleClusters / MaxCandidateClusters / MaxNodes(容量预算)。
14.3 调优工作流:三板斧
1 | 1. 看数字 r.Nanite.ShowStats 1 |
14.4 常见坑清单
- 材质不支持:
Translucent/Masked混用导致部分路径失效——用可视化模式确认哪些材质走了可编程路径; - UV 精度问题:
bLerpUVs误关(UV 可插值却关了)→ 简化误差不含 UV → 贴图拉伸; - 远距离闪烁/跳变:流送带宽不足(页面跟不上)→ 加大常驻预算或调
MaxPixelsPerEdge; - 显存/内存预算:
rhi.dumpresourcememory summary name=Nanite(BaseEngine.ini 里注册的调试命令)看 Nanite 实际占用; - 与 TAA/TSR 配合:VisBuffer 方案依赖时域抗锯齿(TSR/TAA)——关掉会看到闪烁(1 像素误差的 LOD 切换无时域平滑);
- Fallback 网格质量:
GenerateFallback用PlatformDefault时,非 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 阅读进阶路线
- 论文:《A Deep Dive into Nanite Virtualized Geometry》(SIGGRAPH 2021 Advances in Real-Time Rendering,Brian Karis / Rune Stubbe / Graham Wihlidal)—— 155 页,最权威的架构解释;论文的”单 DrawIndirect”等表述与当前代码有差异(见 10.4 避坑);
- Into the Bytecode 系列(Marco Giustino):按主题深挖(Cluster、Culling、流送、Rasterizer),与本文各章可对照阅读(注意:它基于 UE 5.0~5.2,名字与结构有差异);
- Epic 官方文档:Nanite Technical Details、Nanite Virtualized Geometry 概述;
- 源码:从附录 A 的源码地图出发——先读
NaniteDefinitions.h(宪法),再读DrawGeometry(骨骼),再按兴趣钻入各子系统; - 自测:做完附录 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 :33、FClusterDAG :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 :59、FPackedCluster :108、FResources :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 参考资料
权威论文(最推荐):
- 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 字节/三角形)均出自此。
- Epic Games 官方文档:Nanite Virtualized Geometry、Nanite Technical Details(dev.epicgames.com,含 5.x 各版本的更新说明)。
博客与社区:
- Marco Giustino, Into the Bytecode 系列(Nanite 主题深度剖析)—— 注意其基于 UE 5.0~5.2,类名与结构与本仓库有差异(如
FNaniteStreamingManager已改名)。 - 社区中文解读系列(如 ttod 的《剖析虚幻渲染体系》相关章节)—— 适合对照阅读,细节请以本文的源码引用为准。
本仓库(第一手):
Engine/Shaders/Shared/NaniteDefinitions.h—— 全部常量(先读它!)Engine/Source/Developer/NaniteBuilder/Private/NaniteBuilder.cpp:936—— 构建入口Engine/Source/Runtime/Engine/Public/Rendering/NaniteResources.h—— 数据结构Engine/Source/Runtime/Renderer/Private/Nanite/NaniteCullRaster.cpp:6818—— 渲染核心
附录 D 自测练习
入门级(30 秒通道毕业):
- 用三句话向同事解释 Nanite 是什么(要求用上”虚拟化”和”GPU 驱动”两个词)。
- 说出四大支柱,并为每一个配一个类比。
r.Nanite.MaxPixelsPerEdge调大和调小分别发生什么?为什么?
进阶级(标准通道毕业):
- 画一张”页面安装”时的完整流程,标注:请求产生 → 回读 → 排序 → 读盘 → fixup → 上传。
- 解释为什么两遍遮挡”只可能多画,不可能少画”,并用它推导 Post Pass 的输入是什么。
- 一个 100 万三角形的模型,启用 Nanite 后显存占用是”100 万 × 128 字节”吗?为什么?
- 为什么微三角形走软件光栅更快?用 2×2 quad 的利用率解释。
bLerpUVs在什么情况下必须关闭?关闭后 LOD 选择会发生什么变化?
源码级(源码考古通道毕业):
- 在
SmallEnoughToDraw(NaniteClusterCulling.usf:287)里,找到”投影边长 > 误差 × LODScale”这一行,解释每个变量的来源。 - 在
FPackedCluster(NaniteResources.h:108)里找出所有”位打包”字段,计算它们加起来用了多少个 dword,与 128 字节是否吻合。 - 追踪一次页面安装:从
FStreamingManager::AsyncUpdate(NaniteStreamingManager.cpp:2758)出发,列出”读盘 → 安装 → fixup → 上传”各自的函数名。 - 修改实验:把
r.Nanite.MinPixelsPerEdgeHW设为 10000,观察 ShowStats 里 SW/HW 比例与帧耗时变化,解释原因。