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 的终极目标:将同样的"无预算"理念应用于几何体

核心设计目标

  1. 消除几何体预算限制 :不再关心多边形数量(poly count)、绘制调用(draw calls)、内存占用
  2. 直接使用影视级资产 :美术可以直接导入电影质量的模型,无需手动优化 即可用于实时渲染
  3. 自由的场景布置 :美术可以随意放置任意数量的网格体,自主决定如何构建场景
  4. 零质量损失 :快速渲染的同时,必须保持美术原始创作的视觉效果

核心理念:将美术从繁琐的性能优化工作中彻底解放,让他们专注于创作本身。美术在优化内容以达到帧率目标上浪费的时间是惊人的。


几何体表示方案的探索与取舍

为什么几何体比纹理更难虚拟化?

  • 纹理虚拟化本质上是一个 内存管理问题 ,而几何体细节 直接影响渲染开销
  • 纹理可以被 平凡地过滤(trivially filterable) (如 mipmap),而几何体不行

方案一:体素(Voxels)与隐式表面(Implicit Surfaces)

优势

  • 体素和隐式表面(如 SDF)在学术界讨论最多,有诸多潜在优势

致命问题

  1. 数据量爆炸 :一个 200 万面的半身像,重采样为 1300 万窄带 符号距离场体素(Narrow Band Signed Distance Field Voxels) ,即便已考虑 稀疏性(Sparsity) (不存储空体素和全填充体素),数据量仍然是原始的 6 倍 ,且看起来还是 模糊的(blobby)

  2. 均匀重采样的本质缺陷

    • 体素化是一种 均匀重采样(Uniform Resampling) ,均匀重采样意味着信息损失
    • 这相当于 3D 版的 "矢量图转像素图"
    • 对于 有机体/均匀采样网格 (如雕刻的皮肤),重采样破坏性不大
    • 对于 硬表面建模(Hard Surface Modeling) ,破坏性可能 极其严重 ——锐利边缘会被模糊掉
  3. 多重技术困境

    • 需要 最大化稀疏性 来控制数据量,但不能牺牲 光线投射性能(Raycasting Performance)
    • 数据结构需要 超级自适应 :锐边处需要高精度,平滑处不浪费采样
    • 即使做到以上所有,存储的最精细分辨率 仍非原始创作的精度 ——我们始终在重采样
    • 是否还需要额外存储原始网格,在近距离观察时切换回去?
  4. 与现有工作流的兼容性问题

    • Nanite 需要支持 从任何工具导入的网格 ,无法控制几何体的创作方式
    • 这些网格 自带 UV ,使用平铺细节贴图(Tiling Detail Maps)——UV 映射虽被美术普遍讨厌,但它是 极其强大的程序化纹理工具
    • 体素着色(Voxel Colors)不能替代 UV三平面投影(Triplanar Projection) 也不行
    • Nanite 的目标是 只替换网格 ,而非纹理、材质或与之相关的所有创作工具
    • 体素 + UV?也许可以,但如何处理 UV 接缝(UV Seams)
    • 其他问题:薄于一个体素的特征在 SDF 中 消失 ;少于几个体素厚度时属性 泄漏 ;如何做 动画

Brian 的结论:体素/隐式表面要完全替代显式表面(Explicit Surfaces),还需要 多年的复合研究和行业经验积累 。即使做到了,也不确定它是否更好。


方案二:细分曲面(Subdivision Surfaces)

优势

  • 理论上可以达到 无限光滑

致命问题

  1. 只能做放大(Amplification),不能简化 :近处观察很棒,但 不会比基础控制笼(Base Cage)更简单
  2. 控制笼本身就很重 :与美术沟通后发现,控制笼通常 比游戏低模还要高 ;在影视中更是 高出一个数量级
  3. 核心矛盾:Nanite 需要 美术创作选择与渲染开销解耦 ,细分曲面做不到

方案三:位移贴图(Displacement Maps)

