“这份讲义用一句话给出了全套技术的坐标:延迟着色算出与向前渲染大致相同的结果,它做的是把渲染的几个关键循环重新排序。于是三个变量决定成败:数据的占用、带宽特性、执行相干性。这一篇沿讲义的主线走一遍:G-buffer 的体积账、O(F×L) 的三类发散、逐灯多趟与 tile 化两套解法、173 MB 的抗锯齿账,以及 PowerVR 硬件里那一版。”
- 先立坐标:延迟着色把着色推迟到所有几何处理与遮挡计算完成之后,等价于一次完美的早期遮挡剔除,且三角形顺序无关、着色量可预测。
- 算清体积:G-buffer 是「胖像素」,四个 RGBA 目标加深度 = 20 字节/像素(96–160 bits/像素是游戏里的常见区间);压缩布局则用材质 ID 加查表把它压窄。
- 看懂失效点:朴素多光源前向把谓词逐「片段 × 灯」求值(O(F×L)),带来三类发散、冗余判定与全部灯光资源绑定,三者同源。
- 两套解法:逐灯多趟 + 光体积裁剪(把谓词交给栅格化器),以及 tile 化裁灯(16×16、片上列表、G-buffer 只读一次),后者的代价是轮廓处的假阳性。
- 把账结掉并划边界:超采样 G-buffer 在 1900×1200×4spp 下要 173 MB(实为 MiB);另有六条边界写在最后一章,含 MSAA 不兼容与不支持透明。
01开场:一场关于「把循环换个顺序」的讲座
先把这份材料的身份说清。它是卡内基梅隆大学课程 15-869《Visual Computing Systems》2014 年秋季的第 9 讲,标题只有两个词:Deferred Shading。全 50 页,讲义里没有编号图表,每页就是一个论述单元,从动机一路讲到硬件实现与取舍判据。课程本身的坐标写在第二页:前半学期已经讲完实时图形管线的设计与实现、图形抽象、着色语言(用应用定义的着色函数扩展管线)、通用 SPMD 编程(compute mode 抽象)、以及 GPU 处理器核如何承载这些抽象;这一讲要讲的是一套替代的管线结构,也是 compute mode 的一种用法。讲义还预告了后续要讲的另外几种替代渲染算法:光线追踪与基于图像的渲染。
这份讲义最值得抄下来的一句话,出现在总结页:延迟着色「算出(或多或少)与向前渲染相同的结果,它做的是把渲染的几个关键循环重新排序,从而改变计算的调度」。这句话之所以重要,是因为它把整套技术从「一种特效」还原成「一次循环重排」,而重排的代价与收益全在三个变量上:数据的驻留位置(footprint)、带宽特性、以及执行的相干性(execution coherence)。整篇讲义其实就是围绕这三个变量在论证。
讲义列出的三个关键循环很短:对所有的灯、对所有的采样、对所有的绘制图元。前向渲染把「图元」放在最外层,延迟着色把「采样」提到最外层,tile-based 延迟着色再插进一层「屏幕分块」。后面十四章会看到,每一次循环次序的改变,都会换出一组新的footprint、带宽与相干性组合,而没有任何一种次序在所有场景里都更好。
还有一处体例要交代,它决定了这一篇怎么引图。讲义全 50 页没有编号 Figure,也没有图表目录;每一页的核心信息以要点行、代码或示意图的形式直接给出,配图自带来源标注(例如某页引用 W. Engel 的 SIGGRAPH 2009 讲稿、某页引用 A. Lauritzen 的可视化)。这种体例在论文里见不到,但它是课程讲义的标准做法:要点行本身就是图注。因此本篇的做法是对技术核心页做整页渲染,把该页的要点与配图一起作为证据件引用,并在图注里注明源 slide 号,让每一条论断都能回查。这个口径与分区里既有的讲座类篇目(09–11 与 10 号 MegaLights)一致。
还有一个背景值得先记住:这一讲不是纯粹的算法课,它同时交代了硬件与 API 两侧的现状。讲义在讲延迟着色的来历时点了一句关键事实:它并不是新想法,早期图形系统里实现过,但多数高端 GPU 并不直接支持;而现代图形管线提供了让应用自己高效实现的机制。同时它明确指出,这套做法被 PowerVR 移动 GPU 原生实现;第 14 章会回到这一点,看硬件版的延迟着色与前文那篇 PowerVR 架构讲义的衔接。这也解释了为什么这一讲要把「循环重排」讲得这么细:理解了循环,才能理解应用侧与硬件侧各自能做什么。
02延迟着色的想法:把着色推迟到遮挡判完之后
核心思路一句话就能说完,讲义也确实只用一句话:重构渲染管线,让着色发生在所有遮挡关系被解决之后。它给这套做法的定位是「现代游戏里流行的渲染算法」,同时提醒这不是新东西:早期图形系统里实现过,但当时多数高端 GPU 并不直接支持,直到现代图形管线提供了让应用自己高效实现的机制。讲义还列了两个经典硬件实现作为出处:[Deering et al. 88] 与 UNC 的 PixelFlow [Molnar et al. 92]。下面这张页面对应的是它的定位与谱系。
两趟结构是这套思路的直接产物。第一趟是几何趟:用传统管线渲染场景,但片段着色器输出的不是最终颜色,而是表面属性(也就是着色器的输入),例如位置、法线、材质的漫反射颜色与高光颜色。这一趟的渲染结果是一张屏幕尺寸的二维缓冲,里面记录的是每个像素上可见表面的几何信息,讲义给它起名叫 G-buffer(geometry buffer)。第二趟是着色趟:几何全部渲染完之后,对 G-buffer 里的每个采样执行一次着色,读出该采样的几何信息、累加所有光源的贡献、输出最终的表面颜色。下面这张页面对应的是这两趟结构与 G-buffer 的四个通道。
把两趟写成伪码,这套结构的边界条件就很清楚:
趟 1 几何趟(geometry pass) 1 用传统管线渲染场景几何 2 片段着色器输出表面属性(位置 / 法线 / 漫反射色 / 高光色) 3 结果写入屏幕尺寸的 G-buffer(而非最终颜色) 趟 2 着色趟(shading pass) 4 对 G-buffer 的每个采样: 5 读出该采样的几何信息 6 累加所有光源对该表面的贡献 7 输出该采样的最终表面颜色
这里有一句限定值得单独标出来,因为它划定了「延迟」到底延到什么时候。讲义的原话是:着色被推迟到所有几何处理,包括所有遮挡计算,都完成之后。这句话是这套方法全部收益的来源,也是它全部代价的来源:收益是「只为可见的片段着色」,代价则是几何信息必须先在某个地方存下来,等着被消费。第 3 章要算的就是「存下来」这件事的账。
03G-buffer 是什么:一组「胖像素」的布局账
讲义用三页把 G-buffer 讲成了一笔具体的存储账,而它的起点是一个很直观的说法:把 G-buffer 理解成一张由「胖像素」组成的大缓冲。前向渲染里每个像素最终存一个颜色,而这里每个像素要存一整套表面信息,所以像素本身变胖了。给 G-buffer 做图示的那一页还标了图片来源(J. Klint 的 Leadwerks 引擎延迟渲染实现),画的是同一场景的 albedo、depth、specular、normal 四张分量图。
变胖到底胖到什么程度,讲义给了一个可复算的例子:现代 GPU 上把管线配置成渲染到四个 RGBA 输出缓冲(每像素每缓冲 32 位),应用把「多渲染目标」(multiple render targets)绑给管线,几何渲染同时输出到深度缓冲与多个颜色缓冲。按这个配置算:
下面这一页是示例布局的原始出处,讲义还标注了数据来源:W. Engel 的《Light-Prepass Renderer Mark III》(SIGGRAPH 2009 Talks)。
20 字节/像素这个数字之所以要紧,是因为它随分辨率与采样数一起放大。后面第 15 章会算一笔超采样 G-buffer 的账,用的就是这个系数。讲义在这里先埋了伏笔,也埋了第一处取舍:G-buffer 的通道越多,着色趟能拿到的表面信息越完整,但几何趟要写出去、着色趟要读回来的数据就越多。
压缩布局正是针对这笔账的一条对策,讲义用一页讲它:把材质信息用「间接」的方式压下来:G-buffer 里只存材质 ID,而高光形状、粗糙度、颜色这些除反照率以外的参数存进一张按材质 ID 索引的表。这一页的图示来自 Bungie 的《Destiny》(2014),来源标注为 N. Tatarchuk 的 SIGGRAPH 2014 Courses。
把这三页连起来,G-buffer 的性格就很清楚了:它是一份把几何信息在屏幕空间里「定型」的中间结果。定型带来两个后果,一个好处一个坏处。好处是下游的着色趟不必再关心几何,只需按采样逐条处理,于是遮挡关系天然被尊重;坏处是这份中间结果必须完整写下再读回,而且一经写下,能表达的材质种类就被那几个通道(或那张表)限定了。第 9 章与第 15 章会分别从带宽和抗锯齿两侧回到这个限定上。
04为什么值得:完美的早期遮挡剔除与可预测的着色量
讲义把延迟着色的动机总结得很干脆:着色很贵,所以只想给可见片段着色;而延迟着色等效于一次完美的早期遮挡剔除。这句话的分量在后面的三个从句里,讲义逐条给了限定条件。第一,它是三角形顺序无关的:不管应用按什么顺序提交三角形,它都只为可见片段着色。第二,它带来一个好性质:被着色的片段数与场景复杂度无关,因此着色性能是可预测的。下面这一页是这段动机被压成的五行要点。
「沿三角形边缘的像素会生成多个片段」这句话需要一个机制解释,讲义用连续三页讲它。它的说法是:着色的粒度是 quad fragment,也就是硬件以 2×2 的像素小块为最小着色单位;当一个像素落在三角形边界上时,这个小块里会包含来自不同三角形的片段,于是同一个像素位置上会被着色多次。讲义配了一张「每像素着色计算次数」的示意,把这种重复标成 8 次、7 次……递减的计数。前向渲染里这被算作必要开销(因为边界本来就要算清楚),但它确实是一笔与「小三角形数量」成正比的额外成本。
第二条动机是光照本身在变复杂。讲义在同一页给出:光照计算的复杂度正在上升,而且把场景扩展到大量光源的兴趣在增长。这条和第 11 页那条「小三角形」的批评合起来,正好解释了为什么这套方法会在那个时间点变得流行:几何端在往「更多更小的三角形」走(细分),光照端在往「更多种类的灯」走,而前向渲染对这两件事都敏感:几何端因为 quad fragment 粒度,光照端因为逐片段遍历全部灯。延迟着色把几何端的重复着色收掉了,也给光照端的裁剪提供了统一的作用面。
不过这一章只讲了收益,代价还没上桌。讲义直到第 9 章(基础多光源延迟着色)与第 15 章(抗锯齿)才把账翻到反面:G-buffer 的驻留体积、几何趟与着色趟之间的往返带宽、以及「每像素只能有一次着色」这条限制对透明与抗锯齿的影响。先把收益记住,读后面几章时才有对照物。
05光源那一侧:一千盏灯,与有限的影响体积
讲义在第 15 页放了一张只有 105 个字符的页面,标题是「1000 lights」,配图来自 J. Andersson 在 SIGGRAPH 2009《Beyond Programmable Shading》课程上的报告。这一页是整讲的转折点:它把问题从「几何怎么着色」换成了「这么多灯怎么办」。下面就是这一页。
紧接着的一页交代了「灯」在工程上是什么形状,这决定了后面能怎么裁。讲义列了四类:带距离截断的全向点光源(图中标出 Dcutoff)、方向性聚光灯、环境光、以及带阴影的光源。它还点明了一条对裁剪至关重要的事实:为了效率,光源通常会指定一个有限的影响体积(finite volume of influence)。
这条事实是后面所有裁剪技巧的物理前提。既然每盏灯的影响范围有限,那么「某个采样会不会被某盏灯影响」就是一个几何判交问题,而不需要真的把光照算一遍再看结果;也正因为它是几何判交问题,才能从「逐片段判」上移到「逐物体判」「逐屏幕分块判」或「用光体积的几何去光栅化」。第 8 到第 12 章会依次看到这几种上移方式,每一次上移都换来一次复杂度的下降,代价则是判定变得更保守、出现假阳性。
还有一处细节要留到后面用:那盏全向点光源标的 Dcutoff。距离截断意味着光源的影响力有一个明确的半径,于是「灯的体积」可以是一个球,而球与 tile 视锥的判交在 GPU 上很便宜。第 12 章的 tile-based 算法里,第一步就是为每个 tile 算出 Z 的最小值与最大值,好让这个判交有深度维度的信息可用;第 13 章讲的那条「轮廓处深度界不紧」的限制,也正来自这里。
06三层循环:三角形、采样、光源
讲义用两页把「渲染」写成了三个嵌套的 for 循环,这是整讲最值得记住的一段伪码。循环从外到内是:三角形、帧缓冲里的可见性采样、光源。最外层遍历每个三角形,把它投影到屏幕(t_proj = project_triangle(t));中间层遍历每个采样,判断这个投影后的三角形是否覆盖它;最内层遍历场景里的每一盏灯,累加该灯对这个表面外观的贡献;然后做一次深度比较,如果三角形在该采样上更近,就更新最近的深度值与颜色。
讲义对「为什么三角形要放在最外层」给了两条理由,都是工程性的。第一条是摊薄:三角形在最外层意味着三角形的建立(setup)只做一次,可以被它覆盖的许多采样摊薄。第二条是相干性:同一批片段来自同一个三角形,跑的是同一个着色程序、访问的是相近的数据,因此着色计算的相干性很高。它还补了一句:成熟的栅格化技术(分块、层次化、包围盒)正是为了降低「三角形 × 采样」这个二维搜索的复杂度而存在的。
下面这一页是这段三层循环的完整伪码与两条注解,值得对着读一遍。
z_closest[] 初始化为无穷、初始化 color[]、绑定相关阴影贴图;然后三层嵌套:loop 1 三角形(先投影)、loop 2 可见性采样(判断是否被三角形覆盖)、loop 3 光源(累加该灯贡献);最内层再做深度比较,更近则更新 z_closest[s] 与 color[s]。右侧注解交代了两条设计理由:成熟的栅格化技术(分块、层次化、包围盒)用来降低「三角形 × 采样」这层搜索的复杂度;三角形放在最外层则让三角形建立只做一次、被许多采样摊薄,并带来很高的着色相干性(片段来自同一三角形,着色程序相同、数据访问相近)。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 18 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改紧接着的一页把最内层那个「光源循环」的代价点了出来。讲义的说法是:这是一个 F × L 的循环,F 是片段数、L 是光源数;而现实中并非所有灯都照亮所有表面。它还给出了想达到的目标:既然「三角形与采样的交互」已经被栅格化技术做得很有效率,那么「光与采样的交互」也应该有同样效率的算法。把两层复杂度写在一起,这一段的账就很清楚:
这一章还留了一个伏笔,是「空间相干性」四个字。讲义在讲 F × L 判定时说,这个谓词在空间上本应存在相干性,相邻的片段大概率被同一批灯照亮。这句话是整个后半讲的钥匙:既然相干性存在,那就应该像栅格化利用三角形相干性那样去利用它,而利用的方式就是「先按空间区域把灯找出来,再让区域内的所有采样共用这份列表」。第 11 章的 tile-based 方法,本质就是把这句话做实。
07朴素多光源前向着色:三类发散与 O(F×L)
讲义在第 17 页先给出最直白的那版实现:一个着色器里声明一张 LightDefinition lightList[MAX_NUM_LIGHTS]、一个 numLights,以及一组按灯数开的大小固定的资源数组(环境贴图与阴影贴图各 MAX_NUM_LIGHTS 张),然后在片段着色器里写一个从 0 到 numLights 的循环,逐个累加每盏灯的贡献。这就是「朴素的多光源着色器」:结构简单,但它把灯数与资源规模都写死在了编译期。
第 20 页在同一版代码上加了一个「是否被照亮」的谓词与按灯类型分叉的逻辑,然后把它的问题逐条列出来。这一页是整讲对前向渲染最完整的批评,值得逐句读。
if (this fragment is illuminated by current light),里面再按 lightList[i].type 分叉(聚光灯 / 点光源 / 其他)。右侧三块注解就是这一页的价值所在,按顺序读。第一块是执行发散,讲义拆成三类:①「是否被照亮」这个谓词的结果不同;② 执行测试的逻辑不同(取决于灯类型);③ 循环体里的逻辑不同(取决于灯类型、是否带阴影)。第二块是工作低效:谓词要对每个「片段 × 灯」组合求值,即 O(F×L),其中 F 是片段数、L 是灯数,并且补了一句「谓词在空间上本应存在相干性」。第三块是占用footprint大:所有灯的资产(阴影贴图、环境贴图等)都必须分配并绑定到管线。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 20 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改把这三块放在一起看,会发现它们是同一个根因的三个侧面。根因是「逐片段、逐灯地判」:因为判定发生在最细粒度上,所以同一批相邻片段会重复做同样的判断(O(F×L) 的工作量);因为不同片段对同一盏灯可能给出不同结果、不同灯又走不同分支,所以执行会发散;因为所有灯的资源都要在管线里备着,所以绑定与分配的占用很大。也就是说,发散、冗余判定、大占用三者是同一次设计决定的连带后果,想改其中一个通常要动的是判定粒度本身。
这一章还有一句话值得单独记:讲义在注解里说「谓词在空间上本应存在相干性(spatial coherence in predicate should exist)」。这句括号里的话,是它自己对「问题可解」的判断:它承认当前写法在浪费相干性,而不是承认相干性不存在。后面两章分别给出两种利用相干性的方向:按物体利用(光列表),按屏幕区域利用(tile 化)。
08前向侧的两条补救:光列表与多趟渲染
讲义对前向渲染提出了一个明确的目标:避免做 F × L 的「是否被照亮」判定。它的解法是一条:由应用维护每物体光列表:每个物体存下照亮它的那些灯;这张列表由 CPU 每帧计算,方式是把光体积与场景几何求交。讲义特意点明这条做法把交互计算的粒度从「灯与片段」换成了「灯与物体」,这正是它省下工作量的原因。下面这一页是这套列表的具体样子。
拿到逐物体列表之后,前向侧有两条路线把它用出去,讲义分别给了评价。第一条是把场景拆成许多小批次绘制:先为所有灯生成数据结构(例如阴影贴图),再算逐物体列表,绘制每个物体前只绑定相关灯的数据,并且为不同的绑定灯集合预编译专门的着色器变体(4 灯版、8 灯版……)。讲义给的好评是着色阶段的执行发散很低;差评是图形状态切换很多、绘制批次很小,因为一个绘制调用只能覆盖单个物体,或者覆盖具有相同灯数的一组物体。
第二条是多趟渲染:假设深度缓冲已经由深度预通道初始化,然后为每一盏灯开一趟:绑定只针对当前灯类型的单灯着色器、绑定相关阴影贴图、只绘制被这盏灯照亮的三角形(用逐灯物体列表),在采样上累加这盏灯对可见片段的照明,并沿用「深度等于最近深度才更新颜色」的写法。它的外层循环由「三角形/物体」换成了「灯」,讲义把这套重排说得很直白:把循环重新排序,让场景每盏灯画一遍,而且第三层循环被特化成「只有一盏灯」。好评是灯光数据的占用最小、执行发散也低;差评写在下一页:几何处理被重复、G-buffer 被反复访问、片段着色器里那些公共子表达式的求值被重复执行。
把两条路线摊开,前向侧的两难就清楚了。小批次路线省的是执行发散,付的是状态切换与批次碎片;多趟路线省的是资源占用与发散,付的是几何与公共项被反复算。两者都没有碰到那件更根本的事:无论哪条路线,着色仍然发生在几何被处理的那一刻,也就是说「被遮挡的片段照样会被着色,只是着色后不写出去」。这正是下一章要交给延迟着色的那件事。
09基础多光源延迟着色:几何一趟,光照逐灯多趟
把多趟的思路搬到延迟管线里,就得到讲义称之为「基础多光源延迟着色」的算法。它把之前的循环重排成两个阶段。第一阶段生成 G-buffer:遍历每个三角形,投影、判断覆盖、把几何信息写入,并按深度比较更新 z_closest[s] 与 gbuffer[s]。第二阶段着色 G-buffer:外层循环换成灯,为每盏灯绑定对应的单灯着色器与阴影贴图,然后遍历每个采样,读出 gbuffer[s]、把当前灯的贡献累加进 color[s]。
讲义对这一版给了三条好评与五条差评,全部落在下一页,是本讲最集中的一次账目清点。
gbuffer[s]),下段是逐灯着色 G-buffer(读 gbuffer[s] 并累加)。右侧分成「Good」与「Bad」两块,这就是这一页的正文。Good 三条:场景几何只处理一次(只在第一阶段);第二阶段的外层循环是灯,因此避开了灯光数据的占用问题(以流式方式遍历灯),也继续避开了片段着色器的发散;再加上延迟本身的收益:只为可见采样着色,不多着。Bad 五条:G-buffer 占用大(尤其在被超采样时);带宽开销高(每趟都要读 G-buffer、并输出到帧缓冲);每个帧缓冲采样恰好只有一次着色计算;不支持透明(透明需要每像素多个片段);高效实现 MSAA 很困难(后面会展开)。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 29 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改先说带宽这一条,因为它是五条里最能量化的。讲义的说法是:每趟都要读一遍 G-buffer、并写出帧缓冲。假设灯数为 L、屏幕像素数为 N,每个像素读 G-buffer 需要 20 字节(第 3 章算过的「胖像素」),写帧缓冲按 4 字节计:
再回头看那三条好评,会发现它们其实是同一件事的三层收益。几何只处理一次,省掉的是前向侧多趟路线里那份重复的几何处理;外层循环是灯,省掉的是把全部灯光资源同时绑定的占用;只为可见采样着色,省掉的是前向侧那些「算了但没写出去」的片段。换句话说,延迟着色用一次几何信息的「固化」(G-buffer),把这三笔账一起换了。
而五条差评里,真正构成本讲后半段主线的有两条:G-buffer 的占用与带宽(第 10、11 章解决),以及每采样恰好一次着色这条结构性限制(第 13 章解决抗锯齿,第 14 章解决透明)。第三条「每像素恰好一次着色」在讲义里被标得很重,因为它同时解释了为什么透明难做(需要多个片段)、为什么 MSAA 要实现得聪明(MSAA 的着色次数按覆盖它的三角形数走,而不是每像素一次)。
10给光照趟减负:光体积栅格化与屏幕对齐四边形
第 30 页标题就是「降低延迟着色的带宽开销」,它给了两条对策。第一条比较直接:在相位 2 的每一趟里处理多盏灯,用多盏灯的光照计算去摊薄同一趟里的 G-buffer 读取与帧缓冲写入。第二条是裁剪:只对那些被该灯照亮的 G-buffer 采样做着色计算,并给了两种实现方式。技术 1 是把光体积的几何光栅化,这样只有被覆盖的 G-buffer 采样会生成片段,也就是说「灯与片段的交互」这个谓词由栅格化器来判,而不是写着色器里判。技术 2 是由 CPU 算出该光体积覆盖的屏幕对齐四边形,然后渲染这个四边形。讲义还补了一个几何上的观察:如果光体积是凸的,那么只渲染它的正面三角形,生成出来的片段就正好落在它影响到的那些采样上。
这两条对策之所以重要,是因为它们把第 7 章那三类发散问题里最关键的一类直接绕掉了。「是否被照亮」这个谓词从着色器里挪到了栅格化器里,于是着色器里不再有那个逐片段的判断,发散与 O(F×L) 的冗余判定一起消失。剩下的分叉只出现在「不同灯类型走不同着色器」这一层,而那一层可以用多趟结构把它限制在「一趟之内只有一种灯」。
下面这一页是这套裁剪效果的可视化,它把「每采样被多少盏灯影响」画成了图。
这一页里那个「四边形的深度取光体积最近点」的做法值得单独记,因为它是一个把深度测试用成裁剪工具的典型例子。四边形本身是屏幕空间的二维图元,本身没有真正的深度;把它的深度设成「光体积离相机最近的那个点的深度」,等价于声明「这个四边形的任何片段都在光体积的近端或之后」。于是当场景几何挡在前面时,早深度测试就自然把那些片段丢掉,不需要在着色器里做任何判断,裁剪由固定功能单元完成。这是同一种思路的第二次出现:第 8 章是把判定上移到物体粒度,这里是把判定下放给栅格化与深度测试。
把第 9、10 章合起来看,延迟着色在「一个像素乘以一千盏灯」这个工况下已经被救到可用的程度:几何只处理一次、逐灯只覆盖影响范围、多灯合并摊薄往返。但它还剩一个结构性的低效:每一个采样都要独立地走一遍裁剪与光照,而相邻采样需要的光列表高度相似。第 6 章那句「谓词在空间上本应存在相干性」到这里终于要兑现了:下一章不再逐采样裁灯,而是按屏幕分块一次性裁出列表,让整块共用。
11tile-based 延迟着色:把光源列表放进片上共享内存
第 32 页把 tile-based 延迟着色的核心思路写成了一句话:利用光源与采样交互中的相干性:先算出影响 G-buffer 中一小块(tile)采样的光源集合,再计算这些灯对该 tile 内采样的贡献。讲义点明,这套做法能高效实现,是因为有了计算着色器(compute shader),而它带来的摊薄是三重的:G-buffer 读取与帧缓冲写入被所有灯摊薄;灯光数据的读取被 tile 内的采样摊薄;光源与采样的裁剪被 tile 内的采样摊薄。这一页的来源标注是 [Andersson 09],与第 5 章那张「1000 lights」的页面同源。
紧接着的算法页是整讲最实的一页,值得逐行读:它用 group shared memory 存该 tile 的光源列表,用两道 barrier 把「协作求深度范围」与「并行裁灯」这两步隔开,然后把着色循环限制在已经裁好的列表上。
tileLightList 存在 group shared memory;第一步由全部线程协作求出当前 tile 的 Z-min 与 Z-max;第一道 barrier 之后,并行遍历灯(并行度在灯上),若光体积与 tile 视锥相交就把该灯追加进列表;第二道 barrier 之后,并行遍历采样(并行度在采样上),每个采样读出 G-buffer 数据、遍历 tileLightList 逐灯累加,最后写回帧缓冲。右侧的关键信息是:一个线程组负责 16×16 采样(256 线程);整个流程做到「深度缓冲只读一次、G-buffer 只读一次、帧缓冲只写一次」,并在 tile 粒度上完成裁灯。伪码里那句注释「no divergence across samples」是本页的重点:同一 tile 内所有采样走同一张灯表,因此没有发散。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 33 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改把这段算法按伪码写出来,它的骨架是「两道 barrier 切三段」:
线程组 = 一个 16×16 的采样 tile(256 线程)
tileLightList[MAX_LIGHTS] // 存在 group shared memory
1 全部线程协作求出本 tile 的 Z-min 与 Z-max
2 barrier // 第一道:让深度范围对组内所有线程可见
3 for each light(并行度在灯上):
4 if (光体积 与 tile 视锥 相交) → 把该灯追加进 tileLightList
5 barrier // 第二道:让灯列表对本组所有线程可见
6 for each sample(并行度在采样上):
7 result = float4(0,0,0,0)
8 读出该采样的 G-buffer 数据
9 for each light in tileLightList: // 同一 tile 内所有采样走同一张表 → 无发散
10 result += 该灯对该表面的贡献
11 把 result 写到帧缓冲对应位置
相位 1:G-buffer 生成趟(产出 G-buffer 与 Z-buffer)
相位 2:用计算着色器着色 G-buffer(本段伪码)
→ 深度缓冲读一次、灯在 tile 粒度裁一次、G-buffer 读一次、帧缓冲写一次
把这套算法与第 10 章那套「逐灯一趟、用四边形裁」的做法对比,差别集中在两处。第一处是裁剪的粒度:逐灯方案按灯的屏幕影响范围裁,每个采样各自判定;tile 方案按屏幕分块裁,块内共享一张列表。第二处是数据流:逐灯方案的相位 2 要跑 L 趟,每趟都读一遍 G-buffer;tile 方案只跑一趟,G-buffer 读一次、帧缓冲写一次。tile 的数量可以直接算出来,它决定了并行任务的规模:
讲义在第 34 页给了这套裁剪的图示:黄框是屏幕对齐的光体积包围盒,蓝框是屏幕 tile 的边界,来源标注为 HMREngine 的一篇博客。把两张图叠在一起看就能理解这套算法的几何含义:裁灯不是逐像素做的,而是「黄框与蓝框求交」。这也正是它效率的来源,同时是它下一章那个限制的来源:一旦蓝框(tile 的屏幕范围)与 tile 内几何的深度范围组合成一个视锥,这个视锥在深度方向上的松紧就决定了裁剪的准确性。
12tile-based 的两面:剔除效率,与轮廓处的低效
讲义第 35 页给的是 tile 版的交互计数可视化,与第 10 章那张同源(同一场景、1024 盏点光源),但裁灯改成 16×16 粒度。页面的说明句点出结论:tile 版的裁灯效率很好,而且从图上就能看出 16×16 这个裁剪粒度。把它与逐灯四边形版并排看,就能看到两种裁剪方式的差别:逐灯版每采样单独判,几乎没有假阳性;tile 版共享列表,块内必然有假阳性,但裁剪的代价被摊薄。下面两页分别是 tile 版的结构性限制与性能对比。
这条限制与第 12 章那篇 PowerVR 讲义的讨论几乎一模一样:那里说的是「与物体边缘重叠的 tile 灯数偏多,因为视锥在深度方向被拉长」,这里说的是「含轮廓的 tile 深度界不紧」。两处是同一个几何事实在不同文档里的复述,区别只在处置:PowerVR 那篇讨论了改进方案(空空间检测)并判断不划算,这份讲义则把重点放在性能影响上。
把这一章的两页合起来,tile 化的账就完整了。收益侧是带宽与发散:G-buffer 只读一次、帧缓冲只写一次、同块采样无发散,这是第 10 章那 51 GB 上界被压下来的直接原因。代价侧是假阳性:块内共享列表意味着块里那些「不需要某盏灯」的采样也要为它付出一次求值,而这些假阳性并非均匀分布,它们集中在物体轮廓所在的块上。于是 tile 方案的实际表现取决于场景的轮廓密度:轮廓越多、块内深度差越大,裁灯越松。
13抗锯齿这笔附加账:超采样 G-buffer 还是 MLAA
讲义在第 39 页摆出了一个未解的问题:在延迟渲染器里给几何做抗锯齿。要理解它的难点,得先回到 MSAA 的原理。第 40 页的复习页把 MSAA 的核心思想写得很干净:把着色的采样率与可见性的采样率解耦:深度缓冲按采样存深度,颜色缓冲按采样存颜色,最后把颜色缓冲重采样成每个像素的最终值。关键在「解耦」二字:可见性可以按更高密度采样(因此边缘轮廓能被正确覆盖),而着色可以按更低频率发生。
延迟着色与这套机制有一处天然冲突。讲义在第 41 页点明:延迟着色对每个 G-buffer 采样恰好做一次着色计算;而 MSAA 的着色次数是「按贡献覆盖的三角形数」走的,于是有效着色率是自适应的:落在三角形内部的像素只着色一次,落在边界上的像素会多着色几次。讲义对这件事的评价是双面的:边界多着色是值得的,因为轮廓的抗锯齿本来就需要它;但它也有一个不想要的副作用:当同一表面的两个相邻三角形在边界相遇时,MSAA 也会多着色一次,而这里其实没有锯齿需要处理。讲义在这一页还加了一条脚注,把第 9 章那条「每采样恰好一次着色」与透明问题联系起来。
第 42 页给出两条对策,并当场算了一笔账,这是整讲最可复算的一页。
那个 173 MB 值得复算一次,因为它牵涉一个单位口径问题:
第 15 章之后还有一层:如果应用不用硬件的 MSAA,而是自己实现一套,会遇到什么。讲义在第 45 页给了第三种方案:渲染超采样的 G-buffer,但为每个输出图像采样启动一个着色器实例,而不是为每个 G-buffer 采样;着色器先判断这个像素是否包含边缘(讲义自己在这里提了一个反问:在不拿几何信息的前提下,这件事怎么做?),如果含边,就顺序地把该像素的所有 G-buffer 采样着色一遍,再把结果重采样成单个像素颜色(例如盒式滤波);如果不含边,就只着色一个 G-buffer 采样。它的评价是:G-buffer 占用上升,但着色成本与 MSAA 大致相当;额外的带宽成本用于边缘检测,并且着色器里会有潜在的执行发散。
发散这件事在第 46 页被单独处理,而且讲义自己指出了它的讽刺之处:自适应着色率会加剧着色器执行的发散,而「消除着色发散」本来是延迟着色的动机之一。它给出的解法是数据并行编程里的常规套路,例如多趟:第一趟给像素分类并写模板缓冲;第二趟着色那些只需要一次着色计算的像素;第三趟翻模板值,着色那些需要 N 次着色的像素。讲义把这条总结为一次典型的带宽换执行相干性的权衡,并且特意回指了前面讲过的同类例子:把几何绘制调用按活跃灯数排序,也是同一原理。
14收尾:同一套循环的另一种排法,与硬件里的那一版
讲义在第 38 页做了一次跨技术的对照,主角是 Forward+。它的说法是:tile-based(层次化)裁灯并非延迟着色独有;Forward+ 分三个阶段:阶段 1 渲染 Z-prepass 填充深度缓冲(处理全部几何)、阶段 2 在计算着色器里为所有 tile 求 zmin/zmax 并算出屏幕 tile 的光源列表、阶段 3 启用着色再渲染一遍场景(再处理一次全部几何),片段着色器判断自己落在哪个 tile,用该 tile 预计算好的光源列表计算表面照明。讲义给的结论句很关键:Forward+ 把一个前向渲染器做到了 tile 版延迟的裁灯收益,而做法「只是把循环又换了一种排序」。
两者真正的差别在着色输入从哪来。讲义把它讲成一次明确的交换:Forward+ 用第二遍几何处理把着色输入重新算出来(讲义用了「rematerialization」这个词),但要在阶段 2 与阶段 3 之间把光源列表存进内存;而 tile 版延迟着色把着色输入存进 G-buffer,并且从不把光源列表写进片外内存:列表只在计算着色器的共享内存里存在,因为它建好之后立刻就被消费掉了。这一句对照把两套方案的存储账翻了个面:一边存着色输入(G-buffer),一边存光源列表(如果是 Forward+ 那种跨阶段传递)。
第 47 页回到移动端,把硬件那一侧的答案补上了。
这一页与第 12 章那篇 PowerVR 讲义正好接上。那里讲的 Tiler/Renderer 两段式结构、片上缓冲、ISP 的隐藏面消除,在这一页被换成了从应用视角看的样子:G-buffer 里只留三角形 ID,表面属性按需重算。把这段 bin 处理写成伪码,它与前面 tile 版延迟的骨架对照很直接:
相位 2(分块渲染器的 bin 处理)
for each bin(屏幕分块):
for each triangle in bin 的三角形列表:
栅格化该三角形 → 每个 G-buffer 采样只存「三角形 ID」
确定哪些 quad 片段对该帧缓冲有贡献
for each sample in tile:
按三角形 ID 反推该采样对应的片段
若该片段还不在待着色列表里 → 加入列表(去重)
只着色那些「贡献覆盖」的片段:着色结果累加进帧缓冲
要点:G-buffer 存的是「哪个三角形覆盖了这个采样」,
不是完整的表面属性;属性可按三角形 ID 现算
两处合起来说明同一件事:在移动端,「延迟」不是应用决定要不要开的效果,而是硬件与驱动为了省电而内置的调度方式。讲义在这一页还留了一句很有价值的设计取向说明:G-buffer 存 ID 而不是存属性,等于把「重新计算」的代价换成了「更小的驻留体积」,这是移动端能效优先的一个具体体现。
最后两页是总结与判据。总结页把整讲的骨架重列了一遍:核心思想是在所有几何处理(栅格化、遮挡)完成之后再做着色计算;现代动机有三条:把场景扩展到复杂光照(多灯、多样的灯)、高几何复杂度(细分带来)抬高 Z-prepass 开销因此值得把几何处理结果存下来重载而不是重算、以及小三角形抬高前向基于 quad fragment 着色的开销。讲义还给出了三组特性的并列:不同的占用(用场景灯光数据结构的占用,换 G-buffer 的占用)、不同的带宽、不同的执行相干性;并且明确指出,传统延迟着色是用带宽换更大的批次与更好的相干性,tile 化方法把带宽需求又显著改善了一次,而 MSAA 会再一次改变这笔账。总结页的末尾留了一句提醒:别忘了它不是给透明表面用的技术。
判据那一页写得比总结更实用,值得抄成一张表:「前向好还是延迟好」没有统一答案,取决于上下文:几何复杂度高不高(预通道可能很贵)、三角形小不小(前向着色有开销)、要不要多重采样抗锯齿(G-buffer 占用可能过大)、以及光照计算里有没有显著的发散。讲义把贯穿全篇的那条取舍命名为带宽与执行相干性之间的权衡,并指出它是「依赖高带宽去换取高 ALU 利用率」的又一个例子,而在图形里这类权衡通常表现为多趟算法。它还给了一条方法论式的提醒:考虑新技术或新系统设计时,要注意它与既有特性与优化的互操作性:延迟着色与硬件加速的 MSAA 实现不兼容(应用只能自己实现一套 MSAA,而且仍要吃下 G-buffer 的大占用),并且延迟着色不支持透明表面。最后,讲义在结尾列了一篇延伸阅读:P. Clarberg 等人的《A Sort-Based Deferred Shading Architecture for Decoupled Sampling》(SIGGRAPH 2013)。
边界一:与硬件加速的 MSAA 不兼容
讲义把这条写在判据页的方法论提醒里,措辞是「不兼容」(not compatible)。原因是结构性的:延迟着色对每个 G-buffer 采样只做一次着色,而硬件 MSAA 的着色次数要按「贡献覆盖的三角形数」走;两者对「一次着色覆盖多少采样」的假设相反。应用若想要 MSAA,只能自己实现一套(讲义给了三趟模板缓冲的写法),而且即便这样做了,G-buffer 的超采样占用仍然要吃下(第 13 章那 173 MB 的账)。这也解释了为什么讲义把「要不要多重采样抗锯齿」列为选型的第一批问题之一。
边界二:不支持透明表面
总结页的末句是「记住:这不是给透明表面用的技术」,判据页也再次重申。原因同样来自那条「每采样恰好一次着色」的限制:延迟着色的相位 2 面向的是一张每个采样只有一份表面信息的 G-buffer,而透明需要每个像素累积多个片段的贡献。讲义在第 41 页把两件事连在一句话里:正是这条「每 G-buffer 采样一次着色」的性质,既让 MSAA 难做,也让透明难做。本篇要额外指出的是,这条边界在这份讲义里没有解:它只交代了限制,没有给出延迟管线下的透明方案。
边界三:tile 化在轮廓处的假阳性没有便宜解法
第 12 章那张页面把本质说清了:tile 的屏幕边界加上该 tile 的 (zmin, zmax) 定义一个视锥,而当 tile 里包含物体轮廓时,这个深度界不紧。讲义在这里停在了「低效」的描述上,没有给出修补方案。与之对照,第 12 章那篇 PowerVR 讲义讨论过同一现象并给过两条出路(空空间检测、空间细分),结论都是「不划算」或「需要更多内存与 ALU」。两份材料合起来能得出一个更硬的判断:这个假阳性不是实现问题,而是「用屏幕二维分块去近似三维光影响」这条思路的固有代价。
边界四:Z-prepass 在高几何复杂度下会变贵
讲义在总结页把这条列为现代动机之一:高几何复杂度(细分带来的)会抬高 Z-prepass 的开销,因此「把几何处理的结果存下来重载」比「再算一遍」更划算。但反过来说,几何越复杂、预通道越贵,延迟着色的入场费也越高。判据页把「几何复杂度高不高(预通道可能很贵)」列为第一个要问的问题,正说明这条边界是双向的:它既是延迟着色成立的理由,也是它在极复杂几何下可能不划算的原因。
边界五:性能读数来自单一来源与单一分辨率
本篇引用的性能对比只有一处(图 14),来源标注是 [Lauritzen 2009],条件写明为 1920×1080;讲义没有给出该图的绝对数值,也没有提供多分辨率、多场景或与其他实现的横向对照。因此这一页的正确用法是「确认 tile 化在那一组条件下的相对优势」,而不是从中推导某一款硬件的绝对性能。讲义本身也没有把任何帧率数字写进正文,这一点与它其余部分的量化风格是一致的。
边界六:结论定格在 2014 年的硬件与 API 现状
这份讲义的状态描述都带着明显的年代标记:G-buffer 的压缩布局取自《Destiny》(2014),裁剪算法取自 [Andersson 09],硬件实现以 PowerVR 的分块渲染器为例,抗锯齿的讨论则以「硬件 MSAA 与延迟着色不兼容」为前提。讲义结尾列的延伸阅读是 SIGGRAPH 2013 的一篇论文。这些都说明它的结论止于当时:适合作为理解「循环重排」这套方法论的一手材料,但不能直接当作今天某一款 GPU 或某一个引擎现状的描述。
| 维度 | 前向(朴素多光源) | 基础多光源延迟 | tile-based 延迟 |
|---|---|---|---|
| 着色时机 | 几何处理时 | 几何全部处理完之后 | 几何处理完之后,按 tile 批量 |
| 着色输入从哪来 | 当场算 | G-buffer(写下再读回) | G-buffer(深宽各读一次) |
| 光源列表放哪 | 常量数组 / 逐物体列表 | 不存(逐灯一趟) | 片上共享内存,用完即弃 |
| 光照判定复杂度 | O(F×L),逐片段逐灯 | 按光源影响范围裁到采样级 | 按 16×16 分块裁,块内共享 |
| 主要带宽项 | 重复的几何处理与缓冲访问 | 相位 2 逐灯读 G-buffer + 写帧缓冲 | G-buffer 读一次 + 帧缓冲写一次 |
| 执行发散 | 高(三类发散) | 低(一趟之内单一灯型) | 低(块内采样走同一张灯表) |
| 已知代价 | 状态切换多 / 批次小,或公共项重算 | G-buffer 占用大;不支持透明;MSAA 难做 | 轮廓处裁灯不紧(假阳性集中在轮廓块) |
把这张表和整讲的循环视角放在一起,这一讲真正想教的读法就清楚了:面对一套新的渲染技术,先问它把哪个循环移到了外面,再看它为此把什么存了下来。前向把图元放在最外,于是不存着色输入,但戴着 O(F×L) 与发散;基础延迟把采样放在最外并逐灯多趟,于是要存 G-buffer,并用带宽换相干性;tile 化再把屏幕分块插进来,于是多了一次块级裁灯,换来 G-buffer 只读一次。三套方案的每一项特性都能在这一句话里定位,而这也正是讲义开头那句「把关键循环重新排序」的完整含义。
- 把整份讲义还原成一句话的读法:先问它把哪个循环移到了外面,再看它为此把什么存了下来;并为前向 / 基础延迟 / tile 化三套方案做了一张七维对照表(着色时机、着色输入来源、光源列表位置、判定复杂度、主要带宽项、执行发散、已知代价)。
- 把三处关键数字做成可复算的式子:G-buffer 32×5 = 160 bits = 20 B/像素;tile 数 ⌈1920/16⌉×⌈1080/16⌉ = 8160(对应约 2.09M 线程);超采样账 1900×1200×4×20 B,并指出讲义的 173 MB 实为 MiB 口径。
- 把讲义的批评拆成同源的三类后果:执行发散、O(F×L) 冗余判定、全部灯光资源绑定,它们都出自「逐片段、逐灯地判」这一次设计决定,因此要动就得动判定粒度。
- 把两份材料接起来:讲义里 PowerVR 的 sort-middle tiled + 延迟着色、以及「tile 的深度界在轮廓处不紧」这条限制,正好对应分区内的 PowerVR 架构篇;两处对同一几何事实给出同一诊断,处置不同,本篇并列呈现。
- 六条边界单列成节:硬件 MSAA 不兼容、不支持透明、tile 假阳性无便宜解法、Z-prepass 在高几何复杂度下变贵、性能读数单一来源单一分辨率、结论定格在 2014 年的硬件与 API 现状。