【Siggraph 2015】GPU-Driven Rendering Pipelines

本文是育碧的两个工程师在Siggraph2015上的陈述,是《刺客信条Unity》(以下简称ACU,Montreal工作室)开发过程中所使用的GPU驱动的渲染管线以及RedLynx工作室的GPU驱动渲染管线实施方案的介绍。

整个陈述分成如下几个部分,第一个部分是GPU驱动渲染管线的背景与动机;第二个部分是GPU/CPU渲染管线都会用到的mesh cluster rendering方法的简介;第三个部分会对ACU的GPU驱动渲染管线做一个详细的介绍;第四个部分则是对Occlusion Depth数据的生成算法的介绍,最后给出所有成果的实施效果。

GPU驱动渲染管线是什么意思?总的来说,就是将此前由CPU完成的物件渲染前剔除处理以及渲染输出的target viewport的指定工作移交给GPU来完成,物件渲染的整个过程不需要CPU对资源数据进行干涉以避免对GPU流程的阻塞。

之所以要这样做,是因为随着算法复杂度的增加以及场景复杂度的提升,相对于串行处理运算器CPU而言,并行处理运算器GPU的消耗会更低,计算效率更高,且物体可见性使用时延基本可以忽略。

育碧RedLynx工作室(以下简称R工作室)产出的游戏,对于UGC(user generated content)依赖较高:

  1. 包括背景在内,场景大多是由小块数据拼接而成;
  2. 场景渲染范围通常也比较广(深度大);
  3. 场景数据需要从服务器下载得到,而由于场景是由小块组成的,因此离线光照烘焙基本用不了。如果再考虑到阴影的渲染管线的话,整个渲染管线的负担进一步加重;
  4. 物理模拟以及逻辑脚本系统会占用较多的CPU时间。

背景介绍:

R工作室很早就在Xbox 360上试验GPU驱动渲染管线的可行性了,最早是尝试通过可编程顶点fetch以及memexport方案来实现,不过由于硬件限制,当时的性能表现并不能达到要求。

之后Persson在Siggraph 2012上给出的Merge-Instancing技术方案(详情参考此前的这篇文章)进入了R工作室的视线,这个技术使用了Xbox 360的可编程顶点fetch技术在运行时通过vs对mesh数据进行合并处理。这个技术实现过程中不需要通过在内存中进行顶点或者索引数据的的拷贝来实现不同mesh的一次性绘制,而是通过在VS中模拟index buffer的工作流程来强制对每个三角面片执行三遍VS逻辑的方式实现的。在这个过程中不需要用到Post Vertex Cache(即post-transform cache ,指的是那些使用带索引的渲染API在执行的时候,会将一小批近期用到的顶点数据存储到cache中,从而提升后续渲染时访问数据的速度),因此性能上有一个非常大的提升。

ACU是第一代为新一代硬件而设计的《刺客信条》游戏,在这个游戏中,美术同学添加了大量的几何物件以实现对真实巴黎的模拟。

同时,ACU也是第一次尝试实现模型内部空间的无缝衔接(seamless interior spaces这个是啥,目的何在?推测是指内部面片结构无缝衔接,以达到高真实度的表现效果,通常会需要使用较多的面片来对细节进行填充)的游戏,这种做法使得需要处理的几何数据进一步上升。

此外,还有众多的角色模型,进一步加剧了渲染管线的压力。

为了能够创建一个如此巨大的游戏场景,巴黎场景的第一轮构建是通过半自动的方式实现的,整个过程使用了数百个可以复用的模型来创建大量的房屋模块(house blocks)。如果按照传统的一个模型占用一个DP的渲染方式,将会导致DP数超过五万,而即使使用实例化渲染技术,最终的DP也会高于一万五。

即使在主机上,CPU也是非常宝贵的资源,为了避免CPU称为渲染管线的瓶颈,这里给出的做法是为使用更为激进的合批策略,同时采用更为高效的剔除手段。而Mesh cluster rendering正好符合这个标准。

Mesh Cluster Rendering可以在加大剔除粒度的前提下同时得到更为激进的合批策略(??没看出这两者有什么矛盾)。由于在GPU中无法通过可编程方式获得顶点的索引数据,因此想要通过单个带实例的DP实现多个不同物件的渲染,就需要强制多个物体使用相同的拓扑结果(为什么使用相同的拓扑结构,就能实现单DP,多Mesh渲染?当每个instance的顶点数固定时,通过instance_id就能够拿到当前instance在VB中的起始地址,从而可以一次性取出所有顶点进行VS处理?)。ACU选择的是“Vertex Strip”拓扑结构:将所有的mesh数据分割成64个vertex strips组成的clusters(由于每个mesh的顶点数不同,组成这个mesh的clusters数目也有所区别,相同的是cluster的尺寸是恒定的),也就是62个三角形组成一个cluster。

