原文:《A trip through the Graphics Pipeline 2011》
翻译:往昔之剑
转载请注明出处
欢迎回来。上个部分是关于vertex shader的,还带有一些GPU shader通用单元的概念。重要的是,它们仅仅是向量处理器,可是它们须要访问不在向量架构上的资源:纹理采样器。它们是GPU管线的一部分,而且十分复杂(还颇有趣!)足以保障它们本身的协议约束,那么就开始讲解吧。
纹理状态(Texture state)
在咱们开始实际的纹理操做以前,让咱们来看一下驱动纹理的API状态。在D3D11里,这是由3个不一样部分组成:
- 采样器状态。过滤模式(filter mode),寻址模式(addressing mode),各项异性过滤(max anisotropy),之类的等等。这个状态一般控制纹理采样如何执行。
- 底层纹理资源。这能够归结为一个指向内存中的原始纹理比特位(raw texture bits)的指针。这个资源还决定了它是一个单独的纹理仍是一个纹理数组,这个纹理使用哪一种多重采样格式(若是使用了多重采样的话),以及纹理比特位(texture bits)的实际布局——例如,在资源层它尚未肯定在内存中如何被确切的解析,可是它们的内存布局已经肯定了。
- 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:)