UE5.8 环境光遮蔽与全局光照

📑 目录

小陈把一块岩石摆进洞穴转角,退后两步看视口,岩石底部黑得像被墨水浸过。他拉远摄像机,墙角、石缝、地面凹槽全是同一种死黑。这画面不像洞穴,像模型还没贴图。他下意识去调 Directional Light 亮度,整个场景立刻过曝,远处岩壁白成一片。问题不在主光,而在那些没直接被光打到的区域缺少了什么。

这就是环境光遮蔽与全局光照要处理的事。前者决定缝隙该暗多少,后者决定光线在表面之间反弹多少次。UE5.8 把这两件事拆成了好几条路,每条路的开销和效果都不一样。选错路的代价,可能是场景要么塑料感,要么跑不动。

环境光遮蔽到底在算什么

现实世界里没有绝对亮的阴影缝。一束光打下来,墙角、凹陷、物体相接的地方会比开阔平面暗一些,因为周围环境挡住了来自上半球的天空光和反射光。环境光遮蔽(Ambient Occlusion,AO)就是要在没有真实二次反弹的情况下,用近似算法给这些缝隙补上一层衰减。

它的核心思想很简单:对于场景中的每个可见点 pp,朝上半球若干方向采样,看有多少方向被周围几何体挡住。被挡住的概率越高,这个点收到的环境光就越少。写成积分形式,AO 可以看成可见性函数 V(p,ωi)V(p,\omega_i) 在半球上的平均:

Lo(p,ωo)=Le(p,ωo)+Ωfr(p,ωi,ωo)Li(p,ωi)V(p,ωi)(nωi)dωiL_o(p,\omega_o) = L_e(p,\omega_o) + \int_{\Omega} f_r(p,\omega_i,\omega_o) L_i(p,\omega_i) V(p,\omega_i) (n \cdot \omega_i) \, d\omega_i

上面的式子是完整的渲染方程。AO 并不解完整方程,它只拿 V(p,ωi)V(p,\omega_i) 这一项做近似,把复杂的光照传输退化成一次可见性查询。这个近似很粗暴,但视觉效果往往够用。角落变暗了,物体落底了,画面层次就出来了。

早期的 AO 实现靠离线烘焙。美术把模型导入 Maya 或 3ds Max,跑一遍 Ambient Occlusion 烘焙,把结果存成一张灰度贴图,再贴回模型。这个方法在静态物体上效果不错,但角色一动、场景一拆,贴图就露馅。游戏里需要能跟着摄像机实时变化的 AO。

flowchart TD
    subgraph scene ["Scene"]
        gbuffer["GBuffer"]
        depth["Depth Buffer"]
    end
    subgraph ao ["Ambient Occlusion"]
        ssao["SSAO"]
        gtao["GTAO"]
    end
    subgraph gi ["Global Illumination"]
        lumen["Lumen"]
        lightmass["Lightmass"]
    end
    subgraph output ["Final Image"]
        composite["Composite"]
    end
    gbuffer --> ssao
    depth --> gtao
    ssao --> composite
    gtao --> lumen
    lumen --> composite
    lightmass --> composite

SSAO 与 GTAO:屏幕空间里的两代方案

SSAO 是 Crytek 在 2007 年带进游戏业的。它的思路很直接:只有深度缓冲,没有场景几何,就在屏幕空间里随机采样周围像素,比较深度差,差得大就认为被遮挡。优点是快,缺点是噪点多、没有颜色泄漏、靠近屏幕边缘会裁切,而且远处的 AO 和近处的 AO 用的是同一套参数。

小陈最早的项目用的就是 SSAO。他在控制台里把采样数从 8 拉到 32,噪点少了,帧时间却翻了一倍。模糊半径拉大,接触阴影又糊成一片。他最后妥协在 16 采样加双边模糊,远看还行,近看岩石和地面的接触带还是像浮着。

UE5.8 里 SSAO 已经不是默认选项。新项目打开后,环境光遮蔽默认跑 GTAO,也就是 Ground Truth Ambient Occlusion。GTAO 不再只做二元的遮挡判断,而是估计被遮挡光线的平均入射方向,再和法线、BRDF 做一次点乘。它产出的 AO 带有方向感,暗部过渡更自然,还能顺带输出一个 bent normal,给后续反射采样当方向用。

flowchart TD
    A["项目启动"] --> B{是否需要高质量接触阴影}
    B -->|"是"| C["启用 GTAO"]
    B -->|"否"| D["启用 SSAO"]
    C --> E["调整半径与厚度"]
    D --> F["调整采样数与模糊"]
    E --> G["最终合成"]
    F --> G

