图形管线之旅 Part5
原文:《A trip through the Graphics Pipeline 2011》
翻译:往昔之剑
转载请注明出处
在上一篇关于纹理采样器之后,我们现在回到了3D前端。那执行完了顶点着色,现在就可以实际的渲染东西了,对吗?可惜,还不行。你看,在我们实际开始光栅化图元之前,仍然还有很多事要做。所以在本篇里我们不会看到任何光栅化内容——还得等到下次讲。
图元组装
当我们离开顶点管线时,我们从shader单元里得到了一块着色过的顶点,这块顶点中包含了一些完整的图元——我们不会让三角形,直线或片被分割到多个块里。这很重要,因为这意味着我们可以真正的单独处理每一个块,并且不需要缓冲多个shader输出块——虽然可以缓冲,但没必要这么做。
下一步是组装单个图元的所有顶点。如果图元碰巧是一个点,就只需要读取确切的顶点并传递它。如果是直线,要读取两个顶点。如果是三角形,要三个顶点。并以此类推大量控制点的片。
简而言之,这里做的工作是收集顶点。我们既可以通过读取原始的索引缓冲来收集顶点并存一份顶点索引的拷贝——缓存周围的位置映射,或者我们也可以存储随同着色的顶点完全展开的图元的索引。这将花费一些空间用于存储输出缓冲,但是在这里我们就不必再读取索引了。用哪种方式都可以。
现在我们已经展开了组成图元的所有顶点。换言之,我们现在有完整的三角形了,而不仅仅是一堆顶点。那我们已经可以光栅化它们了吗?还不行。
视口剔除与裁剪
我猜我们应该先执行这个,对不?这是管线中,你应该最感兴趣的部分。我不打算在这里解释三角形裁剪,你可以在任何一本计算机图形课本上查到,尽管通常都是一大堆内容看上去很可怕。如果你想详细了解,就用Jim Blinn的(本书13章),虽然你可能会想通过传[0,w]的裁剪空间来替代,如果没别的方法就别搞混了。
裁剪简而言之,即:在齐次裁剪空间里,从vertex shader中返回顶点的位置。选用裁剪空间是为了使方程描述视锥体尽可能的简单;在D3D中,是
,
,
以及
;注意所有最终方程实际上排除了齐次点(0,0,0,0),这是一种退化情况。
我们首先需要找出三角形是部分的,还是完全的在裁剪*面之外。这可以用 Cohen-Sutherland直线裁剪算法高效执行。为每个顶点(例如,可以在顶点着色的时候计算,并随位置一起存储)计算输出码out-code(或裁剪码clip-code)。然后,对于每个图元,裁剪码clip-code的按位与运算将会告诉所有的视锥*面所有图元顶点在错误的一侧(意味着图元完全的在视锥之外,就可以被抛弃了),并且裁剪码clip-code的按位或运算将会告诉视锥*面需要再次裁剪图元。裁剪码只是硬件部分的很简单的东西。
另外,shader还可以生成一组“剔除距离(cull distances)”(如果所有顶点中任何一个剔除距离小于0,该三角形就会被丢弃),和一组“裁剪距离(clip distances)”(定义了额外的裁剪*面)。这些还用来参考图元的rejection/clip testing。
在实际的裁剪过程中,可以采用两种形式:我们既可以使用多边形裁剪算法(会添加额外的顶点和三角形),也可以添加额外的裁剪边方程到光栅器里(如果听不懂没关系,等到下个部分讲光栅化,就理解了)。后者方式更好,完全不需要实际的多边形裁剪器,但是我们得需要能够规范化32位浮点值来作为有效的顶点坐标;可能会有技巧构建快速的硬件光栅器来这样做,但是似乎很困难。所以我认为有一个实际的裁剪器,包含了所有相关的东西(生成额外的三角形等)。这很麻烦,还很珍贵(比你想的要珍贵,我马上就会讲到),所以它不是个大问题。不确定是否特殊的硬件也是,或者执行实际的裁剪;专用的裁剪单元的大小和需要多少,取决于在这个阶段派发一个新的顶点着色负载是否合适。我不知道这些问题的答案,但是至少在性能方面,它不是很重要:实际上不会频繁地“真的”裁剪。因为我们会用到保护带裁剪(guard-band clipping)。
保护带裁剪(guard-band clipping)
这个名字不是很恰当;这不是一个神奇的裁剪方法。事实上,恰恰相反:直截了当的不做裁剪:)
底层思路非常简单:在左,右,上和下裁剪面之外部分的大多数图元完全不需要裁剪。靠GPU来光栅化三角形,实际上的做法是扫描全屏区域(更准确的说,是裁剪区域scissor rect)并询问每个像素:“这个像素被当前三角形覆盖了吗?”(实际上这有点复杂,并且有更高效的方式,但这是常规思路)。并且这同样适用于三角形完全在视口内的情况。只要我们的三角形覆盖测试(coverage test)是可靠的,我们就完全不需要裁剪靠*左,右,上和下*面的部分。
这个测试通常都是用固定精度的整数运算。最后,一步步的得到一个三角形顶点,将会整数溢出并且得到错误的结果。我觉得由光栅器生成像素而不是在三角形中生成,这点让人感觉很不爽非常,这应该是不合理的。硬件实际上是违反了规范的。
针对这个问题有两个解决办法:首先是确保绝对不会进行三角形测试。如果真正做到了这点,那么就不用裁剪四个*面了。这就是所谓的“无限保护带”,保护带实际上是无限的。解决方案二是最后裁剪三角形,仅当他们在安全区域(光栅器计算不会溢出的区域)之外时。例如,光栅器有足够的内部位来处理整数三角形坐标:
,
(注意我这里都用大写的X和Y来表示屏幕空间的位置)。仍然用常规的视*面做视口裁剪测试,但实际上在投影和视口变换之后只是裁剪了指定的保护带裁剪*面,结果的坐标都在安全区域里。如图所示:
中间的小块蓝边白色矩形表示我们的视口,而大块的橙色区域就是保护带(guard band)。图中的视口看起来貌似很小,但其实我还画大了呢,好让你可以看到所有东西!在保护带裁剪范围-32768~32768里,视口大约是5500个像素宽度,这里可以容纳下一些很大的三角形。这些三角形表示了某些重要情况。黄色三角形是最常见的——延伸到视口之外但没出保护带。这种可以通过测试,没必要进一步处理。绿色三角形在保护带以内视口区域以外,所以它会被视口裁剪掉不会通过测试。蓝色三角形延伸到了保护带裁剪区域之外,需要被裁剪,但它完全在视口区域之外,会被视口裁剪拒绝。最后的紫色三角形既延伸到了视口区域之内又延伸到了保护带之外,就需要被裁剪了。
如你所见,这几类三角形需要被四个侧面裁剪都是比较极端的情况。正如所说的,不要担心,这都是很罕见的情况。
题外话:正确的裁剪
如果你熟悉算法,这块就不是很难。但其中细节总是很神奇。三角形裁剪器实际上不得不遵守一些潜规则。如果破坏了这些规则,共用一个边的邻接三角形就会产生裂缝。这是不允许的。
- 视锥内的顶点位置必须被裁剪器保存为比特率(bit-exact)。
- 裁剪一个*面中的边AB必须与裁剪边BA(方向相反)产生相同的结果(这可以保证数学上完全对称,或者可以保证总是裁剪相同方向上的边)。
- 对多个*面裁剪的图元必须按相同的顺序对*面裁剪(或者一次对所有*面裁剪)。
- 如果用到了保护带,必须对保护带*面裁剪。如果真的需要剔除,就不能用保护带了,得对原始的视口*面裁剪。不这么做的话会产生裂缝。
讨厌的远**面
好吧,虽然对于4侧*面有很好解决方案,但是对于*和远*面呢?尤其**面是很麻烦的,因为所有。那我们该怎么做呢?用z保护带吗?但是要怎么工作呢——我们实际上并没有按z轴来光栅化!事实上只是在三角形上做插值!
另外,这只是三角形的插值。实际上插值Z的话,z-near test(Z<0)是很简单的——只是 符号位。而z-far(Z>1)要额外比较(这里我使用Z,而不是z,表示屏幕坐标或投影之后的坐标)。但是我们还要进行逐像素的Z比较(Z test),所以这不是很大的开销。视情况而定,但这样执行z裁剪是一个可选项。如果你想要支持像NVidias的“depth clamp”OpenGL扩展的话,就需要跳过z-near/z-far裁剪。实际上,这个扩展很好的暗示了他们是这样做的,至少用过一段时间。
对于w>0的裁剪。也能摆脱它吗?答案是当然,比如齐次坐标的光栅化算法( http://www.cs.unc.edu/~olano/papers/2dh-tri/)。 我不确定硬件是是否这样用的。这个方法不错,不过很难符合D3D11的光栅化规则。也可能用一些我不了解的技巧。以上就是裁剪相关内容。
投影和视口变换
投影只需要将x,y和z坐标除以w(除非你使用了齐次的光栅器,否则实际上是并不投影,下面将忽略这种可能性),就得到了在-1到1之间的NDC(规范化的设备坐标Normalized device coordinates)。然后用视口变换将投影的x和y映射到像素坐标(将称为X和Y)以及投影的z映射到[0,1](将称为Z),这样在z-near*面Z=0并且在z-far*面Z=1。
我们还要对齐像素到子像素格上的小数坐标。从D3D11开始,硬件需要精确的8位三角形坐标的子像素精度。这个对齐会把一些非常窄的碎片(这些碎片会导致问题)变成退化三角形(不需要被渲染)。
背面和其它三角形剔除
当我们拥有了所有顶点的X和Y,我们就可以叉乘边向量来计算标记的三角形面积。如果面积是负值,三角形就是逆时针的(在这里负面积对应逆时针,因为我们正处于像素坐标空间,在D3D的像素空间中y向下增加而不是向上增加,所以符号是相反的)。如果面积是正值,就是顺时针。如果是0,就是退化三角形,不覆盖任何像素,那么它就可以被安全的剔除了。我们知道了三角形朝向就可以进行背面裁剪了(开启的情况下)。
我们现在快准备好光栅化了。实际上我们还得先设置好三角形。但这块还需要光栅化如何执行的知识,所以我会把放到下一篇再讲。
结束语
我跳过并简化了一部分内容,实际情况要更复杂:比如,我假设你只是使用常规的齐次裁剪算法。通常是这样——但你可以用一些vertex shader属性标记作为使用屏幕空间线性插值来替代透视矫正插值。目前,常规齐次裁剪都是透视矫正插值;在使用屏幕空间线性属性的时,你实际上需要执行一些额外的工作来不进行透视矫正:)
有很多光栅化算法(比如我提过的Olanos 2DH方法)可以让你跳过几乎所有的裁剪,但如前所述,D3D11对于三角形光栅器需求很严格,所有没有很多硬件实现的余地;我不确定那些方法是否符合规范(有很多细节下次会介绍)。我用的方法不是很先进,在光栅器中逐像素处理上用到少量的数学运算。如果你知道更好的解决方案,请在评论中告之。
最后,三角形剔除我这里描述的是最基本情况;例如,一类三角形在光栅化时会生成零个像素远大于零面积的三角形,如果你可以足够快的查找到它,你就可以立即丢弃掉这个三角形并且不需要经过三角形设置。最后说一点,在三角形设置之前以最低限度的光栅化进行剔除——找到其它方法来早期拒绝(early-reject)三角形是相当值得的。