通过前面几篇的内容,我们知道,应用程序中的Draw API调用会经过D3D Runtime, 用户态驱动等等各个层级,最终将命令传到GPU的命令解释器,GPU就会根据命令的内容来进行图形的计算和处理。那么这一篇我们就来看看顶点的处理流程。

1. 开胃菜

在介绍GPU的顶点处理之前,我们先看看3D渲染管线。3D渲染管线是由一系列的stage构成的,每个stage都完成一些特定的工作。下图是D3D 11的渲染管线。

D3D 11 渲染管线

D3D 11 渲染管线
上图中的每个每个方框内都是一个stage的名称,这个名称就是D3D10/11的官方文档的叫法,括号里面的就是它的缩写。在后续的内容中我们会逐渐的介绍它们,在这里我们先对它们都用一句话来做个总结性的介绍。
  • IA — Input Assembler. 读取索引数据和顶点数据.
  • VS — Vertex shader. 读入传进来的顶点数据,对顶点进行处理,传出处理之后的顶点数据.
  • PA — Primitive Assembly. 读入顶点数据,并将顶点数据组装成图元,然后将图元传出.
  • HS — Hull shader. 接受批量的图元,对批量图元的控制点进行转换,输出给DS, 同时添加一些额外的信息给Driver中做细分.
  • TS — Tessellator stage. 为曲面细分的线或三角形创建顶点和连接.
  • DS — Domain shader. 使用shader的控制点,以及来自HS的额外数据和来自TS的曲面细分位置,再次将它们转换为顶点.
  • GS — Geometry shader. 输入图元,选择性的使用顶点邻接信息,然后输出得到不同的图元.
  • SO — Stream-out. 将GS的输出写到Buffer中.
  • RS — Rasterizer. 将图元进行光栅化处理.
  • PS — Pixel shader. 获取插值后的顶点数据,输出像素颜色, 也可以写入UAVs.
  • OM — Output merger. 从PS获取着色后的像素,进行alpha blend并将它们写回backbuffer.
  • CS — Compute shader. 在它自己的管道中本身。 输入是常量缓冲区+线程ID; 可以写入缓冲区和UAV.

现在已经开始了,这里是我将要讨论的各种数据路径的列表,按顺序排列。我将在这里省略IA,PA,RS和OM阶段,因为他们实际上没有对数据做任何事情,他们只是对数据进行了重新的排列或者排序,它们本质上是将stage连接起来。

  1. VS —> PS: 经典的可编程管道. 在D3D9中只有一条可编程的流水线. 这个路径仍然是目前最重要的常规渲染路径。后面我们将从头到尾地经历这个过程,一旦我完成了这个流程,我们就可以关注其他更有趣的流水线了.
  2. VS —> GS —> PS: GS是在D3D10中新引进来的一个stage.
  3. VS —> HS —> TS —> DS —> PS, VS —> HS —> TS —> DS —> GS —> PS: Tessellation 是在D3D11中新引进来的.
  4. VS —> SO, VS —> GS —> SO, VS —> HS —> TS —> DS —> GS —> SO: Stream-out的管道 (可以经过或者不经过Tessellation).
  5. CS: Compute. 这也是D3D11新引入的,它是自己独立的一个通路.

让我们从VS开始吧!在进入VS之前我们可以再看看前面的那个图,在VS之前我们还必须经过IA.

2. Input Assembler stage

