Introduction to Turing Mesh Shaders

Simple Introduction - 简介

新版的 Turing (图灵)结构介绍 通过使用 Mesh Shader 来实现几何可编程着色器管线。Mesh Shader 带来了新的计算模型,在GPU中图形管线中将多线程合作生成精简的网格(meshlets),该网格是直接为 Rasterizer(光栅器)提供数据使用的。应用程序和游戏处理高精度的几何体将得益于灵活的两个阶段,允许高效的 culling(剔除),程序生成的LOD(level-of-detail) 技术。

Motivation - 值得一提

真实的世界中视觉是非常的丰富的,各种几何体的形状,还有各种摆放的位置。特别是户外场景可以成百万或上千万的物件、元素的数量(岩石、树、小的植物、等等)。CAD 模型的呈现就类似的挑战了这两点,复杂的表面形状,就像是由许多的很小部分组成,例如太空飞船。图1展示了一些示例,关于当代的图形管线中,使用 vertex(顶点),tessellation(曲面细分),和 geometry(几何) shader,instancing(实例化绘制)和 multi draw indirect(非立即绘制的:延迟多绘制),也是非常的高效的,但仍然限制于全屏分辨率时的几何体到达了 上千万的三角形上十万的对象
图1
图1。用巨量的复杂几何体来提升逼真度。

其他的使用案例不会展示像上面的包含大量几何体,而是合理的计算(粒子、文字、代理对象、点云)或生成形状(电子工程布局,vfx 粒子,带条、拖尾,路径渲染)。

后面我们将看看使用 mesh shader 来加速渲染大量三角形的网格。原始的网格被分解为更小的 meshlets ,如 图2 的展示。理想情况下每个 meshlet 用于优化顶点复用。使用新的硬件阶段和和分解调度机制,我们可以并行的渲染更多的几何体而无需 fethcing(获取)所有的数据。
图2.1图2.2
图2。大量的网格被分解到 meshlets,用于 mesh shader 渲染使用。

例如 CAD 中可达上千万级技术细节,说明几何体可以不限制顶点数量、多边形的数量,可以做到非常密集的的数量,密集到一个像素还可以容纳到数个多边形。

例如 CAD 中可达上千万级或亿级别数量的三角形。即使在 occlusion culling(遮挡剔除)后,还是有大量的三角形存在。一些在管线中固定功能可能还一些浪费的工作、浪费的内存加载:

  • 顶点批量创建,它在硬件每次都 primitive distributor scanning(图元分布扫描) indexbuffer(索引缓存),即使拓扑没有改变过
  • 看不见(背面,视锥体外,或子像素剔除)的顶点和属性数据的也 fetch (获取)

mesh shader 给开发者提供了新的可能性来避免这些瓶颈。新的方法允许内存被一次读取,并保持在 on-chip (芯片)中,而不是之前的方法,例如,基于 compute shader 的图元剔除(查看 脚注3,4,5),可见的三角形的索引缓存被计算并延迟绘制。

mesh shader 阶段为 光栅器 生成三角形,内部使用的是协作线程模型来处理,而不是单线程程序模式,类似 compute shader。在新的 mesh shader 管线中在 mesh shader 阶段的前一个是 task shader。task shader 操作类似于 control stage of tessellation(tessellation control stage,曲面细分的控制阶段),为了能动态生成的工作。然而,使用一个协作线程模式而不是像tessellation的输出决定输出的方式,它是输入和输出都是用户定义的。

简单的比较一下 on-chip 的几何体创建 与之前的死板的方式,与带有限制的 tessellation 和 geometry shader 的线程只能用于特定的任务,如图3 的展示。

在这里插入图片描述
图3。Mesh Shader 代表着在处理复杂几何的逐步步骤

Mesh Shading Pipeline - Mesh 着色管线

一个新的两个阶段的管线可替代传统的 attribute fetch, vertex, tessellation, geometry shader 管线。这个新的管线包含一个 task shader 和 mesh shader:

  • Task shader:一个可编程单元,它是在 workgroups 工作组中操作生成每个需要(或不需要)的mesh shader 工作组。
  • Mesh shader:也是一个可编程单元,它在 workgroups 工作组中操作,并允许生成图元。

mesh shader 阶段为 rasterizer 光闪器生成三角形,内部使用到的方式就是上面提及到的写作线程模式。 task shader 操作类似与 tessellation 阶段的 hull shader,为了可动态生成的工作。然而,类似 mesh shader一样,task shader 也使用写作线程模式。它们的输入和输出都是用户定义的,而不是像 tessellation 中拿一小块数据来决定输出的内容。

