上一章主要介绍了VS的相关内容,并顺嘴提了一些GPU Shader Units的一些通用知识。这些都是跟vector processors(并行处理)相关的,不过其中涉及到一项其他并行处理结构中所不具备的资源,即texture sampler,这个实际上是GPU管线的一部分,且是十分复杂的一部分。
Texture state
在开始贴图操作介绍之前,先来看一下驱动贴图采样的API,在D3D11中,主要包含三个部分:
Sampler state. Filter mode, addressing mode, max anisotropy等,这些内容用于对贴图采样的一些通用设置进行控制。
贴图资源,对应的就是一个指向内存中贴图raw data的指针。 此外,这里还有单张贴图与贴图数组等相关信息,multisample格式信息, 贴图数据物理布局信息
Shader resource view (SRV). 这个部分将用于控制sampler是如何对内存中的raw data进行插值的。在D3D10+中,SRV会被链接到贴图资源上,因此在shader中不需要再另外显式指定贴图资源。
大部分时候,我们都是先创建一张对应格式的贴图(比如RGBA,每个通道8bits),之后再创建一个与之关联的SRV。不过这里实际上也可以在创建贴图的时候不指定格式(typeless),之后再创建不同的SRV,用于对贴图中的数据做不同的解释,比如可以创建一个UNORM8_SRGB SRV用于将数据按照浮点数进行读取(每个浮点数用8bits表示,对应的是sRGB空间中的数据),或者创建一个UINT8 SRV,将数据按照整数读取。
可能大家会觉得SRV这个东西实在太过多余,直接使用对应格式的贴图不就好了吗。实际上SRV创建的时候会顺带对贴图的格式进行检测,如果合理才会返回有效的SRV,也就是说,只要SRV存在,那么后续就不再需要对格式有效性进行确认,从这个角度来看,SRV的存在是为了提升API调用的效率。
在硬件层面,这些内容就对应于一个贴图采样操作,以及一系列与这个操作相关联的状态数据(这些状态数据的存储位置跟前面part 2 提到的各种存储策略就很好的吻合起来),前面说过,这些数据的存储有着多种策略,而每种策略都有着各种各样的选项。不用为这些令人眼花缭乱的选项与策略而担心,硬件还会通过少量workload的模拟来给出最佳的实践策略与选项。
不要假设贴图切换会有很高的消耗,不同贴图状态之间可能是按照stateless的策略进行存储,在这种情况下的切换基本上是免费的;也不要假设贴图切换完全没有消耗,管线硬件上能同时支持的贴图状态套数是有限的。除非是在硬件固定不变的主机系统上(或者对每代图形硬件都进行过手动的优化),那么这些设置其实就是固定的,否则很难说清楚贴图切换到底是否会有消耗。因此如果要做优化的话,尽量选那些显然会有收益的工作(比如将渲染调用按照材质进行排序等),而不要基于特定的硬件架构来做优化,否则下一代硬件采用其他的架构,这些优化内容眨眼就泡汤。
Anatomy of a texture request
贴图采样请求所需要下发的信息量取决于贴图类型以及具体的采样指令。假设我们需要用一个四倍的各项异性来对一张2D贴图进行采样的话,那么就需要以下一些数据:
2D贴图坐标 – 2 floats, 按照D3D的术语,用 uv 来指代
uv沿着屏幕 “x” 方向上的偏微分数据:
uv沿着屏幕 “y” 方向上的偏微分数据:
也就是说,对于一个非常常用的2D贴图采样的话,这里就需要6个浮点数——这个数据可能比我们原来想象的要多一点。其中四个微分数据用于计算mipmap层级,同时也会用于对各项异性采样的尺寸与形状进行计算。在实际使用中,有时候可能不用微分数据,而是直接使用固定的用户指定的mipmap层级,对于这种情况,那么就可以节省掉微分数据,而是用一个表示LOD层级的整数来取代,不过这种情况下可能就没办法做各向异性滤波了,最好的情况也就是使用三遍线性插值。不管怎样,我们这里先假设2D贴图采样所需要的数据是6个浮点数,这个数据量已经算是比较庞大的了,是否真的需要在每次贴图采样请求到来的时候都将所有的数据都下发下去呢?
结论是,看情况。对于除了PS之外的其他情况而言的话,是需要下发全部数据的,尤其是当我们需要各向异性采样的时候。而在PS中,则不需要每次下发所有数据。通过PS的梯度计算指令从贴图坐标中计算出所需要的微分数据从而省掉微分数据的下发。因此对于PS的2D采样指令,只需要下发贴图坐标即可,不过代价是需要在PS中添加梯度计算的消耗。
对于单个贴图采样而言,需要下发参数的最差情况是哪种呢?对于D3D11而言,是cubemap数组上的 SampleGrad
操作:
3D 贴图坐标 – u, v, w: 3 floats.
Cubemap 数组索引: 一个int (先假设其消耗与一个浮点数等同).
(u,v,w)沿着屏幕空间xy方向上的梯度数据:6 floats.
总的来说也就是每个像素需要十个floats的数据,对应40个字节。可能我们想的是我们这里不用float,而是用一个更为紧凑的数据结构,不过依然会有很高的数据消耗。
那么在实际中,我们这里大概会需要消耗多少带宽呢?假设大多数采样的贴图都是2D的(少量3D cubemap),且基本上都是在PS中完成(不考虑VS中的请求),采样的API中,假设频率最高的为 Sample
(对应的是带微分形式的采样),其次是 SampleLevel
(显示指定LOD层级的采样API,这种假设是符合实际游戏情况的)。那么爱这种情况下每个像素对应的32bits数据数为2 (u+v) 到3 (u+v+w / u+v+lod)之间,那么假设这个数值为 2.5, 也就是10 个字节。
假设一个中等分辨率的屏幕,比如说1280×720,那么像素数目就是 92 。通常情况下一个PS的贴图采样数,假设为3个。此外再假设像素平均overdraw程度不高不低,每个像素都被处理了两次。之后再加上一些重度贴图采样操作的后处理过程,大概每个像素需要6个采样调用,这里考虑了部分后处理是在低分辨率模式下完成的。将这些数据加起来就是 92 * (3*2 + 6) = 大概是每帧需要1100万贴图采样,如果按照30fps来算的话,那就是每秒3.3亿次贴图采样。每次采样10个字节,总计就是3.3 GB/s,而这仅仅是贴图采样的消耗,可以看成是消耗的下限。 一张好的D3D11显卡上对应的贴图分辨率通常比这里的要高,且PS复杂度也比列举的要高,overdraw程度有可能会稍低一点(延迟渲染等方法导致),帧率更高,后处理复杂程度更高,现在算算,带宽消耗多高了?
总的来说,贴图采样导致的高带宽消耗是我们目前无法摆脱的的。贴图sampler也不是shader core的一部分,实际上在芯片上相距还挺远的,且每秒数个GB的数据转移并不是自发执行的。这是一个架构问题,幸运的是,大部分的贴图采样不需要使用 SampleGrad
指令。
那么谁会一次性只请求一个贴图采样操作呢?
结论是没有。贴图采样请求来自于shader units,而shader units每次会处理16~64个像素、顶点、锚点等。也就是说,shader并不会只下发单一的贴图采样指令,而是会下发一批贴图采样。上回的32不是整数的平方,对于2D贴图采样而言,这个数值看起来怪怪的,这次我们假设这个数值为16。 之后构建一个批次的贴图采样payload,再加上一些field用于表明需要完成的指令,再加上一些field用于给出采样的贴图指针以及sampler stage,并将之下发到贴图sampler的某个位置。这个过程会需要占用一点时间。
真的,我们很快就能知道,贴图sampler有一个相当长的管线,而如果在这个过程中,shader unit什么都不做会显得非常的浪费,因此出于对吞吐量的考虑,通常在贴图采样的时候,shader unit会自动切换到另外一个线程/batch去做一些其他的工作,之后等到贴图采样结果回来之后再切换回来。而由于有很多相互独立工作等着shader units去做,因此这种机制还挺有作用的。
And once the texture coordinates arrive…
嗯,需要先进行一揽子的计算工作(这里以及后面,先假设我们使用的是简单的双边线性采样,三边线性采样以及各向异性采样需要考虑更多的工作,这个等后面再说)
如果shader中调用的是
Sample
或者SampleBias
接口,那么就需要先计算uv梯度数据。如果没有给出显式的mip层级,那么就需要从梯度数据中计算出层级数据,并根据需要添加LOD偏移值。
对于每个采样uv地址,添加地址模式计算处理 (wrap / clamp / mirror etc.)来得到正确的处于[0, 1]范围内的贴图uv坐标。
如果采样的是一个cubemap,我们还需要计算出需要采样的cube face(主要是基于u/v/w坐标的绝对值以及符号),之后通过一个除法来将贴图坐标投影到一个单位cube上,以确保数据都是处于[-1, 1]范围之内的。之后移除掉uvw坐标中的一项(基于cube face)并将剩下的两项缩放到[0, 1]范围内。
之后将归一化的[0, 1]范围内的uv坐标转换为定点像素坐标,另外还需要将转换后的小数数据保留下来用于进行双边线性插值。
最后,根据转换后的整数坐标以及对应的贴图数组索引,就可以计算出图素数据存储的地址,并进行数据读取。
这里给出的是一个简单版本的总结,其中很多的实现细节比如贴图边缘处理以及cubemap边角采样等都没有覆盖到。听起来更复杂了,不过幸好,我们有专门的硬件来处理这些事情。不管怎么说,我们现在已经知道了数据存储的内存地址,而有地址的地方,都对应着相应的数据缓存。18
Texture cache
现在基本上都是使用两级缓存来处理贴图的缓存,其中第二级缓存完全是一个普通的用于对存储贴图数据的存储空间进行处理的标准缓存,第一级缓存就不是那么标准了,这个缓存空间小,大概是每个sampler需要4~8kb。
事实上,大多数的贴图采样都是在PS中完成的,且都需要开启mipmap来保证采样贴图图素与屏幕空间像素尺寸接近1:1。不过除非是相机维持不变,每次采样到的贴图位置都是相同的,否则肯定会发生cache miss的情况,每个贴图采样请求可能会导致平均一次的cache miss,实测数据是,对于双边线性采样而言,这个数值为1.25。而这个数值会在很长一段时间内保持稳定,即使你修改了缓存的尺寸也是如此,直到缓存尺寸能够装下一整张贴图为止(大概是几百KB到几MB之间,这个数值对于L1缓存而言,是不太现实的),此时就会导致这个数值骤降。
实际上,不论什么尺寸的cache,最终都被证明对于性能起到重要的优化(比如可以将双边线性采样的4个采样消耗降低到1.25个),不过不像CPU或者shader core的共享内存,这里将缓存从4k扩大到16k对于性能的增益则基本可以忽略不计,因为这里会对大尺寸贴图应用streaming策略。
第二点,由于平均1.25次的cache miss,贴图sampler管线必须要要足够长才能保证在将数据从内存读取出来的时候不受任何干扰跟阻碍(不是很懂这句话的逻辑),即贴图sampler管线已经长到在执行内存读取的时候不会受到任何的阻碍(是因为阻碍成本太高?)。虽然耗时长,但实际上还依然是一个管线:花费数百个cycle将数据从一个管线register移交给另一个, 在完成这个内存读取之前不进行任何其他的处理工作。
现在的情况就是,小尺寸的L1 cache,漫长的管线。而在使用的时候,我们会有用到压缩贴图格式,比如PC中的S3TC即DXTC等。如果这些数据是在贴图采样的时候才被解码,那就意味着对于按块压缩的数据需要能够支持每个cycle解码多达4个block(对应于4个双边线性采样点刚好位于四个block的边界的情况),显然这种情况非常的糟糕。因此,更常见的做法是在将数据取入到L1 Cache的时候才进行解码,对于DXT5编码格式而言,我们从L2 Cache获取一个128位的block并将之解码成16个像素后存入L1 Cache,在这种情况下,我们就不需要对每个sample解码4个block了,而是对于每个sample只需要解码1.25/(4x4) = 0.008个block,至少在后面所访问其他数据与当前所访问的像素数据具有很好的一致性,即两者大概率处于同一个block中的时候是这样的。 即使数据访问不是很一致,即在没有将当前block中的数据都访问完,而是只访问了部分就进入到下一个block的访问中了,对于性能依然是一个很大的提升。这项技术的优化效果并不仅限于DXT压缩格式,对于D3D11的cache fill path中,有1/3的path是可以通过这种方式覆盖到的,已经非常不错了。比如,UNORM sRGB贴图可以通过将sRGB像素转换为每个通道16位的整数(或浮点数,或者甚至32位浮点数)存储在贴图cache中,之后在线性空间中对这些数据进行filtering处理,需要注意的是,这种做法确实会导致所使用的L1 cache的图素数目增加,因此需要考虑增加L1 Cache的尺寸,这个不是因为我们需要缓存更多的像素,而是因为当前需要缓存的像素尺寸变大了。
Filtering
到这个时候,双边线性滤波处理过程就非常直观了,从贴图缓存中抓出四个采样数据,用小数位置对其进行混合。对于三边线性插值呢,只需要使用两个双边线性插值,之后再接一个线性插值即可。
各向异性插值呢?在这里我们需要用到此前计算过的mip层级数据,根据贴图的梯度计算出屏幕空间像素的面积与形状,如果长宽差不多大,那么只需要一个普通的双边线性采样或者三边线性采样就可以了;而如果两者相差较大,我们就需要将之分割成几个长宽相等的像素,进行多次采样(这些采样都是双边线性或者三边线性采样)并将结果混合起来。采样点位置与权重的计算是每个显卡厂家的秘密,已经被藏了好多年,不过这个我们可以不用关心,对于我们这边并没有太大的帮助。
总的来说,各向异性采样所增加的只有一些setup消耗以及对采样点进行线性循环采样的消耗。
Texture returns
现在就进入贴图采样管线的尾声了,对于每个采样请求,我们最多得到四个数值(rgba)。跟输入数据可变不同,这里的输出数值基本上是固定的,大部分shader都会用完这四个数值,将这四个数值全部转发回去对于带宽来说不是什么问题。当然,在一些特殊情况中,大家可能还是想做一些优化,比如说如果我们采样的是一个32位的浮点数通道贴图,那么我们可以直接返回32位的浮点数,而如果我们读取的是8位的UNORM sRGB贴图,如果也返回32位的数据就太浪费了,我们可以考虑使用一个小一点的数据格式来节省带宽。
shader unit拿到贴图采样管线返回的数据之后就可以重启此前挂起的计算过程直到结束。
Update: 这里是贴图采样管线的流程图。
The usual post-script
当前的硬件大多是支持解压缩数据的L1贴图缓存,部分早期的硬件则依然存储的是未解压的贴图数据(即使在L1 Cache中),这种结构是不太先进的,应该会逐渐淘汰掉。