UE5渲染技术简介:Nanite篇

作者:洛城
原文地址:https://zhuanlan.zhihu.com/p/382687738

前言

在今年初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)InstanceID,表示当前像素属于哪个Instance(16~24 bits);

(2)PrimitiveID,表示当前像素属于Instance的哪个三角形(8~16 bits);

(3)Barycentric Coord,代表当前像素位于三角形内的位置,用重心坐标表示(16 bits);

(4)Depth Buffer,代表当前像素的深度(16~24 bits);

(5)MaterialID,表示当前像素属于哪个材质(8~16 bits);

以上,我们只需要存储大约8~12 Bytes/Pixel即可表示场景中所有几何体的材质信息,同时,我们需要维护一个全局的顶点数据和材质贴图表,表中存储了当前帧所有几何体的顶点数据,以及材质参数和贴图。在光照着色阶段,只需要根据InstanceID和PrimitiveID从全局的Vertex Buffer中索引到相关三角形的信息;进一步地,根据像该素的重心坐标,对Vertex Buffer内的顶点信息(UV,Tangent Space等)进行插值得到逐像素信息;再进一步地,根据MaterialID去索引相关的材质信息,执行贴图采样等操作,并输入到光照计算环节最终完成着色,有时这类方法也被称为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对应位置的ClusterID得到),对于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 http://www.elopezr.com/a-macro-view-of-nanite/
  2. ^UE5 Nanite实现浅析 https://zhuanlan.zhihu.com/p/376267968
  3. ^Vulkan API Overhead Test Added to 3DMark https://www.geeks3d.com/20170323/vulkan-api-overhead-test-added-in-3dmark/
  4. ^剔除:从软件到硬件 https://zhuanlan.zhihu.com/p/66407205
  5. ^Mesh Shading: Towards Greater Efficiency of Geometry Processing http://advances.realtimerendering.com/s2019/Mesh_shading_SIG2019.pptx
  6. ^A Trip Through the Graphics Pipeline https://alaingalvan.gitbook.io/a-trip-through-the-graphics-pipeline/chapter6-triangle-rasterization
  7. ^Nanite | Inside Unreal https://www.youtube.com/watch?v=TMorJX3Nj6U&t=1248s
  8. ^Tile-Based Rendering https://developer.arm.com/solutions/graphics-and-gaming/developer-guides/learn-the-basics/tile-based-rendering/single-page
  9. ^游戏引擎中的渲染管线 https://zhuanlan.zhihu.com/p/92165837
  10. ^The Visibility Buffer: A Cache-Friendly Approach to Deferred Shading http://jcgt.org/published/0002/02/04/paper.pdf
  11. ^Triangle Visibility Buffer http://diaryofagraphicsprogrammer.blogspot.com/2018/03/triangle-visibility-buffer.html
  12. ^Bindless Texture https://www.khronos.org/opengl/wiki/Bindless_Texture
  13. ^Deferred Lighting in Uncharted 4 http://advances.realtimerendering.com/s2016/s16_ramy_final.pptx

剔除:从软件到硬件(转)

作者:洛城
原文地址:https://zhuanlan.zhihu.com/p/66407205

前言

剔除是游戏引擎中非常重要的技术:现代引擎需要在十几毫秒的预算里,渲染出数以万计的物体,场景复杂度往往是数千万面的级别,同时还需要处理千计盏灯光和数百种材质。对于开放世界类型的游戏更是如此:场景动辄就是数十公里的延伸,想要通过暴力穷举地方法逐一绘制这些物体是不现实的。因此,如何有效地减少不必要的绘制就显得格外重要。

这是一篇很早就想写的话题,也是我入职腾讯后的第一篇技术文章,这篇文章仅就游戏引擎中用到的各种剔除技术进行一个概述,涵盖了从引擎算法层面到硬件层面的内容。文中会较少涉及细节,感兴趣的同学可以去看文末的引用。

算法层面的剔除

视锥剔除

视锥剔除是所有3D引擎的标配。实时图形学里,摄像机一般是基于针孔模型:光线经过一个小孔(可认为是一个点)照射到小孔背后的传感器阵列(通常是规则的矩形)上,从而形成图像。从小孔出发,只有与传感器阵列有交点的光线才能够真正进入最终的画面,这个区域就是我们一般说的视锥体

由此得到的最基本的剔除思路就是视锥剔除:即简单的判断一个物体是否位于视锥棱台内。在实践中,由于模型往往是比较复杂的,很难精确计算它和视锥体的交集,因此一般是用轴对齐包围盒(AABB)有向包围盒(OBB)或者包围球(BSphere)代替模型本身进行相交计算。

对于复杂场景来说,线性数组的遍历方式往往不够高效,这时候也可以将场景以层次结构组织起来进行剔除(譬如QuadTreeOctree等)。

视锥剔除原理虽然简单,但要做到又快又好仍然不容易,尤其对于性能更严苛的生产环境来说。除了上面提到的场景包围盒的组织结构以外,另一个能够加快剔除速度的方法是利用CPU的SSE指令集,这个指令集的执行方式是SIMD,即单指令多数据,可以同时处理多组浮点数的相同运算。利用这个特性,我们可以同时处理多个包围盒和视锥的相交测试。具体的实现以及性能分析可以参考Frostbite的这篇分享[1]

视锥剔除本质上还是一个场景遍历的过程,通常来说,场景是通过树的形式存储和访问,每个子节点的最终变换由所有父节点的变换和它自身局部变换共同决定,因此如何快速地计算每个节点的变换数据也很重要。这篇文章[2]详细地阐述了面向数据编程(DOP)的思想,包括SoA和AoS两种数据布局的差异以及它们对于缓存和性能的影响。其中就以场景遍历作为例子,解释了如何利用DOP的编程思想去优化场景遍历和视锥剔除。

基于Portal的剔除

相较于视锥剔除的通用性来说,Portal的应用场景更受限制,同时需要更多的手工标定的步骤,但在某些特定环境下能够发挥很好的效果。

