原文章直接翻译,未能理解消化,纳为己用,输出的内容晦涩难懂,此处先做报废处理,提供一些其他的参考文章链接用作后续工作查询使用,待时机成熟再重新整理:
[1]. D3D12中的mesh shader
总体来看,Mesh Shader的引入是为了优化GS、Tessellation(Hull Shader+Domain Shader)用于实现geometry生成效率低下的问题而提出的:
- 早期DX版本引入的GS、Tessellation目的都是扩展geometry细节,但性能较为低下
- 后续人们发现可以通过Compute Shader实现Geometry细节的扩展,通过indirectDraw完成VS/PS的调用,效率竟然还高过GS/Tessellation,需要考虑的是CS跟Graphics之间的同步问题
- DX12一想,干脆增加一个一步到位的方法,统一在Graphics管线中完成包括Input Assembler、Geometry扩展到Vertex计算在内的所有逻辑,这就是Mesh Shader(替代DS或DS+GS)
- 与Mesh Shader同时引入的还有一个叫做Amplifying Shader的概念,这是一个可选阶段,时序位于Mesh Shader之前。此阶段的作用是替代硬件的Tessellation(准确来说是VS+HS)功能,如果我们不需要这个功能,可以不加
本文是对NVIDIA在18年所发表的Turing Mesh Shader技术文档的翻译与学习,这里是原文链接。
NVIDIA在2018年提出的Turing架构介绍了一种全新的可编程Shader,即Mesh Shader。这个新的Shader所引入的compute programming model使得GPU上的各个线程可以相互配合并直接在芯片上生成后续光栅化所需要的细小面片数据(meshlets)。这种two-staged方法对于那些需要较高面片复杂度的应用场景有着极其巨大的帮助,同时为高效剔除,LOD以及渐进式数据生成等技术的实现增加了新的选项。
这里来介绍一下新管线的一些基本知识,并给出GLSL实现的一些示例代码。本文的大部分内容来自于此前NVIDIA在Siggraph 2018年的演讲视频上,感兴趣的同学自行前往观看。下面是本文的大纲:
Mesh Shading Pipeline
Meshlets and Mesh Shading
-
Pre-Computed Meshlets
Data Structures
Rendering Resources and Data Flow
Cluster Culling with Task Shader
Conclusion
References
1. Motivation
现实世界中的场景包含着非常丰富的信息,每个物件的细节都非常的复杂,而在计算机中通过模型来模拟就面临着面片复杂度以及细节雕刻的挑战。下面给出的Figure 1给出了传统渲染管线的仿真结果,虽然看起来十分漂亮,但是其模拟细节依然有所不足,在面片过亿物件数达到数十万以上之后,这个管线性能就会非常吃紧,很难保持实时帧率。
本文后续将给出如何通过mesh shader来对高面片复杂度的场景进行加速。原始的mesh会被分割成一个个的小patch,这些patch被称之为meshlets,如Figure 2所示。划分的依据是保证每个meshlet内部的顶点重用方案都是最优的,之后meshlet会经过mesh shader进行处理。通过新的硬件stage以及这个划分方案,可以保证在更少的数据获取的同时完成更多面片的渲染。
CAD等建模软件的数据量通常非常庞大,比如可以达到数千万乃至数亿面片数目。即使经过occlusion culling 处理,面片数依然很多。渲染管线中的一些固定处理stage会因此而导致一些不必要的时间与内存消耗:
硬件primitive distributor每次(每帧?)都会扫描index buffer并创建顶点batch,即使拓扑结构根本没有变化
对顶点属性数据的fetch操作,fetch了很多不可见的顶点的属性数据,造成浪费
为了解决上述问题,NVIDIA提出了mesh shader的概念。跟一些早期的方案有所不同,新的方案只需要进行一次内存访问(剔除前上传,剔除后存在chip上,之后通过indirect draw调用数据进行绘制),之后将(没有变化的)数据常驻在chip上,比如以前基于compute shader的面片剔除方案(see [3],[4],[5])会计算可见面片的index buffer并通过indirect draw进行绘制。
mesh shader stage跟compute shader stage一样,都是使用并行(cooperative)线程模型而非单线程模型进行工作的。mesh shader生成的面片数据后面会提供给光栅化组件使用。渲染管线中位于mesh shader之前的stage是task shader,其操作方式有点类似于tessellation的控制stage,比如都是用来动态生成work的(相当于task shader是thread dispatcher,mesh shader则是thread executor),task shader使用的也是并行(cooperative)线程模型,其输入输出都可以交由用户自行设定(tessellation的输入是patch数输出则是tessellation decision)。
如Figure 3所示,相对于此前的tessellation shader & geometry shader中线程只能用于专属任务,新的mesh shader管线功能更为通用,可以极大简化on-chip面片数据的生成
2. Mesh Shading Pipeline
一个全新的两阶段的管线可以完全取代此前管线中的如下内容:顶点属性获取,顶点shader,tessellation shader,geometry shader管线。
新的管线包含的两个阶段给出如下:
Task shader : 一个以workgroup作为基本工作单位的可编程单元,每个workgroup可以发起(或者不发起)mesh shader workgroups。
Mesh shader : 一个以workgroup作为基本工作单位的可编程单元,每个workgroup都会输出对应的面片(primitive)数据。
mesh shader生成的面片会传递给光栅化阶段。task shader操作方式跟tessellation流程的hull shader很像。
Figure 4给出了新老管线的对比,可以看到除了光栅化组件与pixel shader的使用流程并未发生变化之外,其他的逻辑都被两阶段的新管线所取代(根据前面的描述推测,Task Shader应该负责输出每个模型需要被分割成多少个面片,而具体的分割逻辑则是放在Mesh Shader中完成,除此之外,Mesh Shader还承担了此前属于Vertex Shader的相关工作)。
新的shader管线有如下优点:
高扩展性 减少了固定管线模块对于面片处理的干涉,更为通用化的GPU使用策略允许应用添加更多的core来提升内存与算数单元的使用效率。
降低带宽消耗, 顶点数据的可重用性(横跨多帧使用,如何做到?难道处理完成的VB/IB数据真的可以常驻在芯片上不释放?那芯片上的空间如果不够用该怎么处理,如何确定哪些数据下一帧不会用了?除非所有数据都装载到GPU上)使得带宽消耗降低。当前API模型意味着每帧硬件都需要对index buffer数据进行扫描(即每个物件都是根据VB/IB构建的,这个过程是每帧执行的)。而大尺寸(面片的面积大,还是面片的数目多?)的meshlets意味着更高的顶点重用性,同时也可以更好的降低带宽消耗(这个具体处理过程是怎么样的呢?)。此外,开发者还可以自行设计压缩策略以及渐进式程序生成算法。task shader中可选的expansion/filtering功能允许完全跳过数据的获取( skip fetching more data,如果前面说的将数据常驻在芯片上是成立的话,这个过程倒是有可能,只是如何解决哪些数据需要常驻,哪些数据需要卸载呢?)
更好的灵活性,灵活性体现在mesh拓扑结构的定义以及graphics work的创建上。此前的tessellation shader受限于固定的tessellation patterns,而geometry shader的threading处理比较低效,且其单线程创建面片(created triangle strips per-thread.)的programming model不太友好。
mesh shader的编程模型跟compute shader很像,允许开发者用来做各种不同功能的工作,跳过光栅化处理阶段的话(这是允许的),还可以用于进行一些非常通用的计算工作。
mesh shader、task shader的输入跟CS一样,只包含一个workgroup index。这两者都是处于GPU管线上的,因此硬件可以实现不同stage之间的内存数据传递,并将数据维持在on-chip上。
下面用一个例子来说明新管线如何利用线程对workgroup中的所有顶点数据的访问权限来进行面片剔除的,Figure 6介绍了task shader的early culling功能。
通过task shader所添加的可选扩展(optional expansion)可以允许提前进行对面片group进行early culling以及LOD选择。整个机制可以跟随GPU进行扩展,因而可以取代小mesh的实例化(instancing)以及multi draw indirect。这个配置过程跟tessellation shader很像,可以很灵活的通过task workgroup来设置一个patch的可tessellation部分以及通过mesh workgroup来设定后续需要产生的tessellation invocations数目。
每个task workgroup可以生成的mesh workgroups数目是有限制的,第一代硬件只支持最多64k个children。不过对于每个draw call中的所有task所能生成的mesh children的数目却是没有限制的(更直接的说,就是每个DP的task的数目是不受限制的),同样的,如果这里不使用task shader,那么最终单个draw call所能生成的mesh workgroups数也是无限的。详情参考Figure 7.
虽然可以保证task T的执行顺序肯定是位于task T-1之后,但是由于workgroups都是管线化的,因此并不需要的等到前一个task的children执行完成后 才开始下一个task。
task shader多用于动态的work(比如蒙皮模型等会发生变化的数据,可以动态对模型进行拆分)生成以及filtering,对于静态的tessellation需求,可以直接使用mesh sheder(对完成拆分的meshlet进行处理,拆分过程可以在CPU完成)完成,跳过task shader的消耗。
mesh以及其内的面片在光栅化后的输出顺序是不变的,而将光栅化过程关闭,task shader跟mesh shader都可以用于通用计算。
3. Meshlets and Mesh Shading
每个meshlet表示的是一定数目的顶点与面片数据(对应UE5的cluster),这里并没有限制面片之间的连接性(connectivity),不过在shader代码中对最大面片数做了约束。
这里推荐的顶点数与面片数分别为64跟126,126末尾的6不是拼写错误。第一代硬件对面片索引数据的分配是以128bytes为粒度进行的,此外由于需要空出4个bytes用于存储面片数目,以每个面片3个索引来计算,那么126个面片就对应于126 x 3 + 4 = 382 < 384=128 x 3,刚好能够塞进3个block中。如果不用126作为最大面片数目,还可以使用84跟40,这两个刚好对应于两个block与一个block数据。
在mesh-shader GLSL代码中,管线会为每个workgroup分配一块固定的内存空间。最大值,尺寸以及面片输出按照如下的方式来给定:
每个meshlet分配的空间大小与编译时的信息有关,同时也跟后面shader所需要引用的输出属性有关。分配的空间越小,硬件同时能够执行的workgroups数目越多,跟CS一样,workgroups共享一块on-chip存储空间。不过相对于以前的实现方式,现在管线所占用的存储空间可能会更多一些(顶点数与面片数都多了)。
// Set the number of threads per workgroup (always one-dimensional).
// The limitations may be different than in actual compute shaders.
layout(local_size_x=32) in;
// the primitive type (points,lines or triangles)
layout(triangles) out;
// maximum allocation size for each meshlet
layout(max_vertices=64, max_primitives=126) out;
// the actual amount of primitives the workgroup outputs ( <= max_primitives)
out uint gl_PrimitiveCountNV;
// an index buffer, using list type indices (strips are not supported here)
out uint gl_PrimitiveIndicesNV[]; // [max_primitives * 3 for triangles]
Turing支持GLSL的一个新的扩展:NV_fragment_shader_barycentric。这个扩展允许Fragment Shader直接读取一个面片的三个顶点数据并进行人工插值。通过这个扩展,开发者就可以直接输出uint顶点属性,并通过各种pack/unpack方法将浮点数存储为fp16,unorm8或者snorm8.这种做法可以极大的降低每个顶点存储的法线,贴图坐标以及顶点色等数据的占用的空间(应该是使用这种做法,就不需要在光栅化的时候对这些属性进行插值,而是在PS阶段通过这个数值对raw vertex data进行插值获取吧)。
顶点跟面片的额外属性数据给出如下:
out gl_MeshPerVertexNV {
vec4 gl_Position;
float gl_PointSize;
float gl_ClipDistance[];
float gl_CullDistance[];
} gl_MeshVerticesNV[]; // [max_vertices]
// define your own vertex output blocks as usual
out Interpolant {
vec2 uv;
} OUT[]; // [max_vertices]
// special purpose per-primitive outputs
perprimitiveNV out gl_MeshPerPrimitiveNV {
int gl_PrimitiveID;
int gl_Layer;
int gl_ViewportIndex;
int gl_ViewportMask[]; // [1]
} gl_MeshPrimitivesNV[]; // [max_primitives]
这里的目的是尽可能的降低meshlet的数目,从而加大meshlets中的顶点的重用性。这种做法有助于在meshlet数据生成之前对顶点对应index-buffer的cache效率进行优化,比如 Tom Forsyth’s linear-speed optimizer 优化算法就可以用于进行这个工作。由于原始面片的顺序关系依然会维持不变,在优化index-bffer的同时优化顶点的位置也是非常有意义的(每太理解其中的逻辑)。CAD模型输出的面片是以triangle strip的拓扑结构存储的,因此已经具有很好的cache locality。对于这种数据而言,如果再去修改index buffer,就可能会起到反面作用。
3.1 Pre-Computed Meshlets
作为示例,这里渲染一个index-buffer维持不变的静态物体。因此生成meshlet数据的消耗就会被顶点索引数据上传到显存的消耗所抵消。而如果顶点数据也是静态的话(不需要进行顶点动画)还可以通过预计算对meshlet进行快速剔除。
Data Structures
在以后的示例代码中,会给出一个meshlet builder,其中包含了基本管线的实现过程,在每次当顶点或者面片数据达到极限时(听这个意思,meshlet的数据量是会随着时间或者空间而增加?),就会对索引数据进行扫描并生成一个新的meshlet。
对于一个输入的mesh,会产出如下的数据:
struct MeshletDesc
{
uint32_t vertexCount; // number of vertices used
uint32_t primCount; // number of primitives (triangles) used
uint32_t vertexBegin; // offset into vertexIndices
uint32_t primBegin; // offset into primitiveIndices
}
std::vector meshletInfos;
std::vector primitiveIndices;
// use uint16_t when shorts are sufficient
std::vector vertexIndices;
为什么需要两个index buffers?
下面的原始面片index buffer序列:
`// let's look at the first two triangles of a batch of many more triangleIndices = { 4,5,6, 8,4,6, ...}
会被分割成两个新的index buffer.
之后在对顶点索引进行遍历的时候建立一套全新的顶点索引。这个处理过程被称为顶点去重(vertex de-duplication).
vertexIndices = { 4,5,6, 8, ...}
// For the second triangle only vertex 8 must be added
// and the other vertices are re-used.
面片索引也会跟随顶点索引进行同步调整。
// original data
triangleIndices = { 4,5,6, 8,4,6, ...}
// new data
primitiveIndices = { 0,1,2, 3,0,2, ...}
// the primitive indices are local per meshlet<
当顶点数目或者面片数目达到极限后,就会开一个新的meshlet,每个meshlet都会创建它们自己的顶点数据。
3.2 Rendering Resources and Data Flow
在渲染的时候,这里使用的是原始的顶点buffer,不过这里使用的不是原始的index buffer,而是三个新的buffer(如Figure 8所示):
Vertex Index Buffer每个meshlet都会对应一套独立的顶点数据,这套顶点数据在全量顶点buffer中的位置对应的就是这套索引buffer,这个buffer会按照meshlet的顺序进行存储。
Primitive Index Buffer 每个meshlet表示一定数目的面片,每个面片对应三个索引,这三个索引存储在一个单独的buffer中。注意,可能会添加额外的索引以实现每个meshlet的4bytes对齐,这个索引buffer是每个meshlet一套的,从0开始存储的。
Meshlet Desc Buffer. 存储workload,每个meshlet的buffer偏移以及cluster culling等相关信息。
由于顶点数据的高度重用,这三个buffer的尺寸要比原始的index-buffer要小,从经验数据来看,大概能减到原始index buffer的75%左右。
Meshlet Vertices:
vertexBegin
存货粗的是顶点索引的起始位置;vertexCount` 存储的是相关的顶点数目;同一个meshlet中的顶点都是独一无二的,没有冗余的索引数据。Meshlet Primitives:
primBegin
存储的是起始面片索引位置;primCount
存储的是meshlet中的面片数目;注意,这里的面片索引数目跟面片类型有很大关系(比如triangle是3),这里的索引指的是对应的顶点相对于vertexBegin
的偏移,即vertexBegin
对应的索引为0.
下面给出mesh shader的一个示例代码,描述了一个workgroup的工作,为了便于理解,这里给出的示例是串行执行的。
// This code is just a serial pseudo code,
// and doesn't reflect actual GLSL code that would
// leverage the workgroup's local thread invocations.
for (int v = 0; v < meshlet.vertexCount; v++){
int vertexIndex = texelFetch(vertexIndexBuffer, meshlet.vertexBegin + v).x;
vec4 vertex = texelFetch(vertexBuffer, vertexIndex);
gl_MeshVerticesNV[v].gl_Position = transform * vertex;
}
for (int p = 0; p < meshlet.primCount; p++){
uvec3 triangle = getTriIndices(primitiveIndexBuffer, meshlet.primBegin + p);
gl_PrimitiveIndicesNV[p * 3 + 0] = triangle.x;
gl_PrimitiveIndicesNV[p * 3 + 1] = triangle.y;
gl_PrimitiveIndicesNV[p * 3 + 2] = triangle.z;
}
// one thread writes the output primitives
gl_PrimitiveCountNV = meshlet.primCount;
如果改成并行执行,其结构大概如下所示:
void main() {
...
// As the workgoupSize may be less than the max_vertices/max_primitives
// we still require an outer loop. Given their static nature
// they should be unrolled by the compiler in the end.
// Resolved at compile time
const uint vertexLoops =
(MAX_VERTEX_COUNT + GROUP_SIZE - 1) / GROUP_SIZE;
for (uint loop = 0; loop < vertexLoops; loop++){
// distribute execution across threads
uint v = gl_LocalInvocationID.x + loop * GROUP_SIZE;
// Avoid branching to get pipelined memory loads.
// Downside is we may redundantly compute the last
// vertex several times
v = min(v, meshlet.vertexCount-1);
{
int vertexIndex = texelFetch( vertexIndexBuffer,
int(meshlet.vertexBegin + v)).x;
vec4 vertex = texelFetch(vertexBuffer, vertexIndex);
gl_MeshVerticesNV[v].gl_Position = transform * vertex;
}
}
// Let's pack 8 indices into RG32 bit texture
uint primreadBegin = meshlet.primBegin / 8;
uint primreadIndex = meshlet.primCount * 3 - 1;
uint primreadMax = primreadIndex / 8;
// resolved at compile time and typically just 1
const uint primreadLoops =
(MAX_PRIMITIVE_COUNT * 3 + GROUP_SIZE * 8 - 1)
/ (GROUP_SIZE * 8);
for (uint loop = 0; loop < primreadLoops; loop++){
uint p = gl_LocalInvocationID.x + loop * GROUP_SIZE;
p = min(p, primreadMax);
uvec2 topology = texelFetch(primitiveIndexBuffer,
int(primreadBegin + p)).rg;
// use a built-in function, we took special care before when
// sizing the meshlets to ensure we don't exceed the
// gl_PrimitiveIndicesNV array here
writePackedPrimitiveIndices4x8NV(p * 8 + 0, topology.x);
writePackedPrimitiveIndices4x8NV(p * 8 + 4, topology.y);
}
if (gl_LocalInvocationID.x == 0) {
gl_PrimitiveCountNV = meshlet.primCount;
}
3.3 Cluster Culling with Task Shader
为了进行early culling,这里会尝试将尽可能多的数据塞入到meshlet descriptor中。NVIDIA此前实验的时候是用一个128位的descriptor来对编码后的数据进行存储的,其中包括此前介绍过的一些数据以及 G.Wihlidal算法所需要的cone等数据. 在生成meshlets的时候,还需要注意做好cluster culling属性与顶点重用之间的平衡,这两者常常会出现冲突的可能。
这个任务最重需要使用32个meshlets.
layout(local_size_x=32) in;
taskNV out Task {
uint baseID;
uint8_t subIDs[GROUP_SIZE];
} OUT;
void main() {
// we padded the buffer to ensure we don't access it out of bounds
uvec4 desc = meshletDescs[gl_GlobalInvocationID.x];
// implement some early culling function
bool render = gl_GlobalInvocationID.x < meshletCount && !earlyCull(desc);
uvec4 vote = subgroupBallot(render);
uint tasks = subgroupBallotBitCount(vote);
if (gl_LocalInvocationID.x == 0) {
// write the number of surviving meshlets, i.e.
// mesh workgroups to spawn
gl_TaskCountNV = tasks;
// where the meshletIDs started from for this task workgroup
OUT.baseID = gl_WorkGroupID.x * GROUP_SIZE;
}
{
// write which children survived into a compact array
uint idxOffset = subgroupBallotExclusiveBitCount(vote);
if (render) {
OUT.subIDs[idxOffset] = uint8_t(gl_LocalInvocationID.x);
}
}
}
对应的mesh shader会从task shader中输出的信息来确认哪些meshlet需要生成。
taskNV in Task {
uint baseID;
uint8_t subIDs[GROUP_SIZE];
} IN;
void main() {
// We can no longer use gl_WorkGroupID.x directly
// as it now encodes which child this workgroup is.
uint meshletID = IN.baseID + IN.subIDs[gl_WorkGroupID.x];
uvec4 desc = meshletDescs[meshletID];
...
}
在渲染高模的时候,这里只在task shader中对meshlet进行剔除计算。其他的使用情景可能会需要考虑根据LOD情况来决定需要选取哪个meshlet。Figure 9给出的是一个使用task shader来进行LOD计算的demo截图。
4. Conclusion
新管线的一些使用注意事项:
只需要对index buffer进行一次扫描,就可以将一个mesh转换为多个meshlets。在这个过程中,可以应用一些顶点cache优化测流来提升meshlet数据使用的效率。另外还可以采用一些更为成熟的cluster方法来通过task shader进行early culling。
task shader可以实现early culling,从而避免硬件为一些不必要的顶点与面片分配存储空间。此外,task shader还可以在必要的时候产生多个child invocations。
顶点数据是通过workgroup中的多个线程并行处理的,这个跟之前的VS没什么两样。
通过一些预处理工作,可以使得VS兼容于Mesh Shader(?)
由于数据重用,渲染时所需要获取的数据大大减少(传统的VS能够处理的最大顶点数目是32,最大面片数目也是32)
所有的数据处理都是通过shader指令来完成,避免了此前固定管线的不灵活性。这种做法还可以用于自定义顶点编码格式以进一步降低带宽消耗。
如果顶点具有较多的属性,那么一个并行执行的面片剔除策略可能会非常有用。这样可以跳过那些后面会被剔除的顶点数据的加载过程。这个剔除放在task阶段得到的收益是最高的。
更多的信息请参考Turing架构介绍.
5. References
[1]: Art by Rens
[2]: photo by Chris Christian – model by Russell Berkoff
[3]: Optimizing Graphics Pipeline with Compute – Graham Wihlidal
[4]: GPU-Driven Rendering Pipelines – Ulrich Haar & Sebastian Aaltonen
[5]: The filtered and culled Visibility Buffer – Wolfgang Engel