单篇论文体 · 课程讲座整册精读 · 署名浅墨(qianmo)

延迟着色的真相:把渲染的几个关键循环重新排序,然后为它付带宽

CMU 15-869《Visual Computing Systems》Lecture 9 · 从 G-buffer 的 20 字节胖像素、三层循环与 O(F×L) 发散,到 tile 化裁灯、173 MB 的抗锯齿账,以及 PowerVR 硬件里那一版

出处:Carnegie Mellon University 课程讲义 · CMU 15-869, Fall 2014 50 页讲座 deck · 16 张技术核心页整页渲染 原样引用 · 逐图四字段署名 · 未修改 精读执笔 · 浅墨(qianmo)
14 章一级章节按拆解点切
16 图E 类证据图 · 全部整页渲染
5 式含 G-buffer 体积与 173 MB 复算
3 版前向 · 基础延迟 · tile-based

图-段配对表 · 本稿 16 图(全部 E 类 · 讲稿技术核心页整页渲染,无自绘)

图角色档归属支撑的论断层次状态
图 1E§c2延迟着色的定位与谱系(含 PowerVR 原生实现)定位:它不是新想法,硬件支持很晚引用 s3
图 2E§c2延迟着色管线:两趟结构与 G-buffer 四通道定位:管线出口从帧缓冲换成 G-buffer引用 s5
图 3E§c3示例 G-buffer 布局:4×RGBA = 20 字节/像素关键:96–160 bits/像素在游戏里很常见引用 s7
图 4E§c3压缩 G-buffer:材质 ID + 查表关键:用一次查表换更窄的 G-buffer引用 s8
图 5E§c4延迟着色的动机:完美的早期遮挡剔除效果账:顺序无关 + 着色量可预测引用 s11
图 6E§c51000 lights(Andersson, SIGGRAPH 2009)定位:后面所有裁剪技巧的起因引用 s15
图 7E§c6三层循环:三角形 / 可见性采样 / 光源关键:T×S 求覆盖 + F×L 光照判定引用 s18
图 8E§c7朴素多光源着色器:三类发散 + O(F×L)关键:发散、冗余判定、大占用同源引用 s20
图 9E§c8每物体光列表(按保守包围体逐帧算)定位:把判定粒度从片段上移到物体引用 s22
图 10E§c9基础多光源延迟着色:优点与五条代价效果账:几何一趟,光照逐灯多趟引用 s29
图 11E§c10光源-采样交互计数可视化(1024 盏点光源)效果账:逐灯四边形裁剪后的实际灯数引用 s31
图 12E§c11tile-based 延迟算法:16×16 / 片上列表 / barrier关键:G-buffer 只读一次、帧缓冲只写一次引用 s33
图 13E§c12轮廓处的剔除低效:tile (zmin,zmax) 不紧关键:tile 版假阳性的结构来源引用 s36
图 14E§c12tiled vs conventional 性能(1920×1080)效果账:tile 化值多少引用 s37
图 15E§c13两条抗锯齿路线与 173 MB 的体积账关键:超采样按可见性频率着色引用 s42
图 16E§c14PowerVR sort-middle tiled 的 bin 处理关键:G-buffer 只存三角形 ID引用 s47
16 张图取自 Carnegie Mellon University 课程讲义《CMU 15-869, Fall 2014 · Lecture 9: Deferred Shading》的技术核心页,按分区既有讲座类口径(篇目 09–11 / 10)做整页渲染(2x),作为 E 类证据图原样引用、逐图四字段署名(源 slide 号 + 页码 + © Carnegie Mellon University 课程讲义 + 未修改声明),不做重绘、不加中文标注、不裁切拼接。
讲稿无标准 Figure 标注,故图号与源 slide 号一一对应并在图注中注明;未计入图量的论述页凡进入正文者,均在正文中标注来源 slide 号可回查。
许可:讲义页脚仅课程标识,未见独立版权或许可声明。本篇按分区既有讲座类非 CC 口径处理:实名署名 + 逐图署名 + 残留风险标注(与篇目 09–11 / 10 同类)。
口径说明:第 13 章那笔 173 MB 的体积账,按十进制为 182.4 MB、按二进制为 173.9 MiB,讲义写的是 173 MB,即 MiB 口径;本篇在正文并列两种换算。
开篇要接住的那个问题 把着色从「几何处理时」挪到「遮挡判完之后」,换来了什么,又付了什么

“这份讲义用一句话给出了全套技术的坐标:延迟着色算出与向前渲染大致相同的结果,它做的是把渲染的几个关键循环重新排序。于是三个变量决定成败:数据的占用、带宽特性、执行相干性。这一篇沿讲义的主线走一遍: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]。下面这张页面对应的是它的定位与谱系。