除了这种拓扑结构之外,其他的比如32四边形(quad)等固定索引buffer的拓扑结构也是可用的。不管选择哪种拓扑结构,都是需要在其中添加退化三角形实现多个mesh part之间的连接,以及在每个mesh末尾添加退化三角形来补足最后一个cluster。

这个过程是在mesh编辑完成后进行的,可以看成mesh编辑后处理:从mesh数据构建出triangle strips,之后使用一个贪心算法构建一个局部clusters。由于在渲染的时候需要获取一个cluster的不同顶点数据,因此这里不能直接使用硬件自带的vertex fetch函数,而是需要通过vertex id & instance id手动读取全量数据。按照这种方式,我们可以通过一个DrawInstancedIndirect(在D3D11中,这个函数可以看成是DrawInstanced函数的重载版本,其作用是将某个instance绘制多遍,其中指定了起始vertex在VB中的Offset跟instance的Offset,如果这个接口真的能够完成对多个不同mesh的一次性绘制,那么即使每个instance使用不同尺寸的VB应该也是可以的吧?DrawInstancedIndirect有两个参数,一个是参数列表指针,第二个是参数偏移,通过调整参数偏移,可以实现对不同instance的绘制,因此在设置好VB之后,多次调用这个接口就能够实现不同mesh的instance绘制,不过这样就是多个DP了,跟描述貌似不太符合?这个接口应该是将多个不同mesh的数据统一到一个VB中,并且将这些带有instance的数据塞入到instance buffer中,之后调用一个DrawInstancedIndirect接口,在VS中完成对不同mesh cluster数据的读取与访问,实现多个mesh的一次性绘制) DP完成任意数目的mesh的绘制,DP参数以及cluster stream数据会通过GPU计算得到,计算过程会对每个cluster启用一次culling计算。


对于stripped渲染拓扑结构,每个strip cut(相当于告诉硬件当前strip已经结束,下面进入下一个strip)都会导致4个额外的冗余顶点与4个冗余面片,对内存占用,VS渲染消耗以及最大多边形吞吐量有影响。

ACU中将mesh数据分割成固定64个顶点的实例数据(每个cluster可以看成一个instance),这个过程通过贪心clustering算法完成,这种渲染架构对于顶点数据与instance数据获取的时间复杂度为O(1)。

算法Bonus:DX11的DrawInstancedIndirect接口会在每个instance结束的时候自动添加一个strip cut(这个是什么?可以堪称是一个用于结束绘制的overhead),这个过程是免费的。

在PC可编程管线中,无法通过vertex fetch获得顶点数据。ACU是通过SRV(Shader Resource View)来对顶点数据进行读取的:顶点数据按照SoA(Structure of Array,由多个数组作为成员组成的结构体,对应到VB上,就是将顶点的每个属性都单独抽取出来组成一个个的属性数组;与之相对应的是AoS,Array of Structure,由相同结构体组成的结构体数组,对应到VB上,就是将每个顶点的多个属性组成一个结构体,之后用这种结构体数组表示VB)方式排布。这种做法可以降低GPU延迟(为啥?对于不同顶点的同一个属性的读取,其速度更快)在GCN(这个是啥?Graphics Core Next,是AMD为其GPU所开发的微结构microarchitecture的代号,也指与之对应的指令集)上比硬件Vertex Buffer读取数据表现更好。

这些特点不但可以用于实现更为激进的合批方案,而且还可以用于对GPU Frustum/Occlusion Bulling效果进行精细调整。

这里不会对GPU Occlusion Culling的实现细节进行深入介绍,不过会给出一些参考文献供大家查阅。

另外,culling方案调整程度越精细,不但可以剔除更多的顶点数据,而且还能进一步降低overdraw(如右图所示),此外对于美术同学的工作也有所帮助:他们不再需要对于物件尺寸做精心规划以得到更为有效的裁剪结果。

测试显示,手动vertex fetch功能比自动vertex fetch功能还要快一点,此外,这种方法还可以用于对cluster depth进行排序,从而起到类似depth pre-pass一样的降低overdraw的作用。

