现在,我们的APP发出的绘制调用已经走过了各种驱动层和指令处理器。如今要做的就是进行真正的图形处理了。我会介绍顶点流水线。但开始之前,我们先认识认识一些
字母缩写
在3D流水线中存在着多个阶段,每个阶段完成特定工作。下面给出的名字是我以后会提到的,当然,多数和D3D10/11官方命名保持一致,只是加了一点缩写。在今后的很多章节里,我会一一讲解,知道这个系列的结束。OK,先给出这些缩写和它们简略的说明。
- IA——输入汇编器(Input Assembler):读入索引和顶点数据
- VS——顶点着色器(Vertex Shader):获取输入的顶点数据,为下一阶段输出处理后的顶点数据。
- PA——图元装配(Primitive Assembly):读入顶点并组装成图元,然后传递出去。
- HS——壳着色器(Hull Shader):接收补丁图元,输出变换后(没变换)的补丁控制点,作为域着色器(domain shader)的输入,加上一些额外数据来驱使曲面细分。
- TS——曲面细分阶段(Tessellator Stage):为细分后的线段与三角形创造新的顶点和连接。
- DS——域着色器(Domain Shader):将从HS来的已着色控制点,额外数据和从TS来的已细分位置融合转化,再次变为一系列顶点。
- GS——几何着色器(Geometry Shader):输入为图元和(可选)连接信息,输出万为不同的图元。主要起集线器的作用。
- SO——流输出(Stream-out):将GS的输出(比如转换后的图元)写入到内存中的某个缓存中。
- RS——光栅化(Rasterizer):光栅化各种图元。
- PS——像素着色器(Pixel Shader):获取插值顶点数据,输出像素颜色。也可写入UAVs(unorder access views)。
- OM——输出合并(Output Merger):获取来自PS的像素,做alpha混合,然后将它们回写到后缓存。
- CS——计算着色器(Compute Shader):独立流水线。唯一输入为常量缓冲器、线程的ID,可以写入缓存和UAVs。
下面列出各种各样的数据流路径,我会按顺序介绍。
- VS -> PS 早期可编程流水线。在D3D9时期,这就是你能控制的全部了。迄今为止,对于常规渲染,这仍然是最重要的路径。我会先从头到尾讲一遍这条路径,然后再介绍更先进的路径。
- VS -> GS -> PS 添加了几何着色(D3D10的新特性)
- VS -> HS -> TS -> DS -> PS, VS -> HS >TS -> DS -> GS -> PS:添加了细分曲面(D3D11的新特性)
- VS -> SO, VS -> GS -> SO, VS -> HS -> TS -> DS -> GS -> SO:添加了流输出(可选细分曲面)
- CS:添加计算着色器(D3D11的新特性)
现在你知道接下来是什么了吧,我们先从顶点着色器开始。
输入汇编器阶段
最先发生的肯定是从索引缓存里加载索引——当然,前提是这个缓存是个已索引的批处理。如果不是,就假装它是一个ID索引缓存(0 1 2 3 4….),把ID当作索引。索引缓存里的内容一般不是直接从内存里读取的,IA通常利用一个数据缓存来访问索引/顶点缓存。要注意索引缓存读取内容(D3D10+所有可访问资源)时会进行边界检测。如果你引用的元素在原始索引缓存之外(比如,在只5个索引的缓存中设置IndexCount == 6调用DrawIndexed),所有越界访问的返回值都为0。同样,你可以使设置一个NULL索引缓存来调用DrawIndexed,相当于你拥有一个大小为0的索引缓存,这样,以后所有读取都是越界访问,而且返回0。
拥有了索引,我们也就从输入顶点流中获取了所有需要的顶点、实例数据(在这个阶段,当前实例ID就是一种计数器,很简单直接吧)。真的非常直接——我们有数据格式的声明,只需从高速缓存/内存读取然后解压成着色器内核需要的浮点格式。只是这种读取不是立即模式,硬件此时正在运行已着色顶点的高速缓存。因此,如果一个顶点被多个三角形引用了,它就不需要每次都被着色——我们只需引用已存在的着色数据就行。
顶点的缓存与着色
注意:本部分内容在某种程度上讲只是“猜测”。
小标题中的两个东西基于“知情人士”对当前GPUs的公开评论。但那带给我的只有“WHAT”,而没有“WHY”,所以这儿会有一些个人推论。同样,我只是简单猜测一些细节。意思就是我在这儿说的在我的了解之内——但我有自信这些是可信可靠的。
长期以来(直到包含shader model 3.0的GPUs出现),顶点着色器与像素着色器被用于不同单元,拥有不同的性能权衡,而且顶点缓存是个相当简单的东西。通常对于少量(一打或是两打)顶点就一个FIFO,对于输出属性在最糟糕情况下的数量也有足够的空间,使用顶点索引作为标记。看吧,相当简单直接。
然后,出现了统一着色器。如果你要统一两种不同作用的着色程序,这种设计就变得非常有必要了。想想看,一方面,你的顶点着色单元需要每帧同时关联大约100万个顶点做一般应用。另一方面,你的像素着色单元每帧需要至少230万个像素来将1920×1200的屏幕做一次全屏填充——如果你想渲染更多,那可能需要更多的像素。你猜哪个单元会拉慢速度?
OK,解决办法就在这儿:扔掉一次处理一个顶点的过时顶点着色单元,换上强悍的统一着色单元,它有着最大的吞吐量,没有延迟,从此开始大批量工作(多大?现今这个数字是每批次16到64个顶点)。
如果不想着色效率低下,你需要16到64个顶点缓存缺失直到可以分配一个顶点着色器进行加载,但整个FIFO是不会这样一次性着色的。问题来了:如果一次性着色整批顶点,意味着实际上你得等所有顶点着色完毕后才能开始装配成三角形。届时,你已在FIFO末端添加了整批(统一成32个)顶点,意味着前面有32个顶点被挤掉了——而其中每一个顶点都有可能已经被当前正在装配的三角形顶点缓存命中。看吧,这明显不能工作。我们正在引用前面快被挤掉的的32个旧顶点,没法在FIFO中把它们看成顶点缓存命中。还有,我们要让这个FIFO多大呢?如果一批着色32个顶点,它至少需要32个入口吧,而我们又不能使用旧的32个入口(因为正在使用/移除它们),这意味着每批次都会遇到空空的FIFO。因此,让它更大,64个入口怎么样?看起来足够大了。每次顶点缓存查找都会在FIFO内将该标签(顶点索引)和所有标签比较一次——这个操作高度并行化,但还是耗时,我们可以在这儿高效地实现一个全关联高速缓存。还有,在分配一个32顶点着色加载器和接收结果这段时间内,我们做什么呢?——只能等着?着色会花上上百个周期,干等是很SB的。交替使用两个着色加载器来做并行化处理?但现在我们的FIFO至少需要64个入口的长度,没法把最后64个入口看作顶点缓存命中,因为我们接受结果的同时它们会被移除。而且,要一个FIFO对抗很多着色核心?不要忘了Amdahl定律(阿姆达尔定律)在这儿是有效的。
这种一体FIFO不是很适应这种环境,所以,扔掉它,我们重新开始吧。我们真正想做的是什么呢?获取一个合适大小的顶点批次用来着色,不需要着色超过必需的顶点数。
所以呢,简单点,为32个顶点(一批次)预留足够的缓存空间,同样为32个入口预留高速缓存标签空间。然后从一个空的“高速缓存”开始,比如所有入口无效。为索引缓存内的每一个图元在全部索引上做一次查询。如果命中一个高速缓存,OK了。如果没有,就在当前批次内分配一个追踪,为高速缓存标签数组添加新的索引。一旦剩下的空间不够再添加新的图元,就将整批次分配给顶点着色器,然后保存高速缓存标签数组(例如,刚刚着色了的顶点的32条索引),开始设置下一批次,又从一个空的高速缓存开始——确保批次间的完全独立性。
每一批都会让一个着色器单元忙一会儿(至少上百个周期)。但没关系,因为我们有足够的着色器单元——只需另找一个单元执行另一批。高度并行化。最终得到结果返回 。届时,我们使用保存的高速缓存标签和原始索引缓存数据来装配图元,然后又送进流水线——这就是“图元装配”,我稍后会讲到。
顺便说一下,我说的“得到结果返回”是什么意思。我们在哪结束的?有两种选择:1、专用缓存 2、一些通用高速缓存/暂存器,以前一般是1,围绕固定结构设计的顶点数据(每个顶点有16个float4 矢量属性空间),最近的GPU更偏向2了。2更灵活,拥有明显的优势——你可以把这个存储器用于其他着色阶段,反之,专用顶点高速缓存对像素着色或计算流水线根本没用。
下图是目前为止所描述的顶点着色数据流
着色器单元内部构件
简介:这差不多就是你期望拆开HLSL编译器能看到的。它只是个擅长运行某类代码的处理器,在硬件上做的就是将着色程序字节码编译成某些东西。不像我前面讲的那些,这个东西文档非常完善——如果有兴趣,可以看看AMD/NVidia的会议演示或阅读CUDA/Stream SDK中的文档。
执行概要:快速ALU主要位于一个FMAC(Floating Multiply-ACcumulate)单元周围,一些HW支持倒数,平方根倒数,log2, exp2,sin,cos,优化高吞吐量高密度没有低延迟,执行高线程量来掩饰延迟,每个线程只有很少量寄存器(因为线程确实太多),非常擅长执行线性代码,不擅长分支结构。
上述差不多是全部的共有执行。当然也存在一些差异。AMD的硬件习惯直接使用HLSL/GLSL和着色程序字节码中默认的4位宽SIMD(最近看起来要改动了),而前阵子NVidia趋向于将4位宽SIMD转化为标量指令。反正,互有交集吧。
有意义的是各种着色阶段的不同之处。简介实在很简略,例如,所有的算法和逻辑指令在各个阶段都是完全相同的。一些设计(比如像素着色器中的派生指令和插值特性)只存于某些阶段,但主要的区别还是输入输出传递的数据类型的不同。
当然,还有一个与着色器相关的特别之物,就是纹理采样(纹理单元),这个主题有点大,所以我把它单列一章,下一章来讲述它。
结束语
再说一次,“顶点的缓存与着色”部分有些是我的猜测,所以不可全信,要有点保留意见。
我也没有阐述高速缓存/暂存器存储是如何管理的细节。缓存的大小(主要)依赖于你处理的批次的大小和你期望顶点输出属性的多少。缓存大小与管理对性能来说非常重要,但我在这儿没讲也不想讲。因为这个东西对任意硬件都很特殊,但没啥深度。
下节再见。