E图 1
讲义第 3 页:延迟着色的定位与谱系(原文 slide 3)
它不是新想法,但硬件侧的支持来得很晚怎么读:这一页只有五行要点,读的顺序就是它的论证顺序。第一行给定位:现代游戏里的流行算法。第二行给核心思路:把管线重构,让着色发生在所有遮挡解决之后。第三行给历史:经典图形系统实现过,但多数高端 GPU 不直接支持,现代管线才提供了让应用高效实现的机制。第四行是这一页最值得记的一句:由 PowerVR 移动 GPU 原生实现。第五行是经典硬件出处:[Deering et al. 88] 与 UNC PixelFlow [Molnar et al. 92]。读它时把第四行圈出来:整篇讲义的「应用侧实现」与「硬件侧实现」两条线,在这里第一次分开。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 3 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改

两趟结构是这套思路的直接产物。第一趟是几何趟:用传统管线渲染场景,但片段着色器输出的不是最终颜色,而是表面属性(也就是着色器的输入),例如位置、法线、材质的漫反射颜色与高光颜色。这一趟的渲染结果是一张屏幕尺寸的二维缓冲,里面记录的是每个像素上可见表面的几何信息,讲义给它起名叫 G-buffer(geometry buffer)。第二趟是着色趟:几何全部渲染完之后,对 G-buffer 里的每个采样执行一次着色,读出该采样的几何信息、累加所有光源的贡献、输出最终的表面颜色。下面这张页面对应的是这两趟结构与 G-buffer 的四个通道。

E图 2
讲义第 5 页:延迟着色管线的两趟结构与 G-buffer 四通道(原文 slide 5)
同一张管线图,出口从「帧缓冲」换成了「G-buffer」怎么读:这一页是一张改过的管线图,读法是找它和前向管线的差别。传统管线的末端是 Frame-Buffer Ops 直接写帧缓冲;这里中间插了一个「G-buffer」,几何趟写进去、着色趟再读出来,末端才回到帧缓冲。页面上还标了这四个通道各自装什么:Albedo(反射率)、Depth(深度)、Specular(高光)、Normal(法线)。读它时记住讲义对这套结构的定义句:不再用传统管线生成 RGB 图像,而是让片段着色器输出表面属性;输出是一张屏幕尺寸的二维缓冲,表示每个像素上可见表面的几何信息。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 5 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改

把两趟写成伪码,这套结构的边界条件就很清楚:

趟 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)绑给管线,几何渲染同时输出到深度缓冲与多个颜色缓冲。按这个配置算:

(1) Spx = 32 bits × 5 = 160 bits = 20 bytes/像素
五个 32 位分量从哪来:四个 RGBA 颜色缓冲各占 32 位(合计 128 位),加上深度缓冲的 32 位,正是讲义标注的「32 x 5 = 160 bits = 20 bytes per pixel」。讲义还顺带给了一个行业区间:96 到 160 bits/像素在游戏里很常见,也就是每像素 12 到 20 字节。这个数字后面会反复用到:第 15 章算超采样 G-buffer 的体积账时,用的就是这里的 20 字节。

下面这一页是示例布局的原始出处,讲义还标注了数据来源:W. Engel 的《Light-Prepass Renderer Mark III》(SIGGRAPH 2009 Talks)。

E图 3
讲义第 7 页:示例 G-buffer 布局(4 个 RGBA 目标 = 20 字节/像素)(原文 slide 7)
「胖像素」到底胖在哪几个通道上怎么读:这一页把 G-buffer 摊成几个并排的缓冲看。左边是管线配置的说明:把管线配置成渲染到四个 RGBA 输出缓冲(每像素每缓冲 32 位),应用绑定「多渲染目标」(图中记为 RT0 到 RT3),几何渲染同时输出到深度缓冲与多个颜色缓冲。中间那句提示是本页的关键读法:把 G-buffer 想成一张带「胖像素」的大缓冲,比想成几个独立缓冲更直观。底部给出两个可核对的量:这个例子里 32 × 5 = 160 位 = 20 字节/像素;以及 96–160 位/像素在游戏里很常见。来源标注为 W. Engel《Light-Prepass Renderer Mark III》SIGGRAPH 2009 Talks。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 7 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改

20 字节/像素这个数字之所以要紧,是因为它随分辨率与采样数一起放大。后面第 15 章会算一笔超采样 G-buffer 的账,用的就是这个系数。讲义在这里先埋了伏笔,也埋了第一处取舍:G-buffer 的通道越多,着色趟能拿到的表面信息越完整,但几何趟要写出去、着色趟要读回来的数据就越多。