Stripped triangle instance渲染方法+mesh clustering 渲染方法在所有平台上的表现都还不错。不过ACU在上线之前还是选择切换到另一种渲染方法,原因是为了降低由于退化三角形导致的内存消耗以及修复由于无法调整cluster的渲染顺序(non-deterministic cluster order)而导致的深度竞争问题(这些问题通常是由于building模块对齐关系处理得不够好导致)。

使用比如手动index buffer数据获取或者cluster depth排序方法可以解决这些问题,不过ACU选择的是一个类似的mesh cluster渲染方法,即使用多个DrawIndexedInstancedIndirect接口来完成绘制(这个接口跟之前的DrawInstancedIndirect接口大同小异,区别在于不再使用strip拓扑结构,加上了索引数据来降低顶点数据的空间占用)。在这种方法中,会借助传统的顶点缓存优化策略来对mesh数据进行优化,之后将mesh分割成统一的64个triangle的cluster(如果使用索引方法,就没有办法通过instance_id + vertex_id来得到顶点在vertex buffer中的位置,反之亦然,此时还有必要保证每个cluster的triangle数目恒定在64上有什么意义呢?难道仅仅是为了指定一个恒定的cluster尺寸吗?方便实现cluster的拆分与深度排序)。

这是整个渲染管线的概览,在CPU侧,依然需要进行粗糙的frustum culling,之后将所有未被剔除的物件按照材质进行合批处理。在GPU侧,对每个instance进行frustum/occlusion culling处理。在对cluster按照frustum/occlusion depth进行cull之前还需要进行一个cluster expansion处理(干了啥,目的何在?)。部分backfacing面片在index buffer compaction过程中会被剔除掉。完成所有可见性测试之后的输出数据被用作multi-drawcall处理阶段的输入。此外,可形变物体不需要经过上述管线的cluster相关步骤,直接跳过即可。

正如之前所说,在CPU上,需要进行简单的quad tree culling。之后对每个动态实例物件进行数据更新,如transform等数据的更新,更新过程在GPU Ring Buffer(Ring buffer是GPU Command的索引buffer,存储了每个GPU Command在Command buffer中的位置与长度)进行,并为所有无法通过GPU Instancing绘制的物体构建一个hash值,之后基于此hash值对DP进行合并处理,并为GPU渲染构建所需要的instance stream。


四叉树中物体的粒度会随着数据而变化:

1.房屋

2.大型物件

3.部分特别的物体

4.部分细小的动态物件(比如角色)

DP会在合并之前按照距离进行排序。虽然通过通过boundingbox来进行排序得到的并不是完美的深度顺序结果,但是相对于合并后排序,其效果已经好很多了。

由于传统技术的限制,ACU的渲染管线距离完美的CPU目标还有很长的距离。在CPU上依然需要对物件(即使是静态的)进行处理,且只能对使用相同材质的物件进行实例绘制。

Instance stream包含了各个instance在GPU-buffer中的offset列表,从而使得GPU获取如transform,instance bounds等数据。

GPU会使用这些数据进行instance层面的frustum/Occlusion culling。对于所有通过culling test的instance,会生成一个cluster chunks列表。

这里ACU使用中间过程的cluster chunk expansion(这个是啥意思?将mesh数据拆分成多个cluster吗?)而非直接的cluster expansion,这是因为每个mesh的clusters数目是可变的(1~1000)。直接的cluster export在同一个wavefront的不同GPU线程中可能会非常的不平衡(会有什么结果呢?GPU算力浪费)。而每个cluster chunk却最多对应于64个clusters。

之后的cluster culling过程会使用instance transform&bounds数据来对cluster进行frustum/occlusion culling处理。对于每个cluster,ACU还会获取一个View Dependent Triangle Mask用于进行烘焙前的backface culling。通过culling的clusters会输出一个index compaction job,用于构建绘制所需要的index buffer。index compaction job输出数据包含了triangle mask以及索引读写offsets。这些offsets可以通过相关的instance面片数目计算得到。

对所有通过剔除测试的cluster进行index compaction处理,并将结果写入到一个动态index buffer中。

这个index buffer是在CPU上分配的,因此需要为每个instance mesh按照全量的未被剔除前的索引数据分配所需要的空间。由于index buffer尺寸比较小(8mb,为什么比较小,8mb怎么得到的?),因此一个render pass的数据可能无法全部塞入到buffer中,而需要分成多个render passes,因此索引buffer compaction与multi-draw rendering操作会交叉进行。

此时index compaction会将被cluster culling & backface culling的triangle移除掉。在每个index compaction计算任务中,每个wavefront会处理一个cluster,各个wavefront之间相互独立,每个线程处理一个triangle,且线程之间也是相互独立的。基于cluster culling输出input/output offsets以及triangle mask数据,每个线程会独立计算输出数据在动态index buffer的位置(write position)并拷贝3个triangle 索引(每个triangle包含三个顶点,对应三个index)。这一步需要占据5%~10%的渲染时间。