GTAO 仍然工作在屏幕空间,所以它继承了 SSAO 的通病:看不到屏幕外的遮挡物。角色把一只手伸出画面,身体其余部分的 AO 不会因此改变。这个问题在开放场景里尤其明显。UE5.8 的缓解办法是把 GTAO 和 Lumen 的屏幕空间追踪做了一点共享,让 AO 在短距离内能借用 Lumen 的场景表示,但长距离遮挡依然没辙。

控制台里可以切回 SSAO,也可以把 AO 关掉:

; Engine/Config/ConsoleVariables.ini 或项目 ConsoleVariables.ini
r.AmbientOcclusion.Method=1          ; 0=SSAO 1=GTAO
r.GTAO.SamplesPerPixel=4             ; 每像素采样数
r.GTAO.MaxTextureSize=512            ; 最大纹理尺寸
r.AmbientOcclusion.ComputeStaticMeshMoving=1  ; 静态网格移动时重新计算

这段配置一共四行,已经能覆盖日常调试。把采样数从 4 提到 8,画面干净一点,主机上要多花 0.3 到 0.5 毫秒。这个数字在 60 帧预算里不是小数。

全局光照:光线不会只照一次

主光打到墙面,墙面变成新的光源,继续照亮墙角和地面。这个二次、三次反弹的集合就是全局光照(Global Illumination,GI)。没有 GI 的场景,阴影死黑,物体之间没有颜色互相影响,就像每个像素只被一盏灯照过一次。

实时渲染里解完整 GI 太贵。渲染方程里的积分要在半球甚至球面上做,每个像素都要采样。离线路径追踪可以 brute force,一帧渲染几小时;游戏必须在 16 毫秒以内给出答案。所以实时 GI 本质上是一套近似方案:要么预计算一部分,要么只算低频部分,要么把反弹次数砍到一两次。

Li(p,ωi)L_i(p,\omega_i) 表示沿方向 ωi\omega_i 到达点 pp 的入射光,frf_r 是 BRDF,nωin \cdot \omega_i 是入射角的余弦。实时 GI 的难题就是如何在有限采样下估计 LiL_i。离线渲染用蒙特卡洛积分,样本成千上万;实时渲染可能只有几十甚至几个样本,方差大到画面闪烁。

UE5.8 提供两条主要实时 GI 路线:Lumen 负责动态场景,Lightmass 负责静态烘焙。它们不是互斥的,很多项目会把静态烘焙作为基础,再用 Lumen 补动态物体。这种混合做法在性能和质量之间取了中间值。

Lumen:把动态 GI 塞进实时帧

Lumen 是 UE5 的招牌功能之一。它让动态物体、动态光源都能产生可信的间接光,不再需要烘焙。原理是把场景表示成两层:近距离用网格距离场(Mesh Distance Field,MDF),远距离用全局距离场(Global Distance Field,GDF)。光线先在屏幕空间步进, miss 了再进距离场,远距离还 miss 就 fallback 到天空光或反射探针。

小陈后来接了一个废弃地铁站关卡。列车来回移动,顶灯闪烁,如果走 Lightmass,每次调灯都要重新烘焙,反复调整的速度慢到崩溃。他改用 Lumen,灯一换,整个隧道的反射和颜色泄漏立刻跟着变。墙面锈迹的红色会映到钢轨上,水洼会把顶棚的冷光反射回天花板。这些细节靠 SSAO 做不出来。

sequenceDiagram
    participant Cam as Camera
    participant RDG as RenderGraph
    participant SDF as "SDF Scene"
    participant RT as RayTracing
    participant Tile as TilePool
    Cam->>RDG: 提交场景描述
    RDG->>SDF: 查询最近表面
    SDF-->>RDG: 返回命中点
    RDG->>RT: 发射漫反射射线
    RT-->>Tile: 写入辐射度缓存
    Tile-->>RDG: 返回光照结果
    RDG->>Cam: 合成到最终像素

Lumen 的追踪流程大致如上图。Camera 把可见像素告诉 RenderGraph,RenderGraph 用 SDF 找最近表面,再发射射线。命中表面后,把辐射度写进 Tile Pool 的缓存,最后合回图像。这个管线在 GPU 上跑,大部分工作都在 Compute Shader 里完成。

但 Lumen 不是免费的。小陈在 Base PS5 上测,开 Lumen 漫反射和反射,帧时间从 12 毫秒涨到 17 毫秒。他最后把反射质量降到 Final Gather 的一半,又关掉远景射线,才压回 14 毫秒。PC 高端卡能扛住全开,主机和移动端必须做取舍。

Lumen 还有一个限制:它擅长中低频 GI,也就是颜色泄漏和柔和阴影。高频细节,比如物体之间精确的接触阴影,仍然要靠 AO 或虚拟阴影图来补。这就是为什么 UE5.8 默认同时开 GTAO 和 Lumen:一个管缝隙,一个管反弹。

