今天分享的是Epic在Siggraph 2021上关于UE5的Nanite技术的深入分析,原始PDF在参考文献中给出。
受Virtual Texture技术影响,Epic希望能能够给出一套类似的用于Mesh的技术——Virtual Geometry,目标是为了消除项目开发以及发布过程中对于模型面片数、DP数以及内存消耗的限制,从而使得美术同学可以直接使用高模来进行项目制作,避免高低模转换等一系列适配工作的时间浪费,同时也不再需要关心高模到低模转换过程中的质量损失。
希望是美好的,但是实施起来遇到了不少的问题,除了内存消耗的问题之外,模型细节的增加导致性能消耗急剧上升,此外由于模型不能像贴图一样进行混合filter也使得这个问题进一步复杂化。
为了解决这些问题,Epic尝试了众多的方案,下面我们来看下这些方案的实施与表现:
1. Voxels
首先考虑使用Voxel来完成场景的表达,不过一个使用多边形(triangle)表示需要2M的模型,使用Voxel(Resample到一个narrow band的SDF)却需要13M,而这还是通过稀疏存储移除了不需要存储的空白区域数据之后的结果,并且从表现上来看,右边的模型精度相对于左边还有所下降。
本质上来说,voxel的最大问题还是存储空间消耗过高,而如果使用过狠的稀疏压缩又会导致精度下降,因此最终这种方案被pass。
使用Voxel的另一个问题是,项目制作与开发的流程需要推倒重来,这个代价太高了。
2. Subdivision Surfaces
Epic尝试的第二种方案是Subdivision Surfaces,这种方案可以对模型进行无限细分从而可以做到无限平滑放大。不过其缺点就是对于模型放大(相机拉近)是满足需求的,但是对于模型缩小(相机拉远)则没有办法无限坍缩实现简模(有一个下限——base cage,且通常这个下限比一般游戏中的低模面数要高,放到生产场景中,美术同学就需要考虑base cage对于渲染消耗的压力,而这并不是一个良好的开发模式)。
3. Displacement Maps
第三种策略是Displacement Maps,因为现在Displacement Map的制作跟Normal Map一样方便,且通过Displacement Map,低模面数可以做到很小。
但是这种方法在部分模型上的表现会存在问题,对于一些单层(displacement map给出的是某个方向上的向量长度,如果某个方向上存在多层面片,就没有办法用一个数值来表示两个长度)模型而言,Displacement Map可以做到很好的表达,而对于一些多层模型如右边所示的链条,就不好表达了。
此外Displacement Map也有跟Subdivision Surface一样的问题,只能增加模型细节,而在删减细节的时候则有一个下限。
4. Point Cloud
点云渲染是Epic考虑的第四种方案,这种方案的问题在于:
- 高昂的overdraw
- hole filling算法的复杂性,很难确定某个gap是否需要fill(可能是正常的模型开口)
5. Triangles
经过对比发现,还是传统的三角面片渲染方式最靠谱,虽然前面给出的众多物件表示方式在UE中也有被用到,不过Nanite的核心还是建立在三角面片的基础上的。
而使用面片来构建场景,目前最先进的技术就是GPU Driven的,几年前UE的Renderer增加了一种Retained Mode,而UE5的Nanite就是在这个Mode的基础上扩展而来的。
扩展后的Retained Mode将在显存中存储了一份完整的场景数据,这份数据的生命周期会跨越多帧(即并不是每帧都会释放重传的),只有当数据发生变化才会触发相应部分数据的更新(增量更新)。
此外,Nanite所需要的所有资源(VB/IB/贴图等)数据都是存储在一个单一资源结构(Single Large Resources)中,因此不需要bindless resources也能随时对其中的任一资源进行访问。
在GPU Driven管线中,我们可以知道每个dispatch(CS调用)中可见的instance,并且如果只是绘制深度的话,只需要一次DrawIndirect就能够完成所有面片的光栅化。
GPU Driven管线提升了渲染的效率,但是到目前为止,一些不可见的instance还会占用很大一部分计算消耗,因此这里需要增加一个剔除逻辑,为了实现这个剔除,UE会将面片组合成一个个的cluster,每个cluster包含128个面片,之后根据各个cluster的bounding box进行剔除(包括Frustum Culling跟Occlusion Culling两步)
Epic在测试中发现Cone based backface culling收益并不高,原因在于绝大部分backface的triangle都能够被Occlusion Culling滤除掉,因此这里的Culling就没有专门针对backface设计一个额外的处理过程(后面光栅化过程会进行此项culling)。
Occlusion Culling是借助HZB完成的:对于每个cluster(或者cluster group),会先计算出其boundingbox/boundingsphere在屏幕空间中所占的rectangle的尺寸,之后找到与之分辨率匹配的Hierarchical Depth Buffer(目标是此Rectangle正好对应于这个Depth Buffer的4x4像素,当Rectangle尺寸小于4x4个像素,那么就直接找到最低一级Depth Buffer即可),并判断此Rectangle是否被遮挡。
HZB剔除算法可以参考这篇博客——Practical, Dynamic Visibility for Games,Epic还对算法进行了改进,比如通过使用boundingbox的depth gradient而非如博客中直接使用最近的depth来进行比对,可以得到更好的效果。
HZB Culling的第一个问题是,HZB要如何获得,一些项目常用的策略是使用上一帧(这里的假设是,上一帧可见的数据,在这一帧可见的概率非常高)的Depth输出经过reproject投射到当前帧,据此计算的HZB来进行当前帧的遮挡剔除,这种做法有一定的可取之处,但是其结果永远只能是近似,精度上无法保证。
Epic这里借鉴了这个思想,不过不是将上一帧的depth数据reproject到当前帧,而是直接对上一帧可见的geometry进行一遍绘制得到当前帧的Depth并构建出对应的HZB,这样得到的HZB更准确。
实际上,Nanite的剔除过程比这个复杂,完整的剔除流程包含两个pass(第二个pass可选):
- 对上一帧可见的物件进行绘制
- 根据已经绘制得到的Depth构建出HZB
- 对剩下的物件使用上面的HZB进行遮挡剔除
- 进入第二个pass,剔除后的结果可以构建一个更新的HZB
- 对3中被剔除的物件使用更新的HZB进行一遍复核
Epic希望给出一套Mesh的Visibility计算与材质Evaluation逻辑分离的机制,目的是为了消除:
- mesh光栅化阶段中的shader(renderstates)切换(光栅化只跟mesh有关,跟材质无关,所以分离可以做到消除切换)
- 材质Evaluation过程中的Overdraw(因为Visibility已经分离出来了,后面只需要对可见的像素进行材质Evaluation即可)
- Depth Prepass的Overdraw(推测是通过前面的HZB来实现prepass的overdraw的降低,因为只对上一帧可见的物件进行绘制,因此较大程度上降低了overdraw)
- 网格过密导致的渲染低效(为什么网格过密会导致渲染低效?因为硬件的设计是为大尺寸面片设计的,对于一些单像素的面片会导致75%的浪费,因此需要为这种情况做特殊处理?)
要想达到上述目的,有多种策略:
- REYES
- Texture Space Shading
这两种方案可以归结为Object Space Shading,弊端在于会导致Overshade(4x或者更高),虽然Overshade可以通过Caching机制来解决,但是这种解决方案是有限制的,比如需要是View dependent,animating以及non UV based的(强翻,后面了解清楚再来做更详细解释),而材质使用的地方太多了,无法全部满足需求。 - Deferred Material
经过考虑,Deferred Material是多种策略中比较靠谱的一种。
Epic的Deferred Material流程主要分成两步:
- Visibility Buffer的写入,Visibility Buffer存储了物件所需要的最基本的几何数据(depth,instanceID,TriangleID等)
- Deferred Material使用流程,在Visibility Buffer写入完成后,进行Screen Space的Shading的时候,会进行下列工作:
A. 加载Visibility Buffer
B. 加载当前像素的Triangle数据
C. 将Triangle的顶点位置变换到屏幕空间
D. 计算当前像素在Triangle中的质心坐标
E. 根据现有数据对顶点属性进行插值
这种实现看起来消耗高,但是因为缓存命中率高,且降低了像素的Overdraw,所以最终的执行效率还可以。
通常来说,Visibility Buffer算法会将Material Evaluation与Shading放在一起,但是Epic这边Material Evaluation只是将相关数据写入到GBuffer,之后再进行Shading逻辑,目的是为了实现Deferred Shading逻辑。
在这种模式下,我们可以将所有的不透明物体通过一个Draw Call就完成绘制,CPU的消耗不再受限于物体数目(只与View的数目相关),Material的调用频率变成每个shader调用一次,虽然还是很多,但是相对于以往每个物件一次已经得到了极大的简化。
此外,每个View只需要进行一遍面片的光栅化,而不需要如Deferred Lighting一样,需要两遍Geometry Pass,从而可以降低Overdraw的消耗。
使用这种策略之后,渲染的速度有了明显的提升,但是其消耗与物件实例数目以及面片数目依然是正相关的。
消耗与物件实例正相关是可以接受的(目前硬件可以很轻易的处理数百万个实例),但是与面片数目正相关就不太能接受了,跟前面设定的目标相冲突。
RayTracing方案的消耗与面片数之间的关系是LogN,但是依然无法满足要求,因为RayTracing要求将场景中的所有内容都加载到内存中,这在当前是做不到的,更何况即使将之完全加载到内存中了,其渲染速度依然不能满足需求。
理论上来说,O(Log n)与O(1)在效率上并不会差太多,10k个面片涨到1M也不过是增加了43%的tree levels。问题在于复杂度越高,内存访问Cache miss的概率就越高,而这会导致渲染性能急剧下降。
此外,由于屏幕像素是固定的,最佳的可见面片数理论上应该跟屏幕像素数目差不多才是,因此消耗也应该有个上限才是合理的。因此,Epic这边采用Cluster来组织面片就是希望每帧渲染的cluster数目基本上是恒定的,不会因为模型的面数不断增加而发生太明显的变化。
虽然并不是一个很完美的答案,但是在这种做法之下,渲染的时间消耗将取决于屏幕分辨率而非场景复杂度,当然,这里肯定少不了LOD的实现逻辑。
Nanite的LOD策略是构建在Cluster之上的,如上图所示,通过将多个Cluster(实际上是Cluster Group)合并成一个Cluster(Group)实现LOD,由于每个Cluster的面片数目是固定的,因此在合并的过程肯定是有面片简化的。
之后在运行时,只需要根据期望的面片尺寸对这棵树进行一次切割就能得到相应粒度的cluster,这个切割根据cluster在屏幕空间的投影误差来决定的——只有当parent node的绘制结果跟当前node的绘制结果差不太大的时候才可以考虑使用parent node替换当前node。
而这种LOD策略可以很好的匹配Virtual Geometry的需要,我们不需要将整个场景的内容全部都塞到内存中,比如我们只需要将切割出来的最下层cluster标记为叶子节点,其他节点可以抛掉不存储。跟Virtual Texture一样,我们可以根据需要将需要渲染的cluster加载进来,不需要渲染的cluster被置换出去。
上面介绍的是上层设计思想,但是在底层实现上会遇到不少问题。比如两个相邻的cluster如果最终计算得到的LOD层级不一致的话,就会导致边界裂缝,一个粗暴的方法就是lock edge,也就是将两个相邻cluster的共享边锁住,即使LOD切换,也不会对这些边进行合并简化,这样就能避免裂缝。
但是由于Cluster的层级比较多,如果一直使用lock edge算法,可能会导致上图所示的奇怪的cluster划分结果(一些本来平滑的区域却出现比较密集的网格)。
按照这种算法,cluster之间的合并将如上图右侧所示,构成一棵树状结构。
Epic这里的做法是将Cluster分组(Group),分到一个组的Cluster,也就是一个Cluster Group中的Cluster永远保持同一个Level,这样在同一个Group中就不会出现裂缝,因为同进同出,同细同粗。
当然,问题还没有完全解决,因为Group之间的Level可能会不一致,因此这里还是有可能出现裂缝,而这里的做法是对Group应用lock edge算法。
为了方便叙述,这里给出了一张示意图,线条表示group边界。
不同的颜色表示不同Level的Group,红色表示Level 0的Cluster Group,绿色表示Level1,蓝色表示Level 2,可以看到不同Level的Group之间并没有共边,这难道不会出现裂缝吗?
或者每个物件只能同时对应一个Level的Group?这显然是不符合需求的,具体是怎么做到的呢,我们接下来看看。
在介绍具体实施细节之前,这里给出了Epic之前考虑过的其他消除裂缝的方案,内容比较分散,且对于阐述Nanite方案没有太大价值,这里就跳过了。
Epic的裂缝消除最终选择的是这套Explicit dependency方案,参考了一篇十分晦涩的文章实现的(PPT注释中给出了他人的拆解分析文章以及原文PPT,有兴趣的同学可以看看)。
下面先介绍一下Cluster Build逻辑,之后通过Build逻辑或许可以解决前面的一些疑问。
- 先构建叶子Clustr,每个Cluster包含128个面片
- 对于每个LOD层级,我们需要完成如下工作
2.1 完成Cluster的分组,得到多个Cluster Group
2.2 对同一个Group中的面片进行合并,得到一个shared list(这个合并是怎么做的?shared list是什么样子的?从下面的示意图并没有看到相应的描述,结合前面的描述,这里的合并需要保证group的边界是不变的,即lock-edge)
2.3 对三角面片进行简化,面片数降低为原面片数的一半,得到简化后的面片list
2.4 对简化后的面片list进行拆分,得到新的Cluster(相对于原始cluster,新的Cluster对应于新一级的LOD,从这里可以看到,在从高精Group到低精Group转换的过程中,需要保证两级Group之间的边界是吻合的,其次两级Group中的Cluster并不构成一对多的关系,而是重新clustering得到的,经过重新Clustering之后,低精cluster又可以继续进行Group,且得到的Group跟高精Cluster的Group并不需要保证是一一对应的,这也是为什么前面示意图中红绿蓝三者边界不重叠的原因) - 对上述过程进行重复,直到最后只剩下一个Cluster
从这个逻辑可以看到,不同Level的Group之间并没有完全的覆盖关系,即Level1的Group并没有对应多个Level0的Group,这样的话,整个模型只能对应于同一个LOD,那么不就无法实现针对Group的LOD吗?
下面通过一个示意图来解释上述步骤。
左图用不同颜色标注了四个相邻的Cluster,这四个Cluster对应于同一个Group,下面给出了对应的DAG(Directed Acyclic Graph,有向无环图)描述。
接下来,我们需要对Group中的面片进行合并与简化,可以看到,合并的时候Group的边界是被保留的(lock edge)。
简化后的mesh group进行二次拆分,得到两个新的Cluster,这两个新的Cluster对应于更上一层的Level,可以看到Level1跟Level0的Cluster也不是一一对应的。
到此为止,四个由四个面片组成的cluster被简化为两个四面片的Cluster,完成了上述的一轮迭代(这里的两个Cluster并不就对应于高一级Level的Group,而是会加入其他的Level1的Cluster再一起组成新的Group)。
而由于合并与拆分的逻辑存在,使得cluster之间的关系不是一棵树,而是一个DAG,这也就意味着不会出现某条边一直被锁定的情况,从而解决了前面奇怪的网格问题。
下面对Cluster Build的步骤做详细解释:
- 首先需要解决的是,哪些Cluster应该被分到同一个Group中。这里的原则是,处于同一个Group中的Cluster得到的Group的外轮廓edge的数目要尽可能的少,因为这些外轮廓的edge就是最终merge的locked edges,越少表明merge受到的约束也就越小。
Cluster分组问题其实是一种Graph Partitioning问题,而这类问题在Computer Science领域已经有众多的研究。
Graph Partitioning算法指的是将一个Graph拆分成多个Partition,并保证从一个Partition到另一个Partition的edge的权重(称之为edge cut cost)是最小的。而在这里,Graph的节点就对应于Cluster,Partition就对应于一个Group,edge weights对应的就是Group之间的共享边数目。
为了避免graph中出现孤岛(孤立的面片,消除孤岛是为了避免孤岛后续划分的结果不受距离约束),这里还需要为那些空间上相近但是并没有连接关系的面片添加一些额外的边。
Graph Partition是一个十分复杂的课题,不过好在有现成的库可以使用——METIS。
不过在进行Graph Partition之前,我们需要得到Graph的节点,也就是triangle cluster,而哪些triangle应该被分到同一个cluster同样是一个多维度的优化问题:
- 考虑剔除的效率,cluster的boundingbox应该要足够小
- 为了保证光栅化的效率,每个cluster的面片数应该接近而不应该超过128
- 为了保证primitive shader的执行效率,每个cluster的顶点数应该不超过某个阈值
- 同样,这里也需要约束cluster的外轮廓的edge数目以实现Group的共享edge数最少的目标
想要兼顾所有维度,使得在这些维度上都达到最优,这个问题就十分复杂了,不过由于这些维度本身是相关的,因此Epic这里的做法是选取其中的两个维度作为主要考虑点,其他维度就听天由命了,这里选取的维度为:
- 尽量保证cluster的轮廓edge数尽可能的小
- 保证每个cluster的面片数不超过最大值
优化cluster轮廓边数的问题跟前面对cluster进行分组的问题的考察逻辑就完全一样了,唯一的区别在于,对cluster的划分是有明确的上限(面片数不超出128),不过可惜,现有的graph partition算法并没有能够直接满足这一点的,Epic通过一些trick来基本保证这一点的达成(具体算法未介绍)。
graph partition算法如果完全自己实现应该能够很好的解决这个问题,不过由于这个算法实在是太过复杂,实现成本十分之高,因此最终还是考虑使用trick来解决这个问题。
这里说到,初始化阶段对原始mesh面片的cluster逻辑跟后面对合并后的mesh triangle进行cluster拆分是同一套逻辑(本来就是吧……)
早期的分组算法是基于空间实现的,不足在于会导致每个group中的面片数不均匀,比如可能会出现某个group只有一个面片的情况。
这里分别介绍了Quick-VDR跟Batch额的 Multi-Triangulation Partition算法,分析了两者的原理与不足,最终决定通过将两者结合的方式来实现Graph Partition。具体细节理解得比较支离破碎,这里就不做展开了。
模型简化是通过edge collapse等经典算法实现的,简化误差是通过Quadric Error Metric计算得到的,虽然这个思想没啥特别创新的地方,但是Epic在实现过程中对原算法进行了大量改进以提升效率与质量。
前后两级LOD之间会存储一个estimate error,这个error会在运行时投影到屏幕空间转换成误差像素的数目,根据这个数目来决定是否需要进行LOD的切换。
这里的estimate error是针对mesh计算的,没有考虑材质等可能会影响感知的因素,虽然不够完美,但是基本上也够用了。
传统的QEM算法只检测Mesh层面的error,没有考虑材质等导致的感知error,Epic这边给出了一个经验式的trick,将mesh error跟attributes error混合在一起考虑(想象二者具有相同的error单位),不过根据需要为不同的物件提供不同的权重。
现在实现的QEM算法还有优化的空间,比如对法线分布进行prefiltering作为法线层面的error权重依据;比如通过一定的策略解决不同尺寸的面片计算结果的偏差等。
要想实现Visibility Prefiltering,就需要实现subpixel层面的数据表达(对于叶子跟草等聚合geometry来说尤其重要),而Triangle是无法做到这一点的,目前并没有一个有效的解决方案,给出的思路是通过triangle mesh跟voxel等混合实现场景的表达。
接下来看下在运行时是如何对物件的LOD进行切换的。我们目前为每个物件构建了一个Cluster Hierarchy,之后每一帧我们都需要为每个cluster确定其LOD级别。
前面说过,每一轮build operation都会生成两套具有相同边界的clusters,这两套cluster具有不同的LOD,因此在这两套Cluster之间进行切换是不会导致crack问题的,在实际使用的时候,会将之前简化计算时保存的error投影到屏幕空间,之后根据到相机的距离以及角度来对这个屏幕空间error进行调制,据此来判定哪一级的LOD应该被取用。
但是前面也说过,这里的一个问题是,高精度的一套Cluster可能刚好组成一个Group,这样可以保证同一个Group中的Cluster处于同一级LOD,但是后面低精度的一套Cluster可能是某个高Level的Group的一部分,这个地方选择的LOD可能跟其他部分选择的LOD不一样,这不就不满足同一个Group中的Cluster LOD一致的原则了吗?
这里的做法很简单,那就是处于同一个Group中的Cluster存储一个完全相同的unioned error,以及一个统一的sphere bounds(用于投影到屏幕空间使用),因此不管Group的哪个部分计算得到的LOD层级应该都是完全相同的,就不会存在同一个Group中的不同部分选择的LOD不同的情况,要么统一用高精度的,要么就统一用低精度的。
总结一下,前面的实现算法的意思就是判定某个cluster的投影误差是否足够小,只选取哪些投影误差不超过阈值的最高一级(越高越粗糙)模型。
而LOD的选择其实就相当于根据相机视角来对DAG进行一次切割,切割线上的部分被绘制,切割线以下(以及更上层)的节点被抛弃。由于整个过程都是在GPU中完成的,因此最好能做到并行计算。
要想实现并行计算,首先需要明确的是一次切割的具体含义。
什么时候会发生切割?只有当parent node的error太高,而当前node的error又正好低于阈值的时候才会发生切割,切割的位置就是保留当前节点,抛弃更下层的节点。
而这个过程实际上并不需要关心周边其他节点的数据,只需要拿到当前节点以及parent node的数据即可,因此可以并行完成。
实际上没这么简单,当有且只有一条正确的切割线的时候,这种做法是没问题的,但是假设如果存在多条满足条件的切割线的话,我们就需要先归一到一个起始节点,而这个需要对整个graph进行遍历,这就不满足并行计算的需要了。
因此,我们需要通过某些手段来保证有且只有一条切割线。而有且只有一条切割线的条件是所有从根节点到叶子节点的路径上,selection function给出的error评估结果是单调的,不会出现先增后减或者先减后增的情况,也就是说我们需要保证parent node的error肯定要高于child node的error,而这一点可以在离线进行DAG构建的时候保证(比如parent_error = max(cur_error, all child_error))。
那么这种LOD算法会导致视觉跳变吗?是否需要通过额外的geomorphing算法或者cross fading算法来对LOD切换进行平滑?
第二个问题的答案是不允许,因为需要额外的数据存储,计算消耗也比较高。
不过如果cluster切换之间的误差不超过一个像素(这也是为什么需要一个精确的误差评估的原因之一),那么在感知上可能是没有区别的,此外,在加上TAA之后,subpixel之间的变化可以被进一步平滑,因此基本上就不会有问题了。
之前的Cluster的误差是在物件空间(Object-Space)中计算的,没有跟观察方向进行绑定,而实际上顶点变化的误差是可能会随着观察方向而变化的。将相关数据投影到屏幕空间并没有考虑表面的角度(表面与观察方向的夹角?),比如在平视(视线与表面法线垂直)时的tessellation factor跟正视(视线平行于表面法线)的factor是相同的(导致一些没有必要的tessellation消耗,此外overdraw等其他消耗也是需要考虑的)。一个简单的想法是使用各向异性的LOD(具体如何做?),但是这种方法在cluster的LOD选择上不能用,因为cluster的LOD选择必须要是各向同性的(目的是为了保证相邻各个cluster具有相同LOD?)。
前面说过,Cluster的选用应该要做成并行的,但是实际运行的时候发现大尺寸场景中的Cluster数目实在是太多了,绝大多数Cluster应该都是不可见的,如果对每个Cluster启用一遍判断,消耗就太高了,正确的做法应该是添加一个层次结构,先粗后细,逐步取精。
层次结构选取逻辑最自然的就想到了前面的DAG,但是直接使用DAG要怎么做到并行又成为一个问题。好在前面说过,Cluster的LOD级别只需要使用cluster的误差以及parent的误差,不需要其他数据,因此我们其实也不需要DAG参与。
哪些Cluster需要被剔除呢?其实就是前面Cluster LOD选择中不满足取用条件的那些。
如果parent的误差已经足够小了,那么这些cluster就已经可以被剔除掉了,这也就是说,一个cluster是否应该被剔除取决于parent的误差而非cluster本身的误差(神奇……)。
根据上面的结论,我们可以通过cluster来构建一个bounding volume hierarchy(简称BVH,这里的BVH每个节点最多包含8个子节点,这里有个问题是,Cluster之间并不构成一对多的关系,那么BVH的结构是怎么实现的呢?除非Cluster Group能够构成一对多关系),跟普通的BVH结构一样,这里的BVH结构中的parent node的bounding volume应该要能够完全包裹所有子节点的bounding volume。
这里对BVH的构建逻辑陈述还是不太清楚,哪些Cluster作为叶子节点(PPT中说是group中的所有cluster都是叶子节点,那不同LOD对应不同Cluster,这些Cluster都是叶子节点吗?如果是的话,难道不会出现同一块模型碎片会需要对不同的LOD的Cluster都进行一次剔除判断吗?此外,是否还存在同一块碎片的多个LOD并存的情况?显然是不合理的,可惜文中没做进一步解释)
这里介绍了BVH结构下的并行剔除示意逻辑,整个剔除过程分成多个pass完成,每个pass负责一层BVH节点的处理,处理完成后会将下一层需要做进一步判断的节点添加到待处理list中,作为下一个pass的输入,每个pass都是通过DispatchIndirect触发的,当待处理列表为空的时候,就可以终止整个剔除流程了。
实际上,我们并不希望等每一层处理完再处理下一层的节点,从缓存有效性利用来说,我们更倾向于处理完父节点马上就处理其子节点,也就是理论上来说,我们希望在父节点处理完之后,就创建对应的子节点处理线程来完成子节点的处理,但是目前GPU并不支持这种处理逻辑。
不过,虽然GPU不支持创建新的线程,但是却支持对老的线程进行重用,并为之赋予新的工作逻辑,通过这种方式我们可以在GPU上实现一套job system:
- 首先创建足够多的worker线程以保证GPU是满载的
- 每个父节点处理完成后,取用目前闲置的线程并为之赋予新的处理数据完成子节点的处理
简单来说,对于层级结构而言,我们首先会从node队列(初始化的时候将每个物件的root node塞进去)中pop一个节点出来,完成对这个节点的处理,如果有必要的话将子节点push到队列中,直到队列为空。
在这种情况下就只需要一个dispatch(最开始的时候)即可,且对于层级数目的限制也不存在了,经过测试,这种方法比前面按照层级进行处理的方法要快上25%左右。
可惜的是,这种算法需要依赖scheduling行为,而D3D/HLSL目前并不支持,关键的一项特征就是一旦某个线程group开始执行,并且在之后开启一个lock,那么需要保证这个线程group还能接着被scheduled(使用)而非无限期的处于饥饿状态。
虽然没有过相关的文档说明,但是这种方法在主机以及目前测试过的所有相关的GPU上都能够正常运行,PC的程序模型后续也会跟进,未来或许也能使用这种优化后的剔除算法。
BVH中的每个节点(推测对应一个Cluster Group?)代表了一个Parent误差,BVH的叶子节点则代表了共享一个parent的clusters —— PPT Notes中的这句话存在逻辑问题。
BVH中的节点需要进行剔除,Cluster也需要进行相同的剔除,因为BVH的深度可能会比较大,而同一时刻处于激活的节点数目相对于GPU的处理能力而言是非常少的,也就是说光凭BVH的节点还无法填满GPU,为了充分利用GPU的运算能力,提升计算效率,Epic这边的做法是将Cluster的剔除计算也放进来,为Cluster单开一个queue,如果BVH worker在等待新的节点的过程中发现Cluster queue中存在需要处理的内容也会从那边取出cluster进行剔除处理。
To avoid divergence this is done in batches..
这句话的意思,推测是为了避免处理过程中同一个线程组的线程会因为处理不同的数据而造成分支等待,这里的处理以64个具有相同计算逻辑的线程为基本单位。
这里需要注意的是,前面在沿着BVH进行Cluster LOD选择的时候,同时也会开始对Cluster或者Cluster Group的可见性进行判断,前面说过可见性判断是一个2个pass的处理逻辑。
直接使用上一帧可见的Cluster/Cluster Group比较麻烦,因为上一帧的LOD跟这一帧的LOD可能不一样,且上一帧可见的Cluster在当前帧可能也是不可见的,因此这里的做法是使用上一帧的BVH来对这一帧选中的Cluster LOD进行可见性判断。
2 pass的剔除逻辑大致给出如下:
- 对每个Cluster使用上一帧的Transform变换之后与上一帧的HZB数据进行比对判断是否可见
- 对可见的Cluster进行绘制,同时保存下不可见的Cluster留待后用
- 使用可见的Cluster构建本帧的HZB
- 使用本帧HZB完成对前面被剔除的Cluster的可见性确认
- 对上一步可见的Cluster进行绘制
- 再次构建一个完整版的HZB,这个HZB会在下一帧被使用
看起来似乎需要跑两遍Nanite的管线,但实际上第二个pass只是对第一个pass中不可见的Cluster进行计算(且只进行HZB剔除,frustum culling以及LOD选择都不需要做),因此消耗很低。
这里是完整的两个pass的处理逻辑,在Main Pass中,首先对Instance进行一遍剔除(Frustum&HZB),可见的Instance会进入到Persistent线程进行BVH剔除,经过这个过程后,会输出具有合适LOD的可见Cluster,根据这些Cluster构建的HZB会用于Post Pass的剔除。
在Post Pass中会基本重复对Main Pass的逻辑,不过处理数据不再是所有的Instance与Cluster,而只是上一个Pass中被剔除的Instance与Cluster。
经过两个Pass之后,会构建一个完整的HZB用于下一帧的处理。下面来介绍一下光栅化相关的内容。
我们现在有了合适的LOD的Cluster,下一个问题就是我们需要绘制多少面片,也就是多大的一个面片才能保证感知上没有质量损失,我们是否可以使用大于一个像素的面片?
在很多情况下是可以的,并不是所有地方都需要十分精细的面片(比如一个平面本身需要的面片就不多)。但是如果从通用角度来看,则是不行的,如果我们希望实现pixel尺寸的效果,那么就需要pixel尺寸的面片。
但是在面片尺寸较小的时候,对硬件光栅化组件来说就是一个十分沉重的负担了。
GPU光栅化组件的工作逻辑:
- 会有一个大尺寸的tile判定机制(broad phase)
- 会有一个小尺寸的tile(4x4)判定机制
- 经过上述两个判定处理后,会以2x2的quad作为最小处理单元进行输出
- 目标是为了实现pixel的并行计算而非面片的并行计算
此外,现代的GPU每个clock最多只能完成4个面片的setup,之后输出用于visibility buffer的primitive ID,从而使得大量的小面片的处理变得更困难。
Primitive Shader或者Mesh Shader在处理面片上性能会好一些,但是却不是为这个目的而设计的,且依然会存在瓶颈,因此这里考虑使用软光栅代替硬光栅来完成小面片的处理。
经过测试,软光栅的计算速度平均是硬光栅方案中最快的Primitive Shader的3倍,而如果小面片较多的话,这个比例还可以更大(当然相对于传统的VS/PS逻辑会稍微大于3倍)。
之所以会有这个收益,是因为硬件光栅化逻辑做了很多在大尺寸面片上十分有效但是放到小尺寸面片上就会是浪费的工作,而这里应用中大量小尺寸面片导致硬件光栅化的表现就不那么如意了。
如果不使用硬件光栅化,我们会同时丢失ROP跟Depth Test的能力,而Depth Test却是必须的,这里由于同一时刻会有很多面片对同一个tile或者同一个像素进行写入,因此不能通过lock的方式来实现。
Epic使用的是64bits的原子操作(具体来说就是对Visibility Buffer使用InterlockedMax,这也是为什么Depth要放在最高位的原因)来完成这个过程的,如上图所示,Depth占用最高的30bits,剩下的34个bits则分配给Cluster Index以及Triangle Index,这一部分是光栅化的有效负载(payload),而这一部分需要尽可能的小才能保证软光栅的执行效率。
这里是软光栅的基础架构,跟mesh shader有点像,不需要post transform cache就能实现顶点变换结果的共享(顶点变换数据从全局Vertex Buffer中获取,变换后的数据放到哪里去了?直接使用CS计算,结果是否自动分配空间存储?),这里CS的线程Group的大小是128,总共分成两个执行阶段:
- 顶点处理阶段,这里每个顶点对应一个线程,如果cluster VB中的顶点数超出128,那么还可以再启动一个Group,最大支持256
- 面片处理阶段,在这个阶段每个线程负责一个triangle,先读取相关顶点属性完成相应的插值计算,并完成后面的光栅化工作
这里是对面片进行处理的示意代码,算法仅做示意使用,逻辑存在优化空间。
这里对包裹面片的rectangle进行循环判断,对rectangle中的每个像素进行判定,如果处于三角形覆盖范围内就执行写操作(会进行深度判断)。
这里每个面片由于覆盖像素较少,因此整个循环消耗不会太高,因此不需要考虑通过各种手段来降低这里的消耗,否则可能导致负优化。
大尺寸面片的光栅化逻辑还是走硬件,这样性能更高。这里具体采用硬光栅还是软光栅是以cluster为单位进行的,具体准则是根据某个公式可以计算出哪种方式更为节省,在UE的Demo中,大部分都是软光栅。
刚开始的时候,软光栅跟硬光栅的结果之间存在接缝问题,不过后面发现DX对光栅化规则的限定比较严格,使得这个接缝问题就不复存在了。
关于深度,最直观的方式是硬光栅走depth buffer,而软光栅走UAV,但是这样一来,两者的结果就无法相互使用了,从而导致运行管线的串行,这里UE最终选择的是硬光栅也写UAV,这样两者就统一了。
前面说了,有个公式可以计算出哪个面片更适合软光栅,而通常是小面片,那么多大才是大呢,这里说到是一般占据像素数目小于32的就是小面片。
但是这么大也有问题,那就是前面示例代码中的rectangle迭代次数就变多了,但有效像素占比却下降了,好的情况下可能有一半是有效像素,差的情况可能一个都没有,这些极端情况应该是有些方法可以处理跟规避的。
那么scanline算法是有还有优化空间呢?传统的不规则四边形的edge walking算法就太复杂了,这里只需要保持内循环指令数较少以及降低triangle的setup消耗即可。
再来看下之前的代码,划线位置处,我们为什么要对每个像素进行可见性检测呢,难道我们不知道在X是多大的情况下这个测试语句会通过吗?
这里是优化后的算法,我们将对rectangle的循环移除了,通过算出每个y下的面片覆盖的x的范围,我们可以对这一部分像素进行循环写入了。
另外文中还提到,当wave(cluster?)中某个面片在X上的有效像素大于4,就会对剩下所有的面片采用最开始的scanline算法(具体原因不明)。
如上图所述,光栅化阶段中会因为各种原因导致的overdraw。
光栅化阶段没有考虑材质的影响(后面会讲到),因此PS中的计算是比较简单的,但是在面片较小的时候,顶点处理过程就相当于像素处理过程了,而顶点处理的计算可能是不简单的。
我们前面介绍的culling逻辑最小单位是cluster,没有针对triangle的cull逻辑,这就导致了一部分的overdraw,通常来说这种情况问题不大,但是也有一些特殊情况(表面重叠比较紧密或者存在较多孔洞(比如树叶))下overdraw会很厉害。
此外,由于没有像素级别的HiZ(前面的HZB只有到cluster级别),所以overdraw也没有办法很好处理。
除了面片导致的overdraw之外,像素层面的情况也不容小觑。
有些地方的mesh是比较稀疏的,因此会存在大量的大尺寸面片,但是这并不会导致overdraw下降,由于面片尺寸变大了,所以cluster裁剪的粒度就变粗了,裁剪的内容就变少了,反而导致overdraw上升,但是有意思的是,这也就意味着,有些地方虽然绘制的面片数增加了,但是其渲染性能反而上升……
前面已经处理过小面片的情况,但是如果整个物件实例在屏幕空间的像素占比就很少要怎么办呢?
一个很直观的解决方案就是将这个实例剔除掉(screen size),但是如果这个实例是某个物件的组成结构之一(比如组成一个建筑的砖块等),直接剔除可能会导致这个物件本身完全消失,这就不合逻辑了。
那么这种情况有多常见呢?在Nanite的框架下,美术同学已经习惯了大手大脚,因此物件实例数目差不多已经赶上传统模型框架下的面数了,而这个会导致较多的问题。
既然剔除策略不可行,那么我们就只能考虑尝试在一定的条件下对物件实例进行合并,将小尺寸的实例合并成大尺寸实例,在这个过程中,高清模型会被转换成低精模型以降低内存消耗。
这样做的好处除了可以降低内存消耗,同时还可以减轻物件实例transform的计算消耗。
UE期望能在未来能够给出一种层级结构的物件实例(类似于树状结构的LOD?),但是可以预见的是,这种方案可能无法解决所有问题。
在合并策略上,希望能够根据距离的变化得到通用化的合并,但是这样的合并就会产生全新的数据(模型、贴图?),而高质量全新数据膨胀迅速,可能很快就会超出掌控。
UE不希望使用类似于megatexture或者megageometry之类的技术方案,因为这类方案会对地图的尺寸有约束(不能过大?),理想的合并方案应该是在任何的观察距离下都应该能够达到像素级别的无暇。
因此这里合并策略应该要在尽可能远的距离下开展(才不会出现肉眼可见的瑕疵),而在抵达这个距离之前,我们应该还要另外一套处理逻辑。
这里给出的方案是visibility buffer imposter方案。这里的imposter跟我们认知中的静态公告板大致差不多,不同的是存储的不是颜色跟GBuffer属性信息,而是跟Visibility Buffer存储数据一致的Depth跟Triangle ID信息。目的是为了能够直接将数据注入到Visibility Buffer中,这种方案支持Material Remapping、Non-uniform Scale(三坐标轴非一致变换)以及其他Nanite支持的特性。
这种方案在一些特殊情况下是能够看到瑕疵的,比如摆上一排相同的模型,在切换到imposter的时候由于跟正常的模型靠得很近,所以很容易看出不同,但是在绝大部分情况下表现都还是不错的。
这种方案的不足在于内存消耗还是比较高,且在一些情况下还是能够看出缝隙,后面有机会或许可以考虑一些更优的替换方案。
下面来介绍下材质相关的内容。
在对Visibility Buffer进行解码之后,原则上我们已经可以取到一个PS计算所需要的一切参数了,因此这里我们可以只绘制一个全屏的quad来完成最终的shading,详细实现逻辑参考上图。
Nanite中,顶点变换逻辑是固定管线实现的,但是PS的实现逻辑还是希望由美术同学百分百掌控。
也就是说,我们这里不可能对所有像素都使用同一个PS,那么我们怎么知道每个像素对应的shader,怎么从visibility buffer中取出这个shader呢?具体逻辑可以参考上图。
在callable shader机制支持下,我们理论上可以在一个pass中完成所有材质的apply,但是这种做法存在复杂性高且执行低效的问题。
UE这里的做法是为每个材质执行一次全屏的quad渲染,之后在渲染的时候跳过那些未被当前材质覆盖的像素,但是由于整个管线是在GPU中完成的,因此我们无法在CPU中知道哪些材质是可见的,所以需要对每个材质都执行一遍渲染,如果在进行材质渲染的时候,需要对每个像素都执行一遍是否被材质覆盖的检测,效率就太低了。
一个直观的想法是利用stencil来完成材质渲染时的像素剔除逻辑,但是这样做的话,每次渲染材质都需要对stencil test的条件进行修改。UE这里的做法是使用depth test,将MaterialID看成是Depth。
通过一个CS,可以输出material depth跟正常的depth,同时还会生成material depth的HTILE(将屏幕分割成等大的tile,每个tile中保存了这个tile中的depth range?之后tile组成hierarchy?),这样就不需要额外的计算量来生成HiZ数据了。
之后在对某个材质绘制全屏quad的时候,只需要将depth test函数设置为equal就行了。
有了Material Depth的HTILE之后,我们就不需要进行一个全屏quad的材质的绘制,而是可以进行一系列tile尺寸的quad的绘制,而哪些tile需要参与绘制则是根据之前material depth构建时同时创建的一个32bits的mask来决定的。
mask计算后,如果某个tile被标记为culled,就在VS中将相关tile的顶点变换到NaN来跳过后续的绘制。
因为材质依然是coherent的PS,因此我们最终依然可以得到用于texture filtering的有限差分derivatives(用于计算mipmap)。
跟传统光栅化逻辑中,以2x2像素的quad作为一个面片最小的光栅化单元不同,这里的quad会跨越多个triangle,这样可以有效避免小尺寸面片的高额overdraw,这是其优越的一面,但是在这种逻辑下,一个quad可能会涵盖depth断续点、UV seam、甚至可能跨越多个不同的物件,这会导致UV差分结果的突变,从而出现mip层级计算结果异常,导致类似于上图中的明显的线条瑕疵。
为了解决这个问题,这里采用的策略是通过解析公式(具体如何做呢?说是会在将node graph转换到HLSL的时候通过Chain Rule(即复合函数的微分求取法则,如)实现计算的,不过具体实现细节还是没有介绍,后面有机会补上)计算 出微分结果,只有在一些无法通过解析公式计算的情况下才会回退到原有的通过相邻像素差分的方式来模拟微分结果的方式(当然,这里还可以通过其他的比如ray differential的方式来计算微分,不过目前这种方式并没有遇到明显的问题)。
计算出微分结果之后,在采样的时候就可以指定gradient,也就是说是用SampleGrad来替换原来贴图采样所用的Sample接口。
理论上来说,这里应该会有比较高的额外消耗,但是实测发现额外消耗不超出原有材质消耗的2%,可能是因为这里的额外消耗都是跟贴图采样相关的,而在UE中,Virtual Texture的采样本身就已经完全被替换成SampleGrad接口(说明原来就有一套微分计算逻辑)了,因此消耗就停留在微分计算上面,而这个的新增比较有限。
这里列出了各个阶段需要处理的geometry的统计数据,从数据上来看,如果走传统的光栅化管线的话,大概需要处理十亿左右的面片,而通过Nanite的LOD逻辑,则只需要处理25M的面片(40倍的差距),而这25M不只是某个场景的统计结果而是这个demo中任意时刻基本上都保持不变的一个数值。从表现上来看,运行还是挺流畅的。
这里Demo中的原始分辨率是2496x1404的,之后通过TAAU(TAA Upsampling)上采样到4K,Visibility Buffer的绘制用了2.5ms,这个过程基本上没有CPU消耗,而Deferred Material的绘制则用了2ms,这里只有少量的CPU消耗(每个材质一个DrawCall),这个性能表现足以实现60 FPS了。
除了主相机渲染之外,在阴影绘制的时候,我们同样需要进行geometry的渲染,那么这个地方是否同样需要像素级别的模型精度呢?间接光可能用不到,但是直接光的阴影是需要高清细节的,实际上高清模型跟法线贴图的主要区别就在于阴影上面。
实现阴影的时候第一个想法是Ray Tracing方案,但是对于某个像素而言,其需要发射的射线可能不止一条(因为可能有多个光源),这就加重了计算压力,此外目前硬件提供的Ray Tracing API在海量面片的Nanite Mesh处理上还有很多问题,因此最终并没有采用这个方案(后面可能会不断尝试与改进)。
这里使用的还是基于光栅化的阴影方案,不过对于影响每个像素的光源数目是要做严格限制的,不然很可能导致Nanite无法达到令人满意的帧率。经过观察发现,大多数光源以及接受光源照射的物体都是静态的,因此可以尝试通过caching的方式来降低计算消耗。
Nanite是支持普通的shadow map的,不过现有的架构使得一种更为先进的阴影渲染方式成为可能,那就是Virtual Shadow Map。
所有的内容都会被绘制到16k x 16k大小的Virtual Shadow Map(简称VSM)上(根据光源类型的不同,Shadow Map的数目可能会有多张,这里的Virtual跟Virtual Map中的Virtual是同一个意思吗?)。
绘制到VSM中的Map分辨率(看起来跟Virtual Map概念很相似)会跟屏幕空间中接受阴影的像素数目相一致,如果某个区域对应的shadow map在屏幕上没有接收者,那么这个区域就不会绘制。
跟传统的Shadow map优化策略相比,VSM可以借用Nanite的LOD跟Culling策略来提升渲染效率,且对于那些不需要进行sample的Map区域,甚至可以不分配空间(怎么做到的?)
16k的Shadow Map是稀疏的,如上图所示,确实如此前推测一般,就是Virtual Map的存储使用方式,这里将16k分成为128x128的原始Page,这个原始Page对应于mip0(也就是最高分辨率的sub shadow map,后面根据需要会不断二分生成不同mip的shadow page)的大小,总共有128x128个原始Page,组成一个Page Table。
VSM的分配策略给出如下(下述计算可以并行完成,消耗不会很高):
- 对于屏幕空间中的每个像素
- 对于影响这个像素的所有光源
- 将这个像素投影到shadow map空间,每个光源都会有一个投影结果,可能处于同一个shadow map,也可能处于不同的shadow map。
- 投影结果会根据一个准则来选取对应的shadow Page的mip,这个准则就是保证屏幕空间的一个像素在Map空间是一个texel。
- 将对应的shadow page标记为needed
由于Virtual Shadow Map是支持Cache的(只要不将对应Page的结果清掉,就是可用的,但是如果光源发生变化了,这个Page就要设置为invalid了,此外Cache的基本条件就是绘制的都是静态物件,那么动态物件的阴影要怎么处理呢?),因此每帧只需要进行动态物件的绘制以及如果相机旋转或者移动了的话frustum边界新增的区域的相关内容的绘制。
Nanite是GPU Driven的,而这跟Virtual Shadow Map是十分吻合的,但是我们还可以改进一下Nanite管线使之更为高效。
目前的Nanite管线因为synchronization的原因,具有一些额外的overhead,因此这里不希望重复多次调用Nanite Rendering,这会使得overhead累加变得不可忽视,比如我们不希望为每盏光源都调用一次,甚至更为极端的为每个mip每个shadow page都调用一次。
UE的做法是对Nanite管线进行改造使之支持multiview rendering,一次性完成一个array的views的绘制而非单单一个view的绘制,这将极大的提升渲染效率(极端情况甚至可以得到百倍的效率提升)。
shadow绘制同样需要进行HZB Cull,不过与正常渲染相比,这里还要多一个test,那就是会将cluster/instance投影进行投影(一次投影多次判断,还是需要对每个needed page进行投影判断?是对每个needed page进行投影判定,因为是multiview render,因此对于每个render而言,实际上只是进行一次投影判断),判断其投影到的区域是否是needed的,如果不是就剔除掉,从而减轻渲染压力。
对于软光栅而言,最好是能够保证inner loop是最简单的,因为测试发现在内循环中的一个shift指令也会导致性能的消耗有明显上升。而由于软光栅处理的都是较小的cluster,这些cluster通常可以认为不会跨越多个page,因此只需要绘制到一个page上就够了,因此这里的做法是,对于cluster覆盖的每个needed page,都只emit一个cluster(不需要考虑cluster跨多个page,从而需要为每个page进行一次emit)到光栅器,只对这个cluster进行一次page translation并通过scissor test将结果写入到对应page。
硬光栅处理的cluster尺寸较大,会可能出现跨越多个page的情况,为每个page都进行一次绘制有点浪费,这里的做法是指进行一次绘制,将结果绘制到一张虚拟的(跟前面的virtual不是一个概念,而是一个幻想的实际不存在的)贴图中,之后通过虚拟到物理贴图(也就是前面存储阴影的VSM)的映射关系,将每个像素的绘制结果转存到VSM的对应Page(这里的一个问题是,难道我们不是为每个Page都进行一次Render吗,这样做的话,不就相当于将A Render的工作放到B Render做了?到后面两者的工作内容不是会重复吗?还是说对于硬光栅而言,所有同级的的Page是一次性绘制完成的?)。因为我们在写深度的时候对UAV开启了原子操作,因此我们可以将一个结果写到任意的位置上去。
Nanite的LOD策略也可以直接套用在Shadow Render上面,跟正常视角的渲染一样,这里的LOD选择也是以一个像素作为误差来完成的,这里的一个像素指的就是shadow需要绘制到的shadow page上的一个像素,由于像素跟texel是一比一的,因此对应到屏幕空间也就是一个像素的偏差,这样可以保证shadow绘制的复杂度是跟屏幕分辨率相关而非场景复杂度相关,但是这并不意味着shadow view跟primary view绘制的面片数目就是相同的了,实际上两者是有差别的,而因为这个差别导致self-shadow效果会存在一些瑕疵,UE这边的做法是在屏幕空间进行一个短距离的trace来平抚这个瑕疵(具体是什么问题,怎么修复这里没有透露更多细节),因为这个修正算法的存在,实际上在LOD的选择上还可以再大胆一点,比如将LOD选择的误差提升到两个像素,这样还可以进一步提升渲染效率。
下面来看下Streaming相关的逻辑。
mesh上的virtual memory(虚拟内存)概念跟virtual texture技术是类似的,只是在具体细节上不太一样,因此会有一些新的挑战。
跟virtual texture一样,当GPU发现当前的渲染质量(如mesh精度)跟目标有一定的差距时,就会告诉CPU异步加载更高精度的数据进来。
不过跟virtual texture不同的是,这里需要对加载跟卸载的内容进行仔细评估,以保证当前处于可见的数据是DAG的有效子集(valid cut),从而避免出现LOD之间的裂缝问题。
那么,我们要怎样实现Streaming呢,最小的streaming单位是多大?前面我们说过为了保证前后两级LOD之间的衔接是无缝的,mesh合并是以Group为单位进行的,即前后两级LOD的Group边界是相同的。当相机走近或者走远的时候,当前Group会被其下一级或者上一级Group替换,且这个替换是以Group为单位进行的,不能只做部分替换。
因此在这里也是一样,我们要加载就是完成一整个Group的加载,否则显示结果就会出问题。
跟virtual texture相比,virtual geometry的一个不同之处在于,每个cluster的顶点数目、属性尺寸等都是不同的,为了避免内存碎片化问题,我们希望每个geometry page的尺寸是固定的,那么在这里我们就需要在每个page中塞入不同数目的cluster。
这里的做法是以group作为基本单位塞入到page中,group在page中的存放会充分考虑数据在空间上的相邻关系,同时也会考虑group在DAG上的层级关系,其目标是为了保证在运行时需要同时加载的page数目是最小的。
为了保证任何时刻都是有可渲染的数据,这里会用第一个Page来保存DAG的根节点以及从根节点往下尽可能多的上层节点,这个Page会设置成常驻的,在具体实现上是会被存储在GPU上的一个大的ByteAddressBuffer中。
这里的一个问题是group包含的数据量较大,如果以这个作为基本单位填充page的话,会导致page中存在较多的空间浪费。
UE这边的做法是将group以cluster为单位拆分成多个部分,之后分别填入到不同的page中以填充page中未被使用的部分,而当需要加载这个group的时候,就会需要将这些cluster所对应的page全部加载进来,好处是page中的空间浪费变少了,但是坏处则是需要同时加载的page数目就增加了。
为了提升加载效率,分拆后的cluster会被放置在一系列连续的page上,因此在需要的时候就会将这一串物理上连续的page都加载进来。
那么我们怎么知道哪些page需要加载进来呢,对于virtual texture而言,这是通过对uv坐标的计算来知道的,而对于Nanite而言,则需要可对DAG层级结构进行遍历才能得知。
在遍历的过程中,我们会查找那些应该需要被绘制的节点,而这些节点并不会局限在streaming cut(以当前需要streaming(是需要streaming还是已经streaming进来的?)的节点为边界画一条线,这些节点是怎么确定的?是按照上一帧的需要)以下,而是从整个DAG层级中进行搜索,这就意味着culling hierarchy(DAG Graph?)需要包含当前没有streaming in的相关节点,不过由于这个hierarchy本身很小(只包含一些meta信息),因此完全可以全部加载进来,这也是UE的做法。
在hierarchy全部加载进来之后,我们就可以跳开当前已经加载的节点,进行全层级的遍历,因此一个新加载的物体可以很快找到需要加载的LOD与对应的Page,而不需要经过一帧帧的迭代才能找到最终需要的数据。
hierarchy数据本身也是可以streaming的,比如不同level具有不同的hierarchy数据,每次进行level的加载与卸载,同时会进行其对应的hierarchy的数据的streaming,这个过程会分散到多帧完成。
实际上steaming request是放在前面介绍过的hierarchical cluster culling遍历过程中的,在进行cluster/group的culling的时候会同步计算哪些cluster/group需要加载进来,这里会根据LOD Error来计算对应cluster/group的加载优先级,不但会考虑primary view的相关数据,同时还会将shadow view所需要的数据也一并考虑了。
需要注意的是,对于一些常驻的page而言,也同样需要进行这样一个更新过程,目的是为了修正其加载的优先级。
之后这个streaming request会被CPU回读,在CPU上会为这个request添加上一些依赖处理,并issue优先级最高的page加载请求,同时还会将低优先级的page卸载掉,以腾出空间。
最后GPU绘制所需要的数据都已经上传了,此时会更新GPU中的数据结构,比如更正loaded/unloaded的page的指针等。
下面来介绍下Nanite中的压缩技术。
Nanite使用的Mesh数据具有两种不同的格式,这两种格式对应的都是同一套资源,只不过分别对应于不同的应用场景。
对于渲染、光栅化、以及deferred material等场景使用的数据,由于需要支持随机读取与实时解码,因此存储的都是没有经过特别多处理的直接数据。
对于磁盘存储的用于streaming的数据,由于发生频率很低且不需要随机读取,因此可以使用一些比较先进的压缩方式,比如某种基于byte的LZ压缩算法实现的压缩机制,其目标是在压缩后能够使用尽可能小的磁盘空间来存储尽可能多的数据。
先来看下渲染所需要的直接数据的存储方式。
美术同学导入与编辑后的数据是全局格式的,但是在转换成cluster之后会按照cluster的min/max范围将之替换成一套局部格式,这个局部格式会根据具体的范围来调整数据存储的位数,也就是说不同的cluster的数据格式可能是不一样的。
cluster中的每个顶点使用的格式是相同的,即固定长度的bit串,也不需要考虑对齐,甚至不需要对齐到整个byte上,即数据长度可以是小数个byte,这是因为在cluster内部的数据尺寸依然是固定长度的(何解?),因此虽然需要一个相对紧凑的顶点声明来赋予数据bits以意义,但是在这种存储方式下,解码与随机访问上依然是十分方便的(为何?)。
前面说过,不同cluster的格式可能是不一样的,这样做的一个问题是两个相邻cluster的位置可能对不上,就会导致裂缝。
裂缝会出现在同一个物体内部,或者不同物体之间衔接的地方,比如对于一些由模块(砖块)组成的场景而言(因为支持极高数目的instancing,Nanite鼓励使用这种方式进行场景搭建以提升性能、缩减包体),就很常见。
由于Nanite Mesh构建的时候是拿不到相邻模型的信息的(即使能够也很复杂远不是目前的算法或者硬件能够处理的),因此无法指望在模型之间进行沟通以实现顺滑的衔接,最终只能考虑将两者通过同一套格式来进行描述。
这里的做法是设定一个固定的step size(比如1/16cm),之后根据用户设定的一个指数值对step size进行指数运算,得到的结果用作grid的最小单位,在对象空间完成对物件坐标系的标定,这里需要记住的是,由于我们的目的是为了解决物件之间的衔接接缝,因此不能将这里的step size根据物件的bound进行归一化。
在这种模式下,两个相邻物体就共用一套grid cell size了,因此在平移跟旋转的时候都能够很好的保持一致,也就不会存在接缝问题了,但是实际上这种做法只能保证叶子节点之间的衔接良好,对于更上层LOD的cluster而言,由于不同的group在边界上的处理方式不一致且运行时在相邻两边的LOD可能是不同步的都会导致接缝问题,不过实际测试发现这个问题也不是很明显,因为叶子节点是距离相机最近的,其他更高LOD由于距离相机较远,因此即使有一些接缝可能也不是很明显。
面片的index数据是经过=处理的,保证面片的第一个index在cluster vertex buffer中的顺序是最靠前的,而一个cluster的顶点数目是有一个范围的,因此通常使用7bits就能完成对这个第一个index的表达,之后剩下的两个顶点的index则存储相对于第一个index的偏移即可,而在Nanite中,是可以保证一个面片的三个顶点在buffer中的偏移不超过32 的,因此可以只使用5bits即可表达,加起来就是17个bits。
UV坐标由于经常会存在跳变(比如其分布范围可能是0 - 0.2, 0.7 - 0.9之类),因此这里的编码会考虑消除其中最大的那个gap,比如上限举例中就是将0.2 - 0.7这一段gap消除掉来缩小表达范围,从而实现对uv的压缩。
法线数据是直接通过octahedral坐标来表达的,tangent数据则直接通过uv的梯度进行计算,无需占用任何存储空间,这种方式在面片数较多的情况下质量表现较好,能够与Nanite实现很好的契合。
tangent数据不是通过对tangent frame进行插值并存储下来的,而是在运行时直接计算的。
其计算方法类似于此前有过的屏幕空间微分计算tangent space的方案,不过这里我们不需要计算屏幕空间的微分,而是直接使用我们之前为计算质心坐标而计算的面片delta数据以及在material pass中需要用到的LOD计算结果来给出。
这种方法可能在后续使用中会有一些问题,不过在目前Nanite面片数较多的情况下暂时没有发现太多不良表现,未来可能也会考虑支持将tangent直接存储下来的方案。
每个mesh可能会有多个材质,我们需要知道哪个triangle对应于哪个材质,如果直接存储每个triangle的材质index就消耗太高了,最开始的想法是将mesh按照材质进行cluster划分,确保每个cluster中的材质是完全相同的,但是后来发现这种做法对于mesh simplification(也就是LOD实现)会有太多限制,且在build阶段处理裂缝问题时会很复杂,经过分析发现,绝大部分cluster包含的材质数目都是小于3的。
在这些信息的驱使下,可以考虑将cluster中的triangle进行排序,属于同一个材质的triangle排在一起,那么只需要两个triangle index(指定材质分界点)以及三个material index就能够得到整个cluster上各个triangle的material index了。对于超出3个材质的cluster而言,会有一个indirection机制(索引转换)将数据指向一个变长的table,这个table可以包含最多64个材质。
不过在上述哪种情况下,最终都是可以通过相关机制计算出每个面片的material index的。
对于磁盘上存储的数据,因为硬件LZ解压目前在性能表现上是无与伦比的,因此这里会考虑在支持的平台上使用硬件LZ解压算法,目前主机是完全支持的,PC在未来也会支持。
Epic预计硬件解压后面会成为一项标准,因此这里的格式设计会尽可能的朝这个方向努力,不过相对于重新设计一套新的格式来支持压缩,这里的做法是将现有的数据进行改造转换成压缩器所支持的格式,在这个过程中也会考虑LZ压缩算法目前的一些局限性,争取实现更高标准的性能。
因为LZ已经提供了对string matching以及serial entropy coding(乱序编码)的原生支持,且由于压缩工作主要是transform,因此这里可以考虑将压缩工作移到GPU来完成,以实现并行加速。
而GPU的并行计算能力对算法效率的提升进一步为对算法的改进提供了空间(比如可以使用更为复杂的算法来实现更好的效果),而如果使用了async compute特性的话,性能还会有更进一步的提升(目前并没有看到这个必要,因为在完全没有优化过的代码上的执行效率已经可以在PS5上跑到50GB/s了)。
对于Nanite而言,在GPU上完成压缩编码还有其他的好处,比如说GPU编码器可以使用常驻的Page中的Parent数据,而不需要在CPU中存一份额外的数据。此外,后续也可以考虑跳过CPU阶段,直接完成数据从磁盘到GPU的加载。
这里介绍了如何对数据进行组织以适应LZ的框架,由于LZ是byte-based的,而之前说过,直接读取的数据是按照未对齐的bit stream的方式组织的,因此直接使用可能会不太高效,虽然将数据更换成按照byte对齐会增加解压后的数据尺寸,但是却可以大大降低压缩后的数据尺寸,除此之外还会有一些其他的好处,比如非对齐的数据可能会导致byte statistics(byte统计数据,是指byte数吗?)混乱等。
在实际使用的是,会考虑将同类型的数据放到一起,从而减少为每个数据添加的offset消耗。
在可能的时候,会选择尽可能小的byte数值(没太看懂),这样会更好的压缩byte statistics,从而提升乱序编码的效率。
下面来介绍一下编码的细节,由于cluster的数据结构以及Hierarchical Cluster的组织方式,数据是存在冗余的,冗余来源于两方面,分别是相邻cluster之间共享的edges以及父子cluster group在LOD变换时lock edge的那些顶点。
为了减少冗余,这边会考虑通过reference来存储数据(大概类似将顶点等数据存在一个table中,之后cluster以及group等都只存储index),但是一个问题是由于我们不是讲所有数据一次性加载到内存中,因此reference到的数据可能还没有加载。不过由于rendering的要求是任意时刻都要有一些可用的数据以保证基础的效果,当前cluster没有加载就使用parent的数据,因此不管什么时候都可以保证reference的数据有一个fallback版本。
通过reference的方式,经统计发现,可以减少约30%的消耗。
此外,由于parent数据实际上是child数据的简化版,后面或许可以考虑通过parent数据进行预测的方式来提升后续加载与解压的效率。
最后在磁盘存储上,这里也进行了不少优化,最终给出的是一个相对于之前17个bits存储一个三角形要紧凑得多的结构,整个结构为一串bitmask,每个mask包含了如上图所示的4个bit,分别代表不同的含义。
为了减少索引的存储消耗,这里会将cluster转换成strip的存储方式,因此每个strip中只有第一个面片需要存储完整的三个顶点的索引,后续每个面片只需要存储一个索引即可,这里需要添加一个bit用于表示strip中新增的面片的顶点绕行关系,避免被backface cull掉,这个bit就是IsLeft,通过增加这个bit可以通过添加一个mask(这个mask导致的新增面片会是不可见的,其作用在于调整最后两个顶点的数值(比如原来是v46-v47-v48,而v47-v48无法跟v49组成一个triangle
,可以考虑增加一个v47-v48-v46的面片来调整成v48-v46)实现strip的衔接与拉长)的方式来拉长strip避免新增一个strip导致的重新编码(需要完整的三个顶点的索引)的消耗。
此外,这里还对第一次用到的顶点进行排序,即按照索引中引用到的顶点出现的顺序进行排序,这样我们就可以省掉对这个顶点的索引的显式表达,只要统计一下当前已经出现过多少顶点就知道了,而对于那些第二次引用的顶点,其索引存储的是相对于当前已经出现过的最大的顶点索引的偏移值,而在编码的时候可以保证这个偏移值不会大于32,因此可以通过5个bits就能表达。
从上面的陈述来看,最终一个顶点只需要3个bits+5个bits的偏移总共是8个bits就能表达,相对于之前的17bits是一个很大的进步了。
下面来看下压缩数据,在“Lumen in the Land of Nanite”Demo中,原始的面片数为433M,而放到Nanite中就是两倍的大小。
而原始的数据格式存储的话,考虑positions, normals, 跟UVs按照浮点存储,而tangent走计算生成,大概需要26GB,而即使使用半精度,也需要7.7GB,而按照前面介绍的磁盘压缩格式进行存储的话,则需要4.6GB,压缩效果还是挺明显的,此外UE提到,未来或许还有进一步压缩的空间
目前Nanite已经处于可用状态,虽然相对于最开始设定的目标还有很大一块没有实现,但是目前只是第一个版本,后面还有很多的优化工作要做。
后续希望能够将Nanite用在任何地方,支持的效果与性能也会做进一步加强,约束做进一步降低,敬请期待。
参考文献
[1] 【Siggraph 2021】UE5 Nanite - A Deep Dive