之后对每个批次调用MultiDrawIndexInstancedIndirect 接口来进行group rendering,group rendering的DP则是在cluster culling阶段通过原子操作生成的。

前面说过,在cluster culling时,会需要取到一个view dependent的面片mask用于计算每个cluster内部的可见面片数目。这里是通过将cluster内部面片的可见性数据烘焙到一张cubemap中,cubemap的每个像素数值对应于一个相机位置的可见性结果。比如相机处于图中黄色像素所对应的frustum区域内,那么绿色面片的可见性就可以根据相机与cluster的相对位置直接从预计算cubemap中读取出来,用这种做法可以一次性拿到cluster中所有64个面片的可见性结果,这个结果是一个bit mask,之后根据面片索引就能得到对应面片的可见性结果。

换个视角来解释这个方法,这个box是cluster的boundingbox,box中带有箭头的线段表示的是cluster中的面片与朝向。右边包裹住相机的绿色区域表示的是黄色像素对应的frustum。每个面片是否可见可以简单的转换为面片的正向半空间(half space)跟当前像素对应的frustum是否存在交集。如果相机本身位于box内部,那么这个时候会直接将所有面片的可见性设置为可见。

ACU出于对内存的考虑,只使用了六个像素来表示cubemap,即每个face只用一个像素表示,这种做法会导致culling精度有所下降,虽然通过增加像素frustum深度范围可以对提升一点精度,但是提升非常有限,且还可能会导致一些不正确的剔除结果(比如本来是不可见的面片随着frustum深度范围的增加变成了可见(如上账图片中下面的面片一样))。总的来说,这种做法可以剔除10%~30%的面片。

要想实现高效的GPU遮挡剔除,就需要一个较好的遮挡深度数据(occlusion depth)。这是ACU中的一个经典场景,下面将以此为例给出ACU是如何生成不同类型的遮挡深度的。

ACU给出的第一种遮挡深度生成算法,是使用前n个最佳遮挡物(面积够大,距离够近)完成一个深度渲染pass(depth pre-pass)。depth渲染结果不但可以用作GPU culling,还可以作为early-z用于实际的normal渲染pass。这种做法可以降低由于合批或者排序问题导致的overdraw。这里生成的depth会被下采样到512x256分辨率,并与上一帧normal渲染pass输出的depth buffer经过reprojection映射到本帧位置的结果相结合来给出最终的depth输出。

最佳遮挡物的选择是基于bounding volume以及美术同学预先设置的标记来进行的。在这些符合条件的遮挡物中,距离相机最近的300个遮挡物会被选择用于进行depth绘制,(也可以考虑面片数少的,以及覆盖屏幕范围大的)。

ACU这边尝试了多种选择策略(比如考虑屏幕空间投影面积等),最终考虑到时间消耗,选择了最为简单的距离判定方法。

在这个pass中,不会进行遮挡剔除,虽然确实可以使用上一帧depth buffer reprojection后的结果来进行遮挡筛选。

这里给出一种填充此前Occlusion depth pre-pass中输出的depth贴图中的孔洞的简易方法。孔洞的产生可能是由于物件不符合作为遮挡物的条件,也可以是选择的遮挡物效果比较差,也可能是由于alpha test物体导致。

当相机静止不动时,使用上一帧的depth buffer结果可以很好的填充这些孔洞。但当相机向着屏幕边缘移动时或者由于视差原因(相机旋转导致),孔洞的填充就比较难办了。

如果是大型的动态物体,上一帧的深度数据也会被污染,为了避开这种错误的depth数据,ACU这边会在处理reprojection时拒绝掉那些距离相机过近的物体(近才会导致在depth buffer中占据足够多的像素,错误就会很明显?)。

此外,ACU还为每级Shadow Map生成了一个64x64的低分辨率遮挡深度贴图。

第一步,通过reprojection操作,将上一帧的depth buffer数据投射到光源空间,来得到由阴影接收者组成的occlusion depth数据,后面会给出更详细的介绍。

这个occlusion depth结果接下来会跟上一帧生成的shadow map经过reprojection后的数据结合起来,同样的,这个过程会由于大型的可移动物体而导致错误的occlusion数据。

由于fog的计算需要用到下采样的指数shadow map,因此上一帧的下采样阴影贴图是可以直接拿到的,不需要额外的计算。