Portal的概念很简单,一般来说它适用于封闭的室内空间:假设我们身处一个封闭的大房子里,房子里有很多相连的房间,Portal就类似于房间之间的门。如果每个房间被看作一个孤立的节点,那Portal就是连接这些节点的桥梁。当我们处于某一个房间里(节点)的时候,只有那些和它相连的房间才有可能被我们看到。Portal就是美术/策划在制作关卡过程中人工标记出来的连接。在剔除的时候,我们只需要在标记好的图里找到我们摄像机所处的房间(节点),然后找到所有与它有Portal连接的节点,没有Portal连接的所有节点都可以直接被判定为不可见的从而被剔除。

基于Occlusion Query的遮挡剔除

遮挡剔除的原理也很简单:处于视锥内的模型也未必是可见的,因为它有可能被其他模型完全挡住。如果我们能够用比较低的代价去找到那些被完全遮挡的模型,那么就不需要对它们再进行绘制,从而提高渲染性能。

最早尝试在GPU上执行遮挡剔除应该是在occlusion query被支持之后。简单来说,occlusion query允许你在绘制命令执行之前,向GPU插入一条查询,并且在绘制结束之后的某个时刻,从GPU将查询结果回读到系统内存里。这条查询命令得到的是某次DrawCall中通过Depth Test的Sample数量,当这个Sample的数量大于0时,就表示当前模型是部分可见的,否则当前模型完全被遮挡。

基于这个API,我们就可以得到一个比较简单的遮挡剔除策略:

(1)用一个简单的depth only的pass绘制整个场景

(2)每次绘制前后插入occlusion query的命令,并根据passed sample count去标记某个物体是否被完全挡住

(3)执行正常的渲染流程,并剔除那些被标记为完全遮挡的模型

对于复杂的场景,即使只用简单的depth only pass也有很大的VS开销,一个显而易见的优化策略就是用包围盒代替模型本身去做渲染,为了更加精确,我们也可以用多个紧贴的包围盒或者相对原模型更简单的Proxy Mesh去做occlusion query[3]。此外,可以通过batch多个模型/包围盒去减少occlusion query阶段的draw call数量。

occlusion query的另一个缺点(也是最致命的缺点)是,它需要将查询结果回读到系统内存里,这就意味着VRAM->System RAM的操作,走的是比较慢的PCI-E。同时数据回读可能意味着图形API背后的驱动程序会在回读的位置Flush整个渲染命令缓冲队列并且等待之前所有的渲染命令执行完毕,相当于强制在回读位置插入了一个CPU和GPU的同步点,很可能得不偿失(实际上基于Render To Texture的Object Picking也有类似的性能问题,原理同上)。

CPU和GPU相互等待的示意图

为了解决这个问题,比较常用的的方法是让CPU回读前一帧的occlusion query的结果,用来决定当前帧某个物体是否visible,对于相机运动较快的场景,用上一帧的结果可能会导致出错,但由于一般是用包围盒,本身就是保守的剔除,所以总体来说影响不明显,UE4默认使用的就是这样的遮挡剔除方案。除了这个方法以外,GPU Gems的这篇文章[4]用了一个类似于分支预测的思路,利用两帧图像的连续性假设,把整个渲染队列里的物体分成了上一帧可见的模型上一帧不可见模型。对于上一帧可见的模型,我们就认为它这一帧也可见并且直接渲染它;对于上一帧不可见的模型,我们就插入一个查询到查询队列中然后暂时不处理。当我们没有可以直接用于渲染的模型时,我们再从查询队列里回读查询结果,并根据查询结果去更新被查询物体的可见性状态(用于下一帧的预测),同时若该模型可见,则执行渲染。具体的算法感兴趣的同学可以去看原文,想想这个方法是如何hide stall的。

基于Software Rasterization的遮挡剔除

这个方案最早应该是Frostbite提出来,用于BattleField3[1]的剔除方案。这个方案的思路是,首先利用CPU构造一个低分辨率的Z-Buffer,在Z-Buffer上绘制一些场景中较大的遮挡体(美术设定的一些大物体+地形):

在构造好的Z-Buffer上,绘制小物体的包围盒,然后执行类似于occlusion query的操作,查询当前物体是否被遮挡:

这个方法相对来说比较灵活(对于光源,物体均适用),由于是纯CPU的,集成起来也比较简单。对于主机平台还可能利用SPU之类的多余算力,同时不会有GPU stall的问题。缺点是需要美术指定一些大的遮挡体,对于CPU bound的项目可能会负优化。天涯明月刀的自研引擎中,应该也应用了这个优化策略,效果很好。参见叶老师 @Milo Yip 的这篇分享[5]

GPU Driven Rendering Pipeline[6][7]

这个思路的产生和发展得益于图形API和硬件的发展,具体来说,有两个feature至关重要:Compute Shader以及ExecuteIndirect。前者允许我们在GPU上方便地执行各类和渲染无关的GPGPU运算,并且将计算结果以Buffer或者Texture的形式存储在VRAM上;后者允许我们以GPU Buffer的形式直接构建Draw Command List。这两者结合起来,就表示我们能够在Compute Shader里构造Draw Command List用于绘制,整个过程无需CPU参与

先抛开具体实现细节,回到我们最初引入剔除的初衷:我们希望GPU知道哪些物体是不需要被渲染的(视锥之外,被完全遮挡),这个信息仅供GPU使用;此外,剔除算法的并行性很好,计算过程又相对简单,没有太多的分支和跳转,非常适合GPU去做。为此,我们只需要将场景的所有渲染资源(包括几何信息,材质信息,变换信息,包围盒信息)以一定规则打包存储在Buffer中,然后提供摄像机(视锥)信息和遮挡体绘制的Z-Buffer(类似于Software Rasterization的Z-Buffer),通过Compute Shader去执行视锥剔除和遮挡剔除,并将通过剔除的模型经由ExecuteIndirect提交渲染。