Lightmass:离线烘焙的老手艺

在 Lumen 出现之前,UE 的 GI 主要靠 Lightmass。Lightmass 是一套离线烘焙系统,把静态光源、静态网格的光照结果预先计算成光照贴图(Lightmap),运行时只采样贴图。它的优点是质量极高、运行时开销极低;缺点是只能处理静态物体,光源或模型移动就要重新烘焙。

graph LR
    A["静态网格体"] --> B["光照贴图 UV"]
    B --> C["构建光照"]
    C --> D[Lightmass]
    D --> E["烘焙纹理"]
    E --> F["运行时采样"]
    F --> G["静态 GI"]

Lightmass 的工作流程如上图。美术要保证每个参与烘焙的静态网格都有合法的光照贴图 UV,不能重叠、不能出界。UV 第二通道专门留给 Lightmass,第一通道可以照常做材质。烘焙时,Lightmass 把场景切分成很多小 patch,用辐射度算法或路径追踪计算每个 patch 收到的间接光,最后把结果打包进纹理。

UE5.8 里 Lightmass 已经进化为 Lightmass GPU,用显存换时间。以前在 CPU 上烘焙一个复杂室内要几十分钟,现在高端显卡上几分钟就能出结果。Lightmass GPU 还支持更精确的间接阴影和颜色泄漏,但内存占用也更高。烘焙一个大型关卡,显存低于 12 GB 会频繁报错。

小陈的一个前辈做过 VR 项目,全程用 Lightmass。理由很简单:VR 要求 90 帧,Lumen 再优化也扛不住。他们把动态物体单独拆出来用 Movable 光源和反射探针,静态环境全部烘焙。画面不如 Lumen 灵动,但帧率稳如老狗。

烘焙与动态:两条路的账本

选 Lightmass 还是 Lumen,要看项目里什么东西会动。建筑可视化、VR、静态关卡,Lightmass 是稳妥选择。开放世界、昼夜循环、可破坏场景,Lumen 更合适。很多项目其实是混合:地形和建筑静态烘焙,角色和载具走 Lumen。

这个选择背后是内存、显存、修改节奏和画面质量的四角博弈。Lightmass 吃烘焙时间和贴图内存,运行时几乎不增加帧时间。Lumen 吃运行时 GPU 算力,但省掉大量贴图内存,反复调试也快。移动端两者都受限,往往只能用最便宜的方案:烘焙光照贴图加 SSAO,反射靠立方体贴图。

小陈的团队在立项时争论了两周。策划想要完全动态的天气系统,美术担心 Lumen 在低端机上跑不动。最后拍板:PC 和主机用 Lumen,移动端用烘焙 Lightmap 加反射探针。UE5.8 的可扩展性让这种分层成为可能,同一套关卡资产,在不同平台走不同的渲染路径。

反射也是间接光

反射常被单独拿出来说,但它本质上是 GI 的一部分。一束光打到光滑表面,按镜面方向反射出去,眼睛里看到的就是反射图像。粗糙表面则把入射光打散,形成模糊的反射,也叫 glossy reflection。

Lumen 的反射和漫反射 GI 走的是同一套追踪管线,但采样策略不同。反射更关注镜面方向附近的小锥形区域,漫反射要覆盖整个半球。UE5.8 里可以单独开关 Lumen Reflections,也可以在材质里用 Roughness 控制反射模糊度。Roughness 接近 0 时走镜面反射,接近 1 时退化成漫反射。

传统方案用反射探针(Reflection Capture)或屏幕空间反射(SSR)。反射探针是预烘焙的立方体贴图,便宜但静态,视角移动时反射内容不会变化。SSR 只能反射屏幕上已有的像素,镜头一转,反射就穿帮。Lumen 反射解决了这两个问题:动态、无穿帮、支持粗糙表面。

在移动端,反射探针依然是主流。美术会把关键区域多放几个 Sphere Reflection Capture,让金属和水面有基本的环境反射。再叠加一层 SSAO 补接触阴影,画面就能及格。这个组合和 UE5.8 的高端管线差距明显,但开销是可控的。

平台与性能:移动端怎么活

UE5.8 的完整 AO + GI 管线是为高端 PC 和主机设计的。Nanite、Lumen、Virtual Shadow Map 一起开,RTX 4080 能跑 4K 60 帧,但移动端直接崩溃。所以 UE5.8 在移动和低端平台上有另一套回退路径。

Android 和 iOS 默认关闭 Lumen,改用 baked Lightmap 加 SSR 或反射探针。AO 用 SSAO 而不是 GTAO,因为 SSAO 的 Compute Shader 负载更低。材质的 Roughness 也会做限制,避免大量模糊反射采样。虚幻把这些设置打包成可扩展渲染层级(Scalability),CVar 一键切换。

