11 一个会动的灯泡,凭什么逼出更多层
解读层 · 痛点开场
如果你想盯着一帧画面找问题,先看那盏会移动的灯,或者任何一个顶着画面边界来回摆头的物件。你会在它身后看到一团灰蒙蒙、会抖的噪点,像拖在身后的一截尾巴。把这帧暂停,噪声还在;让它继续走,尾巴跟着物件一起走。总之,这条尾巴根本不打算靠加多几个采样就能消掉,它与"算得够不够多"没太大关系。
为了说清为什么消不掉,我们得先把"这一帧是怎么攒出来的"讲一遍。实时渲染里,为了在几毫秒内交出一张图,每个像素通常只投一两条主光线。就这么点预算,直接做路径追踪会糊成一团噪声。于是业界普遍采用一招:把前面几帧里已经算出来的结果攒下来,跟当前帧的采样混在一起用。这在图形学里叫时域复用(temporal reuse)。像素不用每帧都从头重算,它背着一个"历史账本",把上一帧的好采样带进当前帧,噪声就摊薄了。
这套思路在今天的实时管线里几乎无处不在,抗锯齿、超分辨率、去噪器,全都依赖它。它有一个共同的动作:把当前像素通过运动向量映到上一帧的某个像素,去取对方攒的历史。上一帧和这一帧画面大致连续,这套搬运就成立。
可问题是,画面并不总是连续的。当某样东西挡到前面,或者一个物件移开,一小块原本被挡住的表面从镜头里重新露出来。这一小块表面在上一帧是"看不见"的,它没有留下任何历史账本。你一截取,取到的是空的,或者取到的是挡在它前面的那个东西的数据,于是这块区域的光照估算只能退回去从头起步,用极少的样本硬算。
回到那盏灯。灯每动一下,它身后就新辟出一小条战场,那里全是刚露面的新表面。这些新表面没有历史,只能靠零星的新采样撑着,于是它们比画面其它地方更吵。这就是那条尾巴。它跟运动快慢有关,跟灯从哪儿来有关,独独跟你想不想静下来多算一会无关。你把它暂停成单帧,噪声依旧,因为这一帧里这些表面本来就没有多少可用信息。
ReSTIR 家族(Reservoir-based Spatiotemporal Importance Resampling)是这条道上一套很出名的工具。它把每一个像素维护成一个"抽签罐",罐里放着"当前最值得相信的那个样本",再加上空间邻域和时间上几帧攒下来的样本,一起重采样,用极低的每像素采样数换来接近干净的画面。它比普通路径追踪好太多,可它的咽喉就卡在刚才那个问题上:一旦表面被遮挡,它的罐子在整个被遮期间都不能把历史带过去。挡板一抬,它背后的采样一个不剩,全都从头再来。
如果你要的是这个问题的第一张实景照,看图 1。它把路径追踪、单层喷管的老方案、和这篇论文的方法放在同一个 Carousel 场景里,这个场景的特产就是"大量物体动来动去、频繁产生遮挡"。
图 1 的读法要抓住两点。一是看右上角那组小图,那里是同一处细节的 3 乘 4 网格放大,逐格比的是"谁在 disocclusion 附近更稳"。老方案那几格里,运动物体边缘附近布满了颗粒状的噪声,像被撒了一把灰沙;新方法这格明显干净。二是看栏顶那行时间戳:路径追踪要 25.8ms(还只有 5 个样本),老的喷管方案 15.5ms,本文 22.5ms。别急着说"它比老的慢",记住对面那个噪声对比,我们后面第 9 章会在同样预算下重新排队,那里会看到,一旦老的也被允许花同样的钱,它还是补不上 disocclusion 这块洞。图 1 真正想让你记住的,是那句"更一致的噪声水平":老方案在无遮挡处干净、在遮挡处突然吵,画面不能看;本文把噪声拉平了,全图一个脾性。
过去已经有人往前推了一步,用"喷管喷射"的办法处理时域复用,把上一帧的主命中样本直接喷到当前帧对应的位置,保证每个旧样本都有一个说话的机会,不轻易把重要的历史白白丢掉。这在处理高频细节时很有效,但当样本被挡、或者上一帧根本看不见的表面重新出现时,仍然会因为缺历史而变得更吵。
这篇论文想解决的,就是这件事的最后一公里。它换了一个思路:与其在"这一帧到底要不要信上一帧"上反复猜,不如直接把那些将被遮挡的样本换个地方存起来。它给每个像素多准备了几层"深抽屉",第 1 层的下方还有第 2 层、第 3 层。一层放挡在前面、当前看得见的表面,深几层就放那些被挡在后面、当前看不见但可能马上又要露出来的表面。既然这些深抽屉里本来就留着它们的账本,等它们重新露脸时,账本直接拿出来用,尾巴就不该有了。
这篇精读要讲清楚的就是这套三层抽屉的完整机关:抽屉怎么定义、怎么把旧样本准确放回正确的抽屉、以及最后怎么只养真正用得上的那几层,别把一整个屏幕的深抽屉都从第一帧养到最后一帧。
22 一句话洞见:被挡掉的阴影,先存起来等它再亮
解读层 · 一句话洞见
先给你整篇最值钱的一句话:别把被挡掉的样本扔掉,把它存进更深的层里,等它重新露脸再复用。
这句话里的"被挡掉",正是困扰时域复用十几年的那个点。你只要把"存起来等它再亮"这九个字想通了,后面所有公式、深度范围、激活域,都只是为了让这句话在几毫秒的预算里跑得起来而精打细算的工程。
先给一个数字上的体感,后面第 9 章会逐项自己复算。论文的方法在四个场景上换了整套对比,只让它自己多花一点点时间,就把 disocclusion 区域的噪声压下去一大截。在一帧 1920×1080 的画面里,真正"被遮挡后又露出来"的像素可能只占很小一部分,但这部分恰恰是眼睛盯得最紧的地方。表 1 里有一行数据能体现这套方案的两个要点:一个叫"覆盖率的代价",一个叫"射线的削减"。
先说覆盖率。论文把"均匀摊开、全屏幕所有层都养着"作为对照组,自己的"选择性激活"只维护其中很少的域,可仍然覆盖了几乎全部真正需要照顾的 disoccluded 像素。以一帧 Carousel 为例,均匀方案要养 732 万个深度域,选择性激活只养 191 万个,差距接近四倍,却仍盖住了 88% 的 disoccluded 像素。换句话说,深抽屉不是开给全屏幕的,只开给那些"马上可能重新露脸"的位置,就够用了。
把"盖住多少"和"花多少域"放在一起看,才能读出这句话的分量。同一个 Carousel 帧里,真正需要照顾的 disoccluded 像素,均匀方案盖住了 24 万个,选择性激活盖了 21.8 万个,两者都几乎把该盖的都盖了;可为了盖这一片,均匀方案撒下去了 732 万个域,选择性激活只用了 191 万个。域是逐域的存储和逐域的追光,域少四倍,意味着存储省了约 74%,splatting 阶段的射线也少了一大截。换句话说,这篇论文没有掏东墙补西墙——省下来的每一分都落在刀刃上,覆盖掉的洞一个不少。
再说射线。给一个移位样本定层,通常要逐段追光线数它到底穿过几层表面。论文用深度范围把这活儿换成两次可见性判定,省掉了大量追光。这部分在表 1 里会看到射线数量往下掉,具体数据留到第 9 章排开。
于是整篇的可验证主线是这样一条链:问题(时域复用断在 disocclusion)→ 方案(多层抽屉接住被挡样本)→ 两个效率开关(深度范围省射线、激活域省域数)→ 证据(等时对比、消融、去噪集成)。下面的章节就沿着这条链把每一步拆开揉碎,公式自己重推,数据自己复算。
33 时域复用的家谱:从打帧缓存到喷管映射
把视野拉远一点,看看"复用上一帧"这个念头是怎么一步步走到今天的。这不是跑题,因为这篇论文的每一步都要站在前面每一招的肩膀上,你不把这套家谱理清,就看不出它替掉了什么、留下了什么。
时间回到更早的实时渲染。今天的游戏画面,几乎没有谁是完全每帧从零开始算的。谁来抗锯齿?时域抗锯齿(TAA)把历史帧的颜色和当前帧混合,边缘就不再每帧抖。谁来提分辨率?DLSS、FSR 这些超分辨率方案,把低分辨率输入往高分辨率重建,靠的也是把时间上攒下来的信息搬上来。谁来压蒙特卡洛噪声?实时去噪器(如 NRD)把历史样本和当前样本做时空滤波。这些工具共同的一个动作,就是用运动向量把当前像素映射到上一帧的对应像素,去那里取历史。
这套映射的前提,上一章已经点过,是"画面大致连续"。它一旦在遮挡处断裂,历史就取不到了。所以这个家谱里出现了一个关键的岔路口:能不能不靠"把当前帧的像素找上一帧的像素"这种回溯式搬运,而是反过来,把上一帧的样本"推"到当前帧?
ReSTIR 把每像素变成一个抽签罐,用重采样在时空间反复淘金。它先解决的是直接光照,后来被推广到全局光照,是目前实时路径追踪领域最响亮的名字之一。到了 Area ReSTIR,作者们又把积分域从"像素中心的一个点"扩大成"完整像素脚印",让我们能直接估计一个像素滤波后的颜色,还顺带把抗锯齿做了。它靠的是跟随分数运动向量,把像素反投影回上一帧的某个偏离采样网格的位置,再在那个区域里重采样。
Area ReSTIR 的车辙里有一块反复硌脚的石头:当子像素细节对应着多条运动向量时,回溯式的搬运就不灵了。而 disocclusion 恰恰就是这种情形,一条像素光线上能同时有好几层表面,几条运动向量打架,到底该去取哪一帧的哪一层?
于是出现了这篇论文直接站在肩膀上的那位前辈,Reservoir Splatting。它的想法是:别再"把当前帧的像素回溯去上一帧找",改为"把上一帧的主命中样本,顺着它跟物体的运动,直接喷进当前帧"。每个喷进去的样本,落在哪个像素,就对那个像素直接出数。这一改有两个好处:时域样本的利用率更高了,而且原来要花重金去追的新主射线的钱,被更便宜的可见性检查替代了。
但这位前辈有个坏消息,就是它是单层的。一个被遮挡后重新出现的表面,依旧没有任何被喷进去的历史可借,噪声照样亮起来。它缺的,正是"被挡住的东西别扔,存进更深一层"这个动作。这篇论文做的第一件大事,就是把 Reservoir Splatting 从单层升级成多层,让喷管不仅能把样本喷到某一层的像素上,还能喷到那个像素的更深的某一层抽屉里。现在,被挡住的样本不再只能被丢弃,它可以被安排到背后那一层,等重见天日。
把这条家谱摆在桌面上横着比,能读出一条更深的规律:从 TAA 到 ReSTIR、Area ReSTIR 再到 Reservoir Splatting,每一代真正进步的地方,都是"历史"这个资产被搬运和承载的方式。TAA 把它存在像素颜色里,问题是颜色已经被滤波过了,丢了原始分布信息;ReSTIR 把它升级成一个能反复重采样、带置信权重的样本罐,历史于是变成了"可变现的样本"而不是"已抹匀的颜色";Area ReSTIR 又给罐子配了像素脚印级别的空间精度,让它能跟高频法线贴图共处;Reservoir Splatting 则干脆把"从上一帧取历史"改成"把样本推给下一帧",从根上绕开了回溯式搬运在遮挡处的歧义。而这篇论文,是在"罐子"这条主线上再钉下一颗钉子:单罐变成一摞罐,让历史不因为一时被挡就永久报废。沿着这条轴看,本文的贡献就是把家谱里那条"怎么让历史活得更久、更值钱"的主线又往前拱了一格。这也解释了为什么它的贡献能跟前面每一代接得上茬,而不需要推翻任何一个人。
44 域:像素跟层的配对,以及一次遮挡怎么把它拆散
在动手推公式之前,得先把这套新世界里最基本的名词立起来。这篇论文把"像素-层"这个组合称为一个域(domain),写作 (像素序号 p, 层序号 i)。第 1 层域就是直接看得见的那层,第 2 层、第 3 层依次往深走。每一个域,其实就是一个抽签罐的所在,维护着它自己的样本历史。
为什么要给同一个像素开好几层罐子?回到那盏灯。假设灯前面站着一根柱子,灯在柱子后面,当前帧里灯被柱子挡住,看不见。可镜头稍微一动,柱子和灯的相对位置变了,灯从柱子侧面探出来。在灯探头出来的那一瞬,屏幕上一个像素的视线之前是柱子,现在变成了灯。这个像素在第 1 层的位置,本来记录的是"柱子的表面",此刻应该换成"灯的表面"。可灯的表面在上一帧是被挡住的,没有账本。要是我们能记住,上一帧在柱子后面、距离更远的地方,还有一层灯的样本,那么当下这一帧,把第 1 层的身份让给灯,把柱子的账本往深挪一格就好。离散的东西要连续地挪,这就是多层抽屉的用处。
图 2 画的是这个"跨域移位"的全过程,把抽象的名词落到了具体的画面上。图中一帧叫上一帧,一帧叫当前帧,中间画了一个从兔子身上发出来的样本。简单说,这个样本在上一帧属于第 1 层域(像素 3, 第 1 层),因为兔子当时直接看得见。当前帧里兔子移到了别的位置,这个样本跟着兔子的运动,被喷到了新的像素。要判断它现在应该属于哪一层,办法是:从相机朝这个重投影后的样本方向投射线,每穿过一层表面就数一格,数到它为止。图中数出来是 3,于是它被归入 (像素 2, 第 3 层)。
注意这套"逐交点计数"的动作,它既定了层,又暴露了成本:每给一个样本定一次层,都要沿着射线一路数过去,穿过几层就追几根光线。这是后面第 6 章要动手优化的第一个目标,先记下来。
图 2 里还藏着两个跟后续章节扣得上的细节。其一,图中出现的专属名词"域(domain)"不是随口的说法,它精确地指"一个像素配一层"这个整体,样本、历史、置信权重,统统挂在 (p, i) 这个组合名下。跳出图来说抽象的定义:一个域,就是一颗抽签罐安的家。全文后面说"激活某个域"、"推移某个域",说的都是对整罐状态的搬运,而不是对单个样本的搬运。提前把"域"和"罐"对齐起来,后面第 8 章实现走查里"成批搬罐子"的动作才不会显得突然。其二,图 2 里那个样本跟着兔子一起动,是"喷管映射"的直观写照,它是第 3 章那位前辈 (Reservoir Splatting) 留下的核心动作,本文只是把它从"只能跨像素"升级成"还能跨层"。
再看一眼单层的时候这里发生了什么。没有多层这个设定时,被挡住的样本如果通不过可见性测试,就直接丢弃。多层方案只是把"丢弃"改成了"往深一层安家"。这个改动的数学勇气很大,因为样本虽然换了层,但它那颗抽签罐的历史并没有丢,等到表面重新露出,第 1 层这个位置正好缺历史时,它就能顶上。这正是整篇方法的心脏。
这里有个容易想糊涂的点,值得单独拎出来:被挡住的样本为什么还要留?表面看,当前帧它看不见,留它对当前帧没直接用处;但画面是流动的,这块表面很可能在下一帧、下下帧就探出头来。牺牲当前帧一点微不足道的深层存储,换来未来某一帧一整块区域的历史不空缺,这笔账在"物体经常动、场景反复遮挡"的游戏场景里是稳赚的。这正是"为当下看不见的东西存账本"的时间价值,也是全文所有效率开关要保住的底线:只能存那些"真的可能再露"的,别为了小概率事件把全屏幕都背成仓库。
55 多层积分:把第 i 次命中也当成"直接可见"去算
上面用画面讲了道理,这一章用公式把它钉死。我们要做的,是把"这一个像素的积分"从一层推广到多层,并且每一步都自己重推一遍,不直接引用原文的式子号糊弄过去。
单层的世界大家都熟。一个像素的辐射(radiance)是个对路径空间 Ω 的积分:
I_1 = ∫_Ω f(x̄) dx̄这里 x̄ 是这条从相机出发的路径:x0 是像素上的相机位置(针孔相机),x1 是路径在场景中碰到的第一个表面顶点,再往后 x2、x3 是后续路径顶点。f(x̄) 是这个路径对像素的贡献。对第一层来说,一条路径要真的对画面有贡献,得满足一个前提:x1 从 x0 看过去必须是可见的,也就是可见性项 V(x0, x1) = 1。如果 x1 被别的表面挡住,V = 0,这条路径对第一层没贡献。这就是"第一层"被"深深限制"住的原因:它只算那些第一眼就看得见的表面。
现在把局面放宽到多层。对第 i 层屏幕空间层,论文定义了这样一个集合:
A_i = { x | rank(x) = i }这里的 rank(x) 是交点 x 沿相机光线上的次序。rank(x) = 1 表示 x 是这条光线沿途的第一个交点,即直接可见的首个表面;每再往里多穿过一层表面,rank 就加 1。于是"第一层域"就对应 rank = 1 的交点集合,"第二层域"对应 rank = 2 的集合,依此类推。只要我们能顺着射线把交点一个个数出来,层也就定了,这正是上一张图做的事。
把"路径贡献"的口径从"只看第一个可见交点"放宽成"第 i 个交点被当作直接可见",第 i 层的积分就长这样:
I_i = ∫_Ω [x1 ∈ A_i] g(x̄) dx̄对比单层的 I1,这里有两个差别。第一个差别是方括号里的"层归属"条件 [x1 ∈ Ai],它等价于 rank(x1) = i,这条路径只有在 x1 恰好落在第 i 层时才计数。第二个差别是,原来的 f(x̄) 换成了一个去掉首项可见性的 g(x̄)。f(x̄) 里本身含着一个 V(x0, x1),它强迫 x1 必须从相机直接可见;而 g(x̄) 的定义,恰恰是 f(x̄) 把 V(x0, x1) 这一项摘掉之后的产物。这个"摘掉"很关键,它意味着:在算第 i 层的积分时,我们不要求 x1 从相机直接可见,因为第 i 层的语义本身就是"这个交点被当作直接可见的那一层"。真正管它可见不可见的,是那一层前面更近的表面。
一句话收束:第 i 层的积分,就是在"把第 i 近的那个交点当作直接可见"这个假设下,这条路径本该贡献的辐射。 每一层各自的 Ai 集合,把完整的路径空间按"第一个交点是第几近"这个标准切成了互不重叠的几块。
把这一章用到的符号收进一张表,方便正读到一半就被公式卡住的读者随时回头查。
| 符号 | 含义 | 约束 / 说明 |
|---|---|---|
| x̄ | 一条从相机出发的路径,顶点 [x0, x1, x2, …] | x0 为像素上的相机位置(针孔) |
| x0 | 相机点 | 本文假设针孔相机 |
| x1 | 相机光线第一个表面顶点 | 决定层级归属的关键 |
| f(x̄) | 这条路径对像素的完整贡献 | 含首项可见性 V(x0, x1) |
| V(x0, x1) | 首交点可见性项 | =1 当且仅当 x1 从 x0 直接可见 |
| g(x̄) | f(x̄) 摘掉 V(x0, x1) 之后的剩余 | 多层积分用它,不再强制直接可见 |
| Ω | 路径空间 | 对所有路径积分 |
| rank(x) | 交点 x 沿相机射线的次序 | rank=1 为首个交点 |
| Ai | 第 i 层域对应的交点集合 | 单层严格=rank(x)=i;深层被深度范围放宽 |
| Ii | 第 i 层积分 | 把第 i 近交点当直接可见去算的贡献 |
这张表里藏着一个要反复回答的追问:为什么把 V 摘掉成 g,第 i 层积分里的可见性逻辑还是对的?答案是,可见性并没有消失,而是被"层归属条件" [x1 ∈ Ai] 接管了。x1 落在第 i 层,本身就说明它前面还有 i−1 个更近的表面在挡着,它"不可直接从相机见"这件事,已经被层归属隐含地断言了。我们只是不需要再重复追问它。这也是为什么整套方法能放心地把"可见性判断"从积分里抽出来,交给"然后的影子判定"去管,这正是下一章深度范围的伏笔。
那为什么这套多元积分,在 disocclusion 下就能救场?论文的论证要点是这样:当一个像素发生 disocclusion,它的当前第一层积分 I1,经常能和上一帧的某个更深层积分 I_prev_i 对得上,因为两者对应的"有效的 x1 集合"是重合的,或者非常接近。上一帧里这块表面被挡在第 i 层,当前帧它露到了第一层,可它露出来的还是同一块表面,路径空间里那一段 x1 的邻居集合几乎没变,于是历史就有了用武之地。只有当遮挡物本身的运动改变了背后的其余路径空间时,比如挡路的东西恰好挡住了射向它背后表面的光源,此时光照条件真的变了,时域复用再信任历史就不对了。这个差值什么时候会长出来,正是第 12 章边界要回过头来收拾的地方。
66 深度范围:两次可见性判定,替掉逐段追光
上一章结尾,我们已经攒下一个不小的成本隐患:要判定一个移位样本该进第几层,最直接的办法是沿着相机射线逐段追光,数它到底穿过几层表面。第 4 章的图 2 就是这么数出层序 3 的。可如果场景里一撮抠细节很多、层次很深的地方,这个"数人头"的动作能把预算吃干。这一章动手治它,用的是深度范围(depth ranges)。
先讲清作者观察到的那条裂缝在哪。原原本本的层定义 Ai = { x | rank(x) = i },其实只在第 1 层上是被严格需要的,因为最终落到画面上的、被所有人看见的,只有第 1 层的积分 A1。到了更深的层,我们未必需要每一层的归属都数得分毫不差,只要大致对就行。这个"可以放松"的空间,就是优化进来的地方。
于是论文做了这样一个工程动作:先给每个层预先算好一个不重叠的深度范围(depth ranges)Di = [Li, Ri],然后放弃"严格按 rank 数到第几层",改按"样本离相机的距离落在哪个深度区间"来定层。这样一个粗一些的划分,可能会丢掉一些样本,因为每个深度区间里只有最靠前(最近)的那个表面才被视为有效,但换来的是定层的代价大幅下降。
用公式把"较深层的域"重新定义一下,就是原文的式 4:
A_i = { x | d(x, x0) ∈ D_i, V(x, x0) = 0, V(x, x0 + L_i·r(x)) = 1 }逐个条件拆开看。第一个条件 d(x, x0) ∈ Di,是说这个点距相机的距离落在第 i 层的深度区间里。第二个条件 V(x, x0) = 0,说的是这个点从相机看过去不是直接可见的,因为它是深层的,前面必然挡着别的东西。第三个条件 V(x, x0 + Li·r(x)) = 1,其中 r(x) 是从 x0 指向 x 的单位方向,x0 + Li·r(x) 就是从相机沿这条方向走到第 i 层深度区间的近端 Li 处的那个点;这个条件断言:从那个近端点再往前看,x 是这条方向上的第一个可见交点。三个条件叠起来的意思很明确:这个点既不在第一层(被挡),又从它所在深度区间的近端一探头就能看见。 它就归第 i 层。
这个定义替换掉"逐段追光数交点",关键在于定层流程变成最多两次可见性判定:
第一问,先把一个从相机可见性的光线发出去,测 x 是不是属于 A1。如果直接可见,就归第 1 层,完事。如果不可见,走到第二步。
第二问,用 x 到相机的距离 d(x, x0),在预计算好的那一组不重叠深度区间里查它落在哪个区间 Di,然后只在 Di 这个区间的范围内发一条受限的可见性光线,测它是不是这个区间内最近的那个交点。如果是,就归第 i 层;如果它的距离没落在任何一个区间里,就判无效,不参与当前帧。这就是图 3 要画的东西。
图 3 把三个样本的归宿一次画清。样本 x1 直接看得见,它走第一问就出结果,属于第 1 层,绿色标记是它所在的深度范围。样本 x2 看不见,但它的距离落在第二层的深度范围里,那条蓝色的受限可见性光线(只在这个区间内)确认它就是这个区间最近的交点,于是归第 2 层。样本 x3 看不见,又不落在任何一个深度范围里,于是被判无效。图里绿色的横条就是每个层预计算好的深度范围,它让"查层号"这个动作从"沿着光逐个剥皮"变成了"先看距离落哪个区间,再往区间深处探一下"。
这套深度范围的预计算,是每帧开始时在像素中心沿视线找到的那组代表交点 cp,i(第 p 个像素第 i 层的代表命中)的深度,被展开成不重叠的区间。因为每个像素的视线穿过的表面层次不一样,这些深度范围是按像素各自算的,不是全屏共用的同一把尺。
实现上还藏了两个要交代的细节。深度范围怎么定?论文用每个域代表命中的深度 t,直接把它往两边撑开一个很小的比例,取 [(1−ε)·t, (1+ε)·t],实测里 ε 取 0.01 就够用。另一个细节,是多个区间可能重叠:如果同一像素上相邻两个区间 [Li, Ri] 和 [Li+1, Ri+1] 撞在一起,就把它们的交界处晾到中点上,即设 R'_i = L'_{i+1} = (Ri + Li+1) / 2,保证每个深度只有一个归属。
把"两层可见性判定"的完整选择逻辑写成一个可照抄的流程,比抽象描述更能体现它到底省了什么:
# 给移位样本 x 定目标层(深度范围版)
# 前提 1:每帧开头,已按像素中心交点把每个像素的深度范围算好、去重
# 前提 2:已知 x 到相机的距离 d(x, x0) 与方向 r(x)
if 从相机向 x 投一条全局可见性光线,x 直接可见:
target_layer = 1
else:
D_i = 命中的那个包含 d(x, x0) 的深度区间 # 二分/线性查
if 命中 D_i 且 在 D_i 内投受限可见性光线,x 是该区间最近的交点:
target_layer = i
else:
x 无效,不参与当前帧的 splatting
对照第 4 章"逐交点数层"的老办法,这个版本的省法一目了然。老办法坏就坏在"逐段"两个字,你永远不知道要剥几层皮,运气差时一根光线能剥出几十个交点,每个交点都是一次耗时追光。新版本固定的就是两次:先问"你直接可见吗",再问"你所在区间里是不是最前那个",最多两次可见性判定就收工,跟场景里到底叠了几层表面无关。这个"成本与深度无关"的性质,是下一章把多层仓库塞进实时预算的第一块承重墙。
77 激活域:别把所有层从第一帧养到最后一帧
到目前为止的多层设计有个数学上爽快、工程上吓人的问题:如果每个像素都常备 k 层抽屉,那么全帧 N 个像素就是 k·N 个域,ReSTIR 的成本直接乘了个 k。可现实里,绝大多数像素根本用不上那么深的抽屉。室内场景里,那些来自远处房间、几乎永远不可能探到镜头前来的表面,你为了它养着一个深层的罐子,是纯浪费。这一章解决"别养用不上的层",正是论文的第三个贡献,叫激活域(active domains)。
写公式的直觉先行一步:一个样本,如果它曾经在某几帧里当过主命中(primary hit,也就是直接可见的首个表面),后来才被挡到后面去,那它未来重新露面的可能性就比较高,值得保留它的账本;反之,那些从来就没在主层亮过相的深层样本,基本不用管。这听起来像是要全局调度判断,论文却用一个极便宜的"传递式激活"把它做成了逐帧流式规则,一条规则同时管住了"激活谁"和"什么时候灭掉"。
规则是这样的。第一帧,只有主层(第 1 层)的所有域是活的。之后每一帧,凡是"活的域",其代表命中 cp,i 会被喷管喷到当前帧,喷到哪儿,就把当前帧对应位置的域 (p', i') 激活;当前帧新亮的域,自然也是激活的。因为域是存在屏幕空间的,一旦某个域的代表命中被喷出了屏幕边缘,它就自动失活,连判都不用判。这样一整条因果链跑下来:"谁以前亮过,谁就可能再亮",全自动成立,不需要任何全局知道。
但喷管本身会在屏幕上留下洞。因为样本是离散的点,往屏幕上喷的时候,有些像素可能一个都没喷到,留下空档,这些空档如果刚好是需要历史的地方,就会暴露噪声。论文用一个朴素的补洞(hole-filling)对付它:把已激活的域周围一定半径内的邻居也一并算作激活,让它们参与采样,但不让这些"补位"的域去激活下一帧的域。实测里这个补洞半径取 3 个像素。这么简单的一条,作者说就足以保住局部连续性,稳定重采样结果。
激活域的效果,用两张图能看明白。第一张看"只养一小片"。
图 4 拍的是 Bistro 场景第二层里,全部的域和激活后的域长什么样。左边是全部域,右边是激活域,中间用法线伪彩色标出每个域中心命中的朝向,灰色表示这个位置没有表面命中,黑色专门标出那些"没有激活"的域。一眼望去,激活后的那块,屏幕上一大片都是黑的,真正亮着的只是很稀松的一小撮。这正是论文想要的结果:把计算都压回到那些"上一帧真的亮过、本帧又必须照顾"的窄条上,全屏幕只维护一个很小的子集。
第二张图回答一个更尖锐的问题:这么抠门地只养一小片,够用吗?图 5 在中高难度的 Carousel 场景上,把 Reservoir Splatting、均匀五层的多层实现、以及本文的选择性激活,三架摆在一起比。
图 5 的上半部分那一行,是所谓的 disocclusion mask,红到发亮的地方标出当前帧里真正发生了 disocclusion 的像素,让你先知道"问题长在哪"。下面是三行结果,Splatting(15.5 ms)、Ours(22.5 ms)、Uniform 5 layers(42.8 ms)。看三行的噪声表现:Splatting 的 disocclusion 区域明显更吵;均匀五层把噪声压得很平,但它烧了 42.8 毫秒;选择性激活的 Ours,噪声水平和均匀五层看齐,却只花 22.5 毫秒,几乎减半。一个关键点在掩码图里也标了出来:噪声多的地方,并不只局限于当前帧的 disocclusion 区,因为画面需要几帧时间才能从上一次的 disocclusion 里缓过来。这一点,正好是后面第 9 章复算表里"覆盖率 88%"含金量的注脚,它说明选择性激活先照顾的就是大部分该照顾的地方,而不只是刚好盖住动的那一小块。
那是不是所有 case 都被覆盖了?不是。图 5 右下的黄色小框指明了一处残余噪声,那往往是因为上一帧没有把该域的主层样本都保留下来,于是这一帧就露了个破绽。这个"覆盖不全"的短板,会原样出现在第 12 章的边界清单里。
到这里,方法的三大块已经立起来了:多层抽屉接住被挡的样本(第 4、5 章)、深度范围把定层层数从逐段追光砍成两次判定(第 6 章)、激活域只养真正会用上的那一小撮(这一章)。接下来看它在一整帧里怎么把这些零件拧成流水线。
88 一帧怎么跑完:从推移域到紧凑缓冲
公式和机制都说清了,这一章把它们落成一段能照着实现的逐帧流水线。硬件是 Falcor 框架,跑在 Intel i9-13900K 加一块 NVIDIA RTX 4090 上,画面 1920×1080。这一帧里,环境里的每件事,都有固定的先后。
每个域(一个抽签罐)在内部存两个表面命中:一个是"代表命中"(从像素中心投出去的,用来代表这个域往屏幕上的落点),一个是"子像素样本命中"(用来估计这个域的积分)。先记住这个分工,后面每一步都要用它。
帧循环的十个动作,按顺序排开:
第一步,推移激活域。 把上一帧所有激活域的代表命中,用喷管喷到当前帧,得到它们新的像素位置。新的层序 i',由"从一个假想的相机对重投影后的代表命中投射、数经过的交点"得到,也就是要重走一次喷管映射判层。这一步生产出当前帧的候选域骨架。
第二步,补洞。 围绕上一步喷出来的激活域,执行第 7 章说的 hole-filling,把半径 3 像素内的邻居补成激活态,让局部连续。
第三步,追新代表命中。 对这些激活的子域,从像素中心投新光线,拿到它们全新的代表命中 cp',i'。
第四步,真假阳性防御。 这一步很关键,因为平移是靠点的近似完成的,会误激活一些不该激活的域(假阳性),白白烧性能。防御动作是:把上一帧代表命中的"直接遮挡物"和当前帧新代表命中的"直接遮挡物"比对一下,两个遮挡物的实例 ID 不一样,就认为这个域不该被激活。这一刀切得很值,因为遮挡物变了,说明这层的历史未必还能原样套用。
第五步,算深度范围。 用刚才那些激活域代表命中的深度 t,按第 6 章的办法展成 [(1−ε)t, (1+ε)t] 的区间,再把同一像素上可能重叠的区间按下接中点的方式铰开。
到这里,当前帧的域和它们的属性都初始化齐了,接下来是采样正戏。
第六步,初始重采样。 为当前帧所有激活域生成规范样本(canonical sample)。这一步是在给"这个域对应积分"投出一个干净的估计值,是保住积分无偏性的地基,不能省。
第七步,时域重采样(多层喷管)。 先把上一帧所有激活域的样本喷进当前帧,凡是落在某个深度范围外的样本直接拒收。接着,跟 Reservoir Splatting 一样,把当前帧所有激活域新生成的样本再喷回上一帧,去算 MIS 权重。为了在时域复用里保无偏,每个激活域的置信权重要用它代表命中反投回上一帧、去复用上一帧对应罐子里存的置信权重。
第八步,空间重采样。 只对第 1 层做。论文明确说,对深层做空间复用的质量收益,不值那个额外的开销,于是砍了。
第九步,存储压缩。 把激活域都压进一块紧凑的连续缓冲,一个域一个线程去跑。会这么设计,是因为深层的域在屏幕上是稀稀拉拉撒开的,压成连续缓冲能显著提高线程利用率。
第十步,剔除背面。 背面朝向的表面本来就不贡献画面,从域构造里直接拿掉。落到实现上,等于只保留奇数次表面交点:第 i 层恰好对应沿相机光线的第 2i−1 个交点。
把这段流水线翻译成伪码,能更直观地看出哪些步是为了质量、哪些步是在省钱:
# 每帧循环 · Multi-Layer Reservoir Splatting
# 前提 1:每个域存两个命中:代表命中 c(像素中心)+ 子像素样本命中
# 前提 2:域是屏幕空间的,出屏自动失活
# 前提 3:上一帧已维护好激活域集合 active_prev
active_cur = ∅
# 骨架推进
for (p,i) in active_prev:
c' = splat(c_{p,i}) # 喷管映射到当前帧坐标
layer' = count_intersections(c') # 逐交点判新层序(第 4 章)
active_cur ∪= {(p', layer')}
active_cur ∪= hole_fill(active_cur, radius=3) # 补洞,只参与采样
for (p',i') in active_cur:
c_{p',i'} = trace_from_pixel_center(p') # 追新代表命中
if occluder(c_{p,i}) != occluder(c_{p',i'}): # 假阳性防御(实例 ID)
active_cur.discard((p',i'))
compute_depth_ranges(active_cur) # ε=0.01,区间去重叠
# 采样
initial_resampling(active_cur) # 生成规范样本,保无偏
temporal_splatting(active_prev → active_cur) # 多层喷管 + MIS 权重 + 置信回接
spatial_resampling(layer_1_only) # 只做第 1 层
十步走到这里,一帧的活就干完了,各层的罐子改朝换代,把该带的历史带到下一帧。门禁选在第 1 章开头那个"灯后尾巴"上回头看,这条流水线换走的东西,正是那条尾巴的来源。是骡子是马,下一章拉去四个场景遛。
99 同样的预算,谁更稳:四场景等时对比
方法讲完,把自己拉出来跟老办法打一架。论文挑的战场很讲究,叫"等时对比":所有方法都用相同的预算,谁在同样的时间里画面更干净,谁赢。因为 ReSTIR 家族的天职就是在短预算里干活,不比精度而比"同样几毫秒里谁的噪声更小",才公平。
对手是谁?第一个是 Reservoir Splatting 原版。它没被设计来处理 disocclusion,于是作者又给它加了两个"临时抱佛脚"的变体,只针对 disoccluded 像素补救:一个是在初始 RIS 阶段把每像素样本数(SPP)提高(叫 more initial spp),一个是把空间复用的邻居数提高(叫 more neighbors)。这两个变体的开销都被调到跟本文方法相当,好让对比站在同一个起跑线上。也就是说,这篇论文被逼着证明:你没有靠多烧预算换干净,你的多出来的钱全花在刀刃上。
场景四个:Bistro、Subway、Emerald Square、Carousel。前三者主要是镜头在动,后者是大量物体在动,两者都能制造出 disocclusion,但成因略有不同。误差用两个指标报:MAPE(平均绝对百分比误差)和 FLIP(一个更贴近人眼感知的差异度量)。
图 9 是四场景等时对比的大图,每个场景一行,底部标着 Time / MAPE / FLIP。先把每一行的"Time / MAPE / FLIP"逐项读下来。以 Carousel 为例:Splatting(15.5ms / 1.863 / 0.696)、More neighbors(15)(23.0ms / 1.811 / 0.677)、More initial spp(12)(22.8ms / 1.733 / 0.668)、Ours(5 layers)(22.5ms / 1.631 / 0.624)。你看,两个变体把开销堆到跟 Ours 一样高,指标才只降到 1.811、1.733,而 Ours 用一个更像样的数字 1.631 站在后头,花的还一样多。
作者自己也补了一句很重要的话,我们要原样强调:因为误差是对整幅图算的,而 disoccluded 区域只占画面很小一块,所以在全局 MAPE/FLIP 上,本文方法的量化增益是"被稀释"的,看着不大。真正的差距在画面上,尤其是那些放大框里,Splatting 在 disocclusion 附近的高方差一目了然,Ours 把它们压下来。这个"数字不大但图上明显"的提醒,是读这张图时必须记住的判读前提,否则你会以为它赢得很勉强。
我把 FLIP 这一栏亲自复算了一遍,见下表的"降幅"列。
| 场景 | Splatting FLIP | Ours FLIP | FLIP 降幅(复算) |
|---|---|---|---|
| Bistro | 0.755 | 0.609 | −19.3% |
| Subway | 0.733 | 0.610 | −16.8% |
| Emerald Square | 0.696 | 0.624 | −10.3% |
| Carousel | 0.446 | 0.413 | −7.4% |
仔细看这排数字能读出两条信息。一,四个场景 FLIP 都降,没有一个反弹,说明方法在"遮挡成因不同"的几类场景里都站得住。二,Carousel 的降幅最窄,只有 7.4%,而它恰好是物体运动量最大、disocclusion 最难啃的一个。这跟 Carousel 的量化误差本身被"全局误差稀释"得最厉害是一致的,绝不是它没在干活,恰恰相反,它干得越多,误差被摊到全图后越不明显。
表 1 给了这场对比背后更细的成本拆账。我挑了三个最能说明问题的维度自己复核。
第一维,深度域的数量被选择性激活压下去多少。 用 Carousel 看,均匀方案要养 732.0 万个深度域,选择性激活只养 191.2 万个,省了约 74%。Bistro 更狠,从 207 万个压到 26 万个,省约 87%。而又重看一遍覆盖率:虽然占比低了,但深度域盖住的 disoccluded 像素绝对数并没有崩,Carousel 从均匀的 240164 个盖到选择性激活的 218358 个,仍然是绝大多数。别被"88.0% vs 97.0%"这种占比数字骗了,占比低是因为它不为全屏服务,它只为"要动的那些地方"服务。
第二维,深度范围替追光,射线省了多少。 表 1 专门列了"有/无深度范围"两套 splatting 阶段射线数。Emerald Square 从 17.42M 条降到 9.29M 条,省约 46.7%;Carousel 从 29.32M 条降到 14.41M 条,省约 50.8%。这正是第 6 章那个"两次可见性判定替掉逐段追光"在数字上的兑现。
这时候要停下来较一个真。论文在正文里写,深度范围让 splatting 的射线"最高降 59%"。但我按表 1 各场景里最大的那一个算,只能算到约 51%(Carousel 那一格,−50.8%)。这是一个值得标出来的口径缝隙。最可能的原因是,59% 指的是某个更具体的局部路径(比如某一帧、某一种查询),或者是计算口径与我这边的"整表加总对比"不同,正文没有展开写清。这里我不替它圆场,直接把原值、我算出的降幅、和"与 59% 的差值"都摆出来,让数字自己可核。这是这个分区一贯的做法:复算要能对得上原文,对不上就把缝隙亮出来,而不是糊弄过去。
第三维,splatting 阶段的时间,深度范围的收益落到了实处。 用 Emerald Square 看,splatting 时间从 4.60ms 降到 3.05ms;Carousel 从 6.05ms 降到 3.93ms。时间下降的幅度和射线下降的幅度基本同步,说明省的就是追光那一截,落账干净。内存方面,因为维护的域变少,内存用得也少,最省的一档从 380MB 起步,都在可接受范围。
结论可以下了。在这场"同样的预算比谁稳"的较量里,Splatting 要靠砸 SPP 或砸邻居数才能跟 Ours 打个平手,而砸了之后指标还略逊一头。Ours 的多层历史,让它在 disocclusion 变得多大、初始采样多难时,都能维持住一个稳定的成本和稳定的质量。这两头,是它跟"靠加预算硬顶"的根本分野。
1010 拧参数:层数、补洞半径、和 SPMIS 搭伙
一个方法好使,不够,还得知道"哪个旋钮管什么、拧过头会怎样"。这一章把三个旋钮挨个拧一遍:层数拧多少、补洞半径拧到多大、以及跟另一种处理 disocclusion 的空间派思路怎么搭伙。
先拧层数。抽屉开多少层,直接决定能接住多少"被挡住的历史"。
图 11 是 Carousel 场景的层数消融,一行一档:1 层、2 层、3 层、5 层,从上到下 Time 分别是 16.4ms、18.2ms、20.2ms、23.0ms。往下看,每多开一层,图中那三处放大的 disocclusion 区域就各降一档噪声,层层见功。物理解释很直接:同一根视线上叠的几何越多,那些深处的样本就越需要抽屉来接,层数太少,接不完,露出还是吵。所以凡是"重叠几何多"的场景,就得多开几层才喂得饱。而有趣的是成本:因为维护的只是激活域,多开几层并不会让每一层都全屏烧钱,层数从 1 涨到 5,时间只从 16.4 涨到 23.0,涨的幅度远小于"多维护 4 层"这个直觉该有的涨幅。这就是激活域的价值在参数维度上的又一次体现。
为什么成本没有跟着层数翻倍?这要回到第 7 章那条"只养会再露的人"的规则。层数开得多,不等于每个像素就真把那么多层都养活了;只有那些"上一帧亮过、这帧又要照顾"的窄区,才会真正开出深抽屉。Carousel 里重叠几何最密的那几块局部,层数开得再高也有抽屉可住,而大半个画面依然是浅层区,成本自然只跟着"真开了深抽屉的地方"涨,不是跟着"理论上的最大层数"涨。于是这里吐出一条很有用的工程经验:调层数要调的,不是"全屏开几层",而是"我需要的最深的那几块局部开到几层才不吵",两者之间隔着激活域这道闸,账算得完全不一样。
再拧补洞半径。前面第 7 章提过补洞是为了对付喷管在屏幕上留下的空档。
图 6 是补洞半径的效果:Without Filling、Radius 1、Radius 3 三行,配 Reference。看中间那行 Radius 1,已经能把大部分空档堵上,噪声明显比 Without Filling 干净;而 Radius 3 相比 Radius 1 的改善,肉眼已经难分高下。作者的结论是,补洞半径继续加大,在典型的动画和镜头运动下,收益很快就消失了。所以 3 像素是个刚刚好的档位,够用,不瞎烧。
最后看 SPMIS 这条不相为谋的路。SPMIS(Stochastic Pairwise MIS)跟本文是两种完全不同的补法:它不着眼于时域,而是尽量在空间上"挑有用的邻居借",不随机抓邻居,专挑那些手里有好样本的邻居来用。它擅长的地方是空间复用效果好、邻近像素手里确实有料的时候。可一旦邻近像素也都没料,比如光照复杂、空间变化大,它就使不上劲,而且在有亚像素几何(比如树叶)的地方,它不能保证时域稳定。
本文刚好相反,它在"把带着历史的好的时域样本存进来"这条道上发力,但也明确承认,它对"上一帧根本不存在的新出现的几何"(比如屏幕边上突然闯进来的东西)产生的噪声无能为力。于是两个短板互补:把本文和 SPMIS 拼在一起,时域也管、空间也管,就能吃到两头的好处。
图 10 就是这场合作在 Bistro 场景的验证:Splatting(10.0ms)、SPMIS(3 neighbors)(12.8ms)、Ours(2 layers)(11.0ms)、Ours + SPMIS(13.8ms)。Splatting 明显最吵;SPMIS 独自上阵,能改善一部分屏幕边界附近的 disocclusion,但它顾不到深处那种刁钻的;Ours 独自上阵,把刁钻的重度 disocclusion 压下去了;而 Ours + SPMIS 那行,两边的优势叠在一起,画面最干净,代价是多了 13.8ms 里的一点。这一张图把"组合优于任一单方案"这一条钉死了。它也反过来提醒我们:本文不打算一手包办,只补时域这一头从没人补好的钉子,剩下交给擅长空间的搭档。
1111 和去噪器同台:NRD 与它那个过度模糊的 DF
到目前为止讨论的都是"原始渲染输出"有多干净。但真实产品里,几乎没有人直接把 ReSTIR 的原始输出端上屏,它要先过一层实时去噪器才能看。去噪器本身就是吃时域历史的家伙,那么本文给去噪器喂一个"更稳的输入",能带来什么,就是这一章要验证的。论文用的去噪器是 NVIDIA 的 NRD(NVIDIA Real-Time Denoisers)。
先把"为什么去噪器尤其怕 disocclusion"这件事讲透,不然这章的验证你会看不出门道。实时去噪器的工作方式,本质上是"拿历史帧的干净信息来给当前帧查漏补缺":它把当前帧每个像素的噪声值,和上一帧对应像素的已去噪值做加权混合,噪声就被时间平均摊薄了。这套逻辑隐含的假设,和前面所有时域复用一样,是"上一帧这个位置确实有可用的干净历史"。可一旦发生 disocclusion,这个位置的历史要么是空的,要么属于挡在它前面的另一块表面,去噪器一抓,抓到的是一把用不得的旧账。于是去噪器要么照单全收,把错误的旧颜色糊过来,要么干脆放弃时域、退回纯空间滤波,而纯空间滤波在这种"整片区域都刚露头"的地方,没有足够的高质量邻域可借,照样压不下去。disocclusion 之于时域去噪器,就像是给一个靠背书的代办逐帧搬搬账的人,突然塞进一摞没留下任何账本的空白页。
图 7 就是这场试验。场景里有一个移动的白色灯泡光源,它离相机很近,移动时在身后制造出一大块 disocclusion 区域,正好是去噪器最怕的场景。图里上下两组,下面是 Splatting(denoised),上面是 Ours(denoised)。看细节,Splatting 那一组由于输入的时域历史在 disocclusion 区质量差、方差高,去噪器容易被带着糊出"色块"(color blobs);而 Ours 因为从被挡的深层仓库里调出了更高质量的历史样本,喂给 NRD 的是一个方差更低的输入,NRD 就能在保细节和贴参考之间取得更好的平衡,没引入额外空间伪影。一句概括:本文不抢去噪器的活,只把去噪器的输入质量垫高,让它在最难的 disocclusion 区也能干好本职。
可去噪器还有一个更深的坑要趟。NRD 自带一个专门对付 disocclusion 的选项,作者叫它 disocclusion fix(DF)。它的思路是:检测到某块区域缺乏可靠的时域去噪历史,就转向激进的时空滤波硬抹。这在理论上好像是救火,实际执行起来却会矫枉过正。
图 8 把这个问题拍了个正着。四格分别是 Ours(denoised with DF)、Splatting(denoised with DF)、Ours(denoised, 无 DF 对照)、Reference。看第一格,即使输入是质量很好的 Ours,一旦 CPU 端把 DF 打开,重度 disocclusion 的区域还是被 DF 狠狠地抹成了平糊糊的一团,细节全没了。它确实能把 baseline 的萤火虫(fireflies)压下去,但代价是把本文的好结果也一并抹糊。所以这篇论文用这张图亮出了一个观点:真正重度的 disocclusion,是时域去噪的一个根本性短板,DF 这种"硬抹"不是解药,而本文通过给输入垫更高的历史质量,比 DF 提供了一个更本质的解法。
最后要补一句量纲上的提醒,它与第 9 章那句一脉相承:这些去噪、对比的量化指标,全部是对整幅画面平均的。disoccluded 区域占画面比例小,所以任何"局部噪声大降"反映到全局指标上都只剩一小笔。读图判断时,一定要去看图上放大框和局部细节,而不是只盯总数。
1212 边界与反例:这套方法在哪些屏幕前失灵
一套方法写到"看起来万能",反而要警惕。这一章把论文自己承认的、以及从机理上推导出来的失效边界,老老实实摊开。每一条都带触发条件,触发不满则不成立。
第一条,视锥外进来的几何,不在这套方法的射程里。 本文完全在屏幕空间里工作,只存屏幕空间的信息。所以它对"从视野边缘闯进来的新几何"没有任何办法,那些地方没有上一帧可查,它照样会以噪声的形式露出来(图 9 屏幕边的噪声就是这么来的)。要去管它,得把样本维持到屏幕之外,那是本文明确不做的事。触发条件是:有几何从视锥外切入。此时不是方法退化,而是它压根不覆盖。
第二条,第一次露脸的新表面,开头注定是吵的。 选择性激活是按"上一帧亮过才可能再亮"来预测的。于是第一次出现在画面里的表面,由于没有历史,刚开始一定带着更高的噪声。它不是永远错,只是把"给第一帧的历史"这件无米之炊留给了后面的帧去补。触发条件是:有全新的、此前从未出现过的表面进入画面。
第三条,大面积"曾经亮过的样本同时被挡"时,成本会涨。 如果一堆本来亮着的样本同时被挡到后面去,因为它们都有可能重新露脸,系统不得不为它们每个人养一个域,域数会一下子多起来。论文坦诚,遇到这种情况,如果有个策略能避免每帧都去刷新深层 reservoir,成本能压下来不少。触发条件是:一次遮挡事件波及大量曾亮过的样本。
第四条,景深和运动模糊下,"是否遮挡"本身的判定要重建。 本文的层序、点定层,都假设相机是针孔、画面瞬时。一旦启用景深或运动模糊,一个样本到底被没被挡住,得在整个光圈开度、或者整个时间积分区间上去判断,这事立马变得不平凡,本文的公式直接失效。触发条件是:开启景深或运动模糊。
最后给一条从机理里推出的反例,呼应第 5 章那句"差值什么时候会长出来"。多层复用的成立前提,是被挡表面重见天日时,它所在的"路径空间邻居集合"基本没变。可如果那个遮挡物本身动了,恰好挡住了射向其背后表面的光源,那么背后表面的光照条件就真的变了,此时再信任"很久以前的深层历史"就是不合理的。这条反例的触发条件写得非常具体:遮挡物的运动,挡住了本应照到重露表面上的光。这是时域复用里"历史可信度"的天花板,任何想靠纯时域解决 disocclusion 的方法,都绕不开它。
把上面这几条放回到真实场景里走一遍,你会发现它们不是孤立的,而是经常结伴出现、互相放大。当嵌套遮挡同时发生,深层历史会连锁过期。 设想一个角色贴着整面墙移动,角色身后的墙面先被角色挡掉,而更里面的货架又被墙面挡掉,形成两层遮挡。当角色移开时,墙面第 2 层极快地露回第 1 层,此刻它还能拿到一点上一帧的口碑;可墙面身后那层货架,因为经历了两个遮挡物的叠加,它"被挡了更久、更久没在主层亮过",等它最后露到第 1 层时,那份深层历史早已观测不到最新的光照,信与不信都别扭,本文的定层与置信回接在这里只能退化为近似。这条边界说明:层数不是越深越保险,越深的层,历史越陈旧,可信度越差,方法能做的只是延缓腐坏,拦不住它一定到来。
另一处容易踩的雷是亚像素高频几何。 disocclusion 的定义依赖"像素光线上有几层表面"这个稳定的几何结构。可一旦面对远处一片树叶、栅栏这种每像素几十个镂空的结构,表面层数在帧与帧之间极不稳定地跳变,深度区间划分和"该进第几层"的问法本身就站不住。这不是论文会假装解决的,它只会提醒:在纯细分高频几何密集的区域,时域这一头的所有招(包括本文)都得退避三舍,老老实实交给空间派或者更高的采样预算。这两条连同上文的四条,才构成一个完整的失效清单:不看它,你会以为多层抽屉无所不能;看全它,你才明白它省下的每一分钱,都是用"只在历史能可信的那一小撮地方开抽屉"换来的。
1313 收口:三件贡献、两条去路
把整篇的骨架再梳理一遍,答案就立在眼前。这篇论文的核心,是把"时域复用断在 disocclusion"这个老伤口,用一个"多层接住被挡样本"的思路给缝上了,并为它配了两件省钱的工程妙手,让它在实时预算里真的跑得起来。
它给这个领域交付的东西,原作者总结成三件,我用自己的话重述一遍,好让读者记住的是"为什么"而不是"编号"。
第一件,把 Reservoir Splatting 从单层抬到多层,让被遮挡的样本可以在不同屏幕空间层之间喷管移位,于是好的深层历史样本,能在重新露脸的那个瞬间回到画面里,而不是被丢弃。这是全篇最本质的那口气。
第二件,找到一个"两层可见性判定就能定层"的办法。通过深度范围重定义积分域,那些原本要沿着射线逐段追光才能数出层数的昂贵动作,被压成最多两次可见性查询,这是把方案从"演示"推到"可实时"的成本地基。
第三件,用一个激活域掩膜,让多层仓库只在"未来很可能用到"的地方才养着,避免全屏幕层级仓库的 k 倍开销。这是让方案真正落进帧预算的最后一个闸门。
三件里,前一件决定"能不能",后两件决定"贵不贵"。
如果只允许你带走三句话,那么请带走这几条。第一句,时域复用的天花板不是样本够不够多,而是"被挡掉的历史该不该信",本文用"深抽屉存起来等它再露"给出了一个不靠猜的答案。第二句,任何看起来要多花 k 倍钱的方案,都要有一个像"深度范围只准两次判定"和"激活域只养老兵"这样的闸门,否则它永远只能活成论文,活不进帧预算。第三句,把方案塞进完整管线(接上 SPMIS、接上去噪器)之后依然成立,这才叫真的解决了问题;单拎出来能跑,不算数。
把这一篇放回实时渲染那座大山里,它的位置很清楚:ReSTIR 家族长期以来把"时域复用"做到极致,却始终绕不开 disocclusion 这个断点;本文没有推翻前面的家底,而是顺着喷管和多层积分的台阶,把断点接上了。它不打算替代空间派思路,而是把"时域这一头"补到连去噪器都受益在它脚下。如果你正要在自己的渲染器里处理运动物体身后的噪声,这一篇给你的是一套可以直接照搬的分诊思路:先问它是不是 disocclusion,再问要不要开多层,最后问这两笔账(深度范围的射线、激活域的存储)吃不吃得消你的帧预算。
未来工作,作者画了两条路。一条是把自己维护出来的这些"被遮挡层里的样本",进一步暴露给时域的上采样器和去噪器,让它们在处理 disocclusion 时也能借到这笔历史,而不是像第 11 章的 DF 那样只能硬抹。另一条是朝透明渲染走:跟踪透明表面身后的不透明样本,这样物体在透明介质后来回移动时,那里的信息也能被收回复用。这两条都在把"多层抽屉"这套资产的收益,从本文自己的输出,扩散到整条渲染管线的其它环节。
关于这套方法的出处与授权,一句话收尾:它是一篇署名清晰、以 CC BY 4.0 授权发表的会议论文,标题、作者、发表场次、DOI 与授权口径都在页面末尾的「参考与可验证性」里逐项列出,本篇正文的图与读数也都来自该论文,未作改动。
符号与术语单(附读后续读,术语一义到底)
| 符号 / 术语 | 含义 |
|---|---|
| domain (p, i) | 像素-层域,一个抽签罐的所在 |
| 层 / 抽屉 | 屏幕空间第 i 层(i=1 为首个直接可见层) |
| rank(x) | 交点 x 沿相机射线的次序,=1 为首交点 |
| Ai | 第 i 层域的交点集合 |
| representative hit cp,i | 从像素中心投射、代表该域的命中 |
| splatting / 喷管映射 | 把上一帧样本喷进当前帧对应位置 |
| 深度范围 Di=[Li, Ri] | 预计算的非重叠深度区间,用于两层可见性定层 |
| 激活域 | 只维护"可能再露脸"的域,其余不养 |
| hole-filling | 对激活域周围补位,抵消喷管空档 |
| SPMIS | Stochastic Pairwise MIS,空间派兄弟方案 |
| NRD / DF | NVIDIA 实时去噪器,及其 disocclusion fix 选项 |
| MAPE / FLIP | 平均绝对百分比误差 / 人眼感知差异度量 |