一、前言

在今年初Epic放出了UE5技术演示Demo之后,对于UE5的探讨就始终未曾进行,相干技术探讨次要围绕两个新的feature:全局照明技术Lumen和极高模型细节技术Nanite,曾经有一些文章[1][2]比拟具体地介绍了Nanite技术。本文次要从UE5的RenderDoc剖析和源码登程,联合一些已有的技术材料,旨在可能提供对Nanite直观和总览式的了解,并理清其算法原理和设计思维,不会波及过多源码级别的实现细节。

二、次世代模型渲染,咱们须要什么?

要剖析Nanite的技术要点,首先要从技术需要的角度登程。近十年来,3A类游戏的倒退都逐步趋向于两个要点:互动式电影叙事和凋谢大世界。为了真切的电影感cutscene,角色模型须要纤毫毕现;为了足够灵便丰盛的凋谢世界,地图尺寸和物件数量呈指数级增长,这两者都大幅度晋升了场景精密度和复杂度的要求:场景物件数量既要多,每个模型又要足够精密

简单场景绘制的瓶颈通常有两个:

  1. 每次Draw Call带来的CPU端验证及CPU-GPU之间的通信开销;
  2. 因为剔除不够准确导致的Overdraw和由此带来的GPU计算资源的节约;
    近年来渲染技术优化往往也都是围绕这两个难题,并造成了一些业内的技术共识。

针对CPU端验证、状态切换带来的开销,咱们有了新一代的图形API(Vulkan、DX12和Metal),旨在让驱动在CPU端做更少的验证工作;将不同工作通过不同的Queue派发给GPU(Compute/Graphics/DMA Queue);要求开发者自行处理CPU和GPU之间的同步;充分利用多核CPU的劣势多线程向GPU提交命令。得益于这些优化,新一代图形API的Draw Call数量相较于上一代图形API(DX11、OpenGL)进步了一个数量级[3]。

另一个优化方向是缩小CPU和GPU之间的数据通讯,以及更加准确地剔除对最终画面没有奉献的三角形。基于这个思路,诞生了GPU Driven Pipeline。对于GPU Driven Pipeline以及剔除的更多内容,能够读一读笔者的这篇文章[4]。

得益于GPU Driven Pipeline在游戏中越来越宽泛的利用,把模型的顶点数据进一步切分为更细粒度的Cluster(或者叫做Meshlet),让每个Cluster的粒度可能更好地适应Vertex Processing阶段的Cache大小,并以Cluster为单位进行各类剔除(Frustum Culling、Occulsion Culling和Backface Culling)曾经逐步成为了简单场景优化的最佳实际,GPU厂商也逐步认可了这一新的顶点解决流程。

但传统的GPU Driven Pipeline依赖Compute Shader剔除,剔除后的数据须要存储在GPU Buffer内,经由Execute Indirect这类API,把剔除后的Vertex/Index Buffer从新喂给GPU的Graphics Pipeline,无形中减少了一读一写的开销。此外顶点数据也会被反复读取(Compute Shader在剔除前读取以及Graphics Pipeline在绘制时通过Vertex Attribute Fetch读取)。

基于以上的起因,为了进一步提高顶点解决的灵便度,NVidia最先引入了Mesh Shader[5]的概念,心愿可能逐渐去掉传统顶点解决阶段的一些固定单元(VAF,PD一类的硬件单元),并把这些事交由开发者通过可编程管线(Task Shader/Mesh Shader)解决。



Cluster示意图

传统的GPU Driven Pipeline,剔除依赖CS,剔除的数据通过VRAM向顶点解决管线传递

基于Mesh Shader的Pipeline,Cluster剔除成为了顶点解决阶段的一部分,缩小没必要的Vertex Buffer Load/Store

三、这些就够了吗?

至此,模型数、三角形顶点数和面数的问题曾经失去了极大的优化改善。但高精度的模型、像素级别的小三角形给渲染管线带来了新的压力:光栅化重绘(Overdraw)的压力。

软光栅化是否有机会战胜硬光栅化?

要弄清楚这个问题,首先须要了解硬件光栅化到底做了什么,以及它构想的个别利用场景是什么样的,举荐感兴趣的读者读一读这篇文章[6]。简略来说:传统光栅化硬件设计之初,构想的输出三角形大小是远大于一个像素的。基于这样的构想,硬件光栅化的过程通常是档次式的。