通过上一帧的depth buffer reprojection得到的shadow map occlusion,目的是为了剔除那些不会有阴影接收者的阴影投影物体的绘制。在这个示例场景中,院子里的所有处于角色前面(这里指的是靠近光源方向)的阴影投射物体(剔除左侧的大建筑物)都不会对阴影贴图的输出有任何贡献,因为整个院子都被前面的建筑物所遮挡住了。

这里给出一个示意图。

黄色箭头表示的是太阳光方向。红色的线段给出的是一级shadow map的覆盖区域。

红色方块则是找不到接收阴影物体的阴影投射物体(因为这些物件对应的阴影接收物体被地表或者其他物件所遮挡。)

亮黄色区域标出的是foreground物件所创建的地平线以下部分,处于这个区域中的物体都是不可见的(即都是不需要用于绘制阴影的),因此这些物体在shadow map渲染中都可以剔除掉。

黄色区域上方的红线在光源空间中的depth数据就是我们这里拿来进行occlusion culling的数据了。

如果对于相机空间depth buffer中的每个像素,都在这个像素对应的深度处绘制一个cube,之后从近平面向着这个立方体所在的深度延伸,就得到了这些cubes。

可以看到,图中的绿色线条就是这些cube在光源空间中的最大深度对应的位置,而这些位置与前面给出的黄色区域上方的红色线条是一致的。很显然,如果真的为每个像素绘制一个cube,那么消耗会非常高,ACU这边的做法是为每16x16个像素组成的tile绘制一个cube,之后使用每个tile中的最大深度用作遮挡剔除的depth,这样做的结果就是绿线所表示的结果相对于此前红线表示的结果会更为保守(从位置上来看,绿线会比红色更为往下)。

另外,在将这些cube绘制到光源空间中时,需要注意修正cube的尺寸以保证绘制的结果不小于一个shadow map像素,且需要考虑shadow map的最大filter尺寸。

将相机空间的depth数据通过reprojection投影到光源空间。

ACU这里使用的方法跟[Silvennoinen2012]中的方法是非常相似的,不同的是原文中使用的depth mask,而ACU使用的是depth buffer,这是因为ACU中计算光源空间frontface的是box tile中的远距面而非近距面,如果使用mask的话,得到的精度会低很多。

由于体积雾使用的是exponential shadow map,因此必须要考虑远距离阴影投射物体的滤除作用(为什么ESM需要考虑这个,标准SM不用吗?)。ACU在生成exponential shadow map的时候,会通过相机空间的深度经过reprojected 后的数据来填充由于一些次要的阴影投射物体被culling掉(不能进行全量shadow map绘制,成本太高)导致shadow map上的孔洞。之所以要这样做,是因为使用地平线来代替光源空间的远平面来对阴影投射物体进行剔除的效果要好得多,且光源空间中某个区域的阴影投射物体距离光源越远,这个效果就越好。

这里给出ACU实施管线的结果,基本达成目标;GPU可能还需要优化下异步处理逻辑,增强GPU处理的并行性。

后面会考虑通过bindless texture进一步降低DP(为什么可以降低?),这样做除了可以降低CPU使用率,同时还有助于降低由于合批mesh在距离上的顺序并不严格所导致的GPU overdraw,当DP数较少的时候,渲染的顺序就会再次接近于按照物体box中心点到相机的距离来排序时的表现(推测这里的渲染指的是cluster的渲染),也就是说,当DP数较低的时候,可以按照cluster到相机的距离来排序了(DP数多的时候不可以吗?)

由于DX12&Vulkan极大的降低了DP的消耗,因此GPU渲染管线(如这里ACU介绍的实现算法)的优势将被抑制。而这种GPU渲染管线在DX12&Vulkan下是否能够生效还取决于所用的数据结构与算法。后面部分将会给出一些可能的前进方向。


GPU驱动渲染管线在DX12上的优势:

1.在DX12中,尤其是PC上,CPU想要获取到GPU的depth buffer等资源,依然具有较高的延迟-à因此不能根据可见像素的数据来对shadow进行剔除

2.CPU在数目超过100k的sub-object层面的剔除处理效率依然很低

3.GPU驱动的剔除算法更为高效。

- 参考: Modified Intel DirectX 12 asteroids demo with ExecuteIndirect. Runs faster on Intel GPU and has much lower CPU usage.

在后面加强异步计算逻辑的控制之后,ACU这边希望能够将一些GPU驱动的管线中的非渲染的部分移动到异步计算逻辑中以移除由于众多的细小异步计算任务所导致的pipeline bubbles。

