A trip through the Graphics Pipeline 2011, part 4 | The ryg blog
欢迎回来。上一部分讲的是顶点着色器,还大致介绍了通用的 GPU 着色器单元。总的来说,它们只是向量处理器,但它们可以访问一种在其他向量处理架构中不存在的资源:纹理采样器。纹理采样器是 GPU 流水线不可或缺的一部分,其复杂程度(以及趣味性!)足以单独写一篇文章来介绍,那接下来就开始吧。
在开始实际的纹理操作之前,我们先来看一下驱动纹理的 API 状态。在 D3D11 部分,这由三个独立的部分组成:采样器状态、底层纹理资源、着色器资源视图
大多数情况下,你会用给定格式(比如每个分量 8 位的 RGBA)创建一个纹理资源,然后只需创建一个匹配的 SRV。但你也可以创建一个“每个分量 8 位、无类型(typeless)”的纹理,然后为同一个资源创建多个不同的 SRV,以不同格式读取底层数据,例如一次以 UNORM8_SRGB(在 sRGB 空间中的无符号 8 位值,映射到浮点 0…1)读取,一次以 UINT8(无符号 8 位整数)读取。
一开始,创建额外的 SRV 似乎是一个令人烦恼的额外步骤,但关键在于,这使得 API 运行时能够在 SRV 创建时完成所有类型检查;如果你得到一个有效的 SRV,说明 SRV 和资源格式是兼容的,并且在该 SRV 存在期间无需再执行类型检查。换句话说,这完全是为了 API 效率。
无论如何,在硬件层面,这归结为,与纹理采样操作相关联的一系列状态——采样器状态、要使用的纹理/格式等——需要存放在某个地方(第二部分解释了在流水线架构中管理状态的各种方式)。所以,存在多种方法,从“每次任何状态更改时都刷新流水线”到“在采样器中完全无状态,并随每个纹理请求发送完整状态更新请求”,以及介于两者之间的各种选项。这些你无需担心——这是硬件架构师拿来做成本效益分析、模拟几种工作负载然后选择最优方法的事情——但值得重复强调的是:作为 PC 程序员,不要假设硬件会遵循任何特定模型。
不要假设纹理切换的代价很昂贵——它们可能将无状态纹理采样器完全流水线化,因此几乎是免费的。但也不要假设它们完全免费——也许它们并非完全流水线化,或者在任何给定时间流水线中可支持的不同纹理状态数量有上限。除非你在具有固定硬件的游戏主机上(或者你为所针对的每一代图形硬件手工优化你的引擎),否则根本无法判断。因此,在优化时,做那些显而易见的事情——尽可能按材质排序以避免不必要的状态更改——这至少可以节省一些 API 工作量,这样就够了。 不要基于硬件当前工作的任何特定模型做任何花哨的操作,因为它可能(并且肯定会!)在硬件代际之间瞬间改变。
那么,我们需要随纹理采样请求发送多少信息?这取决于纹理类型以及我们使用的采样指令类型。现在,假设是二维纹理。如果我们想做一次最多 4 倍各向异性采样的 2D 纹理采样,我们需要发送哪些信息?
2D 纹理坐标 —— 两个浮点数,在本系列中遵循 D3D 术语,我称它们为 u , v u, v u,v,而不是 s , t s,t s,t
u 和 v 在屏幕 “x” 方向上的偏导数: ∂ u ∂ x , ∂ v ∂ x \displaystyle \frac{\partial u}{\partial x}, \quad \frac{\partial v}{\partial x} ∂x∂u,∂x∂v
同样,我们还需要 “y” 方向上的偏导数: ∂ u ∂ y , ∂ v ∂ y \displaystyle \frac{\partial u}{\partial y}, \quad \frac{\partial v}{\partial y} ∂y∂u,∂y∂v
所以,对于一次相当普通的 2D 采样请求(属于 SampleGrad 类型),总共就是 6 个浮点数——可能比你想象的要多。那 4 个梯度值既用于 mipmap 级别的选择,也用于确定各向异性滤波核的大小和形状。你也可以使用明确指定 mipmap 级别的纹理采样指令(在 HLSL 中是 SampleLevel)——这些指令不需要梯度,只需要一个包含 LOD 参数的值,但也无法做各向异性滤波——最多只能做到三线性!无论如何,我们先暂且以这 6 个浮点数为例。看起来确实很多。我们真的需要在每次纹理请求时都发送它们吗?
答案是:要看情况。在除像素着色器之外的所有阶段,(如果想要各向异性滤波),则确实必须发送。在像素着色器中,不必如此;像素着色器可以使用一个技巧,先计算某个值,然后询问硬件“这个值在屏幕空间的近似梯度是多少?”,纹理采样器同样可以用这个技巧,仅凭坐标就能获得所有必要的偏导数。因此,对于像素着色器的 2D “sample” 指令,实际上你只需发送那两个坐标,剩下的都可以让采样单元做更多的数学运算来隐含地算出。
额外好玩的是:单次纹理采样所需参数的最坏情况是多少?在当前的 D3D11 管线中,最坏的情况是对立方体贴图数组的 SampleGrad。我们来统计一下:
3D 纹理坐标 —— u, v, w:3 个浮点数。
立方体贴图数组索引:一个整数(这里按与浮点数相同的成本计算)。
(u, v, w) 在屏幕 x 和 y 方向上的梯度:6 个浮点数。
合计每次采样 10 个值——如果全部以 32 位存储,就是 40 字节。你或许会认为并不需要为所有这些都用完整的 32 位(对于数组索引和梯度来说这可能过度了),但即便如此,仍然需要在各处传输大量数据。
事实上,让我们估算一下带宽开销。假设大多数纹理都是 2D(只有少量立方体贴图),大多数采样请求来自像素着色器,顶点着色器里几乎没什么纹理采样,且最常用的是常规 Sample 类型请求,其次是 SampleLevel(这在实际游戏渲染中相当典型)。那么,平均每像素发送的 32 位浮点值数会介于 2(u+v)和 3(u+v+w 或 u+v+lod)之间,我们取 2.5,也就是 10 字节。
再假设中等分辨率,比如 1280×720,大约 0.92 百万像素。你的游戏像素着色器平均有多少次纹理采样?我认为至少 3 次。再假设有适度的过度绘制,在 3D 渲染阶段每个像素平均被访问两次。然后我们还有几个以纹理为主的全屏后处理通道,这可能又带来至少 6 次采样(考虑到部分后处理会在较低分辨率进行)。把这些加起来:0.92 × (3 × 2 + 6) ≈ 11 百万次采样/帧,假设 30 FPS,就是每秒约 330 百万次采样。以每次 10 字节计算,仅请求负载就需要 3.3 GB/s。这个只是下限,还没算额外开销(我们稍后会讨论)。我这里还是偏保守了一点:现代游戏在高端 DX11 卡上通常分辨率更高、着色器更复杂、过度绘制量相当或更少(得益于延迟渲染),帧率更高、后处理更复杂——自己随手算算,像四分之一分辨率下做一个体素化环境光遮蔽(SSAO)然后双边上采样,需要多少纹理请求带宽……
要点是,纹理带宽的问题可不是能随便带过的。纹理采样单元并不在着色器核心里,而是芯片上相对独立的单元,高速移动数 GB/s 的数据可不是自动完成的。这是个真正的架构问题——幸好并不是对所有情况都用对立方体贴图数组做 SampleGrad
请求负载不容小觑
平均带宽压力巨大
答案当然是:没人。我们的纹理请求来自着色器单元,而我们知道这些单元一次会处理 16 到 64 个像素/顶点/控制点/……所以着色器不会发送单个纹理采样请求,而是一次性发出一大批。这次我用 16 作为示例——仅仅因为上次我选的 32 在讨论 2D 纹理请求时显得非正方形有点奇怪。因此,一次发送 16 个纹理请求——构建那些纹理请求负载,在开头加上一些命令字段以告诉采样器该做什么,再加上一些字段以告诉采样器要使用哪个纹理和哪个采样器状态(有关状态的说明见上文),然后将其发送到某处的纹理采样器。
这可需要一些时间。
真的。纹理采样器有一个相当长的流水线(我们稍后会看到原因);一次纹理采样操作花费的时间太长,以至于着色器单元不能闲着什么都不做。因此,再强调一遍:吞吐量。因此,在发起纹理采样时,着色器单元会悄悄切换到另一个线程/批次去做其他工作,然后在结果就绪后再切换回来。只要有足够的独立工作让着色器单元去做,这种方式就完全可行!
首先要做一堆计算:(以下假设是简单的双线性采样;三线性和各向异性采样的工作更复杂,见后文)
如果这是 Sample
或 SampleBias
类型的请求,先根据梯度计算纹理坐标的偏导数。
如果没有显式给定 mip 级别,则根据梯度计算要采样的 mip 级别,并在指定时加上 LOD 偏差。
对每个计算得到的采样位置,应用寻址模式(wrap/clamp/mirror 等),以获得归一化到 [0,1] 范围内的正确采样位置。
如果是立方体贴图,还需根据 u/v/w 坐标的绝对值和符号确定要采样的立方体面,然后进行除法,将坐标投影到单位立方体,使其落在 [-1,1] 区间。接着,根据所选面舍弃三个坐标中的一个,并对剩余两个进行缩放/偏移,使它们落在与常规模板采样相同的 [0,1] 归一化坐标空间中。
接下来,将 [0,1] 归一化坐标转换为固定点的像素坐标——我们需要一些小数位来做双线性插值。
最后,根据整数 x/y/z 和纹理数组索引,就可以计算出读取纹素的内存地址。嘿,到了这一步,再来几次乘加运算算得上什么呢?
如果你觉得这样总结听着糟糕,容我提醒一下,这还是简化后的视图。上面的概要甚至没涵盖诸如纹理边界或立方体贴图边缘/拐角采样等有趣的问题。相信我,现在听起来可能不好受,但如果你真的把所有需要完成的操作都写成代码,你会彻底被吓傻。幸好我们有专门的硬件来为我们做这些。 好了,现在我们已经有了一个内存地址来获取数据。凡是有内存地址,就会有一个或两个缓存紧跟其后。
在计算机图形学中,MIP 映射(MIP mapping)是一种纹理映射技术,用于优化渲染质量和性能。MIP 映射通过为纹理生成一系列预先计算好的、逐渐缩小的版本(称为 MIP 级别),来减少由于纹理大小与屏幕像素大小不匹配而导致的锯齿和模糊问题。
在纹理采样过程中,图形处理器(GPU)会根据片段(fragment)到纹理的采样距离来选择合适的MIP级别进行采样。这个过程称为MIP映射选择或MIP级别选择。
如今,大家似乎都在使用两级纹理缓存。二级缓存是一个完全标准的缓存,用来缓存包含纹理数据的内存。一级缓存则不那么标准,因为它具有额外的“智能”功能。它的容量也比你想象的小——每个采样器大约只有 4–8 KB。我们先来聊容量,因为这常常让人大吃一惊。
关键在于:大多数纹理采样都是在像素着色器中启用 mip 映射后进行的,而选择采样的 mip 级别正是为了让屏幕像素与纹素的比例大致为 1:1——这也是整个 mip 映射的意义所在。但这意味着,除非你不断命中纹理中的完全相同位置,否则每次纹理采样平均都会有大约 1 个纹素未命中——实际测量值在使用双线性过滤时约为 1.25 次未命中/请求(如果你单独追踪每个像素)。这一数值随着纹理缓存大小的改变几乎不会变化,直到缓存足够大以容纳整个纹理(通常在几百 KB 到几 MB 之间,远高于 L1 缓存的现实大小)时才会骤降。
也就是说,任何纹理缓存都是巨大的收益(因为它能将双线性采样从大约 4 次内存访问降到 1.25 次),但与 CPU 或着色器核心的共享内存不同,将 L1 缓存从 4 KB 扩大到 16 KB 收益甚微; 无论如何,我们都在通过cache传输体量更大的纹理数据。
第二点:由于平均 1.25 次未命中/采样,纹理采样流水线必须足够长,才能在不阻塞的情况下完成一次完整的内存读取。 换句话说,纹理采样流水线要足够长,以至于即使内存读取需要 400–800 个周期,也不会停顿。这真的是一条非常长的流水线——它在字面意义上就是流水线,数据在几百个周期内从一个流水寄存器传到下一个,中间没有任何处理,直到内存读取完成。
因此,一级缓存很小,流水线很长。
那么,“额外的智能”是什么?那就是压缩纹理格式。PC 上常见的 S3TC(又名 DXTC/BC1-3)、D3D10 引入的 BC4/5(都是 DXT 的变种),以及 D3D11 引入的 BC6H/7,都是基于块的方法,以 4×4 像素块为单位编码。如果在纹理采样时才解码,最坏情况下每个周期需要解码 4 个块并从每个块中取出一个像素,这实在糟透了。因此,这些 4×4 块会在提入 L1 缓存时就解码:以 BC3(DXT5)为例,你从 L2 中取回一个 128 位块,然后在纹理缓存中将其解码为 16 个像素。这样,你就不必在每次采样时解码多达 4 个块——平均只需解码 1.25/(4×4) ≈ 0.08 个块(前提是纹理访问模式足够连贯,能命中与所需像素同块的其他 15 个像素 )。即便在它从 L1 中被驱逐前只用到部分解码结果,这也是巨大的性能提升。
这种技术并不限于 DXT 块;你可以在缓存填充路径中处理 D3D11 所需的 50 多种纹理格式的大部分差异,而缓存填充的命中率大约是实际像素读取路径的三分之一——非常划算。例如,对 UNORM sRGB 纹理,可以在纹理缓存中将 sRGB 伽马空间像素转换为每通道 16 位整数(或 16 位浮点、甚至 32 位浮点),然后在滤波时在正确的线性空间中进行操作。但要注意,这会增加 L1 缓存中纹理元素的占用空间,因此你可能需要适当增加 L1 缓存大小;不是因为要缓存更多纹理元素,而是因为每个纹理元素“变胖”了。和往常一样,这是一种权衡。
提前解码:把所有压缩格式的块在填入 L1 时就解码好,后续采样只做简单的像素读取与插值,无需每次都做复杂的压缩块解码。
格式转换:同样在缓存填充时就完成 sRGB→线性空间、UNORM→浮点/整数等转换,后续滤波直接基于已经“就绪”的格式化数据。
高命中利用:由于采样经常在相邻或同一块内,L1 缓存中的 16 个像素能够被多次复用,大大摊薄了解码与访存延迟。
平均解码块数/采样 = 1.25 未命中/采样 × 1 块/未命中 16 像素/块 = 1.25 16 ≈ 0.078125 ≈ 0.08 块/采样 \text{平均解码块数/采样} = \frac{1.25\ \text{未命中/采样} \times 1\ \text{块/未命中}} {16\ \text{像素/块}} = \frac{1.25}{16} \approx 0.078125 \approx 0.08\ \text{块/采样} 平均解码块数/采样=16 像素/块1.25 未命中/采样×1 块/未命中=161.25≈0.078125≈0.08 块/采样
双级缓存结构
L2 缓存:标准缓存,用于存放较大块的纹理数据。
L1 纹理缓存:容量仅约 4–8 KB/采样器,却内置“智能”解码与优化策略。
高未命中率与长流水线
启用 mip 映射且像素与纹素比约 1:1 时,平均每次双线性采样会有 ≈ 1.25 次 L1 未命中。
未命中导致 400–800 周期的全局内存访问延迟,为遮盖该延迟,纹理采样器设计了数百级深的流水线。
解压到 L1 缓存的“额外智能”
将压缩纹理块(如 BC1–7 各种 DXT/BC 格式)在提入 L1 时解码为完整像素块。
典型以 BC3(DXT5) 为例:每取回一个 128 位压缩块,解码成 16 个像素;平均每采样仅需解码 ≈ 0.08 块。
此策略大幅降低了解码计算开销,并且将块格式差异的处理集中在缓存填充路径,命中率高且成本低。
格式转换与权衡
可在 L1 缓存中完成 sRGB→线性空间、UNORM→浮点/整数等转换,方便后续滤波。
转换后纹理元素会“变胖”,可能需要适当增大 L1 缓存容量;不过这是为提升吞吐与效率所做的合理权衡。
到这一步,实际的双线性过滤过程相当简单。从纹理缓存中抓取 4 个采样点,用它们的小数坐标部分进行插值混合。这就需要我们的老搭档——乘加单元。(实际上要用得更多——因为我们同时对 4 个通道进行处理……)
三线性过滤?执行两次双线性采样,然后再做一次线性插值。只需往计算中再添加一些乘加运算即可。
各向异性过滤?这就需要在流水线更早的阶段做一些额外工作,大致在最初计算要采样的 mip 级别时进行。我们会查看梯度,不仅确定屏幕像素在纹素空间中的“面积”,还判断其“形状”;如果它的宽高差不多,我们就执行常规的双线性/三线性采样;但如果它在某个方向被拉长,则沿该方向取多个采样点并将结果混合。这会产生多个采样位置,因此我们要多次循环完整的双线性/三线性流水线。
具体如何布置采样点及计算它们的相对权重,则是各大硬件厂商的核心机密;他们在这个问题上研究多年,并已在合理硬件成本下收敛出非常优秀的方案。说实话,作为图形程序员,你完全不需要去猜测那些底层算法;只要它们不出严重伪影且不会带来巨大性能开销,就足够了。
总之,除了设置和对所需采样进行循环调度的控制逻辑外,这并不会给流水线带来显著的额外计算负担。此时我们已有足够的乘加单元来完成各向异性过滤所需的加权求和,而无需在过滤阶段增设过多专用硬件。
样本布点和权重 由硬件内部的高效算法决定,程序员只需指定各向异性等级即可。
控制逻辑 则负责将整个多点采样拆成一连串流水线调用,并在后台管理调度,用户层面无需介入细节。
GPU 的纹理采样器并非即时完成一次复杂多点采样后再返回,而是将对一组采样位置的请求拆成多次流水线调用(loop over sample positions)。
在每一次子采样完成后,控制逻辑会判断是否还需更多子采样(例如在 8× 各向异性时要做 8 次),并在同一个纹理请求批次中依次发出这些子请求。
这套逻辑保证了:深长的纹理流水线可以持续“满载”运行,而着色器核心则无需在每个子采样间空等,而是可以切换去执行其它线程,最大化吞吐。
现在我们几乎已经走完了纹理采样器流水线。经过这一切处理,结果是什么?最多为每次纹理采样请求返回 4 个值(r、g、b、a)。与纹理请求大小差异显著不同,这里最常见的情况无疑是着色器会消费所有 4 个分量。注意,从带宽角度来看,发送 4 个浮点数并非小事,因此在某些情况下你可能想要减少位数。如果着色器采样的是每通道 32 位浮点纹理,那么最好仍以 32 位浮点返回;但如果它读取的是 8 位 UNORM SRGB 纹理,则以 32 位返回就显得过度,通过在返回路径上使用更小的格式可以节省带宽。
就这样——着色器单元现在拿到了纹理采样结果,可以继续处理你提交的批次,这部分内容到此结束。下一期我将讲解在真正开始光栅化图元之前需要完成的工作。补充说明:下面还有一张纹理采样流水线的示意图,其中包括一个我后来像职业选手那样修正的有趣小错误!
这次没有太多免责声明。示例中提到的带宽数字其实是即兴编的,因为我懒得去查当前游戏的真实数据 :),但除此之外,我在这里描述的流程应该与你手上的 GPU 十分接近,尽管我略过了一些过滤等边缘的细节(主要是那些细节闻之生厌,却不够启发)。
至于 L1 纹理缓存中存放解压后的纹理数据,据我所知,目前的高端硬件确实如此。早期有些设备即便在 L1 缓存中也保持某些格式的压缩状态,但鉴于“在大多数缓存大小下平均 1.25 次未命中/采样”的规律,这样做收益不大,也不值得增加复杂度。我想那些设计现在基本都被淘汰了。
有意思的是一些嵌入式/低功耗的图形芯片,比如 PowerVR;鉴于本系列聚焦 PC 顶级性能硬件,我不会过多深入此类芯片,但如果你感兴趣,可以查看前几部分评论区的相关笔记。PVR 芯片有自己非块格式的纹理压缩方法,与它们的过滤硬件紧密集成,所以我猜它们可能在 L1 缓存中也保持压缩(实际上我不清楚它们是否还有二级缓存!)。这种方法在单位面积与能耗下的工作效率或许相当出色。不过我认为“在填充 L1 缓存时解压”能提供更高的整体吞吐率,正如我再三强调的那样,高端 PC GPU 的核心就是追求吞吐量