以N卡的光栅器为例,一个三角形通常会经验两个阶段的光栅化:Coarse RasterFine Raster,前者以一个三角形作为输出,以8x8像素为一个块,将三角形光栅化为若干块(你也能够了解成在尺寸为原始FrameBuffer 1/8*1/8大小的FrameBuffer上做了一次粗光栅化)。

在这个阶段,借由低分辨率的Z-Buffer,被遮挡的块会被整个剔除,N卡上称之为Z Cull;在Coarse Raster之后,通过Z Cull的块会被送到下一阶段做Fine Raster,最终生成用于着色计算的像素。在Fine Raster阶段,有咱们相熟的Early Z。因为Mip-Map采样的计算须要,咱们必须晓得每个像素相邻像素的信息,并利用采样UV的差分作为Mip-Map采样层级的计算根据。为此,Fine Raster最终输入的并不是一个个像素,而是2x2的小像素块(Pixel Quad)

对于靠近像素大小的三角形来说,硬件光栅化的节约就很显著了。首先,Coarse Raster阶段简直是无用的,因为这些三角形通常都是小于8x8的,对于那些狭长的三角形,这种状况更蹩脚,因为一个三角形往往横跨多个块,而Coarse Raster岂但无奈剔除这些块,还会减少额定的计算累赘;另外,对于大三角形来说,基于Pixel Quad的Fine Raster阶段只会在三角形边缘生成大量无用的像素,相较于整个三角形的面积,这只是很少的一部分;但对于小三角形来说,Pixel Quad最坏会生成四倍于三角形面积的像素数,并且这些像素也蕴含在Pixel Shader的执行阶段,使得WARP中无效的像素大大减少。

小三角形因为Pixel Quad造成的光栅化节约

基于上述的起因,在像素级小三角形这一特定前提下,软光栅化(基于Compute Shader)确实有机会战胜硬光栅化。这也正是Nanite的外围优化之一,这一优化使得UE5在小三角形光栅化的效率上晋升了3倍[7]。

Deferred Material

重绘的问题长久以来都是图形渲染的性能瓶颈,围绕这一话题的优化也层出不穷。在挪动端,有咱们相熟的Tile Based Rendering架构[8];在渲染管线的进化历程中,也先后有人提出了Z-PrepassDeferred RenderingTile Based Rendering以及Clustered Rendering,这些不同的渲染管线框架,实际上都是为了解决同一个问题:当光源超过肯定数量、材质的复杂度晋升后,如何尽量避免Shader中大量的渲染逻辑分支,以及缩小无用的重绘。无关这个话题,能够读一读我的这篇文章[9]。

通常来说,提早渲染管线都须要一组称之为G-BufferRender Target,这些贴图内存储了所有光照计算须要的材质信息。当今的3A游戏中,材质品种往往复杂多变,须要存储的G-Buffer信息也在逐年减少,以2009年的游戏《Kill Zone 2》为例,整个G-Buffer布局如下:

除去Lighting Buffer,实际上G-Buffer须要的贴图数量为4张,共计16 Bytes/Pixel;而到了2016年,游戏《Uncharted 4》的G-Buffer布局如下:


G-Buffer的贴图数量为8张,即32 Bytes/Pixel。也就是说,雷同分辨率的状况下,因为材质复杂度和逼真度的晋升,G-Buffer须要的带宽足足进步了一倍,这还不思考逐年进步的游戏分辨率的因素

对于Overdraw较高的场景,G-Buffer的绘制产生的读写带宽往往会成为性能瓶颈。于是学界提出了一种称之为Visibility Buffer的新渲染管线[10][11]。基于Visibility Buffer的算法不再独自产生臃肿的G-Buffer,而是以带宽开销更低的Visibility Buffer作为代替,Visibility Buffer通常须要这些信息:
(1)Instance ID,示意以后像素属于哪个Instance(16~24 bits);
(2)Primitive ID,示意以后像素属于Instance的哪个三角形(8~16 bits);
(3)Barycentric Coord,代表以后像素位于三角形内的地位,用重心坐标示意(16 bits);
(4)Depth Buffer,代表以后像素的深度(16~24 bits);
(5)Material ID,示意以后像素属于哪个材质(8~16 bits);

