全局光照讲解 · 写给美术和刚入门 PBR 的人
从 PRT
到 Lumen
一盏灯照亮一面墙很容易。难的是那面墙再把光弹回房间,照亮所有灯没直接照到的角落。这份讲解从这个现象讲起,沿着二十年里的五条算法路线往下走,最后拆开 UE5 的 Lumen、5.8 新增的 Lumen Lite 和移动端的 Lumen,看它们怎么在十几毫秒里把这件事算出来。
0.1交互图
光是怎么一次次弹开的
开头的白盒是三维的。这里换成一张剖面图,把光的路径一段一段拆开看。房间左边有扇窗,阳光从窗户进来,打在地板上,这是直接光:光源直接照到的部分。
把反弹次数拖到 1。地板不是黑的,它把一部分光弹了出去,于是右墙也亮了。这就是间接光的第一次反弹。再拖到 2,右墙又把光弹向天花板。表面每次只能反射一部分能量,所以右侧的总亮度条每次涨的幅度都比上一次小。
全局光照(Global Illumination,简称 GI)指的就是第一次反弹以后的所有部分。把滑块拖回 0,就能看到没有它的画面:阴影里一片死黑。真实世界里不存在这样的房间,所以缺了 GI 的画面看起来总是假的。
这个过程也说明了它为什么难算。要知道右墙有多亮,得先知道地板有多亮;要知道天花板有多亮,又得先知道右墙有多亮。每个表面的答案都依赖别的表面的答案。第 1 章会把这件事写成公式,你会发现它正好是图下方那串 1 + ρ + ρ² + …。
动画为原理示意,不是真实渲染结果 · 动画在网页与演示模式下播放,导出 PPTX/PDF 时会变成静态图
第 1 次:直接光
阳光从窗户进来,打在地板上。这是灯光直接照到的部分。
第 2 次:间接光登场
地板不是黑的,它把一部分光弹向右墙。右墙因此亮了——这就是间接光的第一次反弹。
第 3 次之后
右墙再弹向天花板。能量越弹越少,但房间整体被"抬"亮了一层。
全局光照就是这一部分
没有它,阴影里会死黑一片。难点在于:要算第 2 次,得先知道地板有多亮;要算第 3 次,又得先知道右墙有多亮——一环套一环。后面《把渲染方程拆成一次次反弹》那一页会把这个级数写成公式。
0.2
六个比喻,后面会一直用到
后面的内容会越来越抽象。进入公式之前,先准备六个比喻。现在不用全懂,读到对应的地方时,它们能帮你把机制挂在一个熟悉的画面上。
音响的均衡器,对应球谐函数。几个频段的旋钮就能大致还原一段音乐,光照在球面上的分布也能这样拆成几个"频段"。漫反射本来就没有"高音",所以只需要低频的几个旋钮。后面"9 个系数就够"的结论,就是从这里来的。
民意调查,对应噪点。问一百个人会有误差,想让误差减半,得问四百个人。用随机光线估计亮度也服从同一条规律。实时渲染的光线永远不够多,所以噪点是这类方法的本性,不是 bug。
冰箱门上的便签,对应 Surface Cache。常做的菜抄在便签上贴在冰箱门,就不用每次翻菜谱。Lumen 把算过的表面光照记在"便签"上,光线撞到表面时直接读,不在现场重算。这是整篇最核心的一招。
夜里走路的测距仪,对应距离场。手里的仪器告诉你最近的墙在三米外,你就敢闭着眼走三米。到了新位置再测一次。离墙越近,步子自然越小,所以永远撞不上。
长曝光摄影,对应时间累积。快门开久一点,画面里的噪点就没了,但走动的人会变成一道虚影。Lumen 的间接光滞后、Lumen Lite 的黑块拖尾,付的都是这笔代价。
隔着墙问信息亭,对应探针遮挡。信息亭就在十米外,但中间隔着一堵墙,它给的答案就不适用于你这一侧。判断"中间有没有墙",是所有探针方案里最要命的一步。
比喻只为建立直觉,严格的机制在对应的页里展开
音响的均衡器
对应:球谐 SH
几个频段的旋钮就能大致还原一段音乐。光照在球面上的分布也一样。而且漫反射本来就"没有高音",所以低频那几个旋钮就够——这就是 9 个系数的来历。
民意调查
对应:噪点从哪来
问 100 个人有误差,想让误差减半得问 400 个。光线也是这条规律。所以实时渲染的采样永远不够,噪点是本质,不是 bug。
冰箱门上的便签
对应:Surface Cache
常做的菜抄在便签上,不用每次翻菜谱。Lumen 把算过的表面光照记在便签上,光线撞上去直接读。这是全篇最核心的一招。
夜里走路的测距仪
对应:距离场 SDF
手里的仪器告诉你"最近的墙在 3 米外",你就敢闭着眼走 3 米。离墙越近,步子自动越小,所以永远撞不上。
长曝光摄影
对应:时间累积
多帧叠在一起能压掉噪点,但画面里动的东西会拖出影子。Lumen 的间接光滞后、Lumen Lite 的黑块拖尾,都是这笔代价。
隔着墙问信息亭
对应:探针遮挡
亭子就在十米外,但中间隔着一堵墙,它给的答案就不能用。判断"中间有没有墙",是所有探针方案最要命的一步。
这六个比喻会在后面各自对应到具体的页。现在不用全懂,有个印象就行,到那一页会再提一次。
0.3
十五个会反复出现的词
这张表是查阅用的,不必一次记住。遇到不认识的词,可以从目录跳回这里。
其中最重要的是一组区分:辐射亮度和辐照度。前者带方向,后者不带。后面会看到,漫反射只需要后者,正是这个结论让探针方案成为可能。
- 全局光照 GI
- 光弹过几次之后照亮的那部分画面
- 直接光 / 间接光
- 光源直接照到的 / 从别的表面弹过来的
- 辐射亮度 L
- 朝某个方向看过去有多亮,带方向
- 辐照度 E
- 这一点总共收到多少光,不分方向
- 反照率 ρ
- 表面把多少光弹回去。白墙高,沥青低
- 探针 Probe
- 空间里的一个采光点,附近的像素都来查它
- 辐射度缓存
- 存放已经算过的光,避免重算
- 距离场 SDF
- 每一点都记着离最近的表面还有多远
- 体素 Voxel
- 把空间切成小方块,像三维的像素
- Clipmap
- 跟着相机走的多层网格,近处细、远处粗
- 球谐 SH
- 用几个数概括球面上的分布,像均衡器
- 八面体映射
- 把一个球面摊平成正方形纹理的办法
- 重要性采样
- 把有限的光线用在更可能有贡献的方向
- 时间累积
- 把前几帧的结果混进来,等于多了样本
- 历史失效
- 画面里新露出来的地方,没有历史可借
这里给的是便于理解的说法,严格定义在后面各自的小节里。
这里给的是便于理解的说法,严格定义见后面各页
全局光照 GI
光弹过几次之后照亮的那部分画面
直接光 / 间接光
灯直接照到的 / 从别的表面弹过来的
辐射亮度 L
"朝某个方向看过去有多亮",带方向
辐照度 E
"这一点总共收到多少光",不分方向
反照率 ρ
表面把多少光弹回去。白墙高,沥青低
探针 Probe
空间里的一个采光点,附近像素都来查它
辐射度缓存
存"已经算过的光"的地方,避免重算
距离场 SDF
每一点都记着"离最近的表面还有多远"
体素 Voxel
把空间切成小方块,像三维的像素
Clipmap
跟着相机的多层网格,近处细、远处粗
球谐 SH
用几个数概括球面上的分布,像均衡器
八面体映射
把一个球面摊平成正方形纹理的办法
重要性采样
把有限的光线用在更可能有贡献的方向
时间累积
把前几帧的结果混进来,等于多了样本
历史失效
画面里新露出来的地方,没有历史可借
加粗的那几个是全篇的主角:辐照度、探针、缓存、时间累积。认住它们,后面一大半内容就是它们的组合。
这张表是查阅用的,不必一次记住。遇到不认识的词可以翻回来。
0.4
怎么读这份讲解
全篇十一章加四个附录,共 115 节,按顺序分成 8 个页签,就是页面顶部那一排。每个页签是一次可以坐下来读完的量,读完一个再进下一个。
一次一个页签,按顺序来。推导不要跳,后面的工程取舍全靠它们来解释。
渲染方程和噪声,DDGI,Lumen 的缓存、追踪和最终收集,最后是附录里的代码地图和 Pass 调度。页签 2 的球谐推导可以按需回看。
开场的白盒、Surface Cache 和薄墙漏光、调参与排错。公式可以用右上角的"公式"按钮收起。
先读懂缓存、追踪和收集,再看 Pass 调度,最后跟着附录 C 的八个里程碑动手,每一步都有完成标准和坑。
第 9 章的档位与排错、第 10 章 Lumen Lite 与移动端,再配合附录里的系统流图、Pass 开关和预算速查。
每个页签开头有一张卡片,列出它包含的章节和预计时间;读到页签末尾,按钮会带你进入下一个页签。页签会记住你在里面读到哪里,切回来时接着读。也可以按数字键 1–8 直接切换,或者把地址栏里带 #t3 这样后缀的链接发给别人,对方会直接打开那个页签。
右上角的演示按钮会把同一份内容切换成一页一屏的幻灯片,用 ← → 翻页,按 N 显示讲稿,适合讲给别人听。阅读时按 T 打开目录,目录可以跨页签跳转。
每一节末尾标了出处。证据分三档:官方文档与版本说明,可以直接引用;2022 年 SIGGRAPH 演讲里的数字,结构可靠,但具体数值可能已经变了;社区报告和本文的推断,对外讲之前请自己验证。附录的最后一节列了完整的参考资料。
路线划分是建议,按听众调整
完整课程
8 个页签,一次读一个,按顺序来。
程序向
页签 1、3、4、5、7:渲染方程 → DDGI → 缓存与追踪 → 最终收集 → Pass 调度。
美术 · 技术美术
页签 1、4、6:白盒、Surface Cache 与漏光、调参排错。
想自己实现
页签 1、4、5、7、8:原理 → 调度 → 八个里程碑动手。
决策向
页签 6、7:档位、Lumen Lite、移动端、系统流图与 Pass 开关。
页签会记住读到哪里
数字键 1–8 切换页签;地址后缀 #t3 可以直接分享某个页签。
00
这份讲解怎么走
全篇十一章加四个附录,分成八个页签。开场先让你看见间接光;第 1 章打数学的底;第 2、3 章走一遍五条算法路线,预计算一章、实时路线一章;第 4 到 8 章按管线顺序拆开 Lumen;第 9 章落到调参和内容规范;第 10 章讲 UE 5.8 的 Lumen Lite 和移动端;第 11 章收尾。附录 A 把所有部件拼回系统流图和代码地图;附录 B 讲引擎里的 Pass 必要与否、怎样调度,最后是参考资料;附录 C 带你按八个里程碑自己写一个 Lumen,并列出一路上的坑;附录 D 讲程序和美术怎样分层控制它、怎样按区域调。
00 · 开场
间接光是什么 · 六个比喻 · 术语
01 · 数学基础
蒙特卡洛 · L 与 E · 反弹级数
02 · 谱系(上):预计算
光照贴图 · 球谐 · PRT 推导与存储账
03 · 谱系(下):实时路线
体素 · 屏幕空间 · DDGI 推导 · 光追
04 · 思想与管线
三层缓存 · 三个 Pass · 一帧的读写
05 · 场景表示
卡片 · 图集 · 打光 · 多次反弹
06 · 追踪
HZB · 距离场 · Clipmap · 剔除
07 · 最终收集
探针 · 重要性采样 · 降噪 · 世界缓存
08 · 反射与硬件光追
分流 · 光线压缩 · Far Field · 实测
09 · 调参与落地
旋钮 · 档位 · 调试视图 · 清单
10 · Lumen Lite
换了哪四块 · 代价 · 评估顺序
11 · 收尾
常见误解 · 总结
A · 工程全景
路线图 · 系统流图 · 预算速查 · 参考资料
01
数学基础
全局光照难,不是因为光线追踪慢,而是因为渲染方程是一个自己引用自己的积分。
开场那个房间里,地板弹亮了墙,墙又弹亮了天花板。看起来只是多弹几次,但真要让计算机来算,马上会撞上两堵墙。
第一堵是数量:每个像素都要知道半球上所有方向射来多少光,而方向有无穷多个。第二堵是递归:要知道墙有多亮,得先知道地板有多亮,而地板的亮度又取决于墙。这一章用四节把这两堵墙讲清楚。后面所有的算法,不管是二十年前的 PRT 还是今天的 Lumen,都是在想办法绕开它们。
1.1
为什么实时全局光照这么难
先把问题摆清楚。离线渲染器算一帧可以花几分钟,游戏只有十几毫秒。在这十几毫秒里,全局光照有三件事很难做到。
第一,光线数量巨大。每个像素都想知道半球上各个方向射来多少光,而每个方向都要派一条光线去看。
第二,光线打中之后还要算光。光线撞到一个表面,得先求出那里的材质,再求那一点自己收到了多少光,而后者本身又是一个全局光照问题。很多人以为光线追踪贵在"追",其实相当一部分成本花在命中之后。
第三,结果不能提前烘焙。把光照预先算好存进贴图,在静态场景里效果极好;但光源一动、门一开、物体一挪,贴图就作废了。
Lumen 对这三件事的总体回答是:把算过的光照缓存起来,反复复用。
这句话现在还很抽象。这一章剩下的三节先把数学基础打好:间接光为什么是一个积分、为什么一算就有噪点;积分里出现的两个量到底是什么;以及这个积分为什么会"自己引用自己"。
光线数量巨大
每个像素都要追踪大量光线,才能收集各个方向的间接光
命中点要算光
光线打到表面后,还要先求材质,再求这一点的入射光
无法预先烘焙
光源、物体和材质随时变化,光照贴图会立刻失效
Lumen 的答案:把算过的光照缓存起来,反复复用。
1.2算法科普
蒙特卡洛积分:为什么会有噪声
先说清楚"间接光"在算什么。站在地板上的一个点抬头看,是一整个半球:天花板、墙、盒子、灯。这一点有多亮,取决于半球里每个方向射过来多少光,全部加起来。
把半球里每个方向来的光加起来。正对着表面的方向算足分,斜着来的打折扣;最后乘上表面自己的颜色。
L = ∫Ω Lin(ω) · cos θ · ρ/π dω
- ∫Ω … dω
- 把半球里的每个方向都加一遍
- Lin(ω)
- 从方向 ω 射来的光
- cos θ
- 斜射打折,越斜贡献越小
- ρ
- 表面颜色(反照率)
方向有无穷多个,计算机没法一个个加。实时渲染的办法叫蒙特卡洛,说白了就是抽样调查:随机挑几个方向,各派一条光线去看,把看到的结果平均一下,当作答案。
随机挑 N 个方向去看,把结果平均。如果某个方向被挑中的机会比别的大,它的结果要打个折,免得被重复计算。
L ≈ (1/N) Σi f(ωi) / p(ωi)
- N
- 派出去的光线条数
- f(ωi)
- 第 i 条光线带回来的结果
- p(ωi)
- 这个方向被挑中的机会
- Σ
- 全部加起来
抽样就会有误差,误差在画面上就是噪点:每个像素随机挑的方向不同,有的碰巧多看到了灯,有的碰巧没看到,相邻像素于是明暗不一。下面这个白盒每一帧只给每个像素几条光线,而且不做任何叠加,你看到的就是原始的噪点:
拖动光线数时会发现一件反直觉的事:从 1 条加到 16 条,花了 16 倍的力气,噪点却只少到原来的四分之一左右。这是抽样的铁律:
想让噪点减半,光线要变成四倍。
误差 ∝ 1 / √N
- √N
- 光线条数的平方根
- ∝
- "跟着……成比例变化"
这就是开场那个"民意调查":问一百个人有误差,想让误差减半,得问四百个人。离线渲染器每个像素可以追几千条光线,游戏一帧只有十几毫秒,每个像素只给得起个位数。
实时渲染不可能靠堆光线解决噪点,只能想办法让每条光线更值钱。
Lumen 用了三招:重要性采样让光线朝更有用的方向去;探针让一条光线服务一片像素;滤波和时间累积把邻居和过去几帧的光线也借来用。白盒里的"开始累积"就是第三招:每一帧的结果叠在一起,噪点很快消失。这三招在第 7 章依次展开。
在 UE 里你见过它切到 Path Tracer 视口模式,画面一开始满是噪点,停住不动之后越来越干净。这是同一件事:它在不停地多追光线,把结果平均。
漫反射间接光 = 对半球入射方向的积分
L = ∫ Lin(ω)·cosθ·ρ/π dω
蒙特卡洛:用 N 条随机光线估计它
L ≈ (1/N) · Σ f(ωi) / p(ωi)
f 是单条光线的贡献,p 是选中这个方向的概率;除以 p,换一种采样方式结果也不偏。
光线数 N 与噪声
N=1
N=4
N=16
N=64
光线数 ×4,噪声只减半(∝ 1/√N)
重要性采样
让每条光线更有用
探针共享
一条光线服务多个像素
滤波与时间累积
把邻居和历史当作额外光线
1.3
辐射亮度 L 与辐照度 E:GI 到底在求什么
上一节的公式里藏着两个量。它们的区别是读懂后面所有算法的钥匙:后面每一种方法的差别,归根结底都是一个问题,存 L,还是存 E。别被字母吓到,它们都是日常就能理解的东西。
青色虚线是法线 n。每条橙线是一个入射方向 ω,它带来一份辐射亮度 L(ω)。
辐射亮度 L(radiance)是"朝某个方向看过去有多亮"。用相机对着一面墙拍,照片上每个像素记录的,就是从那个方向射进镜头的 L。它带方向:同一个点,从不同角度看可能不一样亮,比如镜子。
它有一个关键的性质:沿着光线走,L 不会变。正因为如此,"派一条光线出去,看它撞到的地方有多亮"才说得通,这是整个光线追踪成立的前提。
辐照度 E(irradiance)是"这一点总共收到多少光",不分方向。在地板上放一个测光表,读出来的就是 E。
E 就是把头顶半球里每个方向的 L 收集起来:正对着表面来的光算足分,斜着来的打折,贴着表面擦过的几乎不算。
E(x) = ∫ L(x, ω) · (n · ω) dω
- x
- 表面上的那一点
- ω
- 某一个方向
- n · ω
- 表面正前方和这个方向的夹角有多小,就是"斜射打折"
- dω
- 一小块方向,像把天空切成小格
为什么斜着来的要打折?拿手电筒照桌面就明白了:正对着照,光圈小而亮;斜着照,同样多的光摊成一个长椭圆,每一小块分到的就少了。
漫反射表面,比如白墙、粗糙的地面,有一个很省事的性质:它朝各个方向反射得一样亮。所以它看起来有多亮,只取决于它收到了多少光,也就是 E:
漫反射表面看起来的亮度 = 它收到的光 × 它自己的颜色 ÷ π。除以 π 只是为了保证反射出去的光不会比收到的还多。
Lo = (ρ / π) · E
- Lo
- 这一点看起来有多亮
- ρ
- 表面颜色,也就是反照率
- π
- 3.14,保证能量不会凭空变多
漫反射只需要 E 这一个数,不需要知道光从哪个方向来。
这一行是后面"探针只存辐照度"的全部理由,DDGI 和 Lumen Lite 都建立在它上面。镜子和光滑的金属就不一样了:它们映出的是某个特定方向的景物,必须知道光从哪个方向来,一个 E 不够用。漫反射和反射在 Lumen 里要分两条路走,根源就在这里。
在 UE 里你见过它Directional Light 的强度单位是 lux,衡量的是"地面收到多少光",这是一种 E。Sky Light 抓下来的那张立方体贴图,每个像素记录一个方向射来的光,那是 L。
本页为标准辐射度量学定义 · 图为二维示意
立体角 ω · 单位 sr
方向的"面积"。整个球是 4π,上半球是 2π。积分符号下的 dω 就是在扫过这些方向。
辐射亮度 L · radiance
单位面积、单位立体角上的功率。它沿光线传播不变,所以"追一条光线、取回一个 L"才成立。这是整个光追的前提。
辐照度 E · irradiance
E(x) = ∫ L(x, ω) · (n·ω) dω
这一点从整个上半球收到的总量。它是一个数(每色彩通道一个),不再带方向。
漫反射的出射
L_o = (ρ / π) · E
ρ 是反照率。π 的来历就是 ∫(n·ω)dω = π,用来做归一化,保证能量不放大。
结论:漫反射只需要 E 这一个数,不需要完整的 L(ω)。整个"探针存辐照度"的做法,理由就在这一行。
反过来,光滑反射需要知道"从哪个方向来的",E 不够用,必须保留 L(ω)。这就是后面漫反射和反射要分两条路走的根源。
1.4
把渲染方程拆成一次次反弹
现在可以回答开场那个房间留下的问题了:为什么"要算墙得先算地板"会让问题这么难。
把上一节的思路写完整,就是图形学里最有名的一个式子,渲染方程:
一个点看起来有多亮 = 它自己发的光 + 它从四面八方收到、再按材质反射出去的光。
Lo(x, ωo) = Le + ∫ fr · Li(x, ωi) · (n · ωi) dωi
- Lo
- 这一点朝相机看起来有多亮
- Le
- 它自己发的光:灯才有,墙是 0
- fr
- 材质,光照上来以后怎么反射(BRDF)
- Li
- 从某个方向射进来的光
难点藏在 Li 里。从某个方向射进这一点的光是哪来的?是从那个方向看过去,撞到的那一点反射出来的光:
我收到的光,就是别人看起来有多亮。而别人有多亮,又要用同一个式子去算。
Li(x, ωi) = Lo(y, −ωi)
- y
- 从 x 朝 ωi 看过去撞到的那一点
- −ωi
- 反过来的方向:从 y 射向 x
所以等号两边都有我们要求的那个"亮度"。这就像两面相对的镜子:你在一面里看到另一面,另一面里又看到这一面,一层套一层。这是一个自己引用自己的方程,没法直接算出来。
换个写法会清楚很多。把"收集四面八方的光、按材质反射出去"这一整套动作起个名字,叫 T,意思是"让光再弹一次":
画面 = 光源本身 + 把整个画面再弹一次。
L = Le + T L
- T
- "让光再弹一次"这个动作
把右边的 L 换成整个右边,反复代入,就展开成一串加法:
最终画面 = 光源 + 弹一次的光 + 弹两次的光 + 弹三次的光 + ……一直加下去。
L = Le + T Le + T² Le + T³ Le + …
- T² Le
- 灯光弹了两次之后的那部分
这串加法的每一项都有具体的样子:第零项是直接看见的灯,第一项是直接光照,第二项是第一次反弹的间接光。开场白盒里的"反弹次数",拖的就是这串加法加到第几项。下面这个白盒多了一个"墙面反照率",可以试试它对这串加法的影响:
每一项比上一项小多少,取决于表面能把多少光弹回去,也就是反照率。深色的室外场景反照率大约 0.2,每一项依次是 100%、20%、4%,弹两次基本就到头了。白墙的室内大约 0.8,依次是 100%、80%、64%,弹到第三次还剩六成以上。
白墙的房间要攒很多次反弹才算得准。这就是 Epic 把"室内干净的光照"列为更难目标的原因。
这串加法后面会通向两条路。Lumen 的 Surface Cache 每更新一轮就多攒一次反弹,本质上是在一项一项地往上加。PRT 的想法更大胆:如果房间里什么都不动,T 能不能整个提前算好?下一章就从这里出发。
标准推导 · 柱状图为按 ρ 的幂次估算的示意,不是实测
① 渲染方程
L_o(x,ω_o) = L_e + ∫ f_r · L_i(x,ω_i) · (n·ω_i) dω_i
L_e 是自发光,f_r 是 BRDF。看着像个普通积分。
② 递归藏在这里
L_i(x,ω_i) = L_o(y, −ω_i)
y 是沿 ω_i 看过去命中的那个点。所以等号两边都有未知的 L —— 这是一个积分方程,不是一道积分题。
③ 写成算子
L = L_e + T·L
把那个积分整体记作传输算子 T,意思是"再弹一次"。方程瞬间变清爽。
④ 展开成级数
L = L_e + T·L_e + T²·L_e + T³·L_e + …
像 1/(1−x) 的展开。第 n 项,就是恰好弹了 n 次的光。
每一项的衰减取决于反照率 ρ
深色室外 ρ≈0.2
100%
20%
4%
弹两次基本就到头了
白墙室内 ρ≈0.8
100%
80%
64%
弹三次还有六成,所以室内更难
这个级数解释了两件事
为什么"室内干净光照"被 Epic 列为更难的目标:白墙反照率高,级数衰减慢,要攒更多项才收敛。
往下通向两条路
Lumen 的"每轮更新多一次反弹",就是在逐项累加这个级数。
而 PRT 的做法更大胆:几何固定的话,T 的作用能不能整个预先算出来?
页签 2 / 8
GI 谱系 · 预计算
五条路线的全貌,然后从最早的一条讲透:光照贴图、球谐、PRT 的推导,以及它为什么走不进动态场景。
02
GI 谱系(上):预计算
五条路线先看全貌,再从最早的一条讲起:把积分提前算好。球谐和 PRT 的推导都在这一章。
上一章的结论有点令人沮丧:积分算不完,算不完就有噪点,想把噪点减半就要四倍的光线。实时渲染每帧只有十几毫秒,正面硬算是走不通的。
所以过去二十年,大家想的都是怎么不正面算。有人把结果提前算好存起来,有人只用屏幕上现成的信息,有人把空间切成小方块,有人在空中撒一把采光点。这一章先用一个比喻把五条路线串起来,然后讲最早的一条:预计算。光照贴图、球谐、PRT,以及 PRT 为什么走不进动态场景。另外四条留到下一章。
2.1先用一个比喻
如果 GI 是"在城里问路"
五条路线各有各的术语,直接讲容易散。先用一个比喻把它们串起来,后面每一节都能挂回这里。
把全局光照想成在城里问路。每个表面都在问同一个问题:我周围有多亮?各种算法的区别,只在于谁去问、问多少次、问到的答案记在哪。
| 做法 | 相当于问路时…… | 它的毛病 |
|---|---|---|
| 烘焙 / PRT | 提前把全城路线印成一本册子,用时翻册子 | 一修路,整本册子作废 |
| 屏幕空间 | 只问视线里看得见的那几个人 | 看不见的人,问不到 |
| 体素 | 把城市简化成粗格子,再按格子问 | 会"穿墙",给出错答案 |
| 探针 | 城里布一批信息亭,就近去问 | 亭子间距决定精度;隔着墙的亭子得排除 |
| 光线追踪 | 真的派个人跑一趟去看 | 准,但慢 |
| Lumen | 上面几种全用,而且把问到的答案记在本子上 | 本子大小固定,所以要按需换页 |
表里的五种做法各有毛病。Lumen 的选择是几种全用,再加一个关键动作:把问到的答案记在本子上,下次有人问同一个地方,直接照着念。这个本子就是 Surface Cache,也就是开场那张冰箱门上的便签。本子的大小是固定的,所以要按需换页,这个限制后面会反复出现。
所以 Lumen 不是又一种 GI 算法,而是一次组合。
这是这一章和下一章的主线。下面一条一条地看。
比喻只为建立直觉,严格的机制在后面各页 · 路线的分法是本文稿为讲解方便所做
每个表面都在问同一个问题:"我周围有多亮?" 各种 GI 算法的区别,其实只在于谁去问、问多少次、答案记在哪。
所以 Lumen 不是"又一种 GI 算法",而是一次组合。接下来一条条看这几种做法各自解决了什么。
"记在本子上"这件事就是 Surface Cache,也就是前面那张冰箱门便签。
2.2
五条路线,最后汇到 Lumen
按思路分,GI 的技术谱系大致是五条路线,每条里按时间排。年份是公开发表的大致时间;分路线是为了讲解方便,实际的发展互有交叉,比如屏幕空间的 SSAO 其实比体素路线还早。
- 预计算光照贴图 → PRT(2002)→ 静态光照探针
离线把积分算好存起来。画质上限最高,但几何不能动。
- 体素LPV(约 2010)→ 体素锥追踪(2011)
把场景装进三维网格。第一次做到全动态,但分辨率低、容易漏光。在 Lumen 里:距离场与 Voxel Lighting
- 屏幕空间SSAO(2007)→ SSGI、SSR
只用屏幕上现成的深度和颜色。几乎免费,但屏幕外的东西不存在。在 Lumen 里:追踪的第一级
- 探针DDGI(2019)→ RTXGI
在世界空间铺一张辐照度探针网格,每个探针还存遮挡信息来防漏光。在 Lumen 里:Lumen Lite 走的就是这条路
- 光线追踪硬件光追(2018 起)→ ReSTIR(2020 起)
求交终于能实时,噪声靠跨时空复用样本来压。在 Lumen 里:可选的求交后端,以及时空复用的思想
Lumen 同时用上了后四条,唯一放弃的是预计算,因为它的目标就是全动态。接下来按顺序看每一条:它解决了什么,又留下了什么。这一章讲预计算,其余四条在下一章。
年份为公开发表的大致时间,按通行说法;分路线是为了讲解方便,实际发展互有交叉
预计算
光照贴图
把积分好的间接光存进纹理
PRT · 2002
把光传输预计算成算子
静态光照探针
给动态物体查表
体素
LPV · 约 2010
在网格里迭代传播光
体素锥追踪 · 2011
在体素 mip 里用锥追踪
遗产:Lumen 的距离场与 Voxel Lighting
屏幕空间
SSAO · 2007
只用深度做遮蔽
SSGI / SSR
复用已有的屏幕数据
遗产:Lumen 的屏幕追踪,第一级
探针
DDGI · 2019
辐照度场 + 探针遮挡
RTXGI
把它做成可落地的实现
这就是 Lumen Lite 走的路
光线追踪
硬件光追 · 2018 起
求交终于能实时
ReSTIR · 2020 起
跨空间与时间复用样本
遗产:可选的求交后端 + 时空复用
Lumen 同时用上了后四条,唯一放弃的是预计算——因为它的目标就是全动态。接下来一条条看。
2.3
起点:把积分的结果烘进贴图
从最简单的方法说起,因为它定义了后面所有方法都要对抗的基准。
光照贴图的做法是:离线阶段让光线在场景里反复弹射,把每个表面点收到的间接光积分好,存进一张纹理。
这是一笔很划算的交易:用离线时间换运行时成本。积分算多久都行,反正只算一次,所以画质上限可以非常高;运行时只要一次纹理采样,几乎免费。
代价也很清楚:几何和光源都不能动,改一处就要重烘;模型要摆好第二套 UV;贴图既占磁盘也占内存。但它并没有过时。在纯静态的场景里,它依然是画质和成本的最优解,Lumen 也不是来取代它的。
接下来的问题是:如果只想换光照,比如让太阳转一圈,而几何完全不动,有没有办法不重烘?答案是 PRT。不过 PRT 要借一个数学工具,下一节先把它补上。
本页为通用原理铺垫,不针对某个具体实现
左:离线阶段,光线在场景里反复弹射,几分钟到几小时。右:结果烘成一张纹理,运行时一次采样就够。
它的交易是什么
用离线时间换运行时成本。积分算多久都行,因为只算一次。
代价
几何和光都不能动;要摆 UV;贴图占磁盘和内存;改一处就要重烘。
为什么今天还在用
画质上限最高,运行时近乎免费。纯静态场景里它依然是最优解。
往下怎么走
如果只是想换光照,而不换几何,有没有办法不重烘?这个问题的答案就是 PRT——不过它要先借一个数学工具,下一节补上。
2.4算法科普
为什么漫反射只要 9 个系数
第 1 章说过,漫反射只需要"这一点总共收到多少光"这一个数。但如果想提前存好一个位置的光照,让任意朝向的表面放到这里都能查到,就得存下"每个方向射来多少光",也就是一张包住这一点的球形光照图。直接存一张小 HDRI 太大了。球谐函数(Spherical Harmonics,简称 SH)是用来压缩它的工具。
理解球谐最好的类比是音响的均衡器。一段音乐可以拆成低音、中音、高音几个频段,每个频段一个推子;只保留低音和中音,曲子的大致样子还在,只是细节没了。球谐对"球面上的光照"做的是同一件事:
第 0 段只有 1 个推子,是整体有多亮,相当于环境光。第 1 段有 3 个推子,描述"哪一边更亮":上亮下暗、左亮右暗、前亮后暗。第 2 段有 5 个推子,描述更细一点的明暗变化,比如"上下都亮、腰上一圈暗"。三段一共 1 + 3 + 5 = 9 个数。
求推子的位置:拿原始光照和每一种"标准明暗图案"比一比,像多少,推子就推多高。还原时,把每个图案按推子的高度叠起来。
Li = ∫ L(ω) · Yi(ω) dωL(ω) ≈ Σi Li · Yi(ω)
- Yi
- 第 i 种标准明暗图案(基函数)
- Li
- 第 i 个推子的高度(系数)
- L(ω)
- 原始的、每个方向的光照
这套"比一比再叠回去"的做法能成立,靠的是这些标准图案互不干扰(数学上叫正交):任意两种不同的图案"比一比",结果都是零。就像均衡器的推子各管各的频段,推一个不会影响另一个。
为什么只要 9 个就够?关键在于漫反射本来就"没有高音"。辐照度是把半球里的光按"越正越重"平均起来,而平均本身就是一种模糊。高频的细节,比如一盏小灯的锐利轮廓,平均之后几乎全被抹掉了:
算辐照度时,每一段的推子都会被乘上一个固定的衰减系数:第 0 段乘 3.14,第 1 段乘 2.09,第 2 段乘 0.79,从第 3 段开始几乎乘 0。所以第 3 段以后的推子推得再高,也几乎影响不到结果。
E(n) = Σl Σm Al · Llm · Ylm(n)
- Al
- 第 l 段的衰减系数:π、2π/3、π/4、≈0……
- n
- 表面朝向(法线)
- l, m
- 第几段、段里的第几个推子
下面在一个圆上把这件事演一遍。圆是球的二维版本,圆上的傅里叶展开和球谐是同一个原理,只是每一段的系数个数不同。拖动段数,同时看左右两边:
9 个数够用,不是凑合,而是漫反射本身就像一个模糊滤镜,把高频全滤掉了。
Ramamoorthi 和 Hanrahan 在 2001 年给出的结论是:用这 9 个数,任意光照下辐照度的平均误差在 3% 以内。运行时还能把它整理成一个 4×4 的小矩阵,查一次光照只要一次矩阵乘法,非常便宜。
它的边界也要说清楚:镜面高光是"高音",9 个推子根本表达不了。所以球谐只服务漫反射,反射必须另走一条路。这条边界后面会反复出现。
在 UE 里你见过它Sky Light 给物体的漫反射照明,引擎内部会先把天空压成一组球谐系数;Lightmass 烘出来的 Volumetric Lightmap,给动态物体提供间接光,存的也是球谐系数。
Ramamoorthi & Hanrahan, An Efficient Representation for Irradiance Environment Maps, 2001 · A_l 数值以原文为准
它是球面上的傅里叶展开
基函数按"带"分组:第 l 带有 2l+1 个。前三带 1 + 3 + 5 = 9 个。
正交归一是一切的前提
∫ Y_i(ω) · Y_j(ω) dω = 1 当 i=j,否则 0
投影:求系数
L_i = ∫ L(ω) · Y_i(ω) dω
重建:还原函数
L(ω) ≈ Σ_i L_i · Y_i(ω)
关键:余弦卷积把高频压掉了
E(n) = Σ_l Σ_m A_l · L_lm · Y_lm(n)
辐照度 = 入射亮度与余弦核的卷积。在球谐域里,卷积退化成逐带乘一个常数 A_l。而 A_l 掉得极快:
π ≈ 3.14
A₀
2π/3 ≈ 2.09
A₁
π/4 ≈ 0.79
A₂
迅速趋零
A₃ 以后
所以第 3 带以后的信息,乘完 A_l 基本就没了——存它们是浪费。
所以 9 个就够
Ramamoorthi 与 Hanrahan(2001)给出:9 个系数(到 2 阶)即可,任意光照下平均误差在 3% 以内。
运行时更便宜
可以整理成 E(n) = nᵀ·M·n,M 是由那 9 个系数线性组成的 4×4 矩阵。一次矩阵乘就出结果。
它的边界
镜面高光是高频的,9 个系数表达不了。所以球谐只服务漫反射,反射必须另走一条路。
2.5
PRT:预计算的不是光,是"光怎么传过来"
有了球谐,就可以讲 PRT 了。它是 Sloan 等人 2002 年提出的,全称 Precomputed Radiance Transfer,预计算辐射传输。
普通的光照贴图烘的是"这一点有多亮",灯一动就作废。PRT 烘的是"这一点对各个方向的光有多敏感"。打个比方:光照贴图像一张拍好的照片;PRT 则记下了"这个位置头顶被遮住了多少、旁边的墙会挡掉多少、墙又会把多少光反过来",然后无论天空怎么变,都能立刻算出这里该多亮。
具体做法是两组 9 个数,一一对应相乘再相加:
运行时,把当前的天空压成 9 个数;离线时,每个点已经存好了自己的 9 个"敏感度"。两组数对应相乘再加起来,就是这一点的亮度。
亮度 = l · t
- l
- 当前天空的 9 个球谐系数,换天光只换它
- t
- 这一点的 9 个"敏感度",离线算好,自遮挡和墙面反光都包在里面
- ·
- 对应相乘再相加(点积)
运行时的工作量,从"把半球重新积分一遍"降到了"九次乘加"。软阴影和墙面反光都是免费的,因为它们已经包在 t 里了。
下面这张图在一个二维的场景里真的跑了一遍 PRT:地板上一点,右边一堵墙。墙挡掉了哪些方向,离线时已经算进了 t;运行时转动灯,只用几次乘加算亮度,再和逐方向积分出来的真实值对比。
这笔交易买到的是光照可以实时换:转天光、改天气、昼夜循环,都不用重烘。没买到的是几何仍然不能动,因为 t 是对着固定的几何算出来的。而且它通常只支持低频的光照;想要光滑的材质,t 就得从 9 个数升级成一张 9×9 的表,存储量跟着暴涨。
它留给今天的遗产是球谐这个工具。2002 年的数学在今天的引擎里还活着:Lumen 的屏幕探针,照样是把六十多条光线压成 9 个球谐系数。
PRT:Sloan 等,2002 · 图中格子数为示意,系数按球谐阶数而定
环境光 L
运行时把环境光投影成 9 个系数。换天光只换这 9 个数。
·
传输算子 T
离线算好、每个点一组。它编码了自遮挡与互反射。
=
一次点积,得到这一点的亮度
运行时的工作量,从"重新积分"降到"九次乘加"。软阴影和互反射已经包在 T 里了。
漫反射用向量点积;要支持光滑材质,T 就得升级成矩阵,存储量随之上去。
买到了什么
光照可以实时换:转天光、改天气、昼夜变化,都不用重烘。
没买到什么
几何仍然不能动——T 是对固定几何算的。通常也只支持低频光照,存储量不小。
留给今天的遗产
球谐这个工具一直沿用到现在。Lumen 的屏幕探针,照样是压成 9 个 SH 系数。
2.6推导
从一个积分,推到一次点积
上一节的 l · t 是怎么来的?推一遍只要五步。每一步都配了白话,不看符号也能跟下来。
第一步,写出要算的东西。一个不动的物体泡在天光里,它某一点有多亮:
这一点的亮度 = 把头顶每个方向的天光加起来,被挡住的方向不算,斜射的打折,最后乘上表面颜色。
Lo(x) = (ρ/π) ∫ Lenv(ω) · V(x, ω) · (n · ω) dω
- Lenv(ω)
- 天空在方向 ω 上有多亮
- V(x, ω)
- 从这一点朝 ω 看,能不能看见天空:能为 1,被挡为 0
第二步,把天空压成 9 个数。用 2.4 节的球谐,天空 ≈ 9 种标准图案按推子高度叠起来:
天空 ≈ 9 种标准明暗图案,各乘一个系数再叠加。
Lenv(ω) ≈ Σi li · Yi(ω)
第三步,代进去,调换先后顺序。"先叠加再积分"和"先积分再叠加"结果一样;推子高度 li 是常数,可以挪到积分外面:
亮度 = 9 个天空系数,各乘一个"只和几何有关的积分",再加起来。
Lo(x) ≈ (ρ/π) Σi li ∫ V(x, ω) · Yi(ω) · (n · ω) dω
第四步是整个推导的关键。看剩下的那个积分:里面已经没有天光了,只剩"能不能看见天空"、标准图案和斜射打折,全是几何的东西。房间不动,它就永远不变,可以离线算好存起来:
这一点对第 i 种明暗图案的"敏感度":只看几何,跟现在天空长什么样无关。
ti(x) ≡ ∫ V(x, ω) · Yi(ω) · (n · ω) dω
- ti
- 传输系数,也就是第 i 个"敏感度"
- ≡
- "定义为"
第五步,代回去。
亮度 = 表面颜色 ÷ π ×(9 个天空系数和 9 个敏感度,对应相乘再相加)。
Lo(x) ≈ (ρ/π) · ( l · t(x) )
九次乘加,结束。
这招能成立,是因为光照和几何被干净地拆开了:l 只管天空,t 只管几何。
房间不动就缓存 t,换天光只换 l。如果材质是光滑的,反射出去的光也有方向,9 个敏感度就得升级成一张 9×9 的表,存储量暴涨,下一节来算这笔账。
PRT 的死穴也写在第四步里:t 里面有 V,"能不能看见天空"。几何一动,V 就变,t 立刻全部作废。这不是性能不够,而是结构上的限制,也是 PRT 进不了动态场景的根本原因。
PRT:Sloan, Kautz, Snyder, 2002 · 推导为标准形式,省略了归一化细节
1
L_o(x) = (ρ/π) ∫ L_env(ω) · V(x,ω) · (n·ω) dω
目标场景:静态几何泡在环境光里。V 是可见性——这个方向看得见天空吗,自遮挡就藏在它里面。
2
L_env(ω) ≈ Σ_i l_i · Y_i(ω)
把环境光投影到球谐。这一步用的就是球谐那一页的投影公式。
3
L_o(x) ≈ (ρ/π) Σ_i l_i ∫ V(x,ω)·Y_i(ω)·(n·ω) dω
代回去,再把求和与积分交换顺序。l_i 是常数,可以提到积分外面。
4
t_i(x) ≡ ∫ V(x,ω)·Y_i(ω)·(n·ω) dω
整页的枢纽:积分里完全没有光照,只剩几何。所以它可以离线算,这就是"传输系数"。
5
L_o(x) ≈ (ρ/π) · ( l · t(x) )
九次乘加,结束。软阴影与互反射都已经包在 t 里,运行时不用再算。
为什么这招能成立
因为 l 只依赖光照、t 只依赖几何,两者被干净地分开了。几何不变就缓存 t,换光只换 l。
光滑材质要升级成矩阵
出射也带方向时,t 变成矩阵 M:
L_o(ω_o) ≈ Σ_j ( Σ_i M_ij·l_i ) Y_j(ω_o)
死穴
几何一动,V 就变,t 立刻作废。这是 PRT 进不了动态场景的根本原因,不是性能问题。
2.7
存储量:为什么 PRT 只能做漫反射
光看公式不够直观。把 PRT 的存储账算出来,就能明白这条路线后来的命运。
| 版本 | 每点的系数 | 每点字节(fp32) | 10 万点 | 100 万点 |
|---|---|---|---|---|
| 漫反射(向量 t) | 9 × 3 通道 = 27 | 108 B | 10.8 MB | 108 MB |
| 光滑(矩阵 M) | 9×9 × 3 = 243 | 972 B | 97 MB | 972 MB |
漫反射版每个点存 9 个系数,每个系数有 RGB 三个通道,一共 27 个浮点数,按 fp32 是 108 字节。十万个点是 10.8 MB,还能接受;一百万个点是 108 MB,开始吃紧。
光滑材质版的传输从向量变成了 9×9 的矩阵,系数从 9 个变成 81 个,每点 972 字节,差了 9 倍。一百万个点接近 1 GB,这条路在当年根本走不通。所以实践中一定要压缩,最有名的是 Sloan 等人 2003 年的聚类主成分分析(CPCA):把相似的传输向量聚成簇,每簇只保留少量主成分。
但还有一笔更致命的隐性成本:存储量随场景的顶点数增长。场景做得越大越精细,这笔账越难看,而这正好和今天开放世界的方向相冲突。
对照一下 Lumen。它的 Surface Cache 是一张固定大小的图集,大约 80 到 90 MB,和场景有多少顶点完全无关,代价是分辨率有限、要按需换页。
PRT 让精度跟着场景走,所以存储会爆炸;Lumen 让预算固定,再让质量按需分配。
这是两种完全不同的工程取向,现代引擎几乎都选了后者。而 PRT 留下的那个问题,几何一动就作废,正是下一章四条路线要回答的。
表中数字为按定义的直接估算,用于说明量级;实际实现会用半精度与压缩,占用更低
差了 9 倍
从向量升到矩阵,系数数量从 9 变成 81。一个百万顶点的场景直接奔着 1 GB 去,这条路在当年就走不通。
所以要压缩
实践里会上压缩,比如聚类主成分分析(CPCA,Sloan 等 2003):把相似的传输向量聚成簇,每簇只存少量主成分。
还有一笔隐性成本
存储量随场景顶点数增长。场景做得越大越精细,这笔账越难看——和今天的开放世界正好冲突。
对照一下 Lumen 的做法
Surface Cache 是一张固定大小的图集,约 80–90 MB,与场景顶点数无关。代价是分辨率有限、要按需换页。
这是两种完全不同的工程取向:PRT 让精度跟着场景走,所以存储爆炸;Lumen 让预算固定,让质量按需分配。现代引擎几乎都选后者。
页签 3 / 8
GI 谱系 · 实时路线
体素、屏幕空间、探针、光线追踪四条实时路线,重点是 DDGI。读完这一页签,Lumen 的每个部件都能找到来历。
03
GI 谱系(下):实时路线
几何一动,预计算就作废。另外四条路线各自想办法在运行时算,Lumen 把它们拼在了一起。
PRT 的死穴是几何不能动。从这里开始,四条路线想的都是同一件事:不预计算,在运行时把间接光算出来。
体素路线第一次做到了全动态,屏幕空间路线几乎免费,探针路线把成本和屏幕分辨率解耦,光线追踪终于能实时求交。这一章的大部分篇幅留给探针路线里的 DDGI,因为它是 Lumen Lite 的直接来源。最后一节把五条路线和 Lumen 一一对上号。
3.1
体素路线:第一次做到全动态
第一次真正做到全动态的,是体素路线。
2010 年前后,CryEngine 3 推广了 LPV(Light Propagation Volumes):把场景装进一个三维网格,把直接光注入进去,再一圈圈地向相邻的格子传播。
LPV:把直接光注入一个体素,再一圈圈向邻居传播。深色是注入点,颜色越浅表示传得越远、越弱。
它极便宜,而且终于不需要预计算,几何可以随便动。代价是分辨率太低,基本只能给出一个大致的方向感,没有细节。
2011 年的体素锥追踪往前走了一大步:把场景体素化成稀疏八叉树,每个体素存带方向的辐射度,再用一个随距离逐渐张开的锥,在 mip 层级里追踪,越远取越粗的层级。质量明显更好,UE4 里也有过实验性的 SVOGI。
但两者有一个共同的硬伤:体素比几何粗得多。一堵薄墙在网格里根本表示不出来,光就从墙里渗了过去,这就是漏光。另外,内存随分辨率的三次方增长。
这条路线留给今天的遗产很重要:"用一份粗的体素化场景代替真实几何"这个思路,被 Lumen 完整继承了,距离场和 Voxel Lighting 都是它的后代。漏光这个老毛病也一起继承了下来,所以第 9 章讲内容规范时会反复强调:墙要够厚。
LPV:Kaplanyan 等,2009/2010;体素锥追踪:Crassin 等,2011 · 图为二维示意
LPV:约 2010,CryEngine 3
场景装进一个三维网格,把直接光注入进去,再迭代向邻居传播。极便宜,而且终于不需要预计算了——几何可以动。
体素锥追踪:2011
把场景体素化成稀疏八叉树,每个体素存带方向的辐射度,然后用一个逐渐张开的锥在 mip 层级里追。质量明显更好。
共同的硬伤
体素比几何粗得多:薄墙在网格里表示不出来,光就从墙里渗过去。这就是漏光。内存也随分辨率三次方涨。
留给今天的遗产
"用一份粗的体素化场景来代替真实几何"这个思路被 Lumen 继承了:距离场和 Voxel Lighting 都是它的后代。连漏光这个老毛病也一起继承了,所以后面会反复讲墙要够厚。
3.2
屏幕空间:最便宜,也最受限
屏幕空间这条路的逻辑非常朴素:屏幕上已经有每个像素的深度和颜色了,为什么不直接拿来用?
2007 年起流行的 SSAO 只用深度缓冲来近似遮蔽,严格说还不算 GI,但它确立了"复用已有屏幕数据"的思路。后来的 SSGI 和 SSR 更进一步:沿着射线在深度缓冲里步进,撞到了就取那个位置上一帧的颜色。
成本几乎为零,而且细节是所有方法里最好的,因为它用的就是全分辨率的真实画面。
但它有一个绕不过去的限制:屏幕外的东西不存在。上图里那个灰色物体真实存在,也确实会把光弹到像素上,可射线一出画面就失效了。所以镜头一转,间接光就跟着变。这不是精度不够,而是信息根本不在缓冲里。
Lumen 没有抛弃这条路,而是把屏幕追踪留作追踪的第一级:先在屏幕上找,找到了就赚了,找不到再走更贵的路径。另外留个伏笔:Lumen Lite 的反射退回了 SSR,所以这里讲的限制会在第 10 章重新出现。
本页为通用原理铺垫 · 图为二维示意
待求像素
屏幕内:找得到
屏幕外:存在,但查不到
SSAO · 2007 起
只用深度缓冲近似遮蔽。严格说不是 GI,但它确立了"复用已有屏幕数据"这个思路。
SSGI / SSR
沿射线在深度缓冲里步进,命中就取上一帧的颜色。成本几乎为零,细节却是所有方法里最好的。
无法绕过的限制
屏幕外的东西不存在。镜头一转,间接光就变——这不是精度问题,是信息根本不在。
留给今天的遗产
Lumen 没有抛弃它,而是把屏幕追踪留作第一级:先查屏幕,命中就赚了,查不到再往下走。另外 Lite 的反射退回 SSR,所以这里的限制在那里会重新出现。
3.3
探针路线:Lumen Lite 的来源
如果这一章只能记住一节,就记这一节,因为它直通第 10 章的 Lumen Lite。
2019 年 NVIDIA 发表了 DDGI,论文标题直译过来是《用光追辐照度场做动态漫反射全局光照》。注意"辐照度场"这个词。做法是在世界空间铺一张探针网格,每帧用光线追踪更新每个探针的辐照度;像素要用时,从周围的探针插值。
白盒里的探针长什么样?下面的视图切到了"显示探针":
它的好处是成本和屏幕分辨率解耦,屏幕再大探针数量也不变,而且完全动态。它的关键发明是探针遮挡:每个探针不只存辐照度,还存一份深度统计。插值之前先问一句"我和这个探针之间有东西挡着吗",挡着就不用它。这是治漏光的药,也是它比 LPV 和体素锥追踪高明的地方。
但这条路线有两项固有代价。第一,探针间距决定了细节的下限:比间距还小的物体拿不到自己的接触阴影,就像上图里的小盒子。第二,探针是跨帧慢慢更新的:场景一动,遮挡信息就落后于几何。
关键的连接在这里。Epic 的 Daniel Wright 说,Lumen Lite 就是"带探针遮挡的辐照度场,加上自动探针放置"。所以第 10 章那些黑块、黑环和滞后,本质上不是某个实现的 bug,而是这条路线的固有代价在显形。理解了这一节,评估 Lite 时就知道该盯着什么看。
在 UE 里你见过它Lightmass 烘出来的 Volumetric Lightmap,就是一张铺在空间里的探针网格,给动态物体提供间接光。区别在于它是离线烘好的,DDGI 是每帧实时更新的。
DDGI:Majercik 等(NVIDIA),2019 · Lite 的说法来自 Daniel Wright 的公开说明 · 图为二维示意
探针网格
小物体
应有却表达不出的接触阴影
DDGI · 2019
NVIDIA 的论文标题就叫《用光追辐照度场做动态漫反射全局光照》。世界空间铺一张探针网格,每帧用光追更新每个探针的辐照度。
关键发明:探针遮挡
每个探针不只存辐照度,还存深度统计。插值前先问一句"我和这个探针之间有东西挡着吗",挡着就不要它。这是治漏光的药。
固有代价有两条
一是探针间距决定细节下限,比间距小的物体没有自己的接触阴影,就是图中的小盒子;二是探针跨帧慢更新,场景一动,遮挡信息就落后于几何。
直通 Lumen Lite
Epic 的 Daniel Wright 说 Lite 就是"带探针遮挡的辐照度场,加上自动探针放置"。所以后面那几条黑块、黑环、滞后,不是实现 bug,而是这条路线的固有代价在显形。
3.4交互图
隔着墙的那个探针,必须排除
上一节说探针遮挡是"治漏光的药"。这一节用一张可以拖动的图,看清楚这味药到底在治什么。
图里黑色的小方块是一个待求的像素。它不自己算光,而是向周围的探针取值。这是探针方案省钱的全部秘密:一个探针的结果,被周围一大片像素共用。
麻烦出在墙另一侧的探针。那一侧是亮的,如果把它们算进来,这个暗房间里就会凭空多出一片亮光,这就是漏光。所以必须把它们排除掉。
程序怎么知道中间有墙?办法是让探针除了存"有多亮",还存一份"各个方向上的遮挡物大概多远"。像素如果比那个距离还远,就说明中间隔着东西。
试着把严格度往两头拖。往右判得松,墙那侧的亮探针被误用,读数里"算出来的值"明显高于正确答案;往左判得严,连同一侧本来能用的探针也被否掉,样本太少,墙角就会发黑。
判严了墙角发黑,判松了到处漏光,没有一个阈值能同时照顾两头。
DDGI 给出的回答是切比雪夫不等式。接下来两节把它推出来。
动画为原理示意,探针数量与位置不代表真实参数 · 导出 PPTX/PDF 时动画会变成静态图
像素不自己算光,它去问探针
这是探针方案省钱的全部秘密:一个探针的结果被周围一大片像素共用。
但不能什么探针都信
墙另一侧的探针那边是亮的。要是把它算进来,暗房间里就会凭空出现亮光——这就是漏光。
怎么判断中间有没有墙
探针除了存"有多亮",还要存"各方向的遮挡物大概多远"。像素比那个距离还远,就说明中间隔着东西。
这一步决定成败
判严了,墙角会发黑;判松了,到处漏光。而且没有一个阈值能同时解决两边——这正是 DDGI 用切比雪夫不等式要处理的问题,下一节会给出那个公式。拖着滑块找不到两头都满意的位置,是正常的。
3.5推导(一)
一个探针里到底存了什么
现在打开一个 DDGI 探针,看看里面存了什么。答案是两张很小的贴图。
每个探针存两张八面体图,也就是把一个球面展开成正方形的贴图(第 7 章会细讲这种展开方式)。一张是辐照度图,大约 8×8 像素,记"朝每个方向的表面会收到多少光";一张是距离图,大约 16×16 像素,记"朝每个方向看出去,多远会撞到东西"。两张图都留一圈边,防止采样时串到隔壁。
每一帧,探针朝四面八方发一批光线,然后更新这两张图。先看辐照度图上的某一个像素,它代表一个方向,比如"正上方":
这一帧的新结果 = 和这个方向夹角不到 90° 的光线,按"越正越重"加权平均。但不直接替换旧值,而是和旧值混合:旧值占大头,新值只掺进来一点。
Enew(ω) = lerp( Eold(ω),Σ max(0, ω·rk) Lk / Σ max(0, ω·rk),α )
- rk, Lk
- 第 k 条光线的方向,和它带回来的亮度
- max(0, ω·rk)
- "越正越重"的权重,背对着的光线算 0
- lerp(旧, 新, α)
- 按比例混合,像图层的不透明度
- α
- 旧值留多少:越接近 1 越稳,也越慢
把它和第 1 章的辐照度对一下:"把半球里每个方向的光加起来,正对着的算足分,斜着的打折"。一模一样,只是把"无穷多个方向"换成了"这一帧追的这几十条光线"。所以探针里存的并不神秘,就是"如果这里有一面朝这个方向的墙,它会收到多少光"。
距离图用的是另一种加权:
距离图同样是"加权平均,再和旧值混合",但权重更挑剔:只有几乎正对这个方向的光线才算数。除了平均距离,还要存"距离平方"的平均,后面用它来算这些距离有多分散。
γ = (ω·r)ksDnew = lerp(Dold, Σγl / Σγ, α)D²new = lerp(D²old, Σγl² / Σγ, α)
- l
- 光线撞到东西的距离
- γ
- 更挑剔的权重,ks 越大越挑剔(1 到 50)
- D, D²
- 距离的平均值,距离平方的平均值
为什么两张图的权重不一样?辐照度需要"宽"的平均才柔和,就像大半径的模糊;遮挡判断需要"窄"的平均才锐利,否则墙的边缘会糊成一片,该挡的挡不住。
两个式子里都有 α,它决定探针的"记性"有多长:越接近 1,画面越稳定,但场景一变,探针要很多帧才跟得上。请记住这个 α,第 10 章 Lumen Lite 的滞后和拖影,源头就在这里。
在 UE 里你见过它Reflection Capture 也是放在空间里、记录"从这里往四周看是什么样"的采样点。区别在于它记的是给反射用的清晰画面,探针记的是给漫反射用的、模糊过的光照。
公式依 DDGI(Majercik 等,2019)及其复现文献 · 分辨率为典型值,不同实现有差异
某个纹素的方向 ω
线越粗 = 余弦权重越大
背向这个方向,权重为 0
辐照度的累积
E_new(ω) = lerp( E_old(ω), Σ max(0, ω·r_k)·L_k / Σ max(0, ω·r_k), α )
把它和前面的 E = ∫ L·(n·ω) dω 对一下:这就是那个积分的离散版,只是把纹素方向 ω 当成了"法线"。
距离的两个矩
γ = (ω·r)^k_s , k_s ∈ [1, 50]
D_new = lerp(D_old, Σγ·l / Σγ, α)
D²_new = lerp(D²_old, Σγ·l² / Σγ, α)
l 是命中距离。注意这里用的是锐化权重:辐照度要宽核才平滑,遮挡要窄核才锐利,否则墙边会糊。
两张八面体图,一个探针
辐照度图约 8×8 纹素;距离图约 16×16,每纹素存 D 和 D² 两个值。各带一圈守卫边界,防止双线性采样串色。
α 是滞后系数
越接近 1,变化越慢、越稳,但对场景变化的反应也越迟。Lumen Lite 的滞后与拖影,源头就是这个 α。
3.6推导(二)
探针遮挡:用两个矩判断"挡住了吗"
这是 DDGI 最精巧的一步。一个像素要从周围八个探针取光,其中可能有探针在墙的另一侧。上一节存下的距离图,就是用来做这个判断的。
思路很直观:探针朝像素的方向看出去,它记得那个方向上平均多远会撞到东西。如果像素比那个距离近,说明中间没东西挡着;如果像素比那个距离远得多,中间很可能隔着一堵墙。
从距离图里读出两个数:这个方向上平均多远撞到东西,以及这些距离有多分散。
μ = M₁σ² = M₂ − M₁²
- μ
- 平均距离
- σ²
- 分散程度(方差):距离忽远忽近,它就大
- M₁, M₂
- 距离图里存的两个数:距离的平均、距离平方的平均
像素到探针的距离记作 r。如果 r 不超过 μ,直接判为看得见。如果 r 比 μ 远,就用一条叫切比雪夫不等式的统计规律,估计"看得见"的可能性最多有多大:
像素越是远远超出平均距离,它和探针之间有墙的可能就越大,这个探针就越不可信。距离本来就很分散时,判断要更保守。
P ≤ σ² / ( σ² + (r − μ)² )
- P
- "看得见"的可能性上限,0 到 1,直接拿来当这个探针的权重
- r − μ
- 像素比平均距离远出多少
为什么只给"上限",不做精确判断?因为探针只存了两个数,不知道那个方向上的东西具体怎么分布,只能给一个保守的估计:宁可偏暗,也别漏光。
代几个数感受一下。设平均距离 μ = 2.0 米,方差 σ² = 0.2。像素在 2.2 米处,P = 0.83,基本看得见;在 3 米处,P 降到 0.17,大幅压低;到 4 米处只剩 0.05,基本排除。如果方差变大到 2.0,同样在 3 米处,P 是 0.67。
上图把这件事画成了曲线。方差小,说明这个方向上撞到的东西距离很一致,比如一面平墙,曲线掉得快,敢果断排除;方差大,说明撞到的东西远近不一,比如树叶或栏杆,曲线掉得慢,只能保守保留。
最终一个探针的权重是三样东西乘起来:方向合不合适、"看得见"的可能性 P、以及离像素有多近。方向的权重用的是一种很柔和的写法,让侧面的探针慢慢淡出,而不是突然消失:
探针越在表面的正前方,权重越高;转到侧面时慢慢变小,转到正背后才变成零。
f(θ) = ( (cos θ + 1) / 2 )²
- θ
- 表面正前方和"指向探针的方向"之间的夹角
另外,取光的位置还要沿表面往外挪一点点,防止表面把自己挡住。这和给阴影加偏移(Shadow Bias)是同一个道理。
算例里的 μ 和 σ² 是本文自设的数字,只用来说明曲线形状。
公式依 DDGI(Majercik 等,2019)及其复现文献 · 算例数字为本页自设,用于说明曲线形状
σ² 小(一面平墙):果断剔除
σ² 大(树叶、栏杆):保守保留
横轴是 r−μ,纵轴是可见概率上界 P
① 从两个矩取出均值与方差
μ = M₁ , σ² = M₂ − M₁²
② 像素距离 r 比 μ 远时,用切比雪夫不等式
P ≤ σ² / ( σ² + (r − μ)² )
r ≤ μ 时直接判为可见。只存了两个矩,分布未知,所以要的是一个保守上界:宁可偏暗,也别漏光。思路和方差阴影图一致。
③ 代个数试试(μ = 2.0,M₂ = 4.2 → σ² = 0.2)
r = 2.2 → P = 0.2/(0.2+0.04) = 0.83 基本可见
r = 3.0 → P = 0.2/(0.2+1.00) = 0.17 大幅压低
r = 4.0 → P = 0.2/(0.2+4.00) = 0.05 基本剔除
换成 M₂ = 6.0(σ² = 2.0)时,r = 3.0 的 P 就变成 0.67——方差越大越不敢剔。
④ 最终权重是乘起来的
方向权重 f(θ) = ((cosθ+1)/2)² × 上面的可见性 P × 八探针三线性权重。
方向权重用这种"包裹"形式而不是 max(0,cosθ),是为了让切线方向的探针柔和退出,不要突然消失。另外还要给采样点加法线偏移 b = (0.8n + 0.3v)·k_n 防自遮挡。
3.7推导(三)
算一笔账:它到底便宜在哪
"便宜"这个说法必须落到数字上。先算存储。
| 内容 | 规模 | 字节(fp16) |
|---|---|---|
| 辐照度图 | 8×8 × RGB | 384 B |
| 距离图 | 16×16 × (D, D²) | 1024 B |
| 合计 | — | 约 1.4 KB |
| 32×32×8 的探针体积 | 8192 个探针 | 约 11.5 MB |
辐照度图 8×8 共 64 个纹素,每个纹素 RGB 三个半精度数,是 384 字节;距离图 16×16 共 256 个纹素,每个存 D 和 D² 两个半精度数,是 1024 字节。一个探针合起来约 1.4 KB。一个 32×32×8 的探针体积有 8192 个探针,总共约 11.5 MB。
关键在于:这是固定开销,和场景有多少顶点无关。对比前面 PRT 的账,同样的精度诉求,PRT 动辄几百 MB 起,而且随顶点数增长。
再算每帧的光线数。每个探针追 64 条,8192 个探针约 52 万条。对比逐像素的方案:1080p 每像素一条是 207 万条,4K 是 830 万条。分辨率翻到 4K 时,逐像素方案的光线数涨 4 倍,探针方案一条都不多。这就是"成本与屏幕分辨率解耦"的真正含义。
代价也写在同一笔账里。如果这个体积覆盖 64 × 64 × 16 米,32×32×8 的探针间距正好是 2 米。比 2 米小的几何,在 GI 上拿不到自己的遮挡,这就是前面那个小盒子没有接触阴影的数学来源。
固定网格还有一个两难:室内要密,旷野要疏。一张固定网格放进开放世界,要么浪费,要么不够。所以当 Daniel Wright 强调 Lumen Lite 是自动探针放置时,你就知道这为什么是卖点,而不是一句实现细节。
本页数字按典型分辨率与网格规模自行估算,用于说明量级;真实实现含边界、压缩与多级网格,数值不同
存储:一个探针约 1.4 KB
固定开销,和场景顶点数无关。对比 PRT 那页:同样的精度诉求,PRT 要几百 MB 起。
每帧光线数:和分辨率解耦
52 万
探针
8192×64
207 万
1080p
每像素 1 条
830 万
4K
每像素 1 条
分辨率翻到 4K,逐像素方案的光线数涨 4 倍;探针方案一条都不多。这就是"与屏幕分辨率解耦"的真正含义。
代价就写在同一笔账里
若这个体积覆盖 64 × 64 × 16 米,32×32×8 的探针间距正好是 2 米。比 2 米小的几何,在 GI 上拿不到自己的遮挡——这就是前面小物体没有接触阴影的来源。
固定网格的两难
室内要密、旷野要疏。一张固定网格在开放世界里,要么浪费,要么不够。
所以 Lite 的卖点是"自动"
Daniel Wright 强调 Lumen Lite 是自动探针放置。读完这一节就明白为什么这是卖点,而不是实现细节。
3.8
硬件只解决了一条光线的一半成本
最后一条路线是光线追踪本身。先说标尺:离线路径追踪是"正确答案"的定义者,每个像素打成千上万条光线,一帧要几秒到几分钟。它是用来对照的标尺,不是实时方案。
2018 年起,GPU 有了光追专用单元,很多人以为问题解决了。但一条光线的成本分两半:求交,和命中点算光。硬件只解决了前一半,把 BVH 遍历和三角形求交下沉到了硬件。后一半要求出命中点的材质,还要知道那一点自己收到了多少光,往往比求交更贵,硬件完全帮不上忙。
Lumen 的答案就落在这里:求交交给硬件光追或者距离场,两个后端都行;命中点算光的那一半,换成读 Surface Cache,把算光变成查表。
硬件让求交变便宜了,没让算光变便宜。Lumen 省的是后一半。
另一个值得一提的是 2020 年起的 ReSTIR。它解决的是噪声:用蓄水池重采样,在空间和时间上复用邻居与历史的样本,把有效样本数撑起来。它最初用于直接光,之后扩展到了间接光。"复用样本"这个思想也贯穿了 Lumen:探针滤波借邻居,时间累积借历史,用的是同一招。
色块宽度为示意,不代表真实耗时比例 · ReSTIR:Bitterli 等,2020 起
朴素光追
求交
光线和几何相交
命中点算光
求材质、求这一点收到多少光,往往比求交更贵
硬件光追解决了
求交 · 2018 起有专用单元
BVH 遍历与三角求交下沉到硬件
这一半照旧
Lumen 的答案
求交:硬件光追 或 距离场
两个后端都行
读 Surface Cache
把算光变成查表
省下来的部分
离线路径追踪
正确答案的定义者。每像素成千上万条光线,一帧几秒到几分钟。它是标尺,不是方案。
ReSTIR · 2020 起
用蓄水池重采样,跨空间和时间复用邻居与历史的样本,把有效样本数撑起来。最初用于直接光,之后扩展到间接光。
留给今天的遗产
"跨空间与时间复用样本"这个思想贯穿 Lumen:探针滤波借邻居,时间累积借历史,都是同一招。
3.9
Lumen 不是新算法,是一次组合
把这两章收个口。五条路线在 Lumen 里各自变成了什么:
| 路线 | 它的核心贡献 | 在 Lumen 里变成了什么 |
|---|---|---|
| 预计算 | 离线换运行时,画质上限最高 | 放弃。目标是全动态,烘不了 |
| 屏幕空间 | 复用已有的屏幕数据,几乎免费 | 屏幕追踪,追踪的第一级 |
| 体素 | 用粗的场景代理代替真实几何 | 距离场(求交)+ Voxel Lighting(兜底光照) |
| 探针 | 辐照度场 + 探针遮挡,与分辨率解耦 | 屏幕探针与世界辐射度缓存;Lite 则整条路线照搬 |
| 光线追踪 | 求交能实时;跨时空复用样本降噪 | 可选的硬件求交后端 + 探针滤波与时间累积 |
| Lumen 自己加的 | Surface Cache | 把"命中点算光"变成"读缓存",这是它真正的新东西 |
预计算被放弃了,因为目标是全动态,烘不了。屏幕空间变成了追踪的第一级,因为它几乎免费。体素路线贡献了"用粗的场景代理代替真实几何",在 Lumen 里就是距离场负责求交、Voxel Lighting 负责兜底光照。探针路线贡献了辐照度场和探针遮挡,在 High 档里体现为屏幕探针和世界辐射度缓存,而 Lumen Lite 干脆把整条路线照搬了过来。光线追踪路线贡献了可选的硬件求交后端,以及跨时空复用样本的降噪思想。
真正属于 Lumen 自己的新东西只有一个:Surface Cache,把命中点算光变成读缓存。
读后面每一章时,都可以问一句:这是哪条路线的遗产,它在规避哪个老问题?反过来也成立,Lumen 继承的毛病也都有出处:漏光来自体素路线,屏幕外的内容缺失来自屏幕空间路线,探针滞后来自探针路线。
本页的归类是为了讲解方便,不是 Epic 的官方表述
GI 算法谱系 · 小结
所以读 Lumen 的每个设计,都可以问一句:这是哪条路线的遗产,它在规避哪个老问题?
也包括继承下来的毛病:漏光来自体素路线,屏幕外缺失来自屏幕空间路线,探针滞后来自探针路线。
页签 4 / 8
Lumen 骨架:缓存与追踪
Lumen 的核心思路、三层缓存、Surface Cache 怎么建怎么打光,以及一条光线怎样在屏幕、距离场和硬件光追之间接力。
04
思想与管线
Lumen 的核心只有一句话:命中之后只读缓存,不重新算光。三层缓存住在三个不同的地方。
谱系走完,Lumen 的位置就清楚了:屏幕追踪来自屏幕空间那条路,距离场追远处,探针负责收集。但如果只是把它们拼起来,Lumen 不会比 DDGI 强多少。
它真正的发明在于缓存:放在哪里、谁来写、谁来读。这一章只有四节,却是整篇的骨架:前三节画出结构,最后一节用默认参数算一笔光线账。后面四章,每一章都是在展开这里画出的某一个方块。
如果想先看全貌,附录 A 有一张可以点击的系统流图,把后面四章的所有部件画在了一张图上,可以按一帧的顺序逐步播放。
4.1
三层缓存,分别住在哪里
Surface Cache 是 Lumen 自己的发明,但它不是唯一的缓存。打开 Lumen 会发现一共有三层。先别急着看每一层怎么工作,先看它们住在哪里。
第一层是 Screen Probes,屏幕探针,贴在屏幕上。它们稀疏地撒在画面里,只在探针上追踪光线,再插值回每个像素。因为跟着屏幕走,数量随分辨率变化。
第二层是 Surface Cache,贴在场景表面上。它存材质和光照,光线命中时直接读,不必现场计算。因为跟着表面走,数量随场景的表面积变化,和分辨率无关。
第三层是 World Radiance Cache,世界空间辐射度缓存,飘在空间里。它缓存远处的光照,既不跟屏幕也不跟表面,所以能被许多帧、许多像素反复共享。
理解了"住在哪里",后面很多设计就讲得通了。屏幕探针要自适应加密,因为它跟着屏幕,物体边缘处一个探针代表不了两侧的表面。Surface Cache 要按需换页,因为它跟着表面,场景一大就装不下。世界缓存可以慢慢更新,因为它被共享得最多,变化也最慢。
三层住的地方不同,回答的却是同一个问题:这份光照能不能不再重算?
图为二维剖面示意,探针数量与间距不代表真实参数
核心思想
Surface Cache
贴在场景表面上。存材质与光照,光线命中时直接读,不必现场算。
Screen Probes
贴在屏幕上。稀疏放置,只在探针上追踪,再插值回每个像素。
World Radiance Cache
飘在空间里。缓存远处光照,被多帧、多像素共享。
三层住的地方不同,但回答同一个问题:这份光照能不能不再重算?
4.2
一帧里的三个 Pass
三层缓存回答的是"存在哪",接下来是"按什么顺序算"。Epic 的性能指南把 Lumen 拆成三个 Pass 来做性能分析,这正好也是理解实现的主线。
Pass 1,Scene Lighting:给 Surface Cache 计算直接光和间接光。它先让缓存里"有光",之后所有光线命中时读到的,都是它的结果。
Pass 2,Screen Probe Gather:用缓存算出漫反射 GI。粗糙表面的反射和半透明物体的 GI,也在这里顺带算掉。
Pass 3,Reflections:只为光滑表面发射专用的反射光线。
间接漫反射 + 间接高光 + 反射 = 间接光再叠加直接光,得到最终画面
三个 Pass 的成本结构完全不同,这一点调性能时很有用。Scene Lighting 的成本跟着缓存的大小和更新比例走,和屏幕分辨率无关;Screen Probe Gather 跟着屏幕上的探针数走,也就是分辨率和探针间距;Reflections 跟着画面里有多少光滑表面走,一地积水和一地泥土的反射成本可以差好几倍。4.4 节会把中间那一项算出具体的数字。
这个顺序本身就说明了依赖关系:先有缓存里的光,才有探针收集;先有漫反射的探针,粗糙反射才能直接复用它们。第 5、7、8 章分别对应这三个 Pass,中间的第 6 章讲它们共用的追踪。
Pass 1
Scene Lighting
给 Surface Cache 计算直接光与间接光
Pass 2
Screen Probe Gather
算漫反射 GI、粗糙反射和半透明 GI
Pass 3
Reflections
为光滑表面发射专用的反射光线
间接漫反射 + 间接高光 + 反射 = 间接光,再叠加直接光,得到最终光照
4.3
一帧里,缓存如何被读写
把三层缓存和三个 Pass 放进同一张图,就是 Lumen 一帧的全局地图。后面的每一节,都能在这张图上找到自己的位置。
更新卡片与距离场
给缓存打光
漫反射 GI
光滑表面的反射
直接光 + 间接光
Surface Cache 光照 · World Radiance Cache · 距离场(追踪加速结构)
图中的五步依次是:更新场景表示,给缓存打光,算漫反射 GI,算反射,最后合成。最下面那条深色的横带,是所有 Pass 共享的缓存。它们是持久的,跨帧复用。
注意读和写的方向。前两步写缓存:更新卡片和距离场,再给卡片打光。后两步读缓存:光线追到哪里,就从那里的缓存取光。
所以 Lumen 不是一个算法,而是一套围绕缓存组织起来的管线。
接下来四章顺着这张图往下走:第 5 章讲被写入的那份缓存长什么样,第 6 章讲光线怎么找到它,第 7、8 章讲读出来的光怎么变成画面。动身之前,下一节先用默认参数给这张图算一笔光线账,后面每一章都会回到这本账上。
Pass 划分与命名依据 Epic 官方文档
共享缓存(持久,跨帧复用)
Surface Cache 光照 · World Radiance Cache · 距离场(追踪加速结构)
前两个 Pass 写缓存,后两个 Pass 读缓存。追踪命中后不重新算光,只读缓存里已经算好的值——这是整套设计的主线,也是 Lumen 真正的发明。缓存又由前面的 Pass 更新,所以整条流程是围绕同一组缓存的读写组织起来的,而不是一条单向的管线。
4.4算例
一帧的光线账本
前三节讲的是结构。这一节换个角度:拿默认设置算一笔账,看 Lumen 每帧到底打得起多少条光线,又是怎么把这点光线摊到两百万个像素上的。
账本只有三行。第一行是像素数:1080p 是 1920×1080,约 207 万。第二行是探针数:默认每 16×16 像素放一个屏幕探针,横向 120 个、纵向 68 个,约 8160 个。第三行是光线数:每个探针朝 8×8 = 64 个方向各打一条,合计约 52 万条。
52 万条光线 ÷ 207 万像素 ≈ 每个像素四分之一条光线
对照一下第 1 章的结论:要得到一张干净的离线参考图,每个像素往往需要几百条光线。四分之一条和几百条,差了三个数量级。下面这张图把这笔账做成了可以拨的算盘。
差出来的三个数量级,是被三件事一层层补回来的,它们正好是第 7 章的主线:
插值借光。一个像素不只读自己头顶那一个探针,而是在周围 4 个探针之间插值。它实际用到的是 4×64 = 256 条光线的结果。
重要性采样。这 64 条光线不是平均撒的,而是按材质和上一帧的亮度,往更可能有光的方向集中。同样的数量,噪声更小。
时间累积。每帧光线的方向会抖动,结果和过去几帧叠在一起。画面不动时,有效样本数还要再乘上累积的帧数。
Lumen 的预算不是按像素算的,而是按探针算的。这就是为什么它的成本主要跟着内部分辨率和探针间距走,而跟场景里有多少盏灯、多少个物体关系不大。
还有一个容易忽略的细节:账本里没算 Surface Cache 的打光和世界缓存的更新,它们有各自的预算:前者下一章会算,后者在第 7 章有单独的更新预算。第 9 章讲的那几个旋钮,调的就是这本账上的数字。
探针间距 16 像素、每探针 8×8 方向为 r.Lumen.ScreenProbeGather.DownsampleFactor / TracingOctahedronResolution 的默认值(以所用引擎版本为准)
05
场景表示
在 Lumen 眼里,场景不是一堆三角形,而是一张固定大小的材质图集,外加一份距离场。
上一章说命中之后读缓存。这一章回答更具体的问题:缓存里存的到底是什么,存在哪儿,什么时候更新。
答案是 Surface Cache。Lumen 在离线时给每个网格摆好几台捕获机位,把表面拍成一组小卡片;所有卡片拼进一张 4096×4096 的图集。运行时再按需给这些卡片打光,并让光在卡片之间一轮一轮地反弹。
5.1
Surface Cache:Lumen 眼中的场景
先看被写入的那份缓存。Surface Cache 是 Lumen 最关键的优化,它做的事可以概括成一句话:自动给场景表面做一套参数化,把材质存进去。
具体来说,每个网格会从几个方向被"拍"成一组卡片(Card)。卡片里存的是材质属性:基础色、自发光、粗糙度、法线。光线命中表面后,不必重新求值材质,直接读卡片就行。
卡片不是拍完一次就不管了。玩家移动时,附近的网格会被重新捕获,保持足够的精度;这一步靠 Nanite 来加速。
具体到一个纹素,里面存了两组东西。一组是捕获时写入的材质:反照率、不透明度、深度、法线、自发光。另一组是 Scene Lighting 算出来的光照:直接光、间接光,以及两者合成的最终光照,光线命中时读的就是最后这一份。材质只在重新捕获时变,光照则每帧滚动刷新,5.10 节会算它的速度。
调试时可以打开 Surface Cache 可视化视图。没有被卡片覆盖的区域会显示为粉色,它们在 Lumen 眼里没有材质:不反弹光,在反射里是黑的。第 9 章排查问题时,第一件事就是看这张图里有没有粉色。
动手实现时卡片生成、图集分配和捕获的写法、伪代码和常见坑,见 。
金色薄片是各方向的卡片,蓝绿色是网格本体,圆点是捕获方向。
5.2
Mesh Cards:离线生成的"捕获机位"
卡片从哪来?它们在网格导入时离线生成,不是运行时临时算的。卡片决定了运行时"从哪几个方向去拍这个网格",可以理解成一组事先摆好的捕获机位。
生成分三步。先把网格体素化成面元(surfel),每个面元记下自己的位置和朝向。再把面元聚类成卡片:用类似 K-means 的区域生长,再做一次全局优化,得到一组轴对齐的投影面。最后是兜底:聚类失败的网格,退回最简单的六面卡片。
默认每个网格生成 12 张卡片,可以在静态网格设置里用 Max Lumen Mesh Cards 调整。很小的聚类还会被合并成六面的组卡片,用在远处的建筑和碎石上。
采样时,Lumen 在 6 个朝向里按表面法线选出 3 张卡片,按深度差和法线夹角加权混合;每张卡片内部再取 4 个深度做双线性滤波。这样,即使卡片和表面并不完全贴合,读出来的值也是平滑的。
下一节把这个聚类过程画出来。
动手实现时命中点怎样挑卡片、做深度校验,以及凹面覆盖不到时怎么回退,见 。
STEP 1
体素化成面元
把网格转成 surfel,记录位置与朝向
STEP 2
聚类成卡片
类 K-means 的区域生长,再全局优化,得到轴对齐的投影面
STEP 3
失败则兜底
生成失败的网格退回六面卡片
默认每个网格 12 张卡片,可用 Static Mesh 的 Max Lumen Mesh Cards 调整
采样时在 6 个朝向里按法线选 3 张,按深度差与法线夹角加权混合
5.3示意图
Card 是怎么从面元聚类出来的
上一节的三步,画出来是这样的。
① 原始网格
导入时的三角形网格
② 体素化成面元
转成带位置和朝向的 surfel
③ 按朝向聚类
朝向相近、位置相邻的归为一类
④ 拟合成 Card
每类拟合一个轴对齐的投影面
从左往右:导入时的原始三角网格;体素化成面元,每个点带着自己的位置和朝向;按朝向和位置聚类;每一类拟合成一块轴对齐的投影面,也就是一张卡片。
注意第三步里朝上的面元被分成了两组。它们朝向相同,但位置不相邻,所以各自成为一张卡片。聚类同时考虑朝向和位置,是为了让每张卡片都尽量贴住一块连续的表面,少留拍不到的死角。
聚类失败时退回 6 面立方体投影;小聚类合并成组卡片;默认上限 12 张。这三条在复杂网格上都会遇到,第 9 章的内容清单里还会再提到。
流程来源:SIGGRAPH 2022 Lumen 演讲 · 图为二维剖面示意
朝上
朝左
朝右
朝下
聚类失败的网格退回 6 面立方体投影;小聚类会合并成“组卡片”,用于远处的建筑和碎石。默认每网格 12 张,可用 Max Lumen Mesh Cards 调整。
5.4
把材质拍进卡片:哪些网格会吃亏
卡片生成之后,还要把材质"拍"进去。可以把卡片想象成贴在网格外面的几块平板相机:每张卡片从一个轴向看着网格,把基础色、法线、自发光等属性画进 Surface Cache 的大图集里。
完整的流程分四个阶段:
- 离线生成卡片。每个静态网格默认 12 张,朝向对齐坐标轴。
- 加入场景时放置卡片。
- 运行时用 Nanite 的多视图光栅化,把材质画进图集。所有操作都有节流,避免卡顿。
- 每一帧对这些表面位置计算直接光和间接光,分摊到多帧完成。
不同类型的网格,待遇不一样。骨骼网格会变形,所以没有离线卡片,只在加入场景时在蒙皮前的包围盒四周放 6 张,覆盖有限。样条网格按每个实例重新拟合卡片,变形越大,质量越差。植被和实例化网格只有使用 Nanite 时才被支持。
没被卡片覆盖的部分,在 Surface Cache 视图里是粉色的,它们不反弹光,在反射里是黑的。这三类特殊网格,正是粉色最常出现的地方。
1 · 离线
生成 Card
每个静态网格默认 12 张,朝向对齐坐标轴
2 · 加入场景
放置 Card
骨骼网格没有离线 Card,在蒙皮前包围盒外放 6 张
3 · 运行时
捕获材质
Nanite 多视图光栅化,把材质画进 Surface Cache 图集
4 · 每帧
给表面打光
对这些表面位置算直接与间接光,分摊到多帧
样条网格:按每个实例重新拟合 Card,变形越大,质量越差。
植被与实例化网格:只有使用 Nanite 才被支持。
没被 Card 覆盖的部分显示为粉色:不反弹光,在反射里是黑的。
5.5
图集与按需更新:像虚拟纹理一样
所有卡片都住在同一张图集里,而图集的大小是固定的:4096×4096,由 128×128 的物理页组成。场景里的卡片远远不止这么多,所以 Surface Cache 的分辨率不是固定的,而是按需分配:光线实际命中了哪些卡片、需要多清晰,就把对应的页和 mip 映射进图集。
这套机制和虚拟纹理非常像,是一个闭环:
- 光线命中某张卡片。
- 着色器写一条反馈:随机选一页,记下它需要的 mip 级别。
- GPU 把所有请求汇总进一张哈希表。
- CPU 读回请求,映射新页,释放不再需要的旧页。
- Nanite 渲染这些卡片,填充新映射的物理页。
没映射到的页会回退到一组常驻的低分辨率页,所以一次采样总能取到值,不会读到空数据。
捕获本身也有预算:每帧 512×512 个纹素。Nanite 一次可见性缓冲绘制就能把所有待更新的卡片画完,比逐张绘制快约 10 倍。捕获输出包括反照率、不透明度、深度、法线和自发光。
光线命中
命中点落在某张卡片上
写反馈
随机选一页,写入所需的 mip
GPU 合并
请求汇总进哈希表
CPU 映射
映射新页,释放旧页
Nanite 捕获
渲染卡片,填充物理页
图集 4096×4096,由 128×128 物理页组成;没映射的页回退到常驻低分辨率页,一次采样就能取到
每帧捕获预算 512×512 texels;Nanite 一次可见性缓冲绘制就能画完所有卡片,约快 10 倍
5.6示意图
图集的内部结构:物理页与子分配
再往里看一层:这张 4096×4096 的图集在显存里是怎么排的。
整页一张大卡片
子分配小卡片
未映射
一张图集,固定开销
4096×4096 的图集,由 128×128 的物理页组成。比一页小的卡片在页内再做子分配;页边留 0.5 纹素的边界,避免双线性采样串到邻居。
每层存什么,占多少
| 层 | 格式 | 内存 |
|---|---|---|
| 反照率 | RGB8 BC7 | 16 MB |
| 不透明度 | R8 BC4 · UE 5.8 已移除 | 8 MB |
| 深度 | R16 | 32 MB |
| 法线 | RG8 BC4(半球编码) | 16 MB |
| 自发光 | RGB16F BC6H | 16 MB |
没映射的页会回退到一组常驻的低分辨率页,所以采样永远能取到值,不会读到空数据。
页有三种用法。大卡片独占一整页,就是图中的青绿色块。比一页小的卡片在页内再做子分配,就是被切成四块的那些页。还有一些页根本没有映射,画成虚线框。页边留半个纹素的边界,这样双线性采样不会串到邻居卡片上。
表里列出了每一层存什么、用什么格式、占多少内存。反照率、深度、法线、自发光,加起来不到 90 MB。深度用的是未压缩的 R16,所以最贵。
这是一笔固定开销,不随场景复杂度增长。
这正是第 2 章拿来和 PRT 对比的那一点:PRT 的存储跟着场景顶点数涨,Surface Cache 的预算是固定的,质量按需分配。
表里的不透明度层在 UE 5.8 中已经移除。第 10 章引用的数字是省下约 16 MB,这里列的 8 MB 是 2022 年演讲的口径,两者统计范围不同,以版本说明为准。
尺寸与内存数字来自 SIGGRAPH 2022 Lumen 演讲;UE 5.8 已移除不透明度层
5.7
Scene Lighting:给缓存打光
卡片里有了材质,还没有光。给它们打光是 Pass 1 的工作,叫 Scene Lighting。之后所有光线追踪命中时拿到的光照,都来自这里。
它在卡片的每个表面位置上计算两样东西:直接光,也就是灯光加阴影;以及间接光,也就是从别的卡片弹过来的光。算完写回卡片,之后不管是漫反射 GI 的光线还是反射的光线,命中这里时都直接读。
直接光这一步和普通的延迟光照很像:对每个纹素,把影响它的灯逐个加起来,再乘上阴影。不同之处在于,很多卡片表面根本不在屏幕上,没法用屏幕上的阴影信息。这时就沿着距离场向灯光追一条阴影光线,看中间有没有东西挡着。
它的开销取决于每帧更新缓存的比例。Lumen 不会每帧把整张图集都重新打一遍光,而是每帧只更新一部分,其余沿用旧值。
还有一个重要的细节:间接光读的是上一帧的结果。这个细节决定了多次反弹是怎么来的,后面两节会把它展开。
材质入缓存
卡片已带着捕获到的材质属性
计算直接与间接光
在这些表面位置上算光照
命中即读取
GI 与反射的光线命中点都读这里
每帧只更新缓存的一部分,开销取决于更新比例
间接光读取上一帧的结果,多次反弹随帧累积
5.8流程
给 Surface Cache 打光:直接光 + 反弹
展开来看,打光分四步。
第一步,选页。每帧只更新一部分页。优先级是"最后被使用的时间减去最后更新的时间":越是最近被光线命中、又很久没更新的页,越先轮到。2022 年演讲给出的每帧预算是直接光 1024×1024 个纹素、间接光 512×512 个纹素。当前版本改用"占整张图集的几分之一"来表示:直接光默认 1/32(约 52 万纹素,相当于 724²),间接光默认 1/64(约 26 万,正好是 512²)。直接光的默认预算比演讲时减半了,5.10 节按当前默认值算账。
第二步,直接光。以 8×8 纹素为一块 tile,每块最多处理 8 盏灯。阴影先从阴影图里取,为每盏灯生成一张 1 位的阴影遮罩;阴影图覆盖不到的地方,比如屏幕外,再用射线补。
第三步,间接光。用的是类似辐射度(radiosity)的方法:每 4×4 个纹素放一个半球探针,探针方向每帧抖动,最多累积 4 帧,插值时按平面和深度加权。
第四步,写回图集。光照存回卡片对应的位置,追踪命中这张卡片时直接读取。
间接光的输入,是本帧的直接光加上缓存里上一帧的间接光。
每多更新一轮,就多一次反弹,不必在一帧里递归。下一节把这条回路画出来。
数字取自 SIGGRAPH 2022 Lumen 演讲,后续版本可能调整
STEP 1
选页
优先更新“最近被用到、却最久没更新”的页
每帧预算:直接光 1024×1024,间接光 512×512 texels
STEP 2
直接光
8×8 一块 tile,每块最多 8 盏灯
先用阴影图出 1 位阴影遮罩,缺的部分用射线补
STEP 3
间接光
每 4×4 个 texel 放一个半球探针,4 帧抖动
按平面与深度权重插值,最多累积 4 帧
STEP 4
写回图集
光照存回卡片对应的位置
追踪命中这张卡片时,直接读取
多次反弹从哪来
间接光的输入 = 本帧直接光 + 上一帧的间接光。每多更新一轮,就多一次反弹,不必在一帧里递归。
5.9示意图
多次反弹是怎么一轮轮攒出来的
常有人问:Lumen 并没有在一帧里做递归光追,那多次反弹从哪来?答案是逐帧反馈。
卡片上每 4×4 个纹素共用一个半球探针(橙色),探针的方向每帧抖动,最多累积 4 帧,再按平面与深度权重插值回每个纹素。
上图是一张卡片的纹素网格。每 4×4 个纹素共用一个半球探针,探针的采样方向每帧抖动,最多累积 4 帧,再按平面和深度权重插值回每个纹素。
关键是这条递推关系:
本帧间接光 = 收集( 本帧直接光 + 上一帧间接光 )
算完写回缓存,下一轮又被当作输入读走。于是第一轮更新得到一次反弹,第二轮两次,第 N 轮 N 次。这就是第 1 章那个级数 L = Le + TLe + T²Le + …:每更新一轮,就往级数里多加一项。
用伪代码写出来,这条反馈回路只差一个细节:探针读的是缓存里已有的最终光照。
// 只处理这一帧按优先级选中的页(默认约整张图集的 1/64) for (page in PagesSelectedThisFrame) { for (probe in page.ProbesEvery4x4Texels) { float3 sum = 0; for (int i = 0; i < 16; i++) { // 4×4 个方向 float3 dir = HemisphereDir(i, FrameJitter); Hit h = TraceDistanceField(probe.pos, dir); // 关键:读的是"上一轮"写进缓存的最终光照 = 直接光 + 上一轮间接光 sum += h.valid ? SurfaceCache.FinalLighting(h) : SkyLight(dir); } probe.radiance = TemporalBlend(probe.history, sum / 16); // 最多累积 4 帧 } IndirectLighting[page] = InterpolateProbes(page); // 写回缓存 } // 合成后,下一轮又会被上面那行读走:每转一轮,多一次反弹 FinalLighting = DirectLighting + IndirectLighting * Albedo;
有两点要注意。第一,这里的"轮"指缓存同一处被更新的次数。每帧只更新一部分页,所以轮数不等于帧数。第二,反弹靠累积,灯光突变时,间接光会有一个追赶的过程,这就是常说的间接光滞后。Final Gather Lighting Update Speed 这个参数调的就是追赶的速度。
动手实现时这条反馈回路的单位约定、伪代码和白炉测试,见 。
探针布局与累积帧数来自 SIGGRAPH 2022 Lumen 演讲
本帧直接光
灯光 + 阴影,当帧算出
上一帧间接光
直接读缓存里的旧值
本帧间接光
写回 Surface Cache
橙色回路:这一帧的结果,成为下一帧的输入
第 1 轮
1 次反弹
第 2 轮
2 次反弹
第 N 轮
N 次反弹
5.10算例
缓存多久追上一次变化
前两节说缓存"每帧只更新一部分"。这一节把"一部分"换成具体的数字,再看它在画面上意味着什么:开一盏灯之后,房间要多久才亮透?
先算直接光。图集是 4096×4096,约 1678 万个纹素。默认每帧给其中 1/32 重新算直接光,也就是约 52 万个纹素。换句话说,整张图集轮一遍要 32 帧,60 帧每秒下大约半秒。
再算间接光。默认每帧刷新 1/64,约 26 万个纹素。5.9 节说过,每 4×4 个纹素共用一个半球探针,每个探针打 4×4 = 16 条光线。所以每帧要更新约 1.6 万个探针、26 万条光线,正好每个被刷新的纹素摊到一条。
直接光:最长等 32 帧轮到一次
间接光:最长等 64 帧轮到一次,而且每多一次反弹,就要再等一轮
这就是开灯时看到的现象:灯直射的墙先亮,靠反弹照亮的天花板一轮一轮地追上来。下面这张图用一个简化模型把它画了出来。
有两点要补充。第一,上面算的是最坏情况。引擎会按离镜头的距离和上次更新的时间排优先级,镜头附近的卡片刷新得勤得多,所以眼前的房间通常比曲线快,慢慢亮起来的多半是远处。第二,追赶的速度可以调:后处理体积里的 Lumen Scene Lighting Update Speed 会把这两个比例一起放大,代价是 Scene Lighting 这个 Pass 变贵。
间接光滞后不是 bug,而是"每帧只刷新一部分"这个预算的直接后果。想要它更快,就要付更多的 Scene Lighting 成本。
更新比例 1/32、1/64 与探针间距 4 为 r.LumenScene.DirectLighting.UpdateFactor / Radiosity.UpdateFactor / Radiosity.ProbeSpacing 的默认值;图中曲线为示意模型
06
追踪
一条光线先查屏幕,再查距离场,命中后只读缓存。漏光和远处发糊,都能在这一章找到结构上的原因。
缓存有了,接下来的问题是:从一个像素发出去的光线,怎么找到它会撞上的那一点?
硬件光追可以直接对三角形求交,但 Lumen 默认走软件路线:先在屏幕上找,找不到再进距离场里闭着眼走。这一章讲距离场是怎么存的,为什么只存表面附近的一圈,为什么远处一定更糙,以及为什么薄墙一定会漏光。
6.1
光线追踪:先便宜,后精确
Lumen 的追踪是逐级降级的:先试最便宜的,不行再换贵的。一共三种求交方式:
屏幕追踪先查屏幕上已有的深度和颜色,命中了几乎不花钱。软件光追在 Lumen Scene,也就是距离场加 Surface Cache 里追踪,这是默认路径。硬件光追在有硬件支持时用真实的三角形求交,质量更高;反射里还可以选 Hit Lighting,在命中点重新算光。
不管走哪条路,命中点的光照默认都读 Surface Cache。
这三种方式的分工可以用一句话记住:屏幕追踪管看得见的,距离场管看不见但不远的,硬件光追管要精确的。屏幕追踪最便宜,但画面外和被挡住的东西它一概不知;距离场什么都知道一点,但只是近似的形状;三角形最准,也最贵。
这一章先讲屏幕追踪为什么快,再花大部分篇幅讲距离场:它怎么走、怎么存、为什么远处一定更糙、为什么薄墙一定漏光。最后把整条路径串起来,再补上一个看不见、却决定性能的环节:剔除。
屏幕追踪
先查屏幕上已有的深度和颜色,命中则几乎不花钱
软件光追
在 Lumen Scene(距离场 + Surface Cache)里追踪,是默认路径
硬件光追
有硬件支持时质量更高;反射还可选 Hit Lighting,在命中点重新算光
6.2算法科普
HZB:屏幕空间追踪为什么快
追踪的第一级是屏幕。屏幕上本来就有一张深度图,问题是怎么沿着射线在里面快速找到交点。逐像素地走太慢,Lumen 用的是 HZB(Hierarchical Z-Buffer,层级深度图)。
HZB 是深度图的一条 mip 链:每升一级,就把下面 2×2 个像素合成一格,只保留其中最近的深度。下面是一个一维的例子:
射线深度 3 < mip 3 的 4:整段 8 个像素一步跳过 射线深度 6 > mip 2 的 5:可能相交,逐级下降确认
判断规则只有一条:如果射线此刻比一整格里最近的表面还近,这一整格就不可能相交,可以直接跳过;否则下降一级,把格子切细了再比。
比如射线在某处的深度是 3,比 mip 3 那一整格的 4 还近,于是一步跳过 8 个像素。走到后面射线深度变成 6,比 mip 2 左格的 5 远,可能相交,于是下降一级重新比较,一直比到 mip 0 也相交,才算真正命中。命中后直接取屏幕上那个像素的颜色作为光照,不必查 Lumen Scene。
空旷的区域一步跨一大段,只有靠近表面的地方才逐级细化,所以它很快。Lumen 还加了几个细节:射线穿过表面后退回上一个未被遮挡的位置;掠射角的射线限制迭代次数;漫反射光线用半分辨率追踪。
它的局限第 3 章已经说过:屏幕外的东西不存在。所以屏幕追踪只是第一级,没命中的光线要交给距离场。
在 UE 里你见过它HZB 就是深度图的 mipmap,和贴图的 mipmap 是同一个东西:每往上一级,长宽各减半。区别只在于贴图的 mipmap 取的是四个像素的平均色,HZB 取的是四个深度里最近的那个。
来源:SIGGRAPH 2022 Lumen 演讲
1 · 建立 HZB
每一级把上一级 2×2 个像素合成一格,只保留最近的深度。
2 · 粗层整段跳过
射线深度 3 比 mip 3 整格的 4 还近:不可能相交,一步跳过 8 个像素。
3 · 细层再确认
射线深度 6 比 mip 2 左格的 5 远:可能相交,下降一级重新比较。
4 · 最细层相交才算命中
命中后直接取屏幕颜色,不必查 Lumen Scene。
Lumen 的补充
越过表面后退回上一个未被遮挡的位置;掠射角射线限制迭代次数;漫反射光线用半分辨率。
6.3算法科普
距离场与 Sphere Tracing
软件光追不对三角形求交,而是在距离场(Signed Distance Field,SDF)里"走"。距离场是一张三维纹理,空间里每一点都存着它到最近表面的距离。
有了这个值,追踪就很简单:从任何一点出发,都知道自己离最近的表面至少还有多远,于是可以放心地一步跨过这段距离,不必逐个三角形检查。这种走法叫 sphere tracing。
Lumen 有两层距离场。每个网格自己的 Mesh SDF 用来追踪光线的前 2 米,保证近处的精度;把所有网格合并起来的 Global SDF 用于更远的距离,快但粗。引擎默认的软件光追模式叫 Global Tracing;另一个模式 Detail Tracing 全程追单个网格的距离场,质量最高,代价也很大。
写成代码,整个算法就是一个循环:
// 沿一条光线在距离场里走 float t = 0; for (int step = 0; step < MAX_STEPS; step++) { float3 p = origin + dir * t; // 当前走到哪 float d = SampleSDF(p); // 查表:离最近的表面至少还有多远 if (d < HitThreshold) // 足够近了,算命中 return Hit(p); // → 去 Surface Cache 取光 t += d; // 放心地跨过这段距离 if (t > MaxTraceDistance) break; // 走太远了,放弃 } return Miss(); // → 交给下一级,最后是天空光
注意 HitThreshold:它和距离场体素的大小有关。墙比一个体素还薄时,采样点可能一步就跨到了墙的另一侧,d 始终没有小到触发命中,这就是漏光在代码里的样子。
距离场看不见摸不着,下面把它切开给你看:
代价是精度。距离场是体素化的,很薄的墙会在采样时被漏掉,所以官方建议墙至少 10 cm 厚。下一节可以亲手试一试。
版本提示:Detail Tracing(逐网格距离场追踪)在 5.6 被标为弃用,5.7 起 r.Lumen.TraceMeshSDFs 默认为 0。Epic 建议需要比全局距离场更高的精度时,改走硬件光追。
在 UE 里你见过它Show › Visualize › Mesh Distance Fields 打开的就是它。材质编辑器里的 DistanceToNearestSurface 节点读的也是这份全局距离场,常被用来给水面贴着岸边加一圈泡沫。
动手实现时距离场的离线生成、全局合成和追踪时必须加的三处细节,见 。
距离场分辨率有限,薄于 10 cm 的墙表示不出来,所以软件光追会漏光。
6.4交互图
距离场里走路:敢迈多大步
下面是一条按 sphere tracing 规则走的光线。场景是一条走廊,中间横着一堵墙。距离场是在体素网格上采样、再双线性插值得到的,和引擎里的做法一致。
默认参数下,光线从左上方出发。第一步只能走到离天花板的距离那么远;往右下走,离墙和地板越来越近,圈越来越小,最后贴在墙面上停下。每一步都不需要预设步长,圈的大小就是步长。
注意墙附近那些小圆点。它们是距离场真正存了数值的位置,就像贴图的像素;橙色实心点表示"这个位置在物体里面"。现在把墙的厚度调到比体素边长还小:橙色点一个都不剩,光线直接穿过墙。墙太薄,所有采样点都落在墙外,在距离场眼里这面墙根本不存在。这就是软件光追漏光的真实机制。把体素调细,或者把墙调厚,墙就回来了。
再试另一个方向:墙保持厚度,把体素边长拉到最大。这次光线可能在半路被地板"拦下"。粗糙的距离场会把每个表面都算得比实际更近,遮挡因此变厚,间接光变暗。后面讲 Global SDF 时会看到,这正是远处的距离场每天都在面对的问题。
为什么不直接对三角形求交?因为查一次距离场只是一次纹理采样,而逐个三角形求交要贵得多。这就是软件光追不需要专用硬件的原因。
步长按真实的 sphere tracing 规则计算:距离场在体素网格上采样后双线性插值,墙薄于体素时漏光是这套算法的真实行为
规则只有一条
站在任何一点,距离场都告诉你"最近的表面在多远"。那就正好迈这么大一步——保证踩不到任何东西。
所以步子自己会收
空旷处一步跨很远,贴近表面时自动变小。不用预设步长,这是它比均匀步进聪明的地方。
为什么用它代替三角形
查一次距离场只是一次纹理采样,而逐个三角形求交要贵得多。这就是软件光追不需要专用硬件的原因。
代价也在这张图里
距离场是体素化的,精度有限。墙要是太薄,它根本记不出来,光线就直接穿过去——这就是漏光。图里的灰色网格就是体素网格,当墙比一个格子还窄,采样点全落在墙外,插值出的场里这面墙就不存在了。
6.5示意图
距离场只存表面附近的那一圈
距离场怎么存,直接决定了它的内存开销,也解释了为什么薄几何会出问题。
朴素的做法是把整个包围盒里的体素都存下来,内存随分辨率的三次方增长,用不起。Lumen 的做法是窄带加稀疏:只在紧贴表面的那一圈分配,内部和远处都不分配。
组织方式是 8³ 一块的砖块,按需分配。精度上,一个体素只用一个字节,记录 −4 到 +4 个体素范围内的距离;超出这个范围的点,反正不需要精确值。
所有砖块共用一个固定 320 MB 的池,按相机距离流进流出,打包进同一张图集。可以用 r.DistanceFields.LogAtlasStats 1 查看占用。
距离场在导入时离线生成:用 Embree 做最近点查询,符号(在内还是在外)靠从该点投 64 条射线、数背面命中的次数来判断。150 万个三角形的网格大约 0.6 毫秒。
只存表面附近的一圈,所以墙太薄时,两侧的窄带会重叠甚至互相抵消,表面根本表示不出来。
数字来自 SIGGRAPH 2022 Lumen 演讲 · 图为二维示意
全部体素都存(朴素做法)
实际分配的砖块
真实表面
窄带 + 稀疏砖块
按 8³ 一块的虚拟体积组织,只在表面附近分配。内部和远处不占显存。
一个体素一字节
只记 −4 到 +4 个体素范围内的距离。超出这个范围的点,用不着精确值。
固定 320 MB 砖块池
按相机距离流进流出,打包进同一张图集。r.DistanceFields.LogAtlasStats 1 可以看占用。
生成有多快
导入时用 Embree 做点查询;符号靠投 64 条射线、数背面命中来定。150 万三角的网格约 0.6 ms。
6.6示意图
Global SDF:四层 Clipmap 跟着相机走
每个网格的距离场只覆盖它自己。光线走出 2 米后,要换成合并起来的 Global SDF。它不能是一张覆盖整个世界的大网格,那样要么分辨率低到没用,要么内存爆掉。
做法是 Clipmap:四层嵌套的方形区域,都以相机为中心。每层的分辨率都是 256³,但覆盖范围一层比一层大一倍。
远处的距离场必然更糙,这是结构决定的,不是哪个参数调低了。
这解释了一个常被当成 bug 的现象:远处物体的遮挡和间接光,总是比近处粗糙。上一节交互图里"体素太粗时被地板拦下"的情形,在外层 Clipmap 上一直在发生。
它的内容是把各个网格的 Mesh SDF,连同地形的高度场,一起合并进这四层。更新是增量的:只更新被改动的砖块,更新列表直接在 GPU 上生成;静态砖块和动态砖块分开缓存,静态的不必每帧重算。相机移动时,整层会滚动,只补新进入范围的那部分,这也是 Clipmap 这个名字的由来。
另外还有一个四分之一分辨率的粗 mip,用 5 次 Eikonal 迭代传播出来。它专门用来加速步进:在空旷区域先用粗 mip 一步跨很远,靠近表面了再切回细的。
在 UE 里你见过它"跟着相机走、近处细远处粗"的 Clipmap 结构,Virtual Shadow Map 给方向光做阴影时也在用。远处的影子比近处糊,原因和这里一样。
结构与数字来自 SIGGRAPH 2022 Lumen 演讲 · 图为二维示意
相机
第 0 层最密
第 3 层最粗
每层同样是 256³
往外一层,覆盖范围翻倍,体素也跟着变粗。所以远处的距离场必然更糙,这是结构决定的,不是精度调低了。
由网格 SDF 与地形合并而来
把各个网格自己的距离场,连同地形的高度场,一起合并进这四层。
只更新被改动的砖块
更新列表在 GPU 上生成;静态砖块与动态砖块分开缓存,静态的不必重算。
还带一个粗 mip
四分之一分辨率,用 5 次 Eikonal 迭代传播出来,专门用来让空旷区域的步进一步跨得更远。
6.7示意图
SDF Expand:把表面"吹胖"半个体素
距离场精度有限,还带来一个更隐蔽的问题:掠射角的光线很容易从本该挡住它的表面旁边钻过去。表现出来,就是漏光,或者该有的遮挡不见了。
不扩张 → 漏过去
掠射光线在体素精度下被判为“还没碰到”,直接穿了过去
扩张后 → 稳定命中
橙色虚线是向外扩张后的等值面,光线在这里就停住
Lumen 的办法是在追踪时把表面向外扩张半个体素的对角线,相当于把表面"吹胖"一点,让光线稳定命中。这是运行时施加的,不改动资产。
但扩张有代价。如果近处也扩这么多,物体和地面接触处那圈细小的阴影会被吃掉,画面会显得发飘。所以扩张量随光线已经走过的距离线性增长:近处几乎不扩,远处才放开。
反射更敏感,用的是最大扩张量和到表面距离两者取小,否则镜面里会出现一圈糊边。植被是另一个特例:距离场带一个覆盖度通道,标出半透明的叶片,对这些地方减小扩张、加大步长,并按覆盖度做随机透明。否则一片树叶在距离场里会变成一堵实心墙。
机制与公式来自 SIGGRAPH 2022 Lumen 演讲 · 图为二维示意
真实表面
扩张出来的那一层
命中点
扩张多少
半个体素的对角线,运行时施加,不改动资产。
为什么要随距离变
近处扩张太多,接触阴影会被吃掉。所以扩张量随光线走过的距离线性增长:近处几乎不扩,远处才放开。
反射更严格
反射用 min(最大扩张量, 到表面的距离),避免镜面里出现一圈糊边。
植被特殊对待
距离场带一个覆盖度通道,标出半透明的植被:减小扩张、加大步长,并按覆盖度做随机透明。
6.8示意图
Voxel Lighting:填满空间的那一份光照
距离场只回答了"撞到哪里",还要知道"那里有多亮"。在 Mesh SDF 的前 2 米里,命中点可以精确地落到卡片上,读 Surface Cache。可是在 Global SDF 里走的光线,命中点往往落在很粗的位置上,用不了那么精确的表面缓存。
所以还有一份填满空间的粗粒度光照,叫 Voxel Lighting。结构上它同样是四层 Clipmap,但每层只有 64³ 个体素,比 Global SDF 的 256³ 粗得多。距离场追得粗,就配粗的光照。
关键的设计是:每个体素不只存一个平均亮度,而是六个轴向各存一份辐射度。一个体素里的光照是有方向的,朝天的面亮,朝地的面暗。如果只存平均值,这个体素里的墙面和地面会拿到同一个值,立体感就没了。六个轴向是性价比最高的折中,有人叫它 ambient cube,解码比球谐便宜。
更新也很省:从一份缓存下来的可见性缓冲重建,里面是 24 位的网格索引加 8 位的命中距离,不必重新光栅化整个场景。
Surface Cache 精确,但只在表面上;Voxel Lighting 粗糙,但到处都有。
结构与数字来自 SIGGRAPH 2022 Lumen 演讲
六个面 = ±X、±Y、±Z 六个轴向,各存一份辐射度。
法线:按它和三个相邻轴向加权混合
和 Surface Cache 什么关系
Surface Cache 是贴在表面上的高精度版本;Voxel Lighting 是填满空间的粗粒度版本。全局距离场追得粗,就配粗的光照。
结构
同样是 4 层 Clipmap,每层 64³ 个体素,比 Global SDF 的 256³ 粗得多。alpha 通道存投影权重。
怎么更新
从一份缓存下来的可见性缓冲重建:24 位网格索引 + 8 位命中距离,不必重新光栅化整个场景。
为什么要存六个方向
一个体素里的光照是有方向性的:朝天的面亮、朝地的面暗。只存一个平均值,墙面和地面会拿到同一个值,立体感就没了。六个轴向是性价比最高的折中,比球谐解码更便宜。
6.9追踪流程
一条光线的旅程(软件光追)
把这一章讲过的东西串起来,就是一条光线在软件光追路径下的完整旅程。
HZB 层级深度,近乎免费
命中点已经在屏幕上,直接取它的颜色,不用查任何缓存。
单个网格的距离场,前 2 米
命中点落在哪张卡片上,就读那张卡片里已经打好光的缓存值。
合并后的全局距离场,更远处
同上。远处精度更低,因为 Clipmap 每层覆盖范围翻倍。
采样天空光;Far Field 可到 1 km
第一级永远是屏幕追踪,在 HZB 里跳过空区域,命中就直接用屏幕颜色。屏幕里找不到,就进 Lumen Scene,在合并的 Global SDF 里继续走,快但粗。旧版本的默认路径是先追每个网格自己的距离场、管前 2 米的精度,再换成 Global SDF;这条 Detail Tracing 在 5.6 被标为弃用,5.7 起默认关闭。只要命中,光照就从缓存里读:近处是命中卡片的 Surface Cache,远处是 Voxel Lighting。
全部没命中,这条光线就被当作射向天空,采样天空光。超出 Lumen Scene 的范围之后,只剩屏幕追踪可用,除非开启 Far Field,它能把范围延伸到大约 1 公里,第 8 章会讲。
上面的流程图是按顺序画的。换成按距离看,会更容易理解每一级管多远:
有硬件光追时,第 2、3 级的距离场追踪换成真实三角形求交;反射里还可以选择 Hit Lighting,在命中点重新计算光照,质量更高,成本也更高。第 1 级的屏幕追踪两条路都走。
层级顺序与距离阈值依据 Epic 官方文档;具体距离可由参数调整
硬件光追(有硬件时)
用真实三角形求交代替第 2、3 级的 SDF 追踪;反射还可选 Hit Lighting,在命中点重新计算光照,质量更高,成本也更高。注意第 1 级的屏幕追踪两条路都走。
6.10示意图
剔除:每条光线只看该看的物体
最后补一个看不见、却决定性能的环节。追踪时,一条光线不可能去问场景里的每一个物体,所以要先剔除。
做法是把相机视锥按角度和深度切成格子,这种格子一般叫 froxel(frustum voxel)。场景物体按包围体分进这些格子,格子内再做一次基于采样的精细测试,把实际影响不到的剔掉,最后把每格的物体列表压紧,方便 GPU 读取。
方向光的阴影另有一套结构:把物体的有向包围盒光栅化到光源空间的一张 2D 网格里,追全长的射线,命中即停,不必找最近的那个交点。
为什么说它决定性能?Detail Tracing 要逐个网格追它自己的距离场,而 kitbash 搭出来的关卡里网格大量重叠,一个格子可能牵扯上百个候选物体,不剔除就直接爆掉。这也是官方把 Global Tracing 设为默认的原因。
对应到内容规范上:少用大量互相穿插的重叠网格;个别问题网格,可以在实例面板里关掉 Affect Distance Field Lighting。
剔除结构来自 SIGGRAPH 2022 Lumen 演讲 · 图为二维剖面
相机
一个格子:只记下蓝色那个物体
场景物体
GI 的物体剔除
视锥按角度和深度切成格子,把物体分进去,格内再做一次基于采样的精细测试,最后把每格的列表压紧。
方向光阴影另有一套
把物体的有向包围盒光栅化到光源空间的 2D 网格里,追全长射线,命中即停。
为什么是性能关键
Detail Tracing 要逐个网格追它自己的距离场。kitbash 搭出来的关卡里网格大量重叠,一个格子可能牵扯上百个候选物体,不剔除就直接爆。这也是官方默认用 Global Tracing 的原因。
对应的内容规范
少用大量互相穿插的重叠网格;问题网格可以在实例面板里关掉 Affect Distance Field Lighting。
页签 5 / 8
Lumen:最终收集与反射
每个像素只打得起四分之一条光线,Screen Probe Gather 怎样把它变成干净的间接光;镜面反射为什么要另走一条路。
07
最终收集
光线太贵,所以把它们集中到稀疏的探针上,再靠邻居和历史把噪点压下去。
追踪告诉我们一条光线撞到了什么、那里有多亮。可是回到第 1 章的结论:每个像素需要成百上千条光线,实时预算只给得起个位数。
Lumen 的 Screen Probe Gather 用 1.2 节预告过的三招来填这个缺口:重要性采样让每条光线更值钱,探针让一条光线服务一片像素,滤波和时间累积把邻居和过去几帧的光线也借来用。另外还有一个省存储的动作:用球谐把几十条光线压成 9 个数。这一章最长,也最精巧。
7.1
Screen Probe Gather 的流程
负责漫反射间接光的是 Pass 2,Screen Probe Gather。三招各自落在流程的哪一步,先看它一帧里的完整流程:
均匀网格探针,再按深度变化自适应加密(两轮)
BRDF 概率 × 光照概率,剔除、排序、重新分配光线
屏幕追踪 → SDF 或硬件光追 → 世界辐射度缓存兜底
与上下左右 4 个探针混合,按深度和角度加权
每探针约 64 条光线,压缩成 9 个 SH 系数
插值到像素,再做时间累积,得到漫反射 GI
第一步按屏幕放探针,先铺均匀网格,再在深度断裂处自适应加密。第二步为每个探针挑选光线方向。第三步追踪光线,走第 6 章讲的那条降级路径,远处用世界辐射度缓存兜底。第四步在探针之间滤波,把邻居的结果当作额外样本。第五步把每个探针的几十条光线压缩成 9 个球谐系数。第六步插值到每个像素,再做时间累积。
把六步写成代码,每一行对应流程图里的一格:
probes = PlaceProbes(GBuffer, spacing = 16); // ① 均匀网格 + 自适应 rays = ImportanceSample(probes, BRDF, lastFrameLight); // ② 64 个方向怎么分 radiance = TraceRays(rays); // ③ 屏幕 → 距离场 / 硬件光追 → 世界缓存兜底 radiance = FilterAcrossProbes(radiance); // ④ 借邻居的样本 sh = ProjectToSH(radiance); // ⑤ 64 个值压成 9 个系数 for (pixel in Screen) { // ⑥ 回到每个像素 float3 E = InterpolateFrom4Probes(pixel, sh); // 按平面和深度加权 DiffuseIndirect[pixel] = TemporalAccumulate(pixel, E); // 叠上历史帧 }
这张流程图是本章的地图。接下来按顺序展开其中的步骤。
动手实现时这六步拆成五个 Pass 的写法和每一步的坑,见 。
步骤划分依据 SIGGRAPH 2022 Lumen 演讲;每探针光线数随档位变化
流程是 ① → ⑥ 顺序执行的,第二行接着第一行。标橙的四步是"省钱"的四步:自适应放置省掉了平坦区域的探针,重要性采样省掉了没用的方向,球谐把 64 条光线压成 9 个数,时间累积白捡历史样本。它们各自的原理在前后的算法科普页里展开。
7.2示意图
探针放置:均匀网格 + 自适应加密
为什么要放探针?如果每个像素都追自己的光线,成本吃不消。所以先降采样,让一个探针服务一片像素。
第一轮是均匀网格:每 N×N 个像素放一个探针,N 由 DownsampleFactor 控制。这是这个 Pass 最主要的性能旋钮:N 调大一倍,探针数量变成四分之一。
但均匀网格在物体轮廓处会出问题。一个探针的两侧,可能是深度差很大的两个表面:前景的物体和远处的背景。用一个探针代表两边,两边都不对。插值出来的间接光会糊成一片,近处物体的光还会"漏"到远处的背景上。
所以第二轮做自适应加密:在深度不连续的地方额外补探针,整个放置过程分两轮完成。补出来的探针和均匀探针一起写进同一张探针图集,后面的追踪、滤波、球谐都不再区分它们。
这就是第 4 章说的"屏幕探针跟着屏幕走,所以要自适应加密"。
自适应放置来自 SIGGRAPH 2022 Lumen 演讲 · 图中探针间距与数量为示意
均匀探针
自适应补充的探针
前景物体(深度不连续)
① 先铺均匀网格
每 N×N 像素一个探针,N 由 DownsampleFactor 控制。这是最主要的性能旋钮。
② 再在断裂处补
轮廓两侧深度差很大,一个探针代表不了两个表面。这里补探针,分两轮放置。
③ 一起进探针图集
两类探针写进同一张图集,每个探针是一小块八面体纹素,后续步骤不再区分它们。
不加密会怎样:物体边缘的间接光糊成一片,近处物体的光“漏”到远处背景上。
7.3算法科普
八面体映射:把球面铺成方形纹理
每个探针要记录"四面八方各射来多少光",这是一个包住探针的球。可 GPU 最擅长处理的是平面的贴图,怎么把一个球放进一张正方形的图里?
这个问题美术其实很熟:这就是给球展 UV。常见的展法各有毛病:立方体贴图要六张图;经纬图(HDRI 那种 2:1 的全景图)在南北两极会被拉伸得很厉害。八面体映射是另一种展法:
先把球"压扁"成一个八面体,就是两个金字塔底对底拼起来。上半个八面体展平后正好是中间的菱形,下半个的四个三角面向外翻折,填满正方形的四个角。
p = d / ( |x| + |y| + |z| )
- d = (x, y, z)
- 一个方向
- p
- 它压到八面体表面上的位置
这种展法的好处是拉伸比较均匀,计算也便宜,采样时接缝问题也少。
Lumen 的每个屏幕探针就是这样一小块八面体贴图。追踪分辨率 8,表示每个探针是 8×8 = 64 个像素,也就是 64 个方向、64 条光线。第 3 章 DDGI 的辐照度图和距离图,用的也是同一种展法。
在 UE 里你见过它给远景树木做 Impostor(替身面片)时,常见的"八面体 Impostor"就是从八面体分布的各个方向把物体拍下来,按同样的方式铺进一张贴图。
1
方向 d 投影到正八面体:p = d / (|x|+|y|+|z|)
2
上半球落在中间的菱形,下半球翻折成四个角
3
展开成方形纹理,每个纹素对应一个方向
方向分布较均匀、编码解码便宜。Lumen 的探针是一小块八面体纹素,约 64 条光线(8×8)。
7.4算法科普
重要性采样:把光线用在刀刃上
每个探针只有 64 条光线,就像一幅画只给你 64 笔。均匀地朝各个方向画是浪费的:有些方向几乎什么都看不到,画在那里等于白画。重要性采样的意思就是:把笔触集中在画面里重要的地方。
什么方向重要?Lumen 看两件事。
材质觉得哪里重要。漫反射表面主要接收正面来的光,贴着表面擦过来的光贡献很小。所以朝表面正前方多派光线,朝侧面少派。
上一帧觉得哪里亮。上一帧这个探针已经看过一遍了,知道窗户在哪边、灯在哪边。亮的方向多派,暗的方向少派。没有上一帧时,就用世界空间的缓存来猜。
两件事乘在一起,就是每个方向最终能分到多少光线:
一个方向分到的光线多少,跟"材质觉得它重要的程度"乘以"上一帧看到它有多亮"成比例。
p(ω) ∝ pBRDF(ω) × p光照(ω)
- p(ω)
- 方向 ω 被选中的机会
- pBRDF
- 材质给的权重
- p光照
- 上一帧亮度给的权重
下面是一个漫反射表面上的点,头顶半圆是它看到的环境,右上方有一扇亮窗户。同样的光线条数,换三种挑方向的办法,看估计值的噪点差多少:
Lumen 实际做的还要多三件事:材质权重太低的方向直接放弃;剩下的按重要程度排序;把放弃省下来的光线,补给最重要的方向。
有一个细节不能漏:被多派了光线的方向,它的结果要打折;被少派的,要加权。否则亮的方向被数了太多次,整个画面会偏亮。这就是第 1 章蒙特卡洛公式里那个"除以 p"。
重要性采样不增加光线,只是让每一笔都画在更有用的地方。
BRDF 概率
材质:哪些方向本来就重要
×
光照概率
历史:上一帧哪些方向更亮
=
合成概率
相乘:实际的采样概率
剔除概率过低的方向
按概率并行排序
把被剔除的光线重新分给重要方向
每条光线按 1/p 加权,结果仍然无偏,但同样的光线数噪声更低。
7.5算法科普
球谐函数:用 9 个数存下漫反射光照
每个探针追完 64 条光线,得到 64 个方向上的亮度。直接存下来太大,插值也慢。第 2 章讲过的球谐在这里派上用场:把它们压缩成 9 个系数。
第 0 阶 · 1 个系数
整体的平均亮度,也就是环境光
第 1 阶 · 3 个系数
亮度随方向线性变化:朝哪边更亮
第 2 阶 · 5 个系数
更细一点的明暗分布,合计 9 个
第 0 阶 1 个系数,是整体的平均亮度,也就是环境光。第 1 阶 3 个系数,描述亮度随方向的线性变化:朝哪边更亮。第 2 阶 5 个系数,是更细一点的明暗分布。合计 9 个,每个系数有 RGB 三个通道。
投影时,每条光线的亮度乘以基函数在那个方向上的值,累加起来就是系数。重建时反过来,系数乘基函数再叠加,就能还原任意方向的亮度。
为什么 9 个就够,第 2 章推导过:漫反射只关心整个半球的余弦加权平均,高频的细节被余弦核抹平了。Lumen 把结果存成环境项和方向项两部分,供后面的插值使用。
同样的理由也划出了边界:尖锐的高光是高频的,球谐存不下。所以光滑表面的反射,要走第 8 章那条单独的路。
投影:每条光线的亮度 × 基函数值,累加成系数
重建:系数 × 基函数,叠加还原任意方向的亮度
漫反射只关心整个半球的加权平均,细节被余弦权重抹平,9 个系数就够;镜面反射不能这样存。
7.6算法科普
降噪三步:滤波、插值、时间累积
即便用了重要性采样,每个探针也只有几十条光线,结果仍然很噪。Lumen 用三步把噪声压下去,而且三步都在降采样后的探针空间里完成,比在全分辨率的屏幕上做便宜得多,效果也更好。
① 探针空间滤波
与上下左右 4 个邻居混合:等于多追光线,却不多花钱。
深度差大的邻居权重低;命中很近的邻居不参与。
② 插值到像素
每个像素从周围探针插值得到结果。
权重会考虑是否在同一表面,避免跨表面漏光。
③ 时间累积
把历史帧与当前帧混合,等于把过去的光线也算进来。
场景变化时降低历史权重,避免拖影。
第一步,探针空间滤波。每个探针每帧的光线方向是随机抖动的,彼此独立。把上下左右 4 个邻居的结果混合进来,就近似于多追了几倍的光线,却不用真的多追。混合时有两条规则:深度差越大的邻居权重越低,避免跨越不同的表面;邻居的光线命中点如果离得很近,它的结果不适合当前这个方向,要排除。
第二步,插值到像素。每个像素从周围的探针插值得到结果。权重同样要看是否在同一个表面上,否则会跨表面漏光。下一节专门讲这一步。
第三步,时间累积。把历史帧和当前帧混合,等于把过去的光线也算进来。场景变化时要降低历史权重,否则会拖影。再后面两节讲这一步。
换个熟悉的说法Photoshop 的"表面模糊"只在颜色相近的像素之间模糊,边缘保得住。探针滤波里"深度差大的邻居权重低"是同一个思路:只在同一个表面上互相借样本,不跨过物体的边缘。
降噪发生在降采样后的探针空间,而不是全分辨率屏幕上。
7.7示意图
插值回像素:漏光是怎么产生的
插值这一步,是整个流程里最容易在画面上看出问题的地方。它和第 3 章那张交互图是同一个问题:周围的探针,哪些能用?
上图是一个墙角。左边有灯,墙是中间那块深灰色的竖条,墙右边是暗房间。待求的像素在地板上,紧贴着墙的左侧。周围四个探针里,三个和它在同一侧,可以采纳;第四个在墙的另一侧,连线穿过了墙,必须排除。
Lumen 的权重看两件事:探针和像素是否在同一个平面上,以及两者的深度差有多大。差得越多,权重越低。
float3 sum = 0; float wsum = 0;
for (probe in Nearest4Probes(pixel)) {
float w = BilinearWeight(pixel, probe);
// 像素离"探针所在的那个平面"有多远?越远越不可信
float planeDist = abs(dot(pixel.pos - probe.pos, probe.normal));
w *= saturate(1 - planeDist / DepthThreshold);
sum += w * probe.SH.Evaluate(pixel.normal);
wsum += w;
}
// 四个探针全被排除:自适应探针就是为了尽量避免这种情况
return sum / max(wsum, 1e-4);代码里的 DepthThreshold 就是下面说的"松"和"严"。
权重太松,就漏光。墙另一侧的亮探针被插进来,暗房间里出现本不该有的亮光。墙太薄时,距离场本身也挡不住光线,两个原因会叠在一起。权重太严,就暗边。可用的探针被排除得太多,墙角和物体接触处发黑,看起来像多了一层很脏的环境光遮蔽。
所以排查漏光时先查几何:墙够不够厚,距离场能不能表示出来,最后才去动参数。
权重机制依据 Epic 文档与 SIGGRAPH 2022 演讲 · 图为二维剖面示意
待求像素
同一表面,采纳
墙后,权重归零
权重看两件事
这个探针是否和像素在同一个平面上;两者的深度差有多大。差得越多,权重越低。
权重太松 → 漏光
墙另一侧的亮探针被插进来,暗房间里出现本不该有的亮光。墙太薄时,距离场也挡不住,两个原因会叠加。
权重太严 → 暗边
可用探针被排除得太多,墙角和接触处发黑,看起来像多了一层脏的环境光遮蔽。
所以漏光先查几何:墙是否够厚、距离场是否表示得出来,再去动参数。
7.8示意图
时间累积:历史什么时候不能用
降噪的第三步是时间累积。思路很直接:上一帧已经追好的光线不该浪费,把它当成额外的样本。
流程分四步:
- 取上一帧的探针结果。它相当于一批已经追好的光线,免费。
- 用运动矢量重投影,算出当前这一点在上一帧画面里的位置。
- 校验:比对深度和法线,确认重投影过去的那个点还是同一个表面。
- 可信就混合,历史占大头,噪声被压下去;不可信就丢弃,只用当帧,这一块于是明显更噪。
第三步最关键。历史失效最典型的情况是这样的:
物体移动后,原先被它挡住的那一条区域露了出来。它在上一帧的画面里压根不存在,所以没有历史可借。镜头快速平移或旋转时,整个画面的边缘都是这种情况。这就是为什么甩镜头时,间接光会明显变噪,然后逐帧收敛。
动手实现时历史校验、历史长度和切镜处理的写法,见 。
时间重投影是实时渲染的通用做法;图为通用原理示意,不代表 Lumen 的具体实现细节
① 取上一帧的探针结果
它相当于一批已经追好的光线,免费
② 用运动矢量重投影
算出这一点在上一帧画面里的位置
③ 校验:还是同一个表面吗
比对深度与法线,判断历史是否可信
可信 → 混合
历史占大头,噪声被压下去
不可信 → 丢弃
只用当帧,于是这里噪声更明显
本帧物体位置
上一帧物体位置
新暴露出来:历史失效
镜头快速平移或旋转时,整个画面边缘都是这种情况,所以甩镜头时间接光会明显变噪、然后逐帧收敛。
7.9白盒
时间累积:噪点没了,但东西会拖影
时间累积是一笔交易。下面这个白盒把交易的两边放在一起:它像游戏引擎一样实时地累积光照,只保留最近若干帧的结果。
静止的时候,每一帧的噪点位置是随机的,叠的帧越多,噪点抵消得越干净。累积 16 帧,相当于每个像素多了 16 倍的光线,成本却是零。所有现代实时渲染都靠这一招。
点"让盒子移动"。叠加有个前提:这个像素和上一帧看到的是同一个东西。盒子一动,这个前提就破了,旧位置的光照还留在历史里,于是盒子身后拖出一道残影。这不是模糊,而是实实在在的错误光照。把累积帧数拖小,残影变短,但画面重新变噪:同一个滑块,一头是噪点,一头是拖影。
再打开"历史校验"。现在每个像素都会先问一句"我看到的还是不是同一个物体",不是就丢掉历史。盒子的边缘立刻干净了,可刚露出来的那块地面变成了噪点,因为那里没有历史可借。
最值得注意的是地上的影子。即使开了校验,它依然拖在盒子后面。原因是校验只问"还是不是同一个表面":地面始终是地面,表面没变,变的是照在它上面的光。这种"光照变了、表面没变"的情况,校验查不出来。
Lumen 的间接光滞后、Lumen Lite 的黑块拖尾,都是这一笔交易,不是哪个参数没调好。
在 UE 里你见过它TAA 和 TSR 用的是同一招:把前几帧混进来,压锯齿也压噪点。快速移动的物体身后那道淡淡的虚影,就是同样的代价。
白盒中的累积按指数滑动平均计算,"历史校验"按每个像素看到的物体编号比对;真实引擎还会做运动矢量重投影
这一招为什么有用
每帧的噪点位置是随机的。把连续几帧叠起来,噪点互相抵消,等于凭空多了几倍的光线,一分钱没多花。
代价是什么
叠加的前提是"这一点和上一帧是同一个东西"。物体一动,这个前提就破了,旧值还赖在原地,于是拖出一串本不该有的暗块。
引擎怎么补救
每帧都要校验一次:这一点还是同一个表面吗?不是就丢掉历史。丢掉就意味着这块重新变噪——所以甩镜头时画面会先噪一下再收敛。
为什么值得记住
Lumen 的间接光滞后、Lumen Lite 的黑块拖尾,根源都是这一笔交易,不是哪个参数没调好。知道这一点,排查的时候就不会在参数面板里打转。
7.10
近场与远场:World Radiance Cache
屏幕探针还有一个矛盾没解决:远处的光怎么办?
屏幕探针只追踪较短的距离,不超过上图的虚线圈。短光线又快又准,近处的细节很丰富。但如果让每个屏幕探针都去追很长的光线,一是贵;二是每个探针的光线数量有限,远处那么大的范围根本采不够,结果就是噪声。
Lumen 的办法是分工:圈外的部分,交给周围的世界空间探针,也就是第 4 章说的第三层缓存,World Radiance Cache。它们的优势是被共享:同一个世界探针会被很多帧、很多个屏幕探针反复读取,所以在它身上多花光线是划算的,噪声能压下去。
这个分工的依据不只是距离,而是变化速度:远处的光照变化慢,适合缓存;近处变化快、需要细节,适合现追。一个像素最终的间接光是三部分之和:
屏幕追踪 + 近场追踪 + 远场(世界缓存)= 这个像素的间接光
每帧更新多少个世界探针是有预算的,第 9 章的调参部分有对应的控制台变量。
动手实现时屏幕探针和世界缓存交接的写法,见 。
图为二维示意,探针数量、间距与追踪距离不代表真实参数
屏幕探针(近场)
世界探针(远场)
近场:短而密
屏幕探针只追踪较短的距离,所以细节丰富。但每个探针的光线数量有限,远处的光它收不全。
远场:长而共享
超出虚线圈的部分,交给周围的世界空间探针。它们被很多帧和很多像素共享,所以敢多花光线把噪声压下去。
为什么这样分
远处的光照变化慢,适合缓存;近处变化快、需要细节,适合现追。按变化速度分工,而不是按距离一刀切。
屏幕追踪 + 近场追踪 + 远场追踪 = 这个像素的屏幕空间辐射度
7.11
World Radiance Cache 是怎么组织的
世界空间的探针并不是均匀铺满空间的。它们由屏幕探针"请求"出来:屏幕探针追踪时需要远处的光照,就在附近的表面上播种世界探针,所以探针总是贴近表面。
探针按 Clipmap 分层,靠近相机的层密,远处的层稀疏,和第 6 章的 Global SDF 是同一个思路。数据结构是两张纹理:一张 3D 纹理当作索引,像一张跳转表;一张图集存实际的辐射度。
查询分三步:把世界坐标换算成某一层的探针坐标;通过索引纹理找到探针;按方向在图集里采样。
探针太多,不可能每帧全部更新。所以它是跨帧持久、慢更新的:按优先级排序,每帧只挑一部分更新,预算可以用控制台变量调节。不透明物体和半透明物体各有一套,下一节讲后者。
世界坐标
Clip Map 探针坐标
索引纹理查表
按方向采样图集
7.12示意图
三个采集域:探针放在哪里
前面讲的都是不透明物体,最后补上半透明和雾。关键的认识是:最终收集不只有一个域,而是三个,区别在于探针放在哪里。
不透明:屏幕空间 2.5D
探针贴在可见表面上,就是前面讲的 Screen Probe。
半透明与雾:相机对齐的 3D 体积
光照发生在空间里,不在某个表面上,所以探针要填满体积。
Surface Cache:纹理空间 2D
就是前面讲的那组半球探针,活在卡片的图集里。
不透明物体:探针贴在屏幕上可见的表面,是 2.5 维的,因为只有一层可见表面。这就是本章一直在讲的 Screen Probe。
半透明和雾:它们的光照发生在空间里,而不是某个表面上。雾的每一段、玻璃的每一层都需要知道周围的光,所以探针要填满一个和相机对齐的三维体积。
Surface Cache 自己:探针活在卡片的图集里,属于纹理空间的二维,也就是第 5 章那组 4×4 纹素共用的半球探针。
工程上有两点要注意。第一,半透明有自己独立的一套 World Radiance Cache,和不透明那套并行,预算单独调:r.Lumen.TranslucencyVolume.RadianceCache.NumProbesToTraceBudget。
第二,半透明物体本身在追踪里基本是被跳过的。硬件光追会跳过半透明网格,alpha 遮罩低于 50% 不透明度的也跳过,由 MaxTranslucentSkipCount 控制;软件光追下,半透明材质会作为一张 Lumen 卡片加进来,在用 Hit Lighting 时能收到漫反射 GI。所以一面玻璃挡不住 Lumen 的光线,这是预期的行为,不是 bug。
三个采集域与跳过规则来自 SIGGRAPH 2022 演讲与 Epic 文档
半透明有自己的世界缓存
不透明与半透明各一套 World Radiance Cache,预算分开调:r.Lumen.TranslucencyVolume.RadianceCache.NumProbesToTraceBudget
半透明物体本身会被跳过
硬件光追追踪时跳过半透明网格,alpha 遮罩低于 50% 不透明度也跳过,由 MaxTranslucentSkipCount 控制。软件光追下半透明材质会作为一张 Lumen 卡片加入,用 Hit Lighting 时能收到漫反射 GI。
08
反射与硬件光追
只有够光滑的表面才值得单独花光线,其余一律复用缓存。另外,硬件光追不一定更快。
球谐只够表达漫反射,第 1 章就说过原因:光滑的反射需要知道光从哪个方向来,一个辐照度不够用。所以反射必须另走一条路。
但这条路也不是每个像素都要走。这一章讲 Lumen 怎么按粗糙度分流、怎么把稀疏的反射光线压紧,再讲硬件光追为了变快做了哪些妥协,以及为什么实测数字常常和直觉相反。
8.1
反射:按粗糙度决定花不花光线
专门为反射追光线很贵,所以 Lumen 先问一个问题:这个表面值不值得单独花光线?答案取决于它有多光滑。
上图这排球从左到右粗糙度递增。最左边是镜面,高光是一个很小很锐的亮点,意味着它只反射某一个特定方向来的光,必须精确知道那个方向上有什么,一个平均值糊弄不过去。最右边很粗糙,高光摊成了一整片,意味着它反射的是周围一大片方向的平均值。
粗糙表面要的本来就是一个模糊的平均值,而这正是辐射度缓存擅长提供的。
所以 Lumen 按粗糙度分流。够光滑的表面发射专用的反射光线:按材质的反射分布采样方向,单独追一遍,再做时空降噪。够粗糙的表面直接复用 Screen Probe 阶段已经算好的辐射度缓存,几乎没有额外成本。这不是偷工减料:粗糙那一侧需要的精度本来就低,画质没有损失,省下的只是成本。
下面这张图把这个判断拆开:左边是反射要收集的方向范围,越粗糙越宽;右边上下两条,分别是这个表面真正需要的反射,和直接复用探针缓存能给的反射。
for (pixel in Screen) {
if (pixel.roughness > MaxRoughnessToTrace) { // 默认 0.4
// 复用:屏幕探针里已经有这个方向附近的光,几乎免费
Specular[pixel] = ScreenProbeRadiance(pixel, reflectDir);
} else {
Ray r = SampleGGX(pixel.normal, viewDir, pixel.roughness); // 一条专用光线
Hit h = TraceScreenThenScene(r);
Specular[pixel] = h.valid ? SurfaceCache(h) // 或 Hit Lighting:当场算光
: SkyLight(r.dir);
}
}
Specular = TemporalAccumulate(SpatialFilter(Specular)); // 稀疏光线靠降噪补齐实际实现里,分界线附近还有一段平滑过渡(RoughnessFadeLength),避免粗糙度刚好跨过 0.4 的地方出现一条硬边。
光线命中之后,光照默认读 Surface Cache。硬件光追下可以改用 Hit Lighting,在命中点重新计算光照,质量最好,但贵得多,本章最后有具体的耗时。
分界线的位置由引擎设定。值得提前说一句:Lumen Lite 干脆把光滑那一侧整个换成了屏幕空间反射(SSR),所以在那一档,镜面里会丢掉屏幕外的内容。
球的高光变化为粗糙度示意 · 两段的宽度不代表真实阈值比例
够光滑 → 发射专用反射光线
按材质的反射分布采样方向,单独追一遍,再做时空降噪
够粗糙 → 直接复用 Screen Probe 的辐射度缓存
反正本来就模糊,缓存的精度完全够用,几乎没有额外成本
为什么能这么省
粗糙表面的反射本身就是一片模糊的平均值,而这正是辐射度缓存擅长的。画质没损失,钱省下来了。
命中之后光照从哪来
默认读 Surface Cache。硬件光追下可改用 Hit Lighting,在命中点重新算光,质量最好但贵得多。
分界线在哪
图里的宽度只是示意。真实阈值由引擎设定,而 Lumen Lite 干脆把左半边整个换成 SSR。
8.2流程
一个像素的反射怎么算
把分流画成流程,是下面这样:
每个像素的材质属性
这一个判断决定了成本
直接拿漫反射那套探针的结果,几乎没有额外成本
沿材质的反射分布采样
屏幕 → SDF → 远场缓存
空间滤波 + 时间累积
两条路径的结果叠加到间接光上
每个像素先读取自己的粗糙度。粗糙的那一支直接拿漫反射那套探针的结果。光滑的那一支要多走几步:发射反射光线,沿屏幕、距离场、远场缓存逐级降级去找命中点,取回命中点的光照;反射光线的结果噪声很大,所以还要做空间滤波加时间累积。两条路最后汇合,和漫反射一起合成到画面上。
注意两条路共用同一套缓存,命中点的光照默认都读 Surface Cache。这又一次体现了第 4 章的主线:能复用缓存的地方,就不现场算光。
分流依据与 Hit Lighting 说明来自 Epic 官方文档
两条路径共用同一套缓存
命中点的光照默认读 Surface Cache;硬件光追下可改用 Hit Lighting,质量更高,成本也更高。注意粗糙表面走复用那一支不是偷工减料:粗糙反射要的本来就是一片模糊的平均值,而这正是辐射度缓存擅长提供的,省下来的是成本不是画质。
8.3示意图
反射怎么省:复用、滤波、压缩光线
光滑表面的反射光线省不掉,但可以省着用。前两招和漫反射那边是同一套思路。空间与时间复用:邻域像素的反射光线互相借用,再叠加历史帧。双边滤波:按法线、深度和粗糙度加权,避免把不同材质的反射糊到一起。
第三招是一个很工程化的优化,值得单独讲:光线压缩。
GPU 按线程组调度,组里只要有一个线程在干活,整组就得等着。反射光线很稀疏,只有够光滑的像素才发,所以压缩之前,活跃光线散落在各个组里,每组只有一两条真在追踪,其余线程都在空转。
做法是每轮追踪之后,用 Z 序前缀和把仍然活跃的光线重新打包,挤到前面的线程组里,后面整组就能直接跳过。2022 年演讲给出的数字是:最多约 50% 的提速;在反射上,这种做法比用局部原子操作快 21%。
技术与数字来自 SIGGRAPH 2022 Lumen 演讲 · 图为示意
活跃光线
空槽位
虚线:线程组边界
上排:活跃光线散落在三个线程组里,每组都在空转。下排:压缩后挤到一起,后面整组可以跳过。
复用漫反射的光线
粗糙表面直接吃 Screen Probe 阶段的辐射度缓存,不另外发光线。
空间与时间复用
邻域像素的光线互相借用,再叠加历史帧,和漫反射那一侧是同一套思路。
双边滤波
按法线、深度、粗糙度加权,避免把不同材质的反射糊在一起。
光线压缩
追踪后用 Z 序前缀和把活跃光线压到一起。演讲里给出的数字:最多约 50% 提速,反射上比用局部原子操作快 21%。
8.4
软件光追与硬件光追的差别
前几章默认走的都是软件光追。有硬件时,Lumen 也可以换成硬件光追。两条路径共用同一套管线,区别只在光线怎么求交。
| 项目 | 软件光追(距离场) | 硬件光追 |
|---|---|---|
| 相交对象 | 网格 SDF + 全局 SDF | 真实三角形(Nanite 用 Fallback Mesh) |
| 骨骼网格 | 只有 6 张 Card,变形时覆盖有限 | 支持蒙皮网格 |
| 命中点光照 | Surface Cache | Surface Cache,或 Hit Lighting(反射) |
| 默认范围 | Lumen Scene 200 m,最大 800 m | Far Field 可达 1 km(需要 World Partition HLOD1) |
| 主要成本 | 距离场图集的内存与更新 | 加速结构构建,场景大时开销高 |
软件光追在距离场里走,不依赖专用硬件,平台覆盖广;但骨骼网格只有 6 张卡片,变形大时覆盖不好。硬件光追直接对三角形求交,支持蒙皮网格,还能在反射里用 Hit Lighting;代价是要构建加速结构,场景大时开销高。
粗略的取舍是:角色多、对反射要求高,倾向硬件光追;平台要求广、场景大,倾向软件光追。这是经验判断,不是官方结论。下一节会看到,硬件光追也有自己的妥协;本章最后的实测数字则说明,哪条路更快,只能在自己的场景里测。
取舍:角色多、反射要求高,倾向硬件光追;平台广、场景大,倾向软件光追。
8.5示意图
硬件光追为了变快,做了哪些妥协
硬件光追为了在实时预算里跑起来,做了几处妥协。每一处都对应一类画面上的问题。
Payload 砍到 20 字节。光线命中后要带回来的数据,默认是 64 字节。Lumen 的命中着色器只取两样东西:几何法线,以及到表面缓存的参数化坐标。光照之后再查缓存,所以 20 字节就够了。
全部几何强制不透明。BVH 里所有几何都标成不透明,这样就不用 any-hit 着色器,省掉大量着色器调用。代价是半透明物体只能跳过,这正是第 7 章讲半透明时提到的。
蒙皮网格每帧重建。变形的网格每帧都要更新加速结构,开销和蒙皮三角形数成正比。角色多的场景要盯住这一项。
还有一类问题来自 Nanite:
Nanite 渲染的是真实的高密度几何,但 Lumen 追踪时可能用的是简化后的 Fallback 网格。于是着色点可能落在 Fallback 网格的内部,射线一出发就打到 Fallback 的背面,自己挡住自己,画面上表现为一块黑斑。Lumen 的解法是先发一条很短的射线并忽略背面,确认没问题再发长射线。
治本的办法是调大 Fallback Relative Error,让简化网格更贴近真实几何。另外,屏幕追踪本身也会顺手掩盖掉大部分差异,因为它用的是屏幕上真实的深度。
实现细节来自 SIGGRAPH 2022 Lumen 演讲与 Epic 文档 · 图为二维示意
Nanite 的真实几何
Lumen 追的 Fallback 网格
先发一条很短的射线,忽略背面
自交 → 画面上是黑斑
Payload 砍到 20 字节
默认 64
Lumen 20
命中着色器只取几何法线和缓存参数化,别的都不带。
全部几何强制不透明
BVH 里不用 any-hit 着色器,省掉大量着色器调用。代价就是半透明只能跳过。
蒙皮网格的隐性成本
变形网格每帧都要更新加速结构,开销与蒙皮三角数成正比。角色多的场景要盯这一项。
黑斑怎么治
调大 Fallback Relative Error,让简化网格更贴近真实几何;屏幕追踪本身也会顺手掩盖掉大部分差异。
8.6
Far Field:间接光能走多远
大世界里有一个很实际的问题:间接光到底能走多远?答案分三段。
0 到 200 米是默认的 Lumen Scene,完整质量,距离场和表面缓存都在。200 到 800 米需要调高 Lumen Scene View Distance,质量一样,但范围越大,每帧的更新压力越大,快速移动时间接光的弹出也越明显。800 米到 1 公里是 Far Field:追踪 World Partition 的 HLOD 几何,用单独的 ray mask 做第二遍追踪,不和近场混在一起。
Far Field 只在硬件光追下可用。开启它要在 DefaultEngine.ini 里写 r.LumenScene.FarField=1,而且 HLOD1 必须已经构建。最容易踩的坑是 r.RayTracing.Culling.Radius:它要和最大追踪距离对上,否则近场物体会被提前剔除,远处留下覆盖空洞,表现为远景的间接光和反射缺了一块。
2022 年的演讲里还有一个有意思的优化:
把远场几何整体平移开,两边都更快
| 做法 | 近场 | 远场 |
|---|---|---|
| 只有近场(基准) | 1.65 ms | — |
| 远近场坐标重叠 | 2.37 ms | 1.04 ms |
| 远场整体平移 | 1.77 ms | 0.60 ms |
Matrix Awakens,PS5 · 1080p。远近场的几何若挤在同一段坐标空间里,BVH 的划分效率会下降。
如果远场的 HLOD 几何和近场几何挤在同一段坐标空间里,BVH 的空间划分效率会下降,两边都变慢:近场从基准的 1.65 毫秒涨到 2.37。把远场几何整体平移开之后,近场回到 1.77,远场也从 1.04 降到 0.60。加速结构的性能,对几何在空间里的分布非常敏感。
范围与设置来自 Epic 文档,耗时来自 SIGGRAPH 2022 演讲;以所用引擎版本的文档为准
默认 Lumen Scene
完整质量:距离场 + 表面缓存
调高 Lumen Scene View Distance 可达
同样质量,但范围越大、更新压力越大
Far Field
追 HLOD 几何
0 — 200 m
200 — 800 m
800 m — 1 km
开启的前置条件
只在硬件光追下可用。r.LumenScene.FarField=1 写进 DefaultEngine.ini,并且需要 World Partition 的 HLOD,HLOD1 必须已构建。
最容易踩的坑
r.RayTracing.Culling.Radius 要和最大追踪距离对上。不然近场物体被提前剔除,远处留下覆盖空洞。
实现方式
HLOD 几何放进单独的 ray mask,第二遍追踪,不和近场混在一起。
8.7实测开销
数字长什么样:PS5 · 1080p
最后看几个真实的数字,让前面的取舍有个量级感。
先看 Final Gather 的软件光追和硬件光追。最刺眼的是 Land of Nanite:软件 0.94 毫秒,硬件 10.03 毫秒,差了十倍。那个场景的三角形密度极高,加速结构的构建和求交都很贵。但换成 Lyra 只差 0.2 毫秒,到了 Matrix Awakens,硬件光追还略快一点。
硬件光追不是一律更慢,也不是一律更快。差距由场景几何决定,只能自己测。
再看反射光线命中之后的光照从哪来。默认读 Surface Cache 是 2.44 毫秒;改成 Hit Lighting 在命中点重新计算完整光照,是 11.54 毫秒,约 4.7 倍。这就是为什么 Hit Lighting 默认只对反射开放,通常也只有对镜面质量要求很高的项目才会打开。
同样这几个场景的 Reflections 耗时(软件 / 硬件):Land of Nanite 0.04 / 0.46,Lyra 1.58 / 2.03,Matrix Awakens 1.76 / 1.79。
数字来自 SIGGRAPH 2022 Lumen 演讲(PS5、1080p、特定场景),不是通用基准,也不代表当前版本
Final Gather:软件光追 vs 硬件光追
0.94
10.03
Land of Nanite
1.21
1.41
Lyra
1.83
1.72
Matrix Awakens
软件光追(距离场)
硬件光追
反射命中点光照怎么取
2.44
读 Surface Cache
11.54
Hit Lighting
Matrix Awakens 的反射追踪。Hit Lighting 在命中点重新算光,质量最高,代价约 4.7 倍。
硬件光追不是一律更慢:Matrix Awakens 里它还略快一点。差距大小由场景几何决定,必须自己实测。
同场景的 Reflections(软件 / 硬件):
Land of Nanite 0.04 / 0.46 · Lyra 1.58 / 2.03 · Matrix 1.76 / 1.79
页签 6 / 8
落地:调参、Lite 与移动端
画面出问题时从哪里查、5.8 的 Lumen Lite 换掉了什么、手机上能不能用,最后纠正几个流传很广的误解。
09
调参与落地
画面出问题时,先查几何和缓存覆盖,最后才动参数。
前八章讲的都是它为什么这么做。这一章换个方向:出了问题怎么办。
答案其实前面已经暗示过了。Lumen 的大多数画面问题,根源是几何表示不出来(墙太薄、网格太碎),或者缓存没覆盖到(卡片不够、物体在视野外)。直接调参数往往只是把症状压住。所以这一章的顺序是:先认识旋钮和档位,再学会看调试视图,最后才是检查清单、排错表,以及一条可以点着走的排错路线。
9.1
性能旋钮:每个 Pass 一组参数
调性能之前,先确认瓶颈在哪个 Pass:用 GPU profiling 看三个 Pass 各占多少,再去调对应的那一组旋钮。每个 Pass 最主要的成本,各有一个对应的参数:
| Pass | 控制什么 | 参数 |
|---|---|---|
| Scene Lighting | 每帧更新多少 Surface Cache 光照 | Lumen Scene Lighting Update Speed |
| Screen Probe Gather | 探针间距:每 N×N 像素一个探针 | r.Lumen.ScreenProbeGather.DownsampleFactor |
| Screen Probe Gather | 每个探针的光线数:N×N 个方向 | r.Lumen.ScreenProbeGather.TracingOctahedronResolution |
| World Radiance Cache | 每帧更新的世界探针预算 | r.Lumen.ScreenProbeGather.RadianceCache.NumProbesToTraceBudget |
它们和前几章一一对应。DownsampleFactor 就是第 7 章均匀网格里的那个 N,调大一倍,探针数量变成四分之一。TracingOctahedronResolution 是八面体纹理的边长,8 就是 64 条光线。世界缓存的预算决定远处的光照多快跟上变化。Scene Lighting 的更新速度决定第 5 章那条反弹回路转得多快,也就是间接光滞后有多明显。
拿 4.4 节的账本验证一下:DownsampleFactor 从 16 改成 32,探针数从约 8160 降到约 2040,光线从 52 万降到 13 万,Screen Probe Gather 的追踪成本大致也降到四分之一。但画面上付出的是更大的插值跨度,细小几何之间的阴影会被抹掉。Scene Lighting Update Speed 从 1 改成 2,每帧刷新的纹素翻倍,5.10 节那条追赶曲线快一倍,Scene Lighting 的成本也跟着涨。
每一个旋钮都是在噪声、滞后和成本之间挪位置,不存在只赚不赔的设置。
更完整的一张表,包括每个模块的预算数字和对应的调试视图,在附录 A 的"预算与旋钮速查"里。
放进项目时这些变量平时不该由美术直接改。它们分别落在可扩展性档位、设备配置和控制台哪一层、谁的值最终生效,在 D.1 节;按区域调质量要用后处理体积,在 D.2 节。
Scene Lighting
每帧更新多少 Surface Cache 光照
Lumen Scene Lighting Update Speed
Screen Probe Gather
探针间距:每 N×N 像素一个探针
r.Lumen.ScreenProbeGather.DownsampleFactor
Screen Probe Gather
每个探针的光线数:N×N 个方向
r.Lumen.ScreenProbeGather.TracingOctahedronResolution
World Radiance Cache
每帧更新的探针预算
r.Lumen.ScreenProbeGather.RadianceCache.NumProbesToTraceBudget
9.2
四个档位,各自的设计目标
Lumen 有四个可扩展性档位。它们不是同一条质量曲线上的四个点,而是各自对着不同的设计目标。
| 档位 | 设计目标 | 要点 |
|---|---|---|
| Epic | 最高质量;次世代主机上 GI + 反射约 8 ms | 1080p 内部分辨率,TSR 输出接近原生 4K |
| High | 次世代主机大世界 60 fps | Epic 给出的主要目标,默认主力档 |
| Medium | 掌机与中端 PC 60 fps | 即 Lumen Lite:换成辐照度场;反射退回 SSR |
| Low | 不跑 Lumen | 关闭 Lumen 功能,改用距离场环境光遮蔽等 |
Epic 是最高质量,次世代主机上 GI 加反射大约 8 毫秒。注意这是 1080p 的内部分辨率,再靠 TSR 输出到接近原生 4K。High 对着 Epic 给出的主要目标:次世代主机上的大世界 60 帧,也是大多数项目的主力档。Medium 就是 UE 5.8 的 Lumen Lite,目标是掌机和中端 PC 的 60 帧。Low 直接关掉 Lumen,改用距离场环境光遮蔽这类替代方案。
另外还有一个次要目标:室内干净光照 30 帧。室内的多次反弹比室外贵得多,第 1 章的级数已经解释过原因。
Medium 换掉的是整个最终收集算法,不是把探针数量调小。
所以它的画面特征和 High 不是"同样的东西糙一点",在 High 上积累的调参经验也不能直接套过去。第 10 章专门讲这一档。最后,这些都是 Epic 给的设计目标和参考场景下的测量,自己项目的实际帧时间只能自己测。
放进项目时档位只是控制面的一层,设备配置和控制台都能盖过它。"切了档位没反应"多半是更高优先级的一层已经设了同一个变量,D.1 节有一个可以动手试的例子。
目标与数字来自 Epic Lumen 文档与 UE 5.8 相关资料 · 以所用引擎版本为准
还有一个次要目标
室内干净光照 30 fps。室内的多次反弹比室外贵,所以放在 30 fps 这一档来谈。
档位不只是调数值
Medium 换掉的是整个最终收集算法,不是把探针数量调小。所以它的画面特征和 High 不是同一条曲线上的点。
这些是目标,不是保证
Epic 给的是设计目标和参考场景下的测量。自己项目的实际帧时间只能自己测。
9.3
可视化模式:先看缓存,再调参数
画面出问题时,先别动参数。Lumen 提供了一组可视化模式,入口在视口的 View Modes › Lumen。它们让你看到 Lumen 眼里的场景,大多数问题在这里就能定位。推荐的查看顺序是:
- Surface Cache:确认关键物体有没有被卡片覆盖。没覆盖的区域标成粉色,那里会缺失间接光,在反射里是黑的。
- Lumen Scene:看 Lumen 眼中的场景和真实场景差多少。它显示的是 Surface Cache 或硬件光追里质量最高的那一份。
- Geometry Normals:检查全局距离场或 Nanite Fallback 网格有没有问题,比如墙被吞掉了、表面鼓了起来。
- Reflection View:反射光线实际看到的场景。它的追踪距离有限,远处看不到的东西,反射里也不会有。
Overview 模式会把前三种并排叠在场景上,适合快速扫一眼。
距离场本身也可以单独看:Show › Visualize 下的 Mesh Distance Fields 和 Global Distance Field。另有两个小技巧:控制台输入 r.Lumen.Visualize.CardPlacement 1 可以显示卡片的摆放;关闭 Show › Advanced › Nanite Meshes,就能看到 Fallback 网格的真实样子。
做完这一轮,才轮到调性能或质量参数。
Epic Lumen 文档
Overview
三种可视化并排叠在场景上
Lumen Scene
Lumen 眼中的场景:Surface Cache 或硬件光追里质量最高的那个
Surface Cache
同上,但没被覆盖的区域标成粉色
Geometry Normals
检查全局距离场或 Nanite Fallback 网格有没有问题
Reflection View
反射光线实际看到的场景,追踪距离有限
距离场可视化
Show > Visualize:Mesh Distance Fields、Global Distance Field
控制台:r.Lumen.Visualize.CardPlacement 1 显示 Card 摆放。关闭 Show > Advanced > Nanite Meshes,可以查看 Fallback 网格。
9.4
缓存带来的内容约束
Surface Cache 和距离场是 Lumen 的核心优化,但它们也给内容制作带来了约束。归结起来是一句话:
Lumen "看不见"的表面,它就照不到,也反射不出来。
三条最常见的约束:
看粉色。Surface Cache 视图里的粉色表示没有卡片覆盖,那里会缺失间接光。美术和技术美术最好养成习惯,在关卡里定期打开这个视图扫一遍。
拆网格。每个网格默认最多 12 张卡片。形状复杂的网格,比如整栋建筑、带很多凹槽的构件,12 张不够覆盖。可以调高 Max Lumen Mesh Cards,更好的办法是拆成几个网格。
慎用骨骼网格。骨骼网格只在蒙皮前的包围盒四周放 6 张卡片,大幅变形时覆盖有限。角色的 GI 质量差,多半是这个原因。
放进项目时资产、实例、材质和灯光上的完整开关表,以及三条违反了也不会报错的资产规则,在 D.3 节。
看粉色
Surface Cache 视图里的粉色表示没有覆盖,该处会缺失间接光
拆网格
每个网格默认最多 12 张卡片;复杂网格可调高 Max Lumen Mesh Cards,或拆开
慎用骨骼网格
运行时在预蒙皮包围盒四周放 6 张卡片,大幅变形时覆盖有限
场景里“看不见”的表面,Lumen 同样看不见。
9.5
做场景前的检查清单
把前面几章的原理,翻译成美术和技术美术可以直接对照的清单。前四条都是在"让缓存和距离场看得见":
- 室内分件建模。墙、地、天花板各自独立;整间房做成一个网格不行。
- 墙厚至少 10 cm。几何体要闭合;极薄的面和单面网格会漏光。
- 大件拆成模块。山体、多层建筑整体做距离场,质量差,还会自遮挡。
- 高面数用 Nanite。植被和实例化网格要用 Nanite 才被支持;同时做好 LOD。
后四条是具体的设置:
- 软件光追的网格避免 WPO。World Position Offset 在软件光追下不被支持。
- 按导入尺寸设距离场分辨率。小尺寸导入、再在场景里放大的网格,要在静态网格编辑器里手动调高分辨率。
- 视角相关的材质给备用版。用到 Pixel Depth、Camera Position 这类节点的材质,用 Ray Tracing Quality Switch 给 Lumen 单独一个版本。
- 植被开启距离场光照。在 Foliage 工具里勾选 Affect Distance Field Lighting。
出问题时,配合前面的调试视图和本章最后的排错表一起用。
Epic Lumen Technical Details
室内分件建模
墙、地、天花板各自独立;整间房做成一个网格不行
墙厚至少 10 cm
几何体要闭合;极薄的面和单面网格会漏光
大件拆成模块
山体、多层建筑整体做距离场,质量差,还会自遮挡
高面数用 Nanite
植被和实例化网格要 Nanite 才被支持;同时做好 LOD
软件光追网格避免 WPO
World Position Offset 在软件光追下不被支持
按导入尺寸设距离场分辨率
小尺寸导入再放大的网格,在静态网格编辑器里设分辨率
视角相关材质给备用版
用 Ray Tracing Quality Switch 节点,给 Lumen 单独的版本
植被要开启距离场光照
Foliage 工具里勾选 Affect Distance Field Lighting
9.6
Lumen 的限制与平台要求
选型之前,先看这张表。
| 项目 | 限制 |
|---|---|
| 静态光照 | Lumen GI 不能与光照贴图并用;反射可以 |
| 渲染路径 | 不兼容 Forward Shading |
| 软件光追 | 需要 DX12 与 SM6,最低约 GTX 1070 |
| 硬件光追 | RTX 2000 / RX 6000 起;PS5、Xbox Series、Switch 2 |
| 不支持 | PS4、Xbox One、VR |
| 工作范围 | 默认 200 m,可调到 800 m;之外只剩屏幕追踪 |
几条值得展开。静态光照和 Lumen GI 互斥,但 Lumen 的反射可以和光照贴图的 GI 一起用。软件光追要 DX12 加 SM6,文档给出的最低显卡约为 GTX 1070。上一代主机和 VR 目前不支持。
工作范围上,Lumen Scene 默认覆盖相机周围 200 米,可以调到 800 米,更远的地方只剩屏幕追踪参与 GI;开启 Far Field 的硬件光追可以到 1 公里。另外,镜头移动太快时,Lumen Scene 的更新会跟不上,间接光会出现弹出。
Epic Lumen Technical Details(以所用引擎版本的文档为准)
镜头移动太快时,Lumen Scene 更新会滞后,间接光会弹出。
9.7
症状、原因、处理:速查表
最后是一张速查表,把前面的原理落到具体的症状上。
| 症状 | 可能原因 | 处理 |
|---|---|---|
| 室内漏光 | 墙太薄,或距离场分辨率不足 | 墙至少 10 cm 厚;提高 Distance Field Resolution Scale |
| 反射里物体发黑 | 没有 Surface Cache 覆盖(粉色) | 拆分复杂网格;提高 Max Lumen Mesh Cards |
| 角色的 GI 质量差 | 骨骼网格只有 6 张 Card | 改用硬件光追 |
| 室内镜面反射斑驳 | 多次反弹的 GI 质量低 | 提高 Lumen Scene Quality |
| 材质看起来不对 | 用了视角相关节点(Pixel Depth、Camera Position 等) | 用 Ray Tracing Quality Switch 提供兼容版本 |
表里的每一行都能在前面找到原因。漏光来自第 6 章:墙比距离场的体素还薄。反射里发黑来自第 5 章:没有卡片覆盖。角色的 GI 差,是骨骼网格只有 6 张卡片。室内镜面斑驳,是第 5 章那条反弹回路的质量不够。材质看起来不对,是视角相关的节点让缓存里的结果和真实画面对不上。
下一节把这张表倒过来,按"手上只有一个症状"的顺序重新走一遍。
排错的顺序始终是:先看 Surface Cache 视图,再看距离场,最后才调参数。
Epic Lumen Technical Details
排错顺序:先看 Surface Cache 视图,再看距离场,最后才调参数。
9.8交互图
排错路线:从症状倒推
上一节的表是按"问题 → 原因"排的,适合查。真正排错的时候,手里只有一个症状,需要的是另一种顺序:先看哪个视图,看到什么说明是哪个原因,再按什么顺序处理。
下面把最常见的七个症状做成了路线。每条路线都遵守同一个原则:先确认几何和缓存,最后才动参数。原因后面的编号可以点,会跳回讲原理的那一节。
把七条路线放在一起看,会发现它们只落在四个地方:距离场表示不出来(漏光),卡片没覆盖(黑块、角色),预算不够(滞后、斑驳、发糊),超出范围(远处消失)。前两类只能改内容,后两类才是参数能解决的。
参数能买到的是速度和清晰度,买不到几何。墙太薄、网格太碎的问题,调多少参数都只是在压症状。
症状与处理依据 Epic Lumen Technical Details 与性能指南;排查顺序是本文的建议
10
UE 5.8 Lumen Lite 与移动端
Lite 不是 Lumen 的低配档,而是换掉了收集和反射的另一套方案,目前仍标为 Beta。手机上的 Lumen 则是另一条路:把桌面渲染器原样搬上去。
UE 5.8 在可扩展性的 Medium 档放进了一套新东西:Lumen Lite。很多人以为它就是画质调低的 Lumen,其实不是。它把收集方式整个换成了第 3 章讲过的那条路线:带遮挡的辐照度探针场。
有了前面的铺垫,这一章可以讲得很直接:它换了哪几块,探针卡进墙里怎么办,为什么一动起来就出现拖尾和黑块,五个档位各在什么位置,评估它该按什么顺序测,以及 5.8 里 Lumen 的其他变化。
后半章讲移动端。很多人以为 Lite 就是"手机版 Lumen",其实手机上的 Lumen 是 5.4 起的一项实验性功能,走的是完全不同的路线。最后把手机、掌机、XR 放在一张表里,看各自该走哪条路。
10.1
Lumen Lite:Medium 档
Lumen Lite 是 UE 5.8 新增的,对应可扩展性的 Medium 档。它的核心叫 Irradiance Field Gather:在像素周围放世界空间的探针,预先算好辐照度,再结合探针遮挡插值到像素。名字很陌生,但读过第 3 章就会发现,这正是 DDGI 那条探针路线。
速度上,High 档以 PS5 上 60 帧为目标,Lite 的速度大约是它的两倍。画质上的代价主要在反射:Medium 档的反射退回了屏幕空间反射(SSR),粗糙表面的高光由 Lumen 的 GI 提供。
当代掌机默认使用这一档,PC 也支持;Low 档则完全关闭 Lumen 功能。
思路依然是第 4 章那句话:能缓存就不重算。只不过这一次缓存的粒度更粗,换来的速度更多。下一节先列出它到底换了哪几块,再细看核心那一块,最后讲代价。
数据来自 Epic 与媒体报道,实际效果以项目实测为准
UE 5.8 新增
- 核心是 Irradiance Field Gather:在像素周围放世界空间辐射度缓存探针
- 探针预先算好辐照度,再结合探针遮挡插值到像素
- High Quality 以 PS5 60fps 为目标,Lite 约快两倍
- Medium 档位下反射退回 SSR,粗糙高光由 Lumen GI 提供
- 当代掌机默认使用,PC 也支持;Low 档位关闭 Lumen 功能
2×
约为 High Quality 的速度
10.2Lumen Lite 深挖
Lite 到底换掉了哪四块
很多人把 Lite 理解成一个开关。其实它是四处同时改,画面会在好几个地方一起变化。下面是 High 和 Medium 两档的对照:
| 部件 | High | Medium(Lite) |
|---|---|---|
| 场景表示 | Lumen Scene:卡片 + 距离场 | 同一套,但更新更省 |
| 漫反射收集 | Screen Probe Gather,逐屏幕探针追踪 | Irradiance Field Gather,辐照度场 + 探针遮挡 |
| 反射 | Lumen Reflections,专用反射光线 | SSR,退回屏幕空间反射(非 Lumen) |
| 半透明与雾 | 半透明体积的采集域 | 同样走加速路径 |
底层的开关是 r.Lumen.FinalGatherMethod:Medium GI 把它设为 0,最终收集就从屏幕探针换成了辐照度场。不过日常不要直接改它,用档位变量。开启方式有一个坑:要同时设置 sg.globalilluminationquality 1 和 sg.reflectionquality 1。只设 GI 那一个,并不构成完整的 Lite:反射还停在上一档,测出来的结果不可比。
5.8 里它已经可用,但 Epic 仍然标为 Beta。这一版还新增了一个蓝图函数 IsGlobalIlluminationAllowed,方便游戏按 GI 是否开启来切换后处理体积的设置。
最关键的一点来自 Epic 的 Daniel Wright:Lite 基于带探针遮挡的辐照度场,但探针放置是自动的。这是它和一般需要手摆探针的方案的根本区别,也是它能直接作为一个档位切换的前提。第 3 章算 DDGI 那笔账时已经看到,固定网格在开放世界里要么浪费、要么不够,自动放置正是冲着这个问题去的。
来源:Epic 工程师 Daniel Wright 的公开说明、Tom Looman 的 5.8 性能总结;以所用引擎版本的文档为准
High
Lumen Scene
卡片 + 距离场
Screen Probe Gather
逐屏幕探针追踪
Lumen Reflections
专用反射光线
Medium
Lumen Scene(加速)
同一套,但更新更省
Irradiance Field Gather
换成辐照度场 + 探针遮挡
SSR(非 Lumen)
退回屏幕空间反射
第四块
半透明与雾的 GI 也走了加速路径。所以 Lite 不是一个开关,而是四处同时改。
怎么开:两个都要设
sg.globalilluminationquality 1
sg.reflectionquality 1
只设 GI 那一个,不构成完整的 Lite。
还是 Beta
5.8 已经可用,但 Epic 仍标为 Beta。新增蓝图函数 IsGlobalIlluminationAllowed,方便按 GI 是否开启来切后处理设置。
核心是自动放置探针
Epic 的 Daniel Wright 说:基于带探针遮挡的辐照度场,但探针放置是自动的。这是它和一般手摆探针方案的根本区别。
10.3
辐照度场与探针遮挡
四块改动里最核心的是漫反射收集。High 和 Epic 档用的是第 7 章的 Screen Probe Gather:为屏幕上的像素放探针,每帧逐个追踪。Lite 换成了 Irradiance Field Gather,辐照度场收集:在世界空间放一张探针格点,预先算好每个探针的辐照度,再插值给像素。
辐照度就是第 1 章的那个 E:一点朝各个方向看出去,入射光的余弦加权平均,正是漫反射需要的量。每个像素只做查表加插值,不再逐像素追踪,所以便宜得多。
这类方法的典型问题也和第 3 章一样:像素和探针之间如果隔着墙,插值就会把墙另一边的光带进来。所以它同样需要探针遮挡,把被挡住的探针的权重压低或归零。
从成本结构上看,这次替换改变的是"钱跟着什么走"。4.4 节算过,Screen Probe Gather 的光线数跟着屏幕走:分辨率越高、探针越密,光线越多,而且每帧都要重新打。辐照度场的光线数跟着世界里的探针走:探针放多少、每帧更新几分之几,和屏幕分辨率基本脱钩。像素那一侧只剩查表和插值。
代价也由此而来。屏幕探针每帧重建,画面一动就跟着动;世界探针要分好几帧才轮一遍,所以场景一动,它就慢半拍。10.5 节会把这一点演示出来。
5.8 的性能指南给出了官方的一句话描述:Medium GI 用 r.Lumen.FinalGatherMethod 0 切到 Irradiance Final Gather,它在像素周围放置世界空间辐射度缓存(World Space Radiance Cache)的探针,预先算好辐照度,再结合探针遮挡插值到像素。
这句话里最值得注意的是"世界空间辐射度缓存"。它不是一套新造的探针,而是第 7 章 7.10、7.11 节讲过的那层缓存:High 档里它给屏幕探针兜远处的光,Lite 里它直接被拿来当主角。所以 High 和 Lite 共享的部件比表面上看起来还多。
用 DDGI 这类方法的写法,像素那一侧的工作大致是这样:
float3 sum = 0; float wsum = 0;
for (probe in ProbesAround(pixel.pos)) { // 周围的世界空间探针
float3 toProbe = normalize(probe.pos - pixel.pos);
float w = TrilinearWeight(pixel.pos, probe);
w *= (dot(toProbe, pixel.normal) + 1) * 0.5; // 背对的探针少算一点
// 探针遮挡:探针记得每个方向上"平均多远会撞到东西"
float2 m = probe.DepthMoments(-toProbe); // 距离的均值、平方均值
float d = distance(pixel.pos, probe.pos);
if (d > m.x) w *= Chebyshev(d, m); // 像素比墙还远 → 多半被挡住了
if (probe.isStuckInGeometry) w = 0; // 挪不出来的探针不用(下一节)
sum += w * probe.Irradiance(pixel.normal);
wsum += w;
}
DiffuseIndirect[pixel] = sum / max(wsum, 1e-4); // 只有查表和插值,不追光线上面的伪代码和图都是这类方法的通用写法。Epic 公开的只有"放在像素周围、带探针遮挡、自动放置、卡在几何里会被挪出"这几层描述,具体的探针间距、权重函数和更新策略没有公开。
图为这类方法的通用思路;Lumen Lite 的具体实现细节以 Epic 文档为准
算法科普
图为这类方法的通用思路;Lumen Lite 的具体实现细节以 Epic 文档为准。
10.4交互图
探针卡进墙里怎么办
上一节说 Lite 的探针是自动放置的。自动放置有一个绕不开的问题:探针按网格铺下去,总有一些会落进柱子、墙体、地面里面。
落在几何体里的探针,朝哪个方向看都只能看到背面,读出来是黑的。更麻烦的是,它的深度信息也是乱的,第 3 章讲的探针遮挡靠的就是深度信息,所以遮挡测试也拦不住它。结果就是附近的像素把一个黑探针平均了进去。
Epic 的性能指南给了 Lite 的处理办法和调试视图:卡在几何体里的探针会被挪出来,挪出来的标成绿色;挪不出来的标成红色,不参与照明。下面这张图把这两件事,以及探针遮挡,拆开给你看。
拖一拖就能分清两类问题:黑块来自留在几何体里的探针,靠挪动解决;漏光来自薄墙两侧的探针被混在一起,靠遮挡解决。两样都开了,剩下的误差主要来自探针间距,比如柱子的阴影边缘被抹宽了。
看 Lite 的调试视图时,红点多的地方要特别留意:那里能用的探针少,插值只能借更远的探针,画面最容易出问题。
图里的挪动规则(在半个间距内找最近的空位)是为了演示写的简化版,Lumen Lite 实际的挪动距离和判定方式没有公开。
探针挪动与绿/红调试配色依据 Epic Lumen 性能指南(5.8);图为二维示意,探针间距和权重不代表真实参数
10.5Lumen Lite 深挖
代价在动态场景里显形
这一节讲 Lite 的代价,同时要先说清证据的等级:症状来自社区报告,成因是本文的推断。
Epic 开发者论坛上有一个 5.8 的提问帖,报告了三条症状:相机急转 180° 之后,中距离的树上出现黑环或黑斑;快速旋转时,间接光明显滞后好几帧;移动的车在地面上拖出一串不规则的黑块。
为什么静态截图里看不出这些问题?因为场景不动时,探针有足够多的帧去慢慢收敛,最后的结果和 High 差不多。问题只在"来不及收敛"的时候出现。
复现条件也印证了这一点:只在 Medium 档出现,High 和 Epic 不受影响。而且编辑器视口里比较轻微,PIE 和打包版本里明显得多。只在编辑器里看,很可能会漏掉。
为什么是这种形态?读过前面几章,答案几乎是现成的。辐照度场靠探针跨帧慢更新来省成本,这正是第 3 章说的探针路线的第二项固有代价;探针遮挡信息也跟着探针走,DDGI 那个滞后系数 α 越接近 1,更新越慢。场景一动,遮挡信息就落后于几何,"这里被挡住了"的判断还留在原地,于是地面上出现本不该有的暗块,而且拖在物体后面。第 7 章那张时间累积的交互图,演示的就是这件事。
下面这个示意把两种收集方式放在一起跑。上面一条是 High:屏幕探针每帧重新追踪,红箱子的阴影和溢色跟得上。下面一条是 Lite 这类方法:探针放在世界空间,每帧只轮到一部分。
拖动滑块能把两种误差分开看:拖尾来自更新预算,预算越低,探针轮一遍要的帧数越多;发糊来自探针间距,就算每帧全部更新也消不掉。社区报告的那三条症状,都属于前一种。
帖子里提到的尝试方向有 r.LumenScene.Radiosity.UpdateFactor、r.Lumen.ScreenProbeGather.Temporal.MaxFramesAccumulated 和 r.Lumen.ScreenProbeGather.RadianceCache.NumFramesToKeepCachedProbes。但回帖者并不是 Epic 员工,Epic 也没有确认这是 bug,所以它们只能算尝试方向,不是解决方案。
后来有两条新的社区信息。一条是 8 月那个帖子下的回复,说 Medium 档会强制只用软件光追,在快速转镜头时容易出这类问题,并建议把 r.LumenScene.Radiosity.UpdateFactor 调低以加快反弹更新、把 r.Lumen.ScreenProbeGather.RadianceCache.NumFramesToKeepCachedProbes 调小以更快丢掉过期的探针;它还提到辐射度缓存的探针追踪预算 NumProbesToTraceBudget 默认是 150。另一条是 5 月的提问帖:一个有很多大型移动巴士的低配 PC 游戏,探针更新太多、GPU 开销太大;可一旦压低更新预算,地面和巴士上就出现黑块,因为探针来不及更新。
后一条几乎就是上面那张交互图的现实版:Lite 的成本和它在动态场景里的瑕疵,是同一个旋钮的两头。这两条信息的作者都不是 Epic 员工,同样只能当作尝试方向。
不管成因如何,它都指明了评估 Lite 时必须重点测什么:动态场景。
症状与 cvar 来自 Epic 开发者论坛的社区帖;成因解释是本文的推断,不是官方结论
移动中的物体
拖在后面的假阴影
遮挡信息还是旧的探针
社区报告的三条症状
急转 180° 后,中距离的树上出现黑环或黑斑;快速旋转时间接光滞后数帧;移动的车在地面拖出不规则黑块。
复现条件
只在 Medium 档出现,High 与 Epic 不受影响。编辑器视口里较轻,PIE 和打包版本里明显得多。
为什么是这种形态(推断)
辐照度场靠探针跨帧慢更新,探针遮挡也是跟着探针走的。场景一动,遮挡信息就落后于几何,被挡住的判断留在原地,于是地面上出现本不该有的暗块。
社区给出的尝试方向(未经验证):r.LumenScene.Radiosity.UpdateFactor · r.Lumen.ScreenProbeGather.Temporal.MaxFramesAccumulated · r.Lumen.ScreenProbeGather.RadianceCache.NumFramesToKeepCachedProbes
注意:这来自开发者论坛的一个提问帖,回帖者并非 Epic 员工,Epic 也没有确认它是 bug。
10.6速查
五个档位,各是什么
Lite 只是可扩展性里的一档。把五个档位放在一起看,才知道它的位置,也知道降到它下面一档会失去什么。
| 档位 | 瞄准的预算 | GI | 反射 |
|---|---|---|---|
| Cinematic | Movie Render Queue 离线出片 | Lumen,最高质量 | Lumen,最高质量 |
| Epic | 主机 30 帧 | Screen Probe Gather | Lumen Reflections |
| High | 当代主机 60 帧(Switch 2 除外) | Screen Probe Gather | Lumen Reflections |
| Medium(Lite) | Switch 2 与中配 PC 60 帧 | Irradiance Final Gatherr.Lumen.FinalGatherMethod 0 | 光滑表面用 SSR,粗糙高光来自 Lumen GI |
| Low | 关闭 Lumen | 只有无阴影的天光,r.SkylightIntensityMultiplier=0.7 | 无 Lumen 反射 |
表里有两点值得多看一眼。
第一,从 Medium 到 Low 是一个断崖。Medium 仍然是动态 GI,只是更粗、更慢;Low 直接没有间接光,只剩一层均匀的天光,室内会一下子变平。所以对掌机和中配 PC 来说,Lite 存在的意义就是把"有 GI"的门槛往下挪了一档。
第二,在不支持辐照度场的平台上,文档给出的退路是:小尺度的遮蔽用后处理体积里的 SSAO,大尺度的遮蔽用距离场环境光遮蔽(DFAO)顶替 Lumen GI。画面会有"暗角",但没有反弹的颜色。
注意 Lite 只改 GI 和反射这两个 sg 变量。阴影、后处理、贴图这些档位是分开的,评估时要固定住它们,否则测出的差别不全是 Lumen 的。
Epic Lumen 性能指南(5.8);以所用引擎版本的文档为准
Epic · High
Screen Probe Gather + Lumen Reflections。主机 30 / 60 帧。
Medium · Lite
Irradiance Final Gather(FinalGatherMethod 0)+ SSR。Switch 2、中配 PC 60 帧。
Low
关闭 Lumen:只剩无阴影天光(×0.7)。
Medium → Low 是断崖
Lite 的意义,是把"有动态 GI"的门槛往下挪一档。不支持辐照度场的平台退回 SSAO + DFAO。
10.7Lumen Lite 深挖
要评估 Lite,按这个顺序测
把前面的原理和已知的问题,落成一份可以执行的评估顺序。这一节整体是判断建议,不是官方结论。
- 先确认档位真的生效。两个 sg 变量都要设。只设 GI 那一个,反射还停在上一档,测出来的数不可比。
- 在 PIE 或打包版本里测。编辑器视口会掩盖问题,已有报告说同样的场景在打包版本里明显得多。
- 把动态场景排在最前。快速转镜头、移动载具、开关灯、可破坏物。Lite 的主要代价出现在运动中,静态截图看不出来,这和评估画质的习惯正好相反。
- 反射单独立一项。Lite 下反射是 SSR,屏幕外的内容反射不出来。玻璃、水面、光滑地面是重灾区。
- 半透明与雾也单独看。它们走第三个采集域,同样被换成了加速路径,雾重的场景要专门过一遍。
- 别拿 High 的调参经验去套。换掉的是整套最终收集算法,不是同一条质量曲线上的低档,"探针多一点就好"那类直觉不适用。
如果 Lite 的画面不达标,还有一条备选路线:用 High 档配合降低探针预算,可能比 Lite 更可控。这是社区的建议,没有经过验证。
基于前面的原理和已知报告给出的判断建议,请按自己项目实测后再定
01
先确认档位真的生效
两个 sg 变量都要设。只设 GI 那一个,反射还停在上一档,测出来的数不可比。
02
在 PIE 或打包里测
编辑器视口会掩盖问题。已有报告说同样的场景在打包版本里明显得多。
03
把动态场景排在最前
快速转镜头、移动载具、开关灯、可破坏物。静态截图看不出 Lite 的主要代价。
04
反射单独立一项
Lite 下反射是 SSR,屏幕外的内容反射不出来。玻璃、水面、光滑地面是重灾区。
05
半透明与雾也要单独看
它们走的是第三个采集域,同样被换成了加速路径,雾重的场景要专门过一遍。
06
别拿 High 的调参经验套
换掉的是整套最终收集算法,不是同一条质量曲线上的低档。探针数量那类直觉不适用。
备选路线:如果 Lite 的画面不达标,可以试 High 档配合降低探针预算,可能比 Lite 更可控。
备选路线来自社区建议,未经验证;本页整体是判断建议,不是官方结论。
10.8
除了 Lite,5.8 还动了这些
Lite 之外,5.8 里 Lumen 还有几处变化。升级时,这些会直接影响你的性能基线。
| 变化 | 影响与备注 |
|---|---|
| 整体:disocclusion、降噪、运动中质量都更好 | 同时内存下降、shader 变体减少。运动中的画面更稳是这一版的主线。 |
| 移除 ScreenProbeGather.IntegrateDownsampleFactor 2(High 档) | 原因是噪声太大、法线被软化。这个取舍现在要按项目自己调。 |
| Epic / Cinematic 档下,小物体剔除不再那么激进 | 减少自发光物体的光照弹出,以及反射里的黑斑。 |
| LumenScene.SurfaceCache.CardMinResolution 由 4 降到 2 | 小卡片可以用更低的分辨率,省图集空间。 |
| 移除 Surface Cache 的不透明度层 | 约省 16 MB 和大量 shader 变体。前面图集那页把这一层列为 8 MB,那是 2022 年演讲的口径,两者统计范围不同,以版本发布说明为准。 |
| 新增 ScreenTraces.HZBTraversal.SkipUnlitHits | 跳过 unlit 着色模型的命中,规避极小自发光物体造成的 GI 噪声。 |
| r.Lumen.HeightFog 默认开启 | 高度雾作用于反射命中点,减少与屏幕追踪的不一致;HeightFogOnGI 仍为 0 以省性能。 |
| 距离场可在集成显卡上运行 | 使软件光追在集显设备上成为可能,这是 Lite 之外另一条拓宽设备面的路。 |
这一版的主题很清楚:不是加新效果,而是降成本、拓宽设备面、把运动中的画面做稳。disocclusion、降噪和运动中的质量都有改善,正好对着第 7 章时间累积那几节的问题。Lite 和"距离场可以在集成显卡上运行"是同一个方向上的两步,后者让软件光追在集显设备上也成为可能。
有两件事需要动手。如果项目里设过 IntegrateDownsampleFactor,升级后要重新评估:High 档下的取值 2 被移除了,原因是噪声太大、法线被软化。表面缓存的内存占用也变了:最小卡片分辨率从 4 降到 2,不透明度层被整个移除,所以内存基线要重测。
第 5 章图集那一节把不透明度层列为 8 MB,那是 2022 年演讲的口径;这里的约 16 MB 来自 5.8 的版本资料,两者统计范围不同,以版本说明为准。
Tom Looman 的 UE 5.8 性能总结与 Epic 发布说明
这一版的主题
不是加新效果,而是降成本、拓设备面、把运动中的画面做稳。Lite 和集显支持是同一个方向上的两步。
升级前要做的事
如果项目里设过 IntegrateDownsampleFactor,升级后要重新评估;表面缓存的内存占用也会变,基线要重测。
10.9配置
移动端 Lumen:把桌面渲染器搬上手机
常有人问:Lite 是不是就是"手机版 Lumen"?不是。Lite 的目标平台是 Switch 2 这类掌机和中配 PC。手机上的 Lumen 是另一件事,而且路线完全不同。
移动端的 Lumen 从 UE 5.4 开始提供,至今仍是实验性功能。它没有为手机另写一套算法,做法是:让高端安卓手机直接跑桌面渲染器,再在上面开 Lumen。所以手机上跑的,就是附录 A 代码地图里那棵桌面的调用树,一个函数都没换。
这决定了它的门槛:
- 只支持安卓,需要 Vulkan 和 Shader Model 5。文档列出的 GPU 是三星 Xclipse 9xx、高通 Adreno 7xx、ARM Mali G7xx 这几代。
- iOS、iPadOS、tvOS 上不能用。苹果设备虽然有 Metal 版的桌面渲染器(Beta),但文档明确说 Lumen 目前在上面不工作。
- 移动端自己的渲染器里没有 Lumen。Mobile Forward 和 Mobile Deferred 这两条移动渲染路径不支持它,必须切到桌面渲染器。
- 软件光追需要距离场(
r.DistanceFields);硬件光追要把r.RayTracing.RequireSM6关掉,因为手机走的是 SM5。
打开它分三步:在 Project Settings › Platforms › Android › Build 里勾选 Support Vulkan Desktop [Experimental];把 r.Android.DisableVulkanSupport 和 r.Android.DisableVulkanSM5Support 都设为 0;重启编辑器之后,像桌面一样开 Lumen。
哪些手机会走这条路,由设备配置(Device Profile)决定。引擎自带了 Android_Vulkan_SM5 和三个按 GPU 细分的配置,要把别的机型归进来,就在 DeviceProfiles.ini 里加一条匹配规则:
; 让 Adreno 7xx、安卓 10 以上的机型使用 SM5 配置 +MatchProfile=(Profile="Android_Adreno_Vulkan_SM5",Match=( (SourceType=SRC_GpuFamily, CompareType=CMP_Regex, MatchString="Adreno \(TM\) 7[0-9][0-9]"), (SourceType=SRC_AndroidVersion, CompareType=CMP_GreaterEqual, MatchString="10") ; ……引擎自带的规则里还会检查 SM5 是否可用 )) [Android_Vulkan_SM5 DeviceProfile] DeviceType=Android BaseProfileName=Android +CVars=sg.GlobalIlluminationQuality=2 ; GI = High +CVars=sg.ReflectionQuality=2 ; 反射 = High +CVars=sg.ShadowQuality=2
注意最后两行:SM5 配置默认把 GI 和反射都设成 2,也就是 High,走的是 Screen Probe Gather 加 Lumen Reflections 这条完整路径,而不是 Lite。
成本方面,文档没有给数字,只给了两句定性的话:桌面渲染器在手机上本身就比移动渲染路径贵得多,Lumen 还要再加一层;Epic 目前不建议用它发布产品。
Epic 文档《Using Lumen Global Illumination on Mobile》《Using the Desktop Renderer on Mobile》(5.8)
是什么
5.4 起的实验性功能:高端安卓切到桌面渲染器(Vulkan SM5),原样跑桌面 Lumen。
门槛
Xclipse 9xx / Adreno 7xx / Mali G7xx。iOS 不支持;移动渲染器里没有 Lumen。
打开
勾选 Support Vulkan Desktop;r.Android.DisableVulkanSupport=0、DisableVulkanSM5Support=0。
默认走 High,不是 Lite
SM5 设备配置把 GI 和反射设为 2。桌面渲染器本身就贵,Epic 不建议用它发布。
10.10判断
手机、掌机、XR:该走哪条路
把上面几节放在一起,不同设备上的 GI 选择其实很清楚:
| 设备 | 官方路径 | 状态 |
|---|---|---|
| 当代主机、中高配 PC | Lumen High / Epic | 正式 |
| Switch 2、中配 PC、低配 PC | Lumen Lite(Medium) | Beta,5.8 起 |
| 高端安卓手机 | 桌面渲染器 + Lumen(默认 High) | 实验性,5.4 起,不建议发布 |
| iPhone、iPad | 不支持 Lumen | — |
| PC VR | 延迟渲染 + DX12 下可用 | 实验性 |
| 一体机 VR 头显 | 不支持 | — |
对要发布的手机项目,现实的选择仍然是移动渲染器加上烘焙光照、光照探针、反射捕获这一套"第 2 章"的方法。动态 GI 在手机上还处于试验阶段。
如果想在高端安卓机上试,有一个值得测的方向:手机走的是桌面那棵调用树,所以理论上可以在设备配置里把 GI 和反射设成 1,让它走 Lite。文档没有说这样是否受支持,这是本文的推断,需要在真机上验证;如果可行,第 10.5 节讲的那些动态场景里的问题,在手机的低帧率下只会更明显。
一句话:Lite 是给掌机和中配 PC 的;手机上的 Lumen 是"把桌面原样搬上去",今天只适合做试验。
平台状态依据 Epic 5.8 文档;"Lite 能否在手机 SM5 上运行"文档没有说明,下文标为待验证
主机 · 中高配 PC
Lumen High / Epic,正式。
Switch 2 · 中低配 PC
Lumen Lite,Beta。
高端安卓
桌面渲染器 + Lumen,实验性。
iOS · 一体机 VR
不支持。PC VR 实验性。
要发布的手机项目
仍然是移动渲染器 + 烘焙光照、光照探针、反射捕获。手机上让 GI 走 Lite 是否受支持,文档没说,需要真机验证。
11
收尾
纠正几个流传很广的误解,再把全篇压成几句话。
11.1
六个常见误解
收尾之前,纠正几句常听到的话。每一条都能对回前面的某一章。
"Lumen 就是光线追踪。"光追只是它可选的一个求交后端,默认走的是距离场。它真正的新东西是 Surface Cache。(第 3、4 章)
"开了 Lumen,画面就自动更准。"它放弃预计算换来了全动态,精度其实是降的。纯静态的场景里,烘焙依然更好。(第 2 章)
"用了 Lumen 就不用管美术规范了。"恰恰相反。墙要够厚、网格要分件、植被要开距离场,否则漏光和黑斑一定会出现。(第 6、9 章)
"画面有噪点,是引擎的 bug。"噪点是蒙特卡洛采样的本质。实时渲染只能靠复用和滤波把它压低,不可能消除。(第 1、7 章)
"Lumen Lite 就是把画质调低一档。"它换掉的是整套最终收集算法,画面特征和 High 不在同一条质量曲线上,调参经验不通用。(第 10 章)
"间接光反应慢,是机器性能不够。"这是设计使然。缓存和探针都靠跨帧累积,灯光一突变,就一定有一个追赶的过程。(第 5、7 章)
"Lumen 已经能上手机了。"手机上的 Lumen 是 5.4 起的实验性功能,靠把桌面渲染器原样搬上高端安卓机,iOS 不支持,Epic 不建议用来发布。Lite 的目标是掌机和中配 PC,不是手机。(第 10 章)
"开了异步计算,Lumen 就变便宜了。"异步只是把 Lumen 的工作藏进别的 Pass 的空隙里,工作量一点没少。空隙不够时照样拖长帧时间;做性能分析时还要先把它关掉。(附录 B)
这些误解有一个共同点:把 Lumen 当成"更准的渲染"。它其实是一套在固定预算里,尽量把光照摊匀的工程方案。
本节为全文要点的归纳
误解
Lumen 就是光线追踪。
实际
光追只是它可选的一个求交后端,默认走的是距离场。它真正的新东西是 Surface Cache。
误解
开了 Lumen,画面就自动更准。
实际
它放弃预计算换来全动态,精度其实是降的。纯静态场景里,烘焙依然更好。
误解
用了 Lumen 就不用管美术规范了。
实际
恰恰相反。墙要够厚、网格要分件、植被要开距离场,否则漏光和黑斑一定出现。
误解
画面有噪点,是引擎的 bug。
实际
噪点是蒙特卡洛采样的本质。实时只能靠复用和滤波压低,不可能消除。
误解
Lumen Lite 就是把画质调低一档。
实际
它换掉的是整套最终收集算法,画面特征和 High 不在同一条质量曲线上,调参经验不通用。
误解
间接光反应慢,是机器性能不够。
实际
是设计使然。缓存和探针都靠跨帧累积,灯光突变就一定有一个追赶过程。
共同点:这些误解都来自把 Lumen 当成"更准的渲染"。它其实是一套在固定预算里尽量把光照摊匀的工程方案。
11.2
能缓存就不重算
回到开场那个房间。地板弹亮了墙,墙弹亮了天花板,每个表面的亮度都依赖别的表面。第 1 章把它写成了一个自己引用自己的积分;第 2、3 章看了二十年里绕开它的五条路线;之后五章拆开了 Lumen 的回答。压成四句话:
- Surface Cache 把"命中点要算光"变成"读缓存"。这是 Lumen 唯一真正新的东西。
- Screen Probe 只在稀疏的探针上追踪,降噪也在探针空间里完成。
- World Radiance Cache 负责远场光照,被许多帧、许多像素共享。
- 反射按粗糙度分流,只有够光滑的表面才单独花光线。
5.8 的 Lumen Lite 保留了第一句,换掉了第二、四句:漫反射改用跨帧慢更新的辐照度场,反射退回 SSR。它更便宜,代价是运动中的滞后和屏幕外反射的缺失。
Lumen 不是单一的算法,而是一套围绕缓存组织起来的管线。理解了缓存在哪一层、每帧更新多少、哪里覆盖不到,就能解释大部分的画面问题和性能问题。
正文到这里结束。后面四个附录换成工程视角:附录 A 把部件拼回系统流图和代码地图;附录 B 讲这些 Pass 在 UE 的一帧里哪些必要、怎样调度;附录 C 带你按八个里程碑自己写一遍,并列出一路上的坑;附录 D 讲程序和美术怎样分层控制它、怎样按区域调。
能缓存,就不重算。
Epic Lumen Technical Details · Lumen Performance Guide · UE 5.8 发布说明 · SIGGRAPH 2022 Lumen 演讲
总结
01
Surface Cache 把“命中点要算光”变成“读缓存”
02
Screen Probe 只在稀疏探针上追踪,降噪也在探针空间完成
03
World Radiance Cache 负责远场光照,被多帧、多像素共享
04
反射按粗糙度分流,再用旋钮在质量与成本之间取舍
页签 7 / 8
工程:系统、代码与 Pass 调度
把前面拆开讲的部件拼回系统流图和代码地图,再往引擎里走一层:一帧里有哪些 Pass、哪些必须跑哪些可以关、它们怎样在图形队列和异步计算之间排队。
A
工程全景
把前面十一章拆开讲过的部件重新拼回一张图:每个模块读什么、写什么、花多少、在哪调、出问题看哪。Lumen 和 Lumen Lite 各一张。
前面十一章是按"为什么"组织的:先讲问题,再讲每个部件的原理。工程上真正干活时,需要的是另一种视角:一张能放在手边的图,告诉你某个画面问题落在哪个模块、它的上游是谁、预算是多少、该看哪个调试视图。
这个附录就是那张图。第一节是一条学习和排查的路线;第二、三节是可以点击的系统流图,Lumen 和 Lumen Lite 各一张,还能按一帧的顺序逐步播放;第四节是对照源码读的代码地图;第五节是预算与旋钮速查;最后是版本路线。再往引擎里走一层的 Pass 调度,在附录 B。
A.1路线图
学习与排查路线
Lumen 的工程部分可以拆成六条线,外加单独的一条 Lumen Lite。每条线都按同一个顺序走:先懂原理,再看数据,然后动手验证,最后记住常见的坑。格子里的编号可以点,直接跳到对应的小节。
卡片怎么生成、图集怎么换页
Surface Cache 视图找粉色;CardPlacement 1 看机位
复杂网格调高 Max Lumen Mesh Cards,或拆成几个,对比粉色变化
骨骼网格只有 6 张卡;整间房做成一个网格
一条光线的降级路径;距离场为什么只存一圈
Mesh / Global Distance Field 可视化;LogAtlasStats 1 看砖块池
同一面墙改厚度、改 Distance Field Resolution Scale,看漏光消失的阈值
墙薄于 10 cm;kitbash 大量重叠网格拖慢剔除
每帧只更新一部分页;反弹靠逐帧回路攒出来
Lumen Scene 视图;开关灯观察间接光追赶的帧数
调 Lumen Scene Lighting Update Speed / Final Gather Lighting Update Speed,记录滞后帧数
灯光突变后间接光滞后,是设计使然
探针放置、重要性采样、球谐、降噪、时间累积
GPU Profiler 看 Screen Probe Gather 各子 Pass 耗时
DownsampleFactor 调大一倍:探针变成 1/4,看噪点和轮廓糊的程度
墙角发黑 / 漏光是插值权重的两头;甩镜头先噪后收敛
按粗糙度分流;硬件光追的几处妥协
Reflection View;软硬两条路径各测一遍 Final Gather
在自己的场景里切换软/硬件光追、Hit Lighting 开关,记录毫秒数
Culling.Radius 和追踪距离对不上,远景缺一块
四个档位各对着什么目标;平台与范围限制
每个档位在目标平台上的帧时间基线
同一镜头在 High 和 Medium 下录制对比,含动态镜头
把 Medium 当成"High 的低一档"来调
换了哪四块;探针路线的固有代价
两个 sg 变量都设;只在 PIE / 打包版本里看
按评估清单的顺序测:动态场景、反射、半透明与雾
黑环、黑块拖尾、间接光滞后(社区报告)
想自己从零写一遍的,这六条线在附录 C 里各有对应的里程碑,顺序就是施工顺序。
六条线不必齐头并进。美术和技术美术最该先走通"场景表示"和"追踪"两条,因为大多数画面问题出在这里;程序先走"最终收集",它是性能的大头;做技术选型的人看最后两条。
A.2流图
Lumen 系统流图
这张图把前面讲过的所有部件放在一起。从左到右是数据的流向:输入生成持久的场景表示,Pass 1 往里写光,Pass 2、3 从里面读光,最后合成。
三种用法。点模块:看它读谁、写谁、预算多少、在哪调、出问题看哪个视图,并能跳到讲它的小节。按一帧的顺序播放:九步依次点亮,每一步只亮参与的模块和它们之间的连线。切到 Lumen Lite:看哪两块被换掉了。
最该记住的是橙色的线:写回 Surface Cache 的那两条。光照写进缓存,下一帧再被读出来,Lumen 的多次反弹和间接光滞后都来自这条回路。
模块划分与数字来自 Epic 文档与 SIGGRAPH 2022 演讲;每帧的执行顺序是按依赖关系整理的示意,不是引擎源码里的精确调度
A.3流图
Lumen Lite 系统流图
同一张图,切到 Medium 档。虚线框是被换掉的两块:Screen Probe Gather 换成了 Irradiance Field Gather,Lumen Reflections 换成了 SSR。卡片、图集、距离场这一套照旧保留,半透明走了更省的路径。
按一帧播放时注意第 4、5 步。探针跨帧慢慢更新,像素只做插值,这是它速度约为 High 两倍的原因;遮挡信息跟着探针走、落后于几何,这是黑块拖尾的来源。两件事出自同一个设计。
官方公开的描述是:探针来自世界空间辐射度缓存,放在像素周围,自动放置,卡在几何里的会被挪出,最终带探针遮挡插值到像素(5.8 性能指南)。图里 Lite 内部的更新顺序是按第 3 章 DDGI 那一类方法整理的,仍属推断。附录 A 的代码地图可以切到 Lite 路径,看调用树里换掉了哪两棵子树。
被换掉的部件依据 Daniel Wright 的公开说明与 5.8 资料;Lite 内部的执行步骤 Epic 没有公开,图中按 DDGI 类方法推断,标为"推断"
A.4代码
代码地图:一帧的调用顺序
前面两张流图是按"数据"画的。想去源码里对照着读时,需要的是另一张图:一帧里哪个函数先被调用,它在哪个文件,调的是哪些变量。
读源码时有三个入口最值得先打断点:BeginUpdateLumenSceneTasks 决定这一帧要捕获哪些卡片;RenderLumenSceneLighting 决定这一帧给哪些页打光;r.Lumen.FinalGatherMethod 那个分支决定走屏幕探针还是辐照度场。画面上看到的大部分问题,都能在这三处找到源头。
Profiler 里对应的三个大块是 Lumen Scene Lighting、Lumen Screen Probe Gather 和 Lumen Reflections。用 stat GPU 看总账、ProfileGPU 看细分;想单独测 Lumen 时,先用 r.Lumen.AsyncCompute 0 关掉异步计算,否则它的耗时会和别的 Pass 重叠在一起。
函数与文件名来自 UE5 源码阅读笔记(How UE Renders a Cube 等)与 Epic 文档;不同引擎版本会改名或拆分,请以所用版本的源码为准
A.5速查
预算与旋钮速查
把散在各章的数字、旋钮和调试视图收进一张表。调性能时先用 GPU Profiler 找到最贵的 Pass,再来这里找它对应的那一行。
| 模块 | 预算 / 关键数字 | 旋钮 | 调试 | 档 | 详见 |
|---|---|---|---|---|---|
| Surface Cache 图集 | 4096×4096,128² 物理页;各层合计 < 90 MB | — | Surface Cache 视图 | 二档 | |
| 卡片捕获 | 每帧 512×512 纹素;默认每网格 12 张卡 | Max Lumen Mesh Cards | r.Lumen.Visualize.CardPlacement 1 | 二档 | |
| 给卡片打光 · 直接光 | 每帧 1024×1024 纹素;8×8 tile,每块 ≤ 8 盏灯 | Lumen Scene Lighting Update Speed | Lumen Scene 视图 | 二档 | |
| 给卡片打光 · 间接光 | 每帧 512×512 纹素;最多累积 4 帧 | Final Gather Lighting Update Speed | 开关灯看追赶帧数 | 二档 | |
| Mesh SDF | 320 MB 砖块池;每体素 1 字节 | Distance Field Resolution Scale | Mesh Distance Fields;LogAtlasStats 1 | 二档 | |
| Global SDF | 4 层 × 256³ + 1/4 分辨率粗 mip | — | Global Distance Field | 二档 | |
| Voxel Lighting | 4 层 × 64³,六个轴向 | — | — | 二档 | |
| Screen Probe Gather | 每 N×N 像素一个探针;每探针约 64 条光线 | ScreenProbeGather.DownsampleFactor · TracingOctahedronResolution | GPU Profiler | 一档 | |
| World Radiance Cache | 每帧更新有探针预算 | RadianceCache.NumProbesToTraceBudget | — | 一档 | |
| 半透明体积 | 独立的一套世界缓存 | TranslucencyVolume.RadianceCache.NumProbesToTraceBudget | — | 二档 | |
| Reflections | 读缓存 2.44 ms / Hit Lighting 11.54 ms(Matrix,PS5) | Hit Lighting | Reflection View | 二档 | |
| 硬件光追 | payload 20 字节;蒙皮网格每帧重建 BVH | Fallback Relative Error | Lumen Scene 视图 | 二档 | |
| Far Field | 800 m – 1 km,追 HLOD1 | r.LumenScene.FarField=1 · r.RayTracing.Culling.Radius | Reflection View 看远景 | 一档 | |
| Lumen Scene 范围 | 默认 200 m,最大 800 m | Lumen Scene View Distance | — | 一档 | |
| Lumen Lite 开关 | 约为 High 两倍速度(Beta) | sg.globalilluminationquality 1 + sg.reflectionquality 1 | PIE / 打包版本,动态镜头 | 一档 |
"档"一列是证据档位:一档来自官方文档,二档来自 2022 年的演讲,结构可靠,但具体数值在新版本里可能已经变了,以你项目里实测的为准。
二档数字来自 SIGGRAPH 2022 演讲,版本之间会变;一档来自 Epic 文档
A.6时间线
版本路线
最后把 Lumen 放回时间线上。它不是一次成型的,读资料时要注意数字属于哪一代。
- UE5 抢先体验
Lumen 首次公开,作为 UE5 默认的动态全局光照与反射。
- UE 5.0 正式版
Lumen 可用于生产。本文第 3–7 章的结构以这一代为基础。
- SIGGRAPH 2022 演讲
Wright 等人公开 Lumen 的完整内部设计。本文绝大多数"二档"数字来自这里。
- UE 5.4
移动端 Lumen 以实验性功能提供:高端安卓机切到桌面渲染器(Vulkan SM5)后可用,iOS 不支持。
- UE 5.6
Epic 以"硬件光追下 60 帧"为目标,把部分工作从 CPU 移到 GPU,改进 Lumen GI 的性能。
- UE 5.7
继续向"硬件光追 60 帧"单一路径收拢:
r.Lumen.TraceMeshSDFs默认关闭(Detail Tracing 在 5.6 已弃用);High 档默认半分辨率积分以省约 0.5 ms。 - UE 5.8
新增 Lumen Lite(Medium 档,Beta);移除不透明度层、放宽小物体剔除、距离场可在集成显卡上运行。
5.1 到 5.3 以及 5.5 也都有 Lumen 的改动,但本文没有逐版核实,所以没有列出。从 5.6 开始,方向已经很清楚:软件光追只保留全局距离场这一条,更高的精度交给硬件光追;同时往下用 Lite 覆盖中低端设备。升级引擎时,请以对应版本的发布说明为准,并重测一遍上一节表里的基线。
5.4 依据 Epic 移动端 Lumen 文档;5.6、5.7 依据 Epic 发布说明与 Tom Looman 的版本性能总结;5.1–5.3、5.5 的逐版本变化本文没有核实,因此未列出
B
引擎实现:Pass 与调度
在 UE 里,Lumen 每帧必须跑哪几块、哪几块可以关,它们又怎样在图形队列和异步计算之间排队。
附录 A 把部件拼回了系统和代码。这个附录再往引擎里走一层,回答落地时最常被问的三个问题:哪些 Pass 是骨架、哪些是可以拿掉的增强;它们在一帧里和别的渲染工作怎样并行;CPU 上的场景更新为什么有时会卡。
第一节是一张可以点着开关的依赖流图,第二节是一帧的队列时间线,第三节把所有开关汇成一张表,第四节讲 CPU 侧的排查。最后是全篇的参考资料与证据等级。
B.1流图
哪些 Pass 必须跑,哪些可以关
附录 A 的系统流图回答"数据怎么流"。这一节换一个问题:在 UE 里,Lumen 每帧到底要跑哪些 Pass?哪些是骨架,关了整个 GI 就塌;哪些是可以拿掉的增强,关了只是丢一部分画质、省一部分时间?
下面这张图按一帧的先后从左到右排开。实线框是必要的;虚线框是可选、默认开的;点线框是可选、默认关的;左边加粗的是取决于追踪方式的。点一个方块看它为什么在这里;对可选的方块再点一次,就能把它关掉,看连线怎么断、丢掉了什么、对应哪个变量。
把三种配置都切一遍,会看到一个很稳定的结构:默认配置下必要的方块是 7 个,归起来只有四件事。场景表示要更新(卡片捕获,加上软件光追的距离场或硬件光追的 TLAS);缓存要打直接光;收集要有放探针、场景追踪、滤波这条主干(Lite 下换成一整块 Irradiance Final Gather);最后要合成。其余的都是增强。
这些增强分两类,取舍思路完全不同:
省时间的大刀:Lumen Reflections 和半透明体积。前者整块换成 SSR,后者让半透明和雾失去 GI。场景里没有大面积光滑表面、没有雾的项目,这两刀几乎不影响画面,却能省下一整个 Pass。
省得少、丢得多的小刀:屏幕追踪、Short Range AO、Radiosity。它们本身不贵,关掉后的损失却很直观:近处的接触光变糊、物体浮在地上、室内变暗。它们更适合在排查问题时临时关掉做对照,而不是拿来省性能。
先砍整块的 Pass,再调预算;不要先关那些便宜却决定观感的小步骤。
Lite 那一栏只画出了 Epic 公开的部分:Pass 2 换成 Irradiance Final Gather,Pass 3 换成 SSR,其余照旧。社区有报告说 Medium 档只走软件光追,但 Epic 没有确认,图里只在读数里提示。
变量名与默认值依据 UE 5.4 源码的变量说明(ue544 cvar wiki)、Epic Lumen 性能指南与 Technical Details(5.8)、5.7/5.8 发布说明;标"推断"的后果是本文按结构推出的
B.2时间线
一帧里的排队:图形队列与异步计算
知道了哪些 Pass 要跑,下一个问题是它们什么时候跑。现代 GPU 有两条可以同时干活的队列:图形队列负责光栅化,异步计算队列可以在旁边并行地跑计算着色器。Lumen 的大部分工作都是计算着色器,所以它能被挪到异步队列上,藏进别的 Pass 的空隙里。
能不能挪,取决于它在等谁:
- Scene Lighting 只需要卡片已经捕获好,不需要这一帧的 GBuffer。所以它可以和 Base Pass 同时跑。
- Screen Probe Gather 要在 GBuffer 的深度和法线上放探针,还要读 Scene Lighting 写好的缓存,所以必须等这两样都完成。它正好可以和阴影、直接光照并行,因为那两步不读它,它也不读那两步。
- Reflections 要复用 Screen Probe Gather 的结果,所以排在它后面。默认留在图形队列上。
- 合成要等间接光和直接光都好了,是汇合点,回到图形队列。
对应的四个变量都在源码里,默认值是:r.Lumen.AsyncCompute 1 总开关;r.LumenScene.Lighting.AsyncCompute 1;r.Lumen.DiffuseIndirect.AsyncCompute 1;r.Lumen.Reflections.AsyncCompute 0。总开关还要再过一道硬件检查:平台必须支持高效的异步计算;用硬件光追时,只有 Inline 光追才能走异步,走光线追踪管线就会整体退回图形队列。
工程上有两个直接的推论。第一,分析性能时要先关异步:r.Lumen.AsyncCompute 0,否则 Lumen 的耗时和 Base Pass、直接光照叠在一起,stat GPU 里分不清是谁慢。第二,直接光照很重的场景(灯多、阴影多),Screen Probe Gather 和 Reflections 会在图形队列上互相挤,官方建议这时把三个异步开关都打开,让整个 Lumen 跑在异步队列上。
异步计算不减少 Lumen 的工作量,只是把它藏进别的 Pass 的空隙里。空隙不够大时,它一样会拖长帧时间。
重叠关系依据 Epic Lumen 性能指南("与非 Nanite 几何 Pass 和直接光照 Pass 重叠");异步默认值与 Inline 限制依据 UE 5.4 源码的变量说明;块的长短为示意
B.3速查
开关速查:怎么关、关了丢什么
把前两节涉及的开关汇总成一张表,方便写进设备配置或可扩展性设置。这些变量大多带 ECVF_Scalability 标记,可以按档位设置。
| 模块 | 变量 | 默认 | 关掉 / 打开后 |
|---|---|---|---|
| 整个漫反射 GI | r.Lumen.DiffuseIndirect.Allow | 1 | Lumen GI 整体关闭 |
| Radiosity 多次反弹 | r.LumenScene.Radiosity | 1 | 缓存里只剩直接光,只剩一次反弹,室内变暗 |
| 收集的屏幕追踪 | r.Lumen.ScreenProbeGather.ScreenTraces | 1 | 近处细节全靠场景表示,接触光变糊 |
| World Radiance Cache | r.Lumen.ScreenProbeGather.RadianceCache | 1 | 远处光照失去共享缓存(后果为推断) |
| Short Range AO | r.Lumen.ScreenProbeGather.ShortRangeAO | 1 | 丢接触阴影,可改用 SSAO 补 |
| Lumen 反射 | r.Lumen.Reflections.Allow | 1 | 换成 SSR,屏幕外反射消失 |
| 反射的屏幕追踪 | r.Lumen.Reflections.ScreenTraces | 1 | 反射里近处细节变粗 |
| 半透明体积 | r.Lumen.TranslucencyVolume.Enable | 1 | 半透明和雾失去 Lumen GI |
| 网格 SDF 细节追踪 | r.Lumen.TraceMeshSDFs | 0(5.7 起) | 打开后近 2 米更准,但已弃用、很贵 |
| Far Field | r.LumenScene.FarField | 0 | 打开后硬件光追追到约 1 km,需 HLOD1 |
| 异步总开关 | r.Lumen.AsyncCompute | 1 | 关掉后 Lumen 全部回到图形队列,适合做性能分析 |
| Scene Lighting 异步 | r.LumenScene.Lighting.AsyncCompute | 1 | 与 Base Pass 重叠 |
| 漫反射收集异步 | r.Lumen.DiffuseIndirect.AsyncCompute | 1 | 与阴影、直接光照重叠 |
| 反射异步 | r.Lumen.Reflections.AsyncCompute | 0 | 直接光照很重时可打开 |
用这张表时有一个顺序:先在后处理体积和可扩展性档位里调(Lumen Scene Detail、Final Gather Quality、反射质量),它们按区域和档位生效,最可控;确认是哪一块慢、而且那一块对画面贡献不大时,再用控制台变量把它整块关掉。直接改变量,是全局的、对所有场景一刀切。
变量名与默认值依据 UE 5.4 源码的变量说明(ue544 cvar wiki)与 Epic 性能指南;5.7 起的默认值依据发布说明。不同版本可能变动,以所用引擎为准
整块的大刀
r.Lumen.Reflections.Allow 0 换 SSR;r.Lumen.TranslucencyVolume.Enable 0 半透明和雾失去 GI。
便宜但决定观感
屏幕追踪、Short Range AO、r.LumenScene.Radiosity:适合排查时对照,不适合拿来省性能。
默认关的增强
r.LumenScene.FarField 1(需 HLOD1);r.Lumen.TraceMeshSDFs 1(5.6 起弃用)。
异步四个开关
r.Lumen.AsyncCompute 1 · LumenScene.Lighting 1 · DiffuseIndirect 1 · Reflections 0。做性能分析时先把总开关关掉。
B.4排查
CPU 侧:场景更新为什么会卡
前面讲的都是 GPU。Lumen 还有一部分开销在 CPU 上,而且常常是卡顿的来源:每帧决定哪些卡片要重新捕获、哪些距离场要重建,再把它们排进绘制命令。A.4 代码地图里的 BeginUpdateLumenSceneTasks 就是这一段的入口。
它贵在什么时候?官方性能指南和 Epic 员工的回复给了几种典型情况:
- 镜头附近的网格:卡片更新针对的是相机附近的网格,不管它们动没动。附近物件很碎、很多时,每帧的卡片请求就多。
- 移动静态物体:会让距离场的静态缓存失效,代价可能很大。开了"影响距离场光照"的可移动物体,同样会触发距离场的动态更新。
- 镜头快速移动:新进入范围的区域要补距离场页和卡片。
- 硬件光追:TLAS 每帧重建,实例越多越贵。
排查时,按这个顺序打开调试工具:
- 看卡片在更新谁:
r.Lumen.Visualize.CardPlacement 1显示正在处理的卡片,它和 Unreal Insights 里卡片更新任务的来源基本一一对应。r.LumenScene.SurfaceCache.LogUpdates 1打印更新日志,设为2会带上网格名。 - 看距离场在重建谁:
ShowFlag.VisualizeGlobalDistanceField 1打开全局距离场视图,配合r.GlobalDistanceField.Debug.DrawModifiedPrimitives 1高亮被更新的物体;r.GlobalDistanceField.Debug.ShowStats 1显示统计,r.GlobalDistanceField.Debug.LogModifiedPrimitives 1打日志。 - 压范围:卡片更新的剔除距离,软件光追看
r.LumenScene.GlobalSDF.ClipmapExtent,硬件光追看r.RayTracing.Culling.Radius。r.LumenScene.SurfaceCache.CardMinResolution和后处理里的 Lumen Scene Detail 决定多小的物体还会进场景。 - 快镜头段落:可以对这些段落打开
r.AOGlobalDistanceField.FastCameraMode。
Epic 员工的原话是:后处理体积里的设置,是按区域在质量和性能之间取舍的最好办法。控制台变量留给定位问题。
调试变量与建议来自 Epic 员工 Lance Chaney 在开发者论坛的回复(2025.10);代价描述依据 Epic 性能指南;调用顺序见附录 A.4
谁会让 CPU 卡
镜头附近碎而多的网格、移动静态物体、快镜头、硬件光追每帧重建 TLAS。
看卡片
r.Lumen.Visualize.CardPlacement 1 · r.LumenScene.SurfaceCache.LogUpdates 2
看距离场
ShowFlag.VisualizeGlobalDistanceField 1 · r.GlobalDistanceField.Debug.DrawModifiedPrimitives 1
先用后处理体积
按区域在质量和性能之间取舍最可控;控制台变量留给定位问题。
B.5
参考资料与证据等级
这份讲解里的内容,证据强度并不一样。阅读和转述的时候,请分清三档。
一档:可以直接引用。Epic 的官方文档,以及 Epic 工程师的公开说明,比如 Daniel Wright 说 Lumen Lite 基于带探针遮挡的辐照度场。
二档:结构可靠,数字会变。论文和演讲。机制描述是可靠的,但其中的分辨率、内存、毫秒耗时都是当年那个版本的。比如 5.8 已经移除了表面缓存的不透明度层,而 2022 年的演讲里它还在。
三档:需要自己验证。社区报告和本文的推断,以及附录 C 里标"建议"的实现选择。比如 Lumen Lite 的几条画面症状来自一个论坛提问帖,Epic 并没有确认它是 bug;黑块的成因解释、评估清单的顺序,也都是本文基于前面原理的判断。
Epic 官方(均在 dev.epicgames.com 的 UE 文档下,按版本查阅)
- Lumen Technical Details
- Lumen Performance Guide
- Lumen Global Illumination and Reflections
- Unreal Engine 5.8 Release Notes
- Using Lumen Global Illumination on Mobile
- Using the Desktop Renderer on Mobile
- Nanite and Lumen for XR
论文与演讲
- Wright 等,Lumen: Real-time Global Illumination in Unreal Engine 5,SIGGRAPH 2022 Advances in Real-Time Rendering 课程
- Sloan, Kautz, Snyder,Precomputed Radiance Transfer for Real-Time Rendering in Dynamic, Low-Frequency Lighting Environments,2002
- Sloan 等,Clustered Principal Components for Precomputed Radiance Transfer,2003
- Ramamoorthi, Hanrahan,An Efficient Representation for Irradiance Environment Maps,2001
- Majercik 等(NVIDIA),Dynamic Diffuse Global Illumination with Ray-Traced Irradiance Fields,2019
- Kaplanyan, Dachsbacher,Cascaded Light Propagation Volumes for Real-Time Indirect Illumination,2010
- Crassin 等,Interactive Indirect Illumination Using Voxel Cone Tracing,2011
- Bitterli 等,Spatiotemporal Reservoir Resampling for Real-Time Ray Tracing with Dynamic Direct Lighting(ReSTIR),2020
社区与二手资料(不是一手资料,引用前请回到原始出处核对)
- Tom Looman,《UE 5.8 Performance Highlights》
- Daniel Wright(Epic)关于 Lumen Lite 的公开说明
- Unreal Engine 开发者论坛:Medium 档 GI 画面问题的提问帖
- DDGI 的实现与评估(捷克理工大学学位论文),本文 DDGI 公式的直接来源
- Tom Looman,《UE 5.7 Performance Highlights》:Detail Tracing 弃用、MaxRayIntensity 默认值等
- Epic 开发者论坛,Lance Chaney(Epic)关于 Lumen 场景更新开销的回复(2025.10):附录 B.4 的调试变量
- Epic 开发者论坛:Irradiance Field Gather 的世界缓存更新开销提问帖(2026.05)
- UE 5.4 控制台变量说明(ue544 cvar wiki,按源码整理):附录 B、C 中变量名与默认值的出处
- How UE Renders a Cube(源码阅读笔记):附录 A.4 代码地图中函数与文件名的出处
每节末尾都标了出处;一节里混用多档时,以最低的那一档为准
Epic 官方
- Lumen Technical Details
- Lumen Performance Guide
- Lumen Global Illumination and Reflections
- UE 5.8 Release Notes
- Plugin Index(UAF 等插件依赖)
均在 dev.epicgames.com 的 UE 文档下,按版本查阅。
论文与演讲
- Lumen: Real-Time GI in UE5 — Wright 等,SIGGRAPH 2022 课程
- Precomputed Radiance Transfer — Sloan, Kautz, Snyder, 2002
- Clustered PCA for PRT — Sloan 等, 2003
- Irradiance Environment Maps — Ramamoorthi & Hanrahan, 2001
- Dynamic Diffuse GI with Ray-Traced Irradiance Fields — Majercik 等(NVIDIA), 2019
- Light Propagation Volumes — Kaplanyan 等, 约 2010
- Voxel Cone Tracing — Crassin 等, 2011
- ReSTIR — Bitterli 等, 2020 起
社区与二手资料
- Tom Looman《UE 5.8 Performance Highlights》
- Daniel Wright(Epic)关于 Lumen Lite 的公开说明
- UE 开发者论坛:Medium GI 画面问题帖
- DDGI 实现与评估(CTU 学位论文)—— 本稿 DDGI 公式的直接来源
这一列不是一手资料,引用前请回到原始出处核对。
一档:可直接引用
官方文档、Epic 工程师的公开说明。
二档:结构可靠,数字会变
论文与演讲。机制稳定,但分辨率、内存、耗时都是当年的。
三档:需要自己验证
社区报告、本稿的推断与判断建议。对外讲时请口头说明。
页签 8 / 8
动手:写一个 Lumen,再把它交给团队
八个里程碑从卡片、距离场写到屏幕探针和时间累积,每一步有伪代码、完成标准和坑;然后是控制面:程序和美术各调什么、怎样按区域调。
C
动手实现:写一个自己的 Lumen
按八个里程碑,从卡片和距离场写到屏幕探针和时间累积。每一步给出数据布局、伪代码、做完应该看到什么,以及会踩的坑。
前面的章节解释了 Lumen 为什么这样做,附录 A、B 讲了它在 UE 里怎样组织和调度。这个附录换成动手的视角:假设你手里有一个支持计算着色器的渲染器,要从零写出一个 Lumen 式的系统。
它按八个里程碑展开,顺序就是建议的施工顺序:每一个都能独立验证,做完下一个才往上叠。每节先讲做法,再给伪代码,最后列出这一步最常见的坑。里程碑之后有一节把所有 Pass 排进一帧,再一节把所有坑汇成一张表,最后一节讲怎么证明你写对了。
凡是 Lumen 自己的做法,都标了出处并指回讲原理的那一节;凡是标"建议"的,是为了让第一版更容易跑通而做的简化,不代表 Lumen 的实现。
C.1路线
先定范围:最小可用版本长什么样
前面的章节回答"Lumen 为什么这样做"。这个附录换成工程师的问题:如果要我自己写一个,从哪里开始、按什么顺序、每一步怎么知道自己写对了、会踩哪些坑。
先说结论:不要一上来就照着 Lumen 全套写。Lumen 是多年迭代的产物,很多部件是为了解决大规模场景、几十种平台和边角案例才长出来的。自己写,应该先做一个能跑通的最小闭环,再一块一块加回去。
最小闭环是这样的:场景用卡片和距离场表示;卡片打直接光;屏幕上放探针,探针的光线在距离场里追踪,命中后读卡片;结果滤波、插值到像素。做到这里,你就已经有了动态的单次反弹 GI。再把卡片的间接光接回去,就有了多次反弹。
| 里程碑 | 做什么 | 做完你应该看到 |
|---|---|---|
| M1 | 每个网格 6 张轴向卡片 + 图集分配 + 捕获 | 调试视图里,卡片图集上能认出每个物体的反照率和法线 |
| M2 | 离线生成网格距离场,运行时合成全局距离场 | 距离场切片视图里,墙体轮廓清楚,内外符号正确 |
| M3 | 距离场里的 sphere tracing | 从相机发主光线追距离场,能画出整个场景的灰模 |
| M4 | 命中点 → 卡片查找 | 用 M3 的灰模去读卡片反照率,画面和光栅化结果基本一致 |
| M5 | 卡片直接光 + 多次反弹 | 卡片图集被灯照亮;白炉测试收敛到理论值 |
| M6 | 屏幕探针:放置、追踪、滤波、插值 | 画面出现正确颜色的间接光(会很噪) |
| M7 | 时间累积:抖动、重投影、历史拒绝 | 静止画面变干净,运动时没有明显拖影 |
| M8 | 世界缓存、屏幕追踪、反射、半透明 | 远处更稳、近处更细、光滑表面有反射 |
有几样东西建议第一版直接砍掉,等闭环稳定了再加:硬件光追(先只做软件路线)、卡片的聚类生成(先用 6 面)、虚拟化的图集分页(先用固定大小的简单打包)、自适应探针、重要性采样(先均匀余弦采样)、反射和半透明。每一样都能单独加回去,而且加回去的时候,你会很清楚它解决的是哪个问题。
需要的硬件能力:计算着色器、3D 纹理(或者能模拟它的大纹理)、间接派发(indirect dispatch)、原子操作。没有间接派发也能做,但追踪阶段会浪费大量线程。
Lumen 的结构依据 SIGGRAPH 2022 演讲(Wright 等)与前文各章;里程碑划分与"先砍掉什么"是本文的建议
C.2数据
数据结构:先把显存布局定下来
写 GPU 系统最容易翻车的地方,是边写边加缓冲区,最后谁读谁写一团乱。动手之前,先把要用到的资源列成一张表:它存什么、谁写、谁读、活多久。
| 资源 | 存什么 | Lumen 的值 | 起步建议 | 生命周期 |
|---|---|---|---|---|
| 卡片描述 | 每张卡片的朝向(6 个轴之一)、局部包围盒、在图集里的矩形、所属网格 | 每网格最多 12 张 | 结构化缓冲,每网格固定 6 张 | 持久,随物体增删 |
| 卡片图集 · 材质 | 反照率、法线、自发光、深度 | 4096²,128² 的物理页 | 2048²,固定分区打包 | 持久,捕获时写 |
| 卡片图集 · 光照 | 直接光、间接光、最终光照 | 同上,按页更新 | RGBA16F 三张 | 持久,每帧滚动更新 |
| 网格距离场 | 每网格的窄带距离,8³ 砖块 | 共享 320 MB 砖块池 | 每网格一张小 3D 纹理(32³–64³) | 离线生成,持久 |
| 全局距离场 | 合成后的场景距离,以相机为中心 | 4 层 × 256³ | 2 层 × 128³ | 持久,相机移动时滚动更新 |
| 屏幕探针 · 位置 | 每个探针的深度、法线、世界坐标 | 每 16 像素一个 + 自适应 | 每 16 像素一个,不做自适应 | 每帧重建 |
| 屏幕探针 · 光线结果 | 每个方向追回来的亮度和命中距离 | 8×8 八面体 | 8×8 八面体,RGBA16F(A 存命中距离) | 每帧重建 |
| 屏幕探针 · 球谐 | 每探针 9 个系数 × RGB | 3 阶球谐 | 同左 | 每帧重建 |
| 历史缓冲 | 上一帧的间接光、深度、法线 | 多张 | 间接光 + 深度 + 法线各一张 | 跨帧,双缓冲 |
| 世界缓存(M8) | 空间探针的索引和图集 | Clipmap 索引 + 探针图集 | 第一版不做 | 持久,按预算更新 |
两条容易被忽视的约定,最好一开始就定死:
光照的单位和曝光。所有缓存里的光照都存线性的、未曝光的 HDR 值。如果你的引擎把场景颜色存成"预曝光"的(乘过当前曝光值,UE 就是这样做的),那么屏幕追踪从场景颜色里读到的值,必须先除回曝光再用。否则自动曝光一变化,间接光就跟着闪。
坐标系和深度的约定。卡片里存的深度是"沿卡片朝向的距离",屏幕深度是"相机空间的深度",距离场是"世界空间的距离"。三套深度混用的 bug 非常难查,建议每种都写成一个带名字的函数,不在着色器里手写换算。
先画资源表,再写着色器。资源表就是你的系统设计图,后面每个 Pass 只是在这张表上读和写。
"Lumen 的值"一列依据 SIGGRAPH 2022 演讲与前文各章;"起步建议"一列是本文给出的、方便调试的保守选择
先画资源表
卡片描述、图集(材质 + 光照)、网格 / 全局距离场、探针三件套、历史缓冲:谁写、谁读、活多久。
单位约定
缓存一律存线性、未曝光的 HDR;读预曝光的场景颜色先除回曝光。
深度约定
卡片深度、屏幕深度、世界距离三套,各写成一个带名字的函数。
要点
资源表就是系统设计图,每个 Pass 只是在表上读和写。
C.3实现
里程碑 1:卡片与 Surface Cache
第一个里程碑做的是第 5 章那份缓存。目标很朴素:每个网格从几个方向被"拍"成卡片,卡片住进一张图集,之后任何一点都能查到它表面的材质。
生成卡片。起步时,每个网格直接用包围盒的 6 个面当卡片:+X、−X、+Y、−Y、+Z、−Z 各一张,朝向网格中心拍。这样做覆盖不了凹进去的地方(下一节的交互图会演示),但足够跑通闭环。等闭环稳定了,再换成第 5 章讲的面元聚类:把表面采样成带朝向的面元,按朝向和位置聚类,每类拟合一张卡片。
分配图集。卡片的分辨率按它在屏幕上的大小决定,近大远小。起步用最简单的"架子"打包(按高度排序,一行一行摆),预留固定比例的空位。Lumen 用的是 128² 的物理页加虚拟映射,能按需换页;这一步可以晚点再做。
捕获。对每张卡片做一次正交投影的光栅化:相机放在卡片外侧,朝卡片方向看,视锥就是卡片的包围盒。输出写进图集里这张卡片的矩形区域。
// 顶点着色器:把网格投到卡片的正交相机里 float4 VS(float3 localPos) { float3 p = mul(card.worldToCard, mul(instance.localToWorld, localPos)); // 卡片空间 float2 uv = (p.xy - card.min.xy) / (card.max.xy - card.min.xy); // 0..1 float z = (p.z - card.min.z ) / (card.max.z - card.min.z ); // 沿朝向的深度 float2 atlas = card.atlasRect.xy + uv * card.atlasRect.zw; // 写到图集里的位置 return float4(atlas * 2 - 1, z, 1); } // 像素着色器:只写材质,不算光 void PS(..., out float4 albedo, out float4 normal, out float4 emissive, out float depth) { albedo = float4(BaseColor * (1 - Metallic), Opacity); // 当作纯漫反射,不存高光 normal = EncodeNormal(WorldNormal); // 存世界空间法线,省得采样时再转 emissive = Emissive; depth = z; // 采样时做深度校验要用 }
采样。任意一点要读缓存时,先按它的法线挑出朝向合适的卡片(6 个轴里最多 3 个),把点投进每张卡片,按"深度对不对得上"和"朝向有多正"加权混合。这一步是 M4 的内容,单独放在下一节之后。
双线性采样跨出了卡片矩形,读到了旁边卡片或空白区域。图集里每张卡片四周留至少半个纹素的边,采样坐标钳在"矩形缩进半纹素"的范围内。
捕获时把 BaseColor 直接当反照率存了。金属的漫反射反照率应该接近 0,它的颜色属于高光。存 BaseColor × (1 − Metallic),金属的高光交给反射。
自发光既写进了卡片的最终光照,又在像素的直接光里算了一次。约定清楚:卡片里的自发光只供"别人"的光线命中时读取。
捕获有预算,新物体要排队。这是正常的,但要给新物体最高优先级,并且在调试视图里能看到"还没拍到"的状态(Lumen 用粉色)。
按屏幕大小算分辨率时没有滞后,物体在阈值附近反复升降级,触发反复重拍。给分辨率切换加迟滞:升级和降级用不同的阈值。
Lumen 的卡片生成、捕获与采样方式依据 SIGGRAPH 2022 演讲;"先用 6 面卡片"等为本文建议
起步
每网格 6 张轴向卡片 + 架子打包;捕获 = 正交光栅化,只写材质。
之后再换
面元聚类生成卡片;128² 物理页 + 虚拟映射。
常见坑
接缝发黑(留边)· 金属发黑(存 BaseColor×(1−Metallic))· 自发光算两次 · 分辨率抖动(加迟滞)。
要点
卡片里只存材质;光照是 M5 的事。
C.4实现
里程碑 2–3:距离场与追踪
第二个里程碑是第 6 章的距离场,分两半:离线为每个网格生成距离场,运行时把它们合成一张跟着相机走的全局距离场。第三个里程碑是在里面走光线。
离线生成。对网格包围盒(四周外扩几个体素)里的每个体素中心,求它到最近三角形的距离,这一步用 BVH 做最近点查询。难的是符号:这个点在网格里面还是外面?游戏资产大多不是水密的,"射一条线数穿过几次"的教科书做法会大面积出错。6.5 节讲过 Lumen 的办法:从这个点向几十个方向发射线,数一数命中的是三角形的背面还是正面,背面多就算在里面。
for (voxel in PaddedBounds(mesh, padVoxels = 4)) {
float3 p = VoxelCenter(voxel);
float d = ClosestDistance(meshBVH, p); // 无符号距离
if (d > NarrowBand) continue; // 窄带:远离表面的体素不存
int back = 0, hits = 0;
for (int i = 0; i < 64; i++) { // 均匀球面方向
Hit h = RayCast(meshBVH, p, SphereDir(i));
if (!h.valid) continue;
hits++;
if (dot(SphereDir(i), h.triNormal) > 0) back++; // 打到背面
}
bool inside = hits > 0 && back > hits * InsideRatio; // 比例按资产质量调
Store(voxel, Quantize8(inside ? -d : d, NarrowBand));// 一个字节,±几个体素的范围
}运行时合成全局距离场。全局距离场的每个体素,取所有覆盖它的实例的距离的最小值。实例可能被旋转、缩放,所以要先把点变换到实例的局部空间,读出局部距离,再换算回世界距离。
float d = ClipmapVoxelSize * 4; // 没有物体时给一个"足够远"的上限 for (inst in InstancesOverlapping(brick)) { // 先用粗网格剔除,不要遍历全场景 float3 local = mul(inst.worldToLocal, p); float dl = SampleMeshSDF(inst.mesh, local); // 出了网格包围盒:用到包围盒的距离代替 d = min(d, dl * inst.minAxisScale); // 非均匀缩放:用最小的轴缩放,保证不会走过头 } GlobalSDF[clipmap][voxel] = d;
追踪。就是 6.3 节的 sphere tracing,但有三处必须加的细节:起点要沿法线偏移,避免一出发就撞上自己;命中阈值随走过的距离放大,对应 6.7 节的"表面扩张";步数要有上限。
float t = NormalBias; // ① 起点偏移:约一个体素 for (int i = 0; i < MaxSteps; i++) { float3 p = origin + dir * t; float d = SampleGlobalSDF(p); // 自动选覆盖 p 的最细一层 clipmap float expand = min(t * ExpandSlope, VoxelSize(p) * 0.87); // ② 越远扩张越大,上限半个体素对角线 if (d < expand + MinHitDistance) return Hit(p, t); t += max(d, MinStep); // 掠射时 d 很小,强制最小步长 if (t > MaxDistance) return Miss(); } return HitAtStepLimit(origin + dir * t); // ③ 步数用尽:当作命中还是未命中,要统一约定
起点没有偏移,或者偏移小于一个体素。光线从表面出发,第一步采样到的距离接近 0,立刻判定命中。起点沿法线偏移约一个体素,屏幕追踪也要跳过最初的几个像素。
网格不水密,或者有单面的面片(地板、广告牌),符号判错了。用背面计数判定,并对确实是单面的资产单独处理(当作有厚度的薄壳)。距离场切片视图里能直接看到红蓝颠倒的区域。
非均匀缩放时用了最大轴缩放去换算距离,算出来的距离偏大,一步跨过了表面。改用最小轴缩放,宁可多走几步。
墙比一个体素还薄,距离场里根本没有"里面"。这是表示能力的极限,6.7 节的扩张只能缓解。资产侧把墙做厚,或者对重要的墙提高该网格的距离场分辨率。
Clipmap 滚动时只平移了寻址,没有重算新露出来的那一条体素。用环形寻址(toroidal addressing),每帧只重算新进入范围的切片。
光线贴着地面走,每步的距离都很小,步数爆炸。设最小步长和最大步数;步数用尽时统一当作命中(偏暗、安全)或未命中(偏亮、可能漏光),两种都可以,但要在调试视图里能看出来。
动态物体的旧位置没有从全局距离场里擦掉。动静分开缓存:静态砖块长期保留,动态物体每帧把它覆盖的砖块标脏重算。
窄带砖块存储、背面计数判定内外、表面扩张随距离增长依据 SIGGRAPH 2022 演讲与 6.5–6.7 节;阈值与起步分辨率为本文建议
离线
最近点距离 + 背面计数判内外 + 窄带 8 位量化。
运行时
全局距离场 = 覆盖实例的最小距离 × 最小轴缩放;Clipmap 环形寻址。
追踪三细节
起点沿法线偏移 · 命中阈值随距离扩张 · 步数上限与统一约定。
要点
漏光、自交、幽灵几何,八成出在这三处。
C.5交互图
里程碑 4:命中之后,从交点找到卡片
光线停下来了,接下来要回答"那里有多亮"。这一步把第 5 章的缓存和第 6 章的追踪接在一起,是整个系统里最容易出隐蔽 bug 的地方。
先要知道撞到的是哪个物体。在单个网格的距离场里追踪时,你天然知道是哪个实例,可以直接去找它的卡片。在全局距离场里追踪时,所有物体被合成了一张图,你不知道撞到的是谁,所以 Lumen 在这种情况下改读 6.8 节的 Voxel Lighting。自己实现时有两个选择:第一版只追网格距离场(用一个粗的空间网格做剔除),命中就知道实例;或者在全局距离场旁边存一张"最近实例编号"的体素图。
知道了实例,再从它的卡片里挑出朝向合适的几张,把命中点投进去,用深度校验判断这张卡片拍到的是不是这一点。下面这张图是一个带凹槽的物体的剖面,拖动命中点,看每张卡片怎么判定。
float3 SampleSurfaceCache(Instance inst, float3 p, float3 n) {
float3 sum = 0; float wsum = 0;
for (int axis = 0; axis < 3; axis++) {
Card c = inst.cards[axis * 2 + (n[axis] > 0 ? 0 : 1)]; // 法线朝哪边,就用哪一侧的卡片
float wN = abs(n[axis]); // 越正对卡片,权重越大
if (wN < 0.1 || !c.valid) continue;
float3 q = mul(c.worldToCard, p); // 投进卡片空间
if (any(q.xy < 0) || any(q.xy > 1)) continue;
// 手动双线性:四个纹素各自做深度校验,避免跨越深度断层把别的面的颜色抹过来
for (texel in Bilinear4(c, q.xy)) {
float err = abs(DepthAtlas[texel] - q.z) * c.depthRange;
float wD = saturate(1 - err / DepthTolerance); // 容差约 1–2 个卡片纹素对应的世界距离
float w = texel.bilinearWeight * wD * wN;
sum += w * FinalLightingAtlas[texel];
wsum += w;
}
}
if (wsum > 1e-3) return sum / wsum;
return CoverageFallback(p); // 没有卡片拍到这里:读体素光照或天空;调试视图里标成粉色
}只用 6 面卡片时,凹槽内壁被外侧卡片"挡住"了,深度校验全部失败。这正是 5.2 节要做面元聚类的原因:给凹面单独生成卡片。起步阶段至少要让这种点走到明确的回退,而不是读出 0。
深度容差太松,卡片拍到的是前面的遮挡面,也被当成了有效样本。容差按卡片纹素的世界尺寸来定,不要用一个全局常数。
深度容差太紧,掠射角下卡片的深度本身就有一个纹素的误差。容差随 1/|n·axis| 适当放大。
用了硬件双线性采样光照图集,跨越了深度断层。改成手动的 4 次采样,每个纹素单独做深度校验。
法线是从距离场梯度算出来的,在窄带边缘和砖块接缝处很不稳定。梯度用中心差分,步长取一个体素;或者在网格距离场旁边存一份法线。
回退写成了返回 0。回退应该返回一个合理的近似(体素光照、天空、或者周围的平均),同时在调试视图里明确标出来。
按法线挑 3 张卡片、深度与法线加权、4 次深度校验的双线性依据 5.2 节与 SIGGRAPH 2022 演讲;全局距离场命中读 Voxel Lighting 依据 6.8 节;容差取值为本文建议
C.6交互图
里程碑 5:给缓存打光与多次反弹
第五个里程碑让缓存亮起来。它分三件事:每帧挑哪些页来更新,给这些页算直接光,再算它们之间的反弹。
挑页。按 5.8 节的规则给每页一个优先级:最近被光线用过、又很久没更新的页优先。每帧只取预算内的前若干页。实现上,最省事的是在 GPU 上做一个优先级直方图,从高到低累加,找到刚好装满预算的那个阈值,再派发一次只处理阈值以上的页。
单位。动手之前把单位定下来,否则π会在你的系统里到处乱飞。建议约定:直接光和间接光都以辐照度(E,单位面积接收到的光)存;最终光照存出射辐射亮度(L,别人看过来时看到的亮度)。对漫反射表面:
最终亮度 = 反照率 ÷ π × (直接辐照度 + 间接辐照度) + 自发光
用余弦加权的方向采样半球时,"间接辐照度 ÷ π"恰好就是这些样本亮度的平均值,代码里不用再出现π。
// 直接光:逐纹素累加灯光 float3 E_direct = 0; for (light in LightsTouchingTile(tile)) { // 8×8 纹素一块,每块限定灯数 float ndl = saturate(dot(n, light.dir(p))); float shadow = InShadowMapFrustum(p, light) ? ShadowMap(p, light) : SDFShadowRay(p + n * Bias, light); // 屏幕外用距离场补 E_direct += light.radiance(p) * ndl * shadow; } // 多次反弹:每 4×4 纹素一个半球探针,读上一轮的最终亮度 float3 meanL = 0; for (int i = 0; i < 16; i++) { float3 dir = CosineHemisphere(i, FrameJitter, n); // 余弦加权:平均值就等于 E/π Hit h = TraceSDF(p + n * Bias, dir); meanL += h.valid ? SampleSurfaceCache(h) : SkyRadiance(dir); // 读的是上一轮写进去的 FinalLighting } meanL /= 16; float3 Eind_over_pi = TemporalBlend(history, meanL); // 最多累积几帧,压噪 FinalLighting[texel] = Albedo * (E_direct / PI + Eind_over_pi) + Emissive;
这个循环有一个必须亲手验证的性质:它是一个反馈系统。每转一轮,最终亮度乘以反照率再喂回自己。只要反照率小于 1,它就收敛;反照率等于或大于 1,它就发散。下面这个白炉测试是检验它最简单的办法:一个封闭的房间,所有表面反照率相同、自发光相同,理论上最终亮度会收敛到 自发光 ÷(1 − 反照率)。
反馈环里的能量大于 1。常见来源:贴图里的反照率超过 1、给 GI 加了"反照率增强"、或者 π 用错了一处。白炉测试能在几秒内发现。反照率在写入卡片时就钳到 1 以下。
直接光被算了两次:既写进了卡片的最终亮度,又在最终收集里通过屏幕追踪读到了已经含直接光的场景颜色,再叠加到像素上。约定好每个量只在一个地方加。
自发光既写进了卡片,又在像素的直接光里算了一遍,或者既被卡片读到又被屏幕追踪读到。和上一条是同一类问题。
更新预算太小,或者优先级只看"多久没更新",没看"最近有没有被用到"。这是 5.10 节讲的追赶问题;载入时可以先连跑几轮反弹做预热。
屏幕外改用距离场阴影射线时,起点偏移或扩张量和主追踪不一致,射线从墙缝里钻过去。阴影射线复用同一套追踪函数和参数。
屏幕追踪读的是预曝光的场景颜色,而卡片里存的是未曝光的值,两者混用。读屏幕颜色时先除回曝光。
页优先级、每 4×4 纹素一个半球探针、读上一轮最终光照依据 5.7–5.9 节与 SIGGRAPH 2022 演讲;单位约定与白炉测试为本文建议
C.7实现
里程碑 6:屏幕探针收集
到这里缓存已经有光了。第六个里程碑把它送到屏幕上:第 7 章的 Screen Probe Gather。它是一条五段的流水线,每段一个 Pass,中间结果都存在探针空间的小纹理里。
第一段,放探针。屏幕切成 16×16 像素的格子,每格选一个像素当探针,记下它的深度、法线和世界坐标。选哪个像素每帧抖动一下,长期看就覆盖了整个格子。落在天空上的格子标记为无效。
第二段,生成光线。每个探针一张 8×8 的八面体小图,每个纹素一个方向。第一版不做重要性采样:用八面体映射覆盖整个球面,每个纹素在自己的范围内随机抖动一个方向,指向表面以下的方向直接跳过。
float2 OctEncode(float3 d) {
d /= abs(d.x) + abs(d.y) + abs(d.z); // 压到八面体上
float2 p = d.xy;
if (d.z < 0) p = (1 - abs(d.yx)) * select(p >= 0, 1, -1); // 下半球向外翻折
return p * 0.5 + 0.5;
}
float3 OctDecode(float2 uv) {
float2 f = uv * 2 - 1;
float3 d = float3(f, 1 - abs(f.x) - abs(f.y));
float t = saturate(-d.z);
d.xy += select(d.xy >= 0, -t, t);
return normalize(d);
}第三段,追踪。先在屏幕上追(6.2 节的 HZB),找不到再进距离场。关键是从屏幕追踪停下的地方接着追,而不是从探针重新出发,否则同一段路会被算两次,还可能撞上探针自己所在的表面。
float3 TraceProbeRay(Probe pr, float3 dir) {
float3 o = pr.worldPos + pr.normal * Bias;
ScreenHit s = TraceScreenHZB(o, dir, MaxScreenDistance, Thickness); // 厚度测试:防止"打到"物体背后
if (s.hit) return SampleSceneColor(s.uv) / PreExposure; // 读上一帧画面,先除回曝光
float tStart = s.lastValidT; // 从屏幕追踪停下处接着走
WorldHit w = TraceSDF(o + dir * tStart, dir);
float3 L = w.valid ? SampleSurfaceCache(w) : SkyRadiance(dir);
return min(L, MaxRayIntensity); // 单条光线的亮度上限:压萤火虫
}第四段,滤波。每个探针和上下左右的邻居混合。邻居和自己深度差太大(不在同一个表面上)就降权;邻居某个方向的命中点离得很近时,它这个方向的结果不适合拿来用,也要降权。
第五段,积分到像素。每个探针的 64 个方向压成 3 阶球谐的 9 个系数。每个像素找周围 4 个探针,按 7.7 节的平面距离加权,用像素自己的法线求值。求值时乘上余弦核的三个系数,得到的就是辐照度。
// 余弦核卷积后的系数:第 0 阶 π,第 1 阶 2π/3,第 2 阶 π/4(第 2 章推导过) float3 IrradianceFromSH(SH9 sh, float3 n) { float3 E = PI * sh[0] * Y00 + (2 * PI / 3) * (sh[1] * Y1m1(n) + sh[2] * Y10(n) + sh[3] * Y11(n)) + (PI / 4) * (sh[4] * Y2m2(n) + sh[5] * Y2m1(n) + sh[6] * Y20(n) + sh[7] * Y21(n) + sh[8] * Y22(n)); return max(E, 0); // 低阶球谐会"振铃",结果可能为负,钳到 0 } // 着色时:DiffuseIndirect = Albedo / PI * E
深度缓冲只有正面,没有厚度信息。光线从物体后面经过时,深度比较会误判为命中。加厚度测试:光线深度比深度缓冲深出一个阈值以上就不算命中,继续走。
极少数光线打中了很小很亮的自发光或高光。给单条光线的亮度设上限;UE 5.7 起把这个上限的默认值调到了 10,并在 5.8 加了"跳过无光照着色模型的命中"。
探针放在了天空、远处背景,或者两层不同的表面之间被插值。无效探针不参与插值;平面距离权重不能省;插值找不到有效探针时,加自适应探针或回退到世界缓存。
屏幕追踪没命中后,距离场追踪又从探针重新出发,屏幕已经走过的那段又被采了一遍。从屏幕追踪最后的有效位置接着走。
按非均匀分布发了光线,积分时还在用均匀平均,或者 1/pdf 的权重和滤波的权重叠错了。Lumen 的做法是把低概率方向的光线挪给高概率方向,并把那里的八面体纹素细分,积分时按每条光线实际代表的立体角计权;自己做时,先用均匀采样把正确的结果存成参考,再对着参考改。
球谐求值出现负数(振铃)。结果钳到 0;高动态的场景可以只用到第 1 阶,或者对系数做加窗。
对八面体图做滤波或双线性采样时跨过了折叠的边。八面体的边界要按翻折关系做镜像寻址,或者四周留一圈复制边。
每 16 像素一个探针、8×8 八面体、屏幕追踪后接距离场、探针空间滤波、3 阶球谐依据第 7 章与 SIGGRAPH 2022 演讲;"先均匀采样"、单光线亮度上限等为本文建议(上限对应 r.Lumen.ScreenProbeGather.MaxRayIntensity)
五段
放探针 → 生成光线(八面体)→ 屏幕 + 距离场追踪 → 探针空间滤波 → 球谐积分到像素。
关键细节
从屏幕追踪停下处接着追;单光线亮度上限;球谐结果钳到 0。
先别做
重要性采样:先用均匀采样存一份参考。
要点
每段一个 Pass,中间结果都在探针空间的小纹理里。
C.8实现
里程碑 7:时间稳定——抖动、重投影与历史拒绝
到 M6 为止的画面是对的,但很噪。第七个里程碑把过去几帧的结果借过来。它是让画面"看起来能用"的最后一步,也是最容易引入拖影的一步。
做法就是 7.8 节的四步:每帧抖动光线方向,用运动矢量找到上一帧的位置,校验那里是不是同一个表面,可信就混合、不可信就丢掉。工程上的关键是第三步的校验,以及丢掉历史之后怎么办。
float2 prevUV = uv - MotionVector(uv);
bool valid = all(prevUV >= 0 && prevUV <= 1);
if (valid) {
float dPrev = LinearDepth(PrevDepth, prevUV), dNow = LinearDepth(Depth, uv);
valid = abs(dPrev - dNow) < dNow * 0.02 // 深度相差 2% 以内
&& dot(PrevNormal(prevUV), Normal(uv)) > 0.9; // 法线基本一致
}
float n = valid ? min(HistoryLength(prevUV) + 1, MaxFramesAccumulated) : 1;
float3 hist = valid ? HistoryIrradiance(prevUV) : 0;
float3 cur = CurrentIrradiance(uv);
float3 outE = lerp(hist, cur, 1.0 / n); // 历史越长,新样本权重越小
WriteHistory(uv, outE, n); // 历史长度也要存下来
// 历史短(刚被拒绝过)的像素:下游的空间滤波加大半径,补偿噪声历史校验太松,或者只校验了深度没校验法线。物体边缘的历史属于背景,却被混了进来。收紧阈值;用速度大的地方适当缩短最大累积帧数。
这是正常的:新露出来的区域没有历史。补偿的办法是按"历史长度"调节空间滤波的半径,历史越短滤得越宽。
镜头切换没有通知 GI,历史被当成有效的继续混合。检测到镜头切换就清空所有历史。
每帧的抖动序列重复了,或者和 TAA/TSR 的抖动同步,两者互相抵消。光线方向的抖动用自己的低差异序列或蓝噪声,不要复用主相机的抖动。
累积帧数太多,旧的光照占了太大权重。可以检测亮度剧变时缩短历史,或者像 TAA 一样把历史钳在当前邻域的范围内,代价是噪声回来一些。
历史存的是预曝光的值,而曝光在变化。历史一律存未曝光的线性值。
时间累积的四步与历史失效情形依据 7.8–7.9 节;最大累积帧数对应 r.Lumen.ScreenProbeGather.Temporal.MaxFramesAccumulated;校验阈值为本文建议
四步
抖动 → 运动矢量重投影 → 深度 + 法线校验 → 按历史长度混合。
被拒之后
历史越短,空间滤波越宽。
常见坑
拖影 · 切镜不清历史 · 抖动和 TAA 同步 · 历史存了预曝光值。
要点
时间累积决定画面能不能用,也最容易引入拖影。
C.9实现
里程碑 8:世界缓存、反射、半透明与硬件光追
前七个里程碑是骨架,这一个是四块可以独立加回去的增强。它们彼此不依赖,可以按项目需要挑着做。
世界缓存。屏幕探针只追到一个半径为止,半径以外的光交给空间里的世界探针(7.10 节)。世界探针只放在屏幕探针"请求"过的地方,所以总是贴着表面。屏幕探针的光线走到半径边界时,在那个位置、沿那个方向去插值世界探针。
WorldHit w = TraceSDF(o, dir, /*maxT=*/ ScreenProbeTraceRadius); if (w.valid) return SampleSurfaceCache(w); float3 farPos = o + dir * ScreenProbeTraceRadius; MarkRadianceCacheProbesUsed(farPos); // 本帧请求:下一帧这些探针会被安排更新 return SampleRadianceCache(farPos, dir); // 在 clipmap 里三线性插值周围 8 个世界探针
世界探针被很多帧、很多屏幕探针共享,所以它可以每个探针追很多光线、慢慢更新。代价是它的分辨率是空间上的,墙两侧的世界探针离得很近,插值时就会漏光。
反射。按粗糙度分流(8.1 节):粗糙像素直接用屏幕探针沿反射方向求值,光滑像素单独发一条 GGX 采样的光线,追踪和取光复用前面的全部函数。反射的时间累积不能直接用运动矢量:反射里的东西随视角移动的方式和表面不同,要用命中距离算出"反射点"再重投影,否则会拖出一片糊。
半透明。在相机前方放一个和屏幕对齐的体积网格(froxel),每格一个小探针,用同一套追踪和缓存。它的分辨率低,适合雾和半透明的大致照明。
硬件光追。如果前面的追踪都藏在一个接口后面,硬件光追就只是换一个实现:输入一条光线,返回命中的实例、位置、法线。8.5 节说过 Lumen 的命中只取法线和缓存坐标,命中后照样读 Surface Cache,所以第 4、5 个里程碑完全不用改。
struct TraceResult { bool valid; uint instance; float3 pos; float3 normal; float t; };
TraceResult Trace(float3 o, float3 d, float maxT) {
#if USE_HARDWARE_RT
return TraceInlineRayQuery(o, d, maxT); // Inline 光追:才能走异步计算(见 B.2)
#else
return TraceDistanceFields(o, d, maxT);
#endif
}世界探针隔着墙被插值了。世界探针也要做遮挡判断(像 DDGI 一样存命中距离),或者缩小屏幕探针交接的半径,让近处更多地由精确追踪负责。
两边对同一段距离的处理不一致:一边算了,另一边又算一次,或者都没算。交接点要严格对齐,世界探针只负责半径以外。
反射用了表面的运动矢量做重投影。改用命中距离重建反射点,再投到上一帧。
光追用的是简化的 Fallback 网格,和屏幕上的 Nanite 几何不一致(8.5 节)。近处优先用屏幕追踪,并给命中点加适当的偏移。
用了光线追踪管线而不是 Inline 光追。B.2 节讲过,Lumen 只有 Inline 路径才能走异步。
世界缓存由屏幕探针请求、Clipmap 布局、按粗糙度分流反射、硬件光追只取法线和缓存坐标依据 7.10–7.11、8.1–8.5 节与 SIGGRAPH 2022 演讲;接口划分为本文建议
世界缓存
屏幕探针追到半径为止,之后在 Clipmap 里插值世界探针。
反射
粗糙复用探针,光滑单独追 GGX;重投影用命中距离。
硬件光追
把追踪藏在接口后面:只返回实例、位置、法线;Inline 才能走异步。
要点
四块增强彼此独立,按需挑着加。
C.10实现
把它们排成一帧:你自己的 Pass 列表
八个里程碑做完,你手里有十几个 Pass。最后一步是把它们排进一帧:谁先谁后、读什么写什么、哪些可以挪到异步队列。这一步不难,但排错了,bug 会表现为"偶尔闪一下",极难复现。
下面这张表就是一份可以直接照着搭的帧列表。顺序和附录 A.4 的 UE 调用顺序基本一致,队列关系对应 B.2 节的时间线。
| # | Pass | 队列 | 读 | 写 | 频率 | 里程碑 |
|---|---|---|---|---|---|---|
| 0 | 场景更新 | CPU | 物体增删、相机位置、上一帧的页请求 | 卡片描述、捕获清单、脏砖块清单 | 每帧 | M1 |
| 1 | 卡片捕获 | 图形 | 网格、材质、捕获清单 | 卡片材质图集 | 按预算 | M1 |
| 2 | 全局距离场更新 | 计算 | 网格距离场、脏砖块清单 | 全局距离场 | 有改动时 | M2 |
| 3 | 挑页 | 计算 | 页的"最近使用 / 最近更新" | 本帧更新列表 + 间接派发参数 | 每帧 | M5 |
| 4 | 直接光 | 计算 · 可异步 | 材质图集、灯光、阴影图、全局距离场 | 直接光图集 | 按预算 | M5 |
| 5 | 多次反弹 | 计算 · 可异步 | 上一轮最终光照、全局距离场 | 间接光图集 → 合成新的最终光照 | 按预算 | M5 |
| 6 | GBuffer + HZB | 图形 | 场景几何 | 深度、法线、反照率、HZB | 每帧 | (已有) |
| 7 | 放探针 | 计算 | 深度、法线 | 探针位置、有效性 | 每帧 | M6 |
| 8 | 探针追踪 | 计算 · 可异步 | HZB、上一帧场景颜色、全局距离场、最终光照图集、世界缓存 | 探针光线结果 | 每帧 | M6 |
| 9 | 滤波 + 球谐 | 计算 | 探针光线结果 | 探针球谐 | 每帧 | M6 |
| 10 | 积分 + 时间累积 | 计算 | 探针球谐、历史、运动矢量 | 间接漫反射、新历史 | 每帧 | M7 |
| 11 | 反射 | 计算 | GBuffer、探针结果、全局距离场、最终光照图集 | 反射 | 每帧 | M8 |
| 12 | 合成 | 图形 | 间接漫反射、反射、直接光照结果 | 场景颜色 | 每帧 | — |
| 13 | 反馈回读 | 拷贝 | 追踪时写下的页请求 | 下一次 CPU 场景更新的输入(晚几帧到) | 每帧 | M1 |
表里有三处读的是"上一份",它们决定了你需要几份缓冲:
多次反弹读上一轮的最终光照。同一张图集既被读又被写,必须分开:要么最终光照做双缓冲(读 A 写 B,下一帧交换),要么像 Lumen 那样把直接光、间接光分层存,最后单独合成一次最终光照,保证读的时候它已经是完整的上一轮结果。
屏幕追踪读上一帧的场景颜色。这一帧的场景颜色要到合成之后才有,所以只能读上一帧的,并用运动矢量重投影过去。记得除回曝光。
页请求的回读晚几帧才到。GPU 写下的"哪些卡片被用到了",CPU 要等几帧才读得到。整个分页系统要按"请求会晚到"来设计:新物体先用低分辨率常驻页顶着,请求到了再升级。
void RenderFrame() {
SceneUpdate(lastFrameRequests); // 0 CPU:读回的请求是 N 帧前的
CaptureCards(captureList); // 1 图形
UpdateGlobalSDF(dirtyBricks); // 2 计算
pages = SelectPages(budget); // 3 计算,输出间接派发参数
BeginAsync(); // 与下面的 GBuffer 并行
DirectLighting(pages); // 4
Radiosity(pages, finalLighting[prev]); // 5 读上一份,写这一份
CombineFinalLighting(pages, finalLighting[cur]);
EndAsync(fenceA);
RenderGBufferAndHZB(); // 6 图形
Wait(fenceA); // 探针追踪要读完整的最终光照
PlaceProbes(); TraceProbes(sceneColor[prev]); FilterToSH(); // 7–9
IntegrateAndTemporal(history[prev], history[cur]); // 10
Reflections(); // 11
Composite(); // 12
CopyRequestsForReadback(); // 13
Swap(finalLighting); Swap(history); Swap(sceneColor);
}异步队列上的打光还没写完,图形队列上的探针追踪就开始读最终光照了。跨队列的读写之间要有显式的栅栏(fence)。UE 里这件事由渲染图(RDG)按资源依赖自动处理;自己写就要自己画依赖。
多次反弹在同一张图集上边读边写,读到的一半是上一轮、一半是这一轮。双缓冲,或者分层存储后单独合成。
回读延迟期间没有可用的页,查找走了回退。给新物体一个常驻的低分辨率页,或者在加入场景的那一帧同步捕获一次。
异步队列上的工作抢了图形队列需要的寄存器和带宽,或者两边互相等待。B.2 节讲过:异步只是把工作藏进空隙,空隙不够时没有收益。先确认空隙在哪,再决定挪谁。
Pass 划分对照附录 A.4 的代码地图与 B.2 的队列关系;读写表、双缓冲与回读延迟的处理是本文建议
十四个 Pass
场景更新 → 捕获 → 距离场 → 挑页 → 直接光 → 反弹 → GBuffer → 探针放置 / 追踪 / 滤波 / 积分 → 反射 → 合成 → 回读。
三处"上一份"
反弹读上一轮最终光照;屏幕追踪读上一帧场景颜色;页请求晚几帧才回到 CPU。
可异步
直接光、反弹与 GBuffer 并行;探针追踪与阴影、直接光照并行。
要点
跨队列读写一定要有栅栏;同一张图既读又写就要双缓冲。
C.11速查
坑的总表:症状 → 实现层原因 → 修法
第 9 章的排错表是给用 Lumen 的人看的,原因落在资产和参数上。这张表是给写 Lumen 的人看的,原因落在代码上。两张表的症状经常一样,比如"漏光",但这里查的是你的距离场符号、步进阈值和探针插值,不是墙有多厚。
| 症状 | 实现层原因 | 修法 | 详见 |
|---|---|---|---|
| 一出发就命中,画面整体发黑 | 起点没有沿法线偏移,第一步就撞上自己 | 起点偏移约一个体素;屏幕追踪跳过最初几个像素 | |
| 大片区域被判在"墙里面" | 网格不水密或有单面面片,距离场符号判错 | 背面计数判定内外;单面资产当作有厚度的薄壳 | |
| 拉伸过的物体被光线穿过 | 非均匀缩放用了最大轴缩放换算距离 | 用最小轴缩放,保证距离偏小 | |
| 薄墙漏光 | 墙比体素薄,距离场里没有"里面" | 加厚资产,或提高该网格距离场分辨率;扩张只能缓解 | |
| 相机移动时远处有幽灵几何 | Clipmap 滚动没有重算新露出的切片 | 环形寻址,每帧只重算新进入的切片 | |
| 移动物体留下残影 | 动态物体的旧位置没从全局距离场擦掉 | 动静分开缓存,动态砖块每帧标脏 | |
| 卡片接缝发黑 | 双线性采样跨出卡片矩形 | 图集留边,采样钳在缩进半纹素的矩形内 | |
| 金属物体在 GI 里发黑或偏色 | 捕获时把 BaseColor 当成了漫反射反照率 | 存 BaseColor × (1 − Metallic) | |
| 凹槽内没有间接光 | 只有外侧卡片,深度校验全部失败 | 面元聚类生成凹面卡片;先保证有明确回退 | |
| 物体边缘出现别的物体颜色 | 深度容差太松,接受了遮挡面的样本 | 容差按卡片纹素的世界尺寸设定 | |
| 深度断层处有光晕 | 硬件双线性跨越了深度断层 | 手动 4 次采样,每个纹素单独做深度校验 | |
| 没覆盖的地方死黑 | 回退返回 0 | 回退读体素光照或天空,并在调试视图里标出 | |
| 间接光越来越亮直到爆白 | 反馈环能量 ≥ 1:反照率超 1、增强系数、π 用错 | 写入卡片时钳反照率;跑白炉测试 | |
| 整体偏亮但不发散 | 直接光或自发光被加了两次 | 约定每个量只在一处加入 | |
| 间接光随自动曝光闪烁 | 预曝光的屏幕颜色和未曝光的缓存混用 | 读屏幕颜色先除回曝光;历史存未曝光值 | |
| 开灯后间接光慢慢才亮 | 更新预算太小或优先级只看更新时间 | 优先级 = 最近使用 − 最近更新;载入时预热几轮 | |
| 屏幕追踪打中物体背后 | 深度缓冲没有厚度 | 加厚度测试,超过阈值继续走 | |
| 萤火虫 | 极少数光线命中小而亮的光源 | 单条光线亮度上限;跳过无光照材质的命中 | |
| 近处偏亮 | 屏幕追踪失败后从头重新追,同段路径算两次 | 从屏幕追踪的最后有效位置接着追 | |
| 暗色斑块 | 球谐求值为负(振铃) | 结果钳到 0,或降阶、加窗 | |
| 物体移动后拖影 | 历史校验太松 | 深度 + 法线双校验;高速区域缩短累积 | |
| 静止画面有固定噪点图案 | 抖动序列重复或与 TAA 同步 | 光线抖动用独立的蓝噪声或低差异序列 | |
| 镜头切换后几帧渐变 | 切镜没有清历史 | 检测切镜,清空所有历史 | |
| 远处的光从墙后漏进来 | 世界探针隔墙插值 | 世界探针做遮挡判断,或缩小交接半径 | |
| 镜面反射拖影 | 反射用表面运动矢量重投影 | 用命中距离重建反射点再重投影 | |
| 异步计算后偶发 GPU 崩溃或闪烁 | 两个队列之间缺同步,读到写了一半的资源 | 跨队列的读写都要显式同步(UE 里由 RDG 管理) |
把这张表倒过来看,坑集中在三类地方:表示的边界(距离场符号、卡片覆盖、薄几何),数值的约定(单位、π、曝光、重复计数),时间上的假设(历史是否有效、预算是否够、抖动是否独立)。写每一个新 Pass 之前,先问自己这三类问题。
汇总自本附录各节;"异步计算同步"一条参考 Epic 开发者论坛上与 Lumen 异步计算相关的资源状态报错帖
表示的边界
距离场符号、卡片覆盖、薄几何。
数值的约定
单位、π、曝光、重复计数。
时间上的假设
历史是否有效、预算是否够、抖动是否独立。
要点
写每个新 Pass 之前,先问这三类问题。
C.12清单
怎么验证你写对了
GI 的 bug 有一个讨厌的特点:画面"看起来差不多"。偏亮 20%、远处漏一点光、某些角度少一次反弹,肉眼很难判断是 bug 还是本来就这样。所以要从第一天起就准备好能给出数字的验证手段。
一,白炉测试。上一个里程碑的交互图就是它。封闭房间、统一反照率、统一自发光,结果必须收敛到 自发光 ÷(1 − 反照率)。它能一次性抓住能量不守恒、π 用错、重复计数这三类最常见的错误。每改一次打光代码就跑一次。
二,参考对照。写一个暴力的路径追踪器作为"正确答案":同一个场景、同样的材质,每像素几千条光线。本页开场的白盒就是这样一个参考。对照时一次只开一个部件:先关掉多次反弹,只比一次反弹;再关掉屏幕追踪,只比距离场;最后才全开。差异出现在哪一步,bug 就在哪一步。
三,必备的调试视图。下面这些视图值得在写对应功能的同一天就做出来,后面每个 bug 都会用到:
- 卡片图集:直接把图集画到屏幕上,能认出每个物体的反照率、法线和光照。
- 覆盖视图:把场景里没有卡片覆盖的点画成粉色。对应 Lumen 的 Surface Cache 视图。
- 距离场切片:一个可以上下移动的平面,按距离着色,符号颠倒一眼就能看出来。
- 单探针光线视图:点屏幕上一个像素,把它所在探针的 64 条光线和命中点画出来,并标出每条是屏幕命中、距离场命中还是天空。
- 探针放置视图:画出每个探针的位置和有效性。
- 历史有效性:把被拒绝历史的像素标红。拖影和噪点的问题都从这里查起。
- 计数器:每帧捕获了多少纹素、更新了多少页、追了多少条光线、每个 Pass 多少毫秒。
四,五个压力场景。每个都针对一类坑,建议做成自动化截图对比:
| 场景 | 测什么 | 判据 |
|---|---|---|
| 小窗的封闭房间 | 多次反弹、能量 | 和参考对照的平均亮度误差 |
| 一面薄墙隔开亮暗两间 | 漏光 | 暗房间墙根的亮度 |
| 快速穿过画面的物体 | 拖影、历史拒绝 | 物体经过后背景恢复所需帧数 |
| 开关一盏灯 | 缓存追赶 | 间接光到达 90% 所需帧数(5.10 节) |
| 满屏的小自发光 | 萤火虫 | 静止画面逐帧亮度的方差 |
先有尺子,再写系统。没有白炉测试和参考对照,你只能靠"看起来对"来判断,而 GI 恰恰是最会"看起来对"的东西。
本文建议;参考对照可用本页开场的白盒路径追踪器,或引擎自带的离线路径追踪
白炉测试
封闭房间必须收敛到 自发光 ÷(1 − 反照率)。
参考对照
暴力路径追踪;一次只开一个部件比。
必备视图
图集 · 覆盖(粉色)· 距离场切片 · 单探针光线 · 历史有效性 · 计数器。
要点
先有尺子,再写系统。
D
控制面:程序和美术怎么调、怎么分区域
预算放在项目、档位、设备配置、变量四层,归程序;观感放在体积、实例、资产、材质与灯光四层,归美术。体积里的值是倍率,不是预算。
前面的章节和附录讲的是 Lumen 怎么算、怎么写。这个附录讲它怎么被用:一个项目里,渲染程序、技术美术、关卡美术、灯光、过场各自能调什么,在哪一层调,调了会影响到谁。
前四节按 UE 的做法展开:控制面的八层和它们的优先级;后处理体积怎样划分区域、怎样叠加混合;资产、实例、材质和灯光上的开关;以及按角色的分工和护栏。最后一节回到附录 C 的视角:如果你在写自己的 Lumen,控制面应该怎样设计。
D.1设计
控制面的八层:从项目设置到一盏灯
前面讲的都是 Lumen 内部怎么算。真正上项目时,更常被问的是另一类问题:这个效果谁来调?在哪调?能不能只在这间屋子里调?调了之后对别的平台有没有影响?这些问题的答案,取决于控制面是怎么分层的。
UE 给 Lumen 的控制分成八层。越往上越全局、越偏预算;越往下越局部、越偏效果。每一层有它的主人和生效的时机:
| 层 | 在哪 | 典型的 Lumen 设置 | 作用范围 | 谁负责 | 何时生效 |
|---|---|---|---|---|---|
| 1 项目 | Project Settings › Rendering | GI / 反射方法、生成网格距离场、软件追踪模式、硬件光追开关、距离场体素密度 | 整个项目 | 程序 | 多数要重启或重新构建 |
| 2 可扩展性档位 | sg.GlobalIlluminationQuality 等 | Low / Medium(Lite)/ High / Epic / Cinematic 各自的一组变量 | 当前机器 | 程序 | 运行时切换 |
| 3 设备配置 | DeviceProfiles.ini | 某类设备默认用哪一档,再单独覆盖几个变量 | 某类设备 | 程序 | 启动时 |
| 4 控制台变量 | ini、命令行、控制台 | B.3 那张表里的全部开关和预算 | 全局 | 程序(调试) | 立即 |
| 5 后处理体积 | 关卡里的 Post Process Volume | Final Gather Quality、Lumen Scene Detail / View Distance、Max Trace Distance、更新速度、天光泄漏、反射质量与粗糙度上限 | 一块区域 | 关卡美术、灯光 | 每帧,随相机位置 |
| 6 实例 | Actor / 组件的 Details 面板 | Affect Distance Field Lighting、Visible in Ray Tracing、Emissive Light Source | 一个物体 | 关卡美术 | 立即 |
| 7 资产 | 静态网格编辑器的 Build Settings | Max Lumen Mesh Cards、Distance Field Resolution Scale、Fallback Relative Error | 用到这个资产的所有地方 | TA、模型 | 重新构建资产 |
| 8 材质与灯光 | 材质图、灯光属性 | Ray Tracing Quality Switch 节点、灯光的间接光强度 | 一种材质 / 一盏灯 | TA、灯光 | 立即 |
第 2 到第 4 层改的都是同一批控制台变量,所以它们之间有一个优先级:同一个变量被几处同时设置时,优先级高的赢。从低到高大致是:构造时的默认值 < 可扩展性档位 < 游戏设置 < 项目设置 < Engine.ini 的 [ConsoleVariables] < 设备配置 < 游戏覆盖 < ConsoleVariables.ini < 热修 < 命令行 < 代码 < 控制台。
后处理体积和这些变量不是同一套东西。体积里的很多 Lumen 参数是乘在变量上的倍率:比如 Lumen Scene Lighting Update Speed 会去除 r.LumenScene.Radiosity.UpdateFactor,Final Gather Quality 到 6 以上时会把屏幕探针间距再减半。所以美术在体积里调的是"在程序给的预算上放大或缩小多少",而不是绝对的预算。
预算放在上面四层,归程序;效果放在下面四层,归美术。体积里的值是倍率,不是预算。
各层的设置项依据 Epic 文档《Lumen Global Illumination and Reflections》《Lumen Technical Details》《Lumen 性能指南》;变量优先级依据 EConsoleVariableFlags 的 API 文档;"谁负责"一列为本文建议
预算四层 · 程序
项目设置 → 可扩展性档位 sg.* → 设备配置 → 控制台变量
观感四层 · 美术
后处理体积 → 实例开关 → 资产构建设置 → 材质与灯光
谁说了算
默认 < 档位 < 设备配置 < ConsoleVariables.ini < 命令行 < 控制台。后写的高优先级覆盖前者。
体积里的值是倍率
Final Gather Quality 不改变量本身,在变量结果上再调整;≥ 6 时探针间距减半。
D.2交互图
分区域:后处理体积怎么划、怎么混
"能不能只在这间屋子里调"的答案,是后处理体积。它是关卡里的一个盒子,盒子里的设置只在相机进入时生效。Lumen 在体积里的设置,正是美术分区域控制它的主要手段。
几个体积叠在一起时,规则是这样的:
- 无边界(Infinite Extent):勾上后这个体积作用于整个场景,通常放一个当全局底色。
- 优先级(Priority):重叠时优先级高的覆盖低的。两个体积优先级相同时,谁先谁后是不确定的,不要让重叠的体积同优先级。
- 混合半径(Blend Radius):盒子外面一圈过渡带,相机从外往里走时效果逐渐加上去。
- 混合权重(Blend Weight):0 到 1,整体削弱这个体积的作用。
- 逐项覆盖:每个参数前面有一个勾选框,只有勾上的参数才会被这个体积改写,没勾的沿用下层的值。
可以把它想成按优先级从低到高一层一层刷上去:每一层只刷自己勾选的参数,刷多重取决于权重和相机离盒子有多远。下面这张图是一条从室外走进大厅、再走进洞穴的相机路线。
这里有一个 Lumen 特有的地方:体积里的参数分两类。收集类参数(Final Gather Quality、两个更新速度、反射质量)只影响这一帧怎么算,平滑过渡没有问题。表示类参数(Lumen Scene Detail、Lumen Scene View Distance)决定哪些物体进入 Lumen Scene、卡片和距离场覆盖多远,相机跨过边界时会让一批物体进出场景、重新捕获(本条为推断)。表示类参数的边界要放在玩家不容易察觉的地方,混合半径给大一些。
几种常见区域的调法:
| 区域 | 问题 | 调什么 |
|---|---|---|
| 开阔室外(全局底色) | 远处要有 GI,近处要省 | 无边界体积;Lumen Scene View Distance 按场景尺度设;Final Gather Quality 保持默认或略低 |
| 封闭室内、洞穴 | 大空间里漏天光、变得太亮 | Max Trace Distance 和 Lumen Scene Detail 一起调高(官方给出的漏光修法);需要暗角时调 Skylight Leaking |
| 镜面大厅、湿地面 | 反射糊、反射里小物件是黑的 | 提高 Max Roughness To Trace 和反射质量;小物件发黑时提高 Lumen Scene Detail |
| 开关灯的过场 | 间接光慢慢才亮 | 只在这段时间提高两个更新速度,结束后改回默认 |
| 单镜头的高质量截图 | 要最好的反射 | Ray Lighting Mode 设为 Hit Lighting;Final Gather Quality 调高 |
| 性能热点 | 这片区域帧率不够 | Final Gather Quality 下调;必要时降低 Lumen Scene Detail,把小物件移出场景 |
过场镜头和电影相机上也可以挂后处理设置,它们叠加在关卡体积之后。过场里临时改的值,要确认镜头结束后会退回去,不要漏进游戏流程。
优先级、混合半径、混合权重、无边界的含义依据 Epic 文档《Post Process Effects》;各区域的调法依据 Lumen Technical Details 与 Lumen GI 文档;按优先级依次插值的混合模型与"表示类参数切换会触发重新捕获"为本文按引擎行为整理的示意
无边界底色
一个 Infinite Extent 体积、优先级 0,定全图默认。
有边界的区域
按区域加盒子,优先级各不相同;混合半径决定过渡带宽度。
逐项勾选
只勾要改的参数;没勾的沿用下层。"调了没效果"多半是忘了勾。
表示类参数的边界放在转角和门洞
Lumen Scene Detail、View Distance 在过渡带里渐变,会让物体反复进出场景。收集类参数渐变没问题。
D.3速查
资产、实例、材质与灯光上的开关
后处理体积管一片区域,下面这些开关管一个物体、一种资产或一盏灯。它们大多是排查问题时的"手术刀":某一个东西出了问题,只动它,不动整片区域。
| 开关 | 在哪 | 做什么 | 什么时候用 |
|---|---|---|---|
| Max Lumen Mesh Cards | 静态网格 Build Settings | 每个网格的卡片上限,默认 12 | 复杂内景、不规则形状出现粉色;也可以改为拆网格 |
| Distance Field Resolution Scale | 静态网格 Build Settings | 这个网格的距离场精度 | 墙漏光、细节被吃掉;导入时尺寸偏小的网格在这里改,不要靠放大实例 |
| Fallback Relative Error | 静态网格 Nanite 设置 | 硬件光追用的简化网格有多粗 | 硬件光追下出现错误的自遮挡 |
| Affect Distance Field Lighting | 实例 Details | 是否进入软件光追的 Lumen Scene | 某个问题网格(大量重叠、破坏表示)直接移出;植被要在植被工具里勾上 |
| Visible in Ray Tracing | 实例 Details | 是否进入硬件光追的场景 | 同上,硬件光追版 |
| Emissive Light Source | 实例 Details | 小的自发光物体不被剔除 | 小灯片、小屏幕要稳定地照亮环境时 |
| Scene Capture Cache Resolution | 场景捕获组件 | 捕获时 Surface Cache 的分辨率倍率,默认 0.5 | 场景捕获占用太多显存 |
| Ray Tracing Quality Switch | 材质节点 | 给 Lumen 一个单独的材质版本 | 材质用了像素深度、相机位置等视角相关的节点;或者材质太复杂,捕获太贵 |
| 间接光强度 | 灯光属性 | 这盏灯对 GI 的贡献倍率 | 艺术控制。值很大时 GI 会随视角变化,因为屏幕追踪无法正确处理 |
还有三条资产规则,违反了不会报错,只会让画面悄悄出错:
距离场按资产上的材质生成。在实例上把材质换成双面或换了混合模式,距离场还是按原来的生成,两者对不上。
软件光追不支持世界位置偏移(WPO)。用 WPO 摆动的植被、旗帜,在距离场里是静止的形状。
植被和实例化网格只有用 Nanite 时才被支持。
Epic 文档《Lumen Technical Details》与《Lumen Global Illumination and Reflections》(5.8)
资产
Max Lumen Mesh Cards(12)· Distance Field Resolution Scale · Fallback Relative Error
实例
Affect Distance Field Lighting · Visible in Ray Tracing · Emissive Light Source
材质与灯光
Ray Tracing Quality Switch · 灯光间接光强度 · 场景捕获缓存分辨率(0.5)
三条不报错的规则
距离场按资产材质生成;软件光追不支持 WPO;植被和实例化网格要用 Nanite。
D.4分工
程序、TA、美术、关卡:谁管什么
控制面分好层之后,分工就自然了:每一层交给最适合负责它的人,并且给每个人一条护栏,让他们的调整不会越界影响到别人。
| 角色 | 负责的层 | 主要工具 | 护栏 |
|---|---|---|---|
| 渲染程序 | 项目、档位、设备配置、变量 | DefaultScalability.ini、DeviceProfiles.ini、B.3 的开关表 | 每个平台一份预算表(附录 B 的时间线和开关速查),档位之间的差异要能用 profiler 复现 |
| 技术美术 | 资产、材质,以及体积的"推荐值" | 网格 Build Settings、Ray Tracing Quality Switch、调试视图 | 资产规范:墙厚 ≥ 10 cm、复杂网格拆分、不用 WPO 做静态结构;导入检查自动化 |
| 关卡美术 | 体积、实例开关 | 后处理体积、实例 Details | 体积里只用倍率类参数;重叠体积不同优先级;表示类参数的边界放在转角、门洞 |
| 灯光美术 | 灯光、天光、体积里的观感参数 | 灯光属性、Skylight Leaking、Diffuse Color Boost | Diffuse Color Boost 保持在 2 以下(大于 1 就不再物理正确,C.6 的白炉测试演示过它的后果) |
| 过场 | 相机上的后处理、临时的更新速度 | Sequencer、电影相机 | 过场结束后恢复;高质量设置不进入游戏流程 |
一个可以照着走的流程:
- 程序先定预算。每个平台、每个档位的目标帧时间里,Lumen 三个 Pass 各占多少。没有这张表,后面所有人的调整都无从判断。
- TA 定资产规范并自动检查。大多数画面问题出在资产上(第 9 章),规范要在导入时就拦住,而不是等关卡里出问题。
- 关卡美术先放一个无边界的底色体积,再按区域加有边界的体积,每个只勾需要改的参数。
- 出了问题按 9.8 节的路线排查,先看几何和缓存,再动体积,最后才找程序动变量。
- 每个里程碑跑一次性能和画面回归。体积越加越多之后,最容易出现的是某个角落悄悄超了预算。
本文基于前几节的控制分层给出的分工建议;各角色可用的设置项依据 Epic 文档
渲染程序
项目、档位、设备配置、变量。护栏:每个平台一份预算表。
技术美术
资产与材质规范,导入时自动检查。
关卡 / 灯光美术
体积只用倍率类参数;Diffuse Color Boost < 2。
流程
先定预算 → 资产规范 → 底色体积再分区域 → 按 9.8 排查 → 每个里程碑跑回归。
D.5实现
如果你在写自己的 Lumen:控制面怎么设计
附录 C 讲的是怎样把系统写出来。写出来之后,它要交给一个团队用几年。控制面设计得好不好,决定了这几年里美术是在调效果,还是在找程序帮忙。下面五条原则来自 UE 的做法,也来自它踩过的坑。
一,预算和观感分开。每帧多少探针、多少光线、图集多大,这些是预算,归程序、按平台设。美术能调的是观感:在预算上乘一个有上下限的倍率。这样同一个关卡放到低端平台上,美术的设置不会把预算顶爆。
二,参数要分类,分类决定能不能平滑混合。至少分三类:
- 每帧类:只影响这一帧怎么算,比如收集质量、更新速度。可以在区域之间平滑插值。
- 表示类:决定什么东西进入场景表示,比如最小物体尺寸、覆盖距离。改变它会触发重新捕获和重建,要加迟滞,不要每帧插值。
- 全局类:图集大小、追踪方式。只能在启动或档位切换时改。
三,区域按相机求值,不按像素。UE 的后处理体积是按相机位置算出一组参数,整帧共用。这让 GI 的成本可预测,代价是站在门口时,屋里屋外用的是同一组参数。如果你需要真正按像素分区(比如室内外用不同的天光遮蔽),那是另一种设计,要单独存一张区域 ID 图,复杂度高得多。
enum class ParamClass { PerFrame, Representation, Global };
struct GIRegionSettings {
uint32 overrideMask; // 每个参数一位:只改勾选的
float gatherQuality; // PerFrame:倍率,钳在 [0.25, 4]
float lightingUpdateSpeed; // PerFrame:倍率
float maxTraceDistance; // PerFrame
float sceneDetail; // Representation:要加迟滞
float sceneViewDistance; // Representation
};
struct GIRegion { Box bounds; bool unbound; int priority; float blendRadius, weight; GIRegionSettings s; };
GIRegionSettings Evaluate(const vector<GIRegion>& regions, float3 cameraPos, GIRegionSettings base) {
auto sorted = SortByPriority(regions); // 同优先级重叠:在编辑器里报警,不要静默
GIRegionSettings r = base;
for (auto& g : sorted) {
float d = g.unbound ? 0 : DistanceToBox(g.bounds, cameraPos);
float w = g.weight * (d <= 0 ? 1 : saturate(1 - d / max(g.blendRadius, 1e-3)));
if (w <= 0) continue;
for (each param p in g.s.overrideMask)
r[p] = (Class(p) == Representation) ? (w > 0.5 ? g.s[p] : r[p]) // 表示类:取阈值,不插值
: lerp(r[p], g.s[p], w);
}
r.sceneDetail = Hysteresis(prevSceneDetail, r.sceneDetail); // 再加一层迟滞,防止在边界附近来回切
return Clamp(r, PlatformLimits); // 最后统一钳到平台允许的范围
}四,临时覆盖要能自动恢复。过场、截图、调试都会临时改参数。给这类覆盖一个栈:压入时记下旧值,弹出时恢复,而不是让各处代码自己记得改回去。UE 的控制台变量用"谁设置的"来记录优先级,也是同一个思路。
五,让人随时看得到当前生效的值。做一个调试面板:当前相机在哪几个区域里、每个参数最终取了多少、是哪一层给的。美术说"这里怎么变暗了",打开它就能回答一半。
表示类参数在区域边界上被插值或频繁切换,物体反复进出场景、重新捕获。表示类参数取阈值并加迟滞,边界放在玩家看不到远处的地方。
美术在区域里写的是绝对预算(比如固定的探针数),而不是倍率。区域参数一律做成倍率,最后再按平台上限钳一次。
两个区域优先级相同,求值顺序不确定。编辑器里检测同优先级重叠并报警。
临时覆盖没有恢复。用覆盖栈,过场退出时整栈弹出。
更高优先级的一层(设备配置、控制台)已经设了同一个变量;或者区域里没勾选这个参数。调试面板里要显示"这个值来自哪一层"。
本文建议。UE 的对应做法见本附录前几节:分层与优先级、体积按优先级混合、体积参数作为倍率
预算和观感分开
区域只写有上下限的倍率,最后按平台上限钳一次。
参数分三类
每帧类插值;表示类取阈值加迟滞;全局类只在档位切换时改。
按相机求值
成本可预测;要按像素分区,就得另存区域 ID 图。
临时覆盖用栈
过场、截图压入,退出整栈弹出。
调试面板
在哪几个区域、每个值多少、来自哪一层。
同优先级重叠
编辑器里报警,不要静默。