具体执行的时候,得益于GPU强大的浮点数计算能力,我们可以做比模型更细粒度的剔除:将Mesh切分成Cluster,每个Cluster有64个顶点,并且重排IndexedBuffer(Cluster大小的选取以及重排IB主要是为了提高Vertex Fetch时缓存的命中率,进而提高Vertex Fetch的速度),基于Cluster计算包围盒,利用Cluster Bouding Box去做视锥剔除。


对于遮挡剔除,在构造用于剔除的hierarchy Z-buffer的时候,也能够利用比software rasterization更快的hardware rasterization在GPU端去做。

构造Hi-Z的过程,首先基于美术指定的大遮挡体和地形去渲染一个Z-Buffer,然后down sample到低分辨率,混合上一帧Z-Buffer经过reprojection的结果,最后做一系列down sample得到一个层次Z-Buffer的结构

不同于Software Rasterization的方法,Hi-Z意味着我们可以从最粗粒度的Z-Buffer开始进行遮挡查询和剔除,相较于全分辨率的遮挡查询,这样的查询效率更高。

除了常用的Frustum Culling和Occlusion Culling,我们还可以在Compute Shader里去做Backface Culling和Small Primitive Culling,把背面的三角形和面积很小的三角形剔除掉。

在剔除工作完成后,通常会启动一个Compaction的Compute Shader,这个Shader会把通过culling的所有triangle复制到一个更紧凑的Buffer里面,并且执行一些基于材质Batch的合并策略。最后调用ExecuteIndirect来渲染最终场景:

GPU Driven Rendering Pipeline的核心思路是减少CPU和GPU之间的通信,尽量将所有渲染相关的事务(包括提交)都放在GPU端(自己的事情自己做),解放CPU的算力用于构造物理和AI的规则。同时,利用GPU的算力能够更精细粒度地去控制渲染命令队列内的生成和合并。在实际渲染时,除了我们这里提到的基于Compute Shader的Culling和Batching,还需要辅以Virtual Texturing,Mega Texture等技术并对渲染管线做配套改造,对原本的渲染引擎架构改动也较大,想要把这一技术植入引擎中并不容易。这部分更细节的内容,除了育碧和寒霜引擎的两篇分享[6][7],也有非常多的文章具体阐释[8][9][10][11],可以配合阅读。

当然,no pain no gain。基于GPU Driven Rendering Pipeline的性能提升也是非常大的,对于CPU提交端往往能提升一两个数量级的性能,同时得益于更精确的剔除,GPU端渲染也有一定程度的性能提升。

硬件层面的剔除

Clipping&Backface Culling

Graphics Pipeline稍有了解的同学对这两个环节应该都不陌生,Clipping是当一个三角形的顶点位置被变换到NDC后,针对NDC外的三角形和穿过NDC的三角形,会执行剔除或者裁剪的操作,具体的裁剪规则,可以参见这个问题。至于Backface Culling,则是在图元装配阶段结束之后,根据用户指定的手向,把面向摄像机或者背对摄像机(一般是背对摄像机)的三角形剔除[12],剔除后的三角形就不会再进入到Pixel Shader和Rasteriaztion的流程里。

Early-Z

提到Early-Z就必须提对应的Late-Z:在图形管线中,逻辑上Depth Test和Stencil Test是发生在Pixel Shader的执行之后的,因为Pixel Depth在Pixel Shader阶段还有可能被修改,所以Pixel Shader->Depth Test的流程顺序就是Late-Z。但由于Pixel Depth修改的需求非常少(基于深度混合的Impostor和某些粒子效果),所以绝大部分情况下,Pixel Depth在Rasterization之后、Pixel Shader执行之前就可以被确定下来,如果我们能够把Depth Test放在Pixel Shader之前,对那些没通过Depth Test的像素不执行Pixel Shader,就能够一定程度上减少SM的压力,这就是Early-Z这个优化策略的初衷,现在已经是GPU的标配了。默认在Pixel Shader里没有修改Depth的操作时,这个优化就会开启。

Z-Cull

很多人会将Z-Cull和Early-Z弄混,其实它俩并不一样,重点体现在剔除的粒度不同:Z-Cull的剔除是粗粒度的Pixel Tile(比如一个8*8的像素块),而Early-Z是细粒度的2*2的Pixel Quad(可以思考一下为什么是Pixel Quad而不是Pixel)。在Z-Cull进行Depth Test的时候,Pixel Tile会被压缩以加速比较(主要是减少带宽开销),比如用平面方程的系数表示一块Pixel Tile,用平面方程去和Z-Buffer做Coarse Depth Test,而不是Tile内部逐个像素去做Depth Test。正因为如此,常用的Alpha Test会让一个原本完整的平面出现空洞,这就会破坏Pixel Tile的压缩算法,进而导致Z-Cull无法开启。

现代引擎基本都会利用Z-Cull和Early-Z的特性去减少SM的计算压力,具体方法是执行一个Z-Prepass(不论是foward,forward+还是deferred管线都一样):先将不透明物体按照距离摄像机从前向后的顺序排序,然后只开启Z-Buffer write和compare,不执行Pixel Shader进行一遍渲染。在执行完Z-Prepass后,关闭Z-Buffer的写入,将compare function改为equal,然后执行后续复杂Pixel Shader(前向渲染的光照计算或者延迟渲染的G-Buffer填充)。

有关Early-Z和Z-Cull更多的开启条件,可以看这两篇文章[13][14]

总结