如果使用了索引缓冲区(Index Buffer)的话,这里发生的第一件事就是从索引缓冲区中加载索引数据。如果没有使用索引缓冲区,只需假设它的索引缓冲区中为0 1 2 3 4…,并将其用作索引。如果存在索引缓冲区,那么此时将从内存中读取其内容,而不是直接使用0 1 2 3 4…。IA中通常有一个数据缓存用来访问索引/顶点缓冲区的位置。需要注意的是,索引缓冲区的读取是要进行越界检查的(实际上,d3d10+中的所有资源访问都需要进行越界检查)。如果引用到原始索引缓冲区之外的元素,得到的值就是0. 比如说DrawIndexed 中设置indexCount为6,但是Index buffer实际上长度只是5,那么越界的索引访问结果就会得到0。这个定义看起来是没有什么作用的,但是有可靠的访问结果有时候就可以在特定的场合派上用场。你可以给drawindexed传入一个空的索引缓冲区,那么这时候drawindexed的效果就像是给drawindexed传入了一个内容全部为0的索引缓冲区。 一旦我们有了索引,我们就可以从输入顶点流中读取每个顶点和每个实例的数据。我们有一个数据布局声明,只需从缓存/内存中读取它,然后将其解包为我们的Shader cores想要输入的浮点格式。但是,这个读取不会一下子就完成。硬件正在运行着色顶点的缓存,因此如果一个顶点被多个三角形引用(在完全规则的闭合三角形网格中,每个顶点将被大约6个三角形引用)。它不需要每次都进行数据的拷贝——我们只是引用已经存在的数据!

3. 顶点缓存和顶点着色

注:本节内容中的部分是猜测的。这些内容基于业内人士对当前GPU的一些说法和解释,但是他们只解释了是怎么工作的但是没有说为什么要这样做,所以这里有一些推断,但这里的猜测都是一些细节,而且这些猜测也不是完全的胡说八道。这里的猜测绝对都是合理而且是讲得通的,但不能保证在真实的硬件中就是这样的,而且也不能保证这中间丢掉了一些细节。

在很长的一段时间内(包括着色器模型3.0时代的GPU),VS和PS都是使用不同的处理单元来实现的,而且都要基于不同性能进行权衡。顶点缓存是一个想当简单的事情:通常它只是一个用来存放顶点的FIFO, 大小一般也就是十几到二十几个,这个大小就可以满足在较差的情况下保证有足够的空间存放顶点的输出属性。 然后就是统一的着色器出现了。如果将过去不同类型的shader统一起来(VS, PS),设计过程肯定会需要做很多妥协。一方面, 顶点着色器VS在正常的使用过程中可能会触及到上百万个顶点。另一方面,Pixel ShaderPS在处理一帧1920*1080的图像时,每帧图像至少触及到230万个像素点才能将整个屏幕填满。如果你要渲染出更为复杂的图形的话,那就需要更多了。所以,他们中间谁会成为性能的瓶颈呢? 这里就有一个权衡:以前的顶点着色器单元每一次处理一个或者几个顶点,现在有了性能强大的统一着色处理单元,它是基于低延迟,高吞吐量来设计的,因此它需要的是一次处理一批一批的内容。一批能有多少呢?目前(2001年),一批的数量在16~64个顶点。 所以,如果你不想出现性能的浪费,你可以在16~64个顶点缓存cache miss的时候去加载数据,然后让着色器工作。这时候好像整个FIFO还没有真正的发挥作用,问题是如果你一次性的去处理一批顶点,那么一旦完成这些顶点的处理接下来的只能是开始组装三角形。基于这一点考虑,你可能会将一批顶点直接放进FIFO的尾部(假如说一批有32个顶点),这意味着现在有32个旧的顶点会被丢掉,但是这些丢掉的顶点中很可能有就是我们当前组装三角形所需要的顶点,这种方式明显就是有问题的。这里我们也没有办法保证FIFO中最老的32个顶点可以被顶点缓存命中,因为我们使用他们的时候可能他们已经不在FIFO里面了。而且我们让这个FIFO设计成多大比较合适呢?如果我们一批顶点是32个的话,那么FIFO的大小至少应该是32个,但是我们无法使用上一批中的顶点,因为它们已经不在FIFO里面了。那么我们可以将这个FIFO设计的更大一点?64个?那又太大了,需要注意的是每个顶点缓存的查找都涉及到将顶点索引和FIFO中的所有标记进行比较——这些是完全并行处理的,但这还是会降低性能。我们在这里有效的实现了一个完全关联的缓存。另外,在调度32个顶点进行着色处理和接收到结果中间我们做什么呢?只是等待着?这中处理一般需要几百个时钟周期,等待似乎是个愚蠢的做法。或许我们可以考虑上一篇中的方法,我们使用两个shader core进行并行处理,现在我们的FIFO至少需要64个Entry的长度,而且我们不能保证上一个64个Entry中的顶点可以hit到,因为在我们接收到结果的时候他们已经从FIFO中移出去了。这是一个FIFO vs. 两个shder core, 我们还可以使用一个FIFO vs. 多个shader core. 这种情况下Amdahl定律仍然成立——将一个严格的串行组件放到一个完全并行的管道中,这无疑是让它成为瓶颈的方法。 弄一个FIFO看起来真的不能很好的适应这种环境,所以,将这种方式直接扔掉。回到原来的位置,我们实际上想做的是什么呢?获得一个批次相当大数量的顶点来处理,但是又要尽量不要那些不需要使用的顶点。 所以,为了保证简单: 分配足够的空间给32个顶点(=一个批次),同样的为32个entry分配标记缓存,使用一个空的缓存作为开始,即所有的entry都是无效状态。对于index buffer中的每一个图元都做一次索引的查找。如果在缓存中hit到了,就直接使用,如果没有hit到,在当前的批次中分配一个slot,然后将新的索引添加到缓存标记数组中。一旦我们没有足够的空间来添加新的图元,那么我们将这一批都送给shader core进行处理, 然后保存缓存标记数组(也就是我们送下去的32个顶点的索引),接着就从一个空的cache中处理一个新的批次,这样就保证的了批次之间的相对独立。 着色处理单元处理一个批次大概需要几百个时钟周期,但这也没有关系,因为我们有多个着色处理单元,这就实现了并行化。我们最终会得到结果,此时我们可以使用保存的缓存标签和原始的索引缓冲区数据来进行图元的组装(这些是在“Primitive Assmbly”做的,在后面的部分会讲到)。 当我们说获取结果的时候,这些结果到底在哪里呢?主要有两个选择:1.专用的缓冲区; 2.一些通用缓存/内存。以前使用的是1,具有围绕顶点数据设计的固定结构(每个顶点具有16个float4向量属性的空间)。但是现在的GPU都倾向于是用2,也就是使用通用的缓存或者内存,这种方式相比更加的灵活,它明显的优势就在于你可以将这块内存用于其他的shader stage。