下面要介绍的是RedLynx工作室(下面简称R工作室)的GPU驱动渲染管线,核心技术跟前面介绍过的Montreal的差不多,因此接下来将着重介绍两者之间的区别。

首先要介绍的是Virtual Texture(以下简称VT)。这种技术方案能够跟GPU驱动的渲染管线完美结合起来,从而可以极大的降低DP数目。

Deferred Texturing不是一个新的想法,不过当将之与VT以及GPU驱动的渲染管线相结合时,将焕发出新的生机。后面将会介绍着三种技术方案结合的实施细节以及R工作室的G-buffer layout,以及后面称之为MSAA Trick的优化方案。

R工作室的遮挡剔除方案跟AC此前的方案有所不同,这种新的方案称之为Unity剔除系统。需要注意的是,R工作室的核心工作大多跟UGC(user generated content) 有关,因此会需要着重考虑多种不同技术方案的适配与筛选。此外,R工作的阴影贴图管线也跟前面不一样,其主要原理跟VT系统类似。

R工作室使用VT方案已经有5年了,到目前为止已经有两款成功的产品应用了这项技术:,Trials Evolution and Trials Fusion

其中的核心思想是指将可见的贴图数据维持在内存中,主要通过将贴图拆分成不同的小块(tile)来实现,这些小块称之为page。

Page ID会提供Page所需要的精确信息以及每个像素所需要的mip级别,ID数据会被加载到一个常数尺寸的Cache中(按照LRU算法进行更新置换)

R工作室的实现是基于256k^2虚拟地址空间实现的,所有的贴图数据都会被适配到这个atlas中,这个atlas会被分割成128x128分辨率的page。

在运行时,有一张8k的贴图atlas(7680×4320?)用于存储当前需要驻守在内存中的page数据。整个贴图cache一共包含了5个数组元素(array slice)用于存储所有需要的材质属性:基色,高光,粗糙度,法线等,贴图cache采用的是DXT压缩。

R工作室跟Montreal工作室的GPU驱动渲染管线的最大区别就在于VT的使用。

VT跟GPU驱动渲染管线能够实现完美契合,可以通过单张贴图binding来实现所有的可见贴图数据的访问。

对于GPU驱动的渲染管线而言,这项特性非常重要,因为可以只用一个DP就允许GPU从任意张数的贴图中读取数据。也就是说,通过这项技术,物件的渲染就不需要进行合批了。

前面说过,GPU驱动渲染管线会一次性取得所有的mesh数据,而通过VT技术,则可以一次性取得所有的贴图数据,也就是说,只用一个DP就可以完成全场景物件的绘制。

通过shader分支,可以实现不同的顶点动画效果,在现代GPU上,shader分支的执行效率很高,通过测试,使用shader分支实现三种不同的复杂动画类型(包括蒙皮动画)只有不超过2%的额外消耗。

使用一个DP绘制所有物体的做法有很多优点:比如说可以将整个场景的物件cluster按照深度来排序,之后以cluster为基本单元按照从前往后的顺序进行绘制。这种做法可以提供类似于early-z(depth prepass)的overdraw优化效果,且不需要一个额外的渲染pass

相对于同类解决方案比如说bindless textures,VT还有一些其他的优点:

1.可以将复杂材质混合的结果以及贴花渲染结果存储到VT atlas中

2.Atlas Cache可以将高带宽消耗的操作均摊到多帧完成

(这些优点对于场景编辑的同学来说是非常有帮助的,可以使用较低的消耗来得到非常丰富的表现效果。)

3.使用VT技术,可以不管实际上有多少张贴图,最终绘制所需要的贴图在内存中的尺寸是恒定不变的:美术同学就不需要关注贴图内存预算,将精力专注在效果上。

Deferred Texturing并不是一项新技术,最早可以追溯到2007年的一篇论坛文章. 不过在此之前,貌似并没有哪款游戏有使用过这项技术,其原因可能是无法实现高效而鲁棒的贴图像素数据的存储与读取。

Nathan Reed上一年的博客对此问题给出了一个解决方案,不过假设只考虑一些重要的特性比如说各向异性采样的话,这个方案会将G-Buffer的存储数据量增加到144bits,成本还是有点高。

而这里的GPU渲染管线的一项重要特性就是,所有可能可见的贴图数据都被加载到一张8k的贴图atlas中了。这张atlas对应的UV坐标足以实现对任意可见的像素的读写。这张atlas的dimension尺寸相对于巨大的256k virtual texture而言,可以算是非常小的,从而可以只使用16+16bit的UV坐标就能实现对任意像素的访问,在这个情况下,texture filtering可以达到8x8的subpixel精度,而这个数值实际上已经可以得到非常不错的显示质量了。