以上,咱们只须要存储大概8~12 Bytes/Pixel即可示意场景中所有几何体的材质信息,同时,咱们须要保护一个全局的顶点数据和材质贴图表,表中存储了以后帧所有几何体的顶点数据,以及材质参数和贴图。

在光照着色阶段,只须要依据Instance ID和Primitive ID从全局的Vertex Buffer中索引到相干三角形的信息;进一步地,依据该像素的重心坐标,对Vertex Buffer内的顶点信息(UV,Tangent Space等)进行插值失去逐像素信息;再进一步地,依据Material ID去索引相干的材质信息,执行贴图采样等操作,并输出到光照计算环节最终实现着色,有时这类办法也被称为Deferred Texturing

上面是基于G-Buffer的渲染管线流程:

这是基于Visibility-Buffer的渲染管线流程:

直观地看,Visibility Buffer缩小了着色所须要信息的贮存带宽(G-Buffer -> Visibility Buffer);此外,它将光照计算相干的几何信息和贴图信息读取提早到了着色阶段,于是那些屏幕不可见的像素不用再读取这些数据,而是只须要读取顶点地位即可。基于这两个起因,Visibility Buffer在分辨率较高的简单场景下,带宽开销相比传统G-Buffer大大降低。但同时保护全局的几何和材质数据,减少了引擎设计的复杂度,同时也升高了材质零碎的灵便度,有时候还须要借助Bindless Texture[12]等尚未全硬件平台反对的Graphics API,不利于兼容。

四、Nanite中的实现

罗马绝非一日建成。任何成熟的学术和工程畛域孕育出的技术冲破都肯定有前人的思考和实际,这也是为什么咱们破费了大量的篇幅去介绍相干技术背景。Nanite正是总结前人计划,联合现时硬件的算力,并从下一代游戏技术需要登程失去的优良工程实际。

它的核心思想能够简略拆解为两大部分:顶点解决的优化和像素解决的优化。其中顶点解决的优化次要是GPU Driven Pipeline的思维;像素解决的优化,是在Visibility Buffer思维的根底上,联合软光栅化实现的。借助UE5 Ancient Valley技术演示的RenderDoc抓帧和相干的源码,咱们能够一窥Nanite的技术真面目。整个算法流程如图:

Instance Cull && Persistent Cull

当咱们具体地解释了GPU Driven Pipeline的倒退历程当前,就不难理解Nanite的实现:每个Nanite Mesh在预处理阶段,会被切成若干Cluster,每个Cluster蕴含128个三角形,整个Mesh以BVH(Bounding Volume Hierarchy)的模式组织成树状构造,每个叶节点代表一个Cluster。剔除分两步,蕴含了视锥剔除和基于HZB的遮挡剔除。其中Instance Cull以Mesh为单位,通过Instance Cull的Mesh会将其BVH的根节点送到Persistent Cull阶段进行档次式地剔除(若某个BVH节点被剔除,则不再解决其子节点)。

这就须要思考一个问题:如何把Persistent Cull阶段的剔除工作数量映射到Compute Shader的线程数量?最简略的办法是给每棵BVH树一个独自的线程,也就是一个线程负责一个Nanite Mesh。但因为每个Mesh的复杂度不同,其BVH树的节点数、深度差别很大,这样的安顿会导致每个线程的工作解决时长大不相同,线程间相互期待,最终导致并行性很差;那么是否给每个须要解决的BVH节点调配一个独自的线程呢?这当然是最现实的情景,但实际上咱们无奈在剔除前事后晓得会有多少个BVH节点被解决,因为整个剔除是档次式的、动静的。

Nanite解决这个问题的思路是:设置固定数量的线程,每个线程通过一个全局的FIFO工作队列去取BVH节点进行剔除,若该节点通过了剔除,则把该节点的所有子节点也放进工作队列尾部,而后持续循环从全局队列中取新的节点,直到整个队列为空且不再产生新的节点。这其实是一个多线程并发的经典生产-消费者模式,不同的是,这里的每个线程既充当生产者,又充当消费者。通过这样的模式,Nanite就保障了各个线程之间的解决时长大致相同。

整个剔除阶段分为两个Pass:Main PassPost Pass(能够通过控制台变量设置为只有Main Pass)。这两个Pass的逻辑根本是统一的,区别仅仅在于Main Pass遮挡剔除应用的HZB是基于上一帧数据结构的,而Post Pass则是应用Main Pass完结后构建的以后帧的HZB,这样是为了避免上一帧的HZB谬误地剔除了某些可见的Mesh。

