如果你做图形渲染相关开发,一定被间接光照折磨过。离线渲染里它是无数采样堆积出来的收敛时间,实时渲染里它是各种 Hack 方案的斗兽场:Lightmap 只能管静态场景,Irradiance Probe 在遮挡复杂的地方漏光漏到怀疑人生,SSAO/SDF 一类方案又只能给个“氛围感”,离真正的多次弹射差得远。我在做项目时一直在找一个平衡点,既要有路径追踪级别的准确性,又要能在普通消费级显卡上实时跑起来。
后来尝试的方案是把轻量级注意力机制直接用在间接光照的推断上。这个思路听起来像“拿 Transformer 做 GI”,但实际上它跟那种动辄几十亿参数的视觉大模型完全是两码事。核心想法很简单:把场景里稀疏分布的光照探针当成一组 token,用注意力机制去计算每个着色点应该“采信”哪些探针的信息,再结合可见性和表面特征做加权合成。它解决的核心问题是:在遮挡复杂的场景里,如何聪明地聚合附近探针的光照贡献,而不是像传统方案那样用固定核函数加权重,结果经常穿墙漏光。
这篇文章我会从原理、架构设计、训练部署到踩坑实录,完整讲一遍我落地这个方案的过程。适合对实时光照渲染有一定了解、想尝试用神经网络改进 GI 效果的技术美术和图形程序,也适合离线渲染方向想了解注意力机制怎么落到具体问题上的读者。纯美术向的朋友可以直接跳到第 3、4 节看参数与效果调优。
1. 从光照开销说起:间接光照为什么需要“注意力”
1.1 间接光照的经典实现和痛点
间接光照的本质是光的多次弹射。想象一个房间:阳光从窗户进来,打到地板上,再弹到天花板,再弹到墙面阴影处,这些二次以上的传播就是间接光照。物理上完美的做法是对每个着色点采样无数条随机光线,追踪它们打到哪些物体、被反射了多少能量,也就是路径追踪。这条路在离线渲染里已经完全成熟,但在实时渲染里,每像素每帧几十上百条光线根本跑不动。
于是业界发明了各种加速策略。最古老的是 Lightmap,把间接光照烘焙到纹理上,静态场景效果好,但只要物体一移动、灯光一变化就全废。Irradiance Volume / Light Probe 是另一种思路,在场景里摆一堆球状探针,记录每个球位置附近从各个方向来的平均辐射度,运行时按位置插值。这个方案的问题是“平均”两个字:探针之间如果有遮挡,插值就会把不该互相看到的两块区域混在一起,表现就是墙角漏光、暗部泛白。
我在一个室内项目里遇到过特别典型的案例:一堵 30 厘米厚的墙体,墙两侧各放了一个探针,墙体右侧是一盏强光。Irradiance Probe 插值之后,墙左侧阴影区的亮度凭空高出 30%。这就是遮挡信息缺失的直接后果。后来想到能不能让“插值”更聪明一点,让每个着色点自己决定“我应该相信哪些探针”。这不是标准的数学插值能解决的,更像是一个匹配和筛选问题。这正是注意力机制擅长的领域。
1.2 注意力机制在 GI 中的角色定位
注意力机制最早是机器翻译里用来建模词与词之间关系的:给定一个查询(Query),在所有的键(Key)里找到最相关的几个,用这些相关性的权重去加权对应的值(Value)。放到 GI 场景里,Query 就是当前要渲染的着色点的状态,Key 是场景里每个探针的位置和朝向信息,Value 是探针上记录的光照数据。
关键区别在于:传统插值是固定的、平滑的、按距离衰减的;注意力插值是数据驱动的、动态的、按语义相关性筛选的。一个探针离你 1 米,但中间隔了一堵墙,传统方案照样给它一个不小的权重,注意力机制学了大量遮挡关系之后,会倾向于把这种高相关但不可见的探针权重压低。
我第一次跑通这个思路的雏形时,看到的效果比预期好很多。用一个只有 400 万参数的小网络,把场景中 512 个探针的权重重算了一遍,之前漏光的墙角和柱子阴影区域全部正常了。那一刻我意识到,真正的价值不是“用了个新架构”,而是找到了一个 结构化场景匹配的正确表达方式。
1.3 轻量化的含义
轻量级注意力有一个明确前提:不能用标准 Transformer 那一套。标准 Transformer 把全场景所有探针当 token 做全局两两交互,计算量是平方级。512 个探针看起来不大,但每个 token 都要和所有 token 计算注意力,512 的平方乘上 64 维特征甚至更高,在 GPU 上跑起来就已经很吃力了,更别说画面里每个像素都要跑一次这样的推理。
轻量化的关键思路有三个:局部窗口、低秩近似、浅层网络。局部窗口是最直观的——一个着色点在物理上只可能被周围几米范围内的探针影响,远的探针贡献趋近于零,强行算全局注意力没有必要。低秩近似是把 QK 点积换成低秩映射,把计算复杂度从 O(N²) 降到 O(N)。浅层网络则是只用 2 到 3 层注意力,不搞十几层堆叠。
后面第 2 节我会详细拆这套架构的具体设计。这里想强调一句我的判断:在 GI 这个问题上,注意力机制的价值不在“多深的网络”,而在“如何定义 token 与 token 之间的关系”。结构选对了,小网络也能出大效果。
2. 核心设计:轻量级注意力机制的架构拆解
2.1 数据表示与特征编码
任何注意力模型第一步都是把输入变成 token。GI 场景里 token 就是探针。每个探针需要携带四类信息:空间位置(xyz)、朝向法线(法线方向)、物理属性(粗糙度、反照率)、光照状态(入射辐射度近似)。这四类是让模型学会“光照传播规律”的最小必要信息。
具体到特征编码,我做过几轮实验。最直接的做法是把位置、法线原样作为连续值丢进网络,效果也能跑,但收敛很慢,因为网络需要自己学会从坐标到空间关系的映射。后来参考了 NeRF 的做法,对位置和方向做高频编码:先用 sin/cos 函数把它们映射到多频段的特征空间,再拼进网络输入。这个做法的好处是让网络在高频几何变化大的区域(比如墙角、柱子边缘)更容易拟合出锐利的光照过渡。
光照状态我用的是一组二阶球谐系数,9 个浮点数就能表达低频辐射分布,足够平衡准确性和输入维度。如果你追求更高品质,也可以对每个探针存一个 6x6 的入射辐射度采样图,但网络输入会明显膨胀,训练和推理都会变慢。我后来实际项目里是“球谐打底 + 局部细节残差”两个分支,球谐分支负责低频,残差分支负责补充小范围遮挡细节,效果和纯高分辨率辐射图基本无异,但计算量小了 5 倍以上。
2.2 注意力计算流程
整个推理流程分三个阶段。阶段一是 token 化:渲染器把场景中的探针数据组织成一个 NxK 的张量,N 是探针数量,K 是特征维度。阶段二是注意力计算:把当前着色点的特征作为 Query,把所有探针的特征作为 Key,计算一个相关性权重,再把这权重作为 softmax 归一化后的系数去加权 Value。阶段三是回归出最终的间接光照值。
注意力计算的标准公式是:Attention(Q,K,V) = softmax(QK^T / √d) · V。d 是特征维度,除以它的平方根是为了让点积结果不会太大导致 softmax 梯度消失。在我这个场景里,Q 是 1xK(因为一次只处理一个着色点),K 是 NxK,V 是 NxDout。Dout 是输出维度,通常就是间接光辐照度的球谐系数维度。
这个过程中有一个我踩过坑的细节:归一化。很多初版实现直接在 softmax 上做归一化,但零遮挡的开放区域里所有探针权重均匀,softmax 会把每个权重压得很小,导致结果偏暗。我的做法是分两步:先按距离计算一个物理先验权重,再让注意力去学习一个修正系数,两者相乘后再归一化。这样既保留了物理规律,又让模型有足够的自由度去调整。
2.3 轻量化策略对比与选型
这个表格是我在多个方案实测后的对比结果,不同项目硬件条件不同选型也会有差异:
| 方案 | 计算复杂度 | 内存占用 | 遮挡处理能力 | 推荐场景 |
|---|---|---|---|---|
| 全局注意力 | O(N²) | 随探针数平方增长 | 强 | 探针极少的离线烘焙场景 |
| 局部窗口注意力 | O(N·w) | 线性 | 较强 | 实时渲染,室内为主 |
| 线性注意力 | O(N) | 线性 | 中等 | 探针密度很高的大场景 |
| 低秩注意力 | O(N·r) | 线性 | 较强 | 高性能预算有限的平台 |
| 稀疏注意力 | O(N·k) | 线性 | 强 | 遮挡复杂的建筑可视化 |
我最终在主力项目中选了“局部窗口 + 低秩近似”的组合,原因很实际:局部窗口把每帧的有效 token 数从几千降到了 64 个,低秩近似让每个 token 的计算量进一步减半。这两个策略叠加之后,整个网络单帧推理时间在 RTX 2060 上约为 1.8 毫秒,在 PS5 级别的主机上也只需要 1.2 毫秒左右。
注意:选择局部窗口时,窗口半径不能拍脑袋定。我在第一个项目里设了 2 米,结果大场景里探针密度不够,窗口内经常只有两三个探针,注意力几乎没有选择空间。后来改成“自适应半径”:先根据当前着色点周围的探针分布密度反推半径,保证窗口内至少有 16 个探针。这个经验值非常重要。
2.4 为什么不用 CNN 或者纯 MLP
很多人会问:光照插值本质上是一个空间问题,为什么不直接上卷积?原因是探针布局通常是不规则的——场景里遮挡密集的地方探针多,空旷的地方探针少,甚至动态物体周围的探针是实时更新的。CNN 需要在规则的栅格上操作,处理这种不规则分布必须先插值到规则网格,这一步等于又绕回了传统方案的老问题。
纯 MLP 也有问题:每一个输入维度的顺序是固定的,模型没有机制去区分“这个探针离我更近”和“这个探针离我更远”。注意力机制的关键区别在于它的权重是动态计算的。同一个探针,在 A 着色点看来可能权重很高,在 B 着色点看来可能接近零,这是静态的全连接层做不到的。
3. 实操:从训练到部署的全流程
3.1 训练数据采集与管理
训练数据是这类方案最难、但决定效果上限的部分。我的训练管线分三步:用离线光线追踪渲染一批基准场景,导出每个像素的间接光照真值;同时导出该场景的探针布局和特征信息;把两者配对成训练样本。
真值数据用 Mitsuba 和 Cycles 都可以。我建议至少包含这几种场景类型:室内房间(遮挡多、光照层次多)、半开放走廊(一次弹射和多次弹射并存)、室外带檐口(有遮挡但有天空光泄漏)。每种场景渲染 200 到 500 帧,帧与帧之间用不同相机随机游走,确保模型见过足够多样的视角。
数据量方面,我的一个经验值是 5 万帧左右基本够一个中小型项目使用。每帧输出分辨率不需要高,256x256 足够,但探针方向的分辨率要够,我一般用 6 个朝向 x 6 个朝向,即每探针 36 个方向做辐射度采样。这个设置可以在准确性和数据体积之间取得较好平衡。
3.2 特征结构与损失函数设计
模型的输入特征我整理成了一条固定顺序的特征向量:位置编码 24 维(位置 xyz 各用 8 维 sin/cos 编码)、方向编码 24 维、粗糙度 2 维、反照率 3 维、球谐辐射度 9 维。总共 62 维。每维都做归一化到 [-1, 1],避免个别维度尺度过大主导训练。
损失函数上,第一版只用了 L2 损失,虽然收敛快但容易“机灵过头”——在亮部区域预测得特别准,暗部直接压到全黑。后来改成 L1 + 相对损失 + 余弦相似度两项的组合。相对损失是预测值和真值除以真值的商,保证暗部也有足够的学习信号;余弦相似度用于控制预测的球谐方向分布与真值一致,而不是只关注数值大小。
具体训练配置可以抄我这份:Adam 优化器,学习率 3e-4 起步,每 20 个 epoch 衰减一次,衰减系数 0.6。Batch size 32,每个样本包含 256 个随机采样点。训练 150 epoch 大概需要 8 个小时(单张 RTX 3090),loss 可以降到 0.02 以内,再往下就比较难了,效果也足够用。
3.3 工程部署上的算力优化路径
训练好模型之后,真正的挑战在推理阶段。我在工程落地时做了三层优化:
第一层是探针管理。场景里探针数量控制在 256 到 512 个之间,每帧只更新可见范围内的探针数据,远离摄像机的探针用上一帧的缓存。这个优化直接把 80% 的重复计算去掉了。
第二层是结构剪枝。原始模型有 3 个注意力层、每层 4 个头,共 180 万参数。剪枝后降为 1 层 2 头,参数变成 60 万,精度损失在 2% 以内。关键是 attention 层的隐藏维度从 128 降到 64,输出量级没有变,因为间接光照本身就是低频信号,高维度主要贡献在遮挡边界,而遮挡边界用位置编码就能表达。
第三层是量化。我们最终的发布版用了 FP16 量化,精度下降可以忽略。如果跑在移动端,还可以做 INT8 量化,但需要在训练时加入伪量化操作(Quantization-Aware Training),否则直接后训练量化会在亮度跳变的边缘区域出现明显噪点。
提示:部署时一定记得把训练时的随机性全部关掉。Dropout 归零、BatchNorm 换成固定均值方差。这些细节我踩过很深:第一版部署时忘了关 Dropout,推理结果在同一帧画面上闪得像霓虹灯,排查了一整天才定位到这个坑。
4. 常见问题与排查技巧实录
4.1 漏光和暗部噪声:遮挡信息的关键作用
漏光是注意力机制 GI 最容易被骂的问题。漏光的根源在于模型把“两个空间相近但相互不可见”的探针赋予了过高权重。我遇到过一个极端案例:一堵很薄的天花板,楼上的强光透过地板漏到了楼下房间,整个屋顶发白。
排查过程让我发现了一个现象:仅靠位置和法线特征,模型很难学会“隔一堵墙就不该采信”。后来我在输入里加了三个额外特征:当前着色点到探针的连线是否被遮挡(一个标量)、遮挡物的视觉厚度(透射率估计)、以及两者之间的垂直空间关系。加了这三个特征后,漏光问题基本解决。
暗部噪点则是另一个方向的坑。暗部区域光照值很小,信号本身弱,模型倾向于学一个“安全的小数值”,结果就是死黑一片或者大面积噪点。我的解决方法是训练时对暗部区域加大采样权重,让模型在暗部也有足够多的监督信号。另外推理阶段对预测值做了一个软下限,防止数值降到 0 以下产生 NaN。
4.2 闪烁与时间稳定性
实时渲染里闪烁比漏光更让人头疼。注意力权重对位置非常敏感,相机一抖动,权重分布就可能剧烈变化,导致同一面墙上出现明显的“游动亮斑”。
处理这个问题我试过很多方法,最终有效的是两个组合:空间上做多帧超采样,时间上做混合缓存。空间上把单帧推理生成的结果放在一个低分辨率缓冲里,然后按高斯权重扩散到周围像素,缓冲分辨率一般是屏幕分辨率的四分之一。时间上把上一帧的结果做重投影,与当前帧结果按帧间隔比例混合。
这样处理后闪烁降低到了可以接受的程度,但注意不要混合过度:室内快速运动的物体旁边会出现拖影。我的调参经验是混合系数取 0.2 到 0.4 之间,运动越剧烈系数越低。
4.3 训练和推理效果不一致的排查
训练时 loss 下降得很好看,到引擎里一跑就废,这个问题我至少遇到三次。最典型的两个原因分别是数据分布不一致和随机操作残留。
数据分布不一致是指训练时采样的场景探针密度和运行时实际场景不一样。我有个项目训练数据里探针间距平均 0.8 米,运行时为了性能把间距拉到了 2 米,结果模型在推理时对“探针距离远”这个分布外情况完全没有鲁棒性。解决办法是在训练时随机删掉 20% 的探针,相当于做了数据增强,让模型适应不同密度的分布。
随机操作残留就是前面提过的 Dropout。排查方法很简单:用一个固定输入跑两次推理,如果输出不一样,就一定还有没关干净的随机项。
4.4 问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 墙角漏光严重 | 遮挡特征缺失 | 添加可见性标量与遮挡厚度输入 |
| 暗部偏黑、无层次 | L2 损失主导 | 换 L1+相对损失,暗部加大采样权重 |
| 画面闪烁 | 注意力权重高频抖动 | 低分辨率缓存 + 时间混合 |
| 物体运动拖影 | 时间混合系数太大 | 运动区域压低混合系数,或加运动向量自适应 |
| 大面积颜色偏灰 | 探针密度太低 | 增加探针数量或缩小局部窗口半径 |
| 训练正常、部署发疯 | Dropout/BN 残留 | 切换 eval 模式,固定归一化参数 |
| 高光区域溢出 | 球谐阶数太低 | 用二阶球谐 + 局部残差分支 |
| 动态物体阴影不准 | 探针未实时更新 | 对动态物体生成独立探针集 |
5. 我的几点体会和后续扩展方向
这个方案从想法到落地前后折腾了将近四个月,回头看最花时间的不是训练模型,而是设计一个适合注意力机制处理的场景表示。探针布局、特征选择、数据管线这些看起来“不性感”的工程问题,才是决定最终效果的关键。用一句话总结我的经验:注意力机制在光照问题上的优势,不是因为它叫“注意力”,而是因为它提供了 一种让网络自行决定空间聚合权重的方式。
实测下来有两点值得分享。一是模型规模真的不需要很大,我们最终的轻量版只有 18 万参数,推理延迟不到 1 毫秒,但漏光和闪烁指标比原来的 Irradiance Probe 方案好了几个量级。二是在做你 自己的产品时,不要把网络当成万能黑盒,把物理知识注入到输入特征里,效果提升比加深网络明显得多。
后续我想做两个方向的扩展:一是把时域信息直接建模进注意力权重里,从根本上解决动态场景残留和闪烁,而不是靠后处理混合去补;二是把轻量级注意力机制接入 ReSTIR 或者实时光线追踪的降噪管线的中间层,用它来决定哪些采样结果可信度更高。目前第二个方向已经有初步实验,在噪点更密集的焦散场景下,注意力选择的采样点质量明显优于固定权重的方案。