场景的渲染,只有UV以及depth数据是不够的,还需要考虑各向异性采样的梯度数据以及光照计算所需要的tangent数据

梯度数据会占用不小的空间,因此这里的做法不存储这项数据。幸运的是,由于G-Buffer中包含了UV坐标数据,因此可以在屏幕空间中完成对梯度的计算。对于连续的表面而言,这种计算方法得到的效果是没什么问题的,不过对于那些在深度上不连续的情况表现就不太好了。为了解决这个问题,这里给出的方案是,比较从正负xy轴上的相邻像素数据,并取其中UV坐标差异小的一个作为输出,在这个计算原则上,相同表面的相邻像素可以得到较高的优先级。

如果选择的相邻像素的UV距离超过了设定的N像素阈值(其中N指的是各向异性采样的等级),就认为此次搜索失效。在这种情况下,会将算法回退到双边线性滤波。实际上这种情况在几何物体比较纤细的时候发生的频率还挺高的,不过从实际表现上来看,并没有构成很大问题,可能是因为双边线性滤波导致subpixel异常在滤波的过程中被模糊掉了。

光照计算所需的tangent数据会以32位归一化的四元数(quaternion)的形式存储,其中2位的alpha通道用于存储主轴索引(major axis index)。VT技术可以免费让我们得到每个page的属性数据,比如mip等级,材质ID以及colorize color,这些数据不需要存储到G-Buffer中。而Page索引可以通过UV除上128来得到。

总结:

每个像素64bits,在现代GPU上比如GCN可以得到Full fill rate,不需要使用MRT(multiple RT)

Deferred texturing技术跟VT技术可以很好的结合起来,UV buffer数据可以当成page ID来使用。如果部分像素的梯度向量长度低于0.5,就表示此时需要加载更高更清晰的贴图page了。

这里是实施方案的一些截图,可以看到梯度重建质量跟ground truth非常接近了。

这里还为deferred texturing+VT结合的方案(命名为virtual deferred texturing,简称VDT)做了一些优化工作,这些优化统称为MSAA trick。

这个trick是基于以下观察结果得到的:不管是UV坐标,还是tangent数据,都是可以通过顶点数据插值得到,且这种插值是无损的。因此可以将G-buffer数据用一个低分辨率存储下来,之后在使用的时候对缺失的数据进行插值求取即可。

使用MSAA绘制一个2x2的低分辨率结果,之后使用GCN可编程采样pattern来构建一个有序的网格,这个网格正好与高分辨率的结果贴图的像素中心相匹配。

在进行光照计算的时候,会通过multisample加载指令加载G-Buffer中任意的MSAA样本数据

在lighting计算compute shader开始之前,会构建一张1080p的G-buffer贴图,并使用快速的LDS(这个是啥?Local Data Store)来存储这张临时贴图。

MSAA会通过硬件完成PS计算,被多个三角面片覆盖的像素,其结果会被存储多次,这种做法是有必要的,因为可以保证三角形edge两边的sample frequency数据是有效的,从而可以按照屏幕分辨率(native resolution)实现对edge的重建。

由于MSAA已经可以兼顾triangle edge效果了,这里只需要完成三角形内部的插值计算就可以实现1080p的UV坐标以及tangent数据的重建。不过由于插值并不是perspective correct,因此相对于直接按照屏幕分辨率渲染的结果而言,可能会有一些轻微的差异。

Benchmark使用的是128bits/pixel的G-buffer格式,并将四个2xMSAA像素编码成一个8xMSAA像素,其最终质量与2xMSAA比较接近。

PS waves的数量削减达到一半,而渲染时间消耗削减达到30%,此外DRAM访问量也有所削减,这是因为这里给出的算法可以将访问最多的MSAA subsample plane数据迁移到ESRAM中(不太明白是啥意思,硬件改动吗?)。

接下来介绍下R工作室的遮挡剔除实现技术,其方案跟ACU的有所不同。

最大的区别在于,R工作室的遮挡剔除方案不需要一个额外的遮挡物体pass(occlusion geometry pass),这是因为R工作室这边需要逐像素精确的遮挡结果,因此粗糙的遮挡剔除结果并没有什么意义。

R工作室这边的项目通常没有比较大的结构性物体,场景中的大物体通常都是玩家用小物体搭建出来的,比如说一堵墙可能是由若干个细小物体搭建而成,中间可能会包括若干孔洞,如果使用一些低模物体来构建遮挡几何体的话,最终的表现可能比较差,且玩家也无法理解为什么会这样。