小陈在骁龙 8 Gen 3 上跑过一个测试场景。开 Lumen 时帧率 18 帧,关掉 Lumen、开烘焙 Lightmap 和 SSAO,帧率回到 45 帧。再把 SSAO 采样降到 4,反射探针分辨率减半,帧率稳在 55 帧。画质和高端 PC 没法比,但作为手游已经够看。

PC 上也不是无脑全开。光追加速卡可以用硬件 RT 代替 Lumen 的软件距离场追踪,画面更准,但 BVH 构建和遍历都有开销。UE5.8 支持混合模式:用硬件 RT 做镜面反射,用 Lumen 做漫反射 GI。这种配置在 RTX 30 系以上表现不错。

组合策略:别让单一技术背锅

实际项目中很少有人只开 Lumen 或只用 Lightmass。更常见的做法是分层:静态大环境用 Lightmass 铺底,动态角色和可动物体用 Lumen 补光,缝隙和接触阴影交给 GTAO,高光反射再用反射探针或 Lumen Reflections 收尾。每一层只负责自己擅长的事。

小陈的地铁站项目最终就是这样。隧道墙体和铁轨是 Static,烘焙成 Lightmap;列车是 Movable,走 Lumen;月台上的积水和金属扶手用 Lumen Reflections;墙角杂物和碎石堆用 GTAO 压暗底部。四层叠加下来,帧时间 13 毫秒,画面比全开 Lumen 更稳。

调这种分层时,他有几个习惯。先看 RHI 线程的 GPU 耗时,确定瓶颈在 AO、GI 还是反射。如果 AO 占比高,就降 GTAO 采样或切回 SSAO。如果 GI 耗时高,就减 Lumen 的 Trace Distance 和 Final Gather 质量。反射贵,就把粗糙表面的反射强度压低,或者把 Lumen Reflections 的 Max Roughness 从 0.4 降到 0.2。

还有一个常被忽略的点:AO 和 GI 的强度会互相打架。GTAO 压暗的缝隙,如果 Lumen 的漫反射又把它提亮,接触阴影就会变浅。小陈会在 Post Process Volume 里把 AO 强度降到 0.6 到 0.8,让 Lumen 负责大面明暗,AO 只保留细节凹陷。这个比例没有标准答案,要靠场景来回试。

UE5.8 的 RenderDoc 和 Unreal Insights 能直接抓到这些开销。建议每次只改一个开关,测三组数据:关、默认、最高。这样不会被多个变量同时变化搞糊涂。Profiler 里的 Lumen 和 AO 条目非常细,把时间花在看懂这些数据上,比盲目拉采样数有用得多。

几个容易踩的坑

调试 AO 和 GI 时,有几个问题会反复出现。

GTAO 的半径设置过大,会把不该暗的区域一起压暗,远景看起来像蒙了一层灰。半径过小,接触阴影又太短,物体像飘着。小陈的经验是:室内场景半径 0.5 到 1.0 米,室外开阔场景可以提到 2 到 3 米,然后按摄像机距离微调。

Lumen 的屏幕空间追踪在植被和透明物体上经常 miss。树叶、玻璃、粒子这些表面要么没进 SDF,要么在屏幕空间被遮挡,追踪结果会漏光或出现黑色噪点。遇到这种材质,可以考虑标记为 Non-Lumen 或用反射探针补光。

Lightmass 烘焙后出现接缝,多半是因为光照贴图 UV 有重叠,或者 Lightmap Resolution 太低。把有问题的网格单独提高分辨率,再检查第二通道 UV,通常能解决。如果整个关卡都花,说明 UV 展开阶段没做好,返工成本很高。

反射探针放得太少,金属表面会出现突然切换的反射内容;放得太多,又浪费内存和烘焙时间。小陈的做法是在金属密集区域每隔十米放一个 Sphere Reflection Capture,空旷区域只保留一个大的 Sky Light Capture。

写在最后

小陈后来再看那个洞穴转角,已经能分出是哪一层在起作用。GTAO 让岩石和地面的接触处沉下去,Lumen 让岩壁的暖光反弹到石缝里,反射探针给水洼补了一层天光。没有哪一项单独解决所有问题,它们像几个工人一起搭架子。

UE5.8 给了很多开关,也给了很多默认配置。真正要理解的不是每个 CVar 的值,而是这些技术背后的假设:屏幕空间算法快但视野受限,离线烘焙质量好但静止,Lumen 动态但吃算力。知道这些假设,才能在面对具体场景时,快速选出不会翻车的组合。

下一次你把模型摆进场景,发现某个角落黑得不自然,别急着拉灯光亮度。先看看 AO 的半径是不是太大,再看看 GI 是烘焙还是动态,最后检查反射有没有漏掉。多数时候,问题就出在这三个地方。