图形管线之旅 Part4

原文:《A trip through the Graphics Pipeline 2011》
翻译:往昔之剑
 
转载请注明出处
 
欢迎回来。上个部分是关于vertex shader的,还带有一些GPU shader通用单元的概念。重要的是,它们仅仅是向量处理器,但是它们需要访问不在向量架构上的资源:纹理采样器。它们是GPU管线的一部分,并且十分复杂(还很有趣!)足以保障它们自己的协议约束,那么就开始讲解吧。
 
纹理状态(Texture state)
 
在我们开始实际的纹理操作之前,让我们来看一下驱动纹理的API状态。在D3D11里,这是由3个不同部分组成:
 
  1.  采样器状态。过滤模式(filter mode),寻址模式(addressing mode),各项异性过滤(max anisotropy),之类的等等。这个状态通常控制纹理采样如何执行。
  2. 底层纹理资源。这可以归结为一个指向内存中的原始纹理比特位(raw texture bits)的指针。这个资源还决定了它是一个单独的纹理还是一个纹理数组,这个纹理使用哪种多重采样格式(如果使用了多重采样的话),以及纹理比特位(texture bits)的实际布局——例如,在资源层它还没有确定在内存中如何被确切的解析,但是它们的内存布局已经确定了。
  3. Shader资源视图(shader resource view,简称SRV)。它决定了纹理比特位如何被采样器解析。在D3D10以上,资源视图链接着底层资源,所以你不需要明确的指定资源。
 
大多数情况,都是按照一个指定格式创建纹理资源,比如是RGBA,每个分量(component)8 bits,然后创建一个格式匹配的SRV。不过你也可以创建一个每个分量8bits无类型的(typeless)纹理,然后创建多个不同的SRV,他们使用同一个资源但可以按照不同格式读取底层数据。例如,既可以作为UNORM8_SRGB(在SRGB空间里用unsigned 8-bit映射到浮点数0..1之间),也可以作为UINT8(unsigned 8-bit integer)。
 
创建额外的SRV看起来像是多余的步骤,但是它可以让API运行时在创建SRV的时候检查数据类型。如果你得到一个正确的SRV,就表示这个SRV与资源格式是兼容的,有了SRV以后就不用再做类型检查了。换言之,这是为了API效率。
 
总之,在硬件层面,归结为一整包关于纹理采样操作的状态——采样器状态(sampler state)和用到的纹理/格式,等——这些东西保存在某个地方(参考Part2中关于管线架构中状态管理的多种方式的说明)。从“每次状态改变刷新管线”到“在采样器中完全无状态执行,以及随同每个纹理请求发送一系列东西”,中间有多种选项。你什么都不用担心——这种事情,硬件架构会做成本分析,计算一些工作量,然后决定采用哪种方法——但是这值得重复:作为PC端程序员,不要以为硬件遵循任何特定的模型。
 
不要认为纹理切换开销很大——它们可能是完全管线化的无状态纹理采样器,所以它们基本上没开销。但是也不要认为完全的没有开销——它们可能不是完全管线化的,或者在管线里的某个时刻,不同的纹理状态集合有一个最大上限。除非你是在指定硬件的终端上(或者你针对每一代图形硬件优化你的引擎),那就没什么好说的了。在做优化时,有一个明显的改善是——按照材质排序,尽可能的避免不必要的状态改变,这样至少可以节省一些API操作。不要做任何基于硬件工作的特定模块,因为在每一代硬件之间可能千差万别。
 
纹理请求解析
 
那么,我们需要发送多大的关于纹理采样请求的信息量呢?这取决于纹理类型和正在使用哪种采样指令。现在,假设有一个2D纹理,如果我们想要做4x各项异性的2D纹理采样,我们需要发送哪些信息呢?
 
  • 2D纹理坐标——2个浮点数,在本篇里还是用D3D术语,称呼它们为u/v,而不是s/t。
  • 在“x”方向上,u和v的偏导数。
  • 同样,还有在“y”方向上,u和v的偏导数。
 
所以,一个普通的2D纹理采样请求需要6个浮点数——可能比你想的还要多。4个梯度值用来选择mipmap和各项异性采样内核的大小与形式。还可以使用指定mipmap层级的纹理采样指令(在HLSL中,是SampleLevel)。这些只是包含LOD参数的值,不需要梯度,但也不能各项异性采样——最适合的是三线性采样。不管怎样,要用6个浮点数。貌似是这样的。我们真的需要每次纹理请求都发送它们吗?
 