压缩布局正是针对这笔账的一条对策,讲义用一页讲它:把材质信息用「间接」的方式压下来:G-buffer 里只存材质 ID,而高光形状、粗糙度、颜色这些除反照率以外的参数存进一张按材质 ID 索引的表。这一页的图示来自 Bungie 的《Destiny》(2014),来源标注为 N. Tatarchuk 的 SIGGRAPH 2014 Courses。

E图 4
讲义第 8 页:压缩 G-buffer 布局(材质 ID + 查表)(原文 slide 8)
把「一堆参数」换成「一个编号 + 一张表」怎么读:这一页只有三行要点加一张布局图,读法是先看那三行。第一行是总纲:材质信息用间接方式压缩。第二行是具体做法的一半:把材质 ID 存进 G-buffer。第三行是另一半:把除反照率以外的材质参数(高光形状、粗糙度、颜色)存进一张由材质 ID 索引的表。图里给的是 Bungie《Destiny》(2014)的实际 G-buffer 布局,可以看到它把 RT0、RT1 与深度/模板拼在一起。读它时要意识到这背后的交换:用一次查表换来更窄的 G-buffer,代价是着色趟多了一次间接访问,而且材质参数被限制在表里那些「预设种类」中。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 8 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改

把这三页连起来,G-buffer 的性格就很清楚了:它是一份把几何信息在屏幕空间里「定型」的中间结果。定型带来两个后果,一个好处一个坏处。好处是下游的着色趟不必再关心几何,只需按采样逐条处理,于是遮挡关系天然被尊重;坏处是这份中间结果必须完整写下再读回,而且一经写下,能表达的材质种类就被那几个通道(或那张表)限定了。第 9 章与第 15 章会分别从带宽和抗锯齿两侧回到这个限定上。

04为什么值得:完美的早期遮挡剔除与可预测的着色量

讲义把延迟着色的动机总结得很干脆:着色很贵,所以只想给可见片段着色;而延迟着色等效于一次完美的早期遮挡剔除。这句话的分量在后面的三个从句里,讲义逐条给了限定条件。第一,它是三角形顺序无关的:不管应用按什么顺序提交三角形,它都只为可见片段着色。第二,它带来一个好性质:被着色的片段数与场景复杂度无关,因此着色性能是可预测的。下面这一页是这段动机被压成的五行要点。

E图 5
讲义第 11 页:延迟着色的动机(完美早期遮挡剔除、顺序无关、可预测)(原文 slide 11)
为什么值得:两条收益,一条对前向的批评怎么读:这一页的要点分成三块,读的时候按块读。第一块讲收益:着色昂贵,所以只给可见片段着色;延迟着色等于一次完美的早期遮挡剔除,而且是三角形顺序不变的(无论提交顺序如何,只着色可见片段);它还带来一个有用的性质:被着色的片段数与场景复杂度无关,也就是着色性能可预测。第二块是这一页真正针对前向的批评:前向渲染着色小三角形时效率低下,原因是着色的粒度是 quad fragment,沿三角形边缘的像素会生成多个片段。第三块是配图的题注,与下一章那张「每像素着色计算次数」的图相呼应。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 11 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改

「沿三角形边缘的像素会生成多个片段」这句话需要一个机制解释,讲义用连续三页讲它。它的说法是:着色的粒度是 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》课程上的报告。这一页是整讲的转折点:它把问题从「几何怎么着色」换成了「这么多灯怎么办」。下面就是这一页。

E图 6
讲义第 15 页:1000 lights(Andersson, SIGGRAPH 2009)(原文 slide 15)
一千盏灯的工况,是后面所有裁剪技巧的起因怎么读:这一页的信息量几乎全在配图与来源标注上。标题「1000 lights」给出工况;来源标注写明它出自 J. Andersson 在 SIGGRAPH 2009《Beyond Programmable Shading》课程上的报告,同一位作者也是本讲后面 tile-based 延迟着色算法([Andersson 09])的出处,两条线在这一页交汇。读它要把注意力放在「数量」上:一千盏灯意味着逐片段遍历全部灯的成本不可行,而延迟着色并没有自动解决这个问题(它只把着色搬到屏幕空间),所以下一页起讲的全是「怎么把灯裁掉」。这一页也顺带解释了本讲后面反复出现的 1024 盏灯场景从哪来。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 15 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改

紧接着的一页交代了「灯」在工程上是什么形状,这决定了后面能怎么裁。讲义列了四类:带距离截断的全向点光源(图中标出 Dcutoff)、方向性聚光灯、环境光、以及带阴影的光源。它还点明了一条对裁剪至关重要的事实:为了效率,光源通常会指定一个有限的影响体积(finite volume of influence)。