这篇文章主要是概述了现代渲染引擎中常用的剔除技术(有关阴影的剔除算法这里没有太多的涉及,我会在未来单独写一篇和阴影相关的主题),我们从上层算法和硬件优化两个大的分类上做了一些具体技术的罗列。总体来说,可以认为从算法剔除到硬件剔除是一个粒度在逐渐变精细的过程(Mesh->Cluster->Triangle->Pixel Tile->Pixel Quad)。而GPU Driven Rendering Pipeline作为一个相对来说比较特殊的存在,它不是一个具体的算法而是一种思路,这种思路代理了某些传统硬件上我们认为是固定管线的功能,同时尽可能地减少CPU和GPU的通信。它的出现符合现代硬件的发展趋势:一是可编程管线的功能日益强大进而代替更多的固定管线单元;二是相较于密集的计算量,现代程序的优化更多地依赖于如何提高硬件的并行程度,减少等待和同步,以及如何优化访存。

参考

  1. ^abCulling the Battlefield: Data Oriented Design in Practice https://www.gamedevs.org/uploads/culling-the-battlefield-battlefield3.pdf
  2. ^Pitfalls of Object Oriented Programming https://www.slideshare.net/EmanWebDev/pitfalls-of-object-oriented-programminggcap09
  3. ^Efficient Occlusion Culling https://developer.download.nvidia.cn/books/HTML/gpugems/gpugems_ch29.html
  4. ^Hardware Occlusion Queries Made Useful https://developer.nvidia.com/gpugems/GPUGems2/gpugems2_chapter06.html
  5. ^为实现极限性能的面向数据编程范式 http://twvideo01.ubm-us.net/o1/vault/gdcchina14/presentations/833779_MiloYip_ADataOrientedCN.pdf
  6. ^abGPU-Driven Rendering Pipelines http://advances.realtimerendering.com/s2015/aaltonenhaar_siggraph2015_combined_final_footer_220dpi.pdf
  7. ^abOptimizing the Graphics Pipeline with Compute https://frostbite-wp-prd.s3.amazonaws.com/wp-content/uploads/2016/03/29204330/GDC_2016_Compute.pdf
  8. ^https://zhuanlan.zhihu.com/p/33881505
  9. ^https://zhuanlan.zhihu.com/p/37084925
  10. ^https://bazhenovc.github.io/blog/post/gpu-driven-occlusion-culling-slides-lif/
  11. ^https://zhuanlan.zhihu.com/p/47615677
  12. ^https://en.wikipedia.org/wiki/Back-face_culling
  13. ^https://zhuanlan.zhihu.com/p/53092784
  14. ^https://developer.nvidia.com/gpugems/GPUGems2/gpugems2_chapter30.html



Unity Shader GrabPass使用注意的问题(转)

最近项目中碰到GrabPass多次抓图的问题,经测试发现可以每帧只抓取一次图,然后共用。后来看到网上这篇文章总结得很好,所以收藏下。

文档

关于Unity Shader中的GrabPass说明文档:

官方的ShaderLab: GrabPass
CSDN其他博主翻译的ShaderLab: GrabPass


GrabPass有两种写法

GrapPass { }
GrabPass { “TheGrabTextureName” }
两种写法的去写在哪呢。
文档中有说明,但是可能说的还是不够清楚。

我用自己总结的:

GrabPass { } 是每次Drawcall中的Shader的GrabPass使用时都会中屏幕内容抓取一次绘制的内容,并保存在默认的命为_GrabTexture的纹理中
GrabPass { “TheGrabTextureName” } 是每一帧Drawcall中的Shader的GrabPass中,第一次调用该GrabPass抓取的内容,保存在TheGrabTextureName的纹理中,后面Drawcall或是pass调用的GrabPass { “TheGrabTextureName” }只要TheGrabTextureName纹理名字与之前的GrabPass { “TheGrabTextureName” }中的TheGrabTextureName相同,都不会再执行GrabPass的过程,而直接使用之前Grab好的纹理对象内容。
下面我在实际Unity测试项目中测试结果写下来。


区别

我写了个测试用的,类似毛玻璃的模糊效果的Shader,如下:

使用GrabPass { } 的方式

// jave.lin 2020.03.04
Shader "Custom/GrabTexBlur" {
    Properties {
        _Blur ("_Blur", Range(0, 1)) = 0
    }
    SubShader {
        Tags { "Queue"="Transparent" "RenderType"="Transparent" }
        GrabPass { }
        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            struct appdata {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };
            struct v2f {
                float4 vertex : SV_POSITION;
                float4 grabPos : TEXCOORD1;
                float4 worldPos : TEXCOORD2;
            };
            sampler2D _GrabTexture;
            float4 _GrabTexture_TexelSize;
            fixed _Blur;
            fixed3 blur(float2 uv) {
                fixed3 sum = 0;
                const int blurSize = 4;
                const int initV = blurSize / 2;
                const int maxV = initV + 1;
                for (int i = -initV; i < maxV; i++) {
                    for (int j = -initV; j < maxV; j++) {
                        sum += tex2D(_GrabTexture, uv + float2(i * _GrabTexture_TexelSize.x, j * _GrabTexture_TexelSize.y));
                    }
                }
                // sum /= (blurSize + 1) * (blurSize + 1); // 这句时正确的效果
                sum /= blurSize * blurSize; // 这句时为了测试两个Sphere交集部分更亮的效果
                return sum;
            }
            v2f vert (appdata v) {
                v2f o;
                o.worldPos = mul(unity_ObjectToWorld, v.vertex);
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.grabPos = ComputeGrabScreenPos(o.vertex);
                return o;
            }
            fixed4 frag (v2f i) : SV_Target {
                i.grabPos.xy /= i.grabPos.w;
                fixed4 col = tex2D(_GrabTexture, i.grabPos.xy);
                col.rgb = lerp(col.rgb, blur(i.grabPos.xy), _Blur);
                return col;
            }
            ENDCG
        }
    }
}


应用

  • 场景中新建一个Sphere球体。
  • 创建Material材质,设置使用的Shader。
  • 给球体设置材质。

效果如下图:

Profiler > Frame Debugger

打开Frame Debugger查看绘制过程,因为shader指定在transparent的queue绘制队列,所以直接看TransparentGeometry下绘制的内容就好

可以看到有一个Grab RenderTexture的绘制

