“延迟渲染能用很多灯,代价是把材质压进 G-buffer 的几个通道、并让每个像素反复读写屏幕空间缓冲。这篇四页短文给了一条加法式的改造:在传统前向管线上只加一道逐 tile 的光源裁剪,裁剪结果交给最终着色器,而材质与光照模型都不再有上限。它还把两种方法的内存流量写成公式,解出一个交叉点条件。这一篇沿同一条链走,并把那步推演自己复算一遍。”
- 先摆双方的账:延迟渲染赢在灯数、几何解耦与着色器组合,卡在材质受限、带宽、透明与抗锯齿;前向渲染的老限制是固定灯数与主机上昂贵的动态分支。
- 看清结构:Forward+ 只在前向管线上加一段,管线变成深度预通道 → 光源裁剪 → 最终着色三阶段,光源改存线性缓冲,且深度预通道对它是必需项。
- 看懂两个取舍:为什么列表按 tile 而不是按像素建(内存足迹 vs 列表精度),以及两种裁剪实现的分界——Gather 单 kernel 适合灯数不太多、Scatter 加基数排序适合灯数极大。
- 把账算成公式:表 1 给出三阶段的读写分布,Deferred 是常数 16 个 float/像素,Forward+ 是 1 + M×(L + 1 + 1/T),两者相减得 Total_diff。
- 把账结掉并划边界:实测三阶段分工与显存降频对照可读,但由 Total_diff 解 M 的那一步与原文所印条件互为倒数,本篇记为待核;另有六条适用范围写在最后一章。
01开场:把光源裁剪加成前向管线上的一道加法
先把论文的身份交代清楚。这是 AMD 的三位研究者 Takahiro Harada、Jay McKee、Jason C. Yang 在 Computer Graphics Forum(Eurographics 2012)上发的一篇短文,只有四页,题名《Forward+: Bringing Deferred Lighting to the Next Level》。四页要讲的事很集中:给传统前向渲染管线加一道光源裁剪阶段,从而在保持前向渲染全部好处的同时,把「几百万盏灯」这件事接住。
摘要里把主张压成了几句话,值得逐句读。第一句定义:Forward+ 是一种通过裁剪并只存下对该像素有贡献的光源来渲染多光源的方法。第二句定位:它是传统前向渲染的扩展。第三句讲实现:光源裁剪借 GPU 的计算能力完成,产出光源列表,再把这张列表传给最终着色器,而最终着色器可以访问光源的全部信息。第四句是权衡:虽然 Forward+ 增加了最终着色器的工作量,但理论上它需要的内存流量比基于计算的延迟光照更少。第五句是它想拿回的东西:它去掉了延迟技术最主要的缺点,也就是对材质与光照模型的限制。
这五句话构成了一条很干净的论证链,而这篇精读的价值也正在这里:它没有停在「我们更快」上,而是先把两种方法的内存流量写成公式,再从中解出一个交叉点条件,最后用两块 AMD 硬件做实测。图 1 就是它给出的效果凭证,一张来自 AMD Leo demo 的实机截图。
页面分配也值得一提,因为它反映了这篇论文的取舍。四页里,方法与实现占一页半,而「结果与讨论」加结论占了一页多;讨论那一节里,理论内存流量表、两块硬件的实测、显存降频对照、以及一段关于 tile 边界假阳性的分析挤在一起。换句话说,作者把大部分篇幅给了「这套结构在各条件下的表现」,而不是给「这套结构有多聪明」。后面读到第 9 章那张表时,这种诚实的写法会更明显。
还有一处前提要记住:这篇论文的比较对象是基于计算(compute-based)的延迟光照,也就是不写 G-buffer、用计算着色器一次读写帧缓冲信息的那种延迟变体。原文明确说,这个对照实现在内存带宽上「理论上是延迟系里最好的」,选它做对手是为了让比较落在最不利的一侧。理解了这一点,后面那句「理论上没有哪个延迟变体能在内存流量上赢过 Forward+」,才知道它是拿最强对手比的。
02坐标:延迟渲染赢在哪,又卡在哪
要理解 Forward+ 的动机,得先把延迟渲染的账摊开。原文在引言里先把它的收益列成三条:一是能用的光源数量多,二是光照与几何复杂度解耦,三是着色器组合(shader combinations)可控。第三条最容易被忽略,但对工程实践很关键:前向渲染里每加一种材质与光照组合,都会多出一份需要编译与维护的着色器变体,而延迟管线把光照挪到屏幕空间统一处理,变体数量就不再随灯数膨胀。
「光照与几何复杂度解耦」这条收益也值得拆开算一遍,因为它解释了延迟管线为什么曾在主机上占优。几何复杂度影响的是顶点处理与光栅化的负担,而光照成本在屏幕空间里正比于「像素数 × 每像素的光源数」;把光照从几何侧解耦之后,一个几何极复杂的场景不会因为三角形变多而让光照计算变贵。反过来说,前向渲染里光照是跟着几何走的,同一个像素可能被多个三角形重复着色,代价随几何与绘制顺序波动。原文把这一条列在收益里,理由就在这里。
收益之后,原文紧接着把代价也列了四条,而且是从参考文献 [Kap10] 引来的、当时已被公认的问题清单:材质种类受限、内存与带宽需求更高、透明物体的处理麻烦、以及缺少硬件抗锯齿支持。这四条里,作者最在意的是第一条。原文对它的论证是:材质多样性对做出真实的着色结果至关重要,而这件事在前向渲染里从来不是问题。也就是说,延迟渲染用「光照统一处理」换来了效率,代价是把每个像素的材质信息压缩进 G-buffer 那几个通道里,材质表达的丰富度随之被固定死了。
那为什么前向渲染当年没有成为多光源的选择?原文给了两条很实在的理由。第一条是固定灯数:前向渲染通常需要设定一个小的固定光源数量,为的是限制着色器排列组合的爆炸,同时还要在 CPU 侧管理光源与物体之间的关系。第二条更硬:在当时的游戏主机上(原文点名 Xbox 360),动态分支(dynamic branching)的开销很贵。多光源的前向着色天然要在像素里循环遍历光源,而这个循环一旦靠动态分支实现,性能就守不住。两条合起来,就解释了为什么延迟渲染会在那个时期特别有吸引力。
转折发生在 GPU 侧。原文说:最新的 GPU 在性能、ALU 算力与灵活性上都有改善,并且具备通用计算能力,这一点与当时的游戏主机形成对比;因此用前向渲染画多光源其实已经是一个现实选项,只不过「在逐像素着色里朴素地遍历每一盏灯」这种做法依然不实用。结论就是 Forward+ 的位置:保留前向渲染的全部优点,同时用裁剪把「遍历所有灯」换成「遍历相关的几盏灯」。
相关工作那一段则把这个位置标得更准。原文提到,前向渲染在着色时能用的灯数有实际限制 [AMHH08];延迟技术克服了这一点,尤其在家用主机上流行起来。它把已有的延迟系做法分成两类来评:一类是导出 G-buffer、在屏幕空间做光照的延迟渲染器,这类做法对内存系统压力很大;另一类是先做光源索引延迟光照(light-indexed deferred lighting)[Tre09],它存的是光源索引而不是累积的光照分量,因此降低了 G-buffer 导出的成本,但代价是最终着色阶段要读更多数据,并且它有几条硬限制,比如每个像素的光源数有一个预设的最大容量。原文给自己的定位是:Forward+ 与这类方法共享思路,但不受那条限制,并且借助现代 GPU 的计算能力进一步降低内存流量。这句话其实就是全文的题眼。
03三阶段管线:深度预通道、光源裁剪、最终着色
方法本身被压缩成一句改造说明:Forward+ 给前向渲染管线只加一个光源裁剪阶段,位置在最终着色之前。整条管线因此分三段:深度预通道(depth prepass)、光源裁剪(light culling)、最终着色(final shading)。原文还点了第二处改动:光源的数据结构必须改成一个线性缓冲(linear buffer),因为裁剪阶段与最终着色阶段都要能从着色器里访问它。
深度预通道这一段的定位值得单独说。原文指出,深度预通道对前向渲染来说是可选项,但对 Forward+ 是必需的,作用是减少最终着色阶段的像素 overdraw,而这一步对 Forward+ 特别贵。为什么特别贵?因为在 Forward+ 里每个可见像素都要遍历自己那一格的光源列表并求值材质,重复着色一次的成本远高于普通的覆盖写入。原文顺手做了一个对照:如果把这个深度预通道和延迟技术里的 G-prepass 比,前向的深度预通道更便宜,因为它只填深度缓冲,不导出全屏的几何信息。
把三段写成伪码,这套结构的顺序与数据流就很清楚了:
阶段 1 深度预通道(depth prepass) 1 只画几何、只写深度缓冲 → 得到每像素最近的深度 2 作用:把最终着色要处理的像素压到最少(这一步对 Forward+ 是必需的) 阶段 2 光源裁剪(light culling,走 GPU 计算) 3 把屏幕切成 tile(本文实验用 8×8) 4 对每个 tile 求出它的视锥(用该 tile 的屏幕空间范围 + 像素深度的最小/最大值) 5 逐个判光源是否与该视锥重叠,重叠的记进该 tile 的光源索引列表 阶段 3 最终着色(final shading) 6 逐像素取出所属 tile 的光源索引列表 7 遍历列表里的每一盏灯,用该像素的材质 + 光照模型求值 8 因为光源的位置、颜色等信息在此处仍然可见,材质与光照模型不受限制
这张伪码里藏着三处设计决定,后面几章会分别展开。第一处在阶段 2 的第 3 步:为什么单位是 tile 而不是像素。第二处在阶段 2 的第 4–5 步:视锥是靠 tile 内的深度最小/最大值定的,这个构造方式后面会带出一个假阳性问题。第三处在阶段 3 的第 8 步:正因为光源信息在着色阶段还活着,材质才不受限,而这正是这篇论文想要的全部。
三阶段里还有一个不显眼但影响实现的约束:光源的数据结构必须换成一个线性缓冲。原文把它作为「另一处改动」单独列出,原因不难推:裁剪阶段要遍历灯做判交,最终着色阶段要遍历灯做求值,两个阶段都在 GPU 上跑,都需要按索引随机访问光源数据。原来的前向管线里,光源是作为常量传给着色器的,数量一固定就没法动态扩展;改成线性缓冲之后,灯的数量与内容都不必在编译期确定,索引列表才能真正发挥作用。这条改动是把「固定灯数」这个老限制拆掉的技术前提。
还有一条容易被跳过的量级说明:原文强调裁剪与最终着色都借 GPU 的计算能力实现,于是整条光照管线都在 GPU 上执行。这句话的实际含义是,Forward+ 不需要 CPU 侧再去管理「哪些灯影响哪些物体」这件事。回看第 2 章里前向渲染的两条旧理由,第一条(固定灯数加 CPU 管理)在这里被整条拆掉了;剩下一条关于动态分支的理由,则由「遍历相关几盏灯」而不是「遍历所有灯」来化解。
04逐 tile 的必然:拿假阳性换内存足迹
光源裁剪这一节里,最值得学的是作者否决自己第一直觉的过程。原文先承认:光源列表可以按每个像素来算,而且对最终着色来说,逐像素的列表是更好的选择——因为它没有假阳性,每个像素只拿到真正影响自己的灯。但它紧接着说,从整条渲染管线的效率看,逐像素并不划算,原因有两处:内存足迹(memory footprint)与裁剪阶段本身的计算效率。
于是设计转向逐 tile。原文的表述是:把屏幕切成 tile,光源索引按 tile 为单位计算。代价被明确写在原文里——分块会给 tile 内的像素引入假阳性,也就是列表里的某些灯其实不影响这个像素,只是它影响了同一格里的别的像素。收益同样被明确写出:这样做大幅降低了内存足迹与计算量。
把这两句话落成数字,取舍的规模会更清楚。假设屏幕是 1280×720、tile 是 8×8,那么全屏有 (1280/8)×(720/8) = 160×90 = 14400 个 tile,每个 tile 覆盖 64 个像素。如果平均每 tile 有 M 盏灯需要记下来,那么这份光源索引缓冲的大小是:
假阳性这件事还有一层结构来源,值得在取舍之前先说清。tile 是屏幕空间的分区,而光源的作用范围是三维的;当一个 tile 同时覆盖很近与很远的几何时,它的视锥会被深度方向拉长,于是落在这一段纵深里、却只影响格子里某一部分像素的灯,也会被算作重叠。分块越大,视锥拉得越长,这类假阳性越多;分块越小,列表越准,索引缓冲的写入量却按 1/T 上升。原文用「权衡」两个字概括这条曲线,并在实验里把 tile 定在 8×8,同时把最优尺寸的问题留给了后续工作。
原文把这份取舍讲成一句很干的话:光源索引缓冲的内存大小与最终着色器的效率之间,是一个权衡(trade-off)。往「更小的缓冲」走,意味着 tile 更大、假阳性更多,最终着色要多做无用的光源求值;往「更准的列表」走,意味着索引缓冲变大、写读更重。这篇论文没有给出最优解,而是选了 8×8 作为实验配置,并在讨论里承认 tile 尺寸本身没有研究透(第 13 章会把这条列成边界)。
这一章还有一个隐含结论值得点出来:逐 tile 这个决定,把 Forward+ 与前向渲染管线的既有结构接上了。前向渲染本来就是逐三角形、按屏幕顺序推进的,而 tile 是屏幕空间的分区;裁剪阶段产出的「每 tile 一份光源列表」,正好能在最终着色时被同格像素复用。原文说整条光照管线因此都能跑在 GPU 上,机制上的落点就在这里——列表是按格生成的,按格消费的,中间不需要 CPU 参与调度。
05最终着色:光源信息还活着,材质就不再受限
如果说裁剪阶段是这套方法的机械部分,那最终着色阶段才是它的目的。原文对这一步的描述是:裁剪阶段给出的是每像素重叠的光源列表,而最终着色遍历这张列表,并用为每盏灯存下的信息来求值材质。原文特意在括号里点明了关键差异——像光源位置与颜色这类信息,在延迟技术里做最终着色时是拿不到的。
这句话为什么重要,要从延迟管线的结构去理解。延迟渲染之所以省,是因为它把「光照」与「材质」拆到两个阶段:先把几何信息压进 G-buffer,再在屏幕空间统一算光照,最后合成。可一旦拆开,最终合成那一端就只剩 G-buffer 里那几通道的材质摘要,光照求值所需要的完整光源信息(位置、朝向、颜色、半径)并不在手上。于是着色只能用预先烘焙进 G-buffer 的近似材质参数,材质表达的丰富度被固定。Forward+ 把两个阶段合回一处:光源信息与材质信息同时在场,求值因此可以按完整的材质与光照模型来做。
原文把这个收益说得很彻底:因为光照累积与着色在同一处同时发生,并且拥有完整的材质与光照信息,所以所有关于像素质量的限制都被去掉了。所谓「限制被去掉」,指的是不再需要为迁就管线结构而简化材质或光照模型。原文给出的实际能力是:使用复杂材质与更准确的光照模型来提升画质,除了 GPU 的计算成本之外不受别的约束。而这笔计算成本本身可以被写成一个三项乘积:
代价也在这条式子里。既然每个可见像素都要做这么一轮三项乘积,那么「多着一次色」就格外昂贵。原文由此给出结论:用这个方法,高像素 overdraw 会直接杀死性能,所以深度预通道是压住最终着色成本的关键。这句话把第 3 章里那句「深度预通道对 Forward+ 是必需的」补完了理由:它不是可选优化,而是这套成本模型能成立的前提。
式 (2) 还解释了这套方法为什么对裁剪精度格外敏感。三项里,Cmaterial 与 Clighting 是作者主动抬高的(换来画质),而 Lpx 却由硬件与场景共同决定,其中还混着分块带来的假阳性。于是同一个场景里,物体轮廓附近那些假阳性偏高的像素,会同时承担最高的材质与光照模型成本;这也是为什么第 10 章要专门看那张每 tile 光源数的可视化,以及为什么原文把 tile 尺寸列为未做的课题。
最后回到论文的定位上。第 2 章说过,延迟渲染当年之所以吸引人,一半原因是主机上动态分支太贵,逼得大家把光照挪到屏幕空间去统一算。Forward+ 的最终着色阶段恰好把这条理由反过来了:它敢在像素里遍历光源,靠的是现代 GPU 的 ALU 算力与通用计算能力;而它敢用复杂材质,靠的是光源信息没有在管线中途被丢掉。两条加起来,就是摘要里那句「去掉延迟技术最主要的缺点」的具体含义。
06裁剪实现一:Gather 单 kernel 与线程本地累加
第四节的标题是「光源裁剪的实现」,开头先给了一条省力的判断:这套管线可以复用既有前向渲染管线里的大部分代码,因为它本来就是在后者上做扩展。真正新增的只有「基于计算的光源裁剪」这一块。原文接着说明,借助当前 GPU 与图形 API 的灵活性,裁剪可以有好几种实现方式,各有优劣;本节给出的两种都把光源列表按 tile 建,理由是逐像素建表虽然可行,但会同时抬高计算时间与内存足迹——这条判断在第 4 章已经算过账。
第一种实现叫 Gather(收集式),原文说它与 [And11] 的做法类似:在单个计算着色器里完成整张列表的构建。它的执行模型是「一个线程组(thread group)负责一个 tile」,流程分四步。第一步算视锥:用该 tile 的屏幕空间范围,加上该 tile 内像素深度的最大值与最小值,构造出这个 tile 对应的视锥体。第二步读灯:整个线程组先用所有线程把一盏灯读进本地寄存器。第三步并行判交:所有线程并行检查这盏灯与 tile 视锥是否重叠,若重叠,则用本地原子操作把该灯累加进线程本地存储(thread local storage,TLS)。第四步回写:等所有与该 tile 重叠的灯都收进 TLS 之后,再用全部线程把它们刷进全局内存。
写成伪码,这段流程的形状很清楚:
Gather 方式(一个线程组 = 一个 tile) 1 由 tile 的屏幕空间范围 + 该 tile 内像素的 min/max 深度 → 构造 tile 视锥 2 对每一盏灯: 3 整组线程把该灯读入本地寄存器 4 所有线程并行检查该灯与 tile 视锥是否重叠 5 若重叠 → 用本地原子操作把该灯累加进线程本地存储(TLS) 6 该 tile 的灯收齐后 → 全部线程把 TLS 的内容刷入全局内存(该 tile 的灯表)
这段实现里有两处细节值得点出来。一处是「读灯」用了整组线程:因为同一盏灯要被组内所有线程判交,把它先放进本地寄存器可以让后续判定不必反复访问全局内存。另一处是累加用了本地原子操作:多个线程可能同时判出同一盏灯重叠,往 TLS 里写就需要互斥;而原子操作的开销随并发写入的碰撞概率上升,这也解释了原文对它的适用性判断。
这条流程里有两个部件必须一起看:线程本地存储与本地原子操作。判交是并行做的,多个线程完全可能在同一轮里判出同一盏灯重叠;若只是往 TLS 里追加,就会出现重复写入与计数错乱。用本地原子操作把「判出重叠就累加」这一步串起来,得到的是去重且完整的列表,代价是原子操作自身的开销——它会随并发写入的碰撞概率上升。而 TLS 的容量也有限:一个线程组能缓存多少灯,受本地存储大小约束,这决定了单个 tile 的灯表在多大时还能在刷新前不溢出。这两个约束合起来,就是原文说 Gather「灯数不是太多时简单有效」的技术背景。
原文给 Gather 的结论是:如果光源数量不是太多,这个方法简单而有效。这句话的背面就是它的瓶颈——当光源数量极大时,原文建议考虑用另一种方式。这里的「大」可以从流程里读出来:Gather 让每个线程组把所有灯都过一遍,才筛出与本地 tile 重叠的那几盏。灯越多,第 2–5 步的循环就越长,而其中绝大多数判定都会以「不重叠」收场;同时,TLS 的容量与本地原子的碰撞概率也都会随灯数变紧。于是第二种实现的存在理由,就是把这个「逐灯全扫」的结构换掉。
07裁剪实现二:Scatter 逐灯写索引,再靠排序归拢
第二种实现叫 Scatter(散射式),原文说它分若干步建表。第一步与 Gather 的思路恰好相反:不是逐 tile 扫所有灯,而是每个线程负责一盏灯,算出这盏灯覆盖了哪些 tile,然后把「灯索引 + tile 索引」这组数据写进一个缓冲。原文对它的数据形态有一句关键说明:此时这份缓冲是按光源索引排列的,而我们需要的是「每个 tile 一份灯索引列表」,所以它必须按 tile 索引重新排序。
排序用什么办法,原文给了明确的答案:用基数排序(radix sort)。排完之后,再跑若干 kernel 去求出每个 tile 在这份缓冲里的起始与结束偏移(start / end offsets)——有了偏移,每个 tile 的灯列表就等价于「全局缓冲里的一段连续区间」,最终着色时按区间读即可,不必为每个 tile 单独维护一张表。
同样写成伪码:
Scatter 方式(一个线程 = 一盏灯) 1 对该灯:算出它覆盖了哪些 tile 2 把(灯索引, tile 索引)成对写入一个缓冲(此时按灯索引排列) 3 用基数排序把这批数据按 tile 索引重排 4 跑 kernel 求出每个 tile 在该缓冲中的起始 / 结束偏移 5 结果:每个 tile 的灯列表 = 全局缓冲里的一段连续区间
两种实现的差异可以归结成一句:Gather 把代价花在「逐 tile 扫全部灯」的重复判定上,Scatter 把代价花在「按 tile 排序」上。前者结构简单、单 kernel 完成,代价随灯数上升;后者多步、需要排序,但每个线程只处理一盏灯,因而更适合灯数极多的场景。原文对这条分界的表述很克制,只说了「灯数不是太多时 Gather 简单有效」「灯数极大时可以改用 Scatter」,没有给出灯数的阈值。
「按 tile 索引重新排序」这一步为什么绕不过去,值得说清。写索引时是一个线程管一盏灯,灯在空间里是散开的,因此同一个 tile 会从很多盏灯的线程那里收到条目;这些条目写进缓冲的先后顺序由灯决定,于是缓冲里同一 tile 的数据是散的。而消费端要的恰恰相反——最终着色处理一个像素时,需要快速拿到「我这个 tile 的全部灯」,也就是一段连续可读的数据。基数排序在这里承担的就是这次重排:它把一个按灯排好序的缓冲,换成按 tile 排好序的缓冲。排完之后求出的起始与结束偏移,等价于为每个 tile 建了一张只有两个字段的清单;有了这张清单,逐 tile 的灯表就不再需要单独维护,直接读全局缓冲的一段区间即可。这比 Gather 那种「每个 tile 各自刷一份列表」的做法更省内存,代价是多了一次排序。
两种实现的分工可以用一张小对照表收住,表里每一项都能在原文里找到出处:
| 维度 | Gather | Scatter |
|---|---|---|
| 并行单位 | 一个线程组负责一个 tile | 一个线程负责一盏灯 |
| 执行结构 | 单个计算着色器内完成 | 多步:写索引 → 基数排序 → 求偏移 |
| 主要代价 | 逐 tile 扫过全部灯做判交;本地原子累加的碰撞 | 按 tile 索引做基数排序 |
| 适用场景 | 光源数量不是太多 | 光源数量极大 |
这张表也解释了实验里为什么选 Gather。原文给的理由是:光源裁剪与延迟渲染里的光照累积很相似,所以选 Gather。这是一条工程性理由——既然两者在结构上都属于「对每个屏幕区域求出影响它的灯」,那么把 Forward+ 的裁剪阶段与对照实现的累积阶段放在同一口径下比较,才谈得上公平。
回头把第 4 到第 7 章连起来看,能看出一条一致的设计取向:这套方法里所有「重」的东西都被挪到了管线中段的一步计算里——分块、判交、建表、排序,全在着色之前完成;而最终的着色阶段只做一件事,就是照着已经裁好的列表逐灯求值。这也解释了为什么它能复用前向管线的代码:真正新增的复杂度被集中在一个可以独立实现的阶段里,管线的其余部分(深度预通道、最终着色)在结构上与普通前向渲染几乎一样。
08内存流量模型:把 Table 1 逐格读一遍
第五节的标题是「结果与讨论」,而它的第一件事不是跑分,是把内存流量写成公式。原文的论证顺序是:先承认「天下没有免费的午餐」,再问「为了更好的像素质量,我们愿意付什么代价」,然后用一张理论流量表回答。为了做这个比较,作者实现了一个基于计算、不写 G-buffer 的延迟光照管线作为对手 [And11]。原文解释了为什么挑它:这个实现只用一个计算着色器读写一次帧缓冲信息,因此在内存带宽上有优势,而带宽本来是标准延迟技术的短板;也就是说,它在延迟系里「理论上是内存带宽最好的」。拿最强的对手来比,是这张表可信度的前提。
比较按管线的三个阶段展开,定义为 (A) 预通道、(B) 光源处理、(C) 最终着色。先把原文的表 1 原样引出:
为了便于对照复算,把表里那四行有数的格子按读/写重排一遍(数据完全取自原文表 1,符号含义同上):
| 方法 | 读写 | (A) 预通道 | (B) 光源处理 | (C) 最终着色 |
|---|---|---|---|---|
| Forward+ | 读 | 0 | N × F | M × N × F × (L + 1) |
| 写 | 0 | M × N / T × F | 0 | |
| Deferred | 读 | 0 | N × F × 4 | N × F × 4 |
| 写 | N × F × 4 | N × F × 4 | 0 | |
| 合计(原文 Total 列) | Forward+:N × F + M × N × F × (1/T + 1 + L) | Deferred:N × F × 16 | ||
表里最值得盯的不是那些公式,而是两种方法对 M 的依赖方式。Deferred 的四格全是常数:写 4 个 float(法向量)、读 4 + 4、写 4,与场景里有多少灯完全无关,这是它把光照挪到屏幕空间统一处理的直接结果。Forward+ 的格子则几乎处处带 M:最终着色读的是 M × (L + 1) 个 float,光源处理写的是 M/T 个 float。两种依赖方式决定了各自的命运:Deferred 的成本在灯数增长时保持平稳,代价是材质与光照模型被 G-buffer 的通道数锁住;Forward+ 的成本随灯数线性上升,换来的是一套不受通道数约束的着色。第 9 章那个交叉点,本质上就是在问这条上升的直线什么时候会越过那条水平线。
逐格读下来,两种方法的性格差别很清楚。Deferred 的流量是与光源数无关的常数:读 4 + 4 个 float,写 4 + 4 个 float,合计每像素 16 个 float,全部压在屏幕空间的缓冲上。Forward+ 则相反:它在 (A) 什么都不读不写(对应原文「不含深度缓冲写入」的口径),在 (B) 只读一遍深度、写一份逐 tile 的灯索引(这份写入被 T 除过,摊到每像素就小了),而把大头放在 (C)——最终着色要为每个像素读出该 tile 的 M 个灯索引,以及这 M 盏灯各自 L 个 float 的数据,合计 M × (L + 1) 个 float。
把两种合计放在一起,也能验证 Table 1 自身的自洽性。Forward+ 的合计 = 读(1 + M(L+1)) + 写(M/T) = 1 + M(L + 1 + 1/T) 个 float,与表中 Total 列的 N·F + M·N·F·(1/T + 1 + L) 完全一致;Deferred 的合计 = 读(4 + 4) + 写(4 + 4) = 16 个 float,与表中的 N·F×16 一致。也就是说,Total 列是读与写两侧之和,这一点在原文图注里也写明了(Total 是 (A)(B)(C) 三阶段之和)。第 9 章要推的那个交叉点,就是拿这两个合计相减得来的。
09推交叉点:那张表能不能解出 M 的界
有了两侧的合计,原文紧接着做了一步减法,用来回答「什么条件下 Forward+ 的内存流量更省」。它把 Deferred 的合计减去 Forward+ 的合计,记作 Total_diff:
接下来原文说「对 M 解 Total_diff > 0」,并给出结果。这里出现了本篇要标出的一处待核:原文印出的条件与它自己上一步的式子并不自洽。
这处差别不是纸面上的小分歧,因为它会直接翻转结论。原文的叙述是:M 若是小于 15 × 9 ≈ 135,则 Forward+ 的内存流量更少;而它实验场景的可视化里,灯最密的 tile 被截到 50 盏,因此按原文的条件,Forward+ 应当总是更省。可若拿 Table 1 自身的算式直接代入 M = 25、T = 64、L = 8,得到的却是另一幅图景:
还要把另一件事分清楚,免得把理论分歧误当成实验结论。原文的实测(第 11、12 章)确实报告 Forward+ 总耗时更快,且显存降频后差距进一步拉开;那是时间上的结果,与这条流量上的交叉点条件是两件事。原文的结论句也是分开写的:先给理论判断「Forward+ 在内存流量上的需求比基于计算的延迟光照更少」,再给实验判断「实验表明 Forward+ 胜过内存最省的延迟光照技术」。本篇的处置是:理论那一步记为待核,实测那两条如实呈现,两者都不替对方圆场。
最后再补一条模型层面的观察,属本篇读法、非原文主张。表 1 把 Forward+ 在最终着色阶段读光源数据这一项,按「每个像素都读 M 盏灯的 L 个 float」计入流量,这是把访问按面值计价的最坏情形;而在真实硬件上,同一 tile 内的线程读的是同一批条目,这类访问有机会被缓存或广播吸收。若这部分确实被吸收,Forward+ 的实际流量会低于表中算式,这也可能是它的实测优于理论的一个方向。这条只是指出模型的口径,不足以取消上面那条待核——待核指的是原文自己两步之间不自洽,与缓存假设无关。
10实测一:3072 盏灯,与每 tile 光源数可视化
理论算完,原文开始做实验。为了验证前面的分析,作者搭了一个把 3072 盏灯散布在空间里的演示场景,用它来比较两种方法的性能,并且明确交代了实验配置:tile 尺寸取 8×8。图 2 就是这个场景的两种呈现,(a) 是渲染结果,(b) 是每 tile 光源数的可视化。
实验配置本身也说明了作者想验的是什么。3072 这个数量远超当时任何前向管线能承受的固定灯数,用它做场景,等于把「前向渲染能不能处理海量动态灯」这个问题直接顶到第 2 章那句「朴素地遍历每一盏灯并不实用」的对立面;而 8×8 这个 tile 尺寸,则把第 4 章那条取舍固定在一个便于与延迟侧对齐的位置上。原文没有给出选择 8×8 的理由,只把它作为实验条件陈述,这一点后面会列入边界。
图 2(b) 除了证明「灯密的地方确实密」,还顺带暴露了一个问题,而原文没有放过它。作者注意到:与物体边缘重叠的那些 tile,灯数偏多。原因被写在原文里——这类 tile 的视锥在深度方向上往往拉得很长,于是判定时会把「与该纵深范围内整段视锥重叠」的灯都报进来。换句话说,视锥是用 tile 内深度最小/最大值构造的,只要格子里同时存在很近和很远的深度,这个视锥就会把中间那一大段空间一并圈进来,落在里面的灯全都算作重叠,尽管它们其实只影响格子里的一部分像素。
原文对这条给出了两步回应,一步是解法,一步是放弃。解法是:要改善这个问题,得检测每个视锥里的空空间(empty space),把没有几何的那部分切掉。放弃的理由是复杂度不划算——原文说,一个 tile 里可能有任意多层深度(there can be any number of depth layers in a tile),因此复杂的空空间检测「不划算」(does not pay off)。它还补了一个替代方向:把二维细分换成空间细分(spatial subdivision)也许能解决,但那需要更多内存与 ALU 操作。这一小段其实是全篇最像工程笔记的地方:它承认了假阳性在 tile 边界处的集中来源,也承认了当时没有便宜的修法。
把这个现象和第 4 章那条取舍放在一起,Forward+ 的成本模型就完整了。它的最终着色成本由式 (2) 三项相乘决定,而 L_px(打到像素上的重叠光源数)并不完全由「真实几何关系」决定,还有一部分来自分块本身带来的假阳性,且这部分假阳性在物体轮廓附近最容易堆高。这也解释了为什么原文要在讨论里专门提这一段,以及为什么它把「tile 尺寸如何影响性能」列为未做的工作——第 13 章会把这两条一起收进边界清单。
11实测二:HD 6970 上的三阶段分工,与一次显存降频
第二组实验在独显上做,用的是 AMD Radeon HD 6970。原文先把计时口径交代清楚,这一点很关键:Forward+(H) 与 Deferred(H) 是同一块卡上的渲染时间,测量的是表 1 里那三个阶段(预通道、光源处理、最终着色)各自的计算时间,测量位置在 CPU 侧,因此包含了 kernel 派发(dispatch)成本与 GPU 管线刷新(flush)的开销。原文自己承认「测到的不是纯 GPU 处理时间」,但也给了公平性理由:两项测试的条件完全相同,所以比较仍然成立。这段话值得单独记下来——它是理解图 3 那组读数边界的前提。
结果的分工与两边的结构完全对得上。预通道阶段 Forward+ 更省,因为它只写深度缓冲,而 Deferred 要在这个阶段写下法向量(表 1 里 Deferred 在 (A) 有 N×F×4 的写入,Forward+ 是 0)。光源处理阶段 Forward+ 更省,因为它写出的是一份被 T 除过的逐 tile 索引,而 Deferred 在这个阶段要读深度与法向量、再写出光照累积。最终着色阶段反过来是 Deferred 更省,因为它只从光照累积缓冲读固定量的数据,而 Forward+ 要为每个像素遍历该 tile 的灯列表、读索引与光源数据。三段各自的方向都能从表 1 的格子里读出来,这也是原文说「结果确认了理论分析」的具体所指。
对实现者来说,这组读数的实用价值在于它指出了「该把力气花在哪一段」。既然 Forward+ 在预通道与光源处理上更好、在最终着色上更差,那么优化方向就很具体:预通道还能再压(减少写入、提前剔除),光源处理还能再快(裁剪实现从 Gather 换到 Scatter),而最终着色要控的是每个像素的灯表长度,也就是第 4 章那条取舍里「列表准不准」的一端。原文没有把这三条写成建议,但三阶段分工的方向本身就是一张优化地图。
更值得注意的是第二组条件。作者做了把 GPU 显存时钟调低的实验,用来模拟一块内存带宽更窄的 GPU;原文对结果的描述是:在 Forward+(L) 与 Deferred(L) 的对比里,两者的差距拉开了,原因是 Deferred 需要更多的内存访问。这条测试的用意很明确——它把「内存带宽」这个变量单独抽出来放大,看两种方法谁更吃亏。带宽变窄时延迟侧掉得更快,正说明它更依赖那块资源。
原文接着给了一段关于硬件趋势的判断,撑起了这套方法的未来位置:以后 GPU 的「ALU 算力 / 内存带宽」比值可能继续抬高,因为加更多 ALU 单元比提高内存带宽更容易。这句话把 Forward+ 的性格说透了:它是一个拿算力换带宽的方案,所以在「算力相对更富余」的硬件上会更有优势。原文用这次降频实验作为这条判断的支撑,措辞是「这个实验表明 Forward+ 在那样的规格上是有前景的」。
把第 8 到第 11 章连起来,这篇论文的论证结构就完整了:先用表 1 把两种方法的内存流量算成公式,再用独显实测确认三阶段的分工方向,然后用降频实验把带宽变量单独放大验证,最后把结论接到硬件趋势上。这条链里唯一没走通的一步,是第 9 章标出的那个交叉点条件;而它并不影响三阶段分工与降频对照这两条读数的方向——那两条是从图 3 直接读出来的。
12实测三:集显 A8-3510MX,与分块架构的天然契合
第三组实验换了平台,落在 AMD A8-3510MX 上,原文交代它内含一块集成的 Radeon HD 6620G GPU。这组测试的用意不是再证一遍结论,而是换一个约束更紧的硬件去检验同一条分析:原文说,图 4 的结果在集显上确认了对 Forward+ 的理论分析,并且点明了这块集显与独显的差别——内存带宽更受限,SIMD 数量也更少。
这一组实验还牵出了原文对移动平台的一段判断,是本篇里少见地把话题往外延伸的地方。原文说:有一些移动或集成 GPU 采用分块渲染(tile-based rendering);而 Forward+ 用的是逐 tile 的光源裁剪,与这种渲染架构契合得很好,原因是它可以降低最终着色阶段读取光源列表的成本。这句话值得和前面几章对读:Forward+ 的灯表本身就是按 tile 组织的,而在分块渲染的硬件上,绘制本来也是按 tile 推进的,于是「按格生成、按格消费」的节奏天然对齐。
把这条和 Forward+ 的成本结构对照,能看出它的一个隐含优势。第 5 章的式 (2) 里,最终着色的成本正比于 L_px(打到像素上的重叠光源数)。在分块渲染里,同一个 tile 的像素在同一时刻被处理,它们共享同一份灯表;灯的索引与数据被读进来之后,可以在处理这一格的过程中为多像素复用。原文把这条写成「降低最终着色读取光源列表的成本」,落点就在这里。也正因为如此,它在集显上的表现格外值得记录:这类平台带宽最紧,而它能靠「按格复用」把最贵的那一环压下来。
把集显这组读数放回带宽这条主线上,它的意义更清楚。集显与独显的差别不只在算力规模,还在于它和系统内存共享同一条通道,原文点出的「内存带宽更受限」指的正是这件事。在这类平台上跑延迟管线,那 16 个 float 的逐像素缓冲流量是绕不开的固定支出;而 Forward+ 的支出结构不同,它把大头压在最终着色的灯表读取上,而灯表是按 tile 组织的、可以在同一格内被多个像素复用。也就是说,带宽越紧、按格复用的收益越明显,这与它在集显上仍然成立是同一件事的两面。
三组实验合起来看,这篇论文的验证覆盖了三个层次的硬件:独显默认频率、独显降显存频率、以及带宽与算力都更紧的集显。原文没有给出这些平台的绝对帧率,也没有给出与其他延迟变体的横向比较;它给的是同平台内两种方法的相对关系,以及三阶段的分工方向。这个口径在第 13 章会被列成一条边界,因为它决定了这些读数该怎么用。
13边界与反例:这篇的账哪里没算
这篇论文在写作上相当克制,把未做的部分与结论分得很开。下面六条里,前三条来自原文自陈的限制与讨论,后三条是从它的口径与结构中读出来的。
边界一:交叉点条件与它自己的算式不自洽
第 9 章已经把这处推演摊开了:原文由 Total_diff = N × F × (15 − M × (1/T + 1 + L)) 出发,本该解出 M < 15 ÷ (1/T + 1 + L),但印出的条件是 M < 15 × (1/T + 1 + L),两者互为倒数。按本文实验配置(T = 64、L = 8)代入,前者的界约为 1.66 盏/tile,后者约为 135 盏/tile。更直接的检验是用表 1 自身的算式算 M = 25 那一格:Forward+ 约 906 B/像素,Deferred 为 64 B/像素,方向与「M < 135 时 Forward+ 更省」相反。本篇的处置是把它记为待核,不代为圆场,同时也不据此否定实测结论——那是两件事。
边界二:理论模型把光源读取按面值计价,不计缓存
表 1 在最终着色阶段给 Forward+ 记的是「每个像素读 M 盏灯、每盏 L 个 float」。这是把访问按面值计入流量的最坏口径,没有考虑同一 tile 内多线程读同一批条目时的缓存与广播效应。原文把这条口径写在图注里(哪些流量计入、哪些不计入),但没有讨论缓存命中会把实际流量压到什么程度。这条属本篇读法:它提示理论流量与实测耗时之间可能存在的落差方向,但不构成对表 1 算式的否定。
边界三:tile 边界处的假阳性是结构性的,且当时没有便宜解法
原文在图 2(b) 之后主动讨论了这个问题:与物体边缘重叠的 tile 灯数偏多,原因是这类 tile 的视锥在深度方向拉得很长,凡是落在整段纵深里的灯都会被报成重叠。原文给的两条出路都不便宜——检测视锥内的空空间,因为一个 tile 里可能有任意多层深度而「不划算」;改用空间细分,则需要更多内存与 ALU 操作。也就是说,这个假阳性来源被识别了,但在本文里没有解决。
边界四:tile 尺寸没有研究,8×8 只是实验配置
原文在讨论里明确写了一句「本文没有考察 tile 尺寸如何影响性能」。它同时说明这件事取决于多种因素:场景的几何复杂度、光源数量、以及光源的最大影响距离。原文把「为场景自动寻找最佳 tile 尺寸」列为未来可能探索的课题。对读者来说,这条边界的含义是:本文所有读数都绑定在 8×8 这一配置上,不能把它的三阶段比例当作与 tile 尺寸无关的结论。
边界五:计时在 CPU 侧测得,含派发与刷新开销
原文对计时口径很坦白:测的是三个阶段的计算时间,测量包含 kernel 派发与 GPU 管线刷新开销,因此不是纯 GPU 处理时间;公平性理由是两组测试条件相同。这条口径有两个后果:一是图 3、图 4 的绝对值不宜当作 GPU 的纯耗时引用;二是两种方法在「派发与刷新次数」上并不相同(Forward+ 与 Deferred 的阶段结构不同),所以这部分开销对两边的权重未必相等。原文没有对这部分的构成做进一步拆分。
边界六:场景是合成的,理论结论强于实测覆盖
实验场景是一个把 3072 盏灯散布在空间里的演示关卡,不是真实游戏中的一帧;硬件覆盖了三块 AMD 平台(两块独显条件 + 一块集显),但没有与其他延迟变体做横向比较。因此原文那句「理论上没有哪个延迟变体能在这项指标上赢过 Forward+」,其强度来自表 1 的推演(只与内存最省的延迟变体比较),而不是来自实测;实测支撑的是「在本平台内、本场景下,Forward+ 合计更快」。
把这六条和正文对照,会发现它们并不削弱这篇论文的核心贡献。它提出的结构(前向管线 + 一道逐 tile 光源裁剪 + 最终着色遍历灯表)是自洽且可实现的,材质不受限这一条也由结构本身保证;真正的争议点集中在「内存流量谁更省」这个定量结论上,以及它背后的那处代数步骤。把这两件事分开对待,是读这类四页短文时最实用的纪律。
14收束:这篇四页短文留下了什么
结论一节写得很短,逐句拆开只有几层意思。第一层是方法定义:作者给出了 Forward+,一种在传统前向渲染管线上加一个基于 GPU 计算的光源裁剪阶段来处理多光源的渲染方法。第二层是它的收益:它把使用者从延迟技术的限制里解放出来,并且因为把前向与延迟两种方法的优点接在一起,从而能取得更好的像素质量。第三层是理论判断:Forward+ 理论上比基于计算的延迟光照需要更少的内存流量。第四层是实验判断:实验表明它胜过内存最省的延迟光照技术。四句话的顺序也说明了这篇论文的自我定位——先立结构,再谈收益,最后才谈数据。
未来工作那一段只有一句话,但方向很清楚:因为最终着色阶段拥有完整的材质与光照信息,自由度很大,所以可探索的方向很多;作者举的例子是对场景里所有光源用局部短距射线投射(local short ray casting)计算阴影。这个例子恰好落在 Forward+ 最擅长的位置上——光源信息在着色时还活着,因此「从每一盏参与该像素的灯出发短距投一条阴影射线」不需要额外的数据搬运,成本只体现在算力上;而算力正是这套方法愿意花的那一侧。
把全文的骨架再压一遍,这篇论文其实交给了读者三样东西。第一样是一个结构:三阶段管线,其中新增的只有中间那一段,且它与既有前向管线可共用大部分代码。第二样是两种实现:Gather 用单 kernel 加线程本地累加,适合灯数不太多的场合;Scatter 用逐灯写索引加基数排序,适合灯数极大的场合。第三样是一套符号与一张表:N、F、M、T、L 五个量,以及它们在三个阶段上的读写分布,让「内存流量」这件事变成可以逐格核算的对象。
最后回到这篇精读的起点。第 1 章说过,这篇短文的论证链很干净:先立结构,再把流量写成公式,再解交叉点,最后实测。走到第 14 章,这条链里值得带走的东西已经清楚了——结构是可靠的,三阶段分工的实测方向是可读的,而那个交叉点条件是一处待核。对一份要拿来指导实现的资料来说,这种「知道哪一句能直接用、哪一句要自己再推一遍」的读法,比记住结论更有用。
如果要给这份材料定一个使用建议,可以按三层来取。第一层是结构,可以直接用:三阶段管线、前向管线复用、灯存线性缓冲,这套骨架本身不依赖本文的实验条件。第二层是两种裁剪实现,可以按灯数规模二选一:Gather 简单、单 kernel,Scatter 多步、可扩展,原文给的分界是「灯数不是太多」与「灯数极大」,具体阈值需要按平台实测确定。第三层是那张内存流量表,用的时候要带上本文的口径(不含公共流量、按面值计光源读取),并且对第 9 章那步交叉点推演自己再算一遍——本篇已经把两个版本都写出来了,照着式 (3) 解一次即可。
顺带把这篇用到的术语收一次尾,方便回查:深度预通道(depth prepass,只写深度的前置一遍,对 Forward+ 是必需项)、光源裁剪(light culling,产出与 tile 重叠的灯索引列表)、tile(屏幕空间的分块,本文实验取 8×8)、灯索引缓冲(light-index buffer,逐 tile 存索引,其大小按 M×N/T×F 估)、最终着色(final shading,遍历灯表并按完整材质与光照模型求值)、以及两种裁剪实现的 Gather 与 Scatter。第 8 章那五个量(N、F、M、T、L)在原文里只在表 1 的图注中出现一次,本篇把它们逐一落到了具体数值上。
- 把这篇四页短文的论证链拆成三段可分开检验的部分:结构(三阶段管线)、公式(表 1 的读写分布)、实测(三块硬件)。三段的可靠度并不相同,本篇逐段标出。
- 把两处关键取舍做成可复算的数:灯索引缓冲 M×N/T×F 在 1280×720、T=64、M=25 下约 1.44 MB,改逐像素存则膨胀 64 倍;以及式 (2) 的三项成本乘积。
- 独立复算了原文的交叉点推演,发现那一步与它的 Total_diff 互为倒数(本篇式解出 M < 1.66,原文印出约 135),并用表 1 自身的算式在 M = 25 处做了校验(Forward+ 约 906 B/像素 vs Deferred 64 B/像素);如实记为待核,两条实测结论照旧呈现。
- 把表 1 逐格重排成一张可对照的参数表(方法 × 读写 × 三阶段),并点出两种方法的本质差别:Deferred 的流量是与灯数无关的常数,Forward+ 的流量随 M 线性上升。
- 六条边界单列成章:交叉点存疑、模型不计缓存、tile 边界假阳性无便宜解法、tile 尺寸未研究、计时在 CPU 侧含派发与刷新开销、场景为合成 demo 且无横向比较。