因此R工作室的遮挡剔除数据是基于G-Buffer的深度数据计算得到的,从深度buffer生成一个深度金字塔(pyramid),按照GCN HTILE min/max深度buffer方式生成,这种做法可以使得金字塔的生成算法比普通的全分辨率递归下采样算法快12倍。

而遮挡剔除测试则是使用一个gather4采样指令一次性获得四个采样结果的方法进行的。

整个culling跟rendering可以分成两个阶段:

第一个阶段使用上一帧创建的深度金字塔数据实现视椎剔除与遮挡剔除,这个阶段可以看成是一个occlusion hint(什么意思?可以看成是一轮预筛选)。对于通过这个检测的物体,会以cluster作为基本粒度进行视椎,back-face以及遮挡测试,并将cluster ids输出到渲染所需要的buffer中。

在第二个阶段,会先使用最新绘制完成的partial frame(是部分物体深度结果吗)对深度金字塔进行更新。对于所有通过其他测试但是没通过遮挡测试的物体以及cluster,会再进行一次遮挡测试,将那些之前检测错误的物体添加到渲染列表中(为啥不一步到位,直接在这里做遮挡检测呢?因为这里遮挡检测的遮挡物来源于当前帧已经绘制的物体,因此需要先绘制一遍,而绘制的数据来源于第一阶段的检测结果)。

如果不使用GPU驱动的渲染管线的话,这种剔除方法是很难做到的,因为需要在之前的步骤中以低时延拿到GPU生成的数据,如果中间夹杂了CPU的数据访问就会严重阻塞GPU的工作流。

为了测试剔除效果,这里做了一个压力测试,在整个场景中渲染了二十五万个移动物体,对于现存的粗糙遮挡剔除系统而言,这些分散的大量物体绝对是个噩梦。

这个Bencmark使用的是DX渲染路径,每个cluster使用64个顶点组成。对于这么高面片场景而言,使用MultiDrawIndirect 接口可能没什么收益。

在光照shader中,物体的贴图用VT来实现,整个场景的绘制只需要两个DP。

从结果上可以看到,GPU驱动的剔除以及setup过程消耗很低,加起来不超过0.5ms。需要注意的是,这些时间并不是损耗了的GPU时间,以cluster为粒度的剔除算法在G-buffer步骤中省下来的时间足以弥补GPU驱动的管线的所有消耗。

在R工作室此前的游戏比如Trials Fusion中,这里列举的这些工作会占据大概50%的CPU时间,而现在只需要单核GPU 0.2ms的时间就能完成,因此用这种方法可以腾出CPU给游戏逻辑,物理仿真,破坏等,以创建更丰富生动的游戏表现。

对于阴影渲染,R工作室这边选取的方法跟此前Montreal给出的方法有所不同。

2001年的时候,Fernando给出了一种称之为Adaptive Shadow Maps的实现技术这种技术将阴影贴图分割成细小的tiles,之后根据距离的不同为每个tile选定不同的分辨率

这种技术的问题在于,需要在渲染的过程中将深度数据从GPU传送到CPU,在CPU中,会对深度数据进行分析,并更新culling structures,之后据此进行shadow rendering,这个过程会极大的阻塞工作流。

而如果使用GPU驱动的剔除算法,就可以借助类似于VT的virtual shadow mapping方法解决这个问题,因为在这个过程中将不再需要CPU的参与,且能够移除shadow map tile page边缘上的顶点处理消耗。DX12允许在VS中访问RT array index,从而可以不使用geometry shader,也可以一次性渲染所有的shadow pages。

Virtual Shadow Mapping方案输出的贴图结果具有最小数量的under- & oversampling,Shadow map的分辨率将在任意区域都能够与屏幕分辨率实现最佳匹配。

最终的性能表现令人非常满意,尤其是在复杂场景中。在之前给出的25万面片的超复杂场景中,测试的结果显示相对于SDSM(标准shadow map方法)而言,这种方法具有3.5倍的优势(怎么定义的)。

至于后面更进一步的优化,比如XOR hashing页面的数据重用等,还能够进一步提升此方案的运行速度。

DX12提供了很多新的特性,而其他的API在PC上也有一些重要的特性,除此之外,相对于下一代主机,还有一些特性是DX以及其他PC API没有提供的,不过在DX 12_3中将会增加这些特性。

你可能感兴趣的:(【Siggraph 2015】GPU-Driven Rendering Pipelines)