这时再复制一个毛玻璃的Sphere,然后再看看绘制过程。

这次看到有两次Grab RenderTexture的绘制
这是GrabPass { } 的绘制过程。
看看运行效果,记住这个效果,与下面的其他方式是不一样的

使用GrabPass { “TheGrabTextureName” }方式

shader还是和上面的一样,都是毛玻璃的测试shader。
只不过,这次我们给GrabPass出来的屏幕内容的纹理定义了一个名字。

GrabPass { "_GrabTexture" }

注意,如果你起的名字与默认的_GrabTexture相同也是没问题的,只要用了这种给GrabPass出来的纹理起个名字,就和没使用名字的处理流程是不一样的

然后再看看绘制过程

可以看到,这次虽然绘制了两个Sphere(黄色框那),但是Grab RenderTexture只执行了一次,因为都使用了GrabPass {“Name”}的方式,并且Name是一样的。那么后续的GrabPass就直接使用之前的纹理。

效果上会也和没起名字的不一样

两图对比一下

首先,shader中有这么一句故意使用的:

               // sum /= (blurSize + 1) * (blurSize + 1); // 这句时正确的效果
                sum /= blurSize * blurSize; // 这句时为了测试两个Sphere交集部分更亮的效果

因为少除了一些采样数据均值权重,所以整体权重比之前大,就会更亮。

GrabPass{ }的方式,在两球体交集部分会比较亮,原因是:GrabPass { } 每次Drawcall时的都重新先取拿一次当前绘制屏幕内容的内容到一个纹理中,所以第一个球体对Grab出来的屏幕像素内容处理逻辑了,导致某些像素比较亮了。但是第二个球体再次Grab出来的屏幕像素时,有些与第一个球体的像素集合有交集的本身有处理过,所以亮度本身比较高了,那么再次处理亮度,就会亮上加亮。
而GrabPass { “Name” } 的方式,区别在于,第二个球再次GrabPass { “Name” }时发现这个”Name”的纹理之前有了,就不再重新抓取当前屏幕内容了。所以使用的还是第一个球体执行毛玻璃+亮度之前的原始屏幕内容,所以交集出的部分不会更亮。第一次球体绘制的与第二次球体绘制有交集的那部分内容,都给第二次球体绘制的覆盖了。

不同Shader中使用了一样的GrabPass {“Name”}

再添加另一个shader,此shader作用就是添加亮度的,如下

Shader "Custom/GrabTexBrightness" {
    Properties {
        _Brightness ("_Brightness", Range(1, 2)) = 1
    }
    SubShader {
        Tags { "Queue"="Transparent" "RenderType"="Transparent" }
        GrabPass { "_GrabTexture" }
        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            struct appdata {
                float4 vertex : POSITION;
            };
            struct v2f {
                float4 vertex : SV_POSITION;
                float4 grabPos : TEXCOORD1;
            };
            sampler2D _GrabTexture;
            half _Brightness;
            v2f vert (appdata v) {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.grabPos = ComputeGrabScreenPos(o.vertex);
                return o;
            }
            fixed4 frag (v2f i) : SV_Target {
                return tex2Dproj(_GrabTexture, i.grabPos) * _Brightness;
            }
            ENDCG
        }
    }
}

可以看到使用了GrabPass { “_GrabTexture” },名字与之前的毛玻璃+亮度方式的shader的”_GrabTexture”是一直的名字。

那么看看Frame Debugger的绘制过程

三次Draw Mesh,但只有一次Grab RenderTexxture,而且Draw Mesh BrightnessSphere与后面两次BlurSphere的Shader是不一样的,只不过他们使用的GrabPass {“Name”}中的Name是一样的。

注意性能

从Frame Debugger中的GrabPass的处理过程,显示的是:Grab RenderTexture,注意后面是RenderTexture

注意都是猜测,具体调用底层的渲染API这个需要使用一个分析工具才能确定

而这个过程是很费(耗费性能)的,所以我们尽量能用GrabPass {“Name”} 就不用GrabPass{ }

刚开始我以为是RT的方式,后来发现一篇文章,虽然不是将GrabPass的,是将一般后效使用的MonoBehaviour类的回调OnRenderImage的方法下的Graphics.Blit,也是挺卡的,在一般的手机上。

这篇文章中有讲解:在使用Mali系列的Android手机。他用Mali Graphics Debugger看到底层渲染API的调用。

图中可以看到Unity的Profiler中,有显示调用的是RenderTexture.GrabPixels

中MGD(Mali Graphics Debugger)中查看底层API

在glReadPixels的最后个参数不为空,则表示数据从显存传输到系统内存,从CPU到GPU的逆向传输,这是非常缓慢的过程,并且是阻塞模式。

2020.03.15 更新,在看到unity 的SIGGRAPH2011的某个文档有说明:
GrabPass {“name”}
• new in 3.4: only copies the color buffer once per frame (_Grab is shared)

所以确定,调用的就是glReadPixel来读取ColorBuffer的像素的。

在FORCE FIELD EFFECT那部分有说明,文档:SIGGRAPH2011 Special Effect with Depth.pdf
如果多年后,下载不了,链接无效了,可以点击这里(Passworld:cmte)下载(我收藏到网盘了)

原文地址

移动端高性能图形开发 – 移动端GPU架构探究(转)

提要

随着移动端上引擎的渲染特性如HDR,PBR,SSR,完整的后处理管线等等不断的增加,直接照搬端游方案很容易翻车,很多时候需要针对移动端做适配,这时候只有对移动端硬件底层做充分的了解才能得出最优解。 本文从引擎开发者角度详细阐述一下对于移动端GPU架构的理解。

从IMR说起

IMR常用于PC,主机,笔记本等设备。最大的特点就是Primitive提交之后直接进行绘制, 如下如所示

这个和RTR 上的Pipeline是一致的。

硬件层面的渲染流程见下图,

