渲染管线

Introduction to Turing Mesh Shad

2020-06-24  本文已影响0人  离原春草

本文是对NVIDIA在18年所发表的Turing Mesh Shader技术文档的翻译与学习,这里是原文链接

NVIDIA在2018年提出的Turing架构介绍了一种全新的可编程Shader,即Mesh Shader。这个新的Shader所引入的compute programming model使得GPU上的各个线程可以相互配合并直接在芯片上生成后续光栅化所需要的细小面片数据(meshlets)。这种two-staged方法对于那些需要较高面片复杂度的应用场景有着极其巨大的帮助,同时为高效剔除,LOD以及渐进式数据生成等技术的实现增加了新的选项。

这里来介绍一下新管线的一些基本知识,并给出GLSL实现的一些示例代码。本文的大部分内容来自于此前NVIDIA在Siggraph 2018年的演讲视频上,感兴趣的同学自行前往观看。下面是本文的大纲:

  1. Mesh Shading Pipeline

  2. Meshlets and Mesh Shading

  3. Pre-Computed Meshlets

    1. Data Structures

    2. Rendering Resources and Data Flow

    3. Cluster Culling with Task Shader

  4. Conclusion

  5. References

1. Motivation

现实世界中的场景包含着非常丰富的信息,每个物件的细节都非常的复杂,而在计算机中通过模型来模拟就面临着面片复杂度以及细节雕刻的挑战。下面给出的Figure 1给出了传统渲染管线的仿真结果,虽然看起来十分漂亮,但是其模拟细节依然有所不足,在面片过亿物件数达到数十万以上之后,这个管线性能就会非常吃紧,很难保持实时帧率。

Figure 1. 想要提升真实性就会导致几何面片数目的急剧增加

本文后续将给出如何通过mesh shader来对高面片复杂度的场景进行加速。原始的mesh会被分割成一个个的小patch,这些patch被称之为meshlets,如Figure 2所示。划分的依据是保证每个meshlet内部的顶点重用方案都是最优的,之后meshlet会经过mesh shader进行处理。通过新的硬件stage以及这个划分方案,可以保证在更少的数据获取的同时完成更多面片的渲染。

Figure 2. : 复杂模型被分割成一个个的meshlet.

CAD等建模软件的数据量通常非常庞大,比如可以达到数千万乃至数亿面片数目。即使经过occlusion culling 处理,面片数依然很多。渲染管线中的一些固定处理stage会因此而导致一些不必要的时间与内存消耗:

为了解决上述问题,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面片数据的生成

Figure 3. Mesh shaders represent the next step in handling geometric complexity

2. Mesh Shading Pipeline

一个全新的两阶段的管线可以完全取代此前管线中的如下内容:顶点属性获取,顶点shader,tessellation shader,geometry shader管线。

新的管线包含的两个阶段给出如下:

mesh shader生成的面片会传递给光栅化阶段。task shader操作方式跟tessellation流程的hull shader很像。

Figure 4给出了新老管线的对比,可以看到除了光栅化组件与pixel shader的使用流程并未发生变化之外,其他的逻辑都被两阶段的新管线所取代(根据前面的描述推测,Task Shader应该负责输出每个模型需要被分割成多少个面片,而具体的分割逻辑则是放在Mesh Shader中完成,除此之外,Mesh Shader还承担了此前属于Vertex Shader的相关工作)。

Figure 4. Differences in the traditional versus task/mesh geometry pipeline

新的shader管线有如下优点:

mesh shader的编程模型跟compute shader很像,允许开发者用来做各种不同功能的工作,跳过光栅化处理阶段的话(这是允许的),还可以用于进行一些非常通用的计算工作。

Figure 5. Mesh shaders behave similarly to compute shaders in using a cooperative thread model

mesh shader、task shader的输入跟CS一样,只包含一个workgroup index。这两者都是处于GPU管线上的,因此硬件可以实现不同stage之间的内存数据传递,并将数据维持在on-chip上。

下面用一个例子来说明新管线如何利用线程对workgroup中的所有顶点数据的访问权限来进行面片剔除的,Figure 6介绍了task shader的early culling功能。

Figure 6. While optional, task shaders enable early culling to improve throughput.

通过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.

Figure 7. Mesh shader workgroup flow

虽然可以保证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<meshletdesc>  meshletInfos;
  std::vector<uint8_t>      primitiveIndices;
 
  // use uint16_t when shorts are sufficient
  std::vector<uint32_t>     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所示):

由于顶点数据的高度重用,这三个buffer的尺寸要比原始的index-buffer要小,从经验数据来看,大概能减到原始index buffer的75%左右。

Figure 8. Meshlet buffer structure - NVIDIA Turing GPU mesh shader buffer structure

下面给出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截图。

Figure 9. NVIDIA Asteroids demo uses mesh shading

4. Conclusion

新管线的一些使用注意事项:

更多的信息请参考Turing架构介绍.

5. References

上一篇 下一篇

猜你喜欢

热点阅读