A Deep Dive into Nanite Virtualized Geometry
A Deep Dive into Nanite Virtualized Geometry
设计愿景与动机
从虚拟纹理到虚拟几何
- Nanite 是 UE5 的 虚拟化几何系统(Virtualized Geometry System) ,由 Epic Games 的 Brian Karis 主导开发
- 灵感源于 虚拟纹理(Virtual Texturing) 的成功经验:虚拟纹理让美术可以自由使用巨大纹理而无需担心显存预算,极大地解放了创作流程
- Nanite 的终极目标:将同样的"无预算"理念应用于几何体
核心设计目标
- 消除几何体预算限制 :不再关心多边形数量(poly count)、绘制调用(draw calls)、内存占用
- 直接使用影视级资产 :美术可以直接导入电影质量的模型,无需手动优化 即可用于实时渲染
- 自由的场景布置 :美术可以随意放置任意数量的网格体,自主决定如何构建场景
- 零质量损失 :快速渲染的同时,必须保持美术原始创作的视觉效果
核心理念:将美术从繁琐的性能优化工作中彻底解放,让他们专注于创作本身。美术在优化内容以达到帧率目标上浪费的时间是惊人的。
几何体表示方案的探索与取舍
为什么几何体比纹理更难虚拟化?
- 纹理虚拟化本质上是一个 内存管理问题 ,而几何体细节 直接影响渲染开销
- 纹理可以被 平凡地过滤(trivially filterable) (如 mipmap),而几何体不行
方案一:体素(Voxels)与隐式表面(Implicit Surfaces)
优势
- 体素和隐式表面(如 SDF)在学术界讨论最多,有诸多潜在优势
致命问题
-
数据量爆炸 :一个 200 万面的半身像,重采样为 1300 万窄带 符号距离场体素(Narrow Band Signed Distance Field Voxels) ,即便已考虑 稀疏性(Sparsity) (不存储空体素和全填充体素),数据量仍然是原始的 6 倍 ,且看起来还是 模糊的(blobby)
-
均匀重采样的本质缺陷 :
- 体素化是一种 均匀重采样(Uniform Resampling) ,均匀重采样意味着信息损失
- 这相当于 3D 版的 "矢量图转像素图"
- 对于 有机体/均匀采样网格 (如雕刻的皮肤),重采样破坏性不大
- 对于 硬表面建模(Hard Surface Modeling) ,破坏性可能 极其严重 ——锐利边缘会被模糊掉
-
多重技术困境 :
- 需要 最大化稀疏性 来控制数据量,但不能牺牲 光线投射性能(Raycasting Performance)
- 数据结构需要 超级自适应 :锐边处需要高精度,平滑处不浪费采样
- 即使做到以上所有,存储的最精细分辨率 仍非原始创作的精度 ——我们始终在重采样
- 是否还需要额外存储原始网格,在近距离观察时切换回去?
-
与现有工作流的兼容性问题 :
- Nanite 需要支持 从任何工具导入的网格 ,无法控制几何体的创作方式
- 这些网格 自带 UV ,使用平铺细节贴图(Tiling Detail Maps)——UV 映射虽被美术普遍讨厌,但它是 极其强大的程序化纹理工具
- 体素着色(Voxel Colors)不能替代 UV , 三平面投影(Triplanar Projection) 也不行
- Nanite 的目标是 只替换网格 ,而非纹理、材质或与之相关的所有创作工具
- 体素 + UV?也许可以,但如何处理 UV 接缝(UV Seams) ?
- 其他问题:薄于一个体素的特征在 SDF 中 消失 ;少于几个体素厚度时属性 泄漏 ;如何做 动画 ?
Brian 的结论:体素/隐式表面要完全替代显式表面(Explicit Surfaces),还需要 多年的复合研究和行业经验积累 。即使做到了,也不确定它是否更好。
方案二:细分曲面(Subdivision Surfaces)
优势
- 理论上可以达到 无限光滑
致命问题
- 只能做放大(Amplification),不能简化 :近处观察很棒,但 不会比基础控制笼(Base Cage)更简单
- 控制笼本身就很重 :与美术沟通后发现,控制笼通常 比游戏低模还要高 ;在影视中更是 高出一个数量级
- 核心矛盾:Nanite 需要 美术创作选择与渲染开销解耦 ,细分曲面做不到
方案三:位移贴图(Displacement Maps)
优势
- 类似法线贴图的捕获方式,配合 矢量位移(Vector Displacement) ,低模可以做得更低
致命问题
- 拓扑限制 :位移 无法增加曲面的亏格(Genus) ——通俗来说,你 无法把一个球体位移成一个环面(Torus)
- 例如一条锁链,根本无法用位移贴图从简单几何体生成
- 均匀重采样的老问题 :投影到法线贴图或位移贴图也是一种均匀重采样
- 对有机表面效果好
- 对硬表面特征,如果不是美术 极其仔细地控制 ,破坏性很大
- 总结:放大用途很好,但不适合通用的简化需求
方案四:基于点的渲染(Point-Based Rendering)
优势
- 点云可以 极快地投射到屏幕上
致命问题
- 除非接受 大量过度绘制(Overdraw) ,否则点云需要 填孔(Hole Filling)
- 无法区分 "应该存在的小间隙"和"应该被填充的孔洞"——在没有额外 连接性数据(Connectivity Data) 的情况下不可能确定
- 所谓的连接性数据本质上就是 索引缓冲区(Index Buffer) ,即回归 三角形网格(Triangle Mesh)
最终结论:三角形(Triangles)是核心
- Brian 花了大量时间探索其他方案,在 Nanite 的需求下, 没有找到比三角形更高质量或更快速的方案
- 其他表示方法在特定场景下有价值——如果你的美术风格可以围绕它们的优缺点设计的话
- 但 Unreal 作为通用引擎 不能强加美术风格 ,因此 三角形是 Nanite 的核心
现代三角形渲染管线基础
保留模式渲染架构(Retained Mode Design)
- 数年前 Unreal 的渲染器重构为 保留模式设计(Retained Mode Design)
- 核心特点:
- 完整的场景副本存储在显存中 ,跨帧持久化
- 仅在变化发生时 稀疏更新(Sparsely Updated)
- 所有 Nanite 网格数据存储在 单个大型资源(Single Large Resources) 中
- 这意味着可以 一次性访问所有数据 ,无需依赖 无绑定资源(Bindless Resources)
- 这些是 GPU 驱动管线(GPU-Driven Pipeline) 的必要基础构件
GPU 驱动管线的基本流程
- 实例可见性判定 :对每个视图,通过 单次 Dispatch 确定可见实例
- 绘制 :若仅绘制深度,可用 单个 Draw Indirect 光栅化所有三角形
- 优势:获得了 GPU 驱动的全部好处
问题:不可见三角形的浪费
- 即便有了 GPU 驱动管线,仍然为大量 不可见的三角形 做了不必要的工作
三角形簇剔除(Triangle Cluster Culling)
基本概念
- 将三角形分组为 簇(Clusters) ,为每个簇构建 包围盒(Bounding Box)
- 基于包围盒对簇进行剔除
剔除方式
- 视锥剔除(Frustum Culling) :排除视锥外的簇
- 遮挡剔除(Occlusion Culling) :排除被其他物体遮挡的簇
- 基于锥体的背面剔除(Cone-Based Backface Culling) :实践中通常 不值得做 ,因为几乎所有背面朝向的簇都会被遮挡剔除淘汰
层级 Z 缓冲区遮挡剔除(HZB Occlusion Culling)
- HZB(Hierarchical Z-Buffer) :层级 Z 缓冲区
- 算法流程:
- 计算簇包围盒的 屏幕空间矩形(Screen Rect)
- 找到该矩形恰好覆盖 4×4 像素 的 最低 mip 层级
- 在该 mip 上测试簇是否 被完全遮挡
遮挡剔除与可见性驱动的渲染管线
两遍遮挡剔除(Two-Pass Occlusion Culling)
核心问题
- 要使用 层级 Z 缓冲(HZB) 做遮挡剔除,但在第一帧开始时我们 还没有渲染任何东西 ,没有深度信息可供测试
- 某些游戏尝试将 上一帧的 Z 缓冲重投影(Reproject) 到当前帧,但这种方法 始终是近似的,不具备保守性(Not Conservative) ——可能导致漏剔除或错误剔除
核心假设
- 上一帧可见的东西,这一帧大概率仍然可见 ——这个假设在实践中非常有效
- 上一帧的深度缓冲所代表的那些可见表面,是很好的 遮挡体(Occluders) 候选
- 但与其重投影深度缓冲,不如 重投影产生那些深度的几何体本身 ——即把上一帧确定为可见的几何体重新绘制
两遍流程
| 遍次 | 操作 |
|---|---|
| 第一遍 | 绘制 上一帧确定为可见 的所有几何体,用其结果构建 HZB |
| 第二遍 | 用 HZB 测试 本帧新出现但上一帧不可见 的几何体,将通过测试的新增几何体也绘制出来 |
至此,我们拥有了一个 state-of-the-art 的三角形渲染管线 。接下来的问题是:如何超越"只画深度",支持完整的材质渲染?
可见性与材质的解耦
为什么要将可见性与材质评估分离?
目标:逐像素确定可见性(深度缓冲光栅化已做到的事) 与 材质计算(Shading) 完全解耦。解耦带来以下收益:
- 消除光栅化期间的着色器切换(Shader Switching)
- 消除材质评估的过度绘制(Overdraw) ,或避免需要一个额外的 depth pre-pass 来规避 overdraw
- 消除像素四边形低效(Pixel Quad Inefficiency) ——对 高密度网格 尤为关键,而 Nanite 正是要渲染极致密集的网格
方案比较
方案一:对象空间着色(Object Space Shading)
- 形式:光线(Rays)或 纹理空间着色(Texture Space Shading)
- 致命问题 :对象空间方案通常会 过度着色(Over-shade) ,因子可达 4 倍或更多
- 通过 缓存(Caching) 来解决过度着色成本很诱人,但 视角相关效果(View-dependent) 、 动画 和 非 UV 材质 在实际项目中太普遍,不能作为渲染基础
方案二:延迟材质 / 可见性缓冲(Deferred Materials / Visibility Buffer) ✅
- 这是 Nanite 的最终选择
可见性缓冲(Visibility Buffer)
基本思路
在光栅化阶段,向屏幕写入 最少量的几何信息 :
- 深度(Depth)
- 实例 ID(Instance ID)
- 三角形 ID(Triangle ID)
材质着色阶段
逐像素执行材质着色器,流程如下:
- 加载 Vis Buffer :读取当前像素的 Instance ID + Triangle ID
- 加载三角形数据 :获取该三角形的三个顶点
- 变换至屏幕空间 :将三个顶点位置投影到屏幕
- 计算重心坐标(Barycentric Coordinates) :根据当前像素位置与三个投影顶点的关系
- 插值顶点属性 :利用重心坐标对 UV、法线等属性进行插值
"听起来很疯狂,但实际并没那么慢"
- 大量缓存命中(Cache Hits) :相邻像素大概率属于同一三角形或相邻三角形,数据局部性极好
- 零过度绘制(No Overdraw) :每个像素只着色一次
- 零像素四边形低效(No Pixel Quad Inefficiency)
与现有延迟渲染器的整合
- Nanite 的 Vis Buffer 着色阶段最终输出到 G-Buffer ,而非直接进行最终着色
- 这是为了与 UE 现有的 延迟着色管线(Deferred Shading Renderer) 集成
- 仍有充分理由保持延迟着色架构(讲者未在此展开)
性能特征总结
| 特性 | 说明 |
|---|---|
| 单次 Draw Call | 所有不透明几何体通过一次绘制调用完成,完全 GPU 驱动 |
| CPU 开销 | 与场景中/视野内的 物体数量无关 |
| 材质开销 | 每种材质着色器一次 draw,数量远少于物体数量 |
| 光栅化次数 | 每个视图只需 光栅化一次 ,无需多遍来减少 overdraw |
从线性到常数:为什么需要 LOD
线性扩展的瓶颈
- 当前管线虽然快了很多,但渲染开销仍然 随实例数和三角形数线性增长
- 实例数线性增长 :在一定范围内可以接受(当前能轻松处理 百万级实例 )
- 三角形数线性增长 :不可接受 ——无法实现"不管扔多少模型进去都能跑"的目标
光线追踪为什么也不够?
- 光线追踪是 ,比线性好,但仍然不够:
- 内存问题 :这些场景的全部数据 根本放不进内存 ——虚拟几何的一个重要维度就是内存管理
- 性能问题 :即使放得下,对目标平台来说 还是不够快
- 需要 比 更好 的方案
思维转换:屏幕驱动而非场景驱动
屏幕上只有那么多像素,为什么要绘制比像素还多的三角形?
- 以 Cluster 为单位来思考:每帧绘制的 Cluster 数量应该恒定 ,不受物体数量或模型密度影响
- 虽然完美做到这一点不现实,但总体原则是:渲染几何的开销应随屏幕分辨率缩放,而非随场景复杂度缩放
- 这意味着相对于场景复杂度是 常数时间(Constant Time)
- 常数时间 = 需要 LOD(Level of Detail)
基于 Cluster 的 LOD 层级结构
基本概念
- 用 Cluster 构建一棵 层级树(Hierarchy Tree) :
- 父节点 是 子节点的简化版本
- 运行时找到一个 树的切面(Cut) ,使其匹配目标 LOD 级别
关键特性
| 特性 | 说明 |
|---|---|
| 同一网格不同部分可以处于不同 LOD | 基于各 Cluster 自身的需求决定 |
| 视角相关(View-dependent) | 根据 Cluster 的 屏幕空间投影误差(Screen Space Projected Error) 决定 |
| 父代替子 | 当判断"从当前视角看不出区别"时,绘制父节点而非子节点 |
LOD 选择的判定条件
- 父节点代替其子节点绘制的条件:从当前视角来看,你无法分辨两者的差异
实现虚拟化几何的核心
- 无需将整棵树同时驻留在内存中 :可以在树的任意位置标记一个切面作为"叶子",不存储切面之后的更精细数据
- 像 虚拟纹理 一样按需加载:
- 需要子节点但不在 RAM 中 → 从磁盘请求
- 子节点在 RAM 中但长时间未使用 → 逐出(Evict)
解决裂缝问题(Crack-Free LOD)
裂缝的根源
- 当 相邻的独立 Cluster 做出不同的 LOD 决策 时,它们边界上的边不再匹配,产生 裂缝(Cracks)
朴素方案:锁定共享边界
- 在简化时 锁定 Cluster 之间的共享边界边(Lock Shared Boundary Edges) ,使独立 Cluster 在边界处始终一致
- 严重问题 :
- 同一边界会 跨越多个层级持续存在
- 被锁定的边界处会累积 密集的三角形残渣(Dense Triangle Cruft)
- 如果能在层级树中 从 LOD 0 到根节点画一条线而不穿过任何边 ——那条线所在的边界将在每一层都被锁定且无法简化
- 在平衡树中,正中间那条线 就是这样的边界
- 这两棵子树之间的边界会积累如此多的残渣,以至于每层的三角形数无法减半——整个机制彻底崩溃
Nanite 的解决方案:Cluster 分组 + 交替边界
核心思路
- 将 Cluster 分组(Grouping) ,强制同组 Cluster 做出 相同的 LOD 决策
- 同组 Cluster 因为永远不会出现层级差异,所以 不可能产生裂缝
- 同组内的共享边可以 解锁 ,像内部边一样自由坍缩简化
关键约束
- 不能将 所有 Cluster 都归为一组 ——否则退化为传统离散 LOD
- 只在 需要的地方 分组
关键创新:交替边界(Alternating Group Boundaries)
- 逐层交替分组边界 :不同层级分组不同的 Cluster
Level 0: | A | A | B | B | ← 组边界在中间
Level 1: | C | C | C | D | ← 组边界换了位置
Level 2: | E | E | E | E | ← 又换了位置
- 效果:
- 一层中的边界 → 在下一层变成组的内部
- 被锁定的边 只会锁定一层 ,到下一层就被解锁
- "锁一层,解一层"(Locked One Level, Unlocked the Next) ——避免了残渣积累
这是 Nanite 解决裂缝问题的核心巧思:通过交替分组边界,确保没有任何边界会被永久锁定,从而使简化过程在每一层都能有效进行。
Nanite 构建操作与运行时 LOD 选择
离线构建操作总览
构建流程步骤
整个 DAG(有向无环图)的构建是一个 自底向上的迭代过程 :
- 构建叶子 Cluster :将原始网格的三角形划分为每个 128 三角形 的 Cluster
- 对每个 LOD 层级 重复以下操作:
- 分组(Group) :将相邻 Cluster 分组
- 合并(Merge) :将组内所有 Cluster 的三角形合并为一个共享列表
- 简化(Simplify) :将三角形数量 减半
- 拆分(Split) :将简化后的三角形列表重新拆分为新的 Cluster
- 终止条件 :重复上述过程直到 根节点只剩一个 Cluster
图解说明
以 4 个相邻 Cluster 为例:
| 步骤 | 操作 | DAG 变化 |
|---|---|---|
| 初始 | 4 个相邻 Cluster(各含若干三角形) | DAG 底层 4 个节点 |
| 合并+简化 | 合并后三角形数减半 | 4 个子节点获得一个新的简化父节点 |
| 拆分 | 简化后的三角形列表拆分为 2 个新 Cluster | 父节点一分为二,每个子节点仍连接到两个父节点 |
结果:4 个 Cluster → 2 个 Cluster,新 Cluster 放回分组池继续迭代。
为什么是 DAG 而不是树?
- 合并步骤 把多个 Cluster 合为一组, 拆分步骤 又把它们分开——这导致 子节点可能连接到多个父节点 ,形成 DAG 而非简单树
- 这是 好事 :在一棵树中,从 LOD 0 到根节点的路径不可能不穿过某条边,意味着 不会有锁定边永远被锁定并持续累积误差(cruft)
- DAG 结构确保了 锁定边(Locked Edges)会在更高 LOD 层级被释放
图划分(Graph Partitioning)
Cluster 分组策略
- 目标:将共享 最多边界边(Boundary Edges) 的 Cluster 分到同一组
- 原因:共享边界边越多 → 锁定边越少 → 简化器自由度越大 → 简化质量越高
问题建模
这是一个经典的 图划分问题(Graph Partitioning Problem) :
- 图节点 :每个 Cluster
- 图边 :连接有直接相邻三角形的 Cluster 对
- 图边权重 :两个 Cluster 之间 共享三角形边的数量
- 额外边 :为 空间上接近但未直接相连 的 Cluster 添加额外图边,避免图中出现孤岛导致任意分组
优化目标
- 最小边割(Minimum Edge Cut) :使跨分区的边权重总和最小
- 最小边割 = 该 LOD 层级上 整个网格锁定边数量最少 ——这正是我们想要优化的理想目标
实现
- 图划分是非常复杂的组合优化问题,使用成熟的开源库 METIS 来完成
叶子 Cluster 的构建
多维优化困境
构建初始叶子 Cluster 时需要同时考虑多个目标:
- 最小化 Cluster 包围盒范围 ——提升剔除效率
- 每个 Cluster 的三角形数尽量接近但不超过 128 ——配合光栅化器
- 顶点数不超过上限 ——配合 Primitive Shader 的约束
- 最小化 Cluster 间的边界边数 ——给简化器最大自由度
无法同时优化所有维度,因此 选择其中两个维度优化,期望其余维度因相关性而表现不错 。
实际选择
- 优化 边界边数量 和 每 Cluster 三角形数量
- 最小化 Cluster 间共享边 = 与 Cluster 分组完全相同的图划分问题
- 区别:此处图是 网格的对偶图(Dual of the Mesh) ,且有 严格的分区大小上限(≤128 三角形)
- METIS 等图划分算法 不保证严格上限 ,但通过 少量松弛(Slack)和回退策略(Fallbacks) 可以解决极少数失败情况
与拆分步骤的统一
- 构建过程中的 拆分步骤(Split) 本质上 与初始 Cluster 构建完全相同 :都是"给定一组三角形,划分为 128 个一组的 Cluster"
简化(Simplification)
算法选择
- 使用经典的 边折叠简化(Edge Collapsing Decimation)
- 误差度量采用 二次误差度量(Quadratic Error Metric, QEM)
- 同时优化 新顶点位置 和 属性(法线、UV 等) 的误差最小化
误差估计——最具挑战的部分
- 简化器返回一个 引入误差的估计值
- 该误差值后续会被 投影到屏幕空间 ,转换为 像素误差(Pixel Error) ,用于运行时 LOD 选择
- 因此误差估计对 质量和效率都是基础性的
Brian 估计在误差估计这个问题上花费了 约一人年的工作量 。
为什么误差估计如此困难?
这在某种意义上是一个 不可能的任务 :
- 构建阶段 不知道网格会使用什么材质
- 不知道表面 多光滑 (无法判断法线误差的视觉影响)
- 不知道 UV 误差 在具体纹理下有多明显
- 不知道 顶点颜色 被用来做什么
当前方案 并不完美 ,偶尔会失败(虽然很少被注意到),且可以通过融入更多 人类感知因素 来变得更激进。
运行时 LOD 选择
基本原理
- 构建操作的每次迭代产生 两组 Cluster :三角形数不同,但 共享相同的边界
- 因此它们可以 互换使用而不产生裂缝 ——这就是 LOD 系统的本质
- 运行时根据 屏幕空间误差 在父子 Cluster 之间选择
误差投影
- 在 Cluster 的 包围球 内找到 使投影误差最大化的点 来计算
- 确保误差估计是保守的
并行化 LOD 决策
关键问题:组内 Cluster 必须做出一致的 LOD 决策
- 解法极其简单: 使用相同的输入数据做决策 → 相同的输入产生相同的输出
- 组内所有 Cluster 存储 相同的联合误差值(Union Error Value) 和 用于投影的球形包围体
定义"切割"(Cut)
LOD 选择 = 在 DAG 上找一个 视角相关的切割 :
- 切割发生在 :父节点误差太大("不"),但子节点误差足够小("是")
- 即 "父说不,子说是"
为什么可以完全并行?
- 切割判断是 完全局部的(Entirely Local) ——只依赖当前节点和其父节点,不依赖从根到叶的完整路径
- 前提条件 :切割必须是 唯一的 ——从根到叶的每条路径上,选择函数从"否"变为"是" 只发生一次,永不反转
保证单调性(Monotonicity)
- 选择函数是对 视角相关误差函数的阈值判定
- 保证路径上唯一切换 = 保证误差函数在每条路径上 单调递减
- 离线构建时强制实现 :父节点存储的误差和投影用包围体 必须至少与其子节点一样大
LOD 切换时的视觉跳变(Popping)
- 问题 :父子 Cluster 切换时是否会产生可见的"跳变"?是否需要 Geomorphing 或 Cross Fading ?
- 答案 :不需要!
- 只绘制 误差小于一个像素 的 Cluster → 切换差异 在感知上不可察觉
- TAA(时间抗锯齿) 天生用于混合亚像素差异,会自动平滑任何切换变化
这就是为什么精确的误差估计如此关键 ——只要误差确实是亚像素级的,TAA 就能替我们完成平滑过渡。
运行时加速结构
问题:大规模场景的效率
- 完全并行的 Cluster 选择在大场景中 极其浪费 ——绝大多数 Cluster 太精细,根本不会被选中
- 需要 快速拒绝(Quick Rejection) 机制 → 需要层级结构
BVH 加速
- 不使用 DAG 结构来遍历 ——并行遍历 DAG 过于复杂
- 由于 LOD 决策是 完全局部的 ,可以构建 任意加速结构 来加速测试
剔除逻辑
- 可以剔除的 Cluster:父节点误差已经足够小的 Cluster (因为父节点会被选中,子节点无需考虑)
- 关键洞察:加速结构应基于父误差(Parent Error)而非 Cluster 自身误差
BVH 构建
- 在 Cluster 上构建 BVH ,父节点保守地包围子节点
- 包围信息包含 父误差 (因为父节点在组中共享)
- BVH 叶节点是 组大小的 Cluster 列表
朴素并行遍历的问题
| 问题 | 说明 |
|---|---|
| 多 Pass 逐层处理 | 每个 Pass 处理树的一层,将通过测试的子节点写入缓冲供下一 Pass 处理 |
| GPU 完全排空(Drain) | 每层之间有依赖,GPU 在每一层之间被完全排空 |
| 空 Dispatch | CPU 不知道递归深度,必须发出覆盖最坏情况的 Dispatch 数量,导致大量 空 Dispatch |
| 高分支因子的权衡 | 提高分支因子可减少层数,但也带来其他低效 |
这些问题需要更高级的 并行工作调度(Parallel Work Scheduling) 方案来解决(如持久线程、工作窃取等)。
持久线程、两遍遮挡剔除整合与软件光栅化
持久线程(Persistent Threads)
动机
- 理想情况下,当 BVH 中某个父节点通过剔除测试后,我们希望 立即开始处理其子节点 ,而非等待同一层级所有节点都处理完毕
- 最理想的是能从 Compute Shader 中 直接派生新线程 ,但当前 GPU 编程模型 没有提供这种能力
核心思想
- 与其派生新线程,不如 复用现有线程 ,通过 自定义作业队列(Job Queue) 分发工作
- 这就是 持久线程(Persistent Threads) 技术——本质上是在 GPU 上实现一个 迷你作业系统(Mini Job System)
工作方式
- 启动时 :派发 刚好足以填满 GPU 的线程数
- 运行时 :每个线程反复执行以下循环:
- 从队列中 弹出(Pop) 一个节点
- 处理该节点 (执行剔除测试)
- 将 通过测试的子节点推入(Push) 队列
- 终止条件 :队列为空
优势
| 方面 | 改进 |
|---|---|
| Dispatch 次数 | 从每层一次 → 全程仅一次 Dispatch |
| 层级数限制 | 不再有限制 |
| GPU 排空问题 | GPU 不再需要反复排空(Drain)再重启 |
| 性能 | 比朴素方法平均 快 25% |
未定义行为的风险 ⚠️
- 持久线程中的阻塞算法依赖一种 D3D 和 HLSL 规范未定义 的调度行为
- 关键假设 :一旦某个线程组(Thread Group)开始执行并持有锁,它 应当持续被调度,不会被无限期饿死(Starved)
- 虽然未定义,但 在所有测试过的相关 GPU 上均能正常工作
将 Cluster 剔除整合进持久线程
问题
- BVH 遍历可能非常深,而某一时刻 活跃节点数可能远小于 GPU 宽度
- 当 BVH 剔除无法填满 GPU 时,少数深度遍历的 延迟(Latency)会主导整个剔除执行时间
解决方案
- 将 Cluster 剔除 也整合进持久层级剔除着色器(Persistent Hierarchy Culling Shader)
- 在 BVH 剔除的空闲间隙中 提前启动 Cluster 剔除 ,填补 GPU 空洞
实现细节
- 引入 额外的 Cluster 队列
- 当工作线程等待新节点出现在节点队列中时,可以 转而处理已发现的 Cluster
- 为避免 线程发散(Divergence) ,以 批处理(Batch) 方式执行
两遍遮挡剔除与 Nanite 的整合
新问题
传统两遍遮挡剔除中,需要追踪 上一帧的可见集合 ,但在 Nanite 中:
- LOD 选择帧间可能不同 ——上一帧的 Cluster 和这一帧的未必一致
- 上一帧的可见 Cluster 可能因为 流式加载(Streaming) 已经 不在内存中了
Nanite 的解决方案
不追踪"上一帧可见了什么",而是测试 当前选择的 Cluster 在上一帧是否会可见 :
- 用 上一帧的 HZB + 上一帧的变换矩阵 测试当前 Cluster 的包围盒
完整两遍流程
| 步骤 | 操作 |
|---|---|
| Pass 1a | 用 上一帧 HZB + 上一帧 Transform 测试当前选择的 Cluster |
| Pass 1b | 绘制判定为可见的 Cluster,将被遮挡的 保存起来 |
| Pass 1c | 从当前深度缓冲 构建本帧初始 HZB |
| Pass 2a | 用 本帧 HZB + 本帧 Transform 重新测试之前被判定遮挡的 Cluster |
| Pass 2b | 绘制 现在可见但之前被遮挡 的 Cluster(去遮挡区域) |
| Pass 2c | 从完整深度缓冲 构建完整 HZB 供下一帧使用 |
性能注意事项
- 整个 Nanite 管线几乎 运行两次 ,但第二遍通常只是 主遍的极小比例(仅处理去遮挡区域)
- 非遮挡相关的剔除只执行一次 :视锥剔除(Frustum Culling)和 LOD 剔除仅在第一遍执行
完整剔除流程总览
Pass 1(主遍):
GPU Scene 实例 → 逐实例可见性测试
→ 可见实例 → 持久线程层级 Cluster 剔除(LOD + 可见性)
→ 可见 Cluster → 光栅化到 Visibility Buffer
→ 构建当前帧 HZB
Pass 2(去遮挡遍):
Pass 1 中被遮挡的实例/节点/Cluster
→ 用当前帧 HZB + 当前 Transform 重新测试
→ 新发现的可见 Cluster → 光栅化
→ 构建最终 HZB(供下一帧使用)
→ 执行延迟材质 Pass
三角形密度与屏幕分辨率的关系
核心问题
- 有了细粒度视角相关 LOD,渲染开销 主要与屏幕分辨率成正比
- 目标是 零感知质量损失 :三角形需要小到其误差 小于一个像素
- 像素级特征需要像素级三角形来表达 ——大多数情况下无法避免
但像素级三角形对传统光栅化器是灾难
这引出了 Nanite 最具创新性的部分——软件光栅化器。
软件光栅化器(Software Rasterizer)
为什么能击败硬件?
- 硬件光栅化器被设计为 在像素维度上高度并行 ——这是其典型工作负载(大三角形覆盖大量像素)
- 现代 GPU 每时钟 最多设置 4 个三角形 ,输出 Vis Buffer 所需的 Primitive ID 使情况更糟
- Primitive Shaders / Mesh Shaders 虽然更快,但仍存在瓶颈
Nanite 的工作负载正好相反
| 传统光栅化 | Nanite 微多边形 |
|---|---|
| 少量大三角形,每个覆盖大量像素 | 大量小三角形,每个只覆盖几个像素 |
| 并行化方向:像素 | 并行化方向:三角形 |
- 传统光栅化器为大三角形设计的大量操作,对微多边形场景 不适用或极其低效
- 软件光栅化比最快的 Primitive Shader 实现平均 快 3 倍 ,纯微多边形场景提升更大
深度测试:64 位原子操作
放弃硬件光栅化也意味着 失去 ROP 和硬件深度测试 ,替代方案:
- 使用 64 位原子操作(64-bit Atomics) :
InterlockedMax到 Visibility Buffer - 64 位整数的布局:
[63 ─── 高位 ─── 30 | 29 ─── 低位 ─── 0]
深度(Depth) 载荷(Payload)
Cluster Index + Triangle Index
- 深度在高位 →
InterlockedMax天然实现了 深度测试(更近的片元深度值更大时取 max;实际实现中会做反转) - 载荷在低位 → 同一原子操作中捎带写入 Cluster Index + Triangle Index
Visibility Buffer 的真正威力在此显现 :载荷必须 小到能塞进 ≤34 位 。没有这个约束,快速软件光栅化就不可能实现。
软件光栅化器的具体实现
基本结构(类似 Mesh Shader)
- 线程组大小 :128
- 共享顶点工作 ,无需 Post-Transform Cache
两阶段执行
| 阶段 | 线程映射 | 操作 |
|---|---|---|
| 阶段 1:顶点处理 | 1 线程 = 1 顶点 | 从 Cluster 顶点缓冲 Fetch 顶点位置 → 变换 → 写入 Group Shared Memory 。若顶点数 > 128,每个线程额外处理一个(支持每 Cluster 最多 256 顶点 ) |
| 阶段 2:三角形处理 | 1 线程 = 1 三角形 | Fetch 索引 → 从 Group Shared 读取变换后位置 → 计算 边方程(Edge Equations) 和 深度梯度(Depth Gradient) → 遍历包围矩形内所有像素 → 测试 → 写入 |
基础内循环(Half-Space Rasterizer)
// 遍历三角形包围矩形内的像素
for (y = rectMin.y; y <= rectMax.y; y++)
for (x = rectMin.x; x <= rectMax.x; x++)
{
// 测试像素中心是否在三条边内侧
if (insideAllThreeEdges(x, y))
{
// 打包深度 + 载荷,原子写入
uint64_t packed = packDepthAndPayload(depth, clusterIdx, triIdx);
InterlockedMax(visBuffer[pixel], packed);
}
}- 对微多边形来说,内循环 迭代次数极少 ——不应增加任何固定开销来试图优化它
- 整个光栅化器经过 指令级微优化(Instruction-Level Micro-Optimization)
Scanline 优化版本
动机
- 当三角形略大时,包围矩形中可能有大量像素需要逐一测试
- 最好情况 只有一半被覆盖, 最坏情况 一个都不覆盖——大量浪费
改进思路
- 既然边方程是线性的,对于每一行 y,直接求解 x 的通过区间 ,只遍历覆盖的像素
for (y = rectMin.y; y <= rectMax.y; y++)
{
// 求解这一行中三条边方程同时满足的 x 区间 [xStart, xEnd]
[xStart, xEnd] = solveXInterval(y, edges);
for (x = xStart; x <= xEnd; x++)
{
// 无需再测试边——已知在内部
uint64_t packed = packDepthAndPayload(depth, clusterIdx, triIdx);
InterlockedMax(visBuffer[pixel], packed);
}
}注意事项
- 求解 x 区间涉及 除法 ,不再是精确的定点数学——但实践中 未发现问题
- 选择策略 :如果 Wave 中任何三角形的 x 循环 大于 4 像素 ,则使用 Scanline 版本
软件 vs 硬件光栅化的混合策略
分流规则
- 按 Cluster 为单位 选择软件或硬件光栅化,依据 哪个更快
- 软件光栅化条件 :Cluster 中三角形的边长 小于 32 像素
- 实际 Demo 中 绝大多数 Cluster 走软件路径
接缝问题
- 最初担心软件和硬件光栅化的 Cluster 之间会有 裂缝(Cracks)
- 但 DirectX 对光栅化规则有 非常严格的规范 ,只要严格遵循就能 精确匹配硬件行为 ——无像素裂缝
硬件光栅化路径的特殊实现
- 硬件路径 不绑定任何颜色或深度目标(Render Target)
- 同样通过 原子写入 UAV ,与软件路径 完全一致
- 原因:
- 如果使用深度测试硬件,就需要 合并 UAV 版本和 Render Target 版本
- 且无法进行 异步重叠(Async Overlap) ——而 Nanite 正依赖这一点
与硬件深度剔除的对比分析
Nanite 没有的硬件功能
| 硬件功能 | Nanite 等价物 | 差异 |
|---|---|---|
| 逐三角形剔除(Per-Triangle Culling) | ❌ 无 | 类似于没有 Hardware Early-Z |
| Hardware Hi-Z | HZB Cluster 剔除(Cluster 粒度) | 对密集网格,Cluster 大小 ≈ HZB Tile 大小,类比尚可 |
缺少逐三角形剔除的代价
- 多数情况 不是问题
- 问题场景 :
- 表面重叠距离 小于 Cluster 包围盒 的热点区域
- 布满 小孔洞 的几何体——听起来像内容问题,但实际上描述了大多数 聚合几何体(Aggregate Geometry) ,如 树叶、草
- 过度绘制(Overdraw)是 Nanite 对这类几何体表现不佳的众多原因之一
像素填充开销与反直觉现象
问题
- 并非只有三角形工作量重要, 像素填充也可能很昂贵
- 网格不总是密集的:大三角形 → 大 Cluster → 剔除粒度更粗(以像素计) → 像素级过度绘制增加
反直觉结论
在某些情况下,绘制更多三角形反而更快!
- 更多三角形 → 更小的 Cluster → 更细的剔除粒度 → 更少的像素过度绘制
- 三角形工作量增加的开销可能 小于 像素过度绘制减少带来的收益
微小实例、材质系统与虚拟阴影贴图
微小实例问题(Tiny Instances)
问题描述
- Nanite 已经针对 微小三角形(Tiny Triangles) 做了大量优化,但当 整个网格实例(Instance)只覆盖几个像素 时,会发生什么?
- Cluster 层级结构 有尽头 ——最终收敛到一个 包含 128 三角形的根 Cluster ,到这一步后 开销不再随屏幕分辨率缩放 ,完全停止缩放
- 虽然"128 三角形覆盖几个像素"已经很小了,但 不能简单剔除微小实例 ——如果它们是 结构性构件(Structural Building Blocks) ,例如远处建筑的墙段,剔除小实例可能导致 整栋建筑消失
实际严重性
- 自从美术拿到 Nanite 后,他们对 实例数量的推高程度不亚于甚至超过多边形数量
- Epic 办公室的玩笑: "实例就是新的三角形(Instances are the new triangles)"
- 微小实例 确实是一个真实问题 ,给多个系统带来压力
长期方案:合并(Merging)
- 远处的景观最终需要 流式切换为更廉价的代理(Proxy)
- 即使渲染开销能完美缩放,存储实例所需的内存不会缩放 ——光是变换矩阵(Transforms)本身最终都会超标
- 未来希望支持 层级化实例(Hierarchical Instancing) 来改变这个等式,但并非适用于所有情况
- 远处需要 通用合并(General Purpose Merging) ,但合并意味着 唯一数据(Unique Data) ,高分辨率唯一数据会 迅速失控
- 不想引入类似 Mega Texture / Mega Geometry 那样的世界尺度限制
- 理想目标:几何体在远处仍然 像素完美,无感知质量损失
- 因此合并代理需要 尽可能推到最远处 ,在此之前需要一个过渡方案
当前方案:可见性缓冲冒名者(Visibility Buffer Imposters)
基本原理
- 本质上是 经典的静态冒名者(Static Imposters) ,但有一个关键改动
- 不存储颜色或 G-Buffer 属性 ,而是存储:
- 深度(Depth)
- 来自根 Cluster 的三角形 ID(Triangle ID)
- 这些数据可以 直接注入屏幕的 Visibility Buffer ,支持:
- 材质重映射(Material Remapping)
- 非均匀缩放(Non-uniform Scale)
- Nanite 支持的其他所有特性
质量与局限
| 方面 | 表现 |
|---|---|
| 一般情况 | 效果相当好 |
| 问题场景 | 大量相同网格相邻排列时(如重复墙段),切换时可见 弹跳(Pop) |
| 原因 | 批量变化 + 直接相邻的参照物使差异明显 |
| 内存 | 仍占用较多内存,希望未来替换为更快、更省内存、 真正无缝 的方案 |
材质系统(Materials)
从 Visibility Buffer 到材质着色
- 解码 Visibility Buffer 后,我们基本拥有了 像素着色器通常拥有的一切信息
- 可以绘制一个覆盖全屏的四边形(Quad),解码 Vis Buffer 后执行材质像素着色器——就好像材质是在光栅化期间绑定的一样
- 顶点变换在 Nanite 中是 固定功能(Fixed Function) ,但要支持 美术创建的完整像素着色器
核心问题:如何知道每个像素属于什么材质?
- 材质 ID 可以从 Vis Buffer 推导 :通过 Instance ID + Triangle ID 查找对应材质
- 理论上可以用 Callable Shaders 在单遍中应用所有材质,但存在复杂性和低效问题
实际方案:逐材质全屏绘制 + 深度测试加速
基本流程
- 由于所有剔除都是 GPU 驱动 ,CPU 不知道哪些材质可见 ,材质 Draw Call 必须 无条件发出
- 为每个唯一材质绘制一个四边形,跳过不匹配该材质的像素
高效的像素筛选:利用深度测试硬件
- 如果逐像素测试每个材质,效率极低
- 核心技巧 :将 材质 ID 编码为深度值(Material ID becomes Depth Value)
- 在主机(Console)上可以 别名内存(Alias Memory) 并控制布局,使方案高效
实现细节
| 步骤 | 操作 |
|---|---|
| Compute Shader 输出 | 同时输出 材质深度(Material Depth) 和 标准深度(Standard Depth) ,以及 HTile 数据用于 Hi-Z 加速 |
| 材质绘制 | 对每个材质绘制四边形,四边形 Z 值 = 材质 ID,深度测试设为 等于(Equals) ——只有匹配的像素通过 |
| Hi-Z 剔除 | 大多数材质只覆盖屏幕的 很小比例 ,Hi-Z 能跳过大量不相关的区域 |
进一步优化:瓦片化绘制
- 不绘制全屏四边形,而是绘制一个 瓦片网格(Grid of Tiles)
- 瓦片根据一个 32 位掩码(32-bit Mask) 来剔除——该掩码在写入材质深度时构建
- 只绘制包含该材质的瓦片
⚠️ 这个区域正在 大幅重构 ,未来可能 完全切换到 Compute Shader 实现。上述描述的是两次 Demo 展示中的 主机路径(Console Path) 。
解析导数替代有限差分(Analytic Derivatives)
问题
- 材质着色仍然是 连贯的像素着色器 ,因此仍有 基于有限差分的导数(Finite Difference Derivatives) 可用于纹理过滤
- 但与传统光栅化不同,Nanite 的 像素四边形(Pixel Quads)跨越了不同三角形
- 这在传统管线中是 好事 ——微小三角形的四边形过度绘制(Quad Overdraw)不再失控
- 但四边形也会 跨越深度不连续处、UV 接缝、甚至不同物体 ——这是 坏事
有限差分在不连续处的后果
- 不连续处的有限差分 毫无意义 ,且通常 值极大
- 导致使用 过高的 Mip 层级 ,产生明显的模糊/伪影
解决方案:解析导数(Analytic Derivatives)
- 计算三角形上属性的解析导数 ——直接从三角形几何关系推导
- 通过美术创建的 节点图(Node Graph) 使用 链式法则(Chain Rule) 自动传播 导数
- 遇到 不可解析微分的操作 时,回退到有限差分
- 所有
Sample调用替换为SampleGrad,传入解析计算的梯度
性能开销
- 看起来应该增加显著成本,但实测 开销不到 2%
- 原因:
- 额外工作 仅限于影响纹理采样的操作
- 所有 虚拟纹理采样已经在使用 SampleGrad (为了处理虚拟纹理的瓦片不连续性)
整体管线性能数据
场景规模对比
| 指标 | UE4 标准路径 | Nanite |
|---|---|---|
| 光栅化三角形数 | 超过 10 亿 | 2500 万 |
| 一致性 | 随场景复杂度线性增长 | 无论场景多复杂,始终约 2500 万 |
渲染分辨率
- 使用 动态分辨率(Dynamic Resolution) + 时域上采样(Temporal Upsampling) 至 4K
- 平均帧在上采样前约 1400p
GPU 耗时
| 阶段 | 平均耗时 |
|---|---|
| 全部几何剔除 + 光栅化 (从 GPU Scene + 视图 → 完整 Vis Buffer) | 2.5 ms |
| 材质应用 + Vis Buffer → G-Buffer | 2.0 ms |
CPU 耗时
- 几何阶段因全部 GPU 驱动 ,CPU 开销 可忽略不计
- 材质阶段有 少量 CPU 开销 :每个材质一个 Draw Call
- 完全在 60 Hz 游戏预算内
阴影渲染:虚拟阴影贴图(Virtual Shadow Maps)
为什么阴影需要特别对待?
- 主视图不是唯一需要绘制几何体的地方——阴影也需要
- 间接光照或许不需要微多边形级别的细节,但 阴影需要精细的几何体
- 真实几何体与法线贴图的 最大视觉差异 往往来自 精细的自阴影(Self-shadowing)
为什么不用光线追踪阴影?
- 阴影光线比主射线更多 ——平均每像素超过一个光源
- 需要至少 和主视图一样快 的方案
- 当前硬件光追 API 存在限制:
- 不够灵活 ,无法评估 Nanite 的复杂 LOD 逻辑
- 要适配其特定三角形格式会 大幅膨胀内存
- 缺乏部分更新 BVH 的能力 ,避免不了昂贵的百万级元素 BVH 从零构建
- 未来会进一步探索 光追 Nanite ,但当前使用 光栅化方案 以复用已有的全部工作
光源数量的严峻挑战
- 每像素平均超过一个光源 ,Nanite 的开销 绝不能因阴影而失控式倍增
- 幸运的是,大多数光源和投射阴影的几何体 都是静态的 ——如果能 缓存这些工作 ,就有可能控制住开销
虚拟阴影贴图方案
核心参数
| 参数 | 值 |
|---|---|
| 阴影贴图分辨率 | 16K × 16K (所有光源统一使用) |
| 页面大小(Page Size) | 128 × 128 像素 |
| Mip 0 页面数 | 128 × 128 页 |
| 虚拟化方式 | 稀疏分配(Sparse Allocation) |
分辨率匹配策略
- 对于每个光源,可能存在一个或多个 Shadow Mount
- 光栅化到阴影贴图中的分辨率被设计为 匹配投影到屏幕上的像素密度 ——即阴影贴图中一个纹素 ≈ 屏幕上一个像素
- 如果阴影贴图的某个区域 不会投影到屏幕上的任何东西 ,则 完全不绘制 ,甚至 不分配内存
逐帧页面分配流程
- 遍历屏幕上每个像素
- 对于 影响该像素的所有光源 :
- 将像素位置 投影到阴影贴图空间
- 选择使 一个纹素 ≈ 一个屏幕像素 的 Mip 级别
- 标记该 Mip 级别的对应页面为"需要"
缓存机制
- 支持 阴影缓存(Shadow Caching) :避免重绘 上一帧已经覆盖过的阴影贴图区域
- 实际效果:每帧 只需更新 的区域仅限于:
- 物体移动 导致变化的区域
- 相机移动 导致的视锥边缘新增区域
与传统阴影优化的根本区别
| 传统方案 | 虚拟阴影贴图 |
|---|---|
| 剔除光栅化工作 | 不仅剔除光栅化工作 |
| 仍分配完整阴影贴图内存 | 不分配不会被采样的阴影贴图空间的内存 |
这是 Nanite 新架构使得 以前不实际的技术变得可行 的典型案例。
Nanite 多视图渲染、虚拟阴影贴图、流式加载与压缩
多视图渲染(Multi-View Rendering)
问题:管线启动开销
- Nanite 管线 相当深 ,有显著的 同步开销(Synchronization Overhead)
- 对于全分辨率主视图来说这些开销微不足道,但若为少量工作反复启动管线则 非常低效
- 绝不能 为每个光源调用一次 Nanite 渲染,更不能为每个阴影贴图的每个 Mip 层级分别调用
解决方案:多视图支持
- Nanite 管线接受一个 视图数组(Array of Views) 而非单个视图
- 单次 依赖链式 Dispatch Indirect 即可完成:
- 整个场景的渲染
- 所有光源的所有阴影贴图
- 所有虚拟化 Mipmap 层级
- 极端情况下相比逐个调用获得 100 倍加速
虚拟阴影贴图中的 Nanite 优化
页面级剔除(Page-Level Culling)
- 为避免向 未映射区域(Unmapped Regions) 写入的无用工作,在常规 HZB 测试旁增加 额外的页面需求掩码测试(Needed Pages Mask Test)
- 如果某个实例或 Cluster 的包围矩形 不与任何已请求的页面重叠 ,则直接剔除
虚拟→物理地址转换
由于物理纹理在虚拟空间中 不连续 ,Cluster 可能跨越多个页面,需要特殊处理:
软件光栅化路径
- 内层循环必须 尽可能简单 ——哪怕一条额外的移位指令都是可测量的性能损失
- 方案:对每个重叠的页面,为同一个 Cluster 发射一次可见实例 ,仅做一次页面地址转换,并 裁剪(Scissor)到该页面的像素范围内
- 软件 Cluster 很小,大部分只重叠一个页面 ,所以重复开销极低
硬件光栅化路径
- 硬件 Cluster 更大,常常 跨越多个页面 ,复制顶点和三角形开销不合理
- 方案:逐像素 执行虚拟→物理页表转换
- 因为硬件路径也使用 原子 UAV 写入(Atomic UAV Writes) ,可以自由 散射(Scatter) 写入位置
LOD 选择对阴影的影响
- 阴影贴图中 Nanite 同样按 1 像素误差 选择 LOD
- 此处的"像素"指的是 阴影 Mip 层级的纹素 ,而 Mip 层级的选择保证了 一个纹素 ≈ 一个屏幕像素
- 这维持了 开销随屏幕分辨率缩放而非场景复杂度 的核心特性
自阴影不一致问题(Self-Shadowing Mismatch)
- 阴影贴图绘制的三角形 不一定和主视图完全相同 (因为 LOD 选择基于各自的像素误差独立进行)
- 这种不匹配可能导致 错误的自阴影(Incorrect Self-Shadowing)
- 解决方案:使用 短距离屏幕空间追踪(Short Screen Space Trace) 跨越两者可能不同的区域
虚拟化几何内存(Virtual Geometry Memory)
与虚拟纹理的类比
| 方面 | 相同点 | 不同点 |
|---|---|---|
| 请求模式 | GPU 发现质量不足时请求数据 | 需要遍历层级结构才能找到所需数据 |
| CPU 端 | 异步从磁盘加载数据并满足请求 | 必须维护 DAG 的合法切面(Valid Cut) ,否则几何体会出现裂缝 |
| 数据大小 | — | 几何体是 可变大小 的(顶点数、属性等不固定) |
最小可流式单元:Cluster Group
- 回顾构建过程:简化发生在 Group 级别 ——一个 Cluster Group 恰好被其父节点替换,反之父节点 只能被完整的一组子节点替换
- 因此:不能在一个 Group 中部分 Cluster 加载的情况下开始渲染 ,否则替换不完整
- 结论:流式加载粒度 不能细于 Group ,更细粒度甚至可能导致 错误渲染
固定大小页面填充(Fixed-Size Page Packing)
挑战
- 为避免 内存碎片(Memory Fragmentation) ,使用 固定大小页面(Fixed-Size Pages)
- 但需要在固定大小中装入 可变大小的几何体
页面填充策略
- 将 Cluster Group 填入固定大小页面
- 填充时同时考虑 空间局部性(Spatial Locality) 和 DAG 层级(Level in DAG) ,以最小化运行时需要的页面数
- 根页面(Root Page)始终常驻 ,包含 DAG 顶部尽可能多的数据——确保 无论如何总有东西可渲染
- 所有常驻页面存储在 GPU 上的一个 大型字节寻址缓冲区(Byte Address Buffer) 中
碎片问题与 Group 拆分
- 以完整 Group 为单位填充页面会产生 大量浪费(Slack) ——因为 Group 可以很大(8~32 个 Cluster,每个 Cluster 最大约 2 KB )
- 解决方案 :在 Cluster 粒度 上拆分 Group 为多个部分
- 一个被拆分的 Group 只有当所有部分都加载后 才被视为"活跃"
- 以 Cluster 粒度填充页面,浪费率降至 约 1%
流式加载决策与请求生成
与虚拟纹理的差异
- 虚拟纹理:可以从 UV 坐标 + LOD 级别 直接确定 需要加载的数据
- Nanite:无法从查询位置和 LOD 误差直接确定 ,必须 遍历层级结构(Hierarchy)
层级结构始终常驻
- 遍历时需要查看 超出当前流式切面的节点 ,判断如果它们已加载是否会被渲染
- 剔除层级结构只包含 元信息(Meta Information) ,体积很小
- 因此 始终保持层级结构完全常驻
好处
- 遍历 独立于当前流式切面 ,可以深入数个层级
- 新出现的物体可以 立即请求所有需要的层级数据 ,而非逐层等待
- 如果层级结构也需要流式加载,则 I/O 延迟会被 乘以偏离目标的层级数 ,导致更多可见的 弹入效果(Pop-in)
请求生成流程
- 请求在 层级 Cluster 剔除遍历 中生成
- 每个请求包含 页面范围 + 基于 LOD 误差 的优先级
- 不仅为 主视图 生成,也为 活跃的阴影视图 生成(阴影视图优先级较低)
- 已常驻页面 也会发射请求以 更新其优先级
CPU 端处理
| 步骤 | 操作 |
|---|---|
| 异步回读 | CPU 异步读取 GPU 生成的请求 |
| DAG 依赖补全 | 添加缺失的 DAG 依赖关系 |
| I/O 请求 | 对最高优先级页面发起磁盘 I/O |
| 页面驱逐 | 驱逐低优先级页面以腾出内存 |
| 安装数据 | I/O 完成后将页面数据安装到 GPU |
| 更新数据结构 | 修复加载/卸载页面的指针、处理拆分 Group 的完成/解除状态、标记/取消标记 Cluster 为叶子节点 |
压缩(Compression)
双格式设计
Nanite 有 两种压缩几何体格式 ,表示相同数据但优化目标不同:
| 格式 | 用途 | 要求 | 优化目标 |
|---|---|---|---|
| 内存表示(Memory Representation) | 光栅化和延迟材质 Pass 直接使用 | 近乎瞬时解码 + 随机访问(Vis Buffer 可能查询任意三角形) | 最小化 流式池内存占用 ——更多数据放入缓存 = 更少 Cache Miss = 更少 I/O = 更少 Pop-in |
| 磁盘表示(Disk Representation) | 流式加载时转码为内存表示 | 不需要随机访问;流式频率远低于渲染频率,可使用更复杂技术 | 假设后端有字节级 LZ 压缩,目标是最小化 LZ 压缩后的磁盘占用 |
内存表示的压缩细节
全局量化(Global Quantization)
- 位置和各种属性基于 美术显式控制 与 启发式算法 的组合进行 全局量化
逐 Cluster 局部编码
- 每个 Cluster 在其 局部坐标系 中存储量化值,相对于 Cluster 内值的 最小-最大范围(Min-Max Range)
- 每个分量使用 表示该值范围所需的最小位数
每个 Cluster 拥有专属的顶点格式
- 不使用 全网格或全 Cluster 统一的固定顶点格式
- 每个 Cluster 的顶点格式 专门针对其自身数据范围特化
- 每个顶点只是一个 固定长度的位串(Fixed-Length Bit String) ——无需对齐,甚至无需字节对齐
- 因为 Cluster 内大小固定,仍然支持 随机访问
- 解码相对简单,但需要先获取一个 紧凑的顶点声明(Compact Vertex Declaration) 来解释位的含义
Nanite 三角形编码、压缩与未来展望
三角形索引编码(Memory Representation)
索引压缩策略
| 技术 | 细节 |
|---|---|
| 索引旋转 | 每个三角形的三个索引被旋转,使 最小索引排在第一位 |
| 第一个索引 | 使用 Cluster 内所需的 最少位数 存储(通常 7 位 ,因为 Cluster ≤ 128 三角形) |
| 剩余两个索引 | 存储为相对于第一个索引的 正向 5 位增量(Delta) |
| 5 位窗口保证 | 构建器确保 三角形永远不会跨越超过 32 个索引的窗口 |
每个三角形的索引编码:≈ 7 + 5 + 5 = 17 位/三角形(内存表示)
顶点属性编码
各属性的处理方式
| 属性 | 编码方式 |
|---|---|
| 位置(Position) | 量化后存储 |
| UV | 由于接缝(Seam)常见,使用 排除最大间隙(Exclude Largest Gap) 的编码,每个分量有效变为两个范围 |
| 法线(Normal) | 基于 八面体坐标(Octahedral Coordinates) 编码 |
| 切线(Tangent) | 完全不存储 ——逐像素从三角形的 UV 梯度隐式推导 |
隐式切线的适用性
- 对 Nanite 常用的 高度细分几何体 效果特别好
- 但计划未来支持 显式切线 以覆盖必需的情况
材质分配(Per-Triangle Material Assignment)
设计选择
- 不强制每个 Cluster 只有一种材质 ——这对简化器限制太大,且在构建过程中处理裂缝极其复杂
- 相反,保持为 逐三角形数据 ,但用紧凑方式存储
编码方案
三角形按材质排序 → 存储每种材质的范围(Range)
| 材质数量 | 存储方式 |
|---|---|
| ≤ 3 种(大多数 Cluster) | 材质表直接编入 32 位 |
| > 3 种(罕见情况) | 通过间接引用指向 可变大小表 ,最多支持 64 种材质/Cluster |
- 查找时对范围做 搜索 ,找到包含目标三角形索引的范围
磁盘格式与压缩
设计哲学:适配硬件 LZ 解压
- 利用 硬件 LZ 解压(Hardware LZ Decompression)
- 主机上 已经可用
- PC 上即将通过 DirectStorage 支持
- 硬件 LZ 在 熵编码 + 字符串去重 方面 无可匹敌地快
- 既然硬件解压将成为常态,不自建熵编码后端 ——这在此时是浪费时间
核心理念
不是为数据构建专用压缩器,而是将数据专门化以适配压缩器。
- 对数据施加 领域特定变换(Domain-Specific Transforms) ,聚焦于 LZ 本身 无法捕获的冗余
- 调整数据格式以 更好地匹配 LZ 的工作方式
GPU 转码(GPU Transcoding)
- 由于串行的熵编码和字符串匹配由 LZ 硬件处理,GPU 只需做 变换(Transforms)
- 变换通常 更具并行性 ,适合 GPU 执行
- 当前在 PS5 上已达到约 50 GB/s 的转码速度(代码尚未充分优化)
- GPU 转码的额外好处:可以 直接引用 GPU 页面池中的父节点数据 ,无需维护额外的 CPU 副本
针对 LZ 的数据优化策略
通用优化
| 策略 | 原因 |
|---|---|
| 填充至字节对齐(Byte-Align Padding) | LZ 是基于字节的,未对齐的位流会导致匹配失败;虽然增大未压缩体积,但 压缩后反而更小 |
| 相同类型数据紧密排列 | 最小化 LZ 匹配偏移量 |
| 偏好较小的字节值 | 使字节统计 尽可能偏斜(Skewed) ,偏斜越大 → 熵编码越有效 |
领域特定变换
1. 顶点去重 → 引用编码
- Cluster 化过程产生大量 冗余:
- 共享边上的 重复顶点
- 简化过程中 未受影响的顶点 (与子节点完全相同)
- 这些冗余 LZ 通常无法识别 ——因为同一顶点在不同 Cluster 中的编码可能不同
- 对于父页面引用,LZ 根本看不到 数据(数据仅在 GPU 页面池中)
解决方案:存储引用而非重复数据
- 可引用 当前页面内的其他 Cluster 和 任意父页面 的数据
- 依赖于流式加载保证的 DAG 合法切面(Valid Cut) ——父页面一定已加载
- 典型情况下,一个 Cluster 中约 30% 的顶点 可编码为引用
- 未来计划将机制从 精确匹配扩展到预测(Prediction) ,预期还有显著提升空间
2. 三角形条带编码(Triangle Strip Encoding)
- 磁盘格式使用 更紧凑的拓扑编码 ,取代每三角形三索引的方式
标准三角形列表: 3 个索引/三角形
三角形条带: 第一个三角形 3 索引,后续每个仅需 1 个新索引
- 使用 广义三角形条带(Generalized Triangle Strips):
- 不是交替左右,而是 每步显式选择左或右
- 形成 更长的条带 ,即使需要额外 1 位/三角形选择方向,净收益仍然显著
3. 顶点按首次使用排序
- 顶点按 首次引用顺序 重排
- 某个顶点首次出现时,其索引 = 已见顶点总数 → 无需显式存储
- 对已见顶点的反向引用:构建器保证 不超过 5 位偏移量
最终磁盘编码结构
一系列位掩码:
- 何时重置条带(Reset Strip)
- 何时左/右转(Left/Right)
- 哪些引用是显式的(Explicit Reference)
≈ 5 位/三角形(磁盘编码) vs 17 位/三角形(内存编码)
具体结果:Lumen in the Land of Nanite Demo
| 指标 | 数值 |
|---|---|
| 源三角形数 | ~4.33 亿 |
| Nanite 三角形数(含层级) | ~8.66 亿(约源的 2 倍) |
| 原始未压缩数据(float 坐标 + byte 索引 + 隐式切线) | ~26 GB |
| 半精度坐标 | ~13 GB |
| Nanite 内存表示 | 7.7 GB |
| 内存表示直接 LZ 压缩 | ~6.9 GB(仅小 ~10%) |
| 磁盘格式 + LZ 压缩 | 4.6 GB |
关键比率
| 度量 | 数值 |
|---|---|
| 每 Nanite 三角形字节数(磁盘) | ~5.6 字节 (含所有 Cluster/层级元数据) |
| 每源三角形字节数(磁盘) | ~11.4 字节 |
这些数字来自最新代码,比早期访问版本有所改进。压缩将是未来的重点方向,相信还有很大提升空间。
当前限制与未来展望
当前版本(UE5 Early Access)的限制
| 类别 | 状态 |
|---|---|
| 刚体几何(Rigid Geometry) | ✅ 完整支持(占大多数场景的绝大部分) |
| 半透明 / 蒙版材质(Translucent / Masked) | ❌ 不支持 |
| 非刚体变形(Non-rigid Deformation) | ❌ 不支持(静态或动画) |
| 聚合体几何(Grass / Leaves) | ⚠️ "开销随分辨率缩放"的特性 不太成立 |
未来愿景:Nanite Everything
最终目标:引擎中不再有"什么是 Nanite、什么不是 Nanite"的概念——它就是我们渲染几何体的方式。
令人兴奋的未来方向
| 方向 | 说明 |
|---|---|
| 核外光线追踪(Out-of-Core Ray Tracing) | 将虚拟化几何的理念引入光追 |
| 微多边形曲面细分(Micro-Poly Tessellation) | 运行时生成超细密几何 |
| 像素级位移贴图(Pixel-Scale Displacement Mapping) | 逐像素精度的位移 |
| 分形实例化(Fractal Instancing) | 层级化重复结构 |
| 植被(Foliage) | 解决聚合体几何的缩放问题 |
| 动画(Animation) | 支持蒙皮和顶点动画 |
| 地形(Terrain) | 统一地形与 Nanite 管线 |
致谢
- Nanite 合著者:Rune 和 Graham
- 虚拟阴影贴图作者:Ola 和 Andrew
- UE5 渲染团队、美术团队
- Epic Games 给予探索这一愿景的机会
完整演讲总结回顾
至此,Brian Karis 的 SIGGRAPH 2021 Nanite 演讲已全部覆盖。整个系统的核心架构可以概括为:
原始网格
↓ 离线构建
Cluster 化 → DAG 层级(METIS 图划分 + QEM 简化 + 误差球传播)
↓ 运行时
GPU 驱动实例剔除 → 持久线程 BVH 遍历 → DAG Cut 选择(1px 误差)
→ Cluster 剔除(视锥 + HZB 遮挡) → 两遍遮挡剔除
→ 软件/硬件双路径光栅化 → Visibility Buffer
→ 逐材质着色(深度模板加速像素筛选) → G-Buffer → 延迟着色
↓ 阴影
多视图渲染 → 虚拟阴影贴图(16K 等效分辨率,按需物理页面)
↓ 流式加载
虚拟几何内存(GPU 页面池 + DAG 合法切面维护)
↓ 压缩
领域特定变换 + 硬件 LZ 解压(~5.6 字节/三角形)
核心成就:渲染开销随屏幕分辨率缩放,而非场景几何复杂度。