IMR中的内存访问 
首先确认的是,FrameBuffer是一直存储在SystemMemory上的,每个Primitive的渲染都会读取和写入DepthStencil,SceneColor,这些都是放在System Memory上的,读取System Memory(也叫Main Memory,也叫DDR,也叫DRAM)上的数据计算完之后又写入主存。

假设FrameBuffer是存储在主存上的一段连续内存,那么每个三角形在绘制的时候其实是一种随机的内存访问,这种随机即使在加入Cache机制之后还是会引起很大的性能消耗。

IMR的优势和劣势 
优势 – 简单顺畅的流程保证了在单个Primitive渲染流程中没有额外的中间结果存储。

劣势 – 带宽消耗。

假设渲染一个1440p的画面,使用32bit的SceneColor,32bit的Depth/stencil,那整体需要的内存大小为30m,如果整个FrameBuffer都要同时随机访问的话,即使有Cache机制,也会触发大量的从System Memory到Cache的读取以及从Cache到System Memory的写入。

移动端方案 – TBDR

移动端硬件设计的目的 相对于桌面gpu单纯追求更高的帧率,移动端的方案则需要同时考虑更低的发热,更小的功耗,更高的性能,并在他们之中寻找平衡点。所以移动端硬件设计方案最重要就是尽可能的减少带宽消耗。所以就有了TBDR(Tiled Based Deferred Rendering)。

整个FrameBuffer被划分成了多个块,称之为Tile,分Tile渲染,每次渲染一个Tile的时候,将当前Tile所影响的Primitive都取出来,进行对应的三角形变换,着色,深度测试后之后,将结果再写回主存,

AMD在早年的分享中做了一个实验,同样渲染两个三角形所产生带宽消耗对比

硬件上和PC有两个区别:

1、显存和系统内存共用,GPU和内存直接打交到代价很大。

2、GPU上封装了一块很小的高速存储空间,称之为On-chip memory(也叫Tile Memory,也叫 on-chip framebuffer, Tile Buffer之类的),这块内存在渲染的第二阶段就会使用这部分的内存,大小因不同的GPU设计而不同,最小可能只覆盖 16 × 16 pixel。

当然有些厂商还做了多级缓存机制,比如最新的apple芯片,除了每个Shader Core都拥有自己的L1 Cache之外,还增加了GPU Last Level Cache,进一步减小了GPU与主存传递数据的代价。

详解TBDR渲染流程

完整的pipeline可以参考下图


伪代码如下

# Phase one
for draw in renderPass:
    for primitive in draw:
        for vertex in primitive:
            execute_vertex_shader(vertex)
        if primitive not culled:
            append_tile_list(primitive)
# Phase two
for tile in renderPass:
    for primitive in tile:
        for fragment in primitive:
            execute_fragment_shader(fragment)

上面的流程可以分为两个Phase – Tiling Phase和Render Phase。

Tiling Phase

在绘制Tile的时候需要只要当前Tile有哪些Primitive参与绘制,这个需要提前算好,这个计算的过程就叫Tilling(也叫Binning)。

Tilling Phase做三件事

1、将当前ViewPort划分为多个Tile,

2、执行VertexShader,三角形都变换到屏幕空间

3、计算Primitive会影响到哪些Tile,并将结果存储到主存上去。

Tilling计算的结果会存储到主存去,称之为Intermediate store(也有叫 polygon lists,也有叫Frame Data,也有叫 parameter buffer, 也叫Geometry Work Set),后面在分Tile渲染的时候会从主存load这个数据,但是这个代价就小很多了,不过也要注意顶点数量,不要让这个buffer过于膨胀。

下面有个简单的示意动画

Rendering Phase

Tiling之后GPU就对依次渲染这些Tiles,每个GPU Core每次渲染一个Tile,Core越多,同时能渲染的Tile就越多。

几个重要的点单独拎出来说一下

硬件层面的RenderPass

简单的说,就是一次渲染管线的执行。一次RenderPass会将渲染结果存储到FBO的Attachments里面,每个Attachment都需要在Tile上初始化(Load Action),并且有可能需要写回到SystemMemory(SaveAction)。

Tile Memory Load Store Action

对于OnChip和System Memory之间内存的拷贝,我们通常称之为Resolve,甚至还有Light weight resolve(从OnChip到System Memory)和Heavy weight resolve(从System Memory到OnChip 再写回到System Memory)的说法,另外MSAA里面也有关于resove的说法,所以…

Resolve 的具体含义得根据上下文来确定。

不过现在多了一种说法就是Load Action 和Store Action。 如果是TileMemory到SysemMemory的内存写入,指的就是Store Action为Store。

Load Action 有三种,DontCare,Load, Clear。 Store Action 有三种, Store, DontCare,storeAndMultisampleResolve, MultisampleResolve. 每种Action 的详细说明请参考苹果的文档Load and Store Actions

如果想要优化流水线的性能,就一定要注意设置好每个RenderTarget的Load Action 和 Store Actions。

比如,深度和Stencil通常只有在Rasterize阶段才会使用,所以直接放到了Onchip上, Store Actions设置为DontCare这样就不用把结果写入主存,省下了大量的带宽。

Resource Storage Mode

通常用StorageMode来表示内存中的对象的被CPU和GPU的访问模式,通常有Shared,Private,Memoryless三种,如下图所示

Shared – CPU 和GPU都可以访问,这类资源通常由CPU创建并更新。

Private – 存在SystemMemory上,只有GPU可以访问,通常用于绘制 render targets,Compute Shader存储中间结果或者 texture streaming.

Memotyless – 存在TileMemory, 只有当前Tile可以访问,用完就会被刷新掉,比如Depth/Stencil Buffer 在 iOS 上对于所有的不需要 resolve 的 rt(或 store action 设置为 don’t care)都应该设置为 memoryless,比如上面说到的Depth和Stencil。

Tiled Base Deferred Render中的Defered