pixel/fragment shader 没有影响。传统的管线仍然能依赖用于使用提供很好的效果。图5 高亮了管线风格的差异。
在这里插入图片描述

Mesh Shader 的管线 mesh shading pipeline (网格着色器管线)替换了一般的 VTG pipeline 管线(VTG = Vertex / Tessellation / Geometry)。

新的 mesh shader 管线为开发者提供了一些好处:

  • Higher scalability:更高的稳定性着色器单元,减少固定函数对图元处理的影响。通用性,现在 GPUs 将可以用于更多不同的应用程序中,添加更多的内核,和提升 着色器通用内存,和算术性能。
  • Bandwidth-reduction:减少带宽消耗,更加直接的重复顶点(可复用的顶点),再许多帧中都可以复用。当前的 API 模型意味着 index buffer 在硬件中每次都扫描。巨量的 meshlets 意味着更高的顶点复用,也降低了对带宽的需求(bandwitdh requirements)。还有开发者可以引入他们自己的压缩或程序生成的调度。task shader 的 expansion/filtering 都是可选的,可以完全的跳过这些数据的获取。
  • Flexibility:灵活性,它是定义 mesh topology(网格拓扑)和创建图形的工作。之前的 tessellation shader 限制于 固定的 tessellation 模式, geometry shader 忍受着低效的线程,不友好的编程模型方式来在每个线程创建三角带条。

Mesh shading 用的是 compute shader 的编程模式,给开发者自由的使用线程来处理不同的共享数据。当 rasterization(光栅化)禁用了,两个阶段可以用于通用计算的工作。
在这里插入图片描述
图5。Mesh shader 表现的类似与 compute shader,使用写作线程的模型。

但 mesh 和 task shader 都是 compute shader 编程模型,使用协作线程来计算他们的结果,no inputs other than a workgroup index(除了 workgroup 索引外都不需要输入的数据)。这些执行在图形管线;因此硬件直接管理内存在多个阶段间的传输并保持在芯片中(kept on-chip)。

我们将展示如何处理图元剔除的例子,线程可以在一个 workgroup 中访问所有的顶点。图6 代表 task shader 可以提早剔除的能力。
在这里插入图片描述
图6。task shader 是可选的,task shader 开启可提前剔除来提升 throughput(吞吐量)。

通过 task shader optional expansion(可选的展开)允许提早的剔除图元组,或是直接的标记 LOD。该机替代了 instancing 或是 multi draw indirect 的方式来绘制小网格。这些配置类似与 tessellation control shader 设置如和细分一小块表面(~task workgroup)和影响要创建多少个 tessellation evaluation 的调用(~mesh workgroup)。

在一个 task workdgroup 能发射(生成)多少个 mesh workdgroups 是有限制的。第一代硬件最大支持 每个 task 任务生成 64K 子空间。在 mesh 子对象的总数没有限制,通过所有 tasks 执行 draw call 绘制。同样的,如果没有使用 task shader,draw call 时 的大量的 mesh workgroups 生成是没有限制的。图7 表示了这个工作。
在这里插入图片描述
图7。Mesh shader 工作组流

第T个task的children子任务都会保证在第T-1个之后执行。然而,task 和 mesh workdgroups 工作组是完全管线化的,所以是不需要等待之前的 childrene task 任务完整。

task shader 一般用于动态的生成或是过滤工作。静态的设置受益於单独使用 mesh shader。

光栅器输出的网格和图元都是保留的。光栅器禁用的话,task 和 mesh shader 可用于实现基础的 compute-trees (计算树)。

Meshlets and Mesh Shading - Meshlets 和 Mesh 着色

每一个 meshlet 代表着一个可变的顶点和图元的数量。连接的对应图元是没有限制的。然而,他们的 shader code 的数量必须在限制的范围内。

我们推荐使用 64 个顶点 和 126 个图元。126中的’6’没有打错。第一代的硬件分配图元的索引使用 128 字节并预留 4 字节作用图元的数量。因为 3 * (126 + 4) 就是 3 * 128 = 384个字节块。超过 126 个三角形将分配到下一个 128 字节(说实话,我对这英文表达能力、和我自己的理解能力表示怀疑,我看过一些其他的教程英文表达能力的清晰度,绝对比 NVidia 这篇好很多,为何会酱紫。。。)84 和 40 都都是很好的数值。

在每个 GLSL mesh-shader 代码中,workdgroup 在图形管线分配 大量固定的网格内存。

最大值,与大小 与 图元的输出如下定义:

