Epic外放的两大特性Nanite跟Lumen,构成了UE版本升级的基石,关于这两大技术,已经有了众多的分享,不过这些分享在结构和内容上难以构成整个方案的全貌,因此尝试先通过现有文章整理出一个大致框架,后续有时间通过代码对其中存疑的地方进行补齐,以实现对整个方案的梳理与理解。
这篇文章主要是对Nanite的解析,关于Nanite我们需要解决如下的一些问题:
- Nanite是什么
- Nanite可以用来做什么
- Nanite需要解决哪些问题?
- Nanite背后的实现原理是什么?
- Nanite优点是什么?
- Nanite有哪些局限?
下面我们分别来对这些问题进行介绍,并尝试根据现有资料给出解答,因为查阅的资料有限,因此在完整性与正确性上或许会存在一些问题,这些问题后面会通过代码分析来修正与补齐,各位点进来的同学如果有理解上的问题或者其他疑惑也欢迎直接评论,一起讨论下。
1. Nanite是什么?
简单的来说,Nanite是一套GPU-Driven的sub-pixel尺寸面片高模场景的实时渲染方案,这里有几个关键字:
- GPU-Driven,整个流程,包括剔除、渲染、缺失Mesh Page梳理等都是在GPU上完成的
- sub-pixel尺寸面片高模场景,指的是场景是由高模组成,高到什么程度呢,可以支持每个面片在屏幕上的面积接近pixel的大小
- 实时渲染,整个渲染方案都是运行时完成的
2. Nanite可以用来做什么?
Nanite有哪些作用,或者说会带来哪些变化?
首先,在画质上的提升,有了Nanite,物件的细节将更为丰富,接近真实世界的物件细节品质。
其次,场景的渲染可以直接使用超高精度模型,不再需要如以前的流程一样,需要在美术工具中输出低模进行渲染,这个带来的改变在于会改变美术同学的制作流程:
- 不再需要烘焙低模
- 如果模型面数足够高的话,将不再需要法线贴图(粗糙度贴图也不再需要了),但是依然支持这些贴图的使用,因为如果使用一张分辨率足够高的贴图,在性价比上依然比纯粹的提高面数要高很多。
3. Nanite需要解决哪些问题?
Nanite的优点很明确,但是其需要解决的问题也很多,总的来说可以归结为如下两点:
- 海量数据问题
- 高额的渲染消耗问题
下面分别对这两个问题进行阐述。
3.1 海量数据问题
正如某篇参考文章所说,单个百万面模型,其顶点与索引数据就超过了140MB(位置、法线、uv、索引等),更何况场景中全是高模,因此这个数据量光是放到内存里都会是一个问题,更别提渲染了。
其他先不说,先来看下海量数据会导致哪些问题:
- 海量数据存储空间消耗问题,包括磁盘存储与内存消耗
- 数据读取与写入时的IO消耗问题
- 数据从CPU到GPU传递时的带宽消耗问题
关于这些问题,这里并没有一一去验证具体采取了哪些策略,而是将参考文章中跟这些问题相关的一些方案汇总到一起,这种方式当然太过粗糙,不过目前资料有限,暂时就先这么做,后面对代码进行分析之后再回过头来对这些问题的解决方案进行补充。
这里要提的第一点就是数据压缩,数据量过大,那好嘛,压缩一下,很合理,主要有如下几种压缩:
- 顶点属性压缩,首先多个面片之间的顶点是有共享的,因此这里会先进行一次去重,大概可以优化15%左右;之后在此基础上进行一次有损压缩,压缩策略为对cluster中顶点数据的min/max进行计算,根据这个范围调整浮点数编码方式,使用类似于protobuf中整数编码的variant bit stream策略(小数据使用较少位数,大数据使用更多位数,通过在每个bit前面添加一个1表示这是某个数据的一个组成部分,往前找到起始bit为0的,就是这个数据的起始位置)
- 索引数据压缩,通过将cluster中的triangles连成strip来减少索引,此外索引也可以通过一些映射策略实现局部索引到全局索引之间的转换,而局部索引存储可以使用更低位数来表示。
下图给出了古代战场Demo的所有资源经过压缩后的数据尺寸,虽然很大,不过跟原始数据相比(140MB -> 13.8MB),已经算是十分平易近人了,且其数据尺寸比一张4k的法线贴图还小,说明使用Nanite在细节保留上可以得到比法线贴图更高的性价比:
第二点是针对传输带宽优化的,主要是通过visibility buffer等方式来避免GBuffer构建的Base Pass时的高额数据传输,具体细节后面会介绍。
3.2 高额的渲染消耗问题
渲染消耗又有如下几点需要分别进行阐述的:
- CPU处理消耗,因为整个计算过程基本上都是放在GPU完成,因此CPU处的消耗就没有那么严重了
- GPU-GPU通信消耗,针对这一点,主要有两个做法:
第一,针对CPU指令提交慢,这里是使用多核CPU向GPU进行并行Command提交,新一代图形API普遍都是采用这种做法,可以使得DP数提升一个数量级;
第二,针对CPU/GPU通信消耗高,因为整个流程是GPU-Driven的,因此实际上CPU-GPU之间的通信数据量已经大大降低了,不再是一个问题 - VS计算消耗,这一点是通过对Cluster的LOD策略与Culling策略解决的,先将一些不可见的Cluster剔除掉,因为剔除粒度下降到Cluster级别,因此相对于传统做法,真正需要进入到VS处理的数据已经非常少了;其次,根据Cluster距离相机的远近,实现Cluster级别的LOD,使得远处的网格密度并没有想象的那么高,传入VS的顶点数自然也就没有那么夸张了。
- 面片光栅化消耗,首先是光栅化所需要的带宽消耗,这里前面已经说过,通过数据压缩来降低;其次是光栅化数据输出时的带宽消耗,这里前面也说过,通过Visibility Buffer方式极大的减少了需要输出的数据,从而得到了有效的控制与降低;第三,跟VS那边的理由一样,因为实际进入VS的数据并不是十分夸张,因此最终进入到光栅化阶段的数据也就不会特别大,所以性能上还好,比较可控;最后是小面片(sub-pixel级别)的处理问题,由于现代GPU的硬件设计对于小面片是不友好的,需要兼顾ddx/ddy等用于mipmap计算的指令,通常是会以2x2的pixel quad作为最小的处理单元,因此小面片会导致比较多的额外消耗(没有覆盖的pixel可能因为quad的原因需要进行pixel shading等处理),Nanite的大量小面片是通过软光栅的方式实现的(有个外国兄弟截帧分析了古代战场,其中90%的面片是软光栅完成的),而实测也发现,软光栅在小面片上的平均性能是硬光栅的3倍(使用的是PS5的primitive shader执行路径,如果跟传统的Vertex Shader执行路径相比的话,这个数值还会更大),因此可以有效解决这个问题。
- Overdraw消耗,移动管线不用说,目前Nanite也没有支持,我们这里就先关注延迟管线,在延迟管线的Base Pass中,随着对画面品质的需求一再提升,GBuffer的格式的数据尺寸也日益上升,因此虽然通过Base Pass的处理可以有效控制Shading阶段的Lighting计算Overdraw,但是却无法组织Base阶段数据写入GBuffer时的Overdraw,而这个阶段的Overdraw的影响主要在于会加重带宽负担。
针对这个问题,前人给出的策略是使用一种叫做visibility buffer的数据结构,这个数据结构存储的数据内容要远远少于GBuffer,下面给出了一个通用的Visibility Buffer的大致格式(Nanite使用的格式跟这个不同):
- InstanceID,物件ID
- PrimitiveID,面片ID
- Barycentric Coordinates,像素的质心坐标,后面在PS阶段根据这个坐标对顶点属性进行插值,这也是为什么Visibility Buffer可以使用更小尺寸的最关键原因
- Depth Buffer
- MaterialID,根据这个ID从Material Map中获取材质,完成后续Shading
除了这些Screen Resolution的Buffer数据之外,Visibility Buffer方案还需要存储两个全局的数据结构:
- 全局的Vertex Buffer,由于PS还需要根据质心坐标从VB中恢复出对应像素位置的顶点属性数据,因此这个全局VB是必要的
- 全局的Material Map数据,为了在PS中完成渲染,就需要知道其使用的材质,前面存储了材质ID,这里就是根据ID从Material Map中找到对应的材质信息(如材质参数、贴图索引等)
不过这里需要注意的是,由于UE的材质系统过于复杂,存在较多的Shading Mode,因此上述方案并不能直接使用,不过其基本思想是一致的。
传统管线中,UE的材质是与Instance绑定的,根据Instance决定需要对哪些材质shader进行编译,而UE5中的Nanite是使用Visibility Buffer方案的,Instance的粒度太粗,可能在处理的过程中就被Cull掉了,最终渲染的shader直接使用instance来获得可能会存在较多浪费,因此这边的做法是根据screen space中的material id来实现需要编译的shader的采集工作,实现面片的visibility与material之间的解耦(即只有当面片真正可见的时候而不是在CPU阶段判断过mesh instance的visibility之后,才会进行材质相关的计算处理),这样可以节省一部分shader编译的工作(Deferred Material名字很合理),此外还可以减少光栅化过程中的shader切换(renderstates切换代价很高),同时尽量降低Shading阶段的overdraw(也不再需要传统管线中的early-z pass)。
下面对上面罗列的一些比较复杂的方案进行阐述。
关于材质这一块,Nanite的处理逻辑比较复杂,根据屏幕空间中的材质数目,存在几种不同的处理策略,这里先不讲,在后面Emit Target部分会有详细介绍。
4. Nanite背后的实现原理是什么?
终于进入到正餐了,这是整篇文章的核心,也是Nanite的技术要点所在,下面我们一起来看下其中的相关细节。
目前Nanite只用在延迟管线中,而延迟管线中跟海量面片相冲的环节主要在于Base Pass GBuffer的生成上面,如前面所说,为了减少GBuffer生成过程中的带宽消耗,UE5在延迟管线的Base Pass阶段增加了一个处理,就是先生成Visibility Buffer,之后再通过一个Screen Space Pass将之转换成GBuffer,至于GBuffer之后的Lighting处理就跟此前的延迟管线保持一致了。
Nanite的渲染总的来说可以分成两部分,即预处理部分与运行时部分,前者针对的是Nanite Mesh的Build过程,后者针对的是Nanite Mesh的渲染流程。
4.1 Nanite Mesh Preprocess
Nanite Mesh的Build是在离线完成的,每当模型发生更新,或者首次开启Nanite选项时,就会触发Build。
这个过程大致可以分成如下几步:
4.1.1 Cluster切分
这一步完成的是对Mesh进行Cluster切分(使用LOD0,目前也就只有LOD0的数据了),为了提高缓存命中率,通常会将几何位置相邻的面片放置在一个Cluster中,这个过程是通过Metis库完成的。
拆分后,每个Cluster包含128个triangle,这里128的确定是跟Vertex Process时的Memory Cache尺寸有关。
Cluster拆分完成后,会根据情况将Mesh Cluster合并成多个Group,每个Group包含8~32个Cluster,这个过程此后每级LOD生成时都需要进行一遍(这里需要注意,每个Group中的Cluster的LOD是完全一样的,且由于Building是针对Mesh Instance进行的,因此每个Group中包含的Cluster都是同一个Mesh的,不存在多个不同Mesh的Cluster组成一个Group的情况)。
4.1.2 Mesh Simplification
为了生成更高LOD的数据,需要对现有的Mesh进行简化,这个简化是在Cluster Group中进行的,比如会对LOD0的每个Group进行Mesh简化,从而得到顶点密度相对较低的临时的LOD1 Cluster Group,在这个过程中会对部分临时的Group进行合并,得到一个正式的LOD1的Group,下面给出了这个过程的示意图:
这个过程是通过QEM算法完成,QEM是Quadric Error Matrics的缩写,这是很早的一篇Siggraph给出的模型减面算法,这个算法是通过对某条边进行坍缩实现减面的,如下图所示:
QEM算法
下面给出这个算法的简介:
-
模型中的每个面片所在的plane都可以通过四个参数表示
-
使用这四个参数可以构建一个Matrix,这个Matrix叫做Quadric Matrix
-
这个Matrix可以用于计算任意点V到这个平面的平方距离:
-
着了降低模型减面误差,这里定义一个代价函数(Cost Function),如下式所示,这个代价函数是点V到V所在边E所连接的所有面的距离平方和,这个值越小,说明将边E收缩到这个点时,原有面片移动的距离最小,也就是新模型相对于原模型的变化越小。
-
为了降低减面后的模型误差,需要从模型中的多条边中选取误差最小的一条,而每条边坍缩的模型误差则是以这条边上代价函数最小的点作为收缩点,每次收缩都按照这个流程进行,最终就能完成Mesh的优化。
在QEM使用的过程中,会记录每个顶点的Error,之后将整个Cluster Group(还是Cluster?具体记不清楚了)上顶点的Error加起来除以顶点数作为Cluster Group的Error,每个Cluster Group都会记录Self Error与Max Parent Error,之后这个数值会在后续进行Page Request的时候,会对这个Error按照到相机的距离进行调制,得到一个Tunned Error,通过比对自身的Tunned Error,Max Parent Tunned Error与设定阈值的比较,来决定是要加载哪一级LOD的Group。
这里有一个问题就是,如何确保两级LOD之间的Cluster Group之间衔接位置不会出现裂缝,具体UE这边的做法还没了解,不过参考文献给的一个方法是,加大Cluster Group外轮廓上各条边的Error,从而确保坍缩是在Cluster Group内部完成,避免边缘的不吻合(这就是UE4中就已经上线过的Lock Edge算法)。
各级LOD、Cluster、Cluster Group之间的关系,如下图所示:
4.1.3 Building BVH
为了提升剔除效率,需要使用层次化的结构来对Cluster进行组织。
对上面的Mesh Simplification过程循环往复,就构成了由不同LOD级别的Group组成的Bounding Volume Hierarchy,简称BVH,BVH中的每个节点对应一个Cluster Grou,通过这种层级关系,可以方便后面剔除的时候进行加速。
BVH算法使用的是轴向树BVH,轴向树跟kd-tree很像,不同的在于,每次拆分不是均分为两个child,其child的数目可以在2~8之间进行选择,下面给出2d kd-tree的划分示意图作为参考:
那么BVH是怎么构建的呢?目前看过的几篇参考资料没有提到这个问题,不过从后面BVH Culling提到Culling是按照Group作为单位判断,BVH是构建在Cluster Group的基础上,即BVH的每个节点是对应于一个Cluster Group,不同层级的节点对应于不同LOD等级的Cluster Group。
BVH用于进行Culling,所以不需要完整的Mesh数据,只需要存储Bounding Volume的相关信息与其引用的Cluster Group的指针即可。
4.1.4 Mesh 数据压缩
为了减少存储与传输消耗,这里需要对数据进行压缩存储。这里的一个策略是为不同的cluster指定不同的存储精度,比如:
- 为高分辨率(面数密集)的Cluster指定高精度浮点数
- 为低分辨率的Cluster指定低精度浮点数
- 不同分辨率Cluster之间的衔接可以通过高精度向低精度靠拢来解决。
4.1.5 Page编码
Page编码这块目前并没有了解到全貌,只有一些支离破碎的信息,这里先记录一下,后续有更全面的了解了再来补充:
- 每个Page尺寸为128kb
- Page存储的最小单位应该跟Culling & Streaming一致,即以Group作为粒度
- 空间相邻,且处于同一个LOD的Group会被放入到同一个Page中,为了保证Page中数据的连续性,这里会使用Moton 3D曲线对Cluster Group进行排序
- 数据按照SOA(Structure of Array)组织,可实现更为高效的缓存读取
- Page数据存储,可以选用LZ4压缩
- Page与Cluster Group之间的关联关系是在离线的时候构建与分配的
4.2 Nanite Mesh Cluster Render
下面进入Nanite的渲染细节,同样,其中有些地方的了解还不够全面,因此一些位置给出的信息还不够构建方案全貌,后面会通过代码分析进行补齐。
4.2.1 加载流程
Nanite的数据加载是专为Nanite Mesh Cluster设计的Nanite Streaming,Nanite的Streaming是按照Page为单位组织的(GPU中的Page Request是按照Cluster Group为粒度进行的,即数据从磁盘加载到内存,是按照Page进行的,而数据从CPU进入到GPU,则是按照Cluster Group为粒度,这两个过程应该都是异步完成的)
4.2.2 Nanite Shadow
Nanite的Shadow用的是一个叫做Virtual Shadow Map的方案,每盏光源对应的Shadow Map的分辨率很高(16k),其绘制方式跟Nanite进行Framebuffer绘制的方式一样,相当于用相机替代光源进行绘制,但是只取Depth数据,这里应该还会有一些额外的处理逻辑:
- 将不同的物件绘制到不同的mip层级上,目的是使得屏幕空间一个pixel在shadow map上正好对应一个texel,这个应该是使用前面所说的LOD策略,将距离相机(不是光源)更远的物件绘制到一个较低分辨率的Map上(此时绘制Shadow的Cluster理应使用一个更低的LOD,那么绘制Shadow的Cluster的级别就单纯由到相机的距离决定吗?)
- 只绘制屏幕空间中可见的一些shadow,
- 依然使用了Shadow Cache技术避免每帧重新绘制,不过这里面还有一些细节目前不是很清楚,具体方案后面再补齐。
使用高分辨率的Shadow Map可以得到以前只能通过raytracing得到的硬阴影
同时通过对shadow map进行ray marching,还能得到一个物理真实的软阴影:
此外由于阴影贴图分辨率很高,因此不再需要跟之前一样为了消除阴影的锯齿跟peterpan现象而对bias参数进行频繁的纠结的手工调整。
4.2.3 渲染流程
由于GBuffer计算完成之后的流程就跟之前UE4的延迟管线一样了,这里主要介绍UE5的延迟管线的Base Pass部分。
下图给出了UE5延迟管线Base Pass流程的简单示意图:
UE5的延迟管线支持Nanite Mesh与非Nanite Mesh同时存在,非Nanite Mesh,可以直接按照传统GBuffer绘制流程完成Base Pass。
对于传统模型渲染方案而言,走的是Vertex Shade+硬件光栅化+Pixel直接写入的方式,下面主要看下Nanite Mesh的处理逻辑,如上图所示:Compute Shader剔除完成后,可见的面片根据其屏幕空间的尺寸会进入两个不同的处理路径,其中大尺寸的面片会通过一个DrawInstancedIndirect调用进入VS+硬光栅+PS的流程写入Visibility Buffer,而小尺寸的面片则是直接采用一个Compute Shader使用软光栅的方式将数据写入Visibility Buffer,之所以小尺寸面片要走软光栅,是由于为了计算贴图的mipmap,通常我们需要使用ddx/ddy求取uv的梯度,因此硬件光栅化组件的实现过程通常是以2x2个像素作为光栅化的最小单元,当面片的尺寸接近单个像素的时候,这种处理方式就会导致至少3个像素的计算浪费,因此小面片对于硬件光栅化来说是十分不友好的。
下面对上述过程的细节进行展开介绍。
首先是数据准备,即数据上传到GPU等,GPU-Driven管线会在GPU上维持一份场景数据,这份数据不会每帧上传,只是会将需要更新的数据进行上传:
下面进入剔除阶段,这个阶段是在Compute Shader中完成的,为什么不用软光栅呢,因为这里使用的是GPU-Driven管线,直接放在GPU会更好,当然后面会说,在GPU中也可以用软光栅,这里也确实用了。
整个剔除流程如下图所示:
分成两个Pass完成,除了Instance Culling之外,两个Pass的流程完全一致,不同的是前者剔除时使用的HZB是用上一帧的可见Cluster构建的,剔除完成后,会将不可见的数据写入到OutOccludedNodesAndClusters结构中,而后者使用的HZB则是使用Main Pass后可见的Cluster构建的(这一步是可选的),并只对之前OutOccludedNodesAndClusters中的数据进行遮挡复核。
4.2.3.1 Instance Culling
这里是针对整个Mesh进行的Culling,主要使用Frustum Culling与HZB Culling(使用上一帧的可见Cluster构建的HZB)
经过剔除后,这一步会输出VisibleInstanceBuffer,根据这个Buffer可以拿到可见Instance的BVH的根节点
4.2.3.2 Hierarchical/Persistent Culling
这一步主要是根据之前构建的BVH实现Cluster Group级别的剔除,跟Instance Culling一样,这里同样需要进行Frustum & HZB两种Culling。
Cluster Group的LOD选择逻辑也是在这个阶段完成的,主要借助前面说过的Group中存储的Self Error与Max Parent Error两个参数,使用Group到相机的距离对这二者进行调制,并与给定的Error Threshold进行比对完成。
这个阶段Culling完成后,会输出一个StreamingPagesBuffer,用于指引后续需要加载哪些Cluster Groups数据。
LOD选择与后续的Page Streaming逻辑,我们将之总称为Nanite Page Readback逻辑,其具体流程给出如下:
- 回读在 PersistentCull pass 中产生的 Cluster Page Request 数据,用于指导下一帧的Cluster Streaming
- Culling以Group为粒度,Streaming也是同样以Group为粒度
- 由于Streaming的数据分布比较随机,为了提升缓存效率,将相邻Group放到同一个Page中,为了效率,可能会将一个Group分割到多个Page中(最小分割单位为Cluster),那么如果需要加载某个Group时,需要进行多个Page的加载(当然,如果只需要加载Group的某个部分分,而这部分正好处于同一个Page中,那么就只需要加载一个Page即可)
- CPU异步请求Page加载,加载完成后上传到GPU的Page,GPU收到数据后,对相应的数据进行修复。
4.2.3.3 Cluster Culling
Cluster级别的Culling跟Cluster Group级别的BVH Culling逻辑基本上是一样的(一说有增加Screen Size Culling,Backface Culling则是放到后面光栅化阶段处理的),Frustum Culling & HZB Culling流程都完全相同,不过需要增加额外的功能,比如对Cluster中的Triangle进行标记(这个标记是针对整个cluster而非单个triangle的,因为后续的光栅化执行只针对cluster进行的,总不能只绘制一个面片传递给光栅化吧?这样的话,数据组织会存在很大问题),判断面片是Large Triangle还是Small Triangle等。
这个阶段会输出VisibleClusterBuffer,里面存储的是光栅化阶段所需要的Cluster Buffer与Indirect参数。
4.2.3.4 Node/Cluster Culling线程模型
BVH & Cluster Culling都是放在Compute Shader中实现的,因此如何进行线程分配,达到最大程度的GPU利用就成为一个问题。
UE这边的做法是将每个处理流程当成一次线程调用看待,通过一个FIFO队列,新增节点或者Cluster塞入队尾,处理完成后节点或Cluster从队首移除,这样各个线程的处理时长基本一致,也就不会存在较大等待导致的浪费。
这其实是一个Multi-Producer Multi-Consumer的生产消费者模型,使用这个模型可以很好的实现线程级别的负载均衡,从测试效果来看,基本上可以保证GPU的满载运行。
由于Group Culing与Cluster Culling流程基本一致,因此在最新版的代码中,为了提升GPU利用率,直接将两者的处理流程合并到一起了。
此外,如果支持Async Compute Shader,软硬光栅化可以同步执行,还可以进一步优化这个阶段的时间消耗。
剔除完成后,可以直接通过Execute Indirect拉起后续的GPU处理流程。
4.2.3.5 Rasterization
上一步完成了Triangle尺寸的标注,主要是为了在这一步进行光栅化方案的分流,前面说过硬光栅在小面片处理上会有较大程度的性能浪费,因此这里将这一部分面片的光栅化直接放在Compute Shader中完成。而大面片处理依然交给硬光栅来完成,直接拉起一个传统的Vertex Shader的执行流程(不过不同于非Nanite Mesh直接写入GBuffer,这里的结果也是写入到Visibility Buffer)即可。
软光栅采用扫描线算法,每个cluster拉起一个Compute Shader,具体流程给出如下:
- 计算并缓存所有Clip Space Vertex Positon到shared memory。为了保证整个软光栅化逻辑的简洁高效,目前不支持带有骨骼动画、材质中包含顶点变换或者Mask的模型。
- 每个线程读取对应三角形的Index Buffer和变换后的Vertex Position(这个变换在前面进行Culling的时候就顺带完成了?)
- 根据Vertex Position计算出三角形的边,执行背面剔除和小三角形(小于一个像素)剔除
- 利用原子操作完成Z-Test,并将数据写进Visibility Buffer
UE5中的Visibility Buffer主要包含三个数据,其格式为R32G32_UINT,内存布局如下图所示(据说软光栅的Visibility Buffer格式与硬光栅的Visibility Buffer格式布局不一样,具体情况暂时不明):
A. 0~6 bit存储Triangle ID
B. 7~31 bit存储Cluster ID
C. G通道存储32 bit深度
- Emit Target,这是光栅化处理完成后的一个重要的处理步骤,其目的是生成后续GBuffer绘制所需要的其他数据。
这里主要分成两个阶段,分别是非Nanite Mesh Base Pass绘制之前的Emit Depth Targets阶段以及非Nanite Base Pass之后的Emit GBuffer阶段
Emit Depth Targets
Nanite Mesh除了Visibility Buffer之外,还有一些额外的Shading所需要的数据,为了与硬光栅化数据混合,软光栅这边需要通过几个全屏Pass将Visibility Buffer之外的一些信息写入到统一的Depth/Stencil Buffer以及Motion Vector Buffer中。
根据最终需要,软光栅还要输出最多四个buffer(Depth跟Stencil如果合并到一起,就是三个)
A. Scene Depth/Stencil
B. Velocity Buffer
C. Nanite Mask,用于标注是Nanite Mesh像素还是普通物件像素。对于Nanite Mesh Pixel:1. 会将Visibility Buffer中的Depth由UINT转为float写入Scene Depth Buffer;2. 根据Nanite Mesh是否接受贴花,将贴花对应的Stencil Value写入Scene Stencil Buffer;3. 根据上一帧位置计算当前像素的Motion Vector写入Velocity Buffer。非Nanite Mesh Pixel直接跳过相应处理。这个数据是存储于后面MaterialID所在的DS Target中的Stencil部分中的。
D. MaterialID Buffer,通过存储MaterialID的方式延迟了贴图的读取等逻辑,降低不必要的计算消耗,这种处理方法通常被称之为Deferred Material。
关于MaterialID,前面就说过,这里的处理逻辑比较复杂,有好几种情况(大概是4种),如果场景中的材质种类较少,那么每个像素完全可以只存储其对应的材质的ID,之后每种材质进行一遍全屏的后处理(为什么要全屏pass,因为shader的绑定是以一个物件的绘制实现的,为了避免部分像素shading遗漏,自然要进行全屏pass,通过后面说的Depth Compare筛选出那些MaterialID匹配的像素),通过depth test(MaterialID存储在Depth中,绘制的时候将当前材质的ID也转换为深度,只需要两者相等,就进行后续的PS处理)即可完成只对对应的像素进行shading,但是如果场景中材质数目非常庞大,不能做到每个材质都进行一遍全屏pass,就会选择第三种处理策略:Buffer的数据格式不是UINT,而是将UINT类型的Material ID转为float存储在一张格式为D32S8的Depth/Stencil Target上的Depth分量中,理论上最多支持2^32种材质,但实际上只有14个bits用于存储MaterialID(MaterialID是存储在D32S8中Depth部分的14个bits上,会提前进行排序(具体逻辑不明),之后按照Block统计出最小与最大depth值,并将之存储在一张R32G32_UINT贴图中)。
这里要对MaterialID的存储和使用方式做一下简要介绍。如上图所示,为了避免Material应用到屏幕不必要的像素,这里进行了一系列的处理:
A. 将屏幕分割成64x64的block,统计每个Block中的材质的Min & Max值(这个过程是在Emit Target之后,通过一个全屏的Compute Shader完成每个Block中的MaterialID统计),得到一张Material Range贴图,如下图所示
#if MATERIAL_CULLING_METHOD == 3
InterlockedMin(TileMinMax.x, MaterialDepthId);
InterlockedMax(TileMinMax.y, MaterialDepthId);
B. 每个block构建一个四个顶点组成的quad,所有block对应的quad组成一个grid(相邻quad之间不能共用顶点了),对每个材质进行一遍全屏的grid绘制
C. 在vs中根据当前block的material range决定是否需要进行后续的ps处理(不满足的Material,Vertex直接变换到NaN从而直接借助硬件的剔除跳过后续的处理逻辑)
D. 在ps中开启depth test与stencil test。depth test规则设置成equal,只保留当前材质的的pixel进行shading;stencil test用于确定是否是Nanite Mesh。
由于同一个block中的材质种类不会很多,因此通过这种方式可以有效控制Shading阶段的Overdraw
大面片这边走硬光栅方案,通过 Vertex Shader 或 Primitive Shader 完成光栅化(如果硬件支持优先启用 Primitive Shader,如 PS5,Epic 测试发现 Primitive Shader 性能要高于 Vertex Shader)。
总结一下,如上图前半部分所示:
通过Emit Scene Depth,实现Visibility Buffer中Depth到Scene Depth的填入
通过Emit Scene Stencil,完成Decal Stencil到Scene Stencil的填入;
通过Emit Velocity,在Pixel Shader中完成当前像素Velocity的计算与填入;
通过Emit Material Depth完成当前像素Material ID的获取与填入;
经过这些Emit逻辑,我们就拿到了Gbuffer生成所需要的必须数据了,之后通过一个Emit Gbuffer数据,如左下图所示,就可以根据上述Buffer数据生成出对应像素位置的Gbuffer数据(如Albedo、Normal、Roughness、Metal等参数),这就是我们前面所说的Deferred Material逻辑,避免过早的进行材质数据的获取,从而降低贴图传输所需要的带宽消耗,避免了Overdraw的过大影响。
上面图中将Emit Depth Targets跟Emit Gbuffer逻辑放在了一起,实际上Emit Gbuffer是在非Nanite Mesh绘制完成之后才开始,这是因为非Nanite Mesh可能会将一些Nanite Mesh的像素遮住,而这些像素因为不可见,所以不需要进行Gbuffer的Emit了,通过这种方式可以进一步节省Emit Gbuffer的时间消耗。
4.2.3.6 Shading(Emit GBuffer)
shading输出的是延迟管线所需要的GBuffer数据,通过Visibility Buffer与Deferred Material顺利的降低了大尺寸GBuffer绘制的带宽的消耗。下图给出了GBuffer的具体结构:
具体而言,有如下几步:
- Shading所需要的VS信息(UV,Normal,Vertex Color等)通过像素的Cluster ID和Triangle ID索引到相应的Vertex Position
- 将Vertex Position变换到Clip Space
- 根据Clip Space Vertex Position和当前像素的深度值求出当前像素的重心坐标以及Clip Space Position的梯度(DDX/DDY)
- 将重心坐标和梯度代入各类Vertex Attributes中插值即可得到所有的Vertex Attributes及其梯度(梯度可用于计算采样的Mip Map层级)。
- 根据前面的Material Range贴图,为每种材质启动一次全屏Grid绘制,在VS阶段完成Block的剔除,在PS阶段则通过Depth Compare只针对对应的Pixel进行Shading处理,从而输出GBuffer所需要的Albedo等数据。
5. Nanite优点是什么?
通过上面的介绍,其优点应该非常明晰了:
- 美术工作流无影响(无感知),也无需再为了性能进行大量的手动调整
- 效果更优,可以实现Cluster级别的LOD,达到电影级别的品质
6. Nanite有哪些局限?
关于Nanite目前的局限,主要有如下几点:
- 不适合简单模型,很多复杂的逻辑可能会构成负优化
- 目前不支持同一母材质不同材质示例之间的合并渲染(shading阶段)
- 目前不支持半透透明物件(alpha blend类型的,alpha test后续可能会支持)的渲染
- 目前只支持刚体模型,不支持蒙皮等会导致模型形变的模型(也不支持world position offset,为啥?是因为VB是全局Buffer,无法确定哪些Cluster的Offset是多少吧,不过这个feature后面会支持),不过刚体模型三个方向非等值拉伸是没问题的
- 当某个cluster的某个面片距离最上层surface非常近的话,可能无法被culled掉,从而导致overdraw增加,这种情况对于远距离的物件来说比较常见。
- 目前不支持对mesh进行曲面细分以及displacement
- 在植被、树叶、头发等上面的处理上还存在一些问题(性能问题,无法做到普通物件那么高的性能;双面渲染材质问题)
参考文献
[1] UE5 Nanite实现浅析
[2] UE5渲染技术简介:Nanite篇
[3] 游戏引擎随笔 0x20:UE5 Nanite 源码解析之渲染篇:BVH 与 Cluster 的 Culling
[4] GDOC Nanite解析 by Epic中国技术总监王祢
[5] A Macro View of Nanite
[6] Nanite | Inside Unreal
网友评论