优势

  • 类似法线贴图的捕获方式,配合 矢量位移(Vector Displacement) ,低模可以做得更低

致命问题

  1. 拓扑限制 :位移 无法增加曲面的亏格(Genus) ——通俗来说,你 无法把一个球体位移成一个环面(Torus)
    • 例如一条锁链,根本无法用位移贴图从简单几何体生成
  2. 均匀重采样的老问题 :投影到法线贴图或位移贴图也是一种均匀重采样
    • 对有机表面效果好
    • 对硬表面特征,如果不是美术 极其仔细地控制 ,破坏性很大
  3. 总结:放大用途很好,但不适合通用的简化需求

方案四:基于点的渲染(Point-Based Rendering)

优势

  • 点云可以 极快地投射到屏幕上

致命问题

  1. 除非接受 大量过度绘制(Overdraw) ,否则点云需要 填孔(Hole Filling)
  2. 无法区分 "应该存在的小间隙"和"应该被填充的孔洞"——在没有额外 连接性数据(Connectivity Data) 的情况下不可能确定
  3. 所谓的连接性数据本质上就是 索引缓冲区(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 驱动管线的基本流程

  1. 实例可见性判定 :对每个视图,通过 单次 Dispatch 确定可见实例
  2. 绘制 :若仅绘制深度,可用 单个 Draw Indirect 光栅化所有三角形
  3. 优势:获得了 GPU 驱动的全部好处

问题:不可见三角形的浪费

  • 即便有了 GPU 驱动管线,仍然为大量 不可见的三角形 做了不必要的工作

三角形簇剔除(Triangle Cluster Culling)

基本概念

  • 将三角形分组为 簇(Clusters) ,为每个簇构建 包围盒(Bounding Box)
  • 基于包围盒对簇进行剔除

剔除方式

  1. 视锥剔除(Frustum Culling) :排除视锥外的簇
  2. 遮挡剔除(Occlusion Culling) :排除被其他物体遮挡的簇
  3. 基于锥体的背面剔除(Cone-Based Backface Culling) :实践中通常 不值得做 ,因为几乎所有背面朝向的簇都会被遮挡剔除淘汰

层级 Z 缓冲区遮挡剔除(HZB Occlusion Culling)

  • HZB(Hierarchical Z-Buffer) :层级 Z 缓冲区
  • 算法流程:
    1. 计算簇包围盒的 屏幕空间矩形(Screen Rect)
    2. 找到该矩形恰好覆盖 4×4 像素最低 mip 层级
    3. 在该 mip 上测试簇是否 被完全遮挡

遮挡剔除与可见性驱动的渲染管线


两遍遮挡剔除(Two-Pass Occlusion Culling)

核心问题

  • 要使用 层级 Z 缓冲(HZB) 做遮挡剔除,但在第一帧开始时我们 还没有渲染任何东西 ,没有深度信息可供测试
  • 某些游戏尝试将 上一帧的 Z 缓冲重投影(Reproject) 到当前帧,但这种方法 始终是近似的,不具备保守性(Not Conservative) ——可能导致漏剔除或错误剔除

核心假设

  • 上一帧可见的东西,这一帧大概率仍然可见 ——这个假设在实践中非常有效
  • 上一帧的深度缓冲所代表的那些可见表面,是很好的 遮挡体(Occluders) 候选
  • 但与其重投影深度缓冲,不如 重投影产生那些深度的几何体本身 ——即把上一帧确定为可见的几何体重新绘制

两遍流程

遍次操作
第一遍绘制 上一帧确定为可见 的所有几何体,用其结果构建 HZB
第二遍用 HZB 测试 本帧新出现但上一帧不可见 的几何体,将通过测试的新增几何体也绘制出来

至此,我们拥有了一个 state-of-the-art 的三角形渲染管线 。接下来的问题是:如何超越"只画深度",支持完整的材质渲染?


可见性与材质的解耦

为什么要将可见性与材质评估分离?

目标:逐像素确定可见性(深度缓冲光栅化已做到的事)材质计算(Shading) 完全解耦。解耦带来以下收益:

  1. 消除光栅化期间的着色器切换(Shader Switching)
  2. 消除材质评估的过度绘制(Overdraw) ,或避免需要一个额外的 depth pre-pass 来规避 overdraw
  3. 消除像素四边形低效(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)

材质着色阶段

逐像素执行材质着色器,流程如下:

  1. 加载 Vis Buffer :读取当前像素的 Instance ID + Triangle ID
  2. 加载三角形数据 :获取该三角形的三个顶点
  3. 变换至屏幕空间 :将三个顶点位置投影到屏幕
  4. 计算重心坐标(Barycentric Coordinates) :根据当前像素位置与三个投影顶点的关系
  5. 插值顶点属性 :利用重心坐标对 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 分组 + 交替边界

核心思路

  1. 将 Cluster 分组(Grouping) ,强制同组 Cluster 做出 相同的 LOD 决策
  2. 同组 Cluster 因为永远不会出现层级差异,所以 不可能产生裂缝
  3. 同组内的共享边可以 解锁 ,像内部边一样自由坍缩简化

关键约束

  • 不能将 所有 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(有向无环图)的构建是一个 自底向上的迭代过程

  1. 构建叶子 Cluster :将原始网格的三角形划分为每个 128 三角形 的 Cluster
  2. 对每个 LOD 层级 重复以下操作:
    • 分组(Group) :将相邻 Cluster 分组
    • 合并(Merge) :将组内所有 Cluster 的三角形合并为一个共享列表
    • 简化(Simplify) :将三角形数量 减半
    • 拆分(Split) :将简化后的三角形列表重新拆分为新的 Cluster
  3. 终止条件 :重复上述过程直到 根节点只剩一个 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 时需要同时考虑多个目标:

  1. 最小化 Cluster 包围盒范围 ——提升剔除效率
  2. 每个 Cluster 的三角形数尽量接近但不超过 128 ——配合光栅化器
  3. 顶点数不超过上限 ——配合 Primitive Shader 的约束
  4. 最小化 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 切换时是否会产生可见的"跳变"?是否需要 GeomorphingCross 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 在每一层之间被完全排空
空 DispatchCPU 不知道递归深度,必须发出覆盖最坏情况的 Dispatch 数量,导致大量 空 Dispatch
高分支因子的权衡提高分支因子可减少层数,但也带来其他低效

这些问题需要更高级的 并行工作调度(Parallel Work Scheduling) 方案来解决(如持久线程、工作窃取等)。

持久线程、两遍遮挡剔除整合与软件光栅化


持久线程(Persistent Threads)

动机

  • 理想情况下,当 BVH 中某个父节点通过剔除测试后,我们希望 立即开始处理其子节点 ,而非等待同一层级所有节点都处理完毕
  • 最理想的是能从 Compute Shader 中 直接派生新线程 ,但当前 GPU 编程模型 没有提供这种能力

核心思想

  • 与其派生新线程,不如 复用现有线程 ,通过 自定义作业队列(Job Queue) 分发工作
  • 这就是 持久线程(Persistent Threads) 技术——本质上是在 GPU 上实现一个 迷你作业系统(Mini Job System)

工作方式

  1. 启动时 :派发 刚好足以填满 GPU 的线程数
  2. 运行时 :每个线程反复执行以下循环:
    • 从队列中 弹出(Pop) 一个节点
    • 处理该节点 (执行剔除测试)
    • 通过测试的子节点推入(Push) 队列
  3. 终止条件 :队列为空

优势

方面改进
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-ZHZB 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 在单遍中应用所有材质,但存在复杂性和低效问题

实际方案:逐材质全屏绘制 + 深度测试加速

基本流程

  1. 由于所有剔除都是 GPU 驱动 ,CPU 不知道哪些材质可见 ,材质 Draw Call 必须 无条件发出
  2. 为每个唯一材质绘制一个四边形,跳过不匹配该材质的像素

高效的像素筛选:利用深度测试硬件

  • 如果逐像素测试每个材质,效率极低
  • 核心技巧 :将 材质 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)

  1. 计算三角形上属性的解析导数 ——直接从三角形几何关系推导
  2. 通过美术创建的 节点图(Node Graph) 使用 链式法则(Chain Rule) 自动传播 导数
  3. 遇到 不可解析微分的操作 时,回退到有限差分
  4. 所有 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-Buffer2.0 ms