这条事实是后面所有裁剪技巧的物理前提。既然每盏灯的影响范围有限,那么「某个采样会不会被某盏灯影响」就是一个几何判交问题,而不需要真的把光照算一遍再看结果;也正因为它是几何判交问题,才能从「逐片段判」上移到「逐物体判」「逐屏幕分块判」或「用光体积的几何去光栅化」。第 8 到第 12 章会依次看到这几种上移方式,每一次上移都换来一次复杂度的下降,代价则是判定变得更保守、出现假阳性。

还有一处细节要留到后面用:那盏全向点光源标的 Dcutoff。距离截断意味着光源的影响力有一个明确的半径,于是「灯的体积」可以是一个球,而球与 tile 视锥的判交在 GPU 上很便宜。第 12 章的 tile-based 算法里,第一步就是为每个 tile 算出 Z 的最小值与最大值,好让这个判交有深度维度的信息可用;第 13 章讲的那条「轮廓处深度界不紧」的限制,也正来自这里。

06三层循环:三角形、采样、光源

讲义用两页把「渲染」写成了三个嵌套的 for 循环,这是整讲最值得记住的一段伪码。循环从外到内是:三角形、帧缓冲里的可见性采样、光源。最外层遍历每个三角形,把它投影到屏幕(t_proj = project_triangle(t));中间层遍历每个采样,判断这个投影后的三角形是否覆盖它;最内层遍历场景里的每一盏灯,累加该灯对这个表面外观的贡献;然后做一次深度比较,如果三角形在该采样上更近,就更新最近的深度值与颜色。

讲义对「为什么三角形要放在最外层」给了两条理由,都是工程性的。第一条是摊薄:三角形在最外层意味着三角形的建立(setup)只做一次,可以被它覆盖的许多采样摊薄。第二条是相干性:同一批片段来自同一个三角形,跑的是同一个着色程序、访问的是相近的数据,因此着色计算的相干性很高。它还补了一句:成熟的栅格化技术(分块、层次化、包围盒)正是为了降低「三角形 × 采样」这个二维搜索的复杂度而存在的。

下面这一页是这段三层循环的完整伪码与两条注解,值得对着读一遍。

E图 7
讲义第 18 页:把渲染写成三层循环(三角形 / 可见性采样 / 光源)(原文 slide 18)
三层循环的原始写法,和它为什么这样排怎么读:这一页左侧是伪码,右侧是三段注解,读法是先读循环再看注解。伪码的骨架是:先把 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 是光源数;而现实中并非所有灯都照亮所有表面。它还给出了想达到的目标:既然「三角形与采样的交互」已经被栅格化技术做得很有效率,那么「光与采样的交互」也应该有同样效率的算法。把两层复杂度写在一起,这一段的账就很清楚:

(2) Work = T × S(求覆盖) + F × L(光照判定)
两项的含义与不对称:第一项里 T 是三角形数、S 是采样数,讲义明确说它已经被分块/层次化/包围盒这类栅格化技术压下来了;第二项里 F 是片段数、L 是光源数,讲义说这才是「想要更有效率」的那一项。两项的不对称在于:第一项有成熟的硬件与算法支持,第二项没有:它是逐片段、逐灯的谓词判定,而且如后面两章所示,它会同时带来工作量、发散与资源绑定三类问题。

这一章还留了一个伏笔,是「空间相干性」四个字。讲义在讲 F × L 判定时说,这个谓词在空间上本应存在相干性,相邻的片段大概率被同一批灯照亮。这句话是整个后半讲的钥匙:既然相干性存在,那就应该像栅格化利用三角形相干性那样去利用它,而利用的方式就是「先按空间区域把灯找出来,再让区域内的所有采样共用这份列表」。第 11 章的 tile-based 方法,本质就是把这句话做实。

07朴素多光源前向着色:三类发散与 O(F×L)

讲义在第 17 页先给出最直白的那版实现:一个着色器里声明一张 LightDefinition lightList[MAX_NUM_LIGHTS]、一个 numLights,以及一组按灯数开的大小固定的资源数组(环境贴图与阴影贴图各 MAX_NUM_LIGHTS 张),然后在片段着色器里写一个从 0 到 numLights 的循环,逐个累加每盏灯的贡献。这就是「朴素的多光源着色器」:结构简单,但它把灯数与资源规模都写死在了编译期。

第 20 页在同一版代码上加了一个「是否被照亮」的谓词与按灯类型分叉的逻辑,然后把它的问题逐条列出来。这一页是整讲对前向渲染最完整的批评,值得逐句读。

