像素处理比较复杂,因此这里会将之拆成两个部分来介绍:第一个部分其实可以说是图形程序员认知中的像素处理的全部内容——dispatch工作以及实际上的像素着色工作;第二个部分对应的则是Alpha Blend以及Late-Z Stage,在人们的印象中,这块内容更多的会被当成一种后处理工作。这样的拆分方式有作者自己的考虑,看下后面会不会提到。
进入到像素着色阶段后,我们现在手头上有的数据就包括了像素(或者更准确的说,quads)的坐标,以及从光栅化过程或者Early-Z阶段输出的coverage mask(干啥的?用于指示quads中究竟哪些像素时被当前triangle所覆盖,具体覆盖范围是怎样的),还有按照提交顺序排序好的triangle数据。我们需要做的只是将这些线性的顺序的工作流分发到数百个shader units中,之后在这些shader units完成工作处理并返回结果之后,再将结果组合成一个线性的数据流用于完成对内存数据的更新。
这个过程可以看成是fork/join并行处理机制的一个教科书式的应用,本文主要介绍fork阶段,而下一篇文章则会介绍join阶段。不过首先还要补充一些光栅化相关的内容,因为前面说的输入数据是单个线性的数据流,其实这种说法并不完全正确。
Going wide during rasterization
实际上,前面说的单个线性数据流的输入,在很长一段时间内是正确的。不过这个阶段使用的是一种线性处理模块,如果我们需要在某个问题上使用超过300个shader units的话,线性处理模块就很有可能成为整个管线的瓶颈。因此GPU架构师尝试使用多个光栅化处理组件。在2010年,NVidia给出了四个光栅化组件的实现方案,而AMD则给出了两个光栅化组件的方案。NVidia的实现方案中可能会需要保证逻辑的运行顺序跟API的顺序保持一致,准确的说,应该是需要在光栅化以及Early-Z处理阶段之前就能够恢复primitives顺序,在Alpha Blend之前才开始进行primitive 排序都不行。
多个光栅化组件之间的工作分配是基于此前介绍的粗糙光栅化过程的tile来进行的,将整个frame buffer分割成以tile为基本尺寸的多个区域,之后每个区域都会被分配一个光栅化组件。在setup完成之后,会根据每个triangle的bounding box来决定triangle与光栅化组件之间的连接关系。一些尺寸较大的triangle可能会需要同时被多个光栅化组件所处理,而小一点的triangle可能只会对应于一个光栅化组件。
这种方案的优点是,只需要修正工作分配逻辑以及粗糙光栅化处理逻辑(负责对tile进行遍历),其他的工作逻辑都是在tile中工作的,不需要做任何的改变。这种方案的问题在于,工作的分配只是基于屏幕空间的范围来划分的,因此其划分的工作负担可能不是很均衡,从而导致光栅化组件的负载出现较大的偏差。这种方案的优点在于,一些对primitive顺序有依赖的处理模块(比如Z-test/write顺序,blend顺序)都会被绑定到frame buffer相同的位置,因此屏幕空间划分算法不会打乱API顺序。
You need to go wider!
因此,总的来说,我们这边处理的输入并不是一个单一的数据流,而是两个或者四个数据流。不过我们依然需要将工作分配到数百个shader units上,因此我们需要一套不同的数据分配单元(dispatch unit)。这也就意味着我们需要另外的一个buffer。这里需要解决的一个问题是,我们发送给shader的batch数据的尺寸是多大的?由于NVidia在他们的 白皮书中公布了相关数据(AMD可能也公布了,不过这里没有找到),因此这里会以NVidia的数据来举例。对于NVidia而言,dispatch unit的线程数是32,这32个线程组成了NVidia所说的一个Warp。每个quad有四个像素(每个像素可以当成一个线程来处理),因此对于我们发起的每个shading batch,我们都需要从光栅化组件中拿到8个输入quad。
这也是为什么我们前面所说的,我们在着色的时候通常是处理2x2个像素组成的quad而非单个像素的原因。主要的原因是为了计算微分。贴图sampler会需要根据uv坐标在屏幕空间的微分结果来进行mipmap选择与filtering,对于Shader Model 3.0及以后的其他Shader Model,还支持了使用相同原理的微分计算指令。在quad中,每个像素在quad中都有一个水平的以及垂直的邻居像素,利用这个数据就能够使用 有限差分(finite differencing) 方法(其实就是几个减法)估计出参数在xy方向上的微分结果。这就提供了一种非常廉价的微分计算方案,其代价就是每次着色都是以2x2个像素块为单位。对于大尺寸三角面片而言,这种代价并不算什么限制,只是在triangle edge上的着色工作中的25~75%都是浪费的(因为quad中的每个像素,不管是否处于coverage mask中,都会进行着色计算),而这是为可见像素计算正确可靠的微分结果所必须的。那些不可见的但是依然参与到着色计算的像素通常被称为“辅助像素”。对于小尺寸三角形,这里有个图形介绍:
这个三角面片与四个quad存在交集,不过只在其中三个quad中生成了可见像素。在这三个quad中,每个quad只有一个像素被cover主(每个像素的采样点用一个黑色的圆圈来表示)——填充的像素以红色表示。在每个部分cover的qua中的剩下的像素都是辅助像素,以一个稍微浅一点的颜色表示。从这个图上可以看出,对于小尺寸的三角形而言,其中大部分的像素都是辅助像素,关于这个现象,还有很多研究者尝试将相邻triangle的quads进行融合,虽然给出的方法非常的精巧,但是却不满足当前API的规则,也不被当前硬件所支持(如果厂商认为这个问题足够严重的话,这个情况可能会发生变化)。
Attribute interpolation
PS的另一个独特feature是属性的插值——其他的Shader类型(VS,GS,HS,DS,CS)等都是直接使用前一个stage的输出数据,而PS则是会对前一个Stage的输出数据进行一个插值操作,前面一篇文章介绍了深度属性的插值处理,这是我们目前见过的第一个插值属性。
其他的属性的插值过程跟深度属性差不多,在triangle setup的时候,会为这些属性计算一个对应的平面方程(GPU会将这个计算过程推迟到这个面片中至少有一个tile通过HiZ测试之后才进行,不过跟我们这里的介绍没什么关系),之后在PS阶段,会有一个单独的unit用于根据quad中的像素位置以及平面方程来实现属性插值。
Update: Marco Salvi在评论中指出,之前是有专属的插值单元负责这块的功能,不过现在硬件发展的趋势则是返回与平面方程相关联的质心坐标,实际的插值计算(每个属性对应的两个MAD指令)则是放在shader unit中完成。
上面的这些结论应该不会有什么争议,不过这里还有一些特殊的插值器需要介绍一下,第一个就是常数插值器。实际开发的时候经常会遇到一些属性是需要在整个primitive上维持不变的,在这个时候,插值器就会从leading vertex(在primitive setup阶段指定)上取出对应属性。而硬件层面有两种实施路径,一种是为常数插值指定一条特殊的快速实现路径;另一种则是直接跟普通的插值器保持一致,使用一个常量的平面方程来处理这个问题。这两条路径都是可行的。
第二个要介绍的是非透视(no-perspective)插值。透视插值的两种实现方法,一种是基于X,Y的插值,将每个顶点的属性按照X,Y的除以相应的W之后进行插值再处理得到;另一种则是质心平面方程插值,根据三角形的edge向量设定。非透视插值的实现就要简单一些,直接使用基于X,Y的插值,不需要再除以W。
“Centroid” interpolation is tricky
下面要介绍一下“centroid”插值。这个插值并不是一个单独的插值模式,而是插值中的一种实现算法,适用于透视与非透视(常数插值没有这个必要)。这个插值只有在multisampling打开的时候才有意义。在开启multisampling的时候,每个像素对应多个采样点,但是只shade一次,这一次是在像素中心进行的,就像是整个像素都被primitive覆盖一样,这种做法会有一些问题,而使用这个插值算法可以解决这个问题:
图中的四边形表示一个像素,大黑圈表示像素中心,四个小黑圈表示的是四个采样点。这个像素只有一部分被primitive覆盖,用黄色表示,注意像素的中心位置并未被primitive覆盖,因此这个primitive上像素属性的插值属于外插值。在这种情况下,如果渲染时的贴图使用了Atlas的话,就会导致当前像素使用的是另外一个输入贴图的uv坐标进行渲染,跟当前primitive完全不相关,从而使得结果出现异常。而Centroid sampling说的是统计primitive覆盖的所有采样点,并计算这些采样点的中心位置,之后在这个位置进行着色计算。当然,这里是理论的做法,实际上硬件的做法可能并不完全相同,只是会保证着色点位置处于primitive覆盖区域。
multisampling计算实际上可以分成如下两种情况:
如果所有的采样点都处于primitive覆盖之下,那么就跟普通的非centroid sampling一样,直接对中心点进行着色处理就行。
如果只有部分采样点处于primitive覆盖之下,那么只需要从这些采样点中随便抽出一个进行着色处理就可以了。
从多个采样点中抽出一个采样点用于着色计算,在此前是随机实现的(比如交给硬件来决定),不过现在DX11应该会规定了具体的抽取方法。总的来说,这是一个各个平台表现一致性的问题而不是一个用户所关心的API的调用问题。就像前面说的,这是一种比较hacky的方法,且可能会导致那些部分被primitive覆盖的quads在进行微分计算的时候得到错误的结果,太难了。虽然centroid sampling是一种工业级别的强力胶布(太过牵强),但总的来说还是一块可用的胶布。
最后,DX11还提出了一种pull-model属性插值方案。常规的插值过程在PS执行之前就已经完成计算,而pull-model插值则是将一些插值指令添加到PS中,让shader决定需要在什么位置计算着色,以及在哪些分支中需要进行插值处理。总的来说,pull-model插值方案给了PS一种在执行的同时调用插值处理单元的能力。
The actual shader body
下面介绍PS执行过程中的一些有意思的点。
其中要介绍的第一点是贴图采样。我们在前面第四篇内容中曾经站在贴图采样的角度介绍过这块内容,其中有一点说到,贴图缓存miss出现的频率很高,因此贴图sampler通常会被设计成在每次进行内存访问请求的时候至少能够维持一次不会产生stalling的miss,从而避免数百个cycle的浪费。但是这个机制会导致ALU的的极大浪费。
因此在这种机制被触发的时候,为了避免ALU的浪费,shader unit会快速切换到另外一个batch进行处理,当这个batch也触发了贴图采样之后,就会再次切换回上一个batch去确认贴图采样结果是否已经返回。这种做法能够极大的提高资源的利用率,虽然的确也会导致单个batch执行时间的加长,因此也算是延迟与吞吐量之间的平衡,很显然,这一次吞吐量赢了。这里需要注意的是,为了使得单个shader unit能够在多个batch(NVidia用Warp表示,AMD用WaveFront表示)之间切换,就需要一些额外的寄存器。因此,如果一个shader占用的寄存器数目较多,那么这个shader所能支持的warps数目就会相应下降,而数目下降就会导致可用的batch数目下降,就会导致stall的风险增高。
另外一点要介绍的是shader中的动态分支(比如循环或者条件指令等)。在shader中,在每个batch上的所有元素的运行逻辑通常会按照lockstep的方式前进,即所有的线程在相同的时刻都会执行相同的代码。对于if等条件语句的时候,为了达到这个目的,就会使用一些trick的方法:如果线程中某个线程想要执行if语句对应的then分支,那么这里的所有线程都要执行then分支,即使大部分的线程最终都不会用到这个分支的结果(通过一个叫做predication的技术对结果进行选取)。同样的,对于else分支,也有相同的运行逻辑。如果这些线程之间的条件判定结果基本一致,所选取的分支也基本相同的话,这种运行逻辑的表现就会非常良好,但是如果各个线程之间的条件选取比较随机的话,表现就会比较差。最差的情况就是每个batch,都需要同时执行条件指令的每一个分支。不幸的是,循环指令的工作机制也是类似的,只要任意一个线程需要继续执行后面的循环计算,那么同一个batch/warp/wavefront中的所有的线程都要跟着执行。
PS中另一个需要关注的点是discard
指令。ps可以通过这个指令放弃对当前像素的后续计算与写入。如果一个batch中的所有像素都被discard了,这个shader unit就可以停止并开始下一个batch,但是如果这个batch中有至少一个线程还在继续执行,那么这个batch中的其他线程就需要等待。DX11在这里添加了更多的精细控制机制,比如通过从PS中输出每个像素的coverage数值(这个结果将会与原始的triangle/Z-test coverage做相交处理,用以确保shader输出的结果不会超出primitive)。这种做法可以使得shader只discard一些采样点,而非discard整个像素。而这个机制也可以用于实现Alpha-to-Coverage算法(比如只需要在shader中添加一个自定义的dithering算法即可)。
PS还可以对输出的深度数值进行修改,但是会极大的妨碍Early-Z,HiZ以及Z Compression等机制的实施效率。
PS会为每个RT输出一个四个元素的向量,而RT数最大可以支持到8个。这些数据最终会被输送到管线的下游,进入到D3D所谓的Output Merger单元中(下一章内容)。
从D3D11开始,数据可以写入到Unordered Access Views(UAVs),而这个功能只有CS跟PS才支持。总的来说,UAVs会在CS的计算中取代RT,不过跟RT不一样的是,shader可以自行决定数据最终要写入的位置,且没有一种隐式的API执行顺序规范(这就是unordered access的名字的由来)。这个功能会在后面介绍CS的时候做更详细的介绍,这里只提一下相关的概念。
Update: Steve在评论中给了一个非常好的 链接(by Kayvon Fatahalian) ,这个链接介绍了GPU中的shader执行过程,大家有兴趣的话推荐仔细看看。