CPU 耗时

  • 几何阶段因全部 GPU 驱动 ,CPU 开销 可忽略不计
  • 材质阶段有 少量 CPU 开销 :每个材质一个 Draw Call
  • 完全在 60 Hz 游戏预算内

阴影渲染:虚拟阴影贴图(Virtual Shadow Maps)

为什么阴影需要特别对待?

  • 主视图不是唯一需要绘制几何体的地方——阴影也需要
  • 间接光照或许不需要微多边形级别的细节,但 阴影需要精细的几何体
  • 真实几何体与法线贴图的 最大视觉差异 往往来自 精细的自阴影(Self-shadowing)

为什么不用光线追踪阴影?

  1. 阴影光线比主射线更多 ——平均每像素超过一个光源
  2. 需要至少 和主视图一样快 的方案
  3. 当前硬件光追 API 存在限制:
    • 不够灵活 ,无法评估 Nanite 的复杂 LOD 逻辑
    • 要适配其特定三角形格式会 大幅膨胀内存
    • 缺乏部分更新 BVH 的能力 ,避免不了昂贵的百万级元素 BVH 从零构建
  4. 未来会进一步探索 光追 Nanite ,但当前使用 光栅化方案 以复用已有的全部工作

光源数量的严峻挑战

  • 每像素平均超过一个光源 ,Nanite 的开销 绝不能因阴影而失控式倍增
  • 幸运的是,大多数光源和投射阴影的几何体 都是静态的 ——如果能 缓存这些工作 ,就有可能控制住开销