其实这里有两个Deferred,一个是指VS之后不立即进行PS着色,所有的厂商的移动端芯片都是如此。 另一个Deferred是由Power VR以及苹果的芯片独占,这个Deferd指的是shading操作是在所有像素都经过可见性检测之后才进行的,这个可见性判定的操作称之为HSR.下图是详细的HSR流程。

其实每个厂商都有一些相关的消除Overdraw的算法,比如Mali的Forward Pixel Kill (FPK),比如高通的A Low Resolution Z (LRZ) ,具体算法以及原理可以看最后的链接。

TBDR的优势和劣势

1、带宽上的节省

2、由于OnChip Memory的存在,一些不需要resolve出来的信息可以只存在OnChip中,可以节省System Memory。

3、分Tile 渲染对于Texture Cache 更加友好

1、屏幕尺寸越大,划分的Tile数量越多,生成的Geometry WorkSet越大。

2、Tilling Phase是完全多出来的,打断了流水线的执行,引入了latency。

Performance Tuning

主要介绍一下Profile工具,之前有写过一篇高级图形调试优化技巧 – XCode篇,有兴趣可以点开看看,不过最新版本的xcode又出了很多新的功能,最好是去wwdc的视频学习一下。

在XCode截帧之后,每个RenderPass都单独标记出来,并且每个Attachment的Load Action和Save Action)以及Memory Type都有标注出来,还可以看到当前pass的带宽消耗以及当前帧的总带宽消耗。

Android下可以使用Renderdoc,不过API需要是Vulkan,截帧之后就可以看到每个pass以及对应的load store action.

当然还有其他厂商比如Arm和高通提供的工具,总体来说,还是Xcode好用一些。

小结

移动端硬件发展至今,大的渲染框架还是TB(D)R这一套,但是随着硬件制程以及技术迭代,移动端硬件能提供的性能空间也在飞速发展,各种移动端的渲染技术也是层出不穷,如何合理地使用与分配这些性能空间算是引擎开发者的必备技能了。

另一方面,移动端和桌面端的硬件区别也将会越来越小,不用说几年前的switch掌机,还是去年苹果发布的arm架构的M1芯片,一些桌面端的GPU甚至也利用TBR来做性能优化了, 称之为partially-tile-based rendering

移动端也有在某些情况下切换IMR渲染的技术,比如高通的FlexRender。

后面还会继续针对移动端高性能图形开发写几篇东西,大家敬请期待。

Reference

Tile-Based Rendering https://developer.arm.com/solutions/graphics-and-gaming/developer-guides/learn-the-basics/tile-based-rendering Performance Tuning for Tile-Based Architectures – OpenGL Insights Chapter.23 ARM’s

Mali Midgard Architecture Explored – https://www.anandtech.com/show/8234/arms-mali-midgard-architecture-explored/7

Next-Gen Tile-Based GPUs – http://developer.amd.com/wordpress/media/2013/01/gdc2008_ribble_maurice_TileBasedGpus.pdf

GPU Framebuffer Memory: Understanding Tiling – https://developer.samsung.com/galaxy-gamedev/resources/articles/gpu-framebuffer.html

Best Practices For Mobile

Principles of High Performance – https://developer.arm.com/solutions/graphics-and-gaming/developer-guides/learn-the-basics/principles-of-high-performance A New Software Based GPU Framework Evgeny Miretsky 2013

On NVIDIA’s Tile-Based Rendering – https://www.techpowerup.com/231129/on-nvidias-tile-based-rendering

Tile-based Rasterization in Nvidia GPUs – https://www.realworldtech.com/tile-based-rasterization-nvidia-gpus/

Understanding GPU Family 4 – https://developer.apple.com/documentation/metal/gpu_features/understanding_gpu_family_4

How GPU Works – https://cs184.eecs.berkeley.edu/sp19/lecture/23-51/how-gpus-work

PowerVR performance tips for Unreal Engine 4 – https://www.imaginationtech.com/blog/powervr-performance-tips-for-unreal-engine-4/

Load and Store Actions – https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/MTLBestPracticesGuide/LoadandStoreActions.html

Killing Pixels – A New Optimization for Shading on ARM Mali GPUs – https://community.arm.com/developer/tools-software/graphics/b/blog/posts/killing-pixels—a-new-optimization-for-shading-on-arm-mali-gpus

Visibility processing – https://developer.qualcomm.com/docs/adreno-gpu/developer-guide/gpu/overview.html

原文地址

UnityShaderVariant的一些探究心得[转]

最近项目中Global Keyword超标(>256),导致频繁报错,所以特效了解了下这方面内容,发现这篇文章解释得挺清楚。

ShaderVariant

举个例子,对于一个支持法线贴图的Shader来说,用户肯定希望无论是否为材质提供法线贴图它的Shader都能正确的进行渲染处理。一般有两种方法来保证这种需求:

  1.在底层shader(GLSL,HLSL等)定义一个由外部传进来的变量(如int),有没有提供法线贴图由外部来判断并给这个shader传参,若是有则传0,否则传1,在Shader用if对这个变量进行判断,然后在两个分支中进行对应的处理。

  2.对底层shader封装,如Unity的ShaderLab就是这种,然后在上层为用户提供定义宏的功能,并决定宏在被定义和未被定义下如何处理。最终编译时,根据上层的宏定义,根据不同的组合编译出多套底层shader.

  上述两种方法,各有利弊,对于前者由于引入了条件判断,会影响最终shader在GPU上的执行效率。而后者则会导致生成的shader源码(或二进制文件)变大。Unity中内置的Shader往往采取的是后者,所以这里只讨论这种情况。   

  Unity的Shader中通过multi_compile和shader_feature来定义宏(keyword)。最终编译的时候也是根据这些宏来编译成多种组合形式的Shader源码。其中每一种组合就是这个Uniy Shader的一个Variant。

MaterialShaderVariant的关系

