A trip through the Graphics Pipeline 2011 (1 - 6章笔记)
A trip through the Graphics Pipeline 2011:
这是一部比较经典的文章系列,主要针对图形管线做了大量的底层描述,提供了一个从图形硬件设计师的角度来看图形管线的视角,帮助我们了解图形管线背后硬件的一些工作细节。
笔者这里做了一些比较详细的记录,请注意并非逐字句的翻译,一方面原作者大量的口语化的内容有些影响阅读,另一方面我认为应当建立在真正理解的基础之上用自己的语言重新描述。另外由于能力有限,笔者仅仅只对理解的部分做了比较详细的记录,如有理解错误的地方,欢迎指正。
1.Introduction; the Software stack.
图形管线在软件层面上的流程
Application:
这是最上层
API Runtime:
图形库API这一层,诸如dx,opengl等,这一层组织上层提交的resource,state,drawcall,做一些校验处理,最终将其提交给图形驱动,准确点说,是user-mode driver
User-mode Driver:
这是驱动在用户态的部分,和application运行在同一进程,提供非常底层的API接口用于给图形库调用,对应的库是:nvd3dum.dll,atiumd*.dll。shader编译也是在这一层处理,d3d把校验后的shader token stream传进来,同时d3d会对HLSL代码绘做high-level的优化(循环优化,冗余代码剔除,常量传递等),这对于umd来说也极大减轻了它的负担,毕竟这些优化都是非常耗时的。当然,driver层也会自己做一些low-level的优化(寄存器分配,循环展开),实际上就是先转成中间表示(IR)然后再针对IR做后端优化编译成机器码,shader机器码已经非常接近d3d bytecode了(dxbc),不需要花太多的精力就能获得一个比较好的结果,但仍然有一些底层贴近硬件的优化d3d是没法做的,并不简单。
硬件厂商也会针对知名游戏的shader做一些手动特定优化,譬如直接检测shader并替换。。。这也是发生在UMD层的
顺便,由于shader编译的原因,这也就是为什么很多时候游戏里面加载一个物体会有一定延迟才出现,一般处理都是在之前去提交一个空的drawcall预热这个shader。
值得注意的是,driver需要做老版本shader mode的兼容
这里面有一部分内存管理的工作,UMD需要为一些resource申请内存空间,UMD会从KMD申请的一大块内存空间上去做内存管理分配,这比较类似于cache。像map/unmap这种操作(哪些GPU内存地址是UMD可访问的,哪些内存地址是GPU可访问的)这些全部只有KMD才有权限做。
UMD还会做一些诸如swizzing texture之类的工作,在显存和内存做一些传输调度。最重要的是,它负责写command buffer(DMA buffer)(注意,command buffer是在KMD上分配)。API层的pso state change,drawcall,等等都会被UMD转成硬件可识别的command。
事实上,driver会更多的把工作放到UMD层,这一层是用户态的,意味着更少的内核态切换,可以处理所有用户态就能搞定的事情,同时只需要一个dll即可,即使出问题崩溃,也只是影响application,不会影响系统,方便调试,方便部署等。
Enter the scheduler:
graphics调度器,专门用来处理不同app访问driver的时间片调度,保证同一时间只有一个进程的command提交给了driver
Kernal-mode Driver:
KMD直接和硬件打交道,它会面对很多UMD,但是一个系统仅有一个KMD,如果KMD崩了,系统通常会蓝屏(这也是为什么driver倾向于把更多的事情放到UMD的一个重要原因)。KMD需要负责调度不同的应用对GPU的操作和访问,更重要的是,它管理真正的command buffer,也就是面向硬件的指令(操作码),UMD产出的command需要在KMD上调度提交到硬件(一般real command buffer都是ring buffer,一个读指针,一个写指针)
The bus:
前面的写command显然不是直接往硬件写,还需要很重要的走bus的过程,通常是PCIE DMA
Small aside: OpenGL
对于OpenGL来说,API和UMD driver层没什么差异了(对比dx)API层完全不管glsl的编译,编译都是在driver层做的,这样的问题是不同的硬件厂商各自都会实现一套glsl前端,各自遵循相同的规范,但有自己的特性和缺陷。这导致driver很重,要处理很多shader的优化,有些优化是非常昂贵的。D3D字节码(dxbc)是更加干净的解决这个问题的方式。
2.GPU memory architecture and the Command Processor.
本章主要是针对GPU内存访问架构和CP做一个简单的框架介绍
The memory subsystem:
主要介绍了GPU的内存子系统,特点是高带宽,高延迟,还有缓存miss带来的额外的时钟周期开销,内存的组织结构,按照内存行来访问更快(cache line)
The PCIe host interface:
总线接口对带宽的影响
Some final memory bits and pieces:
写了一堆废话
At long last, the command processor:
到这里才算是进入了主题,CP前端就是一个状态机,不停地从command buffer里面获取command,解析指令然后丢给后端做对应的操作(譬如2d绘制,3d绘制,shader等) 的处理为了效率是并发处理,这其中的关键是state状态变化,这种变化会影响后续的command的处理,并发需要针对这种情况做额外的处理,通常的处理方式:
1.state改变后,把所有引用该state的command全部处理完毕直到下一个state change
2.把硬件处理单元做成stateless的,直接把state附加到command上
3.存储state的副本,譬如state存在reg0和reg1上,当前任务都引用reg0,需要改变的时候写reg1,然后后续任务引用reg1,但是缺点是明显的,需要copy的次数可能会比较多
Synchronization:
fence,一般是一个寄存器上的wait指令来实现"wait直到寄存器M的值等于N"
从CPU端也可以直接进行fence操作,这个被dx11用于多线程渲染
3.3D pipeline overview, vertex processing.
本章开始正式讲渲染管线的内容
Have some Alphabet Soup!
介绍graphics pipeline内部流程
IA,VS,PA,HS,TS,DS,GS,SO,RS,PS,OM,CS
VS→PS:dx9时代的固定管线流程
VS→GS→PS:d3d10出的geometry shading
VS→HS→TS→DS→PS, VS→HS→TS→DS→GS→PS:曲面细分(dx11的新流程)
VS→SO, VS→GS→SO, VS→HS→TS→DS→GS→SO: Stream-out (with and without tessellation).
CS: Compute shading(dx11)
Input Assembler stage:
首先会从内存中读取index数据,并非直接读取,而是有一个针对index/vertex局部性访问的cache。这里还会做bound-check,比如你指定了6个size的index count,但是只传了5个count长度的index buffer,那么超出的部分就返回0。
当读取完index数据之后,我们就需要从vertex buffer中读取每个三角形或者instance的数据了。很直接的,根据vertex layout从cache或者memory中读取数据,unpack成float格式喂给shader处理单元。注意,硬件会把着色的定点做一个缓存,当有多个三角形共用了同一个顶点的时,不需要做多次着色,直接从缓存取就可以。
索引缓冲在API中会做bound check,同时,index/vertex buffer都是有cache的。在一个完全规则的闭合三角形网格中,每个顶点将被大约6个三角形引用
Vertex Caching and Shading:
在shader mode3.0之前,vs和ps是由不同的shader处理单元运行的,vertex cache非常简单,就是一个FIFO队列
Brief summary from my quick scan through: There is no vertex cache on modern GPUs, but the way GPUs batch vertices together and deduplicate invocations within that batch does produce similar effects. On Nvidia GPUs, it's roughly equivalent to a cache of size 10.
在统一着色器到来之后,事情变的有些复杂了,统一着色器可以满足最大的吞吐量,一次处理的顶点以batch为单位(通常是16到64个顶点),也就是说统一着色器是以顶点batch为单元进行shading的(可能是16 - 64个顶点一个batch)
如果还是简单以batch为单位的FIFO是不行的,因为batch中有些顶点可能本身也在上一个batch内。所以FIFO没啥意义。
我们的目标是什么,是以一个batch为单元进行shading的同时,同一个顶点尽量不去做多次重复的shading。
可能采用的最简单的做法:保留32个顶点(一个batch)的空间以及32个索引id空间,然后遍历index buffer里面每个顶点,如果索引不在索引空间中,就把对应的vertex放到vertex cache里,直到cache装满一个batch的顶点,然后把这个batch喂给shader单元。这样的shader单元有很多个,所以并不用担心会阻塞(这里可以联想到怎样的顶点内存布局会影响cache命中率)。
Shader Unit internals:
fxc /dumpbin可以查看hlsl的字节码,shader指令和dxbc字节码非常近似,它也有很好的文档说明。
注意的是不同着色器阶段之间的差异。其实差异很小,比如所有的算术和逻辑指令在所有阶段都是完全相同的。一些构造(如导数指令和ps中的属性插值)只存在于某些阶段,但主要的区别在于传入和传出的数据的类型(和格式)。
4.Texture samplers
Texture state
贴图采样在驱动中的有3部分组成:
1.采样状态。Filter mode, addressing mode等等诸如此类的状态,控制贴图采样的部分行为
2.底层贴图资源。可以用一个指向内存中原始纹理位的指针来表示。贴图资源也决定了这是一个single texture还是texture array,或者多重采样格式,以及内存布局格式
3.SRV(shader resource view)。它决定了纹理bit如何被sampler解释。d3d10中,srv和底层贴图资源关联,你不必显示访问底层贴图资源。
通常在创建Texture的时候就指定了一种格式,例如R8G8B8A8,然后创建一个对应的srv。还有一种情况,你可以创建一个typeless的Texture,然后为其创建多个不同格式的srv。
创建SRV看起来没必要,但是它可以让API runtime仅在SRV创建的时候对srv和texture format做兼容性检测,而无需在后续读取时做,这是考虑效率的因素。
从硬件层面的角度看,这归结为在贴图采样操作(譬如采样state,贴图格式等)上关联的一组需要在某处保存的状态。那么如何设计就有多种方式。比如“任意状态变化就重新刷新pipeline”,或者“采样器是完全stateless的,并且每次有贴图采样请求时都提交状态集合”(实际上就是immediate还是react的区别)。然而你完全不用关心,这取决于硬件设计者在考量具体效率时做出的决定。我们不要先验性的认为硬件应该使用哪种模式。
不要认为纹理切换的开销很大,他们可能是完全流水线化,stateless的(也就是immediate模式),这样就基本没什么开销。但也不要认为就完全没任何开销,有可能在pipeline中纹理状态集合有个数量上限。除非你是针对主机平台的特定硬件设备(或者你针对每一代图形硬件手动优化引擎),你基本是没法确定硬件行为的。因此,在做优化时,做一些显而易见的优化,例如根据材质排序,避免pso的频繁切换,至少这能节省一些API的调用。不要基于任何硬件模型做花哨的事情,因为硬件随时的更新换代都可能发生变化。
Anatomy of a texture request
发送一个Texture sampler请求我需要哪些信息呢?这取决于贴图类型以及我们正使用哪种采样指令。假定我们正采样一张2D贴图,使用4x anisotropic sampling
1.2D贴图的UV坐标,2个float
2.uv沿屏幕x方向的偏导
, ![](https://img2022.cnblogs.com/blog/572815/202206/572815-20220618144245840-2040330907.png)
![](https://img2022.cnblogs.com/blog/572815/202206/572815-20220618144245912-437070915.png)
![](https://img2022.cnblogs.com/blog/572815/202206/572815-20220618144245840-2040330907.png)
3.uv沿屏幕y方向的偏导
, ![](https://img2022.cnblogs.com/blog/572815/202206/572815-20220618144245992-2077595957.png)
![](https://img2022.cnblogs.com/blog/572815/202206/572815-20220618144246077-1656958896.png)
![](https://img2022.cnblogs.com/blog/572815/202206/572815-20220618144245992-2077595957.png)
这样一个SampleGrad采样指令需要6个float数据(估计比你想象的要多)。4个梯度值用于mip level的选择以及anisotropic filtering kenerl形状和大小的选择。你也可以直接指定mipmap level(通过SampleLevel参数),这样就无需计算梯度值,当然这样也不能做各向异性滤波了,最多只能做三线性滤波。先来看这6个float,是否每个texture sampler请求都需要传递这6个float呢?
答案是视情况而定。如果不在ps中采样是需要的,而如果在ps中,并不需要,因为可以直接计算偏导(ddx/ddy),所以对于一个2d的采样指令,只需要发送2个float坐标值即可。
考虑一个有意思的问题,一次贴图采样最多需要发送多大的数据?对于dx11来说,是对一个Cubemap array进行SampleGrad操作。包含一个3d纹理坐标,cubemap array index以及3个维度分别在屏幕x方向和y方向的梯度值。这需要40byte的数据,数据量是很大的。
接下来做一个带宽的估计。首先做一个简单的假设,假定我们的贴图采样包含大量的2d以及少量cubemap的采样,并且几乎都是ps中的普通采样(附带一个SampleLevel)。那么数据大概在2(u+v)到3(u+v+w/u+v+lod)之间,我们姑且认为是2.5个float,那么就是10byte。
然后,我们考虑一个1280x720的分辨率,假定至少采样3次贴图,考虑overdraw,再假定每个像素画了2次,在考虑一般都会有比较多的后处理,再加6次。这样算下来,有92万x(3x2+6)=1100万次贴图采样(每帧),再乘上10byte(数据量)以及30(fps)得到3.3GB/s的带宽。
考虑到现在游戏通常有更高的分辨率,帧率以及更复杂的着色器效果,这个带宽量更加巨大。关键在于,纹理采样器并非shader核心,而是在芯片上距离shader core有一段距离的独立单元,每秒传输几G个字节并不那么容易。这取决于架构。
But who asks for a single texture sample?
着色器单元,注意,前面说过着色器单元一次是处理一批顶点,那么相应的,纹理采样也是一次发送一批。假定我们一次发送包含16个纹理请求(包含一系列采样状态),需要花费的时间会很长。要注意的是,在发送纹理请求之后,着色器单元需要等待数据返回才能继续后续操作,此时着色器单元并非闲置,而会切到另外一个thread去做其他工作,当数据返回后再切换回来。
And once the texture coordinates arrive...
当坐标数据到达纹理采样器后,要做的事情就非常多了。这里简单考虑一个双线性滤波,如果是三线性和各向异性则需要更多操作。
1.如果是一个Sample或者SampleBias类型的请求,先计算纹理坐标的梯度值
2.如果没有明确给出miplevel,则依据梯度值计算miplevel,同时添加lod bias(参数指定)
3.对于每个采样位置,应用寻址模式,归一化坐标到[0,1]之间。
4.如果是cubemap,需要确定从哪个cube face采样(基于uvw的绝对值和符号来判断),并且归一化到[-1,1]之间。然后剔除表示立方体表面的坐标,把另外2个坐标分量归一化到[0,1].
5.取归一化之后的坐标,乘上贴图size转换成定点数以便进行最终的采样,同时小数位也要用于计算插值。
6.最终,使用得到的整数x/y/z以及texture array index,读取像素值。
上面是一个简化版的流程。一切都是硬件帮我们做好了,最终,我们计算出了一个用于获取像素值的内存地址,这时候cache登场了。
Texture cache
一般都是两级纹理缓存,二级缓存是个普通的数据缓存(也包含除了纹理缓存以外的数据?)。L1-cache比较特殊,一般4-8kb。
大多数纹理采样都伴随开启了mipmap,采样的miplevel根据屏幕的像素1:1选择具体的miplevel。这意味着,除非每次都刚好采样到图片相同位置的像素,否则,每次纹理采样操作会平均cache miss 1个像素,双线性滤波实际测量值大约是1.25个像素的miss。当cache大小大后这个值会基本保持不变,当变的足够大时(几百kb甚至几M),这个值会急剧变小,而显然L1缓存不可能达到这么大的值。重点是,任何纹理缓存都是极其重要的(每次都把双线性插值从4次内存访问减少到1.25次),但与cpu不同的是,从4k的缓存加到16k并没有很大的好处。
第二点:由于平均1.25个像素的cache miss,纹理采样的流水线需要足够长,以便于维持每个采样都能够不加停顿地读取内存。换一种说法就是,即便读取内存需要400-800个指令周期,纹理采样流水线也足够长不会停顿。(这个还需要进一步理解)
L1缓存小,流水线长,优化来了,那就是压缩纹理格式。S3TC,又称为dxtc或者bc1-3,然后d3d10引入了bc4和bc5,他们是dxt的变种,最后dx11引入了bc6h以及bc7,都是基于block的压缩方式,对4x4的像素块进行编码压缩。如果在纹理采样过程中进行解码,最坏情况下需要解压4个这样的块(双线性插值的4个采样点刚好位于4个block里),这很糟糕,因此4x4的块在进入L1缓存时被解码,举例来说:一个bc3(dxt5),从L2缓存中获取了一个128bit的block,然后在L1中被解码为16个像素值。如此一来,你只需要解压1.25/4x4=0.08个blocks,至少,如果你的访问模式是足够连续的,就大概率能命中到你实际访问的像素的同一block的其他15个像素,这是一个巨大的改进。这不仅仅局限于dxt block,任何其他的贴图格式也是同样的道理,到达L1缓存时把数据解码出来。当然,这会增加L1缓存中像素的空间占用,所以你可能希望增大L1缓存的大小,不是因为需要缓存更多的像素,而是每个像素的空间占用变大了。it's a trade-off
Filtering
在滤波阶段,拿双线性滤波来举例,过程比较简单直接,从texture cache里取4个采样点,做一个加权平均即可。如果是三线性过滤,就是2次双线性滤波再加一次线性插值。
各项异性稍微有些复杂,在贴图采样的早期阶段,大致是计算miplevel的阶段需要做一些额外工作,需要通过梯度值来确定屏幕像素在纹理空间(texel space)中的形状,如果宽高比差不多是1:1的话,就做一个常规的双线性或者三线性滤波,如果差异很大(在一个方向上被拉的很长),就需要在这个方向上采样多个样本混合,具体会先生成若干采样点,然后循环多次三线性或者双线性插值,具体采样点位置以及插值方式这由硬件决定。
基本上,最为一名图形程序,你不太需要关注底层各向异性过滤算法的实现。
Texture returns
最终,采样的结果返回到shader单元进行下一步的计算,每个纹理采样最多范围rgba 4个颜色分量值,不过有时候我们并非需要返回32bit的数据,比如读取一个8bit的UNORM SRGB格式的贴图,可以返回更小的数据量。
至此,贴图采样过程结束。
下一期我们讨论在光栅化之前管线需要完成的工作。
5.Primitive Assembly, Clip/Cull, Projection, and Viewport transform
Primitive Assembly
vs过程执行完毕之后,我们从shader单元输出了一整块(block)顶点,这批顶点都能组成完整的primitive,意味着一个primitive的顶点不可能存在多个块内。这是非常关键的,这意味着每一块数据都能被单独处理而不依赖任何其他的块。
下一步就是片元装配:把属于一个primitive的顶点组装起来。对于点来说就是1个点,线段就是2个端点,三角形就是3个顶点。
简单来说这步就是搜集顶点的过程。实现上基本有2种方式,一种是读取索引缓冲,仍然用索引缓冲到顶点的映射来表示图元,另一种就是从索引缓冲直接展开顶点坐标,这需要额外的一些空间,不过后续就不需要读取索引缓冲了。
得到所有的图元后,我们就有了完整的三角形信息,在光栅化之前还需要做一些事情。
Viewport culling and clipping
裁剪发生在齐次坐标空间(或者叫clip空间),就是把平截头体外的图元部分裁减掉不参与后续的光栅化。所谓的平截头体就是裁剪空间,对于d3d来说,就是范围-w <= x <= w, -w <= y <= w, 0 <=z <= w(w > 0)的区域。
判断顶点是否处于clip空间,我们可以首先找出完全处于clip空间外的图元,可以利用Cohen-Sutherland编码裁剪算法,先对每个顶点进行区域编码,然后对线段顶点进行bitwise and操作,如果是不为0,意味着一定在区域外,可以直接剔除。如果为0则和区域相交,计算出交点进一步再继续做裁剪。这一步在硬件上实现非常简单。
在vs中也可以自行定义cull distance(opengl中是gl_CullDistance)如果所有的顶点的cull distance都小于0,那么这个图元会就被裁减掉。
排除了完全处于clip space外的图元,接下来的裁剪过程,实际上可以有2种形式:第一种就是直接使用多边形裁剪算法(会额外生成新的顶点和图元),或者我们直接在后续光栅化判断顶点是否要着色(处于图元内部)的条件加上额外的裁剪平面。后面这种方式显得更加优雅简洁,不需要使用裁剪器,但我们需要能够精准有效地处理所有归一化的32位精度浮点数,这可能比较棘手。那么我们这里假设还使用裁剪器,会生成额外的图元,这其实很麻烦,但实际上比较罕见(并不多)。这里不确实是有特定硬件来处理裁剪,或者说使用了一个着色器单元来做这个事?这取决于分发一个新的着色任务的负载,专用的剪切硬件单元需要多少个。这只是猜测,我并不清楚细节,然而这并不重要,实际发生裁剪的情况很少很少,因为我们可以使用Guard-band clipping。
Guard-band clipping
保护带裁剪实际上并非什么华丽的裁剪方法,而是为了不做裁剪的一种保底方式。
前面提到,我们其实根本不用做裁剪,在光栅化阶段判断像素是否在三角形内部需要着色的时候直接丢弃掉裁减范围外的像素就可以了。但是,这种检测(判断像素是否处于三角形内部,实际上判断要更复杂高效一点)通常是用固定精度的整形来计算,当三角形越来越远时,整形可能溢出得到不正确的结果。这种错误的结果显然不符合规范,硬件是不允许这种情况出现的。
解决这个问题有2种思路。一种是确保任何输入的图元数据都能得到正确的计算结果,称之为"infinite guard-band",因为这种情况下等同于保护带就是无限大的。第二种仅仅对于超过安全范围外的图元进行裁剪,在安全范围内光栅化计算不会溢出。
![](https://img2022.cnblogs.com/blog/572815/202206/572815-20220618144121661-161035558.webp)
Those pesky near and far planes
前面已经解决了4个裁剪面的处理,但是还有近平面和远平面,尤其是近平面,大部分裁剪都发生在此处。我们无法使用前面的保护带方法,因为gpu并不会在z方向上做光栅化。
但仔细想想,深度也是三角形内部插值计算的数值。那么可以直接用深度z来进行比较,也非常快。而且,有些情况下你必须跳过远平面或近平面的裁剪,比如需要支持nvidia的"depth clamp" opengl扩展(ps里修改z)。
现在只剩下一个非常规的裁剪面:0 < w。这个裁剪面也可以直接省略掉,使用齐次空间中的光栅化算法(这里作者引用的算法链接已经失效)
Projection and viewport transform
接下来直接做透视除法就可以得到NDC空间(-1到1的标准化范围),然后使用viewport变换,坐标从ndc空间变换到屏幕空间,并且将z的范围映射到[0,1](近平面0,远平面1)
这里,我们同时捕捉像素在sub-pixel网格上的小数位坐标(?)。对于d3d11,硬件需要8bit subpixel精度的三角形坐标。这会将一些非常小的三角形(这种情况如果不用subpixel精度会有问题,顶点容易计算到一个像素坐标上?)直接退化成不需要渲染的三角形。
Back-face and other triangle culling
背面剔除:直接用三角形边向量叉乘结果的正负值来判断是否是顺时针旋转,如果符号是正的,则是顺时针方向,反之,逆时针方向。
我们现在已经准备好光栅化了,在接下来的章节继续!
6.(Triangle) rasterization and setup
本篇可以配合RTR4的第23章Graphics Hardware第一节Rasterization来看,RTR里面说的更直接清楚,这里就简单过一些重点内容。
本章讲述三角形的光栅化,在真正光栅化之前,我们要先做一些三角形setup的工作,目的是为了后续的光栅化算法对硬件更加友好。
How not to render a triangle
注意这一章讲光栅化区别于软光栅,软光栅包括了整个渲染管线的大部分流程,包括搜集三角形,对三角形进行扫描,确定参与像素,z-buffer test,坐标插值,纹理映射等步骤。这里说的光栅化仅仅对应其中的三角形扫描这部分。
软光栅一般会用扫描线算法进行扫描,然而这显然不合适GPU simd的架构(通常以tile为单位处理像素,这对于纹理采样,深度缓冲,颜色缓冲都是更加内存友好的)
A better way
Pineda在1988年提出了一种更简单的算法,核心思想是一个点到一条直线的距离能够直接用向量点乘来计算(这就是signed distance),那么直接判断点距离edge的值是正负就可以判断是否在三角形内部。
具体的公式如下:
Ei(x,y) = aiX + biY + ci
当对于i为0,1,2的值都>0表示位于三角形内部。
另外,公式有一个非常重要的性质,它是线性增量的,对于一个像素右侧的临接像素来说,E(x+1,y) = E(x, y) + a,这对于GPU并行处理有非常大的好处,比如,一次处理一个8x8的tiled块,我们只需要预先计算好ia+jb(0<=i<7, 0<=j<7)放入寄存器,然后仅需要实时计算每个tiled的左上角的E值,就可以非常快的得到所有64个像素点的符号值结果,同时完全是并行的检测(具体细节可以参考论文:Hierarchical Polygon Tiling with Coverage Masks)。这里面仅包含整形运算。注意上一章在讲裁剪时提到顶点坐标是用整形定点数来表示的,相对于浮点数效率会高很多。举例来说,定点数的形式可以是1.14.8位的形式,1bit符号位,14bit网格坐标,8bit像素内部的小数部分(这样一个pixel的精度可以达到1/64)
当一个像素刚好处于2个三角形的共享边时,如何避免绘制2次? top-left rule方法,这里直接略过,不细说
线段的光栅化可以等价于是一个2个三角形组成的单位像素的四边形,仍然用上述架构算法来做,同理点也可以看做是个正四边形
What we need around here is more hierarchy
为了提升效率,很显然可以用层次化的方式来做遍历优化。这体现在2个方面:
首先,硬件会计算每个三角形的包围盒,先用tile和包围盒做相交判断,如果tile完全和包围盒不想交,那么tile不需要处理,如果相交,再判断是否和三角形不相交,通常是选取tile距离某一条边最近的角来判断,如果在边缘以外,则tile必然不和三角形相交,那么该tile不需要做任何内部像素的判断测试。
其次,tile本身可以层次化处理,比如首先判断16x16的tile,如果tile和三角行不相交,那可以直接丢弃,这样省去了内部的4个4x4tile的判断。
需要注意的是,算法对于那些很细长的三角形非常不友好,会遍历非常多的tile同时仅仅只覆盖了很少的像素。
So what does triangle setup do?
到此已经说明白了光栅化的算法,我们看一下我们需要在三角形光栅化之前setup阶段需要计算哪些常量
1.Edge方程a,b,c三个参数
2.前面提到的ia+jb(0<=i<7, 0<=j<7),一般不会真的存64个value,最好的方法是硬件实现一个特定的加法器,只需要存ia和ib,然后能快速计算ia +ib + c
3.在层次化中提到的选取tile corner的参考值。
4.Tile的层次化处理时对于每一个tile起点的Edge方程的初始值。
这就是setup三角形所需要的一些数值计算,基本都是一些整数的乘法和加法
Other rasterization issues and pixel output
有2件事可以特别提一下,scissor和msaa
屏幕aabb的裁剪非常容易实现了,先用大的tile剔除掉非裁剪区域,然后用小的tile和裁剪区域做bit and操作就可以了。.
msaa会针对一个像素内部做多次采样,至少可以支持8x的采样。内部的采样点并不是按矩形网格排布的,这个对于扫描线算法非常不友好,但是对于上述光栅化算法来说,仅仅只是多加了几个像素内部测试用的偏移量而已。