概述
本文是两篇 Nanite 技术分享,Nanite - A Deep Dive 以及 Journey to Nanite 的记录。
方案选择
受 virtual texture 启发,Epic 想要一套 virtual geometry 技术用来减少面数、DrawCall 和内存对美术团队的限制。为此,他们尝试了各种方案。
Voxel
首先使用体素化的方法来描述 mesh,但是对于一个 2M 的 mesh,使用体素方法重新采样到 narrow band SDF 后达到了 13M,而这甚至是去除了空白区域的稀疏存储。
这是因为体素化的采样是一种均匀的重采样方式,而均匀的重采样意味着细节的丢失,这对于硬表面模型来说很不友好。因为这种采样方式不是自适应的,在平滑区域和边缘区域采样方式是相同的。
而且即使使用高分辨率的存储,重建的模型的效果也不好,再加上这种做法可能会影响整个工作流,因此该放弃了方案。
Subdivision Surface
细分表面看起来可以无限细分,非常适合近距离展示模型,但需要美术来创建一个 base cage 模型,来构建基本的形状信息。然而 base cage 的精度依然比游戏中使用的模型精度要高,这使得美术的工作变复杂了,因此放弃该方案。
Displacement Map
然后又想到了使用顶点置换的方式,通过 displacement map,低模的顶点数可以很少,而且烘焙的方式也很成熟。但是 Displacement Map 对于多层的链条状模型存在限制,而且该方法和 Subdivision Surface 一样,只能增加细节,对于模型退化时存在一个下限。
Point Cloud
点云的做法可能会引入巨量的 overdraw,缺少连续性信息时,很难确定相邻的点之间的洞是否需要填充。或许机器学习的相关算法比较成熟后,可以获得不错的应用。
Triangles
最终发现还是三角形比较靠谱,它通用、高效。
GPU 管线
在 UE 4.22 版本中,渲染管线经过了一次重构,具体可以参考官方文档,以及该 Talk。在这次重构后,渲染器变为了 Retain 模式,场景信息可以被存储到显存中,并在不同的帧间保持不变,当场景发生变化时,会触发相应数据的增量更新。
此外由于 Nanite 的资源会存储于一个单一的资源结构中,所以不需要 bindless 便可以访问其中的任一资源。
以上构成了 GPU 管线的必要条件,在一次 dispatch 中就可以获得整个视图的可见性信息,一次 indirect draw 就可以对深度中所有的三角形进行光栅化。
剔除
Nanite 的剔除是基于 Cluster 来进行的。一个 Cluster 就是一组三角形,最多为 128 个,Nanite 会为每个 cluster 构建一个 bounds,裁剪时会根据该 bounds 进行裁剪。
剔除的方式有视锥剔除和遮挡剔除。
Nanite 的遮挡剔除借由 HZB 来完成。根据 cluster 的 bounds 来计算得到一个屏幕控件的矩形,然后使用最低的 mip 来判断是否遮挡。
使用 HZB 的剔除,需要先构建 HZB,通常游戏中会使用上一帧的深度信息,但这种做法无法保证剔除的精度,不过上一帧可见的,当前帧大概率可见。因此,Nanite 引入了一种双 Pass 的遮挡剔除,具体做法是:
- 绘制上一帧的可见内容
- 根据上一帧的可见性构建 HZB
- 对当前帧使用上一步构建的 HZB 进行剔除
- 使用上一步剔除的结果构建一个新的 HZB
可见性与材质解耦
Nanite 实现了像素的可见性与材质的解耦,这么做是因为开发团队想要消除在光栅化过程中 Shader 的切换、材质的 evaluate 造成的 overdraw 以及低效的 quad 绘制。
有一些 object space 的方法可以实现这一点,比如使用 REYES 渲染架构或使用 Texture Space。
但是 object space 的方法可能会造成很多 overshade,所以回归到 Deferred Material 的方法,为此引入了 Visibility Buffer。
Visibility Buffer
首先将深度信息,Instance ID,Triangle ID 等数据写入 Visibility Buffer。
接下来在渲染材质时,会先加载 visibility buffer 然后加载三角形数据。随后将当前像素的三角形变换到屏幕空间,并推导出像素的重心坐标,利用它来加载、插值。
在延迟渲染的路径下,材质的评估只是将数据写入 GBuffer 然后再进行着色。在这种方式下,所有不透明物体可以通过一个 DrawCall 完成绘制,CPU 端的消耗不再受限于物体的数量,材质的绘制与 Shader 相关而非物体数量。
到此为止,渲染速度有所提升,但消耗依然与实例与面片数目正相关。
最优情况是可见的面片的数量与屏幕像素数量相近,但在实践中很难实现这一点:渲染几何体的消耗与屏幕分辨率正相关,而非场景的复杂度。
Nanite 使用 Cluster 来组织场景,就是希望每帧渲染的 cluster 数目基本保持稳定,从而实现渲染消耗与场景复杂度的解耦。
LOD
Nanite 的 LOD 是基于 cluster 的划分,父节点的 cluster 是子节点 cluster 的简化版本。
在运行时,对 cluster 来做切割来获得相应粒度的 cluster,切割的依据是 cluster 在屏幕空间投影的误差。父节点的绘制结果与当前节点绘制结果差异不大时,才考虑切换为下一级 LOD。
LOD cracks
如果 LOD 的切换只考虑当前 cluster 而不考虑邻域的 cluster,就会在切换时出现缝隙导致破面。直观的解决方法有两个:
- 简化时锁定与邻接 cluster 的共享边
- 使独立的 cluster 始终与边缘匹配
但是锁边的方式会使得各 cluster 的边不再平滑。Nanite 的做法是在 build 时将 cluster 分组,同一个 cluster group 中的永远在同一级 LOD,这样一来就不存在独立的 cluster 了,也就不会产生 cracks。对组与组之间切换可能出现的问题,可以对组应用锁边,为此研究了不少方案,感兴趣的话可以查看 Multiresolution structures for interactive visualization of very large 3D datasets 和 Batched Multi Triangulations 这两篇论文。
Streaming
这种 LOD 策略能够匹配 virtual geometry 的需求,不需要将所有的 cluster 一次性全加载到内存中,只需要将切割下来的 cluster 加载即可,不需要渲染的 cluster 可以被置换出去。就像 VT 一样根据需要来请求数据。
Build
首先构建叶子节点 cluster,一个 cluster 包含 128 个三角形。
然后对每一级 LOD:
- 分组以消除共享边
- 合并三角形
- 将三角形的数量简化至原有数量的一半
- 将简化后的三角形继续划分为 cluster
重复该过程,直到 cluster 只剩下一个。
注意到构建完成后,最后形成的数据结构不是树而是一个 DAG。
Group 的选择
具有较多共享边的 Cluster 应归于一个组中。当具有的外轮廓的边越少,被锁定的边越少,最终简化时受到的制约就越少。
这种问题在 CS 领域被称为 Graph Partitioning,它会将一个图拆成多个划分,并保证 一个划分到另一个划分的权重是最小的。Nanite 的 cluster 就对应 Graph 中的节点, Cluster group 对应一个划分,共享边的数量就对应划分的权重。
Nanite 构建时使用了 METIS 库来做划分。
初次构建 cluster
构建 cluster 时需要考虑多方面的因素:
- 为了使剔除的效率尽可能高,cluster 的 bounds 应尽量小
- 为了保证光栅化的效率,每个 cluster 的三角面的数量应该接近 128
- 为了保证 primitive shader 地效率,每个 cluster 的顶点应该存在上限
- 为了 nanite mesh 更自然地退化,cluster 外轮廓的边数应尽量少
这些要求很难完全满足,因此三角形的数量以及外轮廓边数被优先考虑。
模型简化
模型简化使用的是传统的边缘坍缩算法,误差简化是由 Quadric Error Metric (QEM)得来。
相邻的两级 LOD 会计算一个estimate error,运行时会根据这个值投影到屏幕空间所产生的像素误差来判断是否需要切换 LOD。
无缝 LOD 切换
简单来说,Nanite 会将模型简化时保存的 estimate error 投影到屏幕空间,然后通对型到相机的距离和角度来评估具体使用哪一级 LOD。
切换 LOD 时,同一个 Group 中的所有 cluster 都需要进行同样的 LOD 选择,这个做法就是为了避免出现上面提到的破面问题。为此,同一组中的 cluster 需要存储相同的误差以及 sphere bounds。
选择 LOD 的过程实际上就是对 Cluster 结构的 DAG 进行裁剪的过程。为了能在 GPU 中高效执行,需要考虑并行化。
DAG 会在什么时候发生裁剪呢?或者说 LOD 会在什么时候进行切换呢?
当父一级的误差较大,而当前节点的误差较小,小于我们设定的误差阈值时,这时父、子节点评估误差的结果不同,此时发生切换。这种说法只有在 DAG 中存在一条分割线时成立,这就要求 DAG 中从根节点到叶子节点的路径的误差评估结果必须是单调的。这一点不会在运行时处理,会在 Nanite 构建时完成。
运行时再加上 TAA,subpixel 的变化会被进一步平滑。
分层 LOD 选择
一个场景中会有许多的 cluster 对它们逐个遍历消耗会很大,而其中绝大多数是不可见的,因此考虑添加一个层次结构,逐步细化。
- 当 ParentError > threshold && ClusterError <= threshold 时渲染
当 **ParentError <= threshold ClusterError > threshold** 时裁剪
Nanite 通过构建一个 8 节点的 BVH 来实现 LOD 的分层。BVH 的剔除是一个多 Pass 的剔除,每个 Pass 处理一层 BVH。实践中为了充分利用缓存机制,往往希望处理完父节点立刻处理其子节点,但是目前 GPU 不支持这种调度。
但是 GPU 可以对之前的线程进行重用,并指派新的工作。
双 Pass 遮挡剔除
在通过 BVH 来选择 LOD cluster 时,可见性检查同时也在进行。但是再引入了 LOD 切换后,原先的流程需要一定的修改。
由于每帧的 LOD 选择不同导致 cluster group 会流入流出,每帧的 cluster 数据可能会不同。所以我们应该检查当前选择的 clusters 是否在上一帧可见。
然后流程就变为了这样:
- 用上一帧的 transform 来做 HZB 测试
- 渲染可见的几何体并保存不可见的几何体列表
- 从 depth buffer 构建当帧 HZB,并使用不可见几何体列表执行 HZB 测试
- 渲染前一帧不可见但当前帧可见的几何体
- 重新构建该帧的 HZB 留给下一帧使用
光栅化
接下来要讨论的一个问题是光栅化的尺度。
小三角形对传统的光栅化性能有较大影响。光栅器会划分出较大的 tile,然后再将该 tile 划分为 4x4 的小 tile,最终以 2x2 的像素的 quad 输出。
现代 GPU 的并行是针对的像素而不是三角形。
软光栅
放弃传统光栅意味着放弃硬件单元的支持,也失去了光栅化单元以及深度测试的硬件支持,但是 depth buffer 又是必然需要的。
Nanite 的软光栅通过 64 位的原子操作 InterlockedMax 完成,从高位到低位分别是:
| Depth | 可见 cluster 的索引 | 三角形索引 |
| 30 | 24 | 7 |
软光栅运行在一个 thread group 为 128 的 compute shader 上,分两个阶段:
- 第一阶段处理顶点,每个顶点对应一个线程,如果 cluster 中的顶点数大于 128,那么可以再启动一个线程组。
- 第二阶段处理面片,每个三角形对应一个线程,先读取顶点属性插值然后再光栅化。
这里有一段简化处理面片的代码以供参考:
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
for (uint y = MinPixel.y; y< MaxPixel.y; x++)
{
float CX0 = CY0;
float CX1 = CY1;
float CX2 = CY2;
float ZX = ZY;
for (uint x = MinPixel.x; x < MaxPixel.x; x++)
{
if( min3(CX0, CX1, CX2) >= 0 )
{
WritePixel(PixelValue, uint2(x,y), ZX);
}
CX0 -= Edge01.y;
CX1 -= Edge12.y;
CX2 -= Edge20.y;
ZX += GradZ.x;
}
CX0 += Edge01.x;
CX1 += Edge12.x;
CX2 += Edge20.x;
ZX += GradZ.y;
}
这里虽然有循环的嵌套,但是由于覆盖的像素少,因此消耗并不高。
硬光栅
大尺寸的面片依然走硬件光栅,这样性能会更好。使用硬光栅还是软光栅需要逐 cluster 评估,Nanite 将像素数小于 32 的三角形面片定义为小三角形,对于这种三角形使用软光栅。
这么做带来的问题是,上面代码中的迭代次数的增多,但有效像素占比下降。那么不对整个矩形进行光栅化,而是用扫描线的方法又如何呢?不对 X 轴方向上的像素逐个测试是否覆盖三角形,直接解出 X 轴方向上的像素数量就可以了。
按照这个方法对上面的代码进行优化后:
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
float3 Edge012 = { Edge01.y, Edge12.y, Edge20.y };
bool3 bOpenEdge = Edge012 < 0;
float3 InvEdge012 = Edge012 == ? 1e8: rcp( Edge012 );
for (uint y = MinPixel.y; y< MaxPixel.y; x++)
{
float3 CrossX = float3( CY0, CY1, CY2 ) * InvEdge012;
float3 MinX = bOpenEdge? CrossX: 0;
float3 MaxX = bOpenEdge? MaxPixel.x - MinPixel.x : CrossX;
float x0 = cell( max(MinX.x, MinX.y, MinX.z) );
float x1 = min3( MaxX.x, MaxX.y, MaxX.z );
float ZX = ZY + GradZ.x * x0;
x0 += MinPixel.x;
x1 += MinPixel.x;
for (float x = x0; x <= x1; x++)
{
WritePixel(PixelValue, uint2(x,y), ZX);
ZX += GradZ.x;
}
...
}
当一个 wave(wavefront?)中 X 方向的像素数大于 4,将会使用扫描线光栅器。
Overdraw
Nanite 没有逐三角形的裁剪,就像是 EarlyZ test 没有硬件支持。HZB 测试也是针对 cluster 中的几何体,而没有细致到像素级别,因此 overdraw 带来的问题不容小觑。
当表面互相覆盖的距离很小,小于 cluster 的边界时,这种紧密的重叠会导致 Nanite 的 overdraw。此外,如果几何体表面布满孔洞也会影响光栅化效率。
Mesh 稀疏的地方通常会存在大量大的三角形面片,但这并不会降低 overdraw。大的三角形对应大的 cluster,cluster 越大,剔除的粒度就越粗糙。这意味着随着三角形的增大,像素的 overdraw 也会随之上升。这就是说渲染的三角形较多时,运行效率反而越高。
这与传统的光栅化流程确实大相径庭,难怪原文中会说:
The reliance on previous frame depth for occlusion culling is one of Nanite’s biggest deficiencies.
Tiny instances
如果物件实例在屏幕空间占用的像素本身就很少呢?
最直观的方法时直接根据 screen size 把它剔除掉,但如果该实例是各个几何体的组成结构,那么直接剔除掉可能会导致整个几何体消失不见。
所以在某个时间点,需要将实例合并。比如远处的地形需要流出并替换为低模的代理网格。因为即使 Nanite 可以调整渲染消耗,但是有限的内存并不允许这么做。
因此在远处,应尽可能的将实例合并为代理。
在合并之前的过渡区域,Nanite 也提供 visibility buffer imposters 作为补充。该做法与常规的 imposter 类似,但是存储的是 visbility buffer 中的 depth 和三角形 ID。
Nanite 材质
在解码了 visibility buffer 后可以获取到 depth, visible cluster index 和 triangle index。接下来就可以准备评估材质了,这一步骤可简略的描述为:
- 加载 VisBuffer
- 加载 Visible Cluster 获取到 Instance Id 和 Cluster Id
- 加载 instance 的变换信息
- 加载三角形顶点的索引
- 加载三角形顶点的位置
- 将三角形的顶点变换到屏幕空间
- 推到出三角形像素的重心坐标
- 加载属性并进行插值
材质 ID
在解码 vis buffer 后可以通过 visible cluster index 获得 instance id 和 cluster id,在这之后可以:
- 通过 cluster id 和 triangle id 获得 material slot id
- 通过 instance id 和 material slot id 获得 material id
这样一来理论上可以通过一个全屏的 quad 来绘制出所有的材质,但实际上的做法是对每个独立的材质来绘制全屏的 quad,渲染时跳过那些未被当前材质覆盖的像素。由于 Nanite 是 GPU 驱动的,CPU 并不知道材质的可见性,所以对每个材质都做全屏的像素检查会非常低效,因此需要对材质像素进行剔除。
材质剔除
剔除材质时最先想到的是使用 stencil,但是这就要在每次材质渲染时重置一次,因此选择使用 depth test,将 material id 看作是 depth 来做剔除。
用一个 CS 可以输出标准深度和材质深度,当输出标准深度时,也可以一并输出 HTILE 以对 HZB 加速。
最终对所有的材质绘制一个全屏的 quad,将材质的深度写入到 quad 的 Z 中,并将深度测试设为等于,便可以仅绘制相应材质的像素了。
但依然有更好的方法:将全屏的 quad 划分为 8x4 的 tile 进行粗粒度的剔除,并将这个信息写入一个 32 位的 mask 中。如果一个 tile 中不包含相应的材质,则为 mask 中相应的 tile 设置标记,并在 VS 中将 x 的值写为 NaN 来跳过该 tile。
UV 差分
Nanite 的软光栅的单位是也是 quad,但相比传统的三角形光栅,nanite 的光栅并非是 2x2 像素的 quad,而是一个较大的 quad 它会包含多个三角形,因此能减少小面片造成的 overdraw。
为避免混淆,下面我会称 nanite 的光栅化以一个 tile 为基本光栅单位。
不过,这样一来,一次光栅化流程中可能会包含 depth 不连续处、UV seam,甚至不同的物件的像素。
为了确保像素的连续,nanite 的做法是做链式求导,传统光栅是将 2x2 的 quad 作为基本单位,而 nanite 为确保像素连续,需要根据原本 tile 再次微分。如果无法通过该解析方法来获取差分信息,将会回退到相邻像素的差分法来模拟微分结果。有了微分的结果,在采样时可以指定梯度来进一步采样贴图。
以上的操作是在材质通过 HLSL translator 转化时实现的,所以对于用户层是感知不到的。
性能数据
Main pass:
| Instances pre-cull | 896322 |
| Instances post-cull | 3668 |
| Cluster node visits | 39274 |
| Cluster candidates | 1536794 |
| Visible clusters SW | 184828 |
| Visible clusters HW | 6686 |
Post pass:
| Instances pre-cull | 102804 |
| Instances post-cull | 365 |
| Cluster node visits | 19139 |
| Cluster candidates | 458805 |
| Visible clusters SW | 7370 |
| Visible clusters HW | 536 |
Total rasterized:
| Clusters | 199,420 |
| Triangles | 25,041,711 |
| Vertices | 19,851,262 |
可见,通过 Nanite LOD 将原本数以亿计的面片减少到了 2500 万左右。
在古代山谷的 demo 中使用了动态分辨率并通过时域上采样到 4K 分辨率,上采样前的分辨率平均约为 1400p。Vis buffer 的绘制消耗了约 2.5 ms,将 vis buffer 中的信息输出到 GBuffer 的消耗约为 2 ms.由于 nanite 由 GPU 驱动,整个过程中基本上没有 CPU 消耗。
Nanite 阴影
实际的渲染中并不是只有 main pass 才绘制多边形,绘制阴影时也需要。但是绘制阴影时需要如此多的细节吗?
也许间接光不需要,但是物件的阴影还是需要的。高精度几何体和使用法线贴图的几何体最大的区别就是自阴影的细节。
由于光追在对海量几何体数量的支持上仍存在问题,因此目前还是使用传统的光栅化阴影方案。这种情况下需要严格限制灯光数量,否则阴影的消耗会因 Nanite 而迅速增长。
考虑到 Nanite 处理的都是静态网格体,而场景里的灯光大部分也是静态的,因此可以考虑通过缓存来减少阴影的计算消耗,那么很自然地想到使用 Virtual Shadow Map。
VSM
使用的 VSM 的分辨率为 16k,这是为了匹配屏幕像素。上面说过,一个 cluster 的三角形数量会控制在 128 个左右,因此 VSM 的每个 page 的大小也为 128,因此虚拟化的阴影贴图 mip0 包含 128 个页,也就是 16k 的分辨率。
绘制 VSM 时大致流程如下:
- 对屏幕空间的每个像素
- 对影响到当前像素的每个灯光
- 将该像素投影到阴影空间
- 通过屏幕空间像素和阴影贴图的纹素是一比一的原则来选择 mip level
- 标记该 mip level 中的 page 为 needed
VSM 依赖于缓存功能,大多数情况下只有移动的物体和相机移动时视锥的边缘需要重绘。
Multi-view
Nanite 的绘制具有相当多的流程,因此它的性能消耗受制于同步。因此 nanite 的调用应尽量少,试想假如一盏灯绘制一次阴影便要调用一次 nanite,最终的性能消耗会相当爆炸。
因此 nanite 管线中实现了多视图的绘制而非只输出一个视图的信息。这能使得 nanite 能够一次性为场景中所有的灯光生成虚拟化的 shadow map 的 mip。
阴影的裁剪
阴影的裁剪也会走 HZB,不过额外添加了一个测试。那就是将 cluster/instance 投影到阴影空间,然后判断投影到的 shadow page 是否被标记为 needed,如果没有标记便剔除掉。
对于软光栅管线,由于软光栅处理的都是很小的 cluster,因此可以认为该 cluster 中的内容不会跨多个 page,也就是软光栅流程中一个 cluster 对应一个 page。
但硬件光栅处理的 cluster 的粒度较大,很可能会一个 cluster 占多个 page。对此引入了 VSM 逐像素的虚拟页表到物理页表的转换。
Shadow LOD
与 Nanite 的 LOD 选择一样,nanite 阴影的 LOD 也是根据一个像素单位的误差来评估 LOD 的。上面说了,屏幕空间像素和阴影贴图的纹素是一比一的,这就使得阴影的绘制是与屏幕分辨率相关而非场景复杂度相关。
但是这不代表绘制到 shadow map 中的三角形与 main pass 中的完全一模一样,这就导致了几何体上可能会出现奇怪的自阴影(就像光追下的 nanite proxy),为此会在屏幕空间做一个短距离的 trace 来修复这个问题,所以理论上,可以选择提升 LOD 的误差来来使 LOD 选择更激进,从而提高渲染效率。
Streaming
在提到 Nanite 的 LOD 切换时有提到,如果当前渲染的 mesh 的精度与目标精度有差距时,就会让 CPU 加载更高精度的数据进来,且切换时是以 cluster group 为单位进行的,以确保 LOD 切换是无缝的。
因此在 streaming 时也要完成一整个 group 的加载才可以显示。
不像 VT 我们可以确定每个 page 的大小,nanite 中的 cluster 具有可变的顶点数、属性等等。同时为避免内存碎片,也应该固定流入单位的大小,因此必须能够适配可变数量的几何信息。
分页
Nanite 会将 cluster group 存入固定大小的 page 中,存入时会考虑数据的邻接关系和 DAG 中的层级关系,来确保运行时要加载的 page 数是最小的。
第一个页会作为 root 页驻留在 GPU 中,保存着尽可能多的 DAG 顶层信息。Root 页总是驻留在 GPU 中,意味者总是有东西需要渲染的。驻留页通过 ByteAddressBuffer 的形式在 GPU 中。可是 cluster group 中包含的数据量是很大的,如果直接以此填充 page 会有很多空间被浪费。
既然如此那就继续细分,将每个 group 以 cluster 为粒度参考,分成多个 parts,然后将它们填入到不同的 page 中以使内存紧凑。这样当需要加载这些 cluster 对应的 group 时就索引它们所在的 page 并加载。当这些 parts 全部加载完成,那么该 group 才能视作加载完成。
评估
VT 通过 UV 梯度可以得知需要加载哪些 page,对于 nanite 则需要对 DAG 进行层级遍历后才能得知加载哪些。
当前裁剪的层级节点需要包含的数据比我们流入所需要的更多,因为在遍历时,我们需要评估是否已经绘制了已经驻留在内存中的节点。不过这些层级信息只是一些 meta 信息,数据量并不大,所以可以全部加载进来。
有了全部的层级信息后,就可以跳过已加载的节点,进行完整的遍历,因此通常新加载的物体可以很快找到需要的 LOD 以及对应的 Page。
层级信息也可以 streaming,这个过程是分多帧进行的。
IO 延迟会导致更对可见的 mesh popping 的现象。
流送请求
流送请求实际上在层级 cluster culling 时就已经发生了。
请求的内容为评估完加载优先级的一系列 pages,具体的评判标准是 LOD 误差。评判时并不会只考虑主视图,同时也会考虑其他视图,比如阴影。
CPU 也会异步回读请求,它会为 DAG 添加缺失的依赖内容,并为优先级最高的 page 发送 IO 请求,然后将低优先级的 page 移出以腾出空间。
最后,当 IO 请求准备完毕,page 数据会被 GPU 加载并据此更新 GPU 端的数据结构,更新加载/卸载的 page,更新完成加载的 group 以及标记 cluster 为叶节点等。
Nanite 压缩
Nanite 有两种压缩几何体的表达方式,它们包含的数据相同,但根据使用方式有不同的优化方向。
- 面向内存
- 这种方式用于渲染代码中,在光栅化、材质评估时会使用。适用于需要实时解码和随机访问场合,为的是更好地控制 streaming pool,以使 cache 的利用率更高。
- 面向磁盘
- 这种方式用于 streaming 过程中,streaming 相较于 rendering 是低频的操作,因此该方式不适合需要随机访问的场合,但也因此可以添加更高级的特性,比如使用基于 byte 的 LZ 压缩,为的是让磁盘占用更小。
内存数据
量化与编码
对于面向内存的方式,首先 Nanite 会根据美术同学的调整和一些启发式调整进行一次全局量化保存位置信息和各种属性,在保存为 cluster 后,会根据 cluster 中的 min/max 范围转换到本地坐标系下,可视为局部数据。
由于 cluster 的 min/max 会变,因此局部数据的存储位数会根据具体范围变化,每个 cluster 需要适配不同的顶点格式,不过 cluster 中的各顶点使用的格式是相同的。
该方式是直接用于实时渲染的,因此必须使用 GPU 上的 bitstream reader 来解码。
如果不同 cluster 使用不同的格式,不连续的量化会导致 cluster 间出现接缝。即使能够处理单个物件内部的接缝,放到具体场景中的摆放方式是无法预测的,此外,由于 nanite 是鼓励多用实例化的方法的,所以场景内多实例的摆放方式是无法避免的。为解决这一问题,这些物件需要全局尺度下的量化。
Nanite 的做法是添加了一个在本地坐标系原点中心的步长,然后由用户设定的值作为该步长的指数进行计算。该步长不是归一化的,对相同量化级别,平移/旋转也是步长的整数倍,从而使两个相邻物体能更好的保持一致。实践中,该做法只能保证叶子节点间的衔接,对于更上层的 LOD cluster,由于 group 可能会不一致,也就是相邻的两个物件使用了不同的 LOD 而导致接缝问题,但由于叶子节点才是距离相机最近的,其他的上层 LOD 距离相机更远,因此有接缝也不很明显。
三角面和其他属性
三角面的索引会进行简单的调整,使第一个顶点的索引是最小的。而 cluster 的顶点数存在上限,因此使用 7 bit 可以保存第一个索引。其他的两个索引则保存为相对于第一个索引的偏移,nanite 保证该偏移不超过 32,因此使用 5 bit 可以保存其余两个索引,共计 17 bit。
UV 坐标可能会存在跳变,在编码时,会考虑消除掉 UV 范围中最大的 gap 来实现对 UV 的压缩。
法线会直接用八面体坐标来表达。切线不存储,会在使用时通过 uv 坐标的梯度计算得出,实际上就是法线空间的 U 和 V,这种方法与屏幕空间微分来获取切线空间类似,不过 nanite 没有直接使用求导,而是重用材质 pass 中重新插值和贴图 LOD 采样所使用的三角形 delta。
材质表
一个 mesh 可能会包含多个材质。为使材质表尽可能小,nanite 本想要使每个 cluster 仅包含一个材质,但这个做法有太多限制。所以最好将材质信息逐三角形保存,按材质进行排序,并存储每个材质对应的三角形的范围。
大多数 cluster 的材质数量不超过 3 个,这些会被记录在一个 32 bit 的表中。超过 3 个的 cluster 会被记录在一个 64 bit 的变长表中。
磁盘数据
会优先在支持硬件 LZ 解压的平台上使用硬件解压,在做该分享时,只支持 console 平台。
目前 nanite 的压缩是将现有数据转码为可压缩的格式,而不是重新设计一套格式来支持压缩,因为开发者认为支持硬件解压的会越来越多。
LZ 压缩提供了字符串匹配和乱序编码的支持,且压缩的数据主要是 transform,因此转码过程可在 GPU 端进行,以充分利用并行性,此外由于 nanite 在 GPU 端就包含了数据,无需从 CPU 端复制,也能减少不少消耗。
LZ 优化
首先是将数据按 byte 对齐,这会增加原始的数据大小,但是可以减少压缩后的数据大小。
然后是将数据按类型重新排序,以最小化数据偏移的宽度。
Nanite 倾向于选择较小的字节值,这么做是为了更好的 LZ 编码效果。
删除重复顶点
在 build 时产生了大量的冗余数据,由于 cluster 是独立的,各相邻的 cluster 之间的共享边存在重复的顶点。这些冗余顶点在 LZ 压缩的过程中通常无法识别,因为相同的顶点在不同的 cluster 中编码不同,而且 cluster 的父级的数据只有在 GPU 端才可见,因此冗余的部分要用引用的方式来存储。由于在引用的数据可能没有加载到内存中,可以使用父节点的数据作为 fallback。
通常有 30% 的顶点会被当做引用来存储。
拓扑编码
磁盘编码使用了更紧凑的拓扑,不再是记录每个三角形的三个顶点作为索引,而是用带状三角形序列,这样在每次新加一个三角形时只要单独添加一个顶点索引就可以。该三角形带越长需要的平均索引数就越少。
在记录顶点时要添加一个 bit 位 IsLeft 来指示新增的面片的位置关系。
编码时还会对首次使用到的顶点来排序,当第一次引用到某个顶点,它的索引将正好是目前遍历过的顶点的总数,因此不需要显式存储索引列表。但对非第一次顶点的引用仍需显式存储,nanite builder 保证这些引用与出现过的最大顶点索引之间的偏移不会超过 5 bit。
最终的编码结构是一系列 bit mask 和偏移值,它们用于指示何时重置条带、何时向左或向右移动以及是否需要应用偏移。
| IsReset | IsLeft | IsRef | Ref Value |
| 1 | 0 | 0 | - |
| 0 | 0 | 1 | 6 |
| 0 | 1 | 0 | - |
假如首次引用的顶点索引为 v44,cluster 中出现的最大顶点索引为 v50,那么记录的偏移值就是 6。
Demo 中的内存表现
在压缩到磁盘后占用的内存只有 4.61GB 是原始数据的大概 18%,因此估算出每百万三角形占大概 10.9MB 的磁盘空间。不过按照官方文档的说法现在每百万三角形的磁盘占用约为 13.8MB。
将来的工作
虽然 nanite 可以用于绝大多数情况了,但依然有些内容需要支持:
- 透明材质和 Mask 材质
- 非刚性几何体的变形
- 具有动画的几何体
此外还有光追、地形、细分等,对于绘制像树木、植被等聚合体的效果和性能消耗也并不理想,将会在未来持续更新。

