核心问题:上一个系列我们追到 early-Z——深度裁决已经决定"谁写、谁不写"。可那些幸存的像素,到底是怎么真正"落"进显存里那两块缓冲的?本篇把像素后端当作整条管线上唯一一个"汇合点"来读:光栅厂把每个三角形派发(fork)成海量像素流,着色之后这些颜色、深度、模板、覆盖信息必须重新汇合(join)进 render target。夹在 fork 与 join 之间的,是一张显存带宽账——overdraw、颜色/深度压缩、MSAA resolve、TBDR 的 HSR,每一笔都在同一个地方结算。全篇收在"为什么像素越少,像素后端的工作未必越少"这句反直觉的话上。
本文由@溯光 出品,转载请注明出处。本文同步发布于公众号与个人博客:技术博客页。
为什么着色完,像素还不算"落地"(set-context)
上一篇我们把三角形一路送进了屏幕:边函数判定覆盖、重心坐标插值、early-Z 在着色前就剔掉一批被挡的像素。故事讲到这儿,听起来像素已经"画"出来了。但还差最关键的一步——那些幸存下来的像素,此刻还只是漂浮在着色器核里的寄存器值,根本没有碰到显存。
你可能会问:这不就是"把颜色写进颜色缓冲"嘛,有什么好讲的?问题恰恰藏在"写"这个字里。像素着色器(pixel shader)算出来了颜色,可它不知道这个颜色该和缓冲里已经存在的什么做计算、以什么格式落地、落在哪个 block 上、要不要顺带更新深度。这些裁决不归着色器管,归管线上最后一个专管"落地"的单元管——而这个单元,恰恰是整条光栅化管线里数据处理密度最高、也最接近显存的一段。
我们用一句话给它的位置定锚:光栅化负责"展开",像素后端负责"汇总"。 前者把每个三角形倒成成千上万个像素(fork),后者把颜色/深度/模板/覆盖这些碎片重新合成回一整块 render target(join)。这一"散"一"聚"之间,硅片要付一笔不便宜的账——显存带宽。
本篇不涉及什么、为什么: - 不写"怎么写 shader"——像素着色器的 HLSL/GLSL 语法、光照模型、PBR,都不在本篇,那属于算法层(《实时渲染管线探析》)。 - 不重复移动端 TBR/TBDR 的完整带宽账与取舍——已在《移动端图形与功耗》ep02/ep03 写透,本篇只在 §10 引用其结论做横向对比。 - 不展开 MSAA 的采样模式选型与 FXAA/TAA 这类后处理抗锯齿——本篇只看 MSAA 在像素后端里的写/解析带宽。
出处:Fabian Giesen《A trip through the Graphics Pipeline 2011》· Part 8/9(fork 与 join 的硬件机制)(CC0 公有领域)。
这一章要回答的,就是像素落地这条线:像素怎么被派发、着色之后在哪汇合、ROP 到底替我们做了什么、以及这一切要花多少显存带宽——更重要的是,哪些场景下这笔账会失控。我们先把地图画出来。
而在聊"写",先看一眼实物:像素最终要一次次"落"进的就是这样一片帧缓冲硬件——下面的 Sun cgsix,是一块 90 年代的 framebuffer 子卡,GPU 算出的颜色就是被 ROP 一行行写进它背后的显存颗粒,再被 DAC 换结成屏幕信号。
一、先看地图:像素后端在管线里的位置(explain-structure)
整条光栅化管线,走到像素后端这一站时,已经绕了很长的路。回顾一下前六站:命令前端把提交翻译成工作,顶点着色器把顶点搬到裁剪空间,图元装配与裁剪把顶点连成三角形并裁掉屏幕外部分,光栅化 setup 算好边函数,光栅化器把三角形覆盖的像素逐一产生出来。
在这张链条图上,像素后端是最后一站,但绝不是功率最低的一站。它被我们组装在管线最底部是有原因的:所有像素、无论来自哪条 triangle 流,最终都要挤进同一块颜色缓冲、同一块深度缓冲,所以它是天然的"汇合点"——多个光栅化/着色单元并行干活,最后必须在这里把结果合到同一个地址空间上。这也是为什么 ROP(Render Output Unit)的数量和分片方式,直接决定了每个 GPU 每帧能往显存写多少数据。
"在很多并行单元汇合"不只是一句套话——真实 GPU 的内部就是把一堆流处理器 + 纹理单元 + ROP 拼在同一块芯片上,靠片上互连汇成同一个出口。下面的 MIAOW 是一个开源 GPU 的内部架构图,灰绿色条块就是并列的 stream processor 簇,Rasterizer/像素处理后端就嵌在它们汇集的出口附近。
需要先说清楚的是"规范怎么写"与"硬件怎么叫"的差异。API 规范里,这一段叫 Output Merger(输出合并,OM)——在 D3D12/Spec 的逻辑管线里,它紧跟在像素着色之后,负责把着色器输出的颜色与 render target 里已有的值合并(合成两个名字都直指"合并/汇合":OM 与 ROP)。对应到硬件,管这个活儿的单元叫 ROP(Render Output Unit),通常是若干挂在显存控制器旁的硬件模块,负责真正把最终像素写进颜色/深度/模板缓冲。二者在功能上大致对应,但实现层面 ROP 还夹带了格式转换、压缩、写平面调度这些规范不曾在意的硬件琐事。
术语一致性声明:下文"像素后端 / ROP / Output Merger"指同一段功能,规范语义说"OM",硬件实现说"ROP"。2×2 quad 的执行模型以现代 NVIDIA/SIMD-SIMT 架构为例,并不代表所有 GPU 通用——这点会在 §三 声明限定。
出处:Microsoft Learn · Output Merger 阶段/Vulkan 规范 · Fragment Operations/NVIDIA · Life of a Triangle。
先把地图钉牢,我们就可以下钻到两个阶段本身了。从"像素从哪来"这个 fork 开始。
二、fork:光栅化怎么把三角形倒成像素流(show-flow)
像素后端的"入账"端,是光栅化器产生的像素。这一段在 ep06 已经细讲,这里只回放关键的几条:覆盖判定(coverage test)决定哪些像素被三角形盖住,重心插值把顶点属性算到每个覆盖像素上,输出带颜色、深度 z、以及"这个像素的哪几个采样点被覆盖"的 coverage mask。
硬件得到的不是"一个像素",而是一份份小组数据。为了在后台同时喂多个着色器核心、也为了满足 shader 里 ddx/ddy(屏幕空间导数)的需求,像素是以 2×2 的 quad(像素四元组)为基本工作单元被派发的。一次派发,就是一个 2×2 的格子。
"一张 quad 一张 quad"不是一个抽象说法——它就是 90 年代那块以硬件光栅化闻名的显卡干的事。下面的 3dfx Voodoo 2 是 1998 年把"固定管线光栅化+每像素流水"做进了量产芯片的代表:芯片上的扫描转换/纹理单元,正是把三角形倒成成片 quad(像素流)再喂给流水线的原始硬件现场。
这就带来一个关键事实:光栅化器从来不是"一个像素一个像素"地往下发,而是"一张 quad 一张 quad"地派发。 决策的最小单元是 quad,不是像素。这在后面讲带宽和"像素少却不省"时会反复出现。
出处:Stanford CS248 · 移动 GPU 硬件(quad 执行模型)/NVIDIA GPU Gems 2 · 像素四元组。
那这个 fork 究竟是怎么发生的?继续往下看 quad 的内部状态。
三、2×2 quad 与像素的 SIMT 命运(explain-structure)
四合一,是像素后端的第一个结构性设计。它同时押在两件事上:SIMT(单指令多线程)的锁步执行,以及 shader 导数的邻域需求。
先说为什么必须是 2×2。现代像素着色器在许多引擎里都用了屏幕空间导数——ddx 告诉你这个值在 x 方向的相邻像素变化,ddy 在 y 方向。它们用于纹理 mipmap 采样级选、法线扰动、抗锯齿等。要算一个像素的导数,就必须拿到它同 quad 里相邻像素的值。 所以如果四个 lane 里有几个没被覆盖,硬件也不能让它们空着——它们被标记为 helper lane(辅助 lane),以"算衍生物不为显示"的身份跟着执行像素着色器。
这就是一串连锁:为了给你一次覆盖像素提供导数,硬件把整个 quad 拉起来跑了一遍。 辅助 lane 算出的颜色会被丢弃,但它们执行了 shader、占用了匈牙利资源。
并不是四个保密,但这一"必须四合一"的约束,让像素着色器的调用模型和顶点着色器完全不同:
- 顶点着色器:每个顶点一个独立线程,顶点之间互不依赖,可以自由并行、任意乱序。
- 像素着色器:以 quad 为单位锁步执行,synchronization 粒度粗,且一条指令流必须服务整个 quad——你无法"只让这个 quad 里第 3 个像素跑、另外三个跳过",除非想损失 SIMT 的并行效率。
限定声明:2×2 quad 是现代 SIMD-SIMT GPU 的常见执行组织(以 NVIDIA 为代表),并非所有 GPU 都强制 pixel shader 按 quad 锁步执行(部分架构内部用更宽的 wavefront/simd 组织,但绝大多数主流的
ddx/ddy语义仍需以 2×2 或等价邻域为前提,quad overshading 的表现略异)。
"每一条 lane 都要跟着跑"这件事,会在 §四 和 §十一 造成两笔账:一次是维持成本(discard 也省不掉),一次是漏斗效应(小三角形、边缘像素)。
一个值得摆在台面上的实物参照:下面这张 NVIDIA G80(GeForce 8800,2006)的照片,暴露的是它裸片上的流处理器簇——现代 SIMD-SIMT 的"一簇并行 lane 共享一条指令流"正是在这类硬件上成型的。quad 的锁步执行、helper lane 的陪跑,全都发生在这些颗粒里。
出处:Fabian Giesen · Part 8(fork 阶段与像素生成)/NVIDIA GPU Gems 2 · 像素四元组。
坐实了 quad,我们来看它导致的第一个意外成本:着色器的"维持代价"。
四、像素着色器的维持代价:discard 与 alpha 切割(compare-options)
既然整个 quad 必须一起跑,那么"这个 quad 里真正想要的像素很少"时,硬件是不是可以只干一半?答案在大多数 SIMT 硬件上是:不行,至少不省钱。因为指令是广播给整个 quad 的,四个 lane 必须同进同出。硬件的"Musketeers motto"是:All for one and one for all——它一次只能同时执行整个 quad。
这带来的就是 quad overshading(quad 过度着色 / 过绘制):2×2 的 quad 只盖住了 2 个像素,硬件仍然为 4 个位置付出了着色器调用(尽管没盖到的 lane 的产出都会被丢弃)。这个浪费在小三角形和边缘像素上尤其严重——一个只盖 1 个像素的三角形,也要付出 4 lane 的成本。
那么 discard 呢?这是最能暴露 quad 代价的错误示范。很多同学以为"我在 shader 里 clip()/discard 掉了不需要的像素,它就不占成本了"。事实分两层:指令层面,discard 像素仍然执行了 shader(只是最终不写),所以 SIMT 成本一分没少——那 3 个 lane 早就在为它"陪跑"。深度层面,discard 更贵——因为它让 early-Z 失效(这份深度能不能写,取决于 shader 里是否 discard,而 early-Z 在着色前无从得知),于是像素被迫退到 late-Z,连本该被 early-Z 省掉的被遮挡像素,都得先着完色再说。
这就是为什么"alpha 切割(alpha cut/clip)"——把半透明像素用一条硬阈值在 shader 里 clip() 掉——常被建议换成"不透明排序 + alpha blend",或用 SV_Coverage 之类在像素后端提前裁决,而不是在 shader 里 discard:
- discard in shader:SIMT 照跑、early-Z 失效 → 双重浪费。
- 提前 coverage 裁决:在进入像素着色前的覆盖阶段就决定哪些采样点不参加 → early-Z 保住,被挡像素不进入着色器。
而"把半透明像素裁掉"这件事,历史上确实被人做成过硬件——S3 ViRGE(1995)是最早把"裁剪/alpha 相关片段处理"带进家用显卡的芯片之一。它那块负责把像素按条件裁掉、再决定实际写不写的逻辑,正是"在像素后端提前裁决、而不是依赖 shader 里 discard 照跑"这个理念在真实硅片上的早期实验。
出处:William Donnelly《To early-Z, or not to early-Z》(discard 污染 early-Z 的实测)/D3D11.3 功能规范 · 像素着色器调用语义。
好——像素好不容易活着走出了着色器。它现在带着四样东西:颜色、深度 z、模板 stencil、coverage。接下来,它要回到"汇合点"。
五、join:带着颜色与深度回到汇合点(show-flow)
着色器核输出的是一大堆并行的像素结果,它们此刻还散在各类寄存器里。像素后端要做的第一件"汇总"动作,是把这些结果重新组织成"像素 × 采样点"的规则格子,凑齐一整块,再统一写进 render target——这一步就是 join,也是"像素后端"真正名副其实的地方。
一个 fragment 在 join 阶段携带的,正是着色器核"幸存"下来的全部家当:
- 颜色(color):像素着色器输出的 RGBA,可能带浮点精度、可能已被写入
SV_Target。 - 深度(depth):若 shader 写了
SV_Depth,这里是它的输出;否则用光栅化插值出的 z。 - 模板(stencil):8 位标记位(如有)。
- 覆盖(coverage):这个像素在 2×2/多采样下,哪几个采样点属于当前三角形。
它们在汇合点重新遇到的,是那两个大写"先写谁"的顺序问题——而顺序的另一半,就是上一章 ep07 讲透的深度裁决。这里只需要把 ep07 的结论续上:通过 early-Z 的像素,带着颜色进入 final color write;被 late-Z 逼回来的,着色完才算数。 join 阶段就是接住这两类幸存者,把它们朝显存张罗。
一个常被忽略的点:join 不是"每个像素单独走一遍",而是以 tile/block 为单位批量回写。因为每次向显存写数据都有固定开销(地址、命令、行缓冲刷新),攒够一整块再写,比一个像素一次要划算得多。这也是为什么像素后端和显存控制器贴得那么近。
上面的框图是逻辑结构,落到实物上,join 汇合之后的最终去处就是一块块这样的显卡板卡——颜色与深度 buffer 就躺在它表面的显存颗粒里,像素后端(图中遮蔽散热器下方的芯片)负责把汇合后的像素逐 block 写进去。
出处:Fabian Giesen · Part 9(join 阶段:ROP 与压缩)/NVIDIA · Life of a Triangle。
现在像素已经"汇合"到 pixel backend 门口,真正决定"怎么写"的那个单元——ROP/OM——终于登场。
六、ROP / Output Merger:像素落进缓冲前要过的六道关(explain-structure)
ROP 是护栏里最后一道闸。它把像素正式"写"进显存,同时夹带一整套可选裁决。我们用"六件事"来给它画像:
- 深度测试与写入(depth test / write):与 ep07 的裁决一致,决定新 z 是否比旧 z 近、是否回写深度缓冲。
- 模板测试与更新(stencil test / update):读 8 位模板标记、按 ref 比较、按 fail/ZFAIL/ZPASS 分三档更新——和 ep07 讲的一致。
- 颜色混合(color blend):新像素颜色与 render target 里已有颜色按混合方程合成——半透明、加法、混合发光都靠它。
- 格式转换(format conversion):把 shader 输出的内部精度(可能是高精度浮点)转成 render target 的存储格式——8bit/16bit/浮点等。
- 采样点解析(sample resolve):多采样 render target 在需要时把 N 个采样点合成一个输出(§九 详谈)。
- 写平面调度与压缩写回(write-out):把最终颜色按 block 编码(DCC/深度压缩)后写进显存对应地址。
落到一块真实显卡上,"六件事"都发生在最接近显存的那一圈芯片里。下面的 NVIDIA GRID K1 板卡正面,中央方块是 GPU,四周一圈就是它要写的显存颗粒——ROP 正是 GPU 与这些颗粒之间的最后一层写调度。
其中混合(blending)最容易被低估:它意味着一次"读-算-写"三连——要合成新颜色,先得把 buffer 里对应位置的旧颜色读出来。所以被多个三角形覆盖的像素(overdraw),每次覆盖都可能触发一次颜色读 + 一次深度读 + 一次最终的写。下一章的带宽账,就是从这里开始累计的。
一个值得说清的视角:规范语义 vs 硬件实现的分离。API 规范把 OM 定义成"像素着色之后、一步步做测试跟混合"的逻辑步骤,像个串行流水线。硬件 ROP 则把它当"能并行的批量写单元"来优化——格式转换、压缩、写合并统统发生在这里,才能把带宽压下去。规范告诉你"应该得到什么",硬件告诉你"怎么花钱地得到"。
诚实标注:以下 ROP 内部的具体分块/压缩/调度细节,厂商并未完全公开,ep07 与本文遵循的 ROP 结构描述主要来自 fgiesen 与 RTR 的二手推断,正文已如实标注"厂商未公开、二手推断";规范层的 OM 语义则可逐条回溯到 Vulkan/D3D 文档。
出处:Vulkan 规范 · Fragment Operations(blend/depth/stencil 全流程)/Microsoft Learn · Output Merger/Turing Whitepaper。
好,六道关过了,像素正式写进显存。但"这么写"是要钱的,而且钱花在哪儿,谁都没明说——这就是下一章那笔分配不避讳的账。
七、那笔带宽账(IMR):overdraw 就是反复进出主存(prove-claim)
还记得 ep07 算过的那个数字:1080p 的深度缓冲 D24S8 = 约 8.3 MB / 帧。立刻补第二笔:1080p 的 RGBA8 颜色缓冲 = 约 8.3 MB / 帧。 两块加起来 16.6 MB / 帧,这是"什么都不画也要付出的最小缓冲占用"。
真正的大头是 overdraw(过绘制)——同一个像素位置上,不止一个三角形想写。每次过绘制都意味着:
- 一次深度读(比一比新旧 z);
- 若不透明:写颜色(读旧色以混合时不透明场景通常直接覆盖,但深度仍要写);
- 若透明或写深度:一次深度写。
一个简单的量化模型。设 1080p、RGBA8 颜色 + D24S8 深度,且全屏平均 O 次 overdraw(每个像素平均被 O 个三角形覆盖)。那么:
- 深度读写流量 ≈ O × 8.3 MB(每次覆盖都要读一次深度、大多要写一次深度);
- 颜色写流量 ≈ O × 8.3 MB(每次覆盖都写一次颜色)——若开启混合,还要加 O × 8.3 MB 的颜色读。
拿全屏 2× overdraw(O=2)来算:深度 16.6 + 颜色 16.6,光"写"就 33.2 MB/帧;开混合再翻一道读,颜色一侧就奔着 49.8 MB 去了。overdraw 把带宽账按乘法放大,而 rop 一点也没少做。
这正是 IMR(immediate-mode renderer,桌面即时模式)的痛处:谁先谁后来,都挡不住同一个像素被反复读写。 每个三角形按提交顺序独立走完整条管线,visibility 靠深度测试在"写入前"临时裁决——可前一个三角形已经付掉的那次颜色写回,不会因为你后来不可能再看见它而退还。
这笔"反复进出主存"的带宽账,记账的地方就是图形与内存控制器——下面的 Intel 82GL40 正是把图形单元(内嵌 GPU)与内存控制器焊在同一颗芯片上的"融合"件,像素后端读/写颜色深度时,走的正是这根到系统主存的带宽通道。
量化锚点:1080p RGBA8 = 1920×1080×4B ≈ 8.29 MB;加 D24S8 ≈ 8.3 MB,合计 ≈ 16.6 MB。O 次 overdraw 使"颜色+深度"写流量 ≈ O×(8.3+8.3) MB/帧,混合场景再叠加颜色读。
出处:Microsoft Learn · 深度与模板缓冲/Fabian Giesen · Part 9(overdraw 与 DRAM page 成本)。
这笔账看着吓人,可 CPU 敢这么跑,正是因为下面这张"压缩"的底牌——让"没变"的地方在带宽上免费。
八、颜色与深度压缩:让"没变"的地方免费(explain-structure)
带宽贵的本质是"每个像素都要进出显存"。压缩的思路很朴素却极其有效:如果一整块区域的值都相同或近似,那就不用给每个像素单独传原始值,传"块"的描述就行。 其中最有名的是颜色侧的 DCC(Delta Color Compression) 与深度侧的深度压缩策略。
DCC 的机制:Render target 被切成固定大小的 block,每个 block 内只存一个"参考值 + 每个像素相对参考值的 delta"。回想 GPU 渲染的典型画面——大片的天空、纯色背景、渐变色块——block 内颜色往往高度连贯,DCC 就能用"参考值 + 极小的 delta"复刻整块,带宽立刻降下来,甚至多块相同直接共享。
- 什么时候最有效:大片平坦/渐变区(天空、地面、纯色界面)。
- 什么时候失效:高频噪声纹理、逐像素剧烈变化的不规则数据——参考值+delta 包不住,只能退回全精度原始传输;涉及 atomic/随机写的场景也会抑制。
深度侧的压缩策略分档,按"能否用简单模型描述整块"逐级退让:常数模式(整块同一 z,如 clear 后的背景)→ 线性/斜率模式(z 在块内随屏幕坐标线性变化,一个 base + 两个 slope 就能描述整块)→ 复杂模式(块内值不规则,只能全精度存储)。
这里和 ep07 的深度压缩结论直接接上:大片恒定/线性深度区域近乎免费,复杂几何时压缩比断崖下跌。 一个被大片三角形铺满、z 变化平缓的场景,深度缓冲的带宽几乎可以不花;一旦是植被、粒子、层级交错的复杂几何,深度压缩就顶不住,带宽打回原形。
上面压缩这个直觉——"把构造相近的块用一份描述打包、别一格一格硬塞原始值"——在图形里是很普遍的花钱哲学。下面这张纹理图集(texture atlas,把许多张小贴图拼进一张大图)就是同一打包思路的视觉样板:一次采样一块、共享同一次纹理带宽,而不是为每张小图分别开一次显存读取。
厂商标注:DCC 为 AMD GPUOpen 公开描述的实现;NVIDIA 有等价的 Turing Memory Compression。各厂商压缩策略细节不同,本站只讲"参考值+delta / 常数·线性·复杂"的共同机理,不逐厂对号入座。压缩的失效边界随驱动、格式、写模式而变,无法给一个通用的"多少几何就失效"的数。
出处:AMD·DCC Overview/AMD RDNA/NVIDIA Turing Whitepaper(Memory Compression)。
带宽已经压下去了,可世界上还有另一种更贵的用法——MSAA。它是给"每个像素存 N 份采样点"咬牙加上去的。
九、MSAA 与 resolve:多了一份 coverage 要多付一次账(show-flow)
MSAA(多重采样抗锯齿)为了锯齿,让每个像素不再是 1 个采样点,而是 N 个(2×/4×/8×)。但像素着色器只跑一次,把算出的颜色同时写到这个像素的 N 个采样点上;采样的差异体现在每个采样点各自的覆盖与深度上。coverage 由三角形在每个采样点上的覆盖决定,深度也逐采样点独立裁决——于是同一个像素内部,N 个采样点可能"有的被三角形盖住、有的没盖住",边界自然平滑。
下面这个对比能直接看出"更高采样 = 更平滑的边界尖角"——同一段折线边缘,采样点越多,锯齿就越细、越接近斜向的连续形状,代价是缓冲里每个像素的样本数(带宽)成倍上涨。
这条"着色一次、覆盖/深度分 N 份"的设计,是 MSAA 省 shader 却贵带宽的原因。写到显存的 render target 是多采样格式(每个像素 N 个样本),深度缓冲也对应放大 N 倍:
- 颜色缓冲:8.3 MB × N(RGBA8×N 采样);
- 深度缓冲:8.3 MB × N(D24S8×N)。
4× MSAA 光这两块就是 16.6 × 4 ≈ 66.4 MB/帧,还不算 overdraw。
真正的二次账单在 resolve(解析):把多采样 render target 降回单采样输出给显示器/后处理时,每个像素要读满 N 个采样点,合成 1 个输出值。4× MSAA 的 resolve = 读 4 份、写 1 份,读带宽是单采样的 4 倍。
避开多余 resolve是移动/低带宽设备极其看重的一条优化:尽量不建 MSAA render target,或让管线在 tile 内就完成 resolve(TBR 的片上 MSAA 衰减广泛,见 §十),避免把 N 份读回主存再合成。
出处:Wikipedia · Multisample anti-aliasing/D3D11.3 功能规范(MSAA 语义)/Fabian Giesen · Part 9。
同一个"像素落进显存"的问题,移动端换了一套完全不同的答案。这就是最后一块拼图:TBDR。
十、TBDR 侧:HSR 如何把账换一种算法(compare-options)
桌面 IMR 的账是"每个三角形独立走管线、overdraw 靠深度测试在写入前临时拦"。移动端主流 GPU 用 TBR/TBDR(分块即时渲染 / 分块延迟渲染) 把问题整个翻了个面:先把这帧的三角形按屏幕分块归好类(binning),再逐块在片上渲染,chunk 内的颜色/深度直接留在高速片上内存里,不急于写回显存。
这套"分块 + 隐藏面消除"的思路,早在桌面时代就从 PowerVR 的量产芯片开始验证了——下面的 ST Kyro 正是 PowerVR Series 3 的代表作,也是当年极少数把 TBDR 真正做上桌面显卡的芯片。
关键差异在可见性裁决的时机与粒度:
- IMR + early-Z:逐像素、逐三角形地"写到最后一刻才裁决",被挡像素的着色能省(early-Z),但颜色/深度的反复写读省不掉,全靠 §八 的压缩兜底。
- TBDR + HSR(Hidden Surface Removal):光栅化器在着色之前先把这块 tile 内三角形的可见性排好——只对最终可见的像素执行一次像素着色器(once-per-pixel)。被后绘制的三角形盖住的前景,其着色器调用被整段省去,不仅省着色,还顺带省掉大量的颜色/深度写读(排完可见性的最终像素,只需要最后写一次)。
移动端还有最后一层带宽杀手锏:Transaction Elimination / 帧缓冲压缩(如 Arm AFBC)——tile 的内容与上一帧相同,就整块不写回主存,直接复用。配合 DCC 这类"块级参考值+delta",TBDR 在不该重写的帧上,能省下压倒性的主存往返。
但 TBDR 的代价要诚实:binning 阶段要额外读取/暂存三角形属性,省的是写回、换来的是"先分块归类"的预处理延迟与复杂度。以及——一旦 render pass 的 tile 溢出(三角形极多、深度深度顺序差),片上内存装不下,就得把中间结果冲刷回主存,那一瞬间的带宽比分分钟打回 IMR 原形,甚至更糟。
引用《移动端图形与功耗》结论:TBR/TBDR 的取舍、片上内存、带宽与功耗账已在该系列 ep02/ep03 写透,本篇只在这里复用结论做 IMR↔TBDR 的横向对比。
出处:Imagination PowerVR · Graphics Architecture(HSR)/Arm · Mali GPU(AFBC)/《移动端图形与功耗》ep02/ep03。
两块方案都讲完了。但读者大概率会冒出一个反问——你说"像素越少工作越少",可为什么我让一个物体只露出一两个像素,它反而不省? 这个反直觉,是给本章收尾的那一刀。
十一、反直觉点汇总:什么时候"像素少"反而更贵(prove-claim)
像素后端有个反直觉的核心:着色的成本并不严格正比于"被画出来的像素数",而是正比于"被派发并执行 shader 的 quad 数"。 这两个数在很多场景下脱节。
1. 小三角形:quad 漏斗效应。 一个只盖 1 个像素的小三角形,仍然要派发 1 个完整 quad、4 个 lane 锁步跑完。三角形平均被几个像素覆盖越小,每个像素摊到的 quad 浪费就越高。海量微小三角形的粒子系统、远处抖动的锯齿边缘,像素后端就是这么被"无效执行"填满的。
2. 不透明排序与 overdraw。 不透明物体可以不透明排序,但即便排得再好,只要深度测试还没把后面的像素剔掉,前面的三角形仍会先写颜色——排序优化的是"最终覆盖顺序"的稳定性,省不掉基本 overdraw 的写/读(除了后续被遮盖的深度/颜色写,到底能不能省,取决于厂商是否开启 early-Z 的闪电剔,ep07 已述)。
3. deferred 的 G-buffer 写放大。 延迟着色(deferred)把光照推迟到第二 pass,代价就是第一 pass 要写 3~4 张 G-buffer(法线、颜色、深度、材质 ID)。正常着色的"写一次颜色"变成"写多层、还要为每个像素写一份"——像素后端的写流量被放大了数倍,个别层还有压缩失效风险。这是"提高别处效率、压榨像素后端带宽"的典型。
4. "像素少时着色器不省"。 回到 §三:只要 quad 被派发,SIMT 就为主它摊满 4 lane;被 early-Z 节省的是"完全没进 shader 的像素",而只要进了 shader,就按 quad 交费。所以"屏幕上就一个小点"的拖影/反射/粒子,往往比大片连续表面更让像素后端吃力——连续表面能借 quad 的满负荷分摊,碎片化的孤点全是浪费。
这些反直觉并最终都指向同一句话:像素后端不是按"结果的像素数"计量,而是按"派发的 quad 数 × 每次落地的带宽"计量。 而这所有的工作,都发生在那片真实、可触摸的硬件上——像素最终写进的那片硅。
出处:Fabian Giesen · Part 8/9/Wikipedia · Deferred shading/NVIDIA ROP(Turing Whitepaper)。
好,该算总账了。
十二、fork/join 一笔账(收尾)(summarize)
我们把整条像素后端线的账归拢成一张表,它也是本篇所有数字的汇总:
| 环节 | 省的是什么 / 付的代价 | 关键数字 |
|---|---|---|
| 2×2 quad + helper lane | 为 ddx/ddy 的导数提供邻域 | 一次派发 4 lane;小三角形时 overshading |
| discard vs early-Z | discard 保 SIMT、败 early-Z | 被挡像素被迫着色完再测 |
| overdraw(IMR) | 颜色/深度反复进出主存 | 1080p ≈ 8.3MB(色) + 8.3MB(深);O×两者 |
| DCC / 深度压缩 | 让"没变"的块免费 | block 参考值+delta;平坦区近乎零带宽 |
| MSAA + resolve | 采样点覆盖/深度独立 | 缓冲 ×N;resolve 读 N 写 1 |
| TBDR + HSR | 每像素只着色一次、片内写回 | binning 延迟;tile 溢出打回原形 |
这张图表背后那台真实桌面 GPU——比如下面这块 GeForce 1060——就是前面所有章节的收编现场:光栅厂在它里面把三角形倒成像素流,着色器核并行着色,ROP 把它归拢写进那块显存,overdraw、压缩、MSAA 的账都是在它身上实时结算的。一块板卡,就是像素后端整条流程的物理落脚点。
记得三个数字:1080p 颜色 ≈ 8.3 MB、深度 ≈ 8.3 MB(合 16.6 MB),O× overdraw 把写流量按 O 放大,以及 4× MSAA 的缓冲与 resolve 读带宽 ×4。TBDR 用 HSR 把"每像素一次"换掉 IMR 的反复写读——但换来 binning 延迟与 tile 溢出的风险。
下篇预告:《GPU 架构剖析》系列进度:已完成【一】命令前端、【二】显存与地址空间、【三】着色器核、【四】纹理采样器、【五】图元装配、【六】光栅化、【七】Z 缓冲、【八】像素后端与 ROP,接下来是【九】几何着色器、流输出与细分。像素这条线在像素后端画上了句号——但上一条分岔,那群还没走进像素流的几何信息,在等待它们的下一站:几何着色器与细分。像素的裁决落下,几何的下游即将登场。
像素后端的规范依据见 Vulkan 规范 · Fragment Operations 与 D3D12 渲染管线/OM。
With Best Wishes. 本文由@溯光 出品,转载请注明出处。我们在硅片到像素之间,继续挖下去。
参考资料与可回溯出处
本篇每一位点与数字均可回溯到规范、厂商文档或经典文献:
- Fabian Giesen《A trip through the Graphics Pipeline 2011》· Part 8(fork 阶段)(CC0)
- Fabian Giesen · Part 9(join 阶段:ROP、压缩、overdraw、DRAM page)(CC0)
- Fabian Giesen · 索引页(CC0 声明)
- Vulkan 规范 · Fragment Operations
- Vulkan 规范 · 光栅化(primitive→coverage)
- D3D11.3 功能规范(shader 调用与 discard)
- Microsoft Learn · Output Merger 阶段
- Microsoft Learn · Windows 渲染管线
- Microsoft Learn · 深度与模板缓冲
- NVIDIA Turing In-Depth
- NVIDIA Turing Whitepaper
- NVIDIA · Life of a Triangle
- NVIDIA GPU Gems 2 · 像素四元组(pixel quad)
- William Donnelly《To early-Z, or not to early-Z》
- AMD · DCC Overview
- AMD · RDNA
- AMD · RGP Manual(DCC/FMask Decompress)
- Imagination PowerVR · Graphics Architecture(HSR)
- Arm · Mali GPU(帧缓冲压缩 AFBC)
- Stanford CS248 · 移动 GPU 硬件(quad 执行)
- RTR 索引(实时渲染综合)
- Modern Rendering Introduction · Rasterization
- Wikipedia · Z-buffering
- Wikipedia · Stencil buffer
- Wikipedia · Multisample anti-aliasing
- Wikipedia · Deferred shading
- Wikimedia Commons · GPU die covered in liquid metal(Der8auer, CC BY-SA 4.0)
- Ep07《Z 缓冲与隐藏面消除》· early-Z 与深度压缩全链条
(本篇完)