答案是:需要。除了在Pixel Shader里,都是需要的(如果需要各向异性采样的话)。在Pixel Shader中,是不需要的。有一个技巧可以在Pixel Shader中得到梯度指令(你可以计算一些值,然后询问硬件“这个值的屏幕空间梯度近似值是多少?”),这种技巧可以被用在纹理采样器中,通过坐标得到需要的偏导数。所以对于一个PS中的2D“sample”指令,在采样单元中做一些数学运算,实际上只需要发送剩余部分的2个坐标。
 
有趣的地方:最坏的情况下,一次纹理采样需要多少个参数呢?在当前的D3D11管线中,最坏情况是在Cubemap数组上执行SampleGrad操作。让我们来看一下统计:
 
  • 3D纹理坐标——u,v,w:3个浮点数。
  • Cubemap数组索引:一个整型(这里认为是和一个浮点开销一样)。
  • 屏幕上x和y的方向上(u,v,w)的梯度值:6个浮点数。
 
每个采样像素一共10个值——这样实际上用40个字节来存储。现在,你可能会认为不需要全部用32位表示(对于数组索引和梯度值可能是多余的),但是 发送的数据量仍然很大。
 
实际上,我们来检查一下这里用到的带宽类型。我们假设我们的纹理大部分都是2D的(还伴有cubemap),大多数纹理采样都来自Pixel Shader中,Vertex Shader中几乎没有纹理采样,并且常规采样类型的请求是最为频繁的,其实是SampleLevel(这些都是在 游戏实际渲染中很典型的)。这意味着每个像素发送32位浮点值的 平均个数介于2*(u+v)和3*(u+v+w/u+v+lod)之间,比如2.5或10个字节。
 
假设一个中等分辨率大小——比如1280x720,大于92万像素。Pixel Shader平均有多少次纹理采样?至少是3。假设有适量的overdraw,那么在整个3D渲染阶段,我们要处理的屏幕像素大约会是两倍。我们处理完之后,将几张 全屏纹理传递给后期处理(post-processing)。这可能会每像素增加6次采样,考虑到一些后期处理将在降低的分辨率下执行。加起来是0.92*(3*2+6)=大约是每帧1100万次纹理采样,30fps大约就是 每秒3.3亿次。每个请求10个字节,就是3.3GB/s的纹理请求带宽。这只是下限,因为还有一些额外的开销(接下来说)。当今的游戏在一块较好的DX11显卡上以高分辨率运行,要比我列出的有更多复杂的shader,大量的overdraw,甚至是一些延迟着色/光照,更高的帧率,以及更复杂的后期处理方式——做一个快速粗略的计算,在四分之一分辨率下采用双边升采样(bilateral upsampling)的高品质SSAO需要多少纹理请求带宽呢……
 
要说的是,整个纹理带宽是你操作不了的。纹理采样器不是shader核心的一分部,他们是芯片上的独立单元,不仅仅通过自身来处理每秒几千兆的字节。这是架构上的问题——这是件好事,我们不用在Cubemap数组上使用SampleGrad。
 
但是谁来请求纹理采样呢?
 
答案当然是:没有人。我们的纹理请求都来自shader单元,我们知道每次在哪里处理16~64个像素/顶点/控制点/……。所以我们的shader不会发送个别的纹理采样,它们是一次派发一批量的。这次,我使用16举例——这样简单一点,因为我上次用的32是非正方形的, 当谈论2D纹理请求时,这看起来有点奇怪。那么,一次16个纹理请求——构成了纹理请求的负载,加上开头的一些采样器如何执行的指令字段,再加上一些采样器用到哪个纹理和采样状态的字段,并发送到某个纹理采样器。
 
这将耗费一段时间。
 
耗时是很严重的。纹理采样器有一个很长的管线(我们很快就会知道为什么);一次纹理采样操作非常耗时,尽管shader单元只是闲置的。我们又要谈到:吞吐量。那么一次纹理采样究竟发生了什么,一个shader单元只是安静的切换到另一个线程/批次,并且执行另一项工作,然后得到结果后再切换回来。只要shader单元 有充分独立的工作就会执行的很好。
 