vertex shading data flow

vertex shading data flow
## 4. 着色器单元内部 简短版本:着色器单元内部处理的内容和你反编译HLSL编译输出(fxc /dumpbin是很好的工具)所看到的内容差不多。只有处理器能很好地运行这类代码,而在硬件中完成这类事情的方式是构建一些非常接近着色器字节码的东西。这与我到目前所讨论的内容不同的是,它有很好的文档,如果你感兴趣,可以查看AMD和NVidia的会议演示文稿,或者阅读CUDA/Stream SDKs的文档。 总之,快速ALU主要围绕FMAC(Float Multiply-ACcumulate)单元。一些HW支持倒数, 倒数的平方根, log2, exp2, sin, cos, 为高吞吐量和低延迟做优化, 运行多数量线程来降低延迟, 每个线程对应的寄存器数量相当少(因为运行的线程数量太多了!),这些线程都擅长执行没有分支的代码。 这些对于所有的实现几乎都是通用的。当然也有一些不同: AMD的硬件曾经直接坚持使用HLSL/GLSL来实现4-width的SIMD (似乎他们最近正在改变这些做法), 而Nvidia不久前决定将4-way的SIMD转换成标量指令。 不过,值得注意的是不同着色器阶段之间的差异。简而言之,它们确实相当少;例如,所有的算术和逻辑指令在所有阶段都是完全相同的。一些结构(如导数指令和`Pixel Shader`中的插值属性)只存在于某些阶段;但是,主要的区别在于传入和传出数据的类型(和格式)。 与着色器有关的有一个特殊的部分,虽然这是一个足够大的主题值得自己的一部分。这部分就是纹理采样(和纹理单元)。这将是我们下次的主题!到时候见。

5. 结束语

再次重申我对“顶点缓存和着色”部分的免责声明:这部分中有些是我自己的猜测。我也不打算详细介绍如何管理scratch/cache内存,缓冲区大小主要取决于你处理的批的大小和你期望的顶点输出属性的数量。缓冲区大小和管理对性能非常重要,虽然很有趣,但是这些东西是特定于你所谈论的任何硬件的。