分配的每个 meshlet 的大小依赖于编译期间的决定的大小,就像 输出的attributes 是参考shader的。分配的越少,能在硬件并行运行的 workdgroup 就可以越多。workdgroup 共享的一块在 on-chip 上的共用内存都是可以访问的。因为我们推荐输出的或是共享的内存尽可能这块共享内存。这在现在的着色器是可行的。然而,内存的占用量将会更高,自从我们允许更大量的顶点和图元的数量在当前编程模式中。

// Set the number of threads per workgroup (always one-dimensional).
// 设置每个 workdgroup 的线程数量(总是一维的)
  // The limitations may be different than in actual compute shaders.
  // 限制可能与 compute shader 不同。
  layout(local_size_x=32) in;

  // the primitive type (points,lines or triangles)
  // 图元类型(点,线或三角形)
  layout(triangles) out;
  // maximum allocation size for each meshlet
  // 每个 meshlet 的最大分配大小
  layout(max_vertices=64, max_primitives=126) out;

  // the actual amount of primitives the workgroup outputs ( <= max_primitives)
  // workgroup 输出的实际图元数量(<= 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 获取原始的三个顶点的数据来生成一个图元,并手动插值。这些原始的方位意味着我们可以输出"unit"(单元)顶点属性,但使用不同的打包/解包函数来储存 float 为 fp16unorm8 或是 snorm8 。这可以大量的减少每个顶点的法线,纹理座标,颜色值占用的空间,并益与标准化 mesh 着色器管线。

另外顶点和图元的属性定义如下:

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]

其一一个目标是最小的 meshlets 的数量,因此 meshlets 将最大化 顶点的复用,也因此浪费了一些分配空间。在 meshlet 生成数据之前,indexbuffer应用顶点缓存优化器是有益的。例如, Tom Forsyth’s linear-speed optimizer (Tom Forsyth 的线性速度优化器)可用于使用这点。优化顶点的位置和索引缓存都是有益的,当使用 mesh shader 时,原来的三角形的顺序都会被保留。CAD 模型通常“naturally”(天生自带的)使用条带生成,因此本身有很好的数据定位。调整索引缓存可以会引起 meshlet 剔除特性的负面影响(查看 task-level culling(task级别的剔除))。

Pre-Computed Meshlets - 与计算的Meshlets

例如,我们可以渲染静态的内容,它们都是 index buffer 在多少都没有改变的。因为 生成 meshlet 数据的消耗可在顶点、索引上传到设备内存前隐蔽起来。这可以在顶点数据都是静态的可以完成(没有逐顶点动画;没有该表顶点的位置),允许预先计算数据,在整个 meshlets 的快速剔除是非常有用的。

Data Structure - 数据结构

在后面的示例中,我们将提供 meshlet 的构建起,它包含一些基础的实现,每次都会扫描索引,并在遇到 meshlet 大小限制(顶点或是图元的数量)时创建一个新的 meshlet。

为一个输入的三角形网格生成下面的数据:

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
  // 在足够的使用可以使用 unit16_t
  std::vector<uint32_t>     vertexIndices;

每位有两个索引缓存?

下面是原始的三角形的索引缓存数组

// let's look at the first two triangles of a batch of many more triangleIndices = { 4,5,6, 8,4,6, ...}
// 让我们看一下,首先是,一个批次中两个三角性索引缓存 = { 4,5,6, 8,4,6, ... }

被分为两个新的索引缓存。

我们构建一个集合,唯一的顶点索引,作为我们遍历三角索引用的。这个处理也就是我们都知道的 vertex de-duplication(顶点去重)。

vertexIndices = { 4,5,6,  8, ...}
// For the second triangle only vertex 8 must be added
// 第二个三角性只有一个顶点8是必须添加的
// and the other vertices are re-used.
// 而其他的顶点都被复用了。

图元索引被调整,相对于整个 vertexIndices

// 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 将会被开启。随后 meshlets 将被创建,并拥有他们唯一顶点集合。

Rendering Resources and Data Flow - 渲染资源与数据流

在渲染中,我们使用原始的顶点缓存。然而,不是原始的三角性的缓存,我们使用三个新的缓存,如下面图8展示:

  • Vertex Index Buffer:顶点索引缓存,就像上面解释到的。每个 meshlet 引用一系列的唯一顶点集合。这些顶点的索引被储存在一个缓存中,该缓存可以为后续的所有 meshlets 使用。
  • Primitive Index Buffer:图元索引缓存,就像上面解释到的。每个 meshlet 代表一个不定的图元数量。每个三角形需要三个图元索引,这些索引储存在但个缓存中。注意:在每个 meshlet 之后添加的额外索引可能需要4个字节对齐。
  • Mesh Desc Buffer:网格表述缓存。储存每个 meshlet 的 workload(工作负载)的信息和缓存偏移值,就想是 cluster culling 的剔除信息。