虚拟阴影贴图方案

核心参数

参数
阴影贴图分辨率16K × 16K (所有光源统一使用)
页面大小(Page Size)128 × 128 像素
Mip 0 页面数128 × 128 页
虚拟化方式稀疏分配(Sparse Allocation)

分辨率匹配策略

  • 对于每个光源,可能存在一个或多个 Shadow Mount
  • 光栅化到阴影贴图中的分辨率被设计为 匹配投影到屏幕上的像素密度 ——即阴影贴图中一个纹素 ≈ 屏幕上一个像素
  • 如果阴影贴图的某个区域 不会投影到屏幕上的任何东西 ,则 完全不绘制 ,甚至 不分配内存

逐帧页面分配流程

  1. 遍历屏幕上每个像素
  2. 对于 影响该像素的所有光源
    • 将像素位置 投影到阴影贴图空间
    • 选择使 一个纹素 ≈ 一个屏幕像素 的 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) ,体积很小
  • 因此 始终保持层级结构完全常驻

好处

  1. 遍历 独立于当前流式切面 ,可以深入数个层级
  2. 新出现的物体可以 立即请求所有需要的层级数据 ,而非逐层等待
  3. 如果层级结构也需要流式加载,则 I/O 延迟会被 乘以偏离目标的层级数 ,导致更多可见的 弹入效果(Pop-in)

请求生成流程

  1. 请求在 层级 Cluster 剔除遍历 中生成
  2. 每个请求包含 页面范围 + 基于 LOD 误差 的优先级
  3. 不仅为 主视图 生成,也为 活跃的阴影视图 生成(阴影视图优先级较低)
  4. 已常驻页面 也会发射请求以 更新其优先级

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 合著者:RuneGraham
  • 虚拟阴影贴图作者:OlaAndrew
  • 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 字节/三角形)

核心成就:渲染开销随屏幕分辨率缩放,而非场景几何复杂度。