秋日偶成 - 北宋 程颢
闲来无事不从容,睡觉东窗日已红。
万物静观皆自得,四时佳兴与人同。
道通天地有形外,思入风云变态中。
富贵不淫贫贱乐,男儿到此是豪雄。
本文是对文献[1]中关于Visibility Buffer渲染管线技术方案的学习与翻译,原文是在[2]的基础上实现与优化的新一代延迟管线,相对于此前的延迟渲染管线,具有众多的优点,原文作者Wolfgang Engel是图形学界的资深大牛,GPU Pro系列与ShaderX系列的主创与主编,曾担任过R星的引擎主程,下面我们一起来看一下这项技术的具体详情。
1. 摘要
Triangle Visibility Buffer是原文作者所在公司从2015年就开始的预研技术,而原文则是对这个项目当前状态的追踪与同步。
之所以叫Triangle Visibility Buffer,是因为这个技术会尝试在整条图形渲染管线中对面片的状态进行追踪,并将场景中每个不透明面片的可见性存储在一个buffer中。
这项技术对于那些具有较小高速Memory但是却想实现超高分辨率(如4k)渲染的应用场景,这项技术开放了源代码,这里给出github链接,在链接中给出了多个平台的实现源码(包括一些主机平台的),不过出于阐述方便,后面的所有内容都是以DX12 API为基础展开的,文章的阐述顺序与渲染管线的stage时间顺序保持一致。
2. 多视角下的面片裁剪与剔除策略
游戏中场景面片数逐年递增,按照这个趋势走,硬件在Command Processor上可能会存在瓶颈,如VS中的顶点变换、back face culling、clipping以及光栅化处理中的大量小尺寸面片等,都会导致相应的性能瓶颈。
解决这类问题的第一步,就是要在渲染管线最前面加上一个triangle removal stage,这个概念至少有十年以上的历史了,不过由于最近游戏场景的复杂化,因此在[Chajdas][Wihlidal]的技术talk中又恢复了新的生命力。
原文demo中用于对triangle进行removal的主要有如下几项技术:
- Cluster Culling,以256个具有相同朝向(similar orientation)面片作为一个cluster,在CPU上对这些cluster进行剔除处理
- Triangle Filtering,调用Compute Shader对Cluster中的单个面片进行异步剔除处理
- Draw Call Compaction,将那些剔除之后没有任何面片的Draw Call移除,并在Compute Shader中对剩下的Draw Call按照(此前的)顺序进行重排。
2.1 CPU上的面片Cluster剔除逻辑
前面说过,Triangle cluster culling是在CPU上进行的,其目的是剔除那些整个cluster都是backface render的cluster,因此当cluster上的多个triangle都具有相近的朝向的时候会有比较好的效果。具体的做法是先根据cluster的数据(通常是面片法线?)计算出一个visibility test cone(如上图中的蓝色区域),如果相机处在这个cone中,那么这个cluster就是可以按照backface cull掉的,如上图所示,面片与法线分别用黄色线段与有向线段表,而visibility test cone则使用浅蓝色的三角形来表示。
visibility test cone包含两个核心点,第一个是cone的顶点,这里我们用中心点来表示,如上图所示,这里是通过对各个面片上的法线进行反向累加来计算cone的中心点(可能有些不精确,不过结果应该是保守的,即按照这种策略计算出来的cone应该是能够完成正确的剔除,凡是被剔除的确实是不可见的,但并不是所有不可见的cluster都被剔除了)。
cone的第二个核心点是其张角(open angle),如上图所示,这里的张角计算选用的是cluster上任意两个triangle中法线的最小夹角,因为只有取最小夹角才能保证落在这个夹角中的相机对cluster上所有的triangle都是看不到的。
从直观上来看,这种方法剔除的效率应该不会很高,原文也说了,只有当cluster上的所有面片朝向十分一致时才能得到不错的效果,而实际情况中的面片方向很难做到一致,因此效率也就无法保证了,且代码中这个特性(放在Visibility_Buffer.cpp中)也是默认关闭的。
2.2 GPU上的面片Filtering逻辑
第二个优化处理是对cluster中的每个面片启用一个Compute Shader的Thread,进行如下的计算处理:
- 面片是否退化(Degenerate)
- 面片是否可以被Backface Cull
- 面片是否比近平面更近(近平面裁剪)
- 面片是否可以被frustum cull
- 面片面积是否小于一个像素等
通过上述处理的面片会被加入到一个叫做Filtered Index Buffer的IB结构中,这段逻辑的代码在 triangle_filtering.comp.fsl文件中可以找到。
2.3.1 退化以及背面面片处理
Back-face Culling使用的是[Olano]给出的计算方法(Triangle Scan Conversion using 2D Homogeneous Coordinates):计算三角形的三个顶点所组成的3x3的clip-space(裁剪空间,对应于裁剪坐标系,指的是相机坐标系经过投影矩阵转换后得到的空间坐标系,之所以叫裁剪坐标系,是因为投影矩阵约定了视角的上下左右前后边界(对应的是相机的Frustum范围),后面会将处于边界之外的数据直接Clip到边界上。)矩阵的行列式(determinant,按照Olano文中的说法,在clip space中的面片的行列式等于此面片在屏幕空间的有符号面积的两倍),如果行列式=0说明这个面片是退化三角形,或者这个面片在相机视角下坍缩成一条线;而在右手坐标系下,行列式>0表示这个面片是front face的,而在左手坐标系下,行列式>0则表明这个面片是back face的。
为什么不使用面片的法线与观察方向的点积来判断呢,因为顶点数据中不一定有法线属性,即使有法线属性也是顶点的法线而非面片的法线,如果想要得到面片法线需要通过两条边向量叉乘得到,这个计算过程比行列式计算应该要消耗更高一些。
#if ENABLE_CULL_BACKFACE
// Culling in homogenous coordinates
// Read: "Triangle Scan Conversion using 2D Homogeneous Coordinates"
// by Marc Olano, Trey Greer
// http://www.cs.unc.edu/~olano/papers/2dh-tri/2dh-tri.pdf
float3x3 m = float3x3(vertices[0].xyw, vertices[1].xyw, vertices[2].xyw);
if (cullBackFace)
cull = cull || (determinant(m) >= 0);
#endif
上面是实现伪代码,启用了backface bulling后,大概可以消去50%的面片数目。
2.3.2 近平面裁剪
近平面裁剪只需要判断对应顶点的w分量是否小于0即可,如果三个顶点都超出了近平面,那么这个面片就可以被剔除了,而如果只是其中某个或者某两个顶点超出了近平面,那么就直接将w分量翻转从而避免横跨在近平面两侧的面片经过投影后不会分布在屏幕(近平面)的两面上(因为翻转之后,其投影结果跟翻转之前的投影效果完全一致,这里没有对超出近平面的部分面片进行拆分处理)。
for (uint i = 0; i < 3; i++)
{
if (vertices[i].w < 0)
{
++verticesInFrontOfNearPlane;
// Flip the w so that any triangle that straddles the plane
// won't be projected onto two sides of the screen
vertices[i].w *= (-1.0);
}
…
}
//If all three vertices of the triangle are in front of the near clipping
// plane, the triangle gets culled:
if (verticesInFrontOfNearPlane == 3)
return true;
2.3.3 视锥裁剪
Demo中提供了将Frustum Culling的结果绘制出来的功能(将相机Frustum的结果保存下来,更换另一个相机视角将之绘制出来),其中Frustum Culling是通过将frustum中的数据转换到[0, 1]范围来进行的,这样做的好处是,可以直接通过判断对面片变换后的坐标是否处于[0, 1]范围内来确定对应顶点是否可见。
...
vertices[i].xy /= vertices[i].w * 2;
vertices[i].xy += float2(0.5, 0.5);
...
float minx = min(min(vertices[0].x, vertices[1].x), vertices[2].x);
float miny = min(min(vertices[0].y, vertices[1].y), vertices[2].y);
float maxx = max(max(vertices[0].x, vertices[1].x), vertices[2].x);
float maxy = max(max(vertices[0].y, vertices[1].y), vertices[2].y);
if ((maxx < 0) || (maxy < 0) || (minx > 1) || (miny > 1))
return true;
...
2.3.4 小面片剔除
如上图所示,如果经过变换后,triangle没有覆盖任何一个像素的中心或者sample点,那么就认为这个triangle是小面片。虽然小面片在效果上看不到,但是rasterizer还是要执行跟其他面片一样的处理流程,由于GPU中每个cycle只能处理一个tile中的一个面片(如上图所示,一个细长的三角面片跨越了多个tile,那么就需要多个cycle),因此即使是不可见的面片可能也会导致较高的消耗。
2.3.5 多视角面片Removal
面片剔除过程包含数据加载(index buffer,vertex buffer,transform data),剔除计算以及将未剔除的数据塞入到filtered index buffer中。整个过程中数据加载时间消耗可能比剔除计算更高。
如果场景面数十分之多,且随着时间不断增加,那么即使GPU的性能也在高速发展,因为上述消耗的存在,最终的收益可能也不会太高。
降低这些消耗的一种方法是通过一次性的处理将所有相关的view(main camera view, a shadow map view, reflective shadow map view, etc)中的细小面片都移除,但是这样做的话,就需要将在任一view中的所有可见的面片都纳入考量(毕竟至少有一个view是可见的),从而导致一次性需要处理的面片数过多,造成这种算法可用性的下降(当然,一次性处理的时间消耗相比于分开多个view进行多次处理的时间消耗自然是降低了)。
对多个view执行视锥剔除的代码放在 triangle_filtering.comp.fsl文件中,主要是通过FilterTriangle()函数完成:
for (uint i = 0; i < NUM_CULLING_VIEWPORTS; ++i)
{
float4x4 worldViewProjection = uniforms.transform[i].mvp;
float4 vertices[3] =
{
mul(worldViewProjection, vert[0]),
mul(worldViewProjection, vert[1]),
mul(worldViewProjection, vert[2])
};
CullingViewPort viewport = uniforms.cullingViewports[i];
cull[i] = FilterTriangle(indices, vertices, !twoSided, viewport.windowSize, viewport.sampleCount);
if (!cull[i])
InterlockedAdd(workGroupIndexCount[i], 3, threadOutputSlot[i]);
}
2.3.6 多视角面片Removal结果
Git仓库Demo中国的San Miguel场景总共有大约8百万个面片,在默认视角下,经过了前面的 multi-view triangle removal操作之后, shadow map view下的面片数为1.843 million,而main view下的面片数为2.321 million。
Triangle removal的思想目前已经成为一种普遍认知,在下一代渲染方案中,基本上都添加了这一项处理逻辑。
2.4 Draw Call Compaction
前面进行Triangle Filtering是按照256个面片作为一个batch进行的,而经过这个过程后,每个batch中的面片数可能就不再是256了,比如:
- Batch0 - start index: 0 | num of indices: 12
- Batch1 - start index: 12 | num of indices: 256
- Batch2 - start index: 268 | num of indices: 120
- Batch3 - start index: 388 | num of indices: 0 (empty batch)
那么Batch3中的面片数就是空的,而如果继续按照这种组织方式发起DrawCall就会很低效,因此需要一个填洞的逻辑,即对面片进行重新组织,这个逻辑是放在batch_compaction.comp.fsl shader 文件中完成的,其主要目的是移除empty drawcall并对面片进行重排以提升ExecuteIndirect的执行效率。下面的示意图给出了这个过程的主要说明:
Batch compaction compute shader会移除空的draw call并在原位置填充上可用的draw call数据,最终得到的draw argument buffer会被后面的ExecuteIndirect指令调用。
同时,在这个shader中,会完成对每个draw indirect指令对应的material buffer的填充,这个buffer中装载的是每个draw call对应的material index数据,此外这个shader会统计出最终的draw call数目并传递给最终的ExecuteIndirect指令。
总的来看,目前Triangle Visibility Buffer渲染系统完成了如下的一些工作:
- CPU上,通过cluster culling剔除了那些在任意视角下都不可见的面片
- Compute Shader中,通过对N个视角下的面片进行culling & filtering,最终输出N个index buffer以及N个ExecuteIndirect buffer
- 之后再通过Compute Shader,完成Draw call的compaction逻辑(每个view的处理,都以对应的index buffer以及ExecuteIndirect buffer作为输入)
有了上述输出之后,下一步要做的就是通过ExecuteIndirect完成对实际的Visibility Buffer的填充工作了。
2.5 填充Visibility Buffer - ExecuteIndirect
Triangle的Visibility Buffer使用的RT是RGBA8格式的,其中存储了如下的一些信息:
- 1bit的Alpha-Masked信息,用于指明当前面片是否alpha masking(alpha test),The PC requires a dedicated code path for each with its own ExecuteIndirect.
- 8bit的drawID,对应的是indirect draw call的id,这个id可以表明当前的面片属于哪个draw call,8bit就对应最多256个draw call
- 23bit的triangleID,这个表示的是当前面片在当前draw call中的偏移,是每个draw call中的局部ID
这个RT在调用ExecuteIndirect的时候完成填充,同时还会完成Depth Buffer的输出,每个ExecuteIndirect指令调用都会读取VB/IB以及Material Buffer(简称MB)
Vertex Buffer
VB中有四个元素:
- Position
- Texture coordinates
- Normals
- Tangents
这里会根据数据元素将VB拆分成四个Buffer(称之为non-interleaved顶点数据,类似于我们常说的Struct of Array,目的是提高缓存命中率),实践证明,由于顶点数据与UV数据使用频率更高,因此这种拆分方式可以得到更为优秀的性能,下面给出了不同阶段中所需要的数据的分析结论:
Triangle filtering uses: Position
Filling the Visibility Buffer uses:Position & Texture coordinates for alpha testing
Shading uses:Position,Texture coordinates,Normals & Tangents
Index Buffer
ExecuteIndirect同时也会用到index buffer的数据,这个demo里面总共用到了6个index buffer,index buffer是在triangle removal stage中通过将可见面片的顶点索引不断append到buffer中创建的,在triangle removal的时候为了实现async compute shader,会对swap chain采用triple buffer模式,而每个buffer都需要同时创建main view跟shadow view两套数据,因此总共6个index buffer。
ExecuteIndirect同时还会用到经过filtering后的indirect argument buffer数据,这个数据是在triangle removal之后经过draw call compaction生成的。
Material Bffer
ExecuteIndirect 用到的最后一项数据为texture id或者material buffer(这个数据也是在draw call compaction过程中生成的),这个数据对应的是场景中的大量material数据。
这个过程中的相关源码在Visibility_Buffer.cpp,drawVisibilityBufferPass() 以及 visibilitybuffer_pass.frag.fsl文件中可以找到。
San Miguel 测试场景中,在四个不同的ExecuteIndirect指令调用中的indirect draw call数目分别为:
- Shadow opaque: 163
- Shadow alpha masked: 50
- Main view opaque: 152
- Main view alpha masked: 50
上述四个ExecuteIndirect指令完成之后,Visibility Buffer跟Depth Buffer就都填充好了,最终得到的是最靠近相机的几何数据(相当于已经完成了overdraw的removal),这里的demo不但提供了上面描述的Visibility Buffer方案,同时还有一个延迟渲染方案,两者填充Buffer数据的方式是十分相似的,不同的只是用到的数据不同而已,下面来对这二者做一下对比。
2.5.1 Visibility Buffer vs. G-Buffer下的内存占用对比分析
内存带宽受限是影响游戏性能的一个重要因素,而这个因素对于一些低端机如移动端来说更为关键,而延迟渲染方案中的G-Buffer随着时间增长数据量不断的增加更加使得这个问题雪上加霜。
游戏使用的数据资源有VB/IB,贴图,渲染参数,uniform以及RT等数据,RT是跟随屏幕分辨率走的,在内存占用上是最为显著的。
Visibility Buffer的一个优点就是最终将所有的数据输出到两个32位的RT中(Triangle Visibility Buffer以及Depth Buffer),而这个数据相对于G-Buffer的数据量而言是存在极大的减少的。
传递给ExecuteIndirect指令的VB/IB数据对于上述两种方案来说是一样的,如下图所示:
除此之外,还有贴图数据,draw argument数据,uniform等数据。从内存消耗来看,我们更关心的是屏幕空间的RT,下图给出了在1080p分辨率不同MSAA设置下的Visibility Buffer内存消耗:
下图则展示了同样的配置下,G-Buffer的内存消耗:
从数据可以看到,两者的内存消耗接近两倍的差距,另外从下面两图可以看到,对于4k分辨率而言,这个差距还会进一步扩大:
不同机器,不同驱动表现会不太一样,上面的数据是在PC上测试得到的,从上面的图片结果来看,G-Buffer相对于Visibility Buffer有更显著的内存占用问题与带宽传输问题,且分辨率越高,问题越明显,也就是说,在一些高分辨率平台下,尤其是这些平台还没有提供高速内存传输硬件的情况下,使用Visibility Buffer方案会得到十分明显的性能提升。
2.6 Shading
Visibility Buffer跟Depth Buffer填充完成之后,就可以考虑进行Shading操作了,不过在Shading之前,还需要先将屏幕划分成Tile,并计算出影响到每个Tile的Light列表。
2.6.1 Tiled Light List
影响每个Tile的光源列表的输出逻辑是通过Compute Shader完成的,在这个Shader中会对每个光源在屏幕空间上的bounding volume,并据此判断此光源是否与对应的tile相交(这个过程中会剔除掉bounding volume落于相机后方的光源),如果相交则将光源添加到tile的light cluster中,并对light count进行自增,源代码放在 cluster_lights.comp.fsl文件中。
2.6.2 Forward++
这里将这种使用Visibility Buffer且通过单个screen-space pass完成绘制的方案称之为Forward++,用于区分需要多个Draw Call才能完成绘制的Forward+方案。
对于透明物体而言,渲染逻辑依然保持跟Forward+一致,在提交之前,需要对DrawCall从前往后进行排序。
Shading算法总的来说可以分成如下一些步骤:
- 获取每个屏幕像素位置对应的drawID/triangleID
- 之后根据上一步拿到的数据,从IB/VB中读取出3个顶点数据
- 计算面片的梯度,也就是质心坐标的偏微分
- 根据梯度对顶点属性进行插值,得到当前像素位置处的相关属性数据
- 计算方向光的输出(着色),在Demo中使用的是Blinn-Phong或PBR
- 通过对每个tile中的光源列表进行遍历,输出局部光源的着色结果
这部分源码可以在 visibilityBuffer_shade.frag.fsl文件中找到。
上面过程中的偏微分计算使用的是T[Schied]中附录A的Equation (4)给出的公式:
源码实现给出如下:
// Computes the partial derivatives of a triangle from the projected
// screen space vertices
DerivativesOutput computePartialDerivatives(float2 v[3])
{
DerivativesOutput output;
float d = 1.0 / determinant(float2x2(v[2] - v[1], v[0] - v[1]));
output.db_dx = float3(v[1].y - v[2].y, v[2].y - v[0].y, v[0].y - v[1].y) * d;
output.db_dy = float3(v[2].x - v[1].x, v[0].x - v[2].x, v[1].x - v[0].x) * d;
return output;
}
注意,这里的偏微分计算中,并没有考虑计算精度的相关问题,如果有精度的问题,还需要另行考虑。
下面是着色相关逻辑的代码,先计算方向光的作用,之后通过for循环计算局部光源的作用:
// directional light
shadedColor = calculateIllumination(normal, uniforms.camPos.xyz, uniforms.esmControl, uniforms.lightDir.xyz, isTwoSided, posLS, position, shadowMap, diffuseColor.xyz, specularData.xyz, depthSampler);
// point lights
// Find the light cluster for the current pixel
uint2 clusterCoords = uint2(floor((input.screenPos * 0.5 + 0.5) * float2(LIGHT_CLUSTER_WIDTH, LIGHT_CLUSTER_HEIGHT)));
uint numLightsInCluster = lightClustersCount.Load(LIGHT_CLUSTER_COUNT_POS(clusterCoords.x, clusterCoords.y) * 4);
// Accumulate light contributions
for (uint i = 0; i < numLightsInCluster; i++)
{
uint lightId = lightClusters.Load(LIGHT_CLUSTER_DATA_POS(i, clusterCoords.x, clusterCoords.y) * 4);
shadedColor += pointLightShade(lights[lightId].position, lights[lightId].color, uniforms.camPos.xyz, position, normal, specularData, isTwoSided);
}
2.7 Visibility Buffer方案的优点
相对于Deferred Shading方案,Visibility Buffer方法有如下的一些优点:
2.7.1 内存带宽
Visibility Buffer方案在带宽消耗上有更大的优势,这一点在高分辨率屏幕,或者在一些高速存储器尺寸受限的情况下(比如只能支持到32位的RT,甚至只能支持屏幕中的部分区域的使用如TBDR架构下的硬件条件)更为明显。
2.7.2 内存访问模式
shading pass中,我们会根据visibility buffer的数据获得各个像素对应的triangle ID,此时并没有发生顶点属性的读取逻辑,对index buffer以及vertex buffer的真正的读取过程跟一个普通的draw call的实现逻辑差不多,不过这个draw call是覆盖全屏幕的,且数据是全屏幕连续的,并且仅发生一次,而这种数据获取的方式是现代GPU架构friendly的,经过测试发现,这种模式下,对于贴图、VB/IB数据的获取,在L2上可以达到99%的命中率,从而使得整个着色过程十分的高效。
2.8 其他
Visibility Buffer方案实施过程中遇到了一些问题,这里也给出其中的一些思考:
2.8.1 蒙皮模型的顶点变换频率有多高?
有三个阶段需要对顶点进行变换,分别是triangle removal,Visibility Buffer填充以及Shading。后面会考虑对triangle进行pre-transformed来减少面片变换的消耗。
2.8.2 Deferred Decals还能用吗?
可以考虑使用异步compute驱动的texture synthesis方案来替代,同样是在Visibility Buffer输出完成之后进行,对triangle、normal数据的获取以及将计算结果应用回最终的buffer中都跟Deferred Decals没有两样。
2.8.3 性能数据
下面给出的是DirectX 12+4k分辨率下的性能对比:
“Culling”列显示的是triangle culling & filtering的时间消耗,其他列名字就可以说明,就不展开介绍了。可以看到,屏幕分辨率越高,两者的性能差距越大。
参考文献
[1] Triangle Visibility Buffer - A Rendering Architecture for high-resolution Displays and Console Games
[2] The Visibility Buffer: A Cache-Friendly Approach to Deferred Shading