这三个缓存实际比原始的 index-buffer 要小,因为 mesh shading 允许有更高的顶点复用性。我们注意到减少的大小,大概在原始索引缓存大小的 75% 左右。
图8
图8. Meshlet 缓存结构

  • Meshlet VerticesvertexBegin 储存着从顶点索引的哪个位置开始读取。vertexCount 储存着连续的顶点数量。顶点在一个 meshlet 是唯一的;没有重复的索引值。
  • Meshlet PrimitivesprimBegin 储存着从索引的哪个位置开始读取。primCount 储存着在 meshlet 中涉及的图元数量。注意索引的数量依赖于图元的类型(这里类型为三角形:3)。有个重点注意的是,索引引用顶点相对 vertexBegin的,意味着索引 ‘0’ 将相当于顶点索引定位在 vertexBegin

下面的伪代码描述了每个 mesh shader workgroup 执行的原则。这一系列伪代码只为了阐明目的。

// This code is just a serial pseudo code,
// 这代码仅仅是一些伪代码
  // and doesn't reflect actual GLSL code that would
  // 并不反映着实际的 GLSL 代码
  // leverage the workgroup's local thread invocations.
  // 影响 workgroup 中的定位线程调用

  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;

mesh shader 也可以看作是像下面的并行写入方式:

void main() {
  ...

  // As the workgoupSize may be less than the max_vertices/max_primitives
  // workdgroup大小可以小于 max_vertecies/max_primitives
  // we still require an outer loop. Given their static nature
  // 我们仍然需要一个外部循环。让它们为 static 的
  // 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
  // 让我们将 8 个索引打包到一个 RG32 位的纹理中
  uint primreadBegin = meshlet.primBegin / 8;
  uint primreadIndex = meshlet.primCount * 3 - 1;
  uint primreadMax   = primreadIndex / 8;

  // resolved at compile time and typically just 1
  // 编译期间计算好,通常为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 
    // meshlets 的大小不超过
    // gl_PrimitiveIndicesNV array here
    // gl_PrimitiveIndicesNV 数据的大小

    writePackedPrimitiveIndices4x8NV(p * 8 + 0, topology.x);
    writePackedPrimitiveIndices4x8NV(p * 8 + 4, topology.y);
  }

  if (gl_LocalInvocationID.x == 0) {
    gl_PrimitiveCountNV = meshlet.primCount;
  }

这个例子只是一个简单的实现。由于所有数据获取都是由开发人员完成的,自定义编码、通过子组内部函数或共享内存进行解压缩,或者暂时使用顶点输出,都可以节省额外的带宽。

Cluster Culling with Task Shader - Task Shader 的剔除

我们尝试挤入更多的信息到 meshlet descriptor(描述器)中去执行提前的剔除。我们以尝试使用 128-bit 的描述器来编码入之前提到的数值,以及 G.whilida 提出的相对于一个BB(BBox)和Cone(圆锥体)的背面剔除。当我们生成 meshlets,需要平衡 cluster-culling 特性与提升顶点复用性。这可以会有负面的影响。

task shader 下面剔除 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. 
    // 写入一些剩余的 meshlets
    // mesh workgroups to spawn
    // 要生成的 mesh workdgroup
    gl_TaskCountNV = tasks;

    // where the meshletIDs started from for this task workgroup
    // meshletIDs 将从这个 task workdgroup 开始
    OUT.baseID = gl_WorkGroupID.x * GROUP_SIZE;
  }

  {
    // write which children survived into a compact array
    // 写入剩余下来的 children 到紧密的数组中
    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
  // 我们可以不在使用 gl_WorkGroupID.x
  // as it now encodes which child this workgroup is.
  // 现在编码 child 到这个 workgroup
  uint meshletID = IN.baseID + IN.subIDs[gl_WorkGroupID.x];
  uvec4 desc = meshletDescs[meshletID];
  ...
}

我们渲染巨量的三角性模型的上下文中,仅在 task shader 剔除 meshlets。其他场合可能涉及提取不同的 meshlet 数据,依赖 level-of-detail 来决定,或完整的生成几何体(例子,带条,等)。下面的图9是一个使用了 task shader 来为 level-of-detail 计算用的Demo。
在这里插入图片描述
图9。NVIDIA 行星 demo 使用了 mesh shading

[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

翻译完后,我发现这个作者的表达能力真的不好,推荐阅读:

先记录一下,后面等有显卡支持我再去实现 OpenGL 的 Mesh Shader demo

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章