这里首先有大量的计算要执行:(这里,我假设是一次简单的双线性采样;三线性和各项异性要做更多的工作,见下文)。
 
  • 如果这是一个Sample或者SampleBias类型的请求,先要计算纹理坐标的梯度值。
  • 如果没给出明确的mip level,要根据梯度值计算采样用到的mip level,如果指定了LOD bias,还要再加上它。
  • 对于每个生成的采样位置,应用寻址模式(wrap/clamp/mirror等)来得到正确的纹理采样位置(在规范化的[0, 1]坐标之间)。
  • 如果是个cubemap,我们还要确定采样哪个cube面(根据绝对值和u/v/w坐标的符号),并且相除,把坐标投影到单位cube上,这样它们在[-1, 1]之间。我们还需要丢弃3个坐标其中的一个(根据所在的cube面)和另外两个坐标的缩放/误差,所以对于我们常规的纹理采样,它们同样在[0, 1]的规范化坐标空间里。
  • 下一步,拿到[0, 1]的规范化坐标并且转换到定点像素坐标来采样——我们需要一些双线性插值的分数位数。
  • 最终,从整数x/y/z和纹理数组索引中,我们可以计算出读取像素的地址。
 
如果你认为这听起来总结的不好,我再来提醒你一下,这是个简化图。上面的总结都没涵盖纹理边界和cubemap边角的采样问题。相信我,现在可能听起来不好,但是如果你实际的对所有事物编码,你肯定会吓坏的。好消息是我们有专门的硬件负责做这件事:)无论如何,我们现在有一个内存地址来获取数据了。并且内存地址的附近还有一两个缓存。
 
纹理缓存
 
如今几乎都用两级纹理缓存。二级缓存完全是个普通的缓存,用来缓存包含纹理数据的内存。一级缓存不是很标准,因为它用到其它 技术。每个采样器大概4~8kb大小,可能比你预想的还要小。我们先来讲下大小尺寸,因为它往往让大多数人感到惊讶。
 
事情是这样的:大多数纹理采样在Pixel Shader中要开启mip-mapping,由采样的mip层级来决定用到的屏幕像素:像素比例约为1:1——这就是关键点。但是这意味着,除非你每次都碰巧命中纹理上的同一个位置,否则每次纹理采样操作平均都会miss1个像素——双线性采样的 实际测量值大约是每次miss1.25个像素(如果你跟踪单独的像素)。这个值基本不会随着改变纹理缓冲大小而变动,除非纹理缓存可以足够容纳下整张纹理(通常是几百kb几兆,对于L1缓存是不切实际的)。
 
基于这点,纹理缓存有很大优点(由于它的存在,让每次双线性采样大约4次的内存访问降低到了1.25次)。但是不同于CPU或者shader内核的共享内存,纹理缓存的进展很缓慢,只是从4k缓存增加到了16k;大纹理数据都是串行通过缓存流化的。
 
第二点:由于平均每次采样有1.25次非命中,纹理采样器管线需要足够长来保障每次采样读取内存不会有停滞(stalling)。换句话说: 对于一次内存读取,纹理采样器管线足够长了,即使要花费400~800时钟周期也 不会有间断。这个管线非常长——在字面意义上它确实是个管线,将没处理的数据从一个管线寄存器传给下一个,经过几百个时钟周期,直到内存读取完成。
 
因此,L1缓存很小,管线很长。那关于“其它技术”是什么呢?好吧,这就是压缩纹理格式。在PC上可以见到——S3TC又称为BC1~3格式,还有D3D10中引进的BC4和5格式,都是DXT格式的变种,最后还有D3D11引进的BC6H和7格式——它们都是基于块的方法,单独编码4x4像素块。如果在纹理采样时解码,每个时钟周期需要一起解码4个像素块并从每个块中取得一个像素。这太操蛋了。所以在加载到L1缓冲的时候,4x4块就被解码了:比如BC3(DXT5)格式,你从L2纹理缓存中取得一个128位块,然后解码到16个像素的纹理缓存。现在每次采样只需要解码1.25/(4*4)=大约0.08块,取代了原来每次采样不得不解码4个块,至少如果纹理访问模式足够连贯了,实际上可以命中 你需要的位于 旁边另外15个解码的像素了:)即使你最后用到超出了L1缓冲的部分,这仍然是一个很大的改善。这种技术也仅限于DXT的块;通过D3D11缓冲 填充路径,你可以处理大于50种纹理格式的需求,这大约能命中通常的实际像素读取路径的三分之一。举个例子,像UNORM sRGB格式的纹理可以在纹理缓存中被转换成每个通道16位整型的sRGB像素(或者每个通道16位浮点数,再或者32位浮点数,按照你的意愿)。然后在合适的线性空间中执行滤波操作。注意,这最终会L1缓存中的像素覆盖区域,所以你可能想要增加L1缓存纹理的大小;不是因为你需要缓存更多的像素,而是因为缓存的像素更富裕。实际上,这是一种权衡。
 