须要留神的是,Nanite并未应用Mesh Shader,究其原因,一方面是因为Mesh Shader的反对尚未遍及;另一方面是因为Nanite应用软光栅化,Mesh Shader的输入仍要写回GPU Buffer再用于软光栅化输出,因而相较于CS的计划并没有太多带宽的节俭。

Rasterization

在剔除完结之后,每个Cluster会依据其屏幕空间的大小送至不同的光栅器,大三角形和非Nanite Mesh依然基于硬件光栅化,小三角形基于Compute Shader写成的软光栅化。Nanite的Visibility Buffer为一张R32G32_UINT的贴图(8 Bytes/Pixel),其中R通道的0~6 bit存储Triangle ID,7~31 bit存储Cluster ID,G通道存储32 bit深度:


Cluster ID


Triangle ID


Depth

整个软光栅化的逻辑比较简单:基于扫描线算法,每个Cluster启动一个独自的Compute Shader,在Compute Shader初始阶段计算并缓存所有Clip Space Vertex Positon到Shared Memory,而后CS中的每个线程读取对应三角形的Index Buffer和变换后的Vertex Position,依据Vertex Position计算出三角形的边,执行反面剔除和小三角形(小于一个像素)剔除,而后利用原子操作实现Z-Test,并将数据写进Visibility Buffer。值得一提的是,为了保障整个软光栅化逻辑的简洁高效,Nanite Mesh不反对带有骨骼动画、材质中蕴含顶点变换或者Mask的模型

Emit Targets

为了保障数据结构尽量紧凑,缩小读写带宽,所有软光栅化须要的数据都存进了一张Visibility Buffer,然而为了与场景中基于硬件光栅化生成的像素混合,咱们最终还是须要将Visibility Buffer中的额定信息写入到对立的Depth/Stencil Buffer以及Motion Vector Buffer当中。这个阶段通常由几个全屏Pass组成:

(1)Emit Scene Depth/Stencil/Nanite Mask/Velocity Buffer,这一步依据最终场景须要的RenderTarget数据,最多输入四个Buffer,其中Nanite Mask用0/1示意以后像素是一般Mesh还是Nanite Mesh(依据Visibility Buffer对应地位的Cluster ID失去),对于Nanite Mesh Pixel,将Visibility Buffer中的Depth由UINT转为float写入Scene Depth Buffer,并依据Nanite Mesh是否承受贴花,将贴花对应的Stencil Value写入Scene Stencil Buffer,并依据上一帧地位计算以后像素的Motion Vector写入Velocity Buffer,非Nanite Mesh则间接Discard跳过。


Nanite Mask


Velocity Buffer

Scene Depth/Stencil Buffer

(2)Emit Material Depth,这一步将生成一张Material ID Buffer,稍有不同的是,它并未存储在一张UINT类型的贴图,而是将UINT类型的Material ID转为float存储在一张格局为D32S8的Depth/Stencil Target上(稍后咱们会解释这么做的理由),实践上最多反对2^32种材质(实际上只有14 bits用于存储Material ID),而Nanite Mask会被写入Stencil Buffer中。


Material Depth Buffer

Classify Materials && Emit G-Buffer

咱们曾经具体地介绍了Visibility Buffer的原理,在着色计算阶段的一种实现是保护一个全局材质表,表中存储材质参数以及相干贴图的索引,依据每个像素的Material ID找到对应材质,解析材质信息,利用Virtual Texture或者Bindless Texture/Texture Array等技术计划获取对应的贴图数据。对于简略的材质零碎这是可行的,然而UE蕴含了一套极其简单的材质零碎,每种材质有不同的Shading Model,同种Shading Model下各个材质参数还能够通过材质编辑器进行简单地连线计算,这种基于连连看动静生成材质Shader Code的模式显然无奈用上述计划实现。

为了保障每种材质的Shader Code依然能基于材质编辑器动静生成,每种材质的PS Shader至多要执行一次,但咱们只有屏幕空间的材质ID信息,于是不同于以往一一物体绘制地同时运行其对应的材质Shader(Object Space),Nanite的材质Shader是在Screen Space执行的,以此将可见性计算和材质参数计算解耦,这也是Deferred Material名字的由来。但这又引发了新的性能问题:场景中的材质动辄成千上万,每个材质都用一个全屏Pass去绘制,则重绘带来的带宽压力势必十分高,如何缩小无意义的重绘就成为了新的挑战。