E图 8
讲义第 20 页:朴素多光源着色器加裁剪谓词后的三类发散与 O(F×L)(原文 slide 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 每帧计算,方式是把光体积与场景几何求交。讲义特意点明这条做法把交互计算的粒度从「灯与片段」换成了「灯与物体」,这正是它省下工作量的原因。下面这一页是这套列表的具体样子。

E图 9
讲义第 22 页:每物体光列表的示例(对象 1–5 与灯 L1–L4)(原文 slide 22)
五个物体、四盏灯,列表是逐物体算出来的怎么读:这一页是一张示意加一份结果表。示意里有四个灯(L1–L4)与五个物体(Obj 1–Obj 5),它们各有包围体;页面的说明句点出算法:「基于光源与场景物体的保守包围体计算列表」。右侧列出结果:Obj 1 只需要 L1,Obj 2 只需要 L2,Obj 3 也是 L2,Obj 4 需要 L2 与 L4,Obj 5 需要 L3 与 L4。读它要注意两点:一是「保守」二字(用包围体求交必然多报,这正是下一段两条补救路线代价的来源);二是粒度的变化:同一物体内的所有片段共用一张列表,于是逐片段的判定被替换成了逐物体的判定。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 22 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改

拿到逐物体列表之后,前向侧有两条路线把它用出去,讲义分别给了评价。第一条是把场景拆成许多小批次绘制:先为所有灯生成数据结构(例如阴影贴图),再算逐物体列表,绘制每个物体前只绑定相关灯的数据,并且为不同的绑定灯集合预编译专门的着色器变体(4 灯版、8 灯版……)。讲义给的好评是着色阶段的执行发散很低;差评是图形状态切换很多、绘制批次很小,因为一个绘制调用只能覆盖单个物体,或者覆盖具有相同灯数的一组物体。

第二条是多趟渲染:假设深度缓冲已经由深度预通道初始化,然后为每一盏灯开一趟:绑定只针对当前灯类型的单灯着色器、绑定相关阴影贴图、只绘制被这盏灯照亮的三角形(用逐灯物体列表),在采样上累加这盏灯对可见片段的照明,并沿用「深度等于最近深度才更新颜色」的写法。它的外层循环由「三角形/物体」换成了「灯」,讲义把这套重排说得很直白:把循环重新排序,让场景每盏灯画一遍,而且第三层循环被特化成「只有一盏灯」。好评是灯光数据的占用最小、执行发散也低;差评写在下一页:几何处理被重复、G-buffer 被反复访问、片段着色器里那些公共子表达式的求值被重复执行。

把两条路线摊开,前向侧的两难就清楚了。小批次路线省的是执行发散,付的是状态切换与批次碎片;多趟路线省的是资源占用与发散,付的是几何与公共项被反复算。两者都没有碰到那件更根本的事:无论哪条路线,着色仍然发生在几何被处理的那一刻,也就是说「被遮挡的片段照样会被着色,只是着色后不写出去」。这正是下一章要交给延迟着色的那件事。

09基础多光源延迟着色:几何一趟,光照逐灯多趟

把多趟的思路搬到延迟管线里,就得到讲义称之为「基础多光源延迟着色」的算法。它把之前的循环重排成两个阶段。第一阶段生成 G-buffer:遍历每个三角形,投影、判断覆盖、把几何信息写入,并按深度比较更新 z_closest[s] 与 gbuffer[s]。第二阶段着色 G-buffer:外层循环换成灯,为每盏灯绑定对应的单灯着色器与阴影贴图,然后遍历每个采样,读出 gbuffer[s]、把当前灯的贡献累加进 color[s]。

讲义对这一版给了三条好评与五条差评,全部落在下一页,是本讲最集中的一次账目清点。

E图 10
讲义第 29 页:基础多光源延迟着色的伪码、三条优点与五条代价(原文 slide 29)
这一页把延迟着色的正反面一次写全了怎么读:左侧是两段伪码:上段是生成 G-buffer(写入 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 字节计:

(3) B相位 2 = L × N × (sgbuf + sfb) ≈ 1024 × 2.07 × 106 × 24 B ≈ 5.1 × 1010 B
代入与口径:取讲义后面那个 1024 盏点光源的场景、1080p(1920×1080 = 2,073,600 像素)、sgbuf = 20 B(图 3 的 32×5 配置)、sfb = 4 B,得每帧约 5.1 × 10¹⁰ 字节 ≈ 51 GB。这个数是「逐灯全屏各来一趟、且不做任何按灯裁剪」的上界,实际实现会用屏幕对齐四边形或光体积几何把每趟限制在灯的影响范围内,因此真实值远低于此。本篇列出它的用意是说明方向:相位 2 的带宽随灯数线性增长,而灯数上千时这条路本身不可行,所以下一章的全部技巧都在做同一件事:把「每趟覆盖全屏」改成「每趟只覆盖受影响的部分」,并把多盏灯合并进同一趟。

再回头看那三条好评,会发现它们其实是同一件事的三层收益。几何只处理一次,省掉的是前向侧多趟路线里那份重复的几何处理;外层循环是灯,省掉的是把全部灯光资源同时绑定的占用;只为可见采样着色,省掉的是前向侧那些「算了但没写出去」的片段。换句话说,延迟着色用一次几何信息的「固化」(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) 的冗余判定一起消失。剩下的分叉只出现在「不同灯类型走不同着色器」这一层,而那一层可以用多趟结构把它限制在「一趟之内只有一种灯」。

下面这一页是这套裁剪效果的可视化,它把「每采样被多少盏灯影响」画成了图。

E图 11
讲义第 31 页:光源-采样交互计数可视化(场景含 1024 盏点光源)(原文 slide 31)
一千零二十四盏灯下,每个采样实际被几盏灯碰到怎么读:这一页是一张自定义的可视化图,图注写明它画的是「每个 G-buffer 采样被求值的光源数量」,而场景里含 1024 盏点光源。同页还交代了裁剪是怎么做的:逐灯用一个屏幕对齐四边形做剔除,并且这个四边形的深度取光体积里最近的点的深度,这样把深度设成「最近点」之后,早深度测试(early Z)就能把位于场景几何之后的那些片段裁掉。读它要把两个数放在一起:场景里有 1024 盏灯,而图上标出的是每采样实际参与求值的灯数,两者之间的落差就是裁剪挣来的那份钱。图片来源标注为 A. Lauritzen。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 31 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改

这一页里那个「四边形的深度取光体积最近点」的做法值得单独记,因为它是一个把深度测试用成裁剪工具的典型例子。四边形本身是屏幕空间的二维图元,本身没有真正的深度;把它的深度设成「光体积离相机最近的那个点的深度」,等价于声明「这个四边形的任何片段都在光体积的近端或之后」。于是当场景几何挡在前面时,早深度测试就自然把那些片段丢掉,不需要在着色器里做任何判断,裁剪由固定功能单元完成。这是同一种思路的第二次出现:第 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 把「协作求深度范围」与「并行裁灯」这两步隔开,然后把着色循环限制在已经裁好的列表上。

E图 12
讲义第 33 页:tile-based 延迟着色算法(16×16 tile、group shared memory、两道 barrier)(原文 slide 33)
两块 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 的数量可以直接算出来,它决定了并行任务的规模:

(4) Ntile = ⌈W / t⌉ × ⌈H / t⌉ = ⌈1920 / 16⌉ × ⌈1080 / 16⌉ = 120 × 68 = 8160
代入讲义给出的规格:tile 为 16×16 采样、每线程组 256 线程(等于 16×16)、目标分辨率取讲义第 37 页给的 1920×1080。得 8160 个 tile;乘上每组的 256 线程,总线程数约 2,088,960,与屏幕像素数 2,073,600 基本吻合(差异只来自 1080 除 16 需要向上取整成 68 行)。也就是说,这套算法的线程总量刚好是「一像素一线程」的规模,而每个线程组额外承担了一次「为该 256 个采样裁灯」的小任务,这就是它用并行度换来裁剪免费的方式。

讲义在第 34 页给了这套裁剪的图示:黄框是屏幕对齐的光体积包围盒,蓝框是屏幕 tile 的边界,来源标注为 HMREngine 的一篇博客。把两张图叠在一起看就能理解这套算法的几何含义:裁灯不是逐像素做的,而是「黄框与蓝框求交」。这也正是它效率的来源,同时是它下一章那个限制的来源:一旦蓝框(tile 的屏幕范围)与 tile 内几何的深度范围组合成一个视锥,这个视锥在深度方向上的松紧就决定了裁剪的准确性。

12tile-based 的两面:剔除效率,与轮廓处的低效

讲义第 35 页给的是 tile 版的交互计数可视化,与第 10 章那张同源(同一场景、1024 盏点光源),但裁灯改成 16×16 粒度。页面的说明句点出结论:tile 版的裁灯效率很好,而且从图上就能看出 16×16 这个裁剪粒度。把它与逐灯四边形版并排看,就能看到两种裁剪方式的差别:逐灯版每采样单独判,几乎没有假阳性;tile 版共享列表,块内必然有假阳性,但裁剪的代价被摊薄。下面两页分别是 tile 版的结构性限制与性能对比。

E图 13
讲义第 36 页:轮廓处的剔除低效(tile 的 (zmin, zmax) 深度界不紧)(原文 slide 36)
tile 裁灯的假阳性,集中在物体轮廓上怎么读:这一页是一张示意加两句说明,说明句就是全部内容。第一句给出视锥是怎么来的:tile 的屏幕边界,加上该 tile 的 (zmin, zmax),定义一个视锥。第二句给出限制:当 tile 里包含物体轮廓时,这个深度界不紧。读它时把「不紧」这两个字展开:一个 tile 里同时有很近的物体与很远的背景时,zmin 取近值、zmax 取远值,于是这个视锥在深度方向上被拉得很长;凡是落在这段纵深里的灯,都会被算成与该 tile 相交,尽管它们其实只影响 tile 里靠近的那一部分采样。图片来源标注为 A. Lauritzen。这就是 tile 版比逐灯版多出来的那类假阳性,也是它唯一的系统性低效。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 36 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改

这条限制与第 12 章那篇 PowerVR 讲义的讨论几乎一模一样:那里说的是「与物体边缘重叠的 tile 灯数偏多,因为视锥在深度方向被拉长」,这里说的是「含轮廓的 tile 深度界不紧」。两处是同一个几何事实在不同文档里的复述,区别只在处置:PowerVR 那篇讨论了改进方案(空空间检测)并判断不划算,这份讲义则把重点放在性能影响上。

E图 14
讲义第 37 页:tiled vs conventional 延迟着色性能对比(1920×1080)(原文 slide 37)
同一分辨率下,两种延迟着色摆在一起比怎么读:这一页是一张性能对比图,图注给出条件:延迟着色的渲染性能,分辨率 1920×1080;来源标注为 [Lauritzen 2009]。页面标题是「Tiled vs. conventional deferred shading」,也就是把 tile 版与传统(逐灯多趟)版放在同一组坐标里比较。读它时要抓住比较的维度:这一页承担的是「tile 化到底值多少」的量化回答,而这个回答的机制在前面几页已经交代清楚:tile 版把相位 2 从「每盏灯一趟、每趟读写一次」压缩成「一趟、每块读写一次」。注意它的口径边界:这是 2009 年那篇来源在 1920×1080 下的读数,不是本讲义自己的实测,也不是所有场景的普适结论(最后一章会把「取决于上下文」写进判据)。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 37 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改

把这一章的两页合起来,tile 化的账就完整了。收益侧是带宽与发散:G-buffer 只读一次、帧缓冲只写一次、同块采样无发散,这是第 10 章那 51 GB 上界被压下来的直接原因。代价侧是假阳性:块内共享列表意味着块里那些「不需要某盏灯」的采样也要为它付出一次求值,而这些假阳性并非均匀分布,它们集中在物体轮廓所在的块上。于是 tile 方案的实际表现取决于场景的轮廓密度:轮廓越多、块内深度差越大,裁灯越松。

13抗锯齿这笔附加账:超采样 G-buffer 还是 MLAA

讲义在第 39 页摆出了一个未解的问题:在延迟渲染器里给几何做抗锯齿。要理解它的难点,得先回到 MSAA 的原理。第 40 页的复习页把 MSAA 的核心思想写得很干净:把着色的采样率与可见性的采样率解耦:深度缓冲按采样存深度,颜色缓冲按采样存颜色,最后把颜色缓冲重采样成每个像素的最终值。关键在「解耦」二字:可见性可以按更高密度采样(因此边缘轮廓能被正确覆盖),而着色可以按更低频率发生。

延迟着色与这套机制有一处天然冲突。讲义在第 41 页点明:延迟着色对每个 G-buffer 采样恰好做一次着色计算;而 MSAA 的着色次数是「按贡献覆盖的三角形数」走的,于是有效着色率是自适应的:落在三角形内部的像素只着色一次,落在边界上的像素会多着色几次。讲义对这件事的评价是双面的:边界多着色是值得的,因为轮廓的抗锯齿本来就需要它;但它也有一个不想要的副作用:当同一表面的两个相邻三角形在边界相遇时,MSAA 也会多着色一次,而这里其实没有锯齿需要处理。讲义在这一页还加了一条脚注,把第 9 章那条「每采样恰好一次着色」与透明问题联系起来。

第 42 页给出两条对策,并当场算了一笔账,这是整讲最可复算的一页。

E图 15
讲义第 42 页:延迟着色的两条抗锯齿路线,与 173 MB 的体积账(原文 slide 42)
两条路:把 G-buffer 超采样,或者把结果聪明地滤掉怎么读:这一页分成两块,正好是两条路线。第一块是超采样 G-buffer:生成超采样的 G-buffer(例如每像素 4 个采样)、对所有 G-buffer 采样着色、再把着色结果重采样成最终像素;它列出的问题有两条:G-buffer 的占用与其读写带宽都上升(提醒一句:每个 G-buffer 采样存的是「胖采样」),以及着色成本上升(因为系统是按可见性频率着色,而不是每像素一次)。这一块里那个算式是必读的:1900 × 1200 × 4 spp × 20 bytes per sample = 173 MB frame-buffer。第二块是对已着色的结果做智能滤波:不增加 G-buffer 占用也不增加着色成本;当前流行的做法是形态学抗锯齿(MLAA)。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 42 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改

那个 173 MB 值得复算一次,因为它牵涉一个单位口径问题:

(5) 1900 × 1200 × 4 × 20 B = 1.824 × 108 B = 182.4 MB(十进制)= 173.9 MiB(二进制)
复算与单位说明:1900 × 1200 = 2,280,000 像素;每像素 4 个采样(4 spp)得 9,120,000 个采样;每个采样 20 字节(正是第 3 章图 3 那个 32×5 = 160 位配置),得 182,400,000 字节。按十进制换算是 182.4 MB,按二进制换算是 173.9 MiB。讲义写的是「173 MB」,与二进制口径吻合,因此这一页的「MB」实际是 MiB。这个差别不影响它的结论,但影响它与其他数字的横向比较:凡是把同类体积账放在一起比的时候,要先确认是不是同一个口径。

第 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 页回到移动端,把硬件那一侧的答案补上了。

E图 16
讲义第 47 页:PowerVR 的 sort-middle tiled 实现与 bin 处理伪码(原文 slide 47)
在硬件里,延迟着色是 OpenGL 系统的一个优化,不是应用叠上去的怎么读:这一页的说明句是第一读者要抓的。它先给动机:移动 GPU 追求能效,其哲学是「激进地剔除不必要的片段工作以省电」。然后给实现:Imagination PowerVR GPU 对 OpenGL ES 管线的实现是 sort-middle 分块的,并带延迟着色;并且特意加了一条注:这里的延迟着色是 OpenGL 系统作为优化实现的,而不是应用像本讲前面那样叠在图形管线之上。下半页是分块渲染器相位 2 的伪码:对每个 bin,遍历 bin 的三角形列表做栅格化,但每个 G-buffer 采样只存三角形 ID;再对 tile 内每个采样,按三角形 ID 反推它对应的片段、去重后加入待着色列表;最后只着色那些「贡献覆盖」的片段并写进帧缓冲。页末那句说明是这套做法的要点:G-buffer 里存的是「哪个三角形覆盖了这个采样」,而不是完整的表面属性,那些属性可以按三角形 ID 现算。来源:CMU 15-869 Fall 2014 Lecture 9《Deferred Shading》原文 slide 47 · © Carnegie Mellon University 课程讲义 · 原样引用,未修改

这一页与第 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 只读一次。三套方案的每一项特性都能在这一句话里定位,而这也正是讲义开头那句「把关键循环重新排序」的完整含义。

来源 Carnegie Mellon University,课程讲义《Visual Computing Systems(CMU 15-869, Fall 2014)· Lecture 9: Deferred Shading》,共 50 页(PDF 元数据为 Keynote 导出)。讲义无编号图表,本篇按分区既有讲座类口径对 16 张技术核心页做整页渲染(2x)作为 E 类证据图。 引用方式:原样引用、未修改像素、未重绘、不加中文标注、未裁切拼接;逐图四字段署名(源 slide 号 + 页码 + © Carnegie Mellon University 课程讲义 + 未修改声明)。讲稿内引用的外部来源(W. Engel / J. Andersson / N. Tatarchuk / A. Lauritzen / [Lauritzen 2009] / [Reshetov 09] 等)均在图注或正文中如实标注。 文本处理说明:讲义为 Keynote 导出,代码页的文本抽取呈逐字符换行,本篇按语义重排后引用,未改动任何代码字符;所有伪码块均据讲义原文重排,并在正文标注对应 slide 号。 口径登记:第 13 章 173 MB 的体积账涉及 MB 与 MiB 的单位差(十进制 182.4 MB / 二进制 173.9 MiB,讲义取后者);第 9 章 51 GB 的量级估算已标注为「逐灯全屏且不做按灯裁剪」的上界。凡属本篇推算或读法者,正文均已显式标注。
这一篇的贡献(精读视角)
  • 把整份讲义还原成一句话的读法:先问它把哪个循环移到了外面,再看它为此把什么存了下来;并为前向 / 基础延迟 / 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 现状。