滤波(Filtering)
 
在这一点上,实际的双线性过滤操作过程是很简单的。从纹理缓存中抓取4个采样点,使用小数位混合它们。多一点会用上乘法累积单元。(实际上很多——一次处理4个通道的时候这样做)。
 
三线性滤波呢?就是两次双线性滤波采样和另一个线性插值 。只是在管线中再添加一些乘法计算。
 
各向异性采样呢?需要在管线中提前做一些额外的工作,最初要计算采样的mip-level。我们要做的是查看梯度值来决定不仅是区域还有像素空间中的屏幕像素形状;如果它们宽高大致一样,就只执行一次常规的双/三线性采样,但是如果它们在一个方向上不一致,要在这条线上采样几次并把采样结果混合起来。这样生成了一些采样位置,所以最终要遍历全部的双/三线性的管线若干次,采样位置和相对权重的计算对于硬件供应商来说是严格保密的;他们研究这个问题已经很多年了,现在的硬件开销都性能很好。我不想猜测他们是怎么实现的。说实话,作为图形程序员,只要它工作正常并且没性能问题,你就不需要去关心各向异性滤波的底层实现算法。
 
不管怎样,除了设置和所需采样点的排序逻辑之外,这不会给管线 增加大量的计算。在实际滤波阶段,我们有足够的乘法累积单元来计算各向异性滤波的权重之和, 不用额外的硬件。
 
纹理返回
 
现在,我们快到纹理采样器管线的最后了。所有这些的结果是什么?每次纹理采样请求有4个值(r,g,b,a)。不同于纹理请求,请求尺寸大小有显著的变化,这里目前为止最常见的只是shader消耗4个值。提醒一下,发送回4个浮点数的带宽也是不能忽视的,某些情况下可以剔除一些位数。如果你的shader是采样一张32位浮点通道的纹理,你最好返回32位浮点,但是如果是读取一张8位UNORM sRGB纹理,返回32位就多余了,你可以用一个更小的返回格式来节省带宽。
 
就是说——shader单元有自己的纹理采样返回结果,并且可以在你提交的批次上继续工作——这是本部分的总结。我们下篇再见,在谈到实际开始光栅化图元之前的需要做的工作时。更新:这是一幅纹理采样管线图,有个错误在图中修复了。
 
 
补充说明
 
这次就不用免责声明了。带宽中提到的例子真的是因为我找不到实际数据:),但除此之外,我这里描述的应该很接近于实际的GPU了,即使我告别了滤波的部分,等(主要是因为实现细节太恶心了)。
 
至于纹理L1缓存包含的压缩纹理数据,据我说知针对于当今的硬件是很准确的。一些老硬件在L1纹理缓存中甚至还保留了一些其它压缩格式,但是由于“每次采样一大块缓冲有1.25次miss”的规律,这些就不重要了,可能不值得复杂化。我认为这些现在都消失了。
 
嵌入式/功耗优化的图形芯片是很有趣的,例如PowerVR;在本系列中,我不会深入这类芯片太多,因为我的关注点是PC端高性能部分,但是如果你感兴趣我在评论中有一些之前部分的说明。
 
PVR芯片有它自己的纹理压缩格式,不是基于块的,并且紧密的集成在它的滤波硬件中,所以我认为在L1纹理缓存中保留着它们的压缩纹理(实际上,我不知道是否有二级缓存!)。这是一个很有趣的方法,而且可能在每个区域和能源耗费上很有效。但是我认为“解压到L1缓存”的方法会有更高的吞吐量,不说太多了,这里都是讲的高端PC的GPU:)

posted on 2015-07-08 01:54  往昔之剑  阅读(1207)  评论(0编辑  收藏  举报

导航