为此,Nanite在Base Pass绘制阶段并不是每种材质一个全屏Pass,而是将屏幕空间分成若干8x8的块,比方屏幕大小为800x600,则每种材质绘制时生成100x75个块,每块对应屏幕地位。为了可能整块地剔除,在Emit Targets之后,Nanite会启动一个CS用于统计每个块内蕴含的Material ID的品种。因为Material ID对应的Depth值事后是通过排序的,所以这个CS会统计每个8x8的块内Material Depth的最大最小值作为Material ID Range存储在一张R32G32_UINT的贴图中:


Material ID Range

有了这张图之后,每种材质在其VS阶段,都会依据本身块的地位去采样这张贴图对应地位的Material ID Range,若以后材质的Material ID处于Range内,则继续执行材质的PS;否则示意以后块内没有像素应用该材质,则整块能够剔除,此时只需将VS的顶点地位设置为NaN,GPU就会将对应的三角形剔除。因为通常一个块内的材质品种不会太多,这种办法能够无效地缩小不必要的Overdraw。

实际上通过分块分类缩小材质分支,进而简化渲染逻辑的思路也并非第一次被提出,比方《Uncharted 4》在实现他们的提早光照时[13],因为材质蕴含多种Shading Model,为了防止每种Shading Model启动一个独自的全屏CS,他们也将屏幕分块(16x16),并统计了块内Shading Model的品种,依据块内Shading Model的Range给每个块独自启动一个CS,取Range内对应的Lighting Shader,以此防止多遍全屏Pass或者一个蕴含大量分支逻辑的Uber Shader,从而大幅度提高了提早光照的性能。


Uncharted 4中分块统计Shading Model Range

在实现了逐块地剔除后,Material Depth Buffer就派上了用场。在Base Pass PS阶段,Material Depth Buffer被设置为Depth/Stencil Target,同时Depth/Stencil Test被关上,Compare Function设置为Equal。只有以后像素的Material ID和待绘制的材质ID雷同(Depth Test Pass)且该像素为Nanite Mesh(Stencil Test Pass)时才会真正执行PS,于是借助硬件的Early Z/Stencil咱们实现了逐像素的材质ID剔除,整个绘制和剔除的原理见下图:


红色示意被剔除的区域

整个Base Pass分为两局部,首先绘制非Nanite Mesh的G-Buffer,这部分依然在Object Space执行,和UE4的逻辑统一;之后依照上述流程绘制Nanite Mesh的G-Buffer,其中材质须要的额定VS信息(UV,Normal,Vertex Color等)通过像素的Cluster ID和Triangle ID索引到相应的Vertex Position,并变换到Clip Space,依据Clip Space Vertex Position和以后像素的深度值求出以后像素的重心坐标以及Clip Space Position的梯度(DDX/DDY),将重心坐标和梯度代入各类Vertex Attributes中插值即可失去所有的Vertex Attributes及其梯度(梯度可用于计算采样的Mip Map层级)。

至此,咱们剖析了Nanite的技术背景和残缺实现逻辑。

参考
[1] 《A Macro View of Nanite》
[2] 《UE5 Nanite实现浅析》
[3] 《Vulkan API Overhead Test Added to 3DMark》
[4] 《剔除:从软件到硬件》
[5] 《Mesh Shading: Towards Greater Efficiency of Geometry Processing》
[6] 《A Trip Through the Graphics Pipeline》
[7] 《Nanite | Inside Unreal》
[8] 《Tile-Based Rendering》
[9] 《游戏引擎中的渲染管线》
[10] 《The Visibility Buffer: A Cache-Friendly Approach to Deferred Shading》
[11] 《Triangle Visibility Buffer》
[12] 《Bindless Texture》
[13] 《Deferred Lighting in Uncharted 4》

这是侑虎科技第983篇文章,感激作者洛城供稿。欢送转发分享,未经作者受权请勿转载。如果您有任何独到的见解或者发现也欢送分割咱们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/luo-cheng-11-75,目前就任于腾讯游戏研发效力部引擎中台部门,再次感激洛城的分享,如果您有任何独到的见解或者发现也欢送分割咱们,一起探讨。(QQ群:793972859)