一个Material同一时刻只能对应它所使用的Shader的一个variant。进行切换的要使用Material.EnableKeyword()和Material.DisableKeyword()来开关对应的宏,然后Unity会根据你设定的组合来匹配响应的shader variant进行渲染。如果你是在编辑器非运行模式下进行的修改那么这些keyword的设置会被保存到材质的.mat文件中,尝试用NotePad++打开.mat文件,你应该会看到类似于下面的一段内容(需要在编辑器设置里把AssetSerializationMode设置为Force Text):

%YAML 1.1

%TAG !u! tag:unity3d.com,2011:

--- !u!21 &2100000

Material:

  serializedVersion: 6

  m_ObjectHideFlags: 0

  m_PrefabParentObject: {fileID: 0}

  m_PrefabInternal: {fileID: 0}

  m_Name: New Material

  m_Shader: {fileID: 4800000, guid: 3e0be7fac8c0b7c4599935fa92c842a4, type: 3}

  m_ShaderKeywords: _B

  m_LightmapFlags: 1

  m_CustomRenderQueue: -1

  …

其中的m_ShaderKeywords就保存了这个材质球使用了哪些宏(keyword).

  如果你手头有built-in Shader的源码可以打开里面的StandardShaderGUI.cs看一下Unity自己事怎么处理对于StandardShader的keyword设置的。

  另外Shader.EnableKeyword,和Shader.DisableKeyword是对Shader进行全局宏设置的,这里不提了。

multi_compileshader_feature的区别

完全没接触过它们的同学可以先看官方文档的介绍,multi_compile是一直都有的,shader_feature是后来的unity版本中加入的关键字。

举例介绍一下multi_compile和shader_feature:

1.如果你在shader中添加了

#pragma multi_compile  _A _B 
#pragma multi_compile _C _D

那么无论这些宏是否真的被用到,你的shader都会被Unity编译成四个variant,分别包含了_A _C,_A _D, _B _C,_B _D四种keyword组合的代码

2.如果是

#pragma shader_feature _A _B 
#pragma shader_feature _C _D

那么你的shader只会保留生成被用到的keyword组合的variant,至于如何判定哪些组合被用到了,等后面提到Assetbundle时候再说。

ShaderVariant与Assetbundle的关系

我所遇到的问题正是和Assetbundle(简称AB)有关,原因是打成AB包之后shader_feature所定义的宏没有被正确包含进去。

  上面说了multi_compile定义的keyword是一定能正确的生成对应的多种组合的shaderVariant,但shader_feature不尽然,Unity引入shader_feature就是为了避免multi_compile那种完整编译所导致组合爆炸,很多根本不会被使用的shader_variant也会被生成。Unity在处理shader_feature时会判断相应的keyword组合是否被使用。需要区分一下几种情况:

1.如果shader没有与使用它的材质打在一个AB中,那么shader_feature的所有宏相关的代码都不会被包含进AB包中(有一种例外,就是当shader_feature _A这种形式的时候是可以的),这个shader最终被程序从AB包中拿出来使用也会是错误的(粉红色).

  2.把shader和使用它的材质放到一个AB包中,但是材质中没有保存任何的keyword信息(你在编辑器中也是这种情况),shader_feature会默认的把第一个keyword也就是上面的_A和_C(即每个shader_feature的第一个)作为你的选择。而不会把_A _D,_B _C,_B _D这三种组合的代码编译到AB包中。

  3.把shader和使用它的材质放到一个AB包中,并且材质保存了keyword信息(_A _C)为例,那么这个AB包就只包含_A _C的shaderVariant.

  可以看到shader_feature所定义的keyword产生的ShaderVariant并不是全部被打包到AB中,特别是你想在游戏运行时动态的通过EnableKeyWorld函数来进行动态修改材质使用的shaderVariant,如果一开始就没有把对于variant放进AB包,自然也就找不到。

ShaderVariantCollection

要正确的让各种variant正确的在游戏运行时正确处理,

最直接暴力的两种方法:

1.把Shader放到在ProjectSetting->Graphics->Always Include Shaders列表里,Unity就会编译所有的组合变种。

2.把Shader放到Resources文件夹下,也会正确处理,我猜也应该是全部keyword组合都编译,有知道的同学,麻烦留言告诉我。

  但是这两种情况最大的问题就是组合爆炸的问题,如果keyword比较少还好,要是多了那真是不得了,比如你把standardShader放进去,由于它有大量的keyword,全部变种都生成的话大概有几百兆。另外一个问题就是这种办法没法热更新。自然不如放到AB包里的好控制。

  放到AB包就又涉及到shader_feature的处理,为了在运行时动态切换材质的shadervariant,可以在工程里新建一堆材质,然后把每个材质设置成一种想要的keyword组合,把他们和shader放到一起打到一个AB中去,这样虽然能让shadervariant正确生成,但是这些Material是完全多余的。

  为了解决这种问题,Unity5.0以后引入了ShaderVariantCollection(下面简称SVC),这里不讲用法,只说问题,这个SVC文件可以让我指定某个shader要编译都要编译带有哪些keyword的变种。并且在ProjectSetting->Graphics界面新加了一个Preloaded Shaders列表,可以让你把SVC文件放进去,编译时指定的Shader就会按照SVC中的设置进行正确的variant生成,而不会像Always Include Shaders列表中的那样全部变种都生成。

  但是它在AB中的表现可就不尽如人意了,要让它起作用,就必须把它和对应的shader放在一个AB中,而且除了5.6以外版本,我试了几个都不能正确使用,不是一个variant都没生成,就是只生成一个shadervariant(和放一个没有设置keyword的材质效果一样).你可以自己用UnityStudio打开查看一下生成的AB内容。

写在最后

应该正确的理解Unity提供multi_compile和shader_feature以及ShaderVariantCollection的意图,根据自己的情况来选择合理的解决方案。

  在查这个问题的过程中也google了一些,发现国外在这方面的讨论远没国内多,应该是因为老外很少使用热更新这种东西,也自然很少用AB。

作者esfog,原文地址http://www.cnblogs.com/Esfog/p/Shader_Variant.html