关于游戏开发:UE5渲染技术简介Nanite篇

109次阅读

共计 9760 个字符,预计需要花费 25 分钟才能阅读完成。

一、前言

在今年初 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,前者以一个三角形作为输出,以 8 ×8 像素为一个块,将三角形光栅化为若干块(你也能够了解成在尺寸为原始 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 最终输入的并不是一个个像素,而是 2 ×2 的小像素块(Pixel Quad)

对于靠近像素大小的三角形来说,硬件光栅化的节约就很显著了。首先,Coarse Raster 阶段简直是无用的,因为这些三角形通常都是小于 8 ×8 的,对于那些狭长的三角形,这种状况更蹩脚,因为一个三角形往往横跨多个块,而 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,而是将屏幕空间分成若干 8 ×8 的块,比方屏幕大小为 800×600,则每种材质绘制时生成 100×75 个块,每块对应屏幕地位。为了可能整块地剔除,在 Emit Targets 之后,Nanite 会启动一个 CS 用于统计每个块内蕴含的 Material ID 的品种。因为 Material ID 对应的 Depth 值事后是通过排序的,所以这个 CS 会统计每个 8 ×8 的块内 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,他们也将屏幕分块(16×16),并统计了块内 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)

正文完
 0