从”普通程序员能听懂”到”能改 UE 光照源码”——基于源码逐行考证,六条主线全覆盖:
烘焙流程 / 自动化展开 UV / 压缩 Lightmap / 提高填充率 / 把 UE 当烘焙器 / Unity 场景导入
(材质、灯光、地表对齐)

  • 文档版本:1.11(2026-09-18)
  • 1.1 更新:新增第 23 章 渲染管线篇(二)——从顶点到像素的采样全链路逐行拆解(顶点 UV 变换 / 参数到达两条路 / 最多 5 次采样 / 全彩直进 SceneColor / GBuffer 亮度的真实消费者);并据此校准第 22 章 22.4关于 DiffuseIndirectComposite 的表述
  • 1.2 更新:新增第 24 章 答疑篇(四)——压缩与动态光照(还能不能再压缩:五档手段与代价;太阳会动/环境色变化:Stationary 太阳 / 中性烘焙+运行时染色 / Lighting Scenarios / 动态 GI / 混合方案);并修正 23.10 避坑 1(Lumen 与 lightmap 的让位机制)
  • 1.3 更新:新增第 25 章 概念篇(二)——Stationary 的三轨模型(两个判据函数 HasStaticLighting/HasStaticShadowing、阴影双轨的距离 lerp、4 通道预算、位置不可移动与移动端的坑),把第 24 章”动态光照”一节的底层机制讲透
  • 1.4 更新:新增第 26 章 编码篇(二)——HQ/LQ 怎么压、为什么能这么压(位预算总账、编码侧逐行 QuantizeLightSamples、解码侧可逆性对照、七条压缩原理、残差通道的 16bit 等效精度)
  • 1.5 更新:新增第 27 章 编码篇(三)——SkyOcclusion 那张图能不能压(方向+可见度打包进一个 RGBA8、W×H 半高布局与 ScaleLightmapUV(uv,(1,2)) 的由来、CompressionNoAlpha 如何决定它只能压到 8bpp、与 HQ/LQ/AOMask 四图开销对比、VT 路线换轨与 r.VT.EnableLossyCompressLightmaps)
  • 1.6 更新:新增第 28 章 体积光照图篇——Volumetric Lightmap(表面光 vs 空间光、IndirectionTexture + 砖图集的两层 3D 纹理、BrickSize+1 的 padding 与采样公式逐行、环境项当分母的 SH 归一化与三处一致的反归一化表、ShouldRefineVoxel 的三条自适应细分理由、Density Volume 的三档 mip、WorldSettings 参数总账、GPU 逐像素与 CPU 逐对象两条消费路、29~33 字节/体素的显存总账,以及”它出生就是压完的样子”)
  • 1.7 更新:新增第 29 章 答疑篇(五)——单盏灯变色 / 开关,lightmap 能支持到哪一步(lightmap 存的是「和」不是「清单」:LightGuids 只是收据 / 纹素没有每灯一格;运行时总闸 AreDynamicDataChangesAllowed() 让 Static 灯的 setter 静默失效;PostEditChangeProperty 排除表里那条决定性注释——Intensity/LightColor 只对 Static 触发重烘;改属性 → UpdateLightGUIDs() 换 GUID → ContainsLight 失配 → 未烘焙交互被动态预览;GetStaticInteraction 四档与 IrrelevantLights;Visible 在排除列表里导致的开关灯坑;IndirectLightingIntensity 是烘焙期旋钮;以及四条可落地的关灯方案)
  • 1.8 更新:新增第 30 章 阴影篇——Static / Stationary 的阴影是怎么烘出来、怎么被用掉的(先破一个直觉:Static 灯根本没有阴影贴图——可见度在 TextureMapping.cpp:1721 被 AddWeighted(DirectLighting, AdjustedShadowFactor) 直接乘进辐照度,独立通道一字节都没有;Stationary 灯才有 FShadowMap2D 与 4 通道预算。CPU Lightmass 默认走有符号距离场五步(低分辨率可见度 → 奇数上采样因子 → 只标记过渡带 → 高分辨率采样 → 散射),编码上 0.5 恰好是阴影边界;GPU Lightmass 则明写着 // TODO: implement SDF,只存 128 spp 的 sqrt(可见度)。落盘时 CompressionNone = true 是硬编码,2 个通道就要付 4 个通道的 4 B/纹素(NumShadowChannelsUsed 是对整张图集取 max)。运行时是 GBuffer 里 4 个数 + 一次 dot(mask)。最后对「合并进 LQ 的 A 通道」做完整账本:5.0 B → 2.0 B(改 BC3)/ 1.5 B(独立 BC4 层),并指出「达到 Static 的品质」在语义上等价于交出逐灯可分辨性)
  • 1.9 更新:更正第 30 章 30.8——原分析把「让 Stationary 阴影达到 Static 的品质」与「不要每灯一个通道、压进 LQ 的 A」当成同一个问题,实为两个正交问题。新增 30.8.7:① 补上决定性事实 TextureMapping.cpp:1702-1722 的 if/else——Stationary 灯的直射光一个字节都不进 lightmap(只把可见度写进 shadowmap),Static 灯才折进辐照度;因此把可见度搬进 alpha 不会伤到间接光,且 Stationary 灯因 HasStaticLighting() 为假仍走延迟渲染;②「Static 品质」引擎早已支持——勾 bUseAreaShadowsForStationaryLight 后就走与 Static 灯同一个 CalculateDirectAreaLightingTextureMapping,仅把结果编码成 Distance = sqrt(V)、PenumbraSize = 1(也解释清了 GPU LM 那个坑的成因);③ 更正后的结论:单主光场景是零误差纯赚(4.0 B → 净省 3 B/纹素,移动端 ASTC 净省 4 B 且少一次采样),多灯才需要合并并付出逐灯可分辨性
  • 1.10 更新:新增 30.11 追补:「可见性」到底是什么——从 GPU Lightmass 的一次采样说起。钉死可见性的精确定义:从着色点看过去、光源表面按立体角加权后未被遮挡的占比(不是二值判断,是连续覆盖率)。逐行给出 GPU Lightmass 的算法——LightmapPathTracing.usf:1436-1482 每次 dispatch 往光源表面上的随机一点打一根 any-hit 光线(RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH + IsMiss() → 单样本非 0 即 1,软阴影来自「多条光线打在光源不同位置」的统计而非单线部分透过),光线方向按灯型分派(方向光在 LightSourceAngle 锥内采样;点光走 SinThetaMax² = R²/d² 的均匀立体角采样),整数域累加后由 LightmapBufferClear.usf:71 归一成 V = 命中数/样本数 并存 sqrt(V);CPU Lightmass 的 CalculatePointAreaShadowing 返回 FVector2f(可见样本, 总样本)、V = X/Y,定义逐位相同,差别只在半影区要不要加密采样一遍。并指出「按 Static 一样存可见性」有两种读法:读法 A(每灯各存各的 V)= 现状,勾 bUseAreaShadowsForStationaryLight 即可;读法 B(合成一个 V 进 alpha)= 合并方案。最后补两个只在合并时才遇到的陷阱:必须在线性域合并再 sqrt(否则 Jensen 不等式导致半影系统性偏亮)、Coverage 的布尔语义必须改成权重和
  • 1.11 更新:新增 30.12 追补:如果真要落地——改哪几处代码。从「要不要给一盏灯单独开阴影通道」的总闸讲起——Lightmass.cpp:243-252 里 HasStaticLighting() / HasStaticShadowing() 分别打出 GI_LIGHT_HASSTATICLIGHTING 与 GI_LIGHT_STORE_SEPARATE_SHADOW_FACTOR,而后者正是 TextureMapping.cpp:805-809 那五个条件之一,决定这盏灯拿不拿独立通道。指出第一个坑:不能简单地让它掉进 Static 那条分支(:855),因为 Stationary 灯 HasStaticLighting() 为假、运行时还要再算一遍直射光,折进辐照度会让房间亮一倍——必须在 :1702 的 if/else 里加第三支(既不写通道也不折进辐照度,只累进合并图)。合并点是 :707-708 那两张 per-light TMap(全仓库唯一一处同时握着所有灯的可见度图);量化顺序给出「量化前后两条路」,共同点是先平方回线性域再合并、最后才 sqrt。存储端要把 LightMap.cpp:1640 的 CompressionNoAlpha 关掉(LQ 的 A 位今天是废的,LightmapCommon.ush:100 注释原话 Alpha doesn't matter, will scaled by zero),并显式跳过不再需要的 EncodeShadowMapTexture。运行时给出两种改法,推荐把标量 V 广播成 half4(GetShadowTermsBase 一行不用动),并点出必须一起改的三处——尤其 ShaderMaterialDerivedHelpers.cpp:54 的 WRITES_PRECSHADOWFACTOR_ZERO,不改会让 GBuffer 写 0 导致整个场景全黑;移动端 GetPrimaryPrecomputedShadowMask 有自己一份解码,必须同步改。最后补 4 处容易漏的语义变更(bIsCompletelyOccluded / Coverage / GPU LM 的 ChannelIndex 与样本数 / ShadowExponent 与逐灯可分辨性一起消失)与 8 条回归验证清单。
  • 源码快照:本地仓库 d:\Project\GameDevelop\UnrealEngine,UE 5.9 移动优化 fork,HEAD 6cea9bd20f8f
  • 写作原则:文中所有”源码考古”块引用的文件路径、行号、函数名均在本仓库中实际验证过;行号只用于定位,请以 路径:函数名 为准
  • 姊妹文档:本文是《Nanite》《VSM》《UE 网络架构》《空间音频》之后的第五篇——前四篇是纯 GPU/纯实时主题,本篇是第一条离线管线主线(CPU 构建 + GPU 渲染消费 + 跨引擎工作流)
  • 符号说明:Lightmap=光照贴图,Bake=烘焙,Texel Density=纹素密度,Atlas=图集,Chart=UV 岛,Padding=边距,Packing=装箱,Tile=瓦片;HQ/LQ/Scale/Add/SH 不翻译

第 0 章 导读:这份文档怎么读

TL;DR|这份文档把 UE Lightmap 拆成六条主线:烘焙流程(GPU Lightmass 怎么把场景
“烤”成光照贴图)、自动化展开 UV(让引擎替你摊橘子皮)、压缩 Lightmap(内存瘦身)、
提高填充率(拼图利用率)、把 UE 当烘焙器(离线光照工厂)、Unity 场景导入
(材质/灯光/地表对齐)。心智模型:Lightmap = 给场景”烤”一层光照贴图——离线预计算,
运行时廉价读取
。三个深度档位:30 秒看懂、半天学会烘焙、1 天读源码。

0.1 目标读者与前置知识

目标读者是普通程序员:会摆场景调灯光、知道 UV 与纹理贴图是什么、见过编辑器里”Build Lighting”转圈。每个术语第一次出现都配一句直觉解释。

前置知识三个:

  1. UV 与纹理:模型表面的”贴图坐标”——光照贴图也是贴图,同样需要 UV;
  2. 场景灯光与移动性:Static/Stationary/Movable 三态(第 1 章细讲);
  3. 烘焙(Bake):离线预计算光照,把结果存成贴图——本篇的一切围绕它展开。

与前三篇的关系:Nanite/VSM 讲实时渲染,空间音频讲听觉,网络讲多机——本篇讲**”光照怎么提前算好”**,与实时 GI(Lumen)是互补关系(第 1 章对比)。

0.2 三条阅读路径

通道 读什么 耗时 读完能做什么
🚀 30 秒通道 每章 TL;DR + 第 2 章概念类比 + 图 F1 10 分钟 跟人讲清 Lightmap 六条主线与”烘焙=拍照片”心智模型
📚 标准通道 TL;DR + 正文 + 第 7~12 章(六专题,占全文 54%) 6~8 小时 会完整烘焙关卡、会开自动 UV、会压内存、会查填充率、能搭离线烘焙流水线、能把 Unity 场景迁进 UE
🔬 源码考古通道 全部 + 源码考古块 + 附录 A 对照 1~2 天 敢打开 GPULightmass/ 与 LightMap.cpp 对照着读

0.3 块标记约定

与前四篇一致的五种块:

  • **TL;DR**(每章开头):30 秒读完本章,3~5 句、必须含类比名、不含源码路径;
  • **类比实验室**(正文小节):用普通程序员已懂的东西讲陌生概念;
  • **源码考古**(每章末尾,折叠块):贴真实源码(≤25 行/段),逐行翻译;
  • **避坑**(正文穿插):版本差异、已删除项、公开资料不一致;
  • **动手实验室**(第 7~12 章):配方型内容——步骤|资产/设置|验收标准 表 + 检查清单。

0.4 一张图看懂全局

图 F1 Lightmap 完整生命周期七段(从场景设置到屏幕显示;贯穿全文的主干图)
① 场景设置 灯光 Mobility 三态 Lightmap UV 检查 世界设置烘焙参数 分辨率/纹素密度 (第 7 章) ② GPU Lightmass Build Lighting 触发 tile 路径追踪 辐照度缓存/去噪 逐 tile 渐进收敛 (第 3 章) ③ 编码 RGBA16F → 8bit 量化 HQ=LogLUVW + SH Simple=LogRGB Scale/Add 归一化 (第 4 章) ④ Atlas 装箱 编辑器侧 skyline 装箱 网格侧 FLayoutUV 填充率/边距 CoordinateScale/Bias (第 5/10 章) ⑤ 压缩与存储 bCompressLightmaps BC7 / 移动端 ASTC MapBuildDataRegistry 流送(0.2 因子) (第 6/9 章) ⑥ 运行时采样 BasePass 读 Lightmap GetLightMapColorHQ 解码 Scale/Add + SH 体积光照图插值 (第 6 章) ⑦ 消费端 游戏画面显示 移动端/桌面 烘焙产物导出 (第 11 章)

第 1/7 章
第 3 章
第 4 章
第 5/10 章
第 6/9 章
第 6 章
第 11/12 章

跨引擎工作流(Unity→UE):第 12 章——FBX/USD/Datasmith/GLTF(经 Interchange)导入 + 材质/灯光/地表对齐
UE 当烘焙器:第 11 章——命令行烘焙 / 分层烘焙 / 产物导出

读图方法:场景设置好了 → GPU Lightmass 逐 tile 路径追踪”烤”出光照 → 编码成 8bit 系数 → 装箱进 Atlas → 压缩存储 → 运行时 BasePass 廉价采样 → 显示。①~⑤ 是离线(构建期),⑥⑦ 是运行时——这就是”预计算”的全部含义。

0.5 章节地图

篇 章节 一句话
开篇 第 0~1 章 怎么读 + 静态光照的”预计算”哲学(Lumen vs Lightmap)
概念篇 第 2 章 六大概念速览(每个一个类比)
机制篇 第 3~6 章 烘焙管线 → 编码 → 装箱 → 运行时采样与流送
专题篇 第 7~12 章 六条主线(用户重点,占 54%)
调优篇 第 13~14 章 CVar 速查与常见坑 / 局限与展望
答疑篇 第 15 章 材质与 Lightmap 答疑(材质参与/半透/自发光/查看/导出 UV)
实战篇 第 16 章 跨引擎烘焙产线通用方案(实战项目提炼:单位/坐标/灯光/headless 烘焙/导出)
答疑篇(二) 第 17 章 参数解析 / 通道全景 / 漏光优化
校准篇 第 18 章 光照对齐校准与产物导出(引擎↔UE 数值等价链,源码验证)
答疑篇(三) 第 19 章 Instance 支持与 GPU Driven
格式篇 第 20 章 贴图格式与 UV 接缝全解(种类/上下半区/编码链/UV 要求/接缝)
导入篇 第 21 章 顶点重排 / 重叠顶点 UV / BuildData 全解(顶点实例化与重排/岛判定五铁律/MapBuildDataRegistry 生命周期与坑)
编码篇(二) 第 26 章 HQ/LQ 的定点压缩原理(对数亮度/色度分离/残差补精度/Min-Max 归一化/块压缩 + 可逆性对照表)
编码篇(三) 第 27 章 SkyOcclusion 那张图能不能压(方向+可见度打包 / W×H 半高布局 / alpha 决定压缩格式 / 4B→1B 的来路 / VT 换轨)
体积光照图篇 第 28 章 空间里的光(IndirectionTexture + 砖图集 / BrickSize+1 padding / 环境项归一化 SH / 自适应细分 / Density Volume 三档 mip / 29~33 B 每体素)
答疑篇(五) 第 29 章 单盏灯变色 / 开关(lightmap 存的是”和”不是”清单” / AreDynamicDataChangesAllowed 总闸 / 编辑器排除表的三条决定性注释 / GUID 换新与未烘焙预览 / GetStaticInteraction 四档 / 关灯的四条路)
阴影篇 第 30 章 Static / Stationary 的阴影怎么烘、怎么用(Static 无阴影贴图 / 面积光软阴影的来源 / SDF 五步与 0.5 编码 / GPU LM 的 TODO: implement SDF / 不压缩的 4 B 与「2 通道付 4 通道钱」/ GBuffer 一次 dot / 合并进 LQ alpha 的账本与语义代价 / 30.8.7 更正:Stationary 直射光一个字节都不进 lightmap / 30.11:可见性的精确定义与合并陷阱 / 30.12:落地改造清单)
概念篇(二) 第 25 章 Stationary 的三轨模型(直接光实时 / 间接光烘焙 / 阴影双轨 + 4 通道预算 + Mobility 决策表)
答疑篇(四) 第 24 章 还能不能再压缩 / 太阳会动与环境色变化怎么办(五档瘦身手段、运行时染色钩子、Lighting Scenarios、Lumen 让位机制、昼夜循环决策树)
渲染管线篇(二) 第 23 章 采样全链路逐行拆解(帧管线定位/顶点 UV 变换/参数到达两条路/最多 5 次采样/全彩直进 SceneColor/GBuffer 亮度真实消费者/CPU 侧渲染调度与密度视图/Nanite 与半透明支线)
渲染管线篇 第 22 章 Lightmap 在渲染管线里怎么被画出来(BasePass 采样链/GBuffer 亮度编码/静态阴影因子/DiffuseIndirect 合成/移动端直加)
附录 A~D 源码地图、术语表、参考资料、自测题

第 1 章 Lightmap 是什么:静态光照的”预计算”哲学

TL;DR|Lightmap 是”给场景烤一层光照贴图“:把静态光照离线算好存成纹理,
运行时每像素读一下就行——把昂贵的实时光照计算换成廉价的贴图采样。Lumen 是
实时全局光照(贵但动态),Lightmap 是离线预烘焙(便宜但静态),Mobility 三态
(Static/Stationary/Movable)决定了灯光参与哪种方案。

1.1 烘焙 = 拍照片

类比实验室:拍照片
烘焙就像给场景”拍照片”:相机(光照求解器)花很长时间拍(计算),洗出来的照片
(Lightmap 贴图)被贴到模型表面,运行时”看照片”(采样贴图)——不重新拍。
照片是死的:灯移动了照片不会变;但看照片几乎零成本。这就是预计算的全部哲学:
把贵的计算挪到离线,把廉价的结果留给运行时
。

1.2 为什么需要预计算

一个静态场景(建筑、地形、道具)的光照其实不会变——灯不动、物体不动,光照结果就是确定的。实时重算它是浪费:每帧算全局光照 = 每帧都要昂贵的路径追踪/辐射度。预计算让”确定的结果”只算一次:

  • 质量:离线可以跑几百采样/像素(GPU Lightmass 默认 GISamples=512),实时 GI 只能给几十;
  • 成本:运行时每像素一次贴图采样(几十纳秒)vs 实时 GI 每像素几十微秒;
  • 代价:静态(灯动/物体动都需要重新烘焙)、存储(贴图占空间)。

1.3 Lumen vs Lightmap:实时 GI vs 离线烘焙

Lumen(实时 GI) Lightmap(离线烘焙)
计算时机 每帧实时 构建期离线
质量 好(但受帧预算限制) 极高(数百采样/像素)
动态物体 ✅ 支持 ❌ 静态
移动端 重(通常关) 轻(贴图采样)
存储 无额外 贴图占用
适用 动态光照游戏 静态场景/移动端

决策树:移动端/静态场景 → Lightmap;动态光照/高端 PC → Lumen;混合(大多数游戏)→ 静态部分 Lightmap + 动态物体 Lumen/动态光。

1.4 Mobility 三态:灯光的三档开关

ENetRole 之外,灯光还有 Mobility(Engine/Classes/Engine/EngineTypes.h 的 EComponentMobility):

Mobility 行为 Lightmap 参与
Static 完全静态(烘焙全量) ✅ 全烘焙(阴影/间接光都进 Lightmap)
Stationary 位置固定但亮度可变 ✅ 烘焙间接光 + 动态阴影遮罩(ShadowMapChannel)
Movable 完全动态 ❌ 不烘焙(Lumen/动态阴影)

烘焙的本质:只有 Static/Stationary 灯光参与 Lightmap 烘焙;Movable 灯完全走实时。场景里全是 Movable 灯 = 烘焙了个寂寞(第 7 章检查清单第一条)。

1.5 体积光照图(Volumetric Lightmap)

VolumeLightingMethod(WorldSettings.h:123)——除了表面贴图(Lightmap),烘焙还产出体积光照图:稀疏 3D 网格(brick)存 SH 光照,供动态物体(角色/粒子)在静态光照环境中采样——“静态场景里的动态角色也有正确光照”。VolumetricLightmapDetailCellSize=200(:159)控制网格密度。

1.6 一点历史

  • UE4:CPU Lightmass(UnrealLightmass,独立进程多线程求解,还有 Swarm 分布式);
  • UE5:GPU Lightmass(Engine/Plugins/Experimental/GPULightmass/)转正为默认——用 GPU 路径追踪逐 tile 收敛,FStaticLightingSystemInterface::GetPreferredImplementation()(StaticLightingSystemInterface.cpp:113)硬编码优先 GPULightmass;
  • 本 fork:无 Lightmap/Lightmass 改动(git 验证),移动端 one-pass 管线沿用现有 lightmap 采样路径。

1.7 小结

这一章记住三句话:

  1. Lightmap = 离线预计算的光照贴图(拍照片哲学);
  2. Lumen vs Lightmap = 实时 vs 离线——移动端/静态场景选 Lightmap;
  3. Mobility 三态决定参与度——只有 Static/Stationary 灯进 Lightmap。

下一章,六大概念各配一个类比。

第 2 章 六大核心概念速览(每个概念 = 一个类比)

TL;DR|Lightmap 的六个核心概念,用六个你早就懂的东西类比:纹素密度 = 光照分辨率、
UV 展开 = 把橘子皮摊平、Atlas 装箱 = 拼图游戏、填充率 = 拼图的利用率、
编码 = 把亮度塞进 8bit 行李箱、Padding = 拼图块之间的缝隙。这一章只立类比不给代码。

2.1 纹素密度(Texel Density)= 光照的”分辨率”

定义:Lightmap 上每个纹素(texel)覆盖多少世界面积。LightMapResolution(StaticMesh.h:1194-1196)是纹理边长(texel 数),实际密度 = 分辨率 ÷ 网格世界尺寸。

类比实验室:分辨率
同样的照片,4K 屏看比 720p 屏看清晰——Lightmap 同理:大模型配小分辨率 =
光照糊成一团(”光斑”);小模型配大分辨率 = 浪费显存。**纹素密度是光照的
“每平方米像素数”**,全场景统一密度(如 4 texel/cm)是烘焙质量的第一要务。

2.2 UV 展开 = 把橘子皮摊平

定义:模型表面(3D)→ UV 平面(2D)的映射。Lightmap 需要第二套 UV(LightMapCoordinateIndex,StaticMesh.h:1221,默认 UV1)——UV 岛(Chart)不得重叠、必须落在 0-1 空间,因为光照贴图是按 UV 位置写的。

类比实验室:橘子皮
把橘子皮完整摊平(不撕裂、不重叠)就是 UV 展开。Lightmap UV 要求摊得均匀
(每块面积与世界面积成比例——否则纹素密度不均)且留缝(岛与岛之间有
边距,否则采样会串色)。引擎能自动摊(第 8 章),但摊得好的关键参数
(MinLightmapResolution/通道选择)需要人懂。

2.3 Atlas 装箱 = 拼图游戏

定义:多个 Actor 的 Lightmap 打包进共享纹理(Atlas)。编辑器侧:FLightMapPendingTexture 用 skyline 装箱(4px 对齐,LightMap.cpp:457);网格侧:FLayoutUV 的 FindBestPacking(LayoutUV.h:67)做 UV 岛装箱。

类比实验室:拼图游戏
所有光照贴图拼进一张大画布(默认 1024×512,PackedLightAndShadowMapTextureSize):
每块光照贴图是拼图片,拼图器(装箱算法)负责把片放进去。片太多/太大 → 换更大的
画布或开新画布
——这是显存与烘焙时间的权衡。

2.4 填充率 = 拼图的利用率

定义:Atlas 中被光照贴图实际占用的面积比例。填充率低 = 拼图有大量空白 = 浪费显存。引擎的 FindBestPackingEfficiency(LayoutUV.h:86)专门统计装箱效率。

类比实验室:拼图利用率
一堆形状怪异的拼图片(细长条、异形)放进画布,空隙多 → 利用率低。
填充率优化 = 让拼图片更”方正”(UV 岛长宽比)、让片大小更匹配
(分辨率统一)、留缝更小(padding 恰到好处)。第 10 章专讲。

2.5 编码 = 把亮度塞进 8bit 行李箱

定义:烘焙结果是高动态范围(RGBA16F),要存进 8bit 贴图——QuantizeLightSamples(LightmapEncoding.cpp:35)用 LogScale=11.5 的对数量化 + 每图 Scale/Add(min/max 归一化)把亮度”塞进行李箱”。

类比实验室:8bit 行李箱
亮度范围从 0 到几百(阳光下的墙 vs 阴影),8bit 只能存 0-255——直接存会爆。
解法:先取对数(LogScale,把大范围压缩进小范围),再整体缩放平移
(Scale/Add,让 0-255 被用满)。**”行李箱装不下的亮度,先对数压缩再装箱”**。

2.6 Padding(边距)= 拼图块之间的缝隙

定义:UV 岛之间/光照贴图之间留的空隙(纹素)。引擎装箱 4px 对齐(LightMap.cpp:491),官方建议约 4 纹素——**缝隙防”串色”**(双线性采样/纹理压缩时相邻岛互相污染)。

类比实验室:拼图缝隙
拼图块之间完全无缝 → 双线性插值会”抄”邻居的颜色(串色/漏光)。
留缝(padding)防串色,但缝隙太宽浪费面积(填充率↓)——缝隙是质量与
效率的平衡
。漏光(Light Bleeding)90% 是 padding 不足。

2.7 概念 → 源码落点预告表

概念 类比 核心类/函数 章节
纹素密度 分辨率 LightMapResolution / GetUVDensity Ch8
UV 展开 橘子皮 FLayoutUV / CreateLightMapUVLayout Ch5/Ch8
Atlas 装箱 拼图 FLightMapPendingTexture / FindBestPacking Ch5/Ch10
填充率 拼图利用率 FindBestPackingEfficiency / FAllocator2D Ch10
编码 8bit 行李箱 QuantizeLightSamples / LogScale=11.5 Ch4/Ch9
Padding 拼图缝隙 FTextureLayout 4px / GLightmassDebugOptions Ch5/Ch10

第 3 章 烘焙管线:GPU Lightmass 全流程

TL;DR|UE5 的默认烘焙器是 GPU Lightmass(Engine/Plugins/Experimental/GPULightmass/):
Build Lighting 触发 → UGPULightmassSubsystem::Launch → FGPULightmass 注册场景 →
后台每帧推进(BackgroundTick)→ FLightmapRenderer 逐 tile 路径追踪(默认 512 采样/
像素 + 辐照度缓存 + OIDN 去噪)→ 渐进收敛。烘焙不是一次性的,是”洗衣机转桶”——
每转一圈(每帧)处理一批 tile,边转边看效果
。

3.1 插件结构

1
2
3
4
5
6
7
Engine/Plugins/Experimental/GPULightmass/
├── GPULightmass.uplugin
├── Shaders/Private/ # LightmapPathTracing.usf / LightmapEncoding.ush 等
└── Source/
├── GPULightmass/ # 核心:LightmapRenderer.cpp(4032行) / LightmapEncoding.cpp
│ # / LightmapTilePool.cpp / IrradianceCaching.cpp / Scene/
└── GPULightmassEditor/ # 编辑器入口(工具栏/设置面板/GPULM.* 命令)

避坑:路径是 Experimental/GPULightmass/(实验性插件目录)——不是 Plugins/GPULightmass。
老资料讲 UE4 的 Lightmass 是独立进程 CPU 求解器(UnrealLightmass),与 GPU Lightmass
完全不同:GPU 版在引擎进程内、用 GPU 路径追踪、逐 tile 渐进。

3.2 触发链

1
2
3
4
5
6
编辑器 Build Lighting 按钮 / 控制台 GPULM.BuildLighting
→ UGPULightmassSubsystem::Launch(GPULightmassSettings.cpp:195)
→ FGPULightmass(GPULightmass.cpp:15)注册场景
→ FScene::BackgroundTick 每帧推进
→ FLightmapRenderer(IVirtualTextureFinalizer)逐 tile 路径追踪
→ 渐进收敛(每帧可见效果变好)

为什么 GPU Lightmass 是默认:FStaticLightingSystemInterface::GetPreferredImplementation()(StaticLightingSystemInterface.cpp:113)硬编码优先取 "GPULightmass" 实现——写死优先,无需配置。

3.3 关键参数:两张大表

世界设置(FLightmassWorldInfoSettings,WorldSettings.h:55-239,挂载 :672):

参数 默认 作用
StaticLightingLevelScale 1 世界缩放补偿(注释”1 UU = 1 cm,大关卡设 2-4 可大幅缩短构建”)
NumIndirectLightingBounces 3 间接光反弹次数
IndirectLightingQuality 1 GI 求解采样数倍率(调高更慢更干净)
IndirectLightingSmoothness 1 间接光平滑(调低细节多噪点多)
bUseAmbientOcclusion false 环境光遮蔽
bCompressLightmaps true 压缩光照贴图(注释”关闭后内存/体积 ×4”)
VolumetricLightmapDetailCellSize 200 体积光照图网格密度
VolumeLightingMethod VLM_VolumetricLightmap 体积光照法

GPU Lightmass 面板(UGPULightmassSettings,GPULightmassSettings.h:33):

参数 默认 作用
GISamples 512 每像素 GI 采样数(质量核心旋钮)
StationaryLightShadowSamples 128 Stationary 灯阴影采样
bUseIrradianceCaching true 辐照度缓存(复用相邻点 GI,大幅加速)
IrradianceCacheQuality 128 缓存质量
TilePassesInSlowMode/FullSpeedMode 1/8 慢速/全速模式每 tile 的 pass 数
LightmapTilePoolSize 55 tile 池大小(显存预算)
bCompressLightmaps true 压缩

3.4 与 CPU Lightmass 的差异

CPU Lightmass(UE4) GPU Lightmass(UE5)
计算设备 CPU 多线程(可 Swarm 分布式) GPU(DX12 + 硬件光追/DXR)
方式 一次性求解 渐进式(边烘边看,可中断续烘)
采样 静态配置 GISamples 可实时调
去噪 无(靠采样数) Intel OIDN 内置
分布式 ✅(多机) ❌

3.5 源码考古:Launch 触发链

源码考古:想看真东西的人进。

GPULightmassSettings.cpp:195(要点):

1
2
3
4
5
6
7
8
9
void UGPULightmassSubsystem::Launch()
{
// 创建烘焙系统实例
GPULightmass = FGPULightmassModule::CreateGPULightmassForWorld(World.Get());
// 注册为当前世界的静态光照系统(GetPreferredImplementation 优先 GPULightmass)
FStaticLightingSystemInterface::SetPreferredImplementation(FName("GPULightmass"));
...
// 标记场景需要烘焙(编辑器勾选"Build Lighting"后进入后台推进)
}

这段代码在干什么:Launch 是烘焙的”点火”——创建 FGPULightmass 并显式把 GPULightmass
设为首选实现
(SetPreferredImplementation)。之后烘焙不再是一次性阻塞调用,而是
FScene::BackgroundTick 每帧推进的渐进过程——这就是”边烘边看、可中断”的来源。


第 4 章 编码与数据结构:FLightMap2D 的行李箱

TL;DR|烘焙结果是高动态范围(RGBA16F),存进 8bit 贴图要靠量化 + Scale/Add:
QuantizeLightSamples 用 LogScale=11.5 对数量化(HQ=LogLUVW 亮度+方向、Simple=LogRGB),
每张图按 min/max 算 Scale/Add 归一化。运行时 FLightMap2D 持有纹理数组与解码参数,
shader 用 LightMapScale/LightMapAdd 还原亮度。类比:8bit 行李箱——对数压缩 +
缩放平移,把大范围亮度装进小行李箱
。

4.1 从 RGBA16F 到 8bit:量化

GPU Lightmass 的 tile 渲染目标是 FLinearColor(RGBA16F),最终 QuantizeLightSamples(LightmapEncoding.cpp:35)量化成 8bit 系数:

1
2
3
// LightmapEncoding.cpp:44-47
const float LogScale = 11.5f;
const float LogBlackPoint = FMath::Pow(2.0f, -0.5f * LogScale);

为什么取对数:亮度范围从 0.01(阴影)到 500+(阳光),线性量化会丢失暗部细节——对数压缩让暗部与亮部都有足够的 8bit 档位。

4.2 两种编码:HQ 与 LQ

编码 内容 用途
HQ(复杂) LogLUVW(亮度+色度 UVW)+ SH 方向(Dx/Dy/Dz+残差) 高质量(默认)
LQ(简单) LogRGB(三分量对数) 低质量/移动端

HQ 的方向性:不只是”这个像素多亮”,还有”光从哪来”(SH 球谐)——材质法线不同,同一 Lightmap 像素的间接光方向不同(法线贴图配合真实感的关键)。解码时 Directionality = max(0, dot(SH, WorldNormal))(LightmapCommon.ush:166)。

4.3 Scale/Add:整图归一化

量化后还要按全图 min/max算 ScaleVectors/AddVectors(LightMap.h:338-341):最终亮度 = 量化值 * Scale + Add——让 8bit 的 0-255 被全图用满(不然整张图只用了一半档位,暗部更糊)。

4.4 FLightMap2D:纹理布局

FLightMap2D(LightMap.h:198):

1
2
3
4
5
6
7
8
9
10
// The textures containing the light-map data.
TObjectPtr<ULightMapTexture2D> Textures[2]; // [0]=系数(LogLUVW) [1]=方向(SH)
TObjectPtr<ULightMapTexture2D> SkyOcclusionTexture;
TObjectPtr<ULightMapTexture2D> AOMaterialMaskTexture;
TObjectPtr<UShadowMapTexture2D> ShadowMapTexture;
...
FVector4f ScaleVectors[NUM_STORED_LIGHTMAP_COEF]; // 解码 scale
FVector4f AddVectors[NUM_STORED_LIGHTMAP_COEF]; // 解码 bias
FVector2D CoordinateScale; // atlas 内 UV 缩放(PostEncode 计算)
FVector2D CoordinateBias;

运行时纹理:ULightMapTexture2D(LightMapTexture2D.h:24,LODGroup=TEXTUREGROUP_Lightmap)——源数据 FColor(8bit),压缩经 SetModernSettingsForNewOrChangedTexture(LightMap.cpp:673)决定格式。

4.5 运行时解码

FPrecomputedLightingUniformParameters(LightmapUniformShaderParameters.h:16-25)携带 LightMapCoordinateScaleBias/LightMapScale[2]/LightMapAdd[2];shader 解码(LightmapCommon.ush:91-159):

1
2
3
LogL = Lightmap0.w
UVW = Lightmap0.rgb^2 * LightMapScale[0].rgb + LightMapAdd[0].rgb
L = exp2(LogL) - LogBlackPoint // 还原真实亮度

4.6 源码考古:PostEncode 的 atlas 收尾

源码考古:想看真东西的人进。

LightMap.cpp:685-723(PostEncode,要点):

1
2
3
4
5
6
7
// 计算光照贴图在 atlas 内的 UV 缩放/偏移:
// 每个实例的光照贴图被装箱进共享纹理后,它的 UV 要乘以 Scale 加上 Bias
// 才能指向 atlas 中自己的位置
FLightMap2D::CoordinateScale = PaddedSize / GetSizeX();
FLightMap2D::CoordinateBias = Base / GetSizeX();
...
// 写入 ScaleVectors/AddVectors(解码用)

这段代码在干什么:装箱的”坐标翻译”——光照贴图在 atlas 里的位置(Base/Size)被
换算成 UV 的 Scale/Bias,运行时每个实例的 lightmap UV 先乘 Scale 加 Bias 再采样,
就精确指向 atlas 中自己的区域。
这是第 10 章”填充率”的收尾动作
:装箱完成后,
每个实例都知道”我的光照贴图在 atlas 的哪个角落”。


第 5 章 打包装箱:Atlas 拼图游戏

TL;DR|装箱发生在两个层面:网格侧(FLayoutUV:UV 岛 FindCharts → FindBestPacking
→ CommitPackedUVs,把岛装进网格的 lightmap UV 空间)与编辑器侧(FLightMapPendingTexture
的 skyline 装箱:把多个 Actor 的光照贴图装进共享 1024×512 atlas)。填充率 = 装箱的
利用率,第 10 章专讲优化。类比:拼图游戏——岛装进网格,网格装进图集。

5.1 一条链两套实现

1
2
3
4
5
6
7
8
9
10
11
12
网格侧(每网格):
FLayoutUV(LayoutUV.h:40-87)
FindCharts(:66) 三角形聚成 UV 岛(Chart)
FindBestPacking(:67) 岛装箱进 lightmap UV 空间
CommitPackedUVs(:68) 写回 UV
→ 调用链:StaticMeshOperations.cpp:1866-1973(CreateLightMapUVLayout)

编辑器侧(每关卡):
FLightMapAllocationGroup(LightMap.cpp:426)按 Outer/距离分组(GMaxLightmapRadius=5000)
FLightMapPendingTexture(:457)FTextureLayout 4px 对齐 skyline 装箱(:491)
PackedLightAndShadowMapTextureSize=1024(2:1 长宽比,:2478-2479)
降序装箱(:2525)→ PostEncode 算 CoordinateScale/Bias

为什么两套:网格侧解决”这个网格的 UV 岛怎么摆“(每网格的 lightmap UV 空间),编辑器侧解决”每个实例的光照贴图怎么拼“(共享 atlas)——两层拼图。

5.2 FAllocator2D:段式 best-fit

FAllocator2D(Allocator2D.h:19-80):段式空闲空间分配器,FindWithSegments(:74)按”最合适矩形”准则(best-fit)找空位——装箱质量的核心算法。FAllocator2DFlipFix 是 ELightmapUVVersion 的一个修复版本(翻转修复)。

5.3 ELightmapUVVersion:装箱算法的演进

MeshUtilitiesCommon.h:7-21:BitByBit=0 → ... → ConsiderLightmapPadding=6 → ForceLightmapPadding=7 → Segments2D=8 → OptimalSurfaceArea=9 → ScaleByEdgesLength=10(=Latest)——每个版本改进了装箱质量(padding 处理、段分配、按边长缩放岛)。

避坑:LightmapUVVersion(StaticMesh.h:772)是”UV 打包算法版本”——改了引擎的装箱
算法后,旧资产的 UV 布局不会自动重打(除非重新生成)。改装箱质量 = 旧资产可能要
重新”Apply Changes”。

5.4 GPU Lightmass 侧:tile 池

GPU Lightmass 不共享 atlas——每实例独立 FLightmap(LightmapStorage.h:66),渲染目标是 tile 池(FLightmapTilePool,LightmapTilePool.h:28-72,LightmapTilePoolSize=55 个 tile,FreeHeap 线性分配);每实例 lightmap 尺寸向上取整到 128 tile(GetPaddedSizeInTiles,LightmapStorage.h:73-79)。

ISM 实例打包(InstancedStaticMesh.cpp:108-119):TotalLightmapRes = 宽 × ceil(sqrt(实例数))——所有实例排进一个正方形大图。

5.5 源码考古:FindBestPacking 装箱循环

源码考古:想看真东西的人进。

LayoutUV.h:67-86(要点):

1
2
3
4
5
6
/** 找到把全部 Chart 装进指定分辨率纹理的最佳排列 */
bool FindBestPacking(uint32 InTextureResolution);
/** 提交装箱结果(把 PackingScale/Bias 写回 UV) */
void CommitPackedUVs();
/** 装箱效率统计(填充率!)——每帧统计,LogStats() 输出 */
uint32 FindBestPackingEfficiency() const;

这段代码在干什么:FindBestPacking 尝试把全部 UV 岛装进给定分辨率的纹理——
装不下就返回失败(调用方提高分辨率重试);FindBestPackingEfficiency 量化
“装得满不满”(填充率)。这就是”自动生成 lightmap UV 时分辨率怎么定”的机制:
从 MinLightmapResolution 开始试,装不下就翻倍,直到装下或达上限。


第 6 章 运行时渲染与流送:烘焙结果怎么被画出来

TL;DR|运行时 BasePass 读 Lightmap:FUniformLightMapPolicy 绑定 uniform(Scale/Add/
CoordinateScaleBias)→ shader 里 GetLightMapColorHQ 解码 → 与直接光合并。UV 从
顶点流的 LightMapCoordinate 通道(默认 UV1)来,两个系数纹理上下打包进一张
(float2(1,0.5) 切分)。流送:GAllowStreamingLightmaps + LightmapStreamingFactor=0.2
按距离流送 mip。类比:运行时是”看照片”——读贴图,不重算。

6.1 BasePass 采样链

1
2
3
4
5
BasePass 像素着色器(BasePassPixelShader.usf:717-731):
#elif HQ_TEXTURE_LIGHTMAP
GetLightMapCoordinates(Interpolants, LightmapUV0, LightmapUV1, LightmapDataIndex);
GetLightMapColorHQ(...); // 解码 + SH 方向性
OutDiffuseLighting *= View.PrecomputedIndirectLightingColorScale;

LightMapPolicyImpl::ModifyCompilationEnvironment(LightMapRendering.cpp:42-49)设置 HQ_TEXTURE_LIGHTMAP/LQ_TEXTURE_LIGHTMAP 宏;FUniformLightMapPolicy::GetPixelShaderBindings(:562-577)绑定 PrecomputedLightingBuffer(含 Scale/Add 等)。

6.2 HQ/LQ 策略选择

宏 触发 用途
HQ_TEXTURE_LIGHTMAP r.HighQualityLightMaps=1(UnrealEngine.cpp:18478) 高质量(默认)
LQ_TEXTURE_LIGHTMAP r.SupportLowQualityLightmaps=1(BasePassRendering.cpp:139) 低质量/移动端

HQ 需要两个系数纹理(方向 SH),LQ 只需一个(LogRGB)。

6.3 顶点流双纹理打包

LocalVertexFactoryCommon.ush:118-128(LocalVertexFactoryCommon.ush):

1
2
3
// lightmap UV 来自顶点流的 LightMapCoordinate 通道(默认 UV1,LightMapCoordinateIndex)
LightmapUV0 = Interpolants.LightMapCoordinate.xy * float2(1, 0.5); // 上半区:系数纹理
LightmapUV1 = LightmapUV0 + float2(0, 0.5); // 下半区:方向纹理

关键技巧:两个系数纹理上下打包进一张贴图(UV.y 乘 0.5 切分上下半区)——省一次纹理绑定与采样。

6.4 VT Lightmap 与流送

  • VT Lightmap:r.VirtualTexturedLightmaps=1(LightMap.cpp:95)时生成虚拟纹理光照贴图(每 tile 64×64 + 2px 边界)——按可视性流送,大世界省显存;
  • 传统流送:GAllowStreamingLightmaps(LightMap.cpp:53,ini [TextureStreaming] AllowStreamingLightmaps)+ LightmapStreamingFactor=0.2(BaseEngine.ini:2040)——lightmap mip 按距离流送;
  • 体积光照图流送:FVolumetricLightmapStreamingManager(PrecomputedVolumetricLightmapStreaming.cpp)。

避坑:老资料(UE4)的 FLightmapStreamingManager / r.LightmapStreaming CVar 在
本 fork 已不存在(UE4 遗留已删)——流送只走上面的三条路径。

6.5 源码考古:GetPixelShaderBindings

源码考古:想看真东西的人进。

LightMapRendering.cpp:562-577(要点):

1
2
3
4
5
6
7
8
void FUniformLightMapPolicy::GetPixelShaderBindings(...)
{
// 绑定预计算光照 uniform(Scale/Add/CoordinateScaleBias/纹理引用)
FPrecomputedLightingUniformParameters LCI;
SetupLCIUniformBuffers(FeatureLevel, Mesh, ...);
PixelShader->SetUniformBufferParameter(..., PrecomputedLightingBuffer);
...
}

这段代码在干什么:把烘焙结果(纹理 + 解码参数)打包进 uniform 绑给像素着色器——
运行时”看照片”的全部准备。烘焙阶段算好的 Scale/Add/CoordinateScaleBias 在这里
变成 shader 可读的参数,GetLightMapColorHQ 用它们把 8bit 系数还原成亮度。

第 7 章 专题一:烘焙流程实操——从世界设置到贴图落地

TL;DR|烘焙实操四步:检查(Mobility 归位/UV 检查/漏光预防)→ 设参(世界设置
Lightmass + GPU Lightmass 面板)→ 触发(Build Lighting / GPULM.BuildLighting,渐进
烘烤边看边等)→ 验证(视口/Lightmap Density 视图/对比)。类比:**相机的快门与
ISO——参数决定”拍多久、多干净”**。

7.1 烘焙前检查清单

检查项 为什么 怎么查
灯光 Mobility 只有 Static/Stationary 灯进 Lightmap(Movable 白烘) 选中灯光看 Mobility 面板
Lightmap UV 没有合法第二 UV 的网格会静默降级(错位/发黑) 视口 → Lightmap Density 视图
漏光预防 UV padding 不足 → 串色 烘焙后检查墙角/岛边缘
网格 LightMapResolution 密度统一(全场景同 texel 密度) 静态网格资产面板
世界规模 大关卡设 StaticLightingLevelScale 2-4 缩短构建 世界设置

7.2 世界设置参数(逐项)

第 3.3 章的两张表是参考——实操要点:

  • **StaticLightingLevelScale**:大关卡(数 km)设 2-4——注释原话”可大幅缩短构建时间”(尺度相关参数全部缩放);
  • **NumIndirectLightingBounces**:室内 3 足够;洞穴/密闭空间可 4-5;室外 2 足够;
  • IndirectLightingQuality:正式烘焙 1;预览烘焙 0.5(快 4 倍,验证布局用);
  • **bUseAmbientOcclusion**:美术风格需要接触阴影时开;
  • **bCompressLightmaps**:默认开(第 9 章详述,调试质量问题时可临时关)。

7.3 GPU Lightmass 面板(编辑器)

UGPULightmassSettings(GPULightmassSettings.h:33):

  • GISamples:512 是质量/速度平衡;预览 64、最终 1024+;
  • bUseIrradianceCaching:开(默认)——相邻点复用 GI,大幅加速;
  • TilePassesInSlowMode/FullSpeedMode:慢速模式(1 pass/tile)用于”烘一会儿看效果”,全速模式(8 pass)用于定稿;
  • LightmapTilePoolSize:显存预算(默认 55 tile,爆显存就调小,会慢)。

7.4 触发与监控

  • 触发:工具栏 Build → Build Lighting;或控制台 GPULM.BuildLighting(GPULightmassEditorModule.cpp:44);
  • 监控:烘焙是渐进的——视口里光照逐帧变好(这是 GPU Lightmass 的标志性体验);输出日志有进度;
  • 中断:可以随时停止(渐进式 = 已烘的部分保留)。

7.5 验证三件套

  1. 视口目检:漏光(墙角发亮)、光斑(纹素密度不足)、噪点(采样不够);
  2. Lightmap Density 视图(编辑器视图模式):红=密度过高、蓝=过低——全场景尽量同一色;
  3. 构建对比:改参数前先记录(截图/时长),每次只动一个旋钮。

7.6 避坑清单

  1. 漏光(Light Bleeding):90% 是 UV padding 不足——装箱 4px 对齐是默认,官方建议约 4 纹素边距;
  2. Stationary 灯阴影噪点:StationaryLightShadowSamples=128 不够就调高;
  3. 大关卡烘焙慢:先 StaticLightingLevelScale,再调 IndirectLightingQuality 预览;
  4. 烘焙结果与实机不一致:检查压缩(bCompressLightmaps)与分辨率差异。

7.7 动手实验室:从零烘焙一个室内关卡

步骤 操作 验收标准
① 场景准备 建室内关卡(四面墙+地板),放 1 Static 定向光 + 1 Static 点光;所有网格确认有 Lightmap UV(自动生成已开) 视口 Lightmap Density 视图无红色/蓝色块
② 参数 世界设置:Bounces=3、Quality=1;GPU Lightmass:GISamples=128(预览) 参数面板正确
③ 烘焙 Build Lighting → 观察渐进收敛 视口光照逐步变好,日志无 error
④ 验证 目检 + Density 视图 + 检查墙角漏光 无漏光/无光斑/无噪点
⑤ 迭代 有问题 → 单旋钮调整重烘 每次只改一个参数,记录对比

检查清单:Mobility 全对 ✓、UV 全绿 ✓、无漏光 ✓、无噪点 ✓、烘焙时长记录 ✓。


第 8 章 专题二:自动化展开 UV——让引擎替你摊开橘子皮

TL;DR|引擎能自动生成 Lightmap UV:FMeshBuildSettings 的 bGenerateLightmapUVs=true
默认开启——构建时 CreateLightMapUVLayout(或旧路径 FLayoutUV Packer)自动把 UV 岛
装箱进第二通道(DstLightmapIndex=1)。缺第二 UV 不会报错,而是静默降级钳制。
FBX 导入还会自动识别名为 LightMapUV 的 UV 集。类比:橘子皮自动摊平机。

8.1 两条生成路径

1
2
3
4
5
新 MeshDescription 路径(默认):
MeshDescriptionHelper.cpp:101-129 → FStaticMeshOperations::CreateLightMapUVLayout
(校验 Src/Dst 索引 → 自动补通道 → 装箱)
旧 RawMesh 路径(legacy):
MeshUtilities.cpp:2612-2640 → FLayoutUV Packer(FindCharts → FindBestPacking → CommitPackedUVs)

默认设置(FMeshBuildSettings,EngineTypes.h:2947):bGenerateLightmapUVs=true、MinLightmapResolution=64、SrcLightmapIndex=0、DstLightmapIndex=1(:2988-3007,默认值 :3051-3056)——默认就从 UV0 复制并重新装箱到 UV1。

8.2 缺第二 UV 的”静默降级”

UStaticMesh::EnforceLightmapRestrictions(StaticMesh.cpp:9857-9941):计算实际 UV 通道数后把 LightMapCoordinateIndex 钳制到合法范围——缺第二 UV 不报错,静默用 UV0 采样(lightmap 会错位/发黑)。CheckLightMapUVs(:9952)区分 Missing/Bad/Valid 供审计。

避坑:**”没报错 = 没问题”是错的**。烘焙后某个物体光照错位/全黑,第一件事就是
查它的 Lightmap UV(视口 Lightmap Density 视图 + 资产 CheckLightMapUVs 工具)。

8.3 分辨率与密度

  • **LightMapResolution(StaticMesh.h:1194-1196):纹理边长 texel 数(ClampMax=4096)——不是”每 texel 世界单位”**,实际密度 = 分辨率 ÷ 网格尺寸;
  • **LightmapUVDensity**(:1176):按 Section 加权平均的密度(流送用),GetUVDensity(StaticMesh.cpp:5194-5216);
  • GPU Lightmass 逐 LOD 减半(StaticMesh.cpp:34-82):LODIndex > 0 ? max(基础分辨率 / 2^(LOD-1), 32) : 基础——LOD 越高光照越粗(合理)。

实践:全场景统一纹素密度(如 4 texel/cm)——大模型(建筑)分辨率 512-1024,小道具 64-128,按世界尺寸而不是感觉调。

8.4 FBX 导入的自动处理

FbxStaticMeshImport.cpp:

  • :400-403:FBX 里有名为 LightMapUV 的 UV 集 → 自动设为 LightMapCoordinateIndex(DCC 导出时命名为 LightMapUV 就会被识别);
  • :1865-1867:默认 SetLightMapResolution(64)、SetLightMapCoordinateIndex(1);
  • :2065-2069:bGenerateLightmapUVs 时 DstLightmapIndex = FirstOpenUVChannel(自动找空通道)。

8.5 Datasmith 自动展 UV

Datasmith 自带展平工具(UVTools/):UUVGenerationFlattenMapping::GenerateUVs(UVGenerationFlattenMapping.cpp:1218,角度阈值 + 面积权重展平);SetupGeneratedLightmapUVResolution(UVGenerationUtils.cpp:48:由 chart 数算最小分辨率)——Datasmith 导入无 UV 的模型会自动摊。

8.6 引擎级自动 UV 接口

  • **IGeometryProcessing_MeshAutoUV**(MeshAutoUV.h:17-72):EAutoUVMethod{PatchBuilder/UVAtlas/XAtlas} 三种算法——程序化批量摊 UV 的官方接口(建模工具/程序化生成用);
  • UVEditor 插件:UVEditorLayoutTool(自动排版)/ UVEditorRecomputeUVsTool(重算)/ UVEditorTexelDensityTool(纹素密度可视化);
  • 蓝图:UStaticMeshEditorSubsystem::SetGenerateLightmapUVs(StaticMeshEditorSubsystem.cpp:1970)。

8.7 动手实验室:自动生成 + 检查配方

步骤 操作 验收标准
① 开启 静态网格资产 → Build Settings → Generate Lightmap UVs 勾选(默认已勾) 复选框状态正确
② 通道 SrcLightmapIndex=0、DstLightmapIndex=1、MinLightmapResolution=64 参数默认
③ 重建 资产上右键 Reimport 或 Apply Changes 构建日志无 UV 错误
④ 检查 视口 Lightmap Density 视图 + CheckLightMapUVs(资产工具) 无 Missing/Bad UV
⑤ 密度微调 LightMapResolution 按世界尺寸调(大模型 512+,小道具 64) Density 视图全场景同色

检查清单:UV 集 Valid ✓、通道=UV1 ✓、密度统一 ✓、无静默降级(烘焙后无错位物体)✓。


第 9 章 专题三:压缩 Lightmap——内存瘦身

TL;DR|Lightmap 压缩三把刀:bCompressLightmaps(默认开,关掉内存×4——调试质量
才关)、纹理压缩格式(BC7 桌面/ASTC 移动端)、流送(LightmapStreamingFactor=0.2
按距离流送 mip)。移动端还有编码级优化(社区实测:跳 SkyOcclusion、AO 写 A 通道,
包体减 150MB——非本仓库实测)。类比:行李箱抽真空——质量与体积的权衡。

9.1 压缩链全貌

1
2
烘焙(RGBA16F tile)→ 量化 8bit(FColor)→ SetModernSettingsForNewOrChangedTexture
(LightMap.cpp:673)→ 纹理压缩(BC7 等)→ 存储/流送

bCompressLightmaps(WorldSettings.h:151):默认 true——注释原话”关闭后内存/体积 ×4”。

9.2 什么时候不该关压缩

场景 建议
正常发布 开(默认)
调试漏光/串色 临时关(压缩是串色的来源之一)
移动端 开(内存是移动端生命线)

9.3 移动端格式与编码级优化(公开资料,非本仓库实测)

社区对移动端 Lightmap 的编码优化(知乎,写作时未能访问核实):

  • 编码格式:HQ=RGBLogL 8:8:8:8、LQ=移动端专属 24bit RGB8、SimpleLogScale=16.0 对数量化、cook 后重映射 ASTC;
  • 自定义优化(源码级改 LightMap.cpp):跳过 SkyOcclusionTexture 分配、把全局 AO 写进系数纹理 A 通道、半尺寸 LQ Lightmap——实测包体减约 150MB。

避坑:这些文章基于 UE4——编码常量与本 fork 的 GPU Lightmass(LogScale=11.5)
不同;思路可借鉴(AO 合并、跳纹理、半尺寸),数值别照抄。

9.4 VT 有损压缩

r.VT.EnableLossyCompressLightmaps=1(LightMap.cpp:111,默认 0):VT Lightmap 路径下有损压缩——体积更小,质量略降(阴影过渡变糊)。

9.5 流送与内存账本

  • GAllowStreamingLightmaps(LightMap.cpp:53,ini [TextureStreaming] AllowStreamingLightmaps);
  • LightmapStreamingFactor=0.2(BaseEngine.ini:2040):lightmap 流送权重(0.2 = 比其他纹理更早降 mip——光照糊一点可接受);
  • 内存估算:单张 1024×512 RGBA8 = 2MB(未压缩)→ BC7 约 0.5MB。

9.6 动手实验室:内存测量与压缩对照

步骤 操作 验收标准
① 基线 烘焙完成,stat memory 记录 Lightmap 内存 记录基线数字
② 对照 世界设置关 bCompressLightmaps → 重烘 → 对比内存 内存约 ×4(验证注释)
③ 恢复 开回压缩,对比画质差异(墙角阴影过渡) 无可见差异 = 压缩无损观感
④ 流送 确认 ini AllowStreamingLightmaps=true 远距离光照糊、靠近变清晰
⑤ 定案 记录最终内存与画质 达标

检查清单:压缩开 ✓、流送生效 ✓、画质无可见损失 ✓、内存记录 ✓。


第 10 章 专题四:提高填充率——让拼图每一块都被用到

TL;DR|填充率 = Atlas 中光照贴图的实际占用率。四把刀:统一纹素密度(片大小匹配)、
岛形状方正(ELightmapUVVersion 的 ScaleByEdgesLength 减小细长岛)、合理 padding
(缝隙够用就行)、分组装箱(编辑器按距离分组,PackedLightAndShadowMapTextureSize)。
测量:FindBestPackingEfficiency(引擎统计)+ Lightmap Density 视图。类比:拼图
利用率——片越方正、缝隙越小、利用率越高
。

10.1 填充率为什么重要

填充率低 = Atlas 里有大片空白 = 显存浪费 + 烘焙时间浪费(空白区域也占 tile)。高填充率 = 同样的显存装更多光照信息。

10.2 网格侧装箱:岛的”方正化”

ELightmapUVVersion::ScaleByEdgesLength=10(MeshUtilitiesCommon.h:7-21,=Latest):按边长缩放岛——细长岛(长宽比极端)装箱时浪费大,按边长缩放让岛更”方正”,填充率提升。FAllocator2D 的 best-fit 段分配(Allocator2D.h:19-80)负责找空位。

10.3 编辑器侧分组装箱

LightMap.cpp:

  • 分组(FLightMapAllocationGroup :426):按 Outer(package)、LightmapFlags、空间距离分组——GMaxLightmapRadius=5000(:56)限制同图最大包围球半径;
  • 装箱(FLightMapPendingTexture :457):FTextureLayout 4px 对齐 skyline 装箱(:491);
  • atlas 尺寸:PackedLightAndShadowMapTextureSize=1024(WorldSettings.cpp:137),2:1 长宽比(:2478-2479);
  • 降序装箱(:2525):大的先放(大件先占位,小件填空——经典贪心)。

10.4 影响填充率的五因素

因素 影响 对策
岛大小分布 大小悬殊 → 空隙多 统一分辨率/拆分超大网格
岛长宽比 细长岛浪费 ScaleByEdgesLength(默认已开)
Padding 缝隙宽 = 浪费 保持默认 4px,别乱加
分辨率匹配 大网格小分辨率 = 岛小 按世界尺寸定分辨率
LOD 差异 LOD 间分辨率不一致 GPU Lightmass 已按 LOD 减半(合理)

10.5 测量手段

  • 引擎统计:FindBestPackingEfficiency(LayoutUV.h:86)——装箱效率原子统计,LogStats() 输出;
  • Lightmap Density 视图:红=过密(浪费)、蓝=过疏(糊)——全场景同色 = 填充率健康;
  • AverageTexelDensity(UnrealLightmass,TextureMapping.cpp:1892-1927):lightmap 面积 ÷ 世界面积的平均密度。

10.6 动手实验室:填充率测量与优化

步骤 操作 验收标准
① 统计 烘焙后看 Density 视图 + 引擎装箱效率日志 记录填充率基线
② 归因 找出红色/蓝色区域对应的网格 定位密度不均来源
③ 修复 统一分辨率(大网格 512、小道具 64)+ 拆超大网格 密度图趋于同色
④ 复测 重烘 → 对比填充率与显存 填充率提升、显存下降
⑤ 定案 记录最终数据 达标

检查清单:Density 视图同色 ✓、无细长岛(ScaleByEdgesLength 已开)✓、padding 默认 ✓、显存记录 ✓。


第 11 章 专题五:把 UE 当烘焙器——离线光照工厂

TL;DR|把 UE 当纯烘焙器 = 让烘焙与关卡制作解耦:命令行/脚本触发烘焙(无人值守)、
分层烘焙(直接光/间接光分离)、产物导出(给其他引擎/流程用)。三个配方:① 命令行
烘焙 ② 分层烘焙 ③ 产物导出。类比:离线光照工厂——输入场景,输出贴图。

11.1 定位:UE 作为烘焙器的价值

  • 质量:GPU Lightmass 的 512 采样/像素 + OIDN 去噪 = 顶级离线光照质量;
  • 可脚本化:GPULM.BuildLighting 控制台命令可进批处理(无人值守烘焙);
  • 产物可导出:GLTFExporter 等把带 lightmap 的网格导出(烘焙结果”带走”)。

11.2 管线架构

1
2
资产规范(分辨率/UV 规范)→ 自动 UV(第 8 章)→ GPU Lightmass(第 3 章)
→ 产物管理(MapBuildDataRegistry)→ 导出(GLTFExporter 等)

11.3 动手实验室配方 ×3

配方 A:命令行/批处理烘焙

步骤 操作 验收标准
① 打开 UnrealEditor.exe 项目 -game -run=...(或编辑器内执行 GPULM.BuildLighting) 命令可执行
② 触发 控制台 GPULM.BuildLighting(GPULightmassEditorModule.cpp:44) 日志出现烘焙进度
③ 等待 渐进收敛完成(观察视口/日志) 无 error
④ 保存 保存关卡(烘焙数据进 MapBuildDataRegistry) 重新打开光照仍在

配方 B:分层烘焙(引用公开资料《命运扳机》经验,非本仓库实测)

步骤 操作 验收标准
① 分层 直接光/间接光分离(不同光照场景) 两套烘焙可独立出
② 地形 RVT 地形需先 Scene Capture 预渲染(公开资料) 地形光照正确
③ 材质 特殊材质需重写 Lightmass 材质导出模块(公开资料:顶点 Position 换 Lightmap UV 的 trick) 材质光照正确

配方 C:产物导出(把烘焙结果带出 UE)

步骤 操作 验收标准
① 导出 启用 GLTFExporter(GLTFExporter)导出网格 导出成功
② 检查 确认 Lightmap 纹理随网格导出 其他引擎可读
③ 复用 回导 Unity 等(第 12 章迁移流程) 光照贴图可显示

11.4 避坑

  1. 烘焙与实机差异:分辨率/去噪/压缩在实机上观感不同——导出前用实机设置验收;
  2. 多场景烘焙:增量烘焙(只烘改动的场景)省时间;
  3. 版本管理:烘焙产物(MapBuildDataRegistry)进版本库——团队协作不重烘。

第 12 章 专题六:Unity 场景导入 UE——材质、灯光、地表对齐

TL;DR|Unity→UE 迁移四关:坐标单位(100 倍缩放:UE 1UU=1cm vs Unity 1 单位=1m;
Y-up→Z-up 换手)、材质对齐(FBX 导入器自动映射 sDiffuse→BaseColor 等,多数需手动
修 Metallic/Roughness)、灯光对齐(单位换算:UE 厘米制 vs Unity 米制,物理公式
在 GetUnitsConversionFactor 注释里)、地表对齐(Heightmap 导入 Landscape)。
导入通道:FBX(官方主线)/ USD / Datasmith(清单无 Unity)/ GLTF(本 fork 经
Interchange 可用)。类比:度量衡转换 + 坐标系换手 + 材质翻译。

12.1 导入器现状对比

通道 本 fork 状态 Unity 支持 适用
FBX ✅ 经典 UnrealEd 管线 ✅ 官方推荐(FBX Exporter 包) 迁移主线
USD ✅ Importers/USDImporter/ ✅ Unity 官方 USD 包 复杂场景/协同
Datasmith ✅ Enterprise 插件 ❌ 官方清单无 Unity 3dsMax/Revit 等
GLTF ✅ 经 Interchange 可用(实测 InterchangeGltfTranslator.cpp 存在,InterchangeImportModule.cpp:111 注册) ✅ Unity 可导出 开源中间格式

避坑:此前资料说”本 fork 无 GLTF 导入器”——实测有(Interchange 的 GLTFCore/
InterchangeImport 模块 + InterchangeGltfTranslator)。GLTF 可用,但 FBX 仍是 Unity
迁移官方主线(材质映射最成熟)。

12.2 坐标与单位:100 倍缩放 + 换手

  • 100 倍:UE 1 UU = 1 cm,Unity 1 单位 = 1 m——WorldToMeters(WorldSettings.h:595,本 fork 用旧名;注释 “1uu = 1cm would be 100.0”)。FBX 导入勾 Convert Scene 自动处理;
  • 换手:Unity Y-up → UE Z-up——FBX Import Options 勾 Force Front X Axis(官方迁移文档);
  • 佐证:UE 默认碰撞盒 32 单位 = 32cm(BoxComponent.cpp 构造 BoxExtent=32)。

12.3 材质对齐:自动映射 + 手动修复

FBX 导入器自动映射(FbxMaterialImport.cpp:817-834):

FBX 属性 UE 属性
sDiffuse BaseColor
sEmissive EmissiveColor
sSpecular Specular
sSpecularFactor Roughness
sShininess Metallic
sNormalMap / sBump Normal
sTransparentColor Opacity(+BlendMode=Translucent)

手动修复清单(自动映射的经典坑):

  1. Metallic/Roughness 反了:Unity 的 Smoothness = 1 - Roughness——FBX 导出器一般已处理,但检查”金属质感全无”;
  2. 纹理颜色空间:Unity 的 sRGB 标记可能丢失——检查 BaseColor 的 sRGB 开关;
  3. 透明度:Unity Transparent → UE 要手动设 BlendMode(自动映射设了但通常要微调)。

12.4 灯光对齐:单位换算

UE 灯光单位(LocalLightComponent.h 的 IntensityUnits 注释):点光/聚光默认 Candelas(cd)或 Lumens(lm);方向光用 lux(ELightUnits,LightComponent.h)。

换算公式(GetUnitsConversionFactor 注释原文):

1
2
3
4
5
Flux = Lm = cd·sr          (光通量 = 坎德拉 × 立体角)
Intensity = Cd (峰值光强 = 坎德拉)
Illuminance = Cd·sr/m² = Lux (照度 = 流明/平方米)
UE unit is in centimeters (CM), while SI unit version are in meter (M),
hence the conversion unit (100*100)

与 Unity 的换算:Unity 用流明(Lumen)或 cd(Candela,米制)——迁入 UE 时:

Unity 值 UE 值(单位换算后)
点光 100 lm UE Lumens 单位直接填 100(同单位)
点光 100 cd UE Candelas 直接填 100
方向光(lux) 直接填(同 lux 单位)

关键:单位一致时数值直接搬;但 UE 的 100 倍缩放让”物理正确”的光强在厘米制下显得暗——光源面积/距离都 ×100,GetUnitsConversionFactor 的 100*100 就是物理换算;实战中更多靠目测微调(方向光 10 lux 量级、点光按半径试)。

项目设置:DefaultLightUnits(RendererSettings.h,ConsoleVariable r.DefaultFeature.LightUnits)——新建灯光的默认单位。

12.5 地表对齐:Heightmap 导入 Landscape

LandscapeImportHelper(LandscapeImportHelper.cpp):GetHeightmapImportDescriptor/GetHeightmapImportData/TransformHeightmapImportData——支持 MyHeightmap_x0_y0.png 分块命名。Unity Terrain 导出 Heightmap(Raw/PNG)→ UE Landscape 导入 → 材质重新指定(地形材质是 UE 的 Landscape 材质体系,Unity Terrain 材质不能直接搬)。

12.6 动手实验室:Unity 场景端到端迁移

步骤 操作 验收标准
① Unity 导出 装 FBX Exporter 包,导出场景 FBX(勾 Embed Textures) FBX 含材质贴图
② 坐标核对 UE 侧 FBX Import Options:Convert Scene ✓、Force Front X Axis ✓ 模型方向/大小正确(人物 180cm 左右)
③ 材质修复 导入后检查 Metallic/Roughness/透明度(12.3 清单) 材质观感接近 Unity
④ 灯光换算 灯光按 12.4 表换算(单位一致直接搬 + 目测微调) 光照氛围接近
⑤ 地表导入 Terrain Heightmap → Landscape 导入 → 指定地形材质 地形轮廓正确
⑥ 烘焙 第 7 章流程烘焙(Lightmap 替代 Unity 的烘焙) 光照质量达标

检查清单:缩放正确 ✓、轴向正确 ✓、材质观感一致 ✓、灯光氛围接近 ✓、地形轮廓正确 ✓、烘焙完成 ✓。

第 13 章 平台支持与性能调优

TL;DR|Lightmap 调优三板斧:内存(压缩/分辨率/流送)→ 烘焙时间(采样数/
LevelScale/辐照度缓存)→ 质量(漏光/光斑/噪点)。CVar 十一项速查 + 症状定位表。

13.1 CVar 速查表

CVar/参数 默认 作用 位置
r.HighQualityLightMaps 1 HQ 编码开关(ReadOnly) UnrealEngine.cpp:18478
r.SupportLowQualityLightmaps 1 LQ 编码编译开关 BasePassRendering.cpp:139
r.VirtualTexturedLightmaps 0 VT Lightmap LightMap.cpp:95
r.IncludeNonVirtualTexturedLightmaps 0 非 VT 光照贴图也保留 LightMap.cpp:103
r.VT.EnableLossyCompressLightmaps 0 VT 有损压缩 LightMap.cpp:111
r.MultithreadedLightmapEncode 1 多线程编码 StaticLightingSystem.cpp:157
GPULM.GISamples 512 GI 采样数(编辑器面板参数) GPULightmassSettings
GPULM.LightmapTilePoolSize 55 tile 池(显存预算) GPULightmassSettings
GPULM.TilePassesInSlowMode 1 慢速模式 pass 数 GPULightmassSettings
GPULM.TilePassesInFullSpeedMode 8 全速模式 pass 数 GPULightmassSettings
LightmapStreamingFactor(ini) 0.2 流送权重 BaseEngine.ini:2040

13.2 调优工作流:三象限决策

1
2
3
4
5
6
7
8
9
内存问题(显存涨/包体大):
压缩已开?→ 分辨率降档(统一密度)→ 流送生效?→ VT Lightmap
烘焙时间问题(等太久):
StaticLightingLevelScale(大关卡)→ IndirectLightingQuality=0.5 预览
→ GISamples 降 → 辐照度缓存确认开启
质量问题(漏光/光斑/噪点):
漏光 → padding/压缩(临时关压缩排查)
光斑 → 分辨率不够(密度视图定位)
噪点 → GISamples 升 / IndirectLightingQuality 升

13.3 症状 → 定位表

症状 大概率原因 检查/对策
烘焙慢 采样太高/关卡太大/辐照度缓存关 LevelScale → Quality 预览 → GISamples
显存涨 压缩关/分辨率过高/流送关 压缩开 → 统一密度 → 流送确认
漏光(Light Bleeding) UV padding 不足/压缩串色 padding 检查 → 临时关压缩排查
光斑(糊) 纹素密度不足 Lightmap Density 视图定位 → 分辨率
噪点 采样不足 GISamples/IndirectLightingQuality
物体光照错位/发黑 Lightmap UV 缺失(静默降级) CheckLightMapUVs → 重新生成 UV
烘焙结果与实机不同 压缩/分辨率差异 实机设置验收

第 14 章 局限、替代方案与展望

TL;DR|Lightmap 的局限:静态(灯动/物体动要重烘)、存储占用、迭代慢。
替代:Lumen(实时)、CPU Lightmass(分布式)、第三方烘焙器。方向:GPU Lightmass
成熟化、VT Lightmap 大世界标配。

14.1 局限与对策

局限 表现 对策
静态 动态物体/灯光无光照 体积光照图给动态物体 + Lumen 补动态
存储 贴图占用显存/包体 压缩 + 流送 + VT(第 9 章)
迭代慢 改灯重烘 预览质量烘焙 + 单旋钮调参(第 7 章)
移动端纹理格式 不同平台格式差异 ASTC(Android)/BC7(桌面)

14.2 替代路径

方案 定位
Lumen 实时 GI(动态场景)
CPU Lightmass(UnrealLightmass) 分布式/无 GPU 环境
第三方烘焙器 特殊需求(更高采样/特殊 GI)

14.3 演进时间线与阅读进阶

时间 里程碑
UE4 CPU Lightmass(Swarm 分布式)
UE5.0~5.2 GPU Lightmass 实验性 → 默认
5.3~5.9 VT Lightmap、OIDN 去噪、辐照度缓存成熟
未来 GPU Lightmass 转正式、大世界 VT Lightmap 标配

进阶路线:官方文档(Lighting the Environment → GPU Lightmass → Understanding Lightmapping → Generating Lightmap UVs → Migrating assets from Unity to Unreal)→ 中文深挖(附录 C)→ 本仓库源码(附录 A 指路)。


第 15 章 答疑篇:材质与 Lightmap 的五个高频问题

TL;DR|五个高频问题一次答清:材质怎么参与烘焙(引擎渲染一个”烘焙 GBuffer”:
BaseColor/法线/粗糙度/自发光,LightmapGBuffer.usf)——
只能烘焙基础 PBR 属性(复杂材质图/WPO 被降级);半透不烘焙(Translucent 不进
求解,Masked 可以);自发光能烘焙且能照亮别人(Emissive 是间接光源);Lightmap
不是资产
(在 MapBuildDataRegistry 里,查看要用烘焙预览/密度视图/RenderDoc);导出
Lightmap UV = 导出网格第二 UV 通道
(FBX 导出自带)。

15.1 材质怎么参与烘焙:烘焙专用 GBuffer

烘焙不是”照着场景直接算”——引擎先渲染一个烘焙专用 GBuffer(LightmapGBuffer.usf),把每个纹素的材质属性记录下来,光照求解器再基于它计算:

1
2
3
4
5
6
7
8
9
10
11
// LightmapGBuffer.usf(要点)
void LightmapGBufferPS(...)
{
// 计算完整材质参数(BaseColor/法线/粗糙度/自发光等)
CalcMaterialParametersEx(MaterialParameters, PixelMaterialInputs, ...);
// 法线写进烘焙 GBuffer(光照求解按真实法线算)
OutWorldNormal = normalize(cross(ddx(OutWorldPosition.xyz), ddy(OutWorldPosition.xyz)));
...
// 注意这行注释:"Support WPO in the far future"
// —— WPO(世界位置偏移)在烘焙时被显式忽略!
}

烘焙真正消费的材质属性:

材质属性 烘焙参与 说明
BaseColor ✅ 漫反射颜色(间接光反弹的颜色)
Normal ✅ 光照按法线求解(法线贴图生效)
Roughness ✅ 影响间接光分布(近似)
Metallic ✅ 影响漫反射/镜面比例
Emissive(自发光) ✅ 作为光源照亮周围(15.4)
Opacity(Masked) ✅ 遮罩裁剪(alpha 测试)
Translucent(半透) ❌ 不参与烘焙(15.3)
WPO / 顶点动画 ❌ 显式不支持(注释原话)

调试工具:世界设置的 bVisualizeMaterialDiffuse(WorldSettings.h:140,材质漫反射可视化)与 EmissiveBoost(:115)/DiffuseBoost(:119)——烘焙调试时用它们核对”烘焙器看到的材质”。

15.2 只能烘焙基础 PBR 材质吗:是的,但够用

结论:Lightmass 只消费基础 PBR 属性子集(上表),复杂材质在烘焙里被”降级”:

材质特性 烘焙行为 对策
基础 PBR(BaseColor/Normal/Roughness/Metallic) ✅ 完整参与 —
WPO(世界位置偏移) ❌ 显式忽略("Support WPO in the far future") 烘焙时关 WPO;动态用顶点动画的物体交给动态光照
程序化噪声/自定义节点 ⚠️ 按最终计算结果采样(结果会被烘焙,但每帧变化的部分不会) 静态噪声 OK,时间变化的部分走动态
分层材质/复杂混合 ⚠️ 可能需要重写材质导出模块(公开资料《命运扳机》经验) 简化材质或定制 Lightmass 导出

为什么够用:Lightmap 存的是”光照结果”(亮度+方向),不是”材质结果”——**材质本身的细节(贴图、混合)运行时照常渲染,烘焙只关心”这块表面吸收/反射多少光”**。所以基础 PBR 属性足够。

15.3 半透材质:不烘焙

Translucent(半透)不参与静态光照求解——半透物体:① 不接收烘焙间接光(光照透过来的是直接光+Lumen);② 通常不投射静态阴影(透明物体投射阴影的语义复杂)。

Masked(遮罩,如树叶/栅栏)可以烘焙——烘焙 GBuffer 里做 alpha 测试,遮罩裁掉的纹素不产生光照。

实践:玻璃/水/粒子走 Translucent(动态光照+反射);树叶/铁丝网用 Masked(能烘焙,还省 Lightmap 面积——被裁掉的区域不占)。

15.4 自发光材质:能烘焙,还能照亮别人

Emissive 在烘焙里是”光源”:发光面(招牌、屏幕、熔岩)的 Emissive 值被写进烘焙 GBuffer,光照求解时作为间接光源弹射——发光招牌能照亮周围的地面,这是烘焙场景里”灯光之外的发光体”的标准做法。

两个概念别混:

  • **”自己被照亮”**:物体表面收到的光照(正常烘焙结果);
  • **”照亮别人”**:Emissive 作为光源弹射(EmissiveBoost,WorldSettings.h:115,控制发光强度参与间接光的倍率)。

实践:霓虹灯/屏幕的 Emissive 值要高(几百到几千 cd 量级)才有”照亮周围”的效果——普通材质里 Emissive 通常 <5,做光源要显著拉高;配合 EmissiveBoost 微调。

15.5 如何查看烘焙出来的 Lightmap 图

关键事实:Lightmap 不是资产——烘焙结果存在 MapBuildDataRegistry(MapBuildDataRegistry.h:294)里,内容浏览器里找不到 Lightmap 纹理。查看方式分四档:

方式 看到什么 怎么操作
烘焙预览 Lightmap 光照效果(GPU Lightmass 渐进) 烘焙时视口就是 Lightmap 预览(第 7 章)
Lightmap Density 视图 纹素密度(红=过密/蓝=过疏) 视口 → 视图模式 → Optimization → Lightmap Density(Alt+0)
Lighting Only 视图 纯光照效果(无材质) 视口 → 视图模式 → Lighting Only
RenderDoc / PIX 抓帧 Lightmap 纹理像素本身(HQ 系数纹理) 抓 BasePass 帧,资源列表里找 Lightmap 纹理(TEXTUREGROUP_Lightmap 分组)

想看”Lightmap 长什么样”(第 4 档):RenderDoc 抓帧后,在纹理资源列表里按 TEXTUREGROUP_Lightmap 过滤——系数纹理是上下打包的两张(UV 变换见第 6.3 章),直接看是”编码过的颜色”(不是最终光照色),配合 DumpLightmapSizeOnDisk 命令(LightMap.cpp:183)查内存占用。

15.6 如何将 Lightmap UV 导出保存

Lightmap UV 就是网格的第二 UV 通道(UV1)——“导出 Lightmap UV” = 导出网格时带上 UV 通道:

方式 操作 结果
FBX 导出 Static Mesh 资产 → 右键 Export → FBX(默认导出全部 UV 通道) 在 DCC(Blender/Maya)里查看 UV1 = Lightmap UV
GLTF 导出 GLTFExporter(GLTFExporter)导出,配置 UV 通道 glTF 的 TEXCOORD_1 = Lightmap UV
程序化 UStaticMeshEditorSubsystem 或 Python 脚本读 LOD 数据里的 UV1 自定格式(JSON/CSV)
UV Editor 查看 打开网格资产 → UV Editor 插件 → Layer 选择 UV1 编辑器内直接看 Lightmap UV 布局

实践:① 烘焙前导出 Lightmap UV(给 DCC 检查重叠/密度);② 跨引擎迁移时把 UV1 一起导出(Unity 导入后指定为 Lightmap UV 通道);③ FBX 导出的 UV 通道顺序要与目标引擎的 LightMapCoordinateIndex 对应(UE 默认 UV1 索引 1)。


第 16 章 实战篇:跨引擎烘焙产线——通用方案

TL;DR|把”地图工程 → 导入 → 自动展 UV → 自动烘焙 → 导出”全管线提炼成通用方案:
单位三环节换算链(顶点缩放与摆放分开,中间格式只缩放一次)、坐标系轴置换 +
金标准回测
、灯光参数全系数可配置换算、headless 自动化烘焙命令模板、
实例化场景的标准导出三件套(实例级 UV 变换 + 网格级 UV + 图集)。
本章基于一个完整实战项目(650 实例/1208 piece/17 灯 + 太阳 + 天空)的验证与踩坑记录。

16.1 管线总览:五段式

1
2
3
4
5
6
地图工程(源引擎 .proj 场景)
→ ① 场景导出(dump:实例 GUID/prefab/transform + 灯光参数 + 环境)
→ ② 模型转换(唯一 prefab → GLB,顶点 ×0.01 缩放)
→ ③ UE 导入 + 布局(Interchange 导入 → 层级 Actor,名=GUID → 灯光/天空程序化)
→ ④ 自动烘焙(headless:-Rebuild 删旧数据 → Lightmass → 保存)
→ ⑤ 产物导出(xml 映射 + 网格 UV + 图集)

通用原则:① 场景信息(谁、在哪、什么灯)与 ② 几何数据(模型长什么样)分离——场景用文本/json 传递,几何用 glTF 传递。这样任何”引擎 A → UE”的迁移都可以复用同一套编排脚本。

16.2 单位体系:三环节换算链(最核心,含历史教训)

环节 单位 规则
源引擎(顶点/摆放/灯光半径) 1u = 1cm(与 UE 同) 摆放 1:1 直传(不进导入器)
中间格式 GLB 顶点 glTF 标准 = 米 导出时顶点 ×0.01(cm→m)
UE glTF 导入 ×100(米→厘米,GltfUnitConversionMultiplier = 100.f,InterchangeGltfPrivate.h:24) 自动
UE 摆放(Actor 位置/灯光半径) 1:1 手摆不进导入器,无二次换算

黄金法则(本项目两个历史坑的总结):
① 顶点缩放与场景摆放必须分开处理——任何”统一乘一个数”都会失调;
② 换算链必须两端 1:1、中间格式缩放一次:源(cm) ──顶点×0.01──▶ glTF(米) ──UE×100──▶ UE(cm)。
曾犯的错:把插件单模型导入的缩放(1/64)套到场景管线 → 全部 ×1/64”范围太小”;
又曾 1:1 顶点 + UE×100 → “模型巨大”。正解只有一个:0.01 顶点 + 1:1 摆放。

16.3 坐标系:轴置换 + 金标准回测

轴映射(Y-up → UE Z-up):(x, y, z) → (z, x, y)——纯置换(偶置换,无镜像),Blender 与 UE 同为 Z-up。

矩阵布局识别(先认布局再变换):

数据源 布局 判据
源引擎 GetWorldMatrix row-vector 平移在 m[12..14],行 = 轴
unreal.Matrix row-vector 4 × unreal.Plane = 4 行
mathutils / 部分导入库 col-vector 需先 transpose
glTF 节点 matrix 列主序数学 = UE row 存储 数组可直接平铺用

轴置换实现(已验证 diff=0):

1
2
3
p = [2, 0, 1]                          # (x,y,z) -> (z,x,y)
out[i*4+j] = m16[p[i]*4 + p[j]] # 3x3 行列双换
out[12..14] = t[p[0..2]] # 平移分量按 p 重排(易漏!)

灯光方向:UE 光发射方向 = 组件 +X 轴,rotator 必须用 yaw=atan2(y,x), pitch=atan2(z,hypot(x,y))——项目曾用 atan2(y,hypot(x,z)) 导致太阳朝上照、全黑。

金标准回测法:任何轴变换必须用已知实例(如旋转 (-90°,0,0) 的面板数值)验证
结果一致才算对——不要相信数学推导,相信回测。层级链的轴变换用共轭
(Y2Z · w · Y2Z⁻¹,单位链 = 恒等)保证嵌套变换不漂移。

16.4 灯光参数换算(全系数可配置)

源参数 换算 UE 属性
点光 multiply ×K_INTENSITY(≈1000 lm/引擎单位,实测点光 892~7458 lm) PointLight intensity
太阳 sun_color × multiply ×100×K_SUN_DAY(白昼=50 → ~34k lux;夜景=1 → ~689 lux) DirectionalLight intensity + light_color
太阳方向 (z,x,y) 置换后直接作发射方向 set_actor_rotation
环境光 ×multiply SkyLight(SLS_CAPTURED_SCENE, STATIONARY,属性名是 light_component)
雾 fog_density=0.001(UE 默认 0.02 会泛白) ExponentialHeightFog
衰减半径 1:1 attenuation_radius

通用原则:所有换算系数进配置文件(如 bake_config.json: {"bake_k": 1500, "import_scale": 1.0}),不写死在脚本——标定是美术/灯光师的工作,脚本只做”系数 × 直传”。

16.5 导入与 UV 通道约定

UV 通道四槽语义(跨引擎约定的通用模板):

通道 内容 说明
TEXCOORD_0 纹理 UV 原始贴图坐标
TEXCOORD_1 真实 UV 层 源引擎的 uv1 迁移
TEXCOORD_2 (备用) —
TEXCOORD_3 Lightmap UV UE 原生展开(Interchange 改动:bGenerateLightmapUVs=true + DstLightmapIndex=3)

通用原则:Lightmap UV 永远交给 UE 生成(不要从源引擎带)——UE 的展开/装箱质量可控且自动;源引擎的 UV 只保留纹理语义。

不合并 piece:复合预制件每 leaf 一个 StaticMeshActor(KEEP_PIECES),节点层级/名称保留源 GUID——GUID 命名是”烘焙产物 ↔ 场景实例”对齐的锚点(导出 xml 的 guid 与实例 label 同源)。

命令管线注意:-ExecutePythonScript 不透传 argv → 参数走环境变量(如 BAKE_* 前缀);bash 需 MSYS_NO_PATHCONV=1。

16.6 Headless 自动化烘焙(通用命令模板)

1
2
UnrealEditor-Cmd <项目>.uproject -run=ExportBakedLightmaps -Rebuild -SetLightmapRes=64
-AllowCommandletRendering -MAP=/Game/<关卡> -OutDir=<导出目录> -unattended -nop4 -nosplash
参数 作用 坑
-Rebuild 删旧 BuiltData → LoadMap → BuildLightMaps → 保存 → 导出 旧 BuiltData 污染是”lightmap 静默失效”的病根(1208/1208 → 0/1208),-Rebuild 自动处理
-SetLightmapRes=N 加载后把所有唯一 mesh 的 LightMapResolution 设为 N ⚠️ Interchange 导入默认 4(4²=16 texel/piece = 豆腐块)——必须显式设 64/128
-AllowCommandletRendering 允许渲染 必须——否则 NullRHI 下 Lightmass 建 RT 断言崩
-unattended -nop4 -nosplash 无人值守 —

耗时参考(650 实例/1208 piece 规模):res=64 ≈ 2.5 分钟、res=128 ≈ 10 分钟。

依赖的引擎补丁(升级引擎需重打,通用清单):Interchange 管道默认值改动、StaticLightingSystem.cpp 的 commandlet 守卫(IsRunningCommandlet() 包 UpdateMapNeedsLightingFullyRebuiltState)、Renderer 编译补丁、项目级导出插件注册。

数据级验证:烘焙日志 storing lightmap data for 1208 meshes in 10 LightmapResourceClusters + 独立进程加载 mesh items with lightmap: 1208。

16.7 导出产物:实例化场景标准三件套

文件 内容 格式
lightmap.xml 实例映射:<mesh guid="..." uvScale="x,y" uvBias="x,y" texIndex="N"/> XML
lmu/<MeshName>.lmu 每唯一网格:[i32 顶点数][f32×2×N UV][i32 三角形数][u32×3×N 索引](取自 LOD0 UV[LightMapCoordinateIndex]) 二进制
atlas_N.bin 图集原始数据 二进制

通用原则:实例级 UV 变换(uvScale/uvBias/texIndex)与网格级 UV(.lmu)分离导出——这是实例化场景(ISM/静态网格实例)的标准模式:网格数据一份,实例变换一份,对端引擎按 uvScale/uvBias 重建每实例的光照贴图采样。

16.8 开/关与调试(美术排查通用流程)

操作 效果
视口 View Mode → Lighting Only 只看 lightmap 烘焙结果(阴影/渐变/光池)
视口 View Mode → Lightmap Density UV 密度诊断(导入默认 res=4 时可能全黑)
World Settings → Force No Precomputed Lighting 开/关 lightmap 的权威开关(勾上 Lighting Only 应变全黑)
数据级对比 删/改名 *_BuiltData.uasset 重开 = 无 lightmap;恢复 = 重跑烘焙
r.AllowStaticLighting ❌ 只读 cvar(ini 读死,运行时改了没反应)

排查顺序(病根思维):① 数据污染(旧 BuiltData)→ ② 开关状态(Force No Precomputed Lighting)→ ③ 分辨率(LightMapResolution 是否被默认 4 坑)→ ④ 代码/参数。

16.9 通用方法论总结(从实战坑中提炼)

  1. 单位:顶点缩放与摆放分离,中间格式只缩放一次,两端 1:1;
  2. 坐标:轴置换用金标准回测验证,层级链用共轭保证不漂移;
  3. 参数:所有换算系数进 config(标定是内容团队的工作);
  4. 数据:GUID 命名贯穿(实例 ↔ 产物对齐锚点);
  5. 自动化:headless 命令 + 环境变量传参 + 数据级验证(mappings 计数);
  6. 病根:问题先查数据污染/开关/默认值,再查代码;
  7. 导出:实例级变换与网格级数据分离(对端引擎可重建)。

第 17 章 答疑篇(二):参数解析、通道全景与漏光优化

TL;DR|三个高频主题一次讲透:① Lightmass 参数逐项解析(世界设置 20+ 项 +
GPU Lightmass 面板,每组参数干什么、什么时候调);② 通道全景(UV 通道 4 槽、
Lightmap 纹理通道 5 种、ShadowMap 通道 4 槽、VT 通道 5 层——每个通道的语义与使用);
③ 漏光专题(成因分类 → 排查流程 → 六项优化手段)。类比:**参数是相机的
旋钮、通道是贴图的抽屉、漏光是贴图之间的”串味”**。

17.1 Lightmass 参数逐项解析

世界设置 → Lightmass 分组(FLightmassWorldInfoSettings,WorldSettings.h:55-239)

① 尺度与速度:

参数 默认 解析 什么时候调
StaticLightingLevelScale 1 世界缩放补偿(1 UU = 1 cm);缩放所有尺度相关参数(SmallestTexelRadius 等) 大关卡设 2-4(注释原话”可大幅缩短构建”);小关卡保持 1
NumIndirectLightingBounces 3 间接光反弹次数(每次反弹都放大采样成本) 室内/密闭 4-5;室外 2 足够;反弹越多越暖(色溢)
NumSkyLightingBounces 1 天空光反弹次数 默认 1;高反照场景(雪地)可 2
IndirectLightingQuality 1 GI 求解采样数倍率(平方关系:2 = 4 倍采样) 预览 0.5、正式 1;质量不够先升它
IndirectLightingSmoothness 1 间接光平滑(<1 保留细节但噪点更多) 噪点多 → 升;细节丢 → 降

② 颜色与环境:

参数 默认 解析
EnvironmentColor / EnvironmentIntensity 灰/1 无天空光时的环境色(烘焙环境兜底)
EmissiveBoost / DiffuseBoost 1/1 自发光/漫反射倍增(历史遗留,GPULightmass 下作用有限)
bVisualizeMaterialDiffuse false 烘焙调试:把材质漫反射可视化(核对”烘焙器看到的材质”)

③ 体积光照:

参数 默认 解析
VolumeLightingMethod VolumetricLightmap 体积光照法(动态物体在静态光照中的采样方式)
VolumetricLightmapDetailCellSize 200 体积光照网格密度(越小越密越贵)
VolumetricLightmapMaximumBrickMemoryMb 30 砖块数据内存上限
VolumetricLightmapSphericalHarmonicSmoothing 0.02 SH 平滑(噪点与细节平衡)

④ 环境光遮蔽(AO):

参数 默认 解析
bUseAmbientOcclusion false 烘焙 AO(接触阴影)
DirectIlluminationOcclusionFraction 0.5 直接光 AO 强度
IndirectIlluminationOcclusionFraction 1.0 间接光 AO 强度
OcclusionExponent 1.0 AO 衰减曲线
MaxOcclusionDistance 200 AO 有效距离(越小越局部)

⑤ 压缩:bCompressLightmaps=true(关闭内存×4,第 9 章)。

GPU Lightmass 面板(UGPULightmassSettings,GPULightmassSettings.h:33)

参数 默认 解析 什么时候调
GISamples 512 每像素 GI 采样数(质量核心旋钮) 预览 64、正式 512-1024;噪点 → 升
StationaryLightShadowSamples 128 Stationary 灯阴影采样 阴影噪点 → 升
bUseIrradianceCaching true 辐照度缓存(相邻点 GI 复用,大幅加速) 追求极致质量可关(慢但更准)
IrradianceCacheQuality 128 缓存质量(越高越准越慢) 缓存导致的伪影 → 升
IrradianceCacheSpacing 32 缓存点间距 缓存伪影 → 降
bUseFirstBounceRayGuiding false 首弹射线引导(复杂光照更准) 大场景质量不够时开
VolumetricLightmapQualityMultiplier 4 体积光照质量倍率 体积光照糊 → 升
TilePassesInSlowMode/FullSpeedMode 1/8 慢速/全速模式的每 tile pass 数 预览用慢速看效果,定稿用全速
LightmapTilePoolSize 55 tile 池(显存预算) 爆显存 → 降(烘焙变慢)
bCompressLightmaps true 压缩 调试串色 → 临时关
IntelOIDN 去噪 自动 烘焙去噪 —

17.2 通道全景:每个通道是什么、怎么用

① 网格 UV 通道(LightMapCoordinateIndex 选谁)

模型最多 4 套 UV(TEXCOORD_0~3),LightMapCoordinateIndex(StaticMesh.h:1221)指定哪一套是 Lightmap UV:

通道 常用语义 建议
TEXCOORD_0 纹理 UV(贴图坐标) 保留给美术贴图
TEXCOORD_1 第二套真实 UV(源引擎 uv1) 按需
TEXCOORD_2 备用 —
TEXCOORD_3 Lightmap UV(UE 原生展开) 推荐专用——与纹理 UV 隔离,改贴图不碰光照

使用:资产 Build Settings 的 DstLightmapIndex 指定生成到哪个通道;跨引擎约定 TEXCOORD_3 = Lightmap(第 16.5 章)。

② Lightmap 纹理通道(FLightMap2D 的纹理抽屉)

FLightMap2D(LightMap.h:198)持有 5 种纹理:

纹理 存什么 说明
Textures[0](系数) LogLUVW(亮度+色度) 必需,上下打包上半区
Textures[1](方向/SH) Dx/Dy/Dz + 残差(HQ 才有) 法线响应方向性(下半区)
SkyOcclusionTexture 天空遮挡(AO 变体) bGenerateAmbientOcclusionMaterialMask 时
AOMaterialMaskTexture AO 材质遮罩 材质用
ShadowMapTexture 静态阴影(Stationary 灯) 见 ③

采样:两个系数纹理上下打包进一张(UV.y 乘 0.5 切分,LocalVertexFactoryCommon.ush:118-128)。

③ ShadowMap 通道(Stationary 灯静态阴影)

FLightComponentMapBuildData::ShadowMapChannel(MapBuildDataRegistry.h:150):4 个通道(0-3)——Stationary 灯的静态阴影遮罩分配其中一个通道,延迟光照时按通道匹配(bShadowChannelValid[4])。使用:场景里 Stationary 灯共享阴影纹理,通道分配由引擎自动做;4 盏以上 Stationary 灯时注意通道冲突(阴影互相覆盖)。

④ VT Lightmap 通道(5 层)

r.VirtualTexturedLightmaps=1 时(LightMap.cpp:95):LightmapVirtualTexture 5 层(LightmapVirtualTexture.h:9-18)——Layer0/1(系数+方向)、ShadowMask、SkyOcclusion、AOMaterialMask。使用:大世界场景开启(按可视性流送);每个纹素 5 次 VT 采样,移动端谨慎。

⑤ 体积光照通道(Volumetric Lightmap)

VolumeLightingMethod=VolumetricLightmap:brick 网格存 SH3(9 系数)——动态物体在静态环境中采样。VolumetricLightmapDetailCellSize=200 控制网格密度。使用:需要动态角色”吃”到静态光照时开(默认开)。

17.3 漏光(Light Bleeding)问题全集

成因分类(六类)

# 成因 表现 原理
1 UV padding 不足 墙角/岛边缘串色(最常见) 双线性采样抄了相邻岛的颜色——UV 岛间距 < 4px
2 UV 岛重叠 光照错位/互相污染 Lightmap UV 有重叠岛(CheckLightMapUVs 可查)
3 纹理压缩串色 压缩后边缘糊+串色 BC7 块压缩跨越岛边界
4 纹素密度过低 大块亮斑(常被误认为漏光) 密度不足 = 每个纹素覆盖太大世界面积
5 LOD 间 UV 不一致 LOD 切换时光照跳变 简化 LOD 的 Lightmap UV 与 LOD0 不对应
6 动态/静态交界 动态物体阴影边缘”渗光” 动态阴影与烘焙 AO 的混合边界

排查流程

1
2
3
4
5
6
7
看到漏光 → ① 定位:Lighting Only 视图(确认是 Lightmap 问题)
→ ② 分类:墙角/岛边缘?(padding/重叠)vs 大面积?(密度)
→ ③ 交叉验证:临时关 bCompressLightmaps 重烘
- 关后好了 → 压缩串色(成因 3)
- 关后依旧 → UV 问题(成因 1/2)
→ ④ 查 UV:CheckLightMapUVs(Missing/Bad/Valid)+ Density 视图
→ ⑤ 对症修复(下表)

六项优化手段

手段 修什么 操作
加 padding 成因 1 装箱 4px 默认;UV 编辑里手动加岛间距(或 GLightmassDebugOptions.bPadMappings)
修复重叠 UV 成因 2 重新生成 Lightmap UV(Build Settings → Apply Changes)或 UV Editor 修岛
关压缩排查 成因 3 bCompressLightmaps 临时关 → 确认后换 VT 有损压缩/调格式
提密度 成因 4 LightMapResolution 升档(Density 视图定位红色区域)
LOD 一致性 成因 5 确保各 LOD 的 Lightmap UV 同源(简化时保留 UV1/UV3)
AO 补接触阴影 成因 6 bUseAmbientOcclusion 开 + DirectIlluminationOcclusionFraction 调

17.4 图集合并与纹理精简:不是 5 种都要烘

① 跨实例合并(Atlas)——默认自动:编辑器侧装箱(FLightMapPendingTexture 的 skyline 装箱,第 5 章)把场景所有 Actor 的光照贴图打包进共享图集(默认 1024×512,PackedLightAndShadowMapTextureSize)。实际产物是”N 个 1024² 图集”而非”每物体一张”——实例通过 CoordinateScale/CoordinateBias 精确指向图集自己的区域。

② 同实例多纹理合一——系数+方向已打包成一张:两个系数纹理通过 UV 变换 float2(1,0.5) 上下切分进同一张贴图(第 6.3 章)——物理上只有一张。

③ 5 种纹理全是条件性的,不是必须全烘:

纹理 必须? 什么时候有
Textures[0] 系数(LogLUVW) ✅ 总是有 Lightmap 本体
Textures[1] 方向/SH ❌ 仅 HQ r.HighQualityLightMaps=1(默认)才有;LQ 只需一张
SkyOcclusionTexture ❌ 可选 开天空遮挡相关设置才有
AOMaterialMaskTexture ❌ 可选 材质需要 AO mask 才有
ShadowMapTexture ❌ 仅 Stationary 灯 全 Static 灯 = 无 ShadowMap(阴影直接烘进 Lightmap)

结论:

  • 最小配置(全 Static 灯 + LQ):只需 1 张系数纹理;
  • 典型 HQ 配置:系数+方向已合一 = 物理 1 张(上下打包);
  • 带 Stationary 灯:+1 张 ShadowMap 纹理(共享 4 通道);
  • 精简手段:不用 HQ 方向性(r.SupportLowQualityLightmaps 强制 LQ)→ 省方向纹理;不用天空 AO → 省 SkyOcclusion;全 Static 灯 → 省 ShadowMap。

① 合并原理回顾(第 4.5 章):装箱完成后每个实例的 CoordinateScale/CoordinateBias 让它精确指向图集自己的区域——图集合并是引擎内置的,不需要手工拼图。想控制图集数量:PackedLightAndShadowMapTextureSize(默认 1024)决定单张图集上限,超出自动开新图集。

防患于未然(制作期规范)

  1. UV 规范:Lightmap UV 岛间距 ≥ 4px、无重叠、密度统一(第 8 章);
  2. 材质规范:漏光高危材质(自发光/亮色)单独验证;
  3. LOD 规范:简化时保留 Lightmap UV 通道(bLerpUVs 影响 Nanite LOD 的 UV 保真,EngineTypes.h:3297);
  4. 验收规范:烘焙后 Lighting Only 全场景过一遍 + Density 视图抽查。

第 18 章 校准篇:光照对齐校准与产物导出(引擎↔UE 数值等价链)

TL;DR|跨引擎烘焙最难的从来不是”烘出来”,而是”数值对齐“——本章给出一条经过
源码推导与实战验证的数值等价链:颜色空间(pow2.2 vs sRGB LUT,偏差 ≤2.2%)、
强度换算(1/π 精确对消,K≈0.83)、灯衰减(smoothstep vs falloff exponent 的坑)、
IBL/SkyLight(无方向遮蔽 × AO 的组合语义)、雾与曝光(A/B 锁定)、Atlas 数量公式
(≈1.2×mesh 数×res²/1M)、headless 产物导出(FTextureSource 取数)。全部关键引用
已与源码逐一验证。

18.1 颜色空间:pow(2.2) vs sRGB LUT(已定论,勿再改)

  • 源引擎灯色:pow(c/255, 2.2) LUT(幂次 2.2 伽马);UE 侧 unreal.Color(r,g,b,255)(FColor)自动精确 sRGB→linear(Color.h:70 sRGBToLinearTable + 构造 :839-840);
  • 两者等价:实测最大偏差 ≤2.2%(8bit 观感差 0~1/255,肉眼不可辨)——颜色链路不要再动;
  • 注意:雾色/后处理色 = FLinearColor 直写(无解码),与灯色的 sRGB 语义不同——fog_inscattering_luminance 直写 LinearColor 值,别套灯色的换算。

18.2 强度换算:1/π 精确对消(核心修正)

推导链(两引擎的着色公式对比):

1
2
3
源引擎: L = A × [Fr_DisneyDiffuse × INV_PI] × NdotL × fAtten × C_linear × fMultiply × globalIntensity
UE : L = A/π × I_lm × C × (1-(d/R)²)^e (非反平方模式)
⇒ UE_intensity(lm) = K_CONV × fMultiply,K_CONV = lerp(1.0, 1/1.51, α) ← 1/π 精确对消!
  • K_CONV = 0.831(α=0.5 标定面;α=源引擎表面粗糙度,范围 [0.662(r=1), 1.0(r=0)]);
  • 仅 use_inverse_squared_falloff=False 成立!反平方 + Lumens 时 K≈1.04e-3(差 800 倍——单位换算与衰减模式耦合,PointLightComponent 的强度单位语义);
  • 不乘的项:百分比强度、反平方开关(只切换体积雾收集)、强度截断——别把”看起来该乘的”都乘上;
  • 隐藏系数:fMultiply ×= GetPercent(profile_mapping_onoff)——高位掩码必须有,裸 0x0001 会返回 0 把灯灭掉(源引擎的位打包语义,跨引擎移植时最容易踩)。

18.3 灯衰减:引擎 smoothstep vs UE falloff exponent

  • 源引擎:fAtten = smoothstep((d - begin)/(end - begin))(平移 smoothstep,无 1/d²);d ≤ begin 恒 1.0;
  • UE 映射:use_inverse_squared_falloff=False + attenuation_radius = 衰减终点 + **light_falloff_exponent=0.5**(PointLightComponent.h:38 LightFalloffExponent);
  • 默认 8.0 是坑:e=8 时 d=begin 处只剩 ~0.004,灯”消失”——e=0.5 是 smoothstep 的 SSE 最优近似(残差 ~0.29 可接受)。

18.4 IBL/SkyLight:无方向遮蔽 × AO 的组合语义(定论)

引擎 IBL = 无方向遮蔽的辐照度(直接加到场景,屋顶挡不住地板)——复刻它的两步:

步骤 设置 理由
① SkyLight 关阴影 cast_shadows=False 否则 UE Lightmass 的天空几何遮蔽采样 → 室内地板全黑(几何正确但不可见)
② 配套 Lightmass AO use_ambient_occlusion=True + occlusion_exponent=1.0 + max_occlusion_distance=5000 引擎 IBL 实际还乘柔遮蔽(aoAttenuate);只关阴影不配 AO → 室内被均匀照亮(”不受光照”观感);AO 距离要覆盖全室内结构
  • AO 距离的坑:默认 200 只罩 2 米内 → 室内中部无遮蔽 = 全亮无结构;5000 覆盖全室内才是”近亮远暗渐变 + 结构暗部”的正确观感;
  • AO 是光图 texel 值,不增加 atlas 数量;
  • cubemap 转换:源引擎的 equirect HDR(512×256 R11G11B10_FLOAT)→ UE cubemap(256²×6 面 RGBA16F DDS);采样映射 UV.x = -(AngleX+π/2)/(2π)(U 随方位角反向——逐字复刻源引擎的 CubeToRectangle);
  • DX10 DDS 坑:cubemap 的 arraySize = cubemap 个数(写 1,UE ×6=6 面;写 6 → ×36 校验失败”没内容可导”);Interchange 的 DDS translator 不支持 DX10 → 用 factory=TextureFactory 走旧路径;
  • 只取 mip0:源引擎的预滤波链(specular)不要整链导入——UE 自己卷积,只取原始 HDR mip。

18.5 雾与曝光:映射与锁定

雾(ExponentialHeightFog)(ExponentialHeightFogComponent.h):

源引擎 UE 属性
光深度偏移 StartDistance(:120,注意 VolumetricFog 不支持它,注释原话)
光学深度换算 fog_density(拟合初值 0.0015)
雾色(无 gamma) FogInscatteringLuminance(:43,属性名是 luminance 不是 color)
高度衰减 fog_height_falloff=0.02

曝光(必须锁定,否则全功尽弃):UE 默认 AE-Histogram 自动曝光会把平均亮度拉回 0.18 中灰——几百倍光强差异被吞。锁定 3 个 CVar:

1
2
3
r.EyeAdaptation.MethodOverride 3        (3=Manual,PostProcessEyeAdaptation.cpp:78)
r.EyeAdaptation.LensAttenuation 0.78 (→有效 LuminanceMax=1.0)
r.EyeAdaptation.PreExposureOverride 1

避坑:tonemap 默认 Filmic 非 ACES——跨引擎对比观感差异的常见来源(高光去饱和),A/B 项。

18.6 Atlas 数量公式:一个可预测的预算

实测铁证(中大型场景,约 1200 实例/115 唯一网格):

1
2
3
4
5
chart 尺寸 = LightMapResolution × 2(所有 mesh 统一)
atlas 数量 ≈ 1.2 × mesh 数 × res² / 1M

res=64 → 每 chart 62×124 texel → 10 个 1024² atlas(BuiltData ~68MB)
res=32 → 每 chart 31×62(1/4 texel)→ 3 个 atlas(BuiltData ~21MB,-70%)

用法:烘焙命令带 -SetLightmapRes=32(内存级,不改资产;漏带 = Interchange 默认 4 = 豆腐块);近景想更细 → 按世界尺寸分档(大网格 64/128、小网格 32),总量控制在 4 张内需大网格 ≤15%;res=16 极端版 ≈ 0.8 atlas(不推荐,阴影边缘起块)。

18.7 产物导出:headless 取数(FTextureSource)

坑:headless(-Cmd)下 FTexturePlatformData→Mips[].BulkData 恒 dataSize=0(platform data 未反序列化)——数据在 UTexture2D.Source(FTextureSource)。正确取数:

1
2
3
// 正确:从 Source 取 mip 数据(自动解压,Texture.h:329/332 内联重载)
TArray64<uint8> MipData;
TextureSource.GetMipData(MipData, 0, 0, MipIndex);

产物清单(导出器自动出):atlas_N.raw(1024²×4B=4.19MB/张,BGRA 字节序)+ skyoccl_N.raw(天空遮蔽层)+ lq_N.raw + lightmap.xml(实例映射)+ lmu/(网格 UV);Format=TSF_BGRE8(2)/bpp=4。转 DDS/PNG 用 raw2dds 工具(DX10 B8G8R8A8_UNORM + PNG/sRGB 双预览)。

枚举诊断:TObjectIterator<UTexture2D> 按 GetOuter()->GetName().Contains("BuiltData") 过滤——纹理名不含 Lightmap 的(SkyOcclusion/ShadowMap)才不会漏。

18.8 HQ/LQ 双编码与预览

  • HQ(Textures[0],恒生成):主光图——UV0/UV1 同纹理双采样(上下打包,第 6.3 章)——**”第一份+第二份”不需要分别导出**,都在同一张 atlas 里;
  • LQ(Textures[1],r.SupportLowQualityLightmaps 非 0 时):另编码(LogRGB 直接 rgb,LightmapCommon.ush:96-97:L = exp2(LogL*16-8) - 0.00390625);
  • HQ 解码公式(LightmapCommon.ush:155-166,已源码验证):
1
2
3
4
UVW = rgb² × Scale + Add(平方域颜色)
L = exp2(LogL) - 0.01858136(LogBlackPoint,对数亮度还原)
Directionality = max(0, dot(SH, float4(WorldNormal.yzx, 1)))(SH 方向性)
OutDiffuseLighting = L × Directionality × UVW
  • LQ 预览:数据层直接看导出的 lq 图;渲染层 r.HighQualityLightMaps=0(UnrealEngine.cpp:18479,ECVF_ReadOnly——控制台运行时无效,需 DefaultEngine.ini [SystemSettings] 启动强制,预览后删行恢复)。

18.9 通用踩坑清单(脱敏汇总)

坑 教训
环境数据旧 dump 缺键 源引擎环境导出格式更新后重 dump(缺失键 → 走纯白默认)
属性名拼写 跨引擎属性名常与直觉不同:light_falloff_exponent(真名 LightFalloffExponent)、fog_inscattering_luminance(不是 color)、override_materials(UE5.9 改名)——用前 grep 源码
32bit 打包色 0xAARRGGBB(r=(v>>16)&255)——按位拆,别按字节序猜
DX10 cubemap arraySize 写 1(面数/6 语义),UE ×6;写 6 → ×36 校验死
预滤波链 只取 mip0(UE 自卷积),别整链导入
同名资产复用 重导入不更新 → 导入前清空目标目录
曝光没锁 自动曝光吞掉光强差异 → 3 个 CVar 锁定(18.5)
材质无关的”黑” 排查顺序:光图语义(IBL 遮蔽/AO)→ 开关 → 数据污染——先排除材质(probe 验证 MI 参数/链)

第 19 章 答疑篇(三):Instance 支持与 GPU Driven

TL;DR|两个高频问题一次讲清:Lightmap 怎么支持 Instance(每实例一套 UV Bias
——FPerInstanceLightmapData 的 LightmapUVBias/ShadowmapUVBias 让所有实例共享同一张
atlas、各自指向自己的区域;ISM 实例打包按 宽 × ceil(√实例数) 排成正方形大图)与
GPU Driven 下的 Lightmap(Nanite 网格在材质评估时从 Primitive Uniform 读
LightmapUVIndex 采样光照贴图——光图采样与几何渲染解耦,GPU 驱动不影响 Lightmap)。
类比:实例 = 同一张贴图的”多人共享”,Bias = 每人指到自己座位的偏移量。

19.1 实例化(ISM)的 Lightmap:一图多用,每人指自己的区

核心机制:实例化场景(成千上万个树/石头/路灯共用同一个网格)的 Lightmap 不能”每实例一张”(爆炸)——引擎的做法是所有实例共享 atlas,每实例一套 UV Bias:

1
2
3
4
5
6
7
// FPerInstanceLightmapData(MapBuildDataRegistry.h:36)
struct FPerInstanceLightmapData
{
FVector2D LightmapUVBias; // 本实例在 atlas 中的 UV 偏移/缩放
FVector2D ShadowmapUVBias; // 阴影纹理同理
...
};
  • 数据:FMeshMapBuildData::PerInstanceLightmapData(每实例一条);
  • 分配:FLightMap2D::AllocateInstancedLightMap(LightMap.cpp:2215)——ISM 组件每实例一套量化系数与 Bias;
  • 采样:运行时实例缓冲携带 LightmapUVBias,shader 里 LightmapUV = UV1 × Scale + Bias 后采样共享 atlas——渲染侧与普通光照贴图无差别,只是 UV 多一步偏移。

实例打包公式(GPULightmass,InstancedStaticMesh.cpp:110,已源码验证):

1
2
3
// 所有实例的 lightmap 排进一个正方形大图:
// 边长 = 单实例 lightmap 宽 × ceil(√实例数)
int32 TotalLightmapRes = LightMapWidth * FMath::CeilToInt(FMath::Sqrt(实例数));

规模感:单实例 lightmap 64² × 100 实例 → 边长 640² 一张图装下;每实例通过 Bias 指向自己的 64² 区域。这是”100 个路灯只占一张 lightmap”的机制。

避坑:ISM 的 LOD 间 Lightmap UV 不一致会导致光照错误(GPULightmass 会在日志警告)——实例化网格的各 LOD 必须用同一套 Lightmap UV 生成。

19.2 顶点颜色实例与 Lightmap 的选择

实例化场景还有一个”伪光照”方案常被混淆:顶点颜色(Vertex Color)——把亮暗烘焙进顶点色,运行时零额外贴图。与 Lightmap 的对比:

顶点颜色 Lightmap
精度 顶点级(大平面会糊) 纹素级
存储 顶点数据里(小) 贴图(大)
方向性 无(只有亮暗) HQ 有 SH 方向性
适用 装饰性暗化/便宜的老树 需要真实光照的物体

实践:远景装饰(草丛、小道具)用顶点颜色 + Lightmap 双通道(顶点色做暗化、Lightmap 做真实光照);近景道具必须 Lightmap。

19.3 GPU Driven 与 Lightmap:解耦,互不干扰

GPU Driven(Nanite/实例化渲染)对 Lightmap 的态度:完全兼容,机制解耦——因为 Lightmap 是”材质阶段采样”,与”几何阶段怎么画”无关:

Nanite 网格的 Lightmap:

  • FNaniteSceneProxy::GetLightMapCoordinateIndex(NaniteSceneProxy.h:553,已源码验证)——Nanite 网格同样声明自己的 Lightmap UV 通道;
  • 关键差异:Nanite 不走顶点流(簇三角形在 GPU 上生成)——Lightmap UV 通过 Primitive Uniform 携带(PrimitiveUniformShaderParametersBuilder.h:76/156:LightmapUVIndex,默认 INDEX_NONE)——材质评估时按 LightmapUVIndex 从光图采样;
  • 含义:Nanite 的 GPU 驱动渲染不改变 Lightmap 的任何机制——光图照常烘焙、照常采样,只是 UV 的获取方式从”顶点流”变成”Primitive Uniform + 簇插值”。

GPU Driven 场景的 Lightmap 预算(与第 16.6 章 atlas 公式呼应):

1
2
3
GPU 驱动渲染(Nanite/ISM)→ 几何 DrawCall 几乎免费 → 瓶颈转移到光图采样
→ Lightmap 预算(atlas 数量/分辨率)成为实例化场景的主要显存与带宽项
→ 优化:统一纹素密度 + 分档分辨率(第 10 章)+ 实例共享 atlas(19.1)

实践结论:

  1. Nanite 大世界:Lightmap 照常烘焙使用(bUseNanite 网格自动带 Lightmap UV);实例化(Nanite ISM)自动共享 atlas;
  2. GPU Driven 的收益与 Lightmap 无关:省的是几何阶段(剔除/绘制),光图阶段(采样/带宽)是独立的账——别指望 GPU Driven 省光图内存;
  3. 光图采样在移动端是带宽大头:GPU 驱动把几何省下来后,Lightmap 采样带宽反而更显眼——用 LQ 编码/VT 流送控制(第 9 章)。

19.4 动手实验室:实例化场景的 Lightmap 验收

步骤 操作 验收标准
① 建实例 一棵树 StaticMesh + ISM 摆 100 个实例,开启 Lightmap UV(第 8 章) 实例全部参与烘焙(日志无 UV 警告)
② 烘焙 GPU Lightmass 烘焙 日志 instanced lightmap 相关无 error
③ 验证 Lighting Only 视图:所有实例光照正确、各自独立 实例间无串光、无错位
④ 预算 查 atlas 数量(第 16.6 公式)与显存 100 实例 ≈ 单实例的 1 张图(Bias 复用)
⑤ 分档 远景实例降分辨率(顶点色补暗化) 总 atlas 控制在预算内

检查清单:实例光照正确 ✓、无串光 ✓、atlas 数量符合公式 ✓、LOD 间 UV 一致 ✓。


第 20 章 格式篇:贴图格式与 UV 接缝全解

TL;DR|把”一张 Lightmap 贴图里到底有什么”彻底讲清:一套 Lightmap = 最多 5 类数据
(HQ 主纹理必出,其余按条件)——HQ 主纹理上下半区打包:上半 = RGB=√辐照度 + A=LogL 亮度,
下半 = RGB=L1 球谐方向 + A=LogL 残差;格式 R8G8B8A8;压缩链 = HDR(16F) → sqrt/对数编码 → 8bit 量化 → Scale/Add 归一化 → 纹理压缩(BC7)。Lightmap UV 五条硬性要求(岛不重叠/0-1
空间/密度一致/padding≥4px/通道专用),接缝四类与处理(chart 缝靠 padding、几何缝靠
插值、LOD 缝靠同源 UV)。全部公式已与源码逐行对照。

20.1 一套 Lightmap 有几类数据、各存什么

# 数据 生成条件 存什么 格式
1 HQ 主纹理 恒生成 光照颜色(平方域)+ 对数亮度 + L1 球谐方向 + 亮度残差(上下打包,见 20.2) R8G8B8A8
2 LQ 纹理 r.SupportLowQualityLightmaps 非 0 LogRGB 颜色 + 方向(见 20.4) R8G8B8A8
3 SkyOcclusionTexture 需要天空遮蔽 被屋顶/墙遮挡的天空可见性 R8G8B8A8
4 AOMaterialMaskTexture 需要材质 AO 遮罩 AO 遮罩 R8G8B8A8
5 ShadowMapTexture 有 Stationary 灯 静态阴影遮罩(4 通道) R8G8B8A8

最小配置 1 类(全 Static 灯 + LQ);典型 HQ 配置 = 第 1 类(上下已合一);只有用
Stationary 灯才 +ShadowMap。

20.2 HQ 主纹理:上下半区各是什么(源码逐行对照)

一张物理纹理,上下两个逻辑半区(UV 变换 float2(1, 0.5) 切分,LocalVertexFactoryCommon.ush:118-128):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
一张 R8G8B8A8 纹理(宽 W × 高 H)
┌──────────────────────────────────────────────┐
│ 上半区(UV.y ∈ [0, 0.5),高 H/2) │
│ Lightmap0 每像素: │
│ RGB = √辐照度(平方域颜色,解码时 rgb² 还原)│
│ A = LogL(对数亮度粗值,8bit) │
├──────────────────────────────────────────────┤
│ 下半区(UV.y ∈ [0.5, 1),高 H/2) │
│ Lightmap1 每像素: │
│ RGB = L1 球谐方向(yzwx swizzle 存储) │
│ A = LogL 残差(补精度:+1/255 步进) │
└──────────────────────────────────────────────┘
采样:LightmapUV0 = Coord × (1, 0.5)
LightmapUV1 = LightmapUV0 + (0, 0.5)

为什么这样分:光照的亮度动态范围巨大(阴影 0.01 ~ 阳光 500+),而颜色与方向的动态范围小——所以亮度走对数编码(A 通道)+ 残差补精度(下半 A),颜色走平方域(RGB 解码时开方还原保暗部精度),方向走 SH(下半 RGB)。

编码端源码(LightmapEncoding.ush:56-71,已逐行对照):

1
2
3
4
5
6
// FinalizeLightmapIrradiance:辐照度 → 编码
// sqrt 存平方域(解码 rgb² 还原,暗部精度高)
float4 EncodedIrradiance = float4(
sqrt(max(Irradiance, 1e-5)), // RGB:平方域颜色
log2(1 + LogBlackPoint) - residual); // A:LogL(减去残差后存粗值)
// SH 方向 yzwx swizzle(与像素着色器匹配)→ 存下半区 RGB

解码端源码(LightmapCommon.ush:146-166,已逐行对照):

1
2
3
4
5
6
7
half LogL = Lightmap0.w;                        // A:亮度粗值
LogL += Lightmap1.w * (1.0/255) - (0.5/255); // + 残差补精度
LogL = LogL * Scale.w + Add.w; // 范围缩放
half3 UVW = Lightmap0.rgb² × Scale.rgb + Add.rgb; // 平方域还原颜色
half L = exp2(LogL) - LogBlackPoint; // 对数还原亮度(0.01858136)
Directionality = max(0, dot(SH, float4(WorldNormal.yzx, 1))); // SH × 法线
OutDiffuseLighting = L × Directionality × UVW;

20.3 压缩链:从 HDR 到 8bit 的完整链路

1
2
3
4
5
6
烘焙(GPU Lightmass tile,RGBA16F HDR)
→ ① 编码:sqrt(颜色平方域)+ log2(亮度对数) ← 压缩动态范围
→ ② 量化:8bit(FColor RGBA8) ← 每通道 0-255
→ ③ Scale/Add:按全图 min/max 归一化 ← 让 0-255 用满
→ ④ 纹理压缩:BC7(SetModernSettingsForNewOrChangedTexture,LightMap.cpp:673)
→ 存储/流送(TEXTUREGROUP_Lightmap)

每步干什么:

  • ① sqrt/log2:把大动态范围(0.01~500+)压进”8bit 够用”的形态——暗部精度保留(平方域/对数域在小值处更密);
  • ② 量化:HDR float → 8bit 整数(每通道),QuantizeLightSamples(LightmapEncoding.cpp:35);
  • ③ Scale/Add:每张图按全图 min/max 算(ScaleVectors/AddVectors,LightMap.h:338-341)——量化值 = (原始 - min) / (max - min) × 255,解码 原始 = 量化 × Scale + Add;
  • ④ BC7:GPU 硬件压缩格式(每 4×4 块 16 字节,~8:1)。

为什么这么绕:直接线性存 8bit → 暗部全丢(0-255 里 0.01 和 1 都挤在第 1 档);对数/平方域让暗部有足够档位,Scale/Add 让每张图用满 255 档。这就是”8bit 行李箱”的完整打包法。

20.4 LQ 编码(低质量/移动端)

LQ(LightmapCommon.ush:78-130,已对照)更简单:

1
2
3
4
5
// Lightmap0.rgb = LogRGB(三分量对数颜色,直接存)
half3 LogRGB = Lightmap0.rgb × Scale.xyz + Add.xyz;
half LogL = Luminance(LogRGB); // 亮度由颜色推导(不单独存)
half L = exp2(LogL × 16 - 8) - 0.00390625; // LogBlackPoint=exp2(-8)
// Lightmap1.rgb = SH 方向(可选);无方向时 Directionality = 0.6 常数

LQ vs HQ 差异:LQ 不单独存亮度(从颜色 luminance 推导)→ 少一个精度维度;LogL 编码用 ×16-8 的移位式(比 HQ 的残差方案粗);无方向性时用 0.6 常数方向(省 SH 纹理读取)。

20.5 Lightmap UV 的硬性要求(五条)

# 要求 违反的后果 检查方式
1 UV 岛不重叠 光照互相污染(错位/串色) CheckLightMapUVs(StaticMesh.cpp:9952)
2 岛必须在 0-1 空间 越界 = 采样到图集外(错色) UV 编辑器/导出检查
3 密度一致(无拉伸) 拉伸区光照糊/浪费 Lightmap Density 视图
4 岛间距 ≥ 4px(padding) 双线性采样串色(漏光) 装箱 4px 默认 + 手动加
5 通道专用(不与纹理 UV 混用) 贴图改动影响光照 专用 UV3(TEXCOORD_3)

为什么要求不重叠 + 0-1:Lightmap 的每个纹素对应 UV 平面上的一个位置——烘焙器按 UV 位置写光照,采样器按 UV 位置读——**UV 平面就是”光照的画布”**,重叠/越界 = 画布上的画互相覆盖/画到画布外。

20.6 接缝(Seam)处理:四类缝与对策

① UV 岛边界缝(chart seam)——最常见

同一连续表面被切成多个岛(chart),岛边界的光照值在烘焙时是连续的,但采样时双线性插值可能采到岛外(越界到别的岛/空白):

1
2
3
处理:padding(岛间距)——装箱 4px 对齐(LightMap.cpp:491)+ 岛间留 ≥4px 空白
→ 双线性插值在空白区平滑过渡,不串到别的岛
→ 引擎还留"mapping gutter":bPadMappings 时每侧 1 texel(LightmassScene.cpp:261-314)

② 几何接缝(连续面被切开)

一个平面被切成两个岛(各自展开),光照值理论上相同,但量化/压缩后两岛的值可能差 1-2 档 → 缝线可见:

1
2
处理:① padding 让过渡区存在;② 分辨率足够高时差值 < 1/255 不可见;
③ 严重时在 DCC 里把岛合并(UV 编辑器重新展开减少岛数)

③ LOD 接缝

LOD 间 Lightmap UV 不一致 → 切换时缝线跳变:

1
2
处理:所有 LOD 用同源 Lightmap UV(简化时保留 UV 通道);
ISM 的 LOD 间 UV 必须一致(GPULightmass 会警告)

④ 法线/方向缝(SH 方向性 + 法线贴图)

HQ 的方向性(SH)与法线贴图交互——两个岛的法线不同导致光照跳变(常见于硬边模型):

1
2
处理:硬边(烘焙法线分开的角)与软边的岛划分要与法线一致;
NormalMap 强度过高时缝更明显——法线贴图通道与 Lightmap UV 的岛边界对齐

排查顺序:看到缝 → ① 放大看是”颜色跳变”(几何/量化缝)还是”串色”(padding 不足)→ ② Density 视图确认密度 → ③ 修对应成因。

通用原则:缝的可见度 = 相邻纹素值差 × 边界过渡质量——差小(量化 1-2 档)靠 padding 平滑;差大(岛间光照真不同)靠合并岛/修 UV。


第 21 章 导入篇:顶点重排 · 重叠顶点 UV · BuildData 全解

TL;DR|模型进引擎后顶点会被”重组”三遍(FBX 控制点转顶点 → 构建期 Compact/清理 → 渲染缓冲按属性拆分),但 UV 是”角的属性”,永远跟着角走,数值与语义不丢。真正能改变 lightmap UV 展开结果的只有三件事:三角形被删(退化剔除)、顶点位置误差超阈值(0.00002cm)、UV 值本身被改写。展开器判断”两块面能不能拼成一个岛”不看顶点是否共享,只看”位置是否重合 + UV 是否连续”(LayoutUV.cpp:117-202)。烘焙数据(BuildData)存在关卡持有的 MapBuildDataRegistry(Level.h:559),按组件/灯的 GUID 键控,只随关卡包保存而保存——不懂它的生命周期,烤完忘保存、复制 Actor、切换 VT 都会让你”白烤”。

21.1 导入后的”顶点重排”:UV 到底会不会丢

21.1.1 引擎的顶点是”两级”的:Vertex(共享)与 VertexInstance(角)

所有”重排会不会破坏 UV”的恐惧,都源于把引擎顶点当成 DCC 里那种”一个位置 + 一套属性”的顶点。引擎里它是分开的:

概念 数量关系 属性归属
Vertex(顶点) 一个空间位置,被多个角共享 只有 Position
VertexInstance(顶点实例/角) 每个三角形角一个(cube = 12 个角) UV/法线/Tangent/顶点色全部挂在角上
Triangle(三角形) 引用 3 个角 PolygonGroup(材质)

类比实验室:人去坐席
Vertex 是”位置(座位)”,VertexInstance 是”座位上的那个人”。搬桌子(顶点重排)不换人;
人(UV)自己带着随身行李(法线/切线)走。UV 是乘客的行李,不是桌子的螺丝。

21.1.2 FBX 导入:控制点 → Vertex,面角 → VertexInstance(源码实证)

FBX 的共享单位叫 Control Point(控制点),导入时引擎对它 1:1 建 Vertex:

1
2
3
4
5
FbxStaticMeshImport.cpp:732-736   for (int32 VertexIndex ... < Mesh->GetControlPointsCount())
FVertexID AddedVertexId = MeshDescription->CreateVertex(); // 每控制点一个顶点
:859-860 FVertexID VertexID(VertexOffset + ControlPointIndex); // 角 → 复用该顶点
:899-904 CreateVertexInstance(VertexID); // 每面角建一个"角"
:921 UVMapIndex = eByControlPoint ? ControlPointIndex : RealFbxVertexIndex // UV 按需查表

关键在 :921:FBX 的 UV 有两种映射——eByControlPoint(整块共享一套 UV,查控制点索引)或按面角索引(每个角独立 UV)。两种都存进”角”的属性。所以同一控制点被两个面以不同 UV 使用是完全合法的——角不同,UV 各带各的。

21.1.3 三处”顶点重排”及各自的影响

# 发生时机 引擎动作 影响 lightmap UV 吗
① 导入/重新导入 控制点→Vertex 实例化(上节) 不影响(UV 随角迁移)
② 网格构建(保存/重烘焙触发) Compact 重排全部 ID(MeshDescriptionHelper.cpp:43-48)→ ValidateAndFixData 清 NaN/Inf(MeshDescriptionHelper.cpp:51)→ 退化三角形剔除(MeshDescriptionHelper.cpp:54,阈值看 MeshDescriptionHelper.cpp:41) 清理 NaN 会修好展开;删退化三角形会让面”消失”
③ 渲染数据构建 渲染顶点缓冲按属性拆分:一个渲染顶点只能有一套法线/UV,共享顶点属性不同就复制成多个;之后 LOD 减面、索引缓冲优化继续重排 不影响——UV1 是角的属性,复制/重排后值不变;但多边形数变化(减面)会重展 UV(见坑 5)

源码考古:退化三角形是怎么被”认出”的
MeshDescriptionHelper.cpp:41:
ComparisonThreshold = (bRemoveDegenerates && !bForNanite) ? THRESH_POINTS_ARE_SAME : 0.0f
三个角位置全部重合(阈值内)的三角形被当退化删除。这个阈值在 UnrealMathUtility.h:201:
UE_THRESH_POINTS_ARE_SAME = 0.00002f(单位 cm,即 0.2 微米——比头发丝直径小三个量级)。
结论:只有”位置几乎完全一致”的重复面才会被删;正常的相邻面(哪怕顶点没焊接)不会误删。

21.1.4 顶点重排的”安全区”结论

不受重排影响的:UV0/UV1 的值与语义、岛与岛的关系(岛判定基于”位置几何 + UV 值”,见 21.2,与顶点 ID 无关)。

会被重排改变的(都要当心):

  1. bRemoveDegenerates 开 → 位置重合的零面积面被删(烘焙后该处无光照数据);
  2. NaN/Inf 顶点数据 → ValidateAndFixData 会改写/删除,坏 UV 会在此暴露;
  3. SrcLightmapIndex/DstLightmapIndex 钳制(MeshDescriptionHelper.cpp:106-122):通道超界会被静默改到 0 或补建通道——勾了”生成 Lightmap UV”但忘了看通道号,可能把 UV1 写到别处;
  4. LOD 减面后的新 LOD 要重新生成/烘焙自己的 lightmap UV(UV 与几何绑定)。

避坑|重导入(Reimport)时:导入选项变了(如切换 bRemoveDegenerates、UV 通道数、焊接开关)→ 引擎会重建并重新走一遍展开(若勾了自动生成)。原 UV1 若被覆盖,之前烘焙的数据位置就全错位了——重导入后必须重新烘焙。

21.2 顶点重叠:展开 Lightmap UV 的五个铁律

21.2.1 展开器怎么知道”两块面是一个岛”?—— 位置 + UV,与共享无关

源码考古:FindCharts 的并集规则(LayoutUV.cpp:99-202,CHART_JOINING=1 于 LayoutUV.cpp:17)
两三角形角 i、j 满足以下全部条件才并入同一岛(LayoutUV.cpp:128-199):

1
2
3
4
5
6
7
① 位置邻居:OverlappingCorners 里 i、j "重叠"          ← 纯位置阈值(见下)
② 共享边:i 所在三角形的另一角 = j 所在三角形的另一角(位置匹配,:139-141)
③ UV 连续:UVsMatch(i,j) && UVsMatch(边两端点) ← 差值 < 阈值
④ 绕序一致:TriangleUVArea(甲) × TriangleUVArea(乙) ≥ 0 ← 有向面积同号(:147),镜像 UV 会断岛
满足①②③④ → DisjointSet.Union(:198),一个岛
①+② 满足、③ 不满足(UV 断裂)但法线匹配且"UV 平移后边向量对齐"(:152-175)
→ 记入 TranslatedMatches(Chart.Join 缝合候选),后续按缝合边处理

“重叠”是纯位置判定:FindOverlappingCorners 把每个角的顶点位置按 Z 排序后做阈值比较(StaticMeshOperations.cpp:1849-1858),不看 UV、不看法线、更不看顶点是否共享。官方注释说得直白(StaticMeshOperations.cpp:2201-2202):
“Allow some threshold - this should not produce any error in a case if resulting mesh will not merge these vertices”——即使渲染时这些顶点不会被合并,展开也不出错。

UV 相等的阈值(LayoutUV.cpp:19-21 + UnrealMathUtility.h:201-204):

1
2
位置/法线相等阈值:0.00002(2e-5,cm / 归一化向量)
UV 相等阈值:旧版 1/1024 = 0.0009765625;新版(SmallChartPacking 起)收紧到 0.00002

21.2.2 五种”重叠”场景速查表

场景 位置 UV0 展开结果 实操含义
1. DCC 已焊接 + UV 连续 重合 连续 一个岛 标准情况,最省 padding
2. 没焊接但位置重合 + UV 连续 重合 连续 还是一个岛 展开器不看共享,不焊也能合(渲染法线照常硬边,互不耽误)
3. 位置重合但 UV0 断裂(每面独立 UV) 重合 断裂 断成多个岛 正常:UV0 硬边 = 展平边界。cube 六面六岛就是它
4. 位置差 0.00002~0.1cm(”以为重合”) 差一点 连续 断岛 最常见坑:拼接建模/两次导出的”重合点”其实差了几微米 → 表面被看不见的缝切成碎岛,padding 翻倍、密度不均
5. 镜像面(绕序相反,如负缩放复制) 重合 连续但镜像 断岛(④ 不过) 负缩放镜像件请单独算一套 UV

避坑|场景 4 是”顶点重叠”主题的头号坑
位置阈值 0.00002cm 极其苛刻:32 位浮点下,离原点越远,坐标能表达的精度越粗(float32 在 1000cm 处精度约 0.006cm,已远超阈值)。所以:

  • 大场景里”手工对齐”的两块模型,几乎必然超阈值 → 断岛;
  • 对策:在 DCC 里做真正的焊接(weld/merge by distance,容差 0.001mm 内)后再导出;不要在引擎里指望展开器帮你”吸”过去。
  • 这是”顶点重叠了,展开出来却一岛变 N 岛”的唯一技术解释。

21.2.3 最危险的不是”顶点重叠”,是”UV 岛重叠”

顶点重叠只是低效;不同表面把 UV 画到同一块 texel 区域才是硬伤:同一 texel 会被烘焙器写入两次,两张面的辐射互相覆盖污染,烘焙后出现说不清的暗块/串色。

引擎自带体检:StaticMesh.cpp:9952 CheckLightMapUVs 逐 LOD 检查渲染缓冲(即拆分重排后的最终网格,StaticMesh.cpp:10138):

  • 通道缺失 → missing light map UVs(StaticMesh.cpp:10185);
  • UV 出 [0,1] 超 ±0.001 → out-of-bound UVs(StaticMesh.cpp:10021-10037,StaticMesh.cpp:10181);
  • 两三角形 UV 重叠:各自”UV 重心”投到对方三角形内即判定(StaticMesh.cpp:10103-10123,斜条偏置 Epsilon=-0.001 防误报)→ %i triangles with overlapping UVs(StaticMesh.cpp:10177)。

**体检默认盯通道 LightMapCoordinateIndex,且引擎认为”UV0 不该拿来当 lightmap”**(StaticMesh.cpp:10141-10143:除非网格自带 lightmap UV,第一套 UV 永远不要当烘焙通道)。

避坑|UV 重叠的五条铁律
① 自动展开(FLayoutUV/XAtlas 装箱)保证不重叠——别手动画 lightmap UV;
② 从别的模型”复制 UV”过来、或 UV 经过镜像/缩放后必查重叠;
③ 双面渲染的面(草/树叶)两面共用同一岛 → 正反面写同一 texel,属合法同值,但正反面光照不同时会出现一面取另一面的光——需要拆两层或接受近似;
④ 零面积/细长三角形(sliver)会造成假重叠,体检已用负 epsilon 偏置(StaticMesh.cpp:10000),但 sliver 本身该清;
⑤ 岛之间至少留 4px 填充(见第 20 章),重叠 UV 的”治疗”永远是把岛挪开而不是缩小 padding。

动手实验室:验证”顶点不共享也能并岛”

  1. 建一个 400×100×10 的长条,沿长边中点把顶点断开(两半各自独立顶点、位置完全重合);
  2. 手动把两半的 UV0 设为连续(左 00.5、右 0.51,共点处 UV 值精确相等);
  3. 资产设置勾 Generate Lightmap UV,SrcLightmapIndex=0, DstLightmapIndex=1,保存;
  4. 打开 UV1:两半应处于同一个岛内(共享 texel 行)——位置重合 + UV 连续即可并,与焊接无关;
  5. 把右半挪 0.1cm 再存:UV1 立刻断成两个岛——0.1cm 就把岛切开了。

21.3 BuildData(MapBuildDataRegistry):烤出来的数据住在哪、什么时候丢

21.3.1 住址:关卡的 UMapBuildDataRegistry

烘焙产物不在 StaticMesh 资产里,也不在光源组件里,而是挂在使用关卡(ULevel)上:

1
2
3
Level.h:559        TObjectPtr<UMapBuildDataRegistry> MapBuildData;   // 每个关卡一份
Level.h:623-624 bIsMapBuildDataOwner —— LevelInstance 的实例不各自持有,
用 owner level 的 MapBuildData(数据归宿主关卡)

Registry 内部是六张 GUID 键控表(MapBuildDataRegistry.h:426-431):

表 键(FGuid) 内容
MeshBuildData 组件 LOD 的 MapBuildDataId Lightmap/ShadowMap 贴图 + 参数(FLightMapRef 等)
LightBuildData 光的 LightGuid 静态阴影深度图、ShadowMap 通道号
ReflectionCaptureBuildData 捕获组件的 MapBuildDataId 反射探针烘焙
LevelPrecomputedLightVolume* LevelId 预计算光体积(体积光照)
SkyAtmosphereBuildData — 大气烘焙

数据随关卡包序列化:烘焙 = 写表 + MarkPackageDirty;保存关卡 = 落盘。关卡文件(.umap)就是烘焙数据的家。

21.3.2 钥匙:MapBuildDataId 是怎么来的

  • 网格组件:首次烘焙需要时才生成(CreateMapBuildDataId:OriginalMapBuildDataId = FGuid::NewGuid(),LOD0 用新 GUID、其余 LOD 从 LOD0 派生(StaticMeshComponent.cpp:3702));运行时实际键 = LOD 级 OriginalMapBuildDataId × ActorInstanceGuid(UpdateMapBuildDataId:3650-3658):
    1
    2
    3
    MapBuildDataId = FGuid::Combine(OriginalMapBuildDataId, FActorInstanceGuid::GetActorInstanceGuid(*Owner))
    // 注释原文:"in case of a LevelInstance Actor we must adjust it to be unique"
    // → LevelInstance / WorldPartition 每个放置实例都拿到独立键,各烤各的,绝不串数据
    读时按同一键查表(StaticMeshComponent.cpp:712 GetMeshBuildData(LODInfo.MapBuildDataId))。
  • 光:LightGuid = Combine(OriginalLightGuid, ActorInstanceGuid)(LightComponent.cpp:268),运行时 GetLightBuildData(LightGuid)(LightComponent.cpp:1592)。
  • 反射捕获:自身持 MapBuildDataId(ReflectionCaptureComponent.h:63)。

类比实验室:储物柜租约
OriginalMapBuildDataId = 租约号,ActorInstanceGuid = 房间号。烘焙器按”租约号+房间号”给你开柜存行李;
柜子(Registry)是关卡的。换房间(搬到别的关卡/新实例)→ 旧租约在新柜子查无此单 → 要重新存。

21.3.3 三个生命周期事件(源码对照)

① 全量重建 = 清空重来(MapBuildData.cpp:965-998):

1
2
InvalidateStaticLighting:清 Mesh/Light/体积表 → 释放 GPU 资源(含 ResourceCluster,:1044-1055)→
EmptyLevelData → MarkPackageDirty // "Build Lighting(全量)"走的路径

② 局部失效(只留指定 GUID,MapBuildData.cpp:1000-1059):把表 Swap 出去、按 ResourcesToKeep 挑回要留的——增量烘焙/局部清除用。

③ 打开关卡自检(World.cpp:2569-2584):编辑器非 Cook 启动时逐个 Level 查 IsLightingValid(FeatureLevel)(MapBuildDataRegistry.h:376),不合法直接 InvalidateSurfaceLightmaps 清掉。

21.3.4 BuildData 的七大坑(每条 = 现象 → 机制 → 对策)

避坑 1|烤完不保存关卡 = 白烤
现象:烤完直接关编辑器,重开光照全没。
机制:数据写在 ULevel::MapBuildData,不保存关卡不落盘(21.3.1)。
对策:烘焙完成 = 立刻 Ctrl+S;团队内把”烤完必存”写进流程。

避坑 2|复制/移动已烘焙的 Actor:光照”贴错”不会自动修复
现象:烘焙后复制一个物体放别处、或把原物体旋转 90°——光照像贴纸一样跟着表面转,方向/遮挡全错。
机制:键(MapBuildDataId)没变 → 仍引用同一份世界空间烘焙(贴图里存的是当时的入射光方向),几何变了数据不变,于是错位;引擎不会因变换改动而自动清数据。
对策:变换过烘焙物体的位置/朝向 → 重新烘焙(区域/全量)。

避坑 3|LevelInstance / World Partition 实例:数据按实例独立
机制:键含 ActorInstanceGuid(21.3.2),多实例各自查表;子内容烘焙数据归 owner 关卡(Level.h:624)。
现象:LevelInstance 蓝图改了几何,各放置实例光照不更新。
对策:改完在宿主关卡里重新烘焙该实例范围。

避坑 4|编辑静态光(属性/位置/颜色)→ 自动换钥匙,旧数据变孤儿
机制:InvalidateLightingCacheDetailed(LightComponent.cpp:1483)对可烘焙的光执行 UpdateLightGUIDs()(LightComponent.cpp:1497)——LightGuid 换新,旧 LightBuildData 再也查不到;Movable 光直接清零(LightComponent.cpp:1523)。
现象:改了光后预览里光照立刻”回退”,提示需要重建。
对策:正常流程就是”改完 → 重新烘焙”;别用撤销试图找回旧烘焙。

避坑 5|改网格资产(几何/UV/材质)→ 组件的键不变,但内容对不上
机制:编辑 StaticMesh 触发重建(StaticMesh.cpp:5436,StaticMesh.cpp:5552 注释 “The super will cause a Build()”);组件 LOD 数据重算但 OriginalMapBuildDataId 保持 → 旧烘焙(按旧 UV 展开的贴图)继续被引用 → 纹理错位。
对策:改网格 = 重新烘焙所有使用它的组件;增量烘焙会按 bMapBuildDataChanged 标记(StaticMeshComponent.cpp:3697)收集 changed 的组件重烤。

避坑 6|切 Virtual Texture / 特性等级:开图自动清全部烘焙
机制:启动自检 IsLightingValid(FeatureLevel) 不通过 → InvalidateSurfaceLightmaps(World.cpp:2581)——VT 数据与非 VT 数据格式不互通,引擎认为”全废”。
现象:项目设置里开关 Virtual Texture Lightmaps、或升级移动/桌面特性等级后,打开地图弹”需要重建光照”,数据已清。
对策:切换前确认代价;大项目先小图试切,确认后统一重烤(可脚本化,见第 11 章)。

避坑 7|BuildData 是关卡二进制的一部分
现象:多人协同时一人重烤 → umap 提交巨大 diff;关卡文件拷给别人不带 asset 引用错乱。
机制:烘焙结果内嵌关卡包;无法 diff/合并。
对策:烘焙与关卡编辑分人分时;提交纪律 + 用烘焙服务器/构建机(第 11 章命令行烘焙)。

动手实验室:复现避坑 2

  1. 室内小场景放一个长椅(Static),烘焙,保存;
  2. Ctrl+W 复制长椅 → 把副本旋转 90° 放到旁边——副本的明暗方向与原位一致(贴图跟着物体走),错得”很自然”;
  3. 选中原长椅移动 1m——光照不再贴合(贴图错位);
  4. 重新烘焙 → 两者全部恢复正常。全程没有任何报错——BuildData 的失效从不报错,只表现为”错位/不对”,这是它最阴险的地方。

第 22 章 渲染管线篇:Lightmap 在管线里怎么被画出来

TL;DR|Lightmap 是间接光,它只进两个地方:① BasePass 像素着色器(采样解码出全彩间接漫反射,此时它只活在像素函数里);② 之后按渲染路径分流——延迟路径把”亮度×AO”对数编码后写进 GBuffer(EncodeIndirectIrradiance),颜色信息不随 GBuffer 走,最后在 DiffuseIndirect 合成 pass 用”解码亮度 × 材质 DiffuseColor”还原;前向/移动路径则把全彩结果直接累加进像素颜色。Lightmap 绝不进入任何 DeferredLighting 光源 pass——每个动态光源 pass 都读不到它;它的”另一半”(Stationary 光静态阴影)则是烘焙时算成 4 通道因子存进 GBuffer(PrecomputedShadowFactors),在光源 pass 里按通道取用。

22.1 全景:一个像素的 Lightmap 旅程(帧管线定位)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
               ┌──────────────────────── CPU 每帧准备 ────────────────────────┐
烘焙结果 ──→ FLightMap2D(贴图+Scale/Add) ──→ FUniformLightMapPolicy 绑定 uniform
(MapBuildData) LightMapRendering.cpp:562 ──→ 像素着色器可见参数(第 6 章)
└──────────────────────────────────────────────────────────────┘
│
帧渲染(DeferredShadingRenderer.cpp)
① BasePass(画所有不透明物体,逐物体绑定上面的 uniform)
像素内:GetLightMapColorHQ 解码 → 全彩间接漫反射(临时)
│
├─ 延迟路径(默认桌面):写 GBuffer
│ IndirectIrradiance(亮度×AO, 对数编码) ← 只存亮度!
│ PrecomputedShadowFactors(4通道静态阴影因子)
│
└─ 前向路径(半透明/移动/Forward):直接累加进像素颜色
② DeferredLighting(逐光源 pass)→ 动态直接光 + 用 ShadowFactors 通道
③ DiffuseIndirectComposite(灯光之后!DeferredShadingRenderer.cpp:3533)
解码亮度 × DiffuseColor → 间接漫反射"真正登场"
④ 反射/后处理/色调映射

关键定位:Lightmap 光照贡献在第①步被算出来,但”看得见的时刻”是第③步(延迟)或第①步内(前向)。它跟 Lumen/SSGI 是同一个合成 pass 的输入家族,不是独立 pass。

22.2 BasePass 采样:从 uniform 到解码(调用链逐层下钻)

第 0 层|策略与绑定(已在第 6 章讲过,此处给完整落点):FUniformLightMapPolicy 编译期开宏 HQ/LQ_TEXTURE_LIGHTMAP(LightMapRendering.cpp:42-49),GetPixelShaderBindings(:562-577)把贴图与 Scale/Add/坐标变换打进 uniform。

第 1 层|像素函数入口:BasePassPixelShader.usf:1359:

1
2
3
4
5
GetPrecomputedIndirectLightingAndSkyLight(MaterialParameters, ..., DiffuseDir,
VolumetricLightmapBrickTextureUVs,
DiffuseIndirectLighting, // out float3 —— lightmap + 天空光 的全彩间接漫反射
SubsurfaceIndirectLighting, // out float3 —— 次表面透射部分
IndirectIrradiance); // out float —— 亮度(给 GBuffer 用)

第 2 层|函数体内分流(BasePassPixelShader.usf:562-731)——按编译宏选三选一:

1
2
3
PRECOMPUTED_IRRADIANCE_VOLUME_LIGHTING → 体积光照图(VolumetricLightmap)查 3D 纹理(:572-704)
HQ_TEXTURE_LIGHTMAP → GetLightMapColorHQ(:724)
LQ_TEXTURE_LIGHTMAP → GetLightMapColorLQ(:731)

第 3 层|真正的贴图解码(LightmapCommon.ush:132 GetLightMapColorHQ / LightmapCommon.ush:78 GetLightMapColorLQ):上、下半区采样 → exp2(LogL*16-8) 还原亮度 → SH 点积出方向性(第 20 章已逐行拆过)→ 输出全彩光照与次表面光。这层之后,lightmap 就是纯粹的 float3 颜色了,与”贴图”再无关系。

第 4 层|天空光与合并:

1
2
3
4
BasePassPixelShader.usf:736  OutDiffuseLighting *= View.PrecomputedIndirectLightingColorScale;  // 全局间接光缩放
:745-749 // Sky lighting must contribute to IndirectIrradiance for ReflectionEnvironment lightmap mixing
OutDiffuseLighting += SkyDiffuseLighting;
OutIndirectIrradiance = Luminance(OutDiffuseLighting); // ← 从这里开始,彩色只留下一个"亮度"!

22.3 落点分歧:Deferred 只存亮度,颜色哪去了

22.3.1 Deferred(默认桌面):GBuffer 单通道 = 间接光亮度 × AO

GBuffer 结构里间接光字段的注释毫不掩饰(DeferredShadingCommon.ush:400):

1
2
// Indirect irradiance luma
half IndirectIrradiance; // 就是"亮度",不是 RGB!

BasePass 输出段(BasePassPixelShader.usf:2364-2384):

1
2
3
4
5
6
7
8
9
10
11
GBuffer.IndirectIrradiance = IndirectIrradiance;              // :2368 亮度的"临时寄存"
#if GBUFFER_HAS_DIFFUSE_SAMPLE_OCCLUSION
GBuffer.GenericAO = ...遮蔽计数... // :2376
#elif ALLOW_STATIC_LIGHTING
// No space for AO. Multiply IndirectIrradiance by AO instead of storing. ← 注释原文
GBuffer.GenericAO = EncodeIndirectIrradiance(GBuffer.IndirectIrradiance * GBuffer.GBufferAO)
+ QuantizationBias * (1.0 / 255.0); // :2379 亮度×AO → 8bit
#else
GBuffer.GenericAO = GBuffer.GBufferAO; // :2381 无静态光照时照存 AO
#endif
EncodeGBufferToMRT(Out, GBuffer, QuantizationBias); // :2384

编码公式(DeferredShadingCommon.ush:221-234):

1
2
Encode: L *= View.PreExposure;  return log2(L + 1/256) / 16 + 0.5;   // 对数,黑点 1/256
Decode: return View.OneOverPreExposure * (exp2(L*16 - 8) - 1/256);

源码考古:为什么注释叫 “No space for AO”?
8bit 的 GBuffer 通道是稀缺资源:法线用 2 通道、第 3 通道让位给间接光亮度(旧编码
DeferredShadingCommon.ush:659
OutGBufferA.b = EncodeIndirectIrradiance(GBuffer.IndirectIrradiance)——法线八面体占 rg、b 通道放
间接光)。AO 没地方存,就乘进间接光再编码。这就是”为什么静态烘焙场景里 SSAO 仍生效”
的代码真相:AO 在 BasePass 内被吃掉,不在延迟 AO pass 里单独乘。

颜色去哪了:Deferred 下不进 GBuffer。lightmap 的方向(SH)与色度在写入时只剩 Luminance;最终画面的”间接光颜色”= 解码亮度 × 材质 DiffuseColor(见 22.4)——所以纯白墙+单色 lightmap 是”灰调间接光”;lightmap 的多彩性更多体现在 Stationary 直接光与材质底色上。要保留彩色间接光到延迟后段,只能走 Lumen/SSGI 那一路(它们是 RGB 缓冲,DiffuseIndirectComposite.usf:516)。

22.3.2 静态阴影:不占”间接光”,而是 4 通道因子提前进 GBuffer

Stationary 灯的阴影不进间接光通道——BasePass 组装 GBuffer 时把烘焙的 ShadowMap 采样成4 个通道的静态阴影因子(BasePassPixelShader.usf:1154):

1
GBuffer.PrecomputedShadowFactors = GetPrecomputedShadowMasks(...);   // LightmapCommon.ush:230/294

结构注释(DeferredShadingCommon.ush:401-402):
“Static shadow factors for channels assigned by Lightmass. Lights using static shadowing will
pick up the appropriate channel in their deferred pass.
“
→ 每个 Stationary 光源在自己的 DeferredLighting pass 里按烘焙分配的 ShadowMapChannel 取一个
因子,把直接光遮掉。这就是”静态阴影在运行时不再碰 ShadowMap 贴图”的原因——它被预采样
成了每像素 4 通道因子
(旧编码里 C.a 存 channel 0,见 DeferredShadingCommon.ush:674)。

22.3.3 Forward / 移动端:全彩直加,没有 GBuffer 中转

前向路径里第 1377 行算出的 DiffuseColor(含 lightmap 全彩)直接进最终颜色累加器:

1
2
3
4
BasePassPixelShader.usf:1377  DiffuseColor += (DiffuseIndirectLighting * DiffuseColorForIndirect
+ SubsurfaceIndirectLighting * SubsurfaceColor)
* AOMultiBounce(GBuffer.BaseColor, ...);
:2350 LightAccumulator_Add(LightAccumulator, Color + DiffuseColor, ...)

移动端有独立的轻量版本(MobileBasePassPixelShader.usf:229-262):同名
GetPrecomputedIndirectLightingAndSkyLight 直接在 :242 调 GetLightMapColorLQ(移动端 LQ 策略)
出全彩 → 加进颜色。所以移动端看到的 lightmap 色彩比桌面延迟完整——代价是没有延迟后期再
调整的空间(这也是 fork 做 OnePass 移动渲染时仍保留 lightmap 全彩路径的原因)。

22.4 消费:DiffuseIndirectComposite —— 间接漫反射”正式登场”的 pass

引擎在所有直接光 pass 之后做间接漫反射合成(注释原文 “Do DiffuseIndirectComposite after Lights so that async Lumen work can overlap”,DeferredShadingRenderer.cpp:3532-3533):
RenderDiffuseIndirectAndAmbientOcclusion → DiffuseIndirectComposite.usf。

Shader 内部(DiffuseIndirectComposite.usf:563-585):

  • 间接漫反射来源三选一:Lumen(:516 读 DiffuseIndirect_Lumen_0)、SSGI(:533)、关闭时为空——静态烘焙场景默认走后者;
  • 最终公式:IndirectLighting.Diffuse = DiffuseIndirectLighting × Occlusion.DiffuseOcclusion × IndirectDiffuseColor(:579/631),其中 IndirectDiffuseColor 就是 GBuffer 的材质 DiffuseColor;
  • 间接光的亮度(GBuffer 里那 8bit)在此前已被 GetGBufferData 解码回 float(GBufferHelpers.ush:422 DecodeIndirectIrradiance),并作为背景底光参与本 pass 的合成输入。

类比实验室:餐厅的两本账
BasePass 是”厨房备菜”(把烘焙味道算成半成品:亮度存进小罐子、阴影因子存进四个小格);
光源 pass 是”上菜时逐桌加主菜”(动态直接光,只认小格子里的阴影因子);
DiffuseIndirectComposite 是”最后给每桌上一份垫底的前菜”(间接漫反射正式入口)。

校准(v1.1 补充):本小节”间接光亮度在 DiffuseIndirectComposite 用『解码亮度 × DiffuseColor』还原”
的表述对 lightmap 不成立——该 pass 只读 Lumen/SSGI 缓冲(DiffuseIndirectComposite.usf:516/533);
lightmap 的全彩结果在 BasePass 就已加进 SceneColor。完整源码链与反证见 23.5 / 23.6,
GBuffer 那 8bit 亮度的真实消费者见 23.7。
Lightmap 既是备菜原料又是前菜——但永远不会被当成主菜(光源 pass)上桌。

22.5 一个像素的最终光照方程(在哪里 += 了什么)

1
2
3
4
5
6
7
8
最终颜色 ≈ Σ 动态直接光(DeferredLight 各光源 pass)
(Stationary 光源 × GBuffer.PrecomputedShadowFactors[该光源通道]) ← 静态阴影在此生效
+ DiffuseIndirectComposite(解码间接光亮度 × DiffuseColor × AO…) ← lightmap 亮度在此登场
+ Specular/反射(ReflectionComposite,反射探针/SkyLight specular)
→ 后处理 → 输出

全程 Lightmap 采样次数:每不透明像素 BasePass 内 1~2 次贴图采样(HQ 上下半区) + ShadowMap 预采样 1 次
——之后不再有任何 pass 碰 lightmap 纹理。

反直觉但源码可证的三件事:

  1. 改动态光亮度不会”冲淡” lightmap——两者在不同 pass 相加,互不干扰;
  2. 把一个烘焙过的物体扔进纯黑房间,它依然亮——lightmap 进的是 BasePass,与场景里有没有灯无关;
  3. **桌面延迟下你看到的光照色调 ≈ 材质 DiffuseColor × (间接光亮度 + Σ直接光颜色)**——lightmap 自身的色度在延迟路径里被单通道编码吃掉大半,这是引擎设计取舍(省显存/带宽),不是 bug。

22.6 避坑与动手实验室

避坑 1|项目关掉静态光照(ALLOW_STATIC_LIGHTING=0)后:BasePass 走 GenericAO = GBufferAO 分支
(BasePassPixelShader.usf:2381),IndirectIrradiance 恒 1——Lightmap 数据连采样都不会发生,
但会照常占 Mesh 构建与内存。检查你是否真的在用静态光照,别白背开销。

避坑 2|间接光亮度会被 PreExposure 缩放后做对数编码(DeferredShadingCommon.ush:222):
PreExposure 与解码端 OneOverPreExposure 必须匹配;自定义曝光/极端 HDR 间接光下 8bit 通道会
出现 banding——对策是调 r.GBufferFormat(更高精度 GBuffer)而不是盲目加大 lightmap 亮度。

避坑 3|”灯全删了画面还亮”不是漏光:那是 lightmap 间接光在 BasePass 的结果(22.5-2)。
排查”不该亮”时先看 Lightmap Density/静态光照开关,再怀疑漏光。

避坑 4|延迟路径里 lightmap 颜色”发灰”不是烘焙错了:单通道亮度编码是默认行为(22.3.1);
需要彩色间接漫反射的场合走 Lumen/SSGI 或在材质层用额外采样补色。

动手实验室:RenderDoc 里亲眼看 GBuffer

  1. 建小室内场景 + 暖色点光(Stationary)+ 蓝色天光,烘焙;
  2. RenderDoc 截帧,找 BasePass 的 GBuffer 输出 MRT(编辑器窗口能直接看 GBufferA/C 纹理视图);
  3. 观察 GBufferA.b(或 GenericAO 通道):暖/蓝光区的数值差异远小于你预期——因为存的是
    亮度×AO 的对数编码,颜色信息不在这里;
  4. 再看 GBufferC.a(PrecomputedShadowFactors 第 0 通道):点光投影区域的物体上能看出 0/1 跳变;
  5. 把点光改成 Movable 再截一帧:C.a 里静态阴影因子消失(该通道改存 AO),房间间接光不变——
    动态光的加入只影响直接光 pass,不影响 lightmap 路径。这就是本意”两本账”的直观验证。

第 23 章 渲染管线篇(二):从顶点到像素——Lightmap 采样全链路逐行拆解

TL;DR|把第 22 章那张”全景图”放大到源码级:Lightmap 的消费只发生在 BasePass 一个 pass 里,
分三步——①顶点阶段把 UV1 乘 LightMapCoordinateScaleBias 变换成 atlas 坐标(从 GPUScene 取,
实例还能自带偏移);②像素阶段用这份坐标采样 2 次(HQ 的上下半区),加上 SkyOcclusion /
AOMaterialMask / StaticShadow 最多 5 次纹理采样,解码出全彩间接光;
③全彩结果当场乘进 DiffuseColor 并写进 SceneColor(MRT[0])——延迟路径也不例外。
GBuffer 里那 8bit “间接光亮度”是副产品(供反射环境混合权重 / AO / 调试视图使用),
不是给 DiffuseIndirectComposite 用的(那个 pass 只读 Lumen/SSGI)。心智模型:
Lightmap 是”提前结账”——BasePass 就把钱付进 SceneColor,之后所有 pass 都不再碰它。

23.1 帧管线里精确到行号的位置

顺序 Pass / 步骤 源码落点 Lightmap 参与?
0 每帧上传 lightmap 参数 GPUScene.cpp:1020/1245/1406(FLightmapSceneShaderData,15×float4) 数据准备
1 PrePass / Nanite VisBuffer DeferredShadingRenderer.cpp:2464/3074 前 ❌
2 BasePass DeferredShadingRenderer.cpp:3074 → BasePassPixelShader.usf:1359 → 1377 ✅ 唯一采样点
3 DiffuseIndirectAndAO(第一次) :3444 → IndirectLightRendering.cpp:1092 ❌(读 Lumen/SSGI/AO)
4 RenderLights(逐光源) :3482 只读 PrecomputedShadowFactors
5 DiffuseIndirectAndAO(第二次·合成) :3533 ❌(同上,只合成 Lumen)
6 反射与天光 :3543 → ReflectionEnvironmentComposite.ush:252-253 读 GBuffer 亮度做混合权重

两次 DiffuseIndirectAndAO 的区别(IndirectLightRendering.cpp:1092 的 bCompositeRegularLumenOnly 参数与 :1119 的 ShouldSkipView):

  • 第一次(:3444,bCompositeRegularLumenOnly = false):跑 SSGI / DFAO / 胶囊间接阴影 / AmbientCubemap 等”常规工作”;
  • 第二次(:3533,bCompositeRegularLumenOnly = true):只做 Lumen 合成,目的是”Do DiffuseIndirectComposite after Lights so that async Lumen work can overlap“(DeferredShadingRenderer.cpp:3532 注释原文)。

关键点:两次调用都不读 lightmap 纹理。整个帧里 lightmap 贴图只在 BasePass 被绑定和采样。

23.2 顶点阶段:UV1 是怎么变成 Atlas 坐标的

类比实验室:快递面单
模型自带的 Lightmap UV(UV1)只是”第 3 排第 5 格“这种格子内地址;装箱进 Atlas 后,
引擎额外给每个物体发一张”面单“(LightMapCoordinateScaleBias):告诉你这块格子在大仓库
(Atlas)里的位置与大小。顶点着色器干的事就是”格子地址 × 面单 → 仓库绝对坐标”。

LocalVertexFactory.ush:867-899(本 fork 实测):

1
2
3
4
5
6
7
8
9
float2 LightMapCoordinateInput = Input.LightMapCoordinate;              // :872  模型 UV1(ATTRIBUTE15, :104)
FPrimitiveSceneData PrimitiveData = GetPrimitiveData(Intermediates); // :876
uint LightmapDataIndex = PrimitiveData.LightmapDataIndex + LocalVF.LODLightmapDataIndex; // :878
float4 LightMapCoordinateScaleBias = VF_GPUSCENE_GET_LIGHTMAP_UV_SCALE_BIAS(Input, LightmapDataIndex); // :881
const float2 InstanceLightMapScaleBias = CondMask(bHasPerInstanceCoordinateScaleBias,
InstanceData.LightMapAndShadowMapUVBias.xy, LightMapCoordinateScaleBias.zw); // :883
LightMapCoordinate = LightMapCoordinateInput * LightMapCoordinateScaleBias.xy
+ InstanceLightMapScaleBias; // :884
SetLightMapCoordinate(Interpolants, LightMapCoordinate, ShadowMapCoordinate); // :899

逐行要点:

  1. LightmapDataIndex(:878)= 图元在 GPUScene lightmap 数据缓冲里的下标 + LOD 偏移——每个 LOD 有自己的 lightmap 数据项(LOD 分辨率逐级减半,见第 3 章);
  2. **LightMapCoordinateScaleBias**(:881)来自 GPUScene,不是常量缓冲——这就是”GPU Driven / Bindless”时代取参的方式;
  3. 实例可覆盖偏移(:883-884,INSTANCE_SCENE_DATA_FLAG_HAS_LIGHTSHADOW_UV_BIAS)——ISM/HISM 里每个实例指自己那一小块(第 19 章的”一图多用”就是靠它);
  4. 结果塞进插值器 TEXCOORD4(LocalVertexFactoryCommon.ush:39,xy=lightmap UV,zw=shadowmap UV),LightmapDataIndex 走 nointerpolation uint(:47)。

源码考古:Nanite 为什么不用普通插值?
NaniteVertexFactory.ush:53-55 声明 nointerpolation float4 LightMapCoordinate 外加两个
LightMapCoordinateDDX/DDY,取值函数(:134-146)用 ConstructFloatDeriv2(...) 手工重建导数。
原因:Nanite 以 cluster 为单位可见性光栅化,
像素四边形的常规屏幕空间导数在这里不可靠
,
所以把 UV 的导数也当插值器传下来。这是”Nanite 也能吃 lightmap”的底层原因(第 19 章 GPU Driven
部分只讲了结论,这里是实现)。

23.3 参数是怎么到达 shader 的:两条路 + 一个贴图包

路 A|逐图元 uniform buffer(传统路径):SetupLCIUniformBuffers(LightMapRendering.cpp:312)→ FPrecomputedLightingUniformParameters(15 个 float4,LightmapUniformShaderParameters.h 顶部)→ shader 里的 PrecomputedLightingBuffer(LightmapData.ush:28-46)。

路 B|GPUScene 数据缓冲(GPU Driven 路径):FLightmapSceneShaderData::DataStrideInFloat4s = 15(LightmapUniformShaderParameters.h:37-38)与 LightmapData.ush:48 的 LIGHTMAP_SCENE_DATA_STRIDE 15 必须一致——上传发生在 GPUScene.cpp:1020(分配)/:1245(uploader)/:1406(逐 LCI 填充)。

那 15 个 float4 装的是什么(LightmapData.ush:16-25,与 FLightmapSceneShaderData 一一对应):

# 字段 作用
0 StaticShadowMapMasks 4 个静态阴影通道的遮罩
1 InvUniformPenumbraSizes 距离场阴影的半影尺寸
2 LightMapCoordinateScaleBias lightmap UV → atlas UV(23.2 那个”面单”)
3 ShadowMapCoordinateScaleBias shadowmap UV → atlas UV
4-5 LightMapScale[0]、LightMapScale[1] 两组系数的缩放([0]=亮度/色度,[1]=SH)
6-7 LightMapAdd[0]、LightMapAdd[1] 两组系数的平移(与 Scale 配对做归一化还原)
8-9 LightmapVTPackedPageTableUniform[2] VT 页表(仅 VT 路径)
10-14 LightmapVTPackedUniform[5] VT 的 5 个 layer 参数

注:上表按 LightmapData.ush:16-25 的声明顺序排列(LightMapScale[2] 与 LightMapAdd[2]
各占 2 个 float4,合计 1+1+1+1+2+2+2+5 = 15)。LightMapScale[0].w / .rgb 分别对应亮度和色度
的缩放(见 23.5 的解码)。改结构体时**必须同步改 ush 与 C++**,两侧不一致会直接花屏。

贴图与采样器:贴图不是逐物体绑的,而是打成一个 uniform 结构 LightmapResourceCluster(LightMapRendering.h:376/388)——里面装着 LightMapTexture / SkyOcclusionTexture / AOMaterialMaskTexture / StaticShadowTexture 及各自采样器(VT 时换成 VTLightMapTexture + 页表)。采样器状态:

1
2
3
// SceneManagement.cpp:1549  VT 路径:各向异性 4x + 三向 Clamp
TStaticSamplerState<SF_AnisotropicLinear, AM_Clamp, AM_Clamp, AM_Clamp, 0, 4>::GetRHI();
// :1564 非 VT 路径:直接用贴图自带的采样器状态(GetTextureSamplerState)

Clamp 是硬性要求——Atlas 里相邻物体只隔 4 纹素 padding,用 Wrap 会直接串到邻居的岛上(第 17 章漏光专题的运行时对应物)。

23.4 像素阶段:坐标再变换 + 最多 5 次采样

拿到插值后的 atlas 坐标,像素着色器还要再做一次”压半“(LocalVertexFactoryCommon.ush:118-121):

1
2
LightmapUV0 = Interpolants.LightMapCoordinate.xy * float2( 1, 0.5 );   // :120  上半区
LightmapUV1 = LightmapUV0 + float2( 0, 0.5 ); // :121 下半区

为什么:非 VT 的 HQ lightmap 把两组系数上下拼在一张贴图里(上半区 = LogL+UVW,下半区 = SH,见第 20 章)。装箱时算出的 Scale/Bias 是相对整张贴图的(LightMap.cpp:717-723:Scale = PaddedSize / GetSize()),所以像素阶段 *0.5 落进上半区、+0.5 取下半区。VT 路径不需要这个技巧(两组系数是两个 layer),LightmapGetVTSampleInfo(LightmapCommon.ush:60-64)里那句 ScaleLightmapUV(UV, float2(1,2)) // Undo transform used to pack 2 lightmap coeffs in 1 texture 就是在”撤销”它。

一个不透明像素的采样清单(默认 HQ + 静态阴影 + 天光遮蔽全开):

采样对象 次数 代码落点 产出
LightMapTexture(上半区) 1 LightmapCommon.ush:142 Lightmap0(LogL + UVW)
LightMapTexture(下半区) 1 :143 Lightmap1(SH + 残差)
StaticShadowTexture 1(仅 Stationary 静态阴影) :245/:306 4 通道阴影因子
SkyOcclusionTexture 1(仅需要天光遮蔽时) :204 BentNormal + AO
AOMaterialMaskTexture 1(仅需要 AO 材质遮罩时) :220 AO 遮罩

典型场景是 2 次(HQ 本体),上限 5 次——这就是”每像素几十纳秒”这句第 1 章估算的来源。

23.5 解码:从 LogLUVW 到全彩(HQ 逐行)

LightmapCommon.ush:132-189:

1
2
3
4
5
6
7
8
9
10
half LogL = Lightmap0.w;                                              // :146  亮度对数(8bit)
LogL += Lightmap1.w * (1.0/255) - (0.5/255); // :149 下半区 A 通道补残差精度
LogL = LogL * LightMapScale[0].w + LightMapAdd[0].w; // :152 每图归一化还原
half3 UVW = Lightmap0.rgb * Lightmap0.rgb * LightMapScale[0].rgb
+ LightMapAdd[0].rgb; // :155-156 色度(注意平方→近似 sRGB)
half L = exp2(LogL) - LogBlackPoint; // :159 对数→线性亮度
float4 SH = Lightmap1 * LightMapScale[1] + LightMapAdd[1]; // :165 SH 系数
half Directionality = max(0.0, dot(SH, float4(WorldNormal.yzx, 1))); // :166 用法线点积出方向性
half Luma = L * Directionality; // :185
half3 Color = Luma * UVW; // :187 ★ 全彩间接光(HDR)

OutDiffuseLighting = Color(:189)。从这一行开始,lightmap 就是一个普通的 float3,和”贴图”再无关系。

图 F2 Lightmap 从顶点到屏幕的完整消费链(第 23 章主干图;★ = 全彩间接光的落点)
顶点着色器 UV1(ATTRIBUTE15) × LightMapCoordinateScaleBias + 实例偏移(ISM) → TEXCOORD4.xy(atlas UV) LocalVertexFactory.ush:867-899 像素着色器 · 采样 UV0 = uv * (1, 0.5) 上半区 UV1 = UV0 + (0, 0.5) 下半区 Texture2DSample ×2(HQ) + SkyOcc / AOMask / Shadow LightmapCommon.ush:142-143 解码 LogL → exp2 → L UVW 色度 × Luma SH · Normal 方向性 ★ 全彩 float3 LightmapCommon.ush:187 ★ 落点:直接进颜色 DiffuseColor += 间接光 × 材质色 LightAccumulator_Add Out.MRT[0] = SceneColor 延迟路径同样如此 BasePassPixelShader.usf:1377 / :2350 后续 pass(不再碰贴图) 光源 pass:读阴影因子通道 Lumen/SSGI 合成:读自己的缓冲 反射:读 GBuffer 亮度做权重 后处理 → 屏幕 副产品分支:Luminance(间接光) → 8bit 对数编码 → GBuffer(GenericAO / GBufferA.b) 消费者:反射环境混合权重(ReflectionEnvironmentComposite.ush:252)· AO 乘算(BasePassPixelShader.usf:2379)· 调试视图(PostProcessGBufferHints.usf:216) ※ 它不是 DiffuseIndirectComposite 的输入——该 pass 只读 Lumen/SSGI 缓冲(DiffuseIndirectComposite.usf:516)

23.6 校准:全彩间接光在 BasePass 就进了 SceneColor(修正 22.4 的表述)

第 22 章 22.4 写”间接漫反射在 DiffuseIndirectComposite 正式登场”,这个说法对 lightmap 不成立。源码链条是这样的:

1
2
3
4
5
6
7
// BasePassPixelShader.usf
:1359 GetPrecomputedIndirectLightingAndSkyLight(..., DiffuseIndirectLighting, ...); // 全彩 lightmap
:1377 DiffuseColor += (DiffuseIndirectLighting * DiffuseColorForIndirect
+ SubsurfaceIndirectLighting * SubsurfaceColor)
* AOMultiBounce(GBuffer.BaseColor, ShadingOcclusion.DiffOcclusion);
:2350 LightAccumulator_Add(LightAccumulator, Color + DiffuseColor, DiffuseColor, 1.0f, ...);
:2356 Out.MRT[0] = RETURN_COLOR(LightAccumulator_GetResult(LightAccumulator)); // SceneColor

:1377 位于 #if !MATERIAL_SHADINGMODEL_UNLIT(:1324)之内、与 USES_GBUFFER 无关——也就是说延迟路径同样把全彩 lightmap 加进了 SceneColor,不是”先存亮度、后合成”。

一个不需要读源码就能验证的反证:纯烘焙场景(关掉 Lumen、关掉 SSGI,DiffuseIndirectMethod = Disabled)画面依然是亮的。而 DiffuseIndirectComposite.usf:499-533 里 DiffuseIndirectLighting 只有两个来源——DiffuseIndirect_Lumen_0(:516)与 UpsampleLegacySSGI(:533),静态烘焙场景两者都为零。所以那份”亮”只能是 BasePass 写进去的。

类比实验室:餐厅结账
正确的两本账是:BasePass 当场把 lightmap 这道菜的钱结进 SceneColor(小票已打);
DiffuseIndirectComposite 结的是另一桌客人(Lumen/SSGI)的账。GBuffer 里那 8bit 亮度不是账单,
是贴在墙上的”本店人均消费”告示——后面来的反射环境看一眼,用来决定自己该出多少力(混合权重)。

23.7 那 GBuffer 里的 8bit 亮度,究竟是给谁用的

写入侧(第 22 章已讲):BasePassPixelShader.usf:2368 存 IndirectIrradiance,ALLOW_STATIC_LIGHTING 下与 AO 相乘后对数编码进 GenericAO(:2379)。

读取侧(本 fork 全量 grep 验证):

消费者 落点 用途
反射环境(延迟/前向) ReflectionEnvironmentComposite.ush:252-253 ComputeMixingWeight(IndirectIrradiance, CompositedAverageBrightness, Roughness)——有烘焙光的表面,反射探针按亮度配比衰减,避免”暗角落里反着亮堂堂的环境”
移动端 IBL MobileLightingCommon.ush:432/459 同上,移动版混合权重
移动延迟 MobileDeferredShading.usf:132 从 GBuffer 取回亮度参与天光
调试视图 PostProcessGBufferHints.usf:216 屏幕上打印 Ind. Irradiance 数值
Decal / MegaLights MeshDecals.usf:230(置 0)、MegaLightsMaterial.ush:205(置 1) 覆盖/重置该通道
无静态光照时 DeferredShadingCommon.ush:1196/1204、GBufferHelpers.ush:418/426 恒为 1,不编码

编码/解码(DeferredShadingCommon.ush:221-234):

1
2
Encode: L *= View.PreExposure;  return log2(L + 1/256) / 16 + 0.5;
Decode: return View.OneOverPreExposure * (exp2(L*16 - 8) - 1/256);

23.8 支线:前向 / 移动端 / 移动延迟 / Nanite / 半透明

路径 lightmap 采样 落点 备注
延迟(桌面默认) HQ(2 次) SceneColor(:1377)+ GBuffer 亮度(:2368) 本 23.5/23.6 结论
前向(桌面 forward) HQ/LQ Color += ...(同一处 :1377)+ 光栅网格直接光 无 GBuffer 中转
移动前向 LQ(MobileBasePassPixelShader.usf:242) 全彩直接加进颜色;OutIndirectIrradiance = Luminance(...)(:298)供 IBL 混合 移动端默认低质量编码
移动延迟 由 GBuffer 提供 MobileDeferredShading.usf:132 取回亮度 本 fork 的移动 one-pass 沿用 lightmap 全彩路径
Nanite HQ,但用手工导数 NaniteVertexFactory.ush:134-146 cluster 光栅化,导数必须显式传(23.2)
半透明 ❌ 不采样 lightmap 走 VolumetricLightmap / IndirectLightingCache(TRANSLUCENCY_LIGHTING_SURFACE_LIGHTINGVOLUME) 半透明没有 GBuffer、也没有稳定的 lightmap UV 归属

避坑:MobileBasePassPixelShader.usf:232 有一句 OutIndirectIrradiance = 0; 的初始化注释
(”To keep IndirectLightingCache conherence with PC”)——移动端 lightmap 场景下这个亮度是 PS 里现算的
Luminance
,不是从 GBuffer 读的。别在移动端拿 GBuffer 亮度做二次计算。

23.9 采样与带宽账(每不透明像素)

项 桌面 HQ 桌面 LQ 移动端 LQ VT 版 HQ
主系数采样 2(上下半区) 2 2 2 + 页表查询
静态阴影 1(Stationary) 1 1 1(不需要 ShadowMapCoordinate)
天光遮蔽 0~1 0~1 0~1 0~1
AO 材质遮罩 0~1 0~1 0~1 0~1
典型合计 2~4 2~4 2~4 2~4 + 页表

每帧采样次数 = 不透明像素数 × 典型次数,与动态光源数量完全无关——这正是”静态光照便宜”的量化解释(第 1 章 1.2 节的”几十纳秒”)。

23.10 避坑清单(本章新增)

避坑 1|Lumen 与 lightmap 的”双计”问题,引擎其实有防冲突机制(v1.2 修正)。
:1377 确实无条件把 lightmap 加进 SceneColor,但引擎在开启动态 Lumen GI 的视图里会把
PrecomputedIndirectLightingColorScale 置零(SceneRendering.cpp:2052-2057,注释原文
“If Lumen Dynamic GI is enabled then we don’t want GI from Lightmaps”),lightmap 间接光因此被乘 0。
副作用:Static 灯的直接光也会一起消失(注释亦已说明)。完整讨论见 第 24 章 24.2.5。

避坑 2|半透明物体看不到 lightmap。
玻璃/水面/粒子用 VolumetricLightmap 或 ILC,与不透明物体的 lightmap 是两套数据——
“玻璃后的亮度跟墙面对不上”多半不是烘焙错了,而是数据本来就不同源。

避坑 3|贴花(Decal)会把 IndirectIrradiance 清零。
MeshDecals.usf:230 / DeferredDecal.usf:224/275 把 DBufferData.IndirectIrradiance = 0——
贴花区域的反射混合权重会变,可能出现”贴花处反射变亮”的边界。

避坑 4|VT lightmap 的坐标语义不同。
VT 下没有”上下半区”技巧(LightmapCommon.ush:60-64 专门撤销它),也没有独立的 ShadowMapCoordinate;
排错时用 r.VirtualTexturedLightmaps 0 切回传统路径对比。

避坑 5|PreExposure 参与 8bit 编码(DeferredShadingCommon.ush:222)。
极端 HDR 间接光下 GBuffer 那 8bit 会 banding,对策是调 GBuffer 精度/曝光,不是加大 lightmap 亮度。

避坑 6|改 LOD 会换 lightmap 数据项。
LocalVertexFactory.ush:878 的 + LocalVF.LODLightmapDataIndex 意味着每个 LOD 有自己的
Scale/Bias 与 Scale/Add
;LOD 过渡时若接缝明显,先怀疑 LOD 的 lightmap 分辨率被逐级减半(第 3 章)。

23.11 渲染调度:CPU 侧怎么决定”画不画、用哪套着色器”

类比实验室:点菜前的三道闸
一个物体能不能吃到 lightmap,要过三道闸:①项目/平台允许静态光照吗(总闸,IsStaticLightingAllowed());
②这个物体真的有烘焙数据吗(LCI->GetLightMapInteraction() 是否为 LMIT_Texture);
③平台与数据支持 HQ 吗(AllowHighQualityLightmaps())。
三道全过 → HQ;缺 HQ 但允许 LQ → LQ;否则 → 不吃 lightmap(动态物体退回体积光照图,Unlit 直接没有)。

决策函数 FBasePassMeshProcessor::GetUniformLightMapPolicyType(BasePassRendering.cpp:2160-2215):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
:2164  const bool bIsLitMaterial = ShadingModels.IsLit();
:2165 const bool bAllowStaticLighting = IsStaticLightingAllowed(); // 总闸
:2167 const FLightMapInteraction LightMapInteraction = (bAllowStaticLighting && LCI && bIsLitMaterial)
? LCI->GetLightMapInteraction(FeatureLevel)
: FLightMapInteraction(); // 没数据 → 空
:2171 const bool bAllowHighQualityLightMaps = AllowHighQualityLightmaps(FeatureLevel)
&& LightMapInteraction.AllowsHighQualityLightmaps();
:2179 r.SupportLowQualityLightmaps → bAllowLowQualityLightMaps // LQ 开关
:2184 case LMIT_Texture: // 有烘焙纹理
:2187 ShadowMapInteraction == SMIT_Texture → LMP_DISTANCE_FIELD_SHADOWS_AND_HQ_LIGHTMAP
:2194 else → LMP_HQ_LIGHTMAP // ← 桌面默认
:2197 else if (bAllowLowQualityLightMaps) → LMP_LQ_LIGHTMAP // ← 移动端
:2201 else → LMP_NO_LIGHTMAP
:2206 default: 非纹理交互 → 体积光照图 / ILC / 无(半透明、无烘焙物体走这里)

策略 → 宏 → 采样行为对照表:

策略 注入的宏 运行时行为
LMP_HQ_LIGHTMAP HQ_TEXTURE_LIGHTMAP 2 次采样 + SH 方向性(桌面默认)
LMP_DISTANCE_FIELD_SHADOWS_AND_HQ_LIGHTMAP HQ + 距离场阴影 多一次静态阴影采样
LMP_LQ_LIGHTMAP LQ_TEXTURE_LIGHTMAP 2 次采样、无 SH(r.SupportLowQualityLightmaps / 移动端)
LMP_NO_LIGHTMAP 无 不采样 lightmap
LMP_PRECOMPUTED_IRRADIANCE_VOLUME_INDIRECT_LIGHTING PRECOMPUTED_IRRADIANCE_VOLUME_LIGHTING 动态物体走体积光照图(3D 纹理)

这些宏是怎么来的(编译期,不是运行时):

  • ALLOW_STATIC_LIGHTING:ShaderCompiler.cpp:3924 按 IsStaticLightingAllowed() 全局注入,不是逐材质开关;
  • HQ/LQ_TEXTURE_LIGHTMAP:LightMapRendering.cpp:481-506 的 ModifyCompilationEnvironment 按策略注入,MAX_NUM_LIGHTMAP_COEF 在 :486(LightmapData.ush 里 LightMapScale[2] 的数组长度就是这个);
  • GBuffer 结构也随它变:GBUFFER_HAS_PRECSHADOWFACTOR = USES_GBUFFER && ALLOW_STATIC_LIGHTING(ShaderMaterialDerivedHelpers.cpp:53)——关掉静态光照的项目,GBuffer 里连预计算阴影通道都不会有(对应 22.6 避坑 1)。

避坑:排列(permutation)会翻倍。同一材质会为 HQ / HQ+距离场阴影 / LQ / 无 lightmap 各编译一套
(BasePassRendering.cpp:222 的 IMPLEMENT_BASEPASS_LIGHTMAPPED_SHADER_TYPE、:537/:645 的分发)。
移动端确定不用 LQ 时,关掉 r.SupportLowQualityLightmaps 能省一批排列与 cook 时间(对照第 9 章)。

23.11.1 编辑器里的”lightmap 渲染”:密度视图是怎么画出来的

除了正常渲染,编辑器还有一种专门把 lightmap 画出来看的 pass:

  • 触发:视口 View Mode 选 Lightmap Density / Lit Lightmap Density(ShowFlags.cpp:344 与 :423 的 SetLightMapDensity(...));
  • 走独立 mesh pass:LightMapDensityRendering.cpp:34(为 HQ 策略生成着色器类型)、:310 处按策略 Process<TUniformLightMapPolicy<LMP_HQ_LIGHTMAP>>(...);
  • 着色器:LightMapDensityShader.usf(MainVertexShader / MainPixelShader,绑定见 LightMapDensityRendering.cpp:20/:24);
  • 判定条件(:299):bIsLitMaterial && (LMIT_Texture || (图元静态 && LightMapResolution > 0))——没有烘焙数据的物体会用 dummy 策略画出来,这正是”该不该显示红/蓝格子”的判据(第 7 章密度视图的源码落点)。

23.12 动手实验室:RenderDoc 五步亲眼验证

  1. 准备:室内场景 + 一盏暖色 Stationary 点光 + 冷色天光,烘焙完成后删除所有动态光(保持烘焙结果);
  2. 截帧定位 BasePass:在 RenderDoc 里找到 BasePass(或 BasePassParallel)那个 pass,勾选它的 MRT[0](SceneColor)输出;
  3. 看 BasePass 之后的 SceneColor:应该已经能看清被暖光照亮的墙面——此时还没有跑任何光照 pass,这就是 23.6 的直接证据;
  4. 看 GBuffer 亮度通道:GBufferA.b(或新版编码下的 GenericAO)只有明暗、没有冷暖差异,因为它存的是 Luminance × AO 的对数编码;
  5. 对比实验:把点光改成 Movable 再截一帧——SceneColor 的间接光部分几乎不变,变化只发生在光源 pass;这验证了”lightmap 与动态光是两本账”。

第 24 章 答疑篇(四):还能不能再压缩 · 太阳会动/环境色变化怎么办

TL;DR|压缩篇:默认状态下 lightmap 已经”压过两轮”(HDR→8bit 对数编码、8bit→BC7/ASTC),
还能再瘦的手段有五档——HQ→LQ(少一组系数,≈-50%)、降分辨率/密度、少生成附属贴图
(静态阴影贴图被强制不压缩,常是隐形大头)、流送与 VT(只压显存不压磁盘)、Pak 层压缩(只压磁盘)。
动态篇:先分清”直接光实时、间接光烘焙、阴影分家“三件事,再选方案——
常见四选一:①Stationary 太阳(默认,直接光/CSM 实时,间接光固定);
②烘焙中性光 + 运行时染色(IndirectLightingColor/Intensity 是官方钩子,天光”遮蔽烘焙、颜色实时”);
③多套烘焙 + Lighting Scenarios 切换(ULevel::bIsLightingScenario,切子关卡整换烘焙数据);
④间接光交给 Lumen/SSGI(此时引擎会把 lightmap 的染色系数置零,烘焙光自动让位)。
心智模型:Lightmap 存的是”这个表面能看到多少光”,不是”光是什么颜色”——把颜色留给运行时,烘焙就不再是枷锁。

24.1 压缩专题:还能再瘦一圈吗

24.1.1 先算账:lightmap 到底占了哪几块

一个带 HQ 烘焙的静态网格,构建产物可能有 4 张贴图(LightMap.cpp:463 起的结构体成员):

贴图 内容 每纹素 是否压缩 落点
系数贴图(HQ) 上半区 LogL+UVW、下半区 SH(2 组 RGBA8) 8 B ✅ CompressionNone = !GCompressLightmaps LightMap.cpp:1636-1642
系数贴图(LQ) 1 组 LogRGB 4 B ✅ 且 CompressionNoAlpha = true :1639
静态阴影贴图 距离场阴影 4 通道 4 B ❌ 强制 CompressionNone = true :1636
SkyOcclusion BentNormal(rgb) + 可见度(a) 4 B ✅ :1440
AOMaterialMask 单通道 AO 遮罩 → BC4 1 B→0.5 B ✅ TC_Alpha :1518-1521

源码考古:为什么唯独阴影贴图不压缩?
LightMap.cpp:1636 的 EncodeShadowMapTexture 里写着 FormatSettings.CompressionNone = true;
—— 它存的是距离场采样值,是拿去做插值和半影计算的连续数据;块压缩(BC/ASTC)的
块内误差会直接变成阴影边缘的抖动与跳变。所以”静态阴影吃内存却不压缩”是设计取舍,不是漏了。

压缩开关来自两处(StaticLightingSystem.cpp:704-706):

1
2
GConfig->GetBool(TEXT("DevOptions.StaticLighting"), TEXT("bCompressLightmaps"), GCompressLightmaps, GLightmassIni);
GCompressLightmaps = GCompressLightmaps && World->GetWorldSettings()->LightmassSettings.bCompressLightmaps; // 默认 true

即:**Lightmass.ini 的 [DevOptions.StaticLighting] bCompressLightmaps 与 WorldSettings 的开关是”与”关系**(WorldSettings.h:151/225 默认 true)。

24.1.2 五档瘦身手段(按”省多少 / 代价”排序)

档 手段 怎么开 省什么 代价 落点
① HQ → LQ 编码 平台/项目关闭 HQ(AllowHighQualityLightmaps),移动端默认 ≈ -50% 系数内存,少一组 SH 无 SH 方向性,暗部细节变平 BasePassRendering.cpp:2171/2197;LightmapCommon.ush:78
② 降分辨率/降密度 LightMapResolution、统一纹素密度 面积级收益(边长减半 = 1/4) 光斑、漏光风险上升 第 8/18 章
③ 少生成附属贴图 天光改 Movable 可省 SkyOcclusion;不用 AO 材质遮罩则省 AOMaterialMask;静态阴影能用 CSM/距离场代替就别烘焙 各 25%~40% 不等 动态阴影开销、天光遮蔽变差 LightMap.cpp:588(NeedsAOMaterialMaskTexture)、:735
④ 流送(显存) Engine.ini [TextureStreaming] LightmapStreamingFactor(默认 0.2,越小越激进),运行时可改 只省显存,磁盘与包体不变 快速转动时可能来不及加载(先糊后清) StreamingManagerTexture.cpp:170、命令行处理 :2558
⑤ 虚拟纹理 VT(显存) r.VirtualTexturedLightmaps 1(ECVF_ReadOnly,需重启) 只驻留可见 tile;还能解除 atlas 尺寸上限 多一次页表查询;磁盘不降 LightMap.cpp:96;r.IncludeNonVirtualTexturedLightmaps :104

**两条容易混淆的”压缩”**(务必区分):

  • GPU 纹理压缩(BC7/BC4/ASTC):减的是显存与带宽,进包体积也会变小;
  • Pak/Oodle 压缩:减的是磁盘与下载体积,运行时解压回同样大小,对显存没有帮助。
    二者可叠加,但别拿”包体小了”当作”显存也小了”。

LOD 组是 TEXTUREGROUP_Lightmap(LightMap.cpp:146/1860/1871/1902),默认配置见 Config/BaseDeviceProfiles.ini:201:
MinLODSize=1, MaxLODSize=16384, LODBias=0, MipGenSettings=TMGS_SimpleAverage——改 LODGroup 的 MaxLODSize/LODBias 是另一条”全局降一档”的快刀。

24.1.3 决策表:按目标选手段

你的目标 首选 次选 不建议
显存吃紧(移动端) LQ + 流送 + VT MaxLODSize 下调 关掉 bCompressLightmaps(会反向增大)
包体/下载体积 降分辨率 + Pak 压缩 少生成附属贴图 指望流送(它不减磁盘)
带宽/发热 BC/ASTC 保底 + 降 mip 采样 LQ 加大 atlas 数量(更多纹理切换)
加载时间 降分辨率 + 少附属贴图 Pak 压缩等级 VT(页表构建有成本)

24.1.4 压缩避坑

避坑 1|关掉 bCompressLightmaps 会让显存暴涨。它控制的是”是否走 BC/ASTC”,
关掉后 8bit 贴图以未压缩形式上显卡(约 ×4)。只有调试对比时临时关。

避坑 2|块压缩 + padding 不足 = 漏光放大。BC/ASTC 以 4×4 块为单位,块内会互相污染;
第 17 章讲的 4 纹素 padding 在压缩后会显得更窄,烘完看着没问题、打包后漏光——验证要打完包看。

避坑 3|静态阴影贴图是隐形大头(强制不压缩)。项目如果为了”阴影柔和”把大量 Stationary 灯
烘焙了静态阴影,ShadowMap 可能比系数贴图还大;优先考虑 CSM/距离场动态阴影,或只给关键灯烘焙。

避坑 4|流送因子不是越小越好。LightmapStreamingFactor 调太小 → 相机转动时远处 lightmap
还是低 mip,”地面先发灰再清晰”。移动端通常 0.2 起调,配合 r.Streaming.* 观察 pool 大小。

24.2 动态专题:太阳会动、环境色会变怎么办

24.2.1 先分清”什么能实时变、什么是死的”

光成分 Mobility = Static Stationary Movable
直接光 ❌ 烘焙(不可变) ✅ 实时(走光源 pass) ✅ 实时
直接阴影 烘焙进 ShadowMap 通道 两者混合:动态 CSM + 烘焙静态遮罩 ✅ 实时 CSM
间接光 烘焙进 lightmap 烘焙进 lightmap(不可变) ❌ 不烘焙
天光颜色 烘焙 实时(遮蔽烘焙) 实时

源码侧证据:

1
2
3
4
// DeferredLightingCommon.ush:111  —— Stationary 灯在自己的光源 pass 里取烘焙的阴影通道
half StaticShadowing = lerp(1, dot(PrecomputedShadowFactors, LightData.ShadowMapChannelMask), UsesStaticShadowMap);
// BasePassPixelShader.usf:487 —— Stationary 天光:遮蔽用烘焙的 BentNormal,颜色用运行时 uniform
float4 WorldSkyBentNormalAndOcclusion = GetSkyBentNormalAndOcclusion(...); // 判断是否用见 ReflectionEnvironmentShared.ush:68

结论:太阳(Directional Light)设成 Stationary 时,它的直接光与颜色可以随便动(实时 pass),
被烘焙的只有”间接光”和”静态阴影遮罩”——所以”太阳转动”真正的麻烦只有两处:间接光不跟着变、
烘焙的静态阴影遮罩不跟着转。

24.2.2 方案 A:Stationary 太阳(UE 默认答案,最省事)

  • 太阳 Stationary:直接光/颜色/CSM 阴影实时 → 日出日落的角度与颜色自动正确;
  • 场景间接光来自 lightmap(固定)→ 表现为”中午的间接光强度,用在黄昏“,但人眼对间接光的绝对强度不敏感;
  • 静态阴影遮罩固定 → 太阳转角过大(>30°~45°)时,静态物体上的软阴影方向会穿帮;
  • 适用:卡通/风格化、室内为主、或太阳角度变化范围小的项目。

24.2.3 方案 B:烘焙”中性光” + 运行时染色(最常用,代价最低)

这是把 lightmap 当”遮蔽 + 亮度图“用的思路,官方给了现成钩子:

1
2
3
4
// SceneRendering.cpp:2046-2050
ViewUniformShaderParameters.IndirectLightingColorScale = PostProcessSettings.IndirectLightingColor
* PostProcessSettings.IndirectLightingIntensity;
ViewUniformShaderParameters.PrecomputedIndirectLightingColorScale = ViewUniformShaderParameters.IndirectLightingColorScale;

而 BasePass 里(BasePassPixelShader.usf:736)正是:

1
OutDiffuseLighting *= View.PrecomputedIndirectLightingColorScale;   // lightmap 结果在这里被整体染色/调强度

玩法:

  1. 烘焙时用中性白/灰环境(或干脆只关心几何遮蔽与光照分布);
  2. 运行时用后处理设置里的 IndirectLightingColor + IndirectLightingIntensity(也可在 PostProcessVolume 里按时间/天气做曲线)给整场间接光染色、调亮暗;
  3. 天光:Stationary SkyLight 天然就是”遮蔽烘焙、颜色实时”(24.2.1 的 :487),换天空颜色不用重烘焙;
    要更彻底就上 **Movable SkyLight + bRealTimeCapture**(SkyLightComponent.h:108,受 r.SkyLight.RealTimeReflectionCapture 控制,SkyLightComponent.cpp:123-128);
  4. 直接光与阴影交给 Stationary/Movable 太阳 + CSM。

代价:间接光只有整体色偏,没有方向性变化(”夕阳从西边打进来,西墙内侧更暖”做不到)。

24.2.4 方案 C:多套烘焙 + 运行时切换(Lighting Scenarios)

UE 官方的”多套烘焙光照”机制:把每个时段的灯光放进独立的子关卡,勾选 **bIsLightingScenario**(Level.h:575),
加载时整关的烘焙数据(MapBuildDataRegistry)被整体替换:

1
2
3
4
// LevelStreaming.cpp:1105/1123
World->Scene->OnLevelAddedToWorld(LevelName, World, LoadedLevel->bIsLightingScenario);
World->Scene->OnLevelRemovedFromWorld(LevelName, World, LoadedLevel->bIsLightingScenario);
// RendererScene.cpp:4707-4728 —— 场景侧据此切换该关卡的烘焙数据
  • 优点:每个时段的质量都是”完整烘焙级”;切换是引擎原生支持的;
  • 缺点:内存/磁盘 ×N(两套 = 两份 lightmap);切换是硬切(需要做黑屏/过场/交叉淡入淡出,或用 PostProcess 颜色渐变掩盖);
  • 适用:少量离散状态(白天/夜晚、开灯/关灯、晴天/阴天),而不是连续日夜循环。

进阶(引擎不自带):有些项目在 shader 里同时采样两套 lightmap 做 lerp,实现平滑过渡。
这需要改 LightmapData/uniform(双份 LightmapDataIndex 与 Scale/Add)与采样函数,
属于引擎改造(本 fork 未做,标注为非官方方案)。

24.2.5 方案 D:间接光交给动态 GI(Lumen / SSGI)

关掉/不依赖静态光照,间接光实时计算。这里有个引擎自带的防冲突机制(也是第 23 章 23.10 避坑 1 的重要补充):

1
2
3
4
5
6
7
// SceneRendering.cpp:2052-2057
// If Lumen Dynamic GI is enabled then we don't want GI from Lightmaps
// Note: this has the side effect of removing direct lighting from Static Lights
if (ShouldRenderLumenDiffuseGI(Scene, *this))
{
ViewUniformShaderParameters.PrecomputedIndirectLightingColorScale = FVector3f::ZeroVector; // ← 直接置零!
}

含义:开启动态 Lumen GI 的视图里,lightmap 的间接光被乘 0(连带 Static 灯的直接光也一起没,注释写得很直白)。
所以 23.10 避坑 1 说的”Lumen 与 lightmap 双计”在标准 Lumen 路径下并不会发生——引擎用这个 scale 主动让位了;
需要留意的是”Static 灯的直接光也一起消失“这个副作用。

  • 优点:太阳随便动、天气随便换、颜色随便改,全实时;
  • 缺点:移动端基本用不起;桌面端也有 GI 分辨率/噪点/帧预算的代价。

24.2.6 方案 E:混合方案(大多数上线项目实际在用)

1
2
3
4
5
静态建筑/地形      → Lightmap(中性烘焙 + 运行时染色) + Stationary/Movable 太阳的 CSM 动态阴影
角色/载具/动态道具 → Volumetric Lightmap / ILC + 动态阴影(Capsule/CSM)
天空/环境色 → Movable SkyLight(可开实时捕获) + 反射捕获按需 Recapture
整体氛围 → PostProcess:Color Grading(LUT)/ 雾 / 曝光 / IndirectLightingColor
极端天气 → 少量 Lighting Scenario 切换(仅"暴雨/夜晚"这类离散态)

为什么它最实用:把”连续变化”(太阳角度、颜色、强度)交给实时部分;把”昂贵且稳定的部分”(多次反弹的间接光、接触遮蔽)交给烘焙;再用后处理把两者的色调统一起来——人眼看到”整体氛围变了”,就认为是”光照变了”。

24.2.7 决策树

1
2
3
4
5
6
需要连续日夜循环?
├─ 否(只几个离散时段) → 方案 C(Lighting Scenarios),质量最好
└─ 是
├─ 目标平台是移动端/低端 → 方案 B(中性烘焙 + 运行时染色)+ Stationary 太阳(方案 A)
├─ 桌面/主机且能付 GI 成本 → 方案 D(Lumen/SSGI),或 D + B 混用
└─ 中间态 → 方案 E(混合):静态 lightmap + 动态直接光/阴影 + 后处理统一色调

24.2.8 动态篇避坑

避坑 1|太阳设成 Static 就完全动不了。Static 灯的直接光是烘焙进 lightmap 的(不在光源 pass 里),
改 Intensity 都不会有反应——要会动,最低也要 Stationary。

避坑 2|Stationary 太阳转角太大会穿帮。烘焙的静态阴影遮罩是”某个角度下的结果”
(DeferredLightingCommon.ush:111 取通道)。大跨度日夜循环建议把静态阴影关掉(改用纯 CSM)
或只给”永远不动的补光”烘焙阴影。

避坑 3|改 IndirectLightingColor 会同时影响体积光照图与天光。该 scale 在 :736 统一乘在所有
预计算间接光结果上(含 Volumetric Lightmap 分支),不是只作用于 lightmap 贴图。

避坑 4|开 Lumen 后”烘焙好的间接光没了”是预期行为(SceneRendering.cpp:2052-2057),
且 Static 灯的直接光也会一起消失——排查”灯怎么不亮了”时先确认 Lumen Diffuse GI 是否开启。

避坑 5|Reflections 不会自动跟着天空变。反射捕获(Reflection Capture)默认只在移动/标记后重捕获,
换天空后要 Recapture 或开天光实时捕获,否则会出现”天空是黄昏、金属球里还是正午”的割裂。

24.3 动手实验室

A. 压缩对照(10 分钟)

  1. 建一个小关卡烘焙,记录 stat streaming 或 obj list class=LightMapTexture2D 里的贴图大小;
  2. 依次只改一个变量,各烘一次并对比:①HQ→LQ;②LightMapResolution 减半;③流送因子 0.2→0.05;
  3. 记录”画质可接受的最低档”——三项叠加通常能到原始显存的 1/4 左右,但漏光会明显增加(配合第 17 章排查)。

B. 动态光照(15 分钟)

  1. 太阳设 Stationary,写一个从 0° 到 180° 的旋转(Matinee/Sequence 或 Tick);
  2. 观察:直接光与 CSM 阴影实时跟随 ✅,间接光强度不变 ⚠️,静态物体上的烘焙软阴影方向不跟随 ❌;
  3. 加一个 PostProcessVolume,把 IndirectLightingColor 从白调到橙红并做动画 → 整场”氛围变黄昏”;
  4. 再做一版 Lighting Scenario:把”正午灯光”和”黄昏灯光”分别放两个子关卡并勾选 bIsLightingScenario,
    运行时切换加载 → 观察整套烘焙数据被替换(间接光也跟着变);
  5. 最后打开 Lumen(动态 GI),回看烘焙间接光是否被”让位”(对照 24.2.5 的置零逻辑)。

24.4 一张图:压缩与动态的两个旋钮

1
2
3
4
5
6
7
                  ┌──────────── 烘焙期旋钮(改了要重烘) ────────────┐
HQ/LQ 编码 · 分辨率/密度 · 附属贴图开关 · bCompressLightmaps
└─────────────────────────────────────────────────┘
┌──────────── 运行期旋钮(不用重烘) ────────────┐
IndirectLightingColor/Intensity(染色·调强) · 天光颜色 · 太阳角度/颜色
流送因子 · VT · Lumen 开关(会置零烘焙光) · PostProcess 色调
└─────────────────────────────────────────────────┘

一句话总结:烘焙决定”光从哪来、被挡了多少”;运行时决定”光是什么颜色、有多亮”。 把这两件事拆开,
日夜循环与天气系统就不再需要重烘焙。


第 25 章 概念篇(二):Stationary 到底”定”在哪——三轨模型

TL;DR|Stationary 不是”半个 Static”,而是把一盏灯拆成三条轨道:
①直接光 = 完全动态(每帧在光源 pass 里算,改颜色/强度免费);
②间接光 = 完全烘焙(Lightmass 算好进 lightmap,运行时改不了);
③阴影 = 双轨混合(近处用 CSM 动态阴影,远处用烘焙的静态阴影通道,按相机距离 lerp 淡入淡出)。
判据就两个函数:HasStaticLighting() = (Mobility == Static)、HasStaticShadowing() = (Mobility != Movable)
——Stationary 是”前者 false、后者 true”的唯一一档。所以太阳设 Stationary 后:
角度、颜色、亮度都能跟着时间走,只有”反弹光”和”静态阴影”是死的。

25.1 官方定义逐句翻译(源码里的原文)

EngineTypes.h:4076-4099 的枚举注释就是权威定义,逐句拆开:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/** Describes how often this component is allowed to move. */
Static:
// Static objects cannot be moved or changed in game.
// - Allows baked lighting
// - Fastest rendering
Stationary:
// A stationary light will only have its shadowing and bounced lighting from static geometry
// baked by Lightmass, all other lighting will be dynamic.
// - It can change color and intensity in game.
// - Can't move
// - Allows partial baked lighting
// - Dynamic shadows
Movable:
// Movable objects can be moved and changed in game.
// - Totally dynamic
// - Can cast dynamic shadows
// - Slowest rendering

关键那句:”a stationary light will only have its shadowing and bounced lighting from static geometry
baked by Lightmass, all other lighting will be dynamic” ——
烘焙的只有两样:静态几何体的阴影 + 反弹光(间接光);其余全动态。

类比实验室:餐厅的”半成品备餐”
Movable 是”现点现做”(慢但什么都来得及改);Static 是”全部预制好速冻”(快但你只能吃那天的味道);
Stationary 是”主料现炒、配菜预制“——主料(直接光、颜色)随时改,配菜(反弹光、静态阴影)
是烘焙那天备好的。你改不了配菜,但可以改主料的味道去压过它(这正是第 24 章”运行时染色”的立足点)。

25.2 三轨模型

图 F3 Stationary 灯的三轨模型(只有 ① 跟着时间走;② 与 ③ 的静态部分被"定住")
Stationary 灯 位置固定 · 颜色亮度可变 · EngineTypes.h:4085 ① 直接光 —— 完全动态 HasStaticLighting() = false(LightComponent.cpp:293) ② 间接光(反弹光)—— 完全烘焙 HasStaticShadowing() = true(:298) ③ 阴影 —— 双轨混合 CSM 动态 + 烘焙静态通道 每帧在光源 pass 里重新计算 改颜色 / 强度 / 角度:免费 → 日出日落直接生效(第 24.2.2) → Lumen 开启时会被一并让位(第 24.2.5) Lightmass 预计算,写进 lightmap 运行时只能"整体染色"(IndirectLightingColor) → 改灯要重烘焙 → 关灯房间仍亮,就是它(第 25.7 避坑 2) 近处:CSM(LightAttenuation.x) 远处:烘焙通道(PrecomputedShadowFactors) → 按距离 lerp(DeferredLightingCommon.ush:137) → 静态通道只有 4 个:超了就没静态阴影

太阳转动时:① 立即跟随 ② 永远是烘焙那一刻 ③ 静态部分不跟随、且随距离逐渐接管

25.3 两个判据函数:一切都从这两行开始

1
2
3
// LightComponent.cpp:293-301
bool ULightComponentBase::HasStaticLighting() const { return (Mobility == EComponentMobility::Static) && GetOwner(); }
bool ULightComponentBase::HasStaticShadowing() const { return (Mobility != EComponentMobility::Movable) && GetOwner(); }
Mobility HasStaticLighting() HasStaticShadowing() 直接光 间接光 阴影
Static ✅ true ✅ true 烘焙(不在光源 pass) 烘焙 烘焙静态通道
Stationary ❌ false ✅ true 实时 烘焙 双轨(CSM + 静态通道)
Movable ❌ false ❌ false 实时 实时 GI 或不参与 实时(CSM/阴影贴图)

Stationary 就是”前者 false、后者 true”的唯一一档——这就是它的全部特殊之处。

渲染侧怎么用这两个结果:

1
2
3
4
5
6
7
8
// LightSceneInfo.cpp:50        —— 送进渲染器的 compact 结构
bStaticLighting = InLightSceneInfo->Proxy->HasStaticLighting();
// LightSceneInfo.h:44-48 —— FLightSceneInfoCompact 的四个相关位
uint32 bCastDynamicShadow : 1; uint32 bCastStaticShadow : 1;
uint32 bStaticLighting : 1; uint32 bIsMovable : 1;
// RendererScene.cpp:3645 —— 移动端挑选方向光时,排除 bStaticLighting 的灯
// RendererScene.cpp:2254/3655 —— 方向光是否静态阴影,会反向决定 lightmap 策略
// (HasStaticShadowing 为真 → 可能走 LMP_DISTANCE_FIELD_SHADOWS_AND_HQ_LIGHTMAP,见 23.11)

为什么 Static 灯”改亮度没反应”?因为 HasStaticLighting() == true 时它的直接光不在光源 pass 里,
而是和间接光一起被烘进 lightmap(BasePassPixelShader.usf:1377 那条链)。运行时改 Intensity 只是改一个
渲染器根本不用的值——要能调,最低得 Stationary。

25.4 ③ 阴影双轨:方向光的距离淡出公式(源码考古)

Stationary 方向光的阴影不是”二选一”,而是按相机距离在动态与烘焙之间 lerp:

1
2
3
4
5
6
7
8
9
// DeferredLightingCommon.ush:96-111
// LightAttenuation 解码后:X = Whole scene directional light shadows(CSM)
half UsesStaticShadowMap = dot(LightData.ShadowMapChannelMask, half4(1,1,1,1));
half StaticShadowing = lerp(1, dot(PrecomputedShadowFactors, LightData.ShadowMapChannelMask), UsesStaticShadowMap);

// :135-137 —— 方向光分支:按距离混合两种阴影
float DynamicShadowFraction = DistanceFromCameraFade(SceneDepth, LightData);
OutShadow.SurfaceShadow = lerp(LightAttenuation.x, StaticShadowing, DynamicShadowFraction);
// ↑ CSM 动态阴影 ↑ 烘焙静态阴影

读法:

  • 近处(DynamicShadowFraction → 0):用 CSM → 动态物体、角色都能正确投影(这就是”Dynamic shadows”那条注释的落点);
  • 远处(DynamicShadowFraction → 1):用烘焙的静态阴影 → 省下超远距离的 CSM 级联开销;
  • 淡出距离由方向光的 Dynamic Shadow Distance Stationary Light 控制(与 CSM 的级联设置同一处)。

通道预算(LightSceneInfo.cpp:415-425 注释原文:”Static shadowing uses ShadowMapChannel, dynamic shadows
are packed into light attenuation using DynamicShadowMapChannel”;LightRendering.cpp:650-666 组装掩码):

1
2
3
ShadowMapChannelMask = (0?1:0)|(1?2:0)|(2?4:0)|(3?8:0)          // 静态:只有 4 个通道
| (dyn0?16:0)|...|(dyn3?128:0); // 动态:另外 4 个通道
// bAllowStaticLighting == false 时:ShadowMapChannel = INDEX_NONE → 掩码全 0 → StaticShadowing 恒为 1

只有 4 个静态阴影通道(一个通道 = PrecomputedShadowFactors 的一个分量)。视锥内相互重叠的 Stationary 灯
超过 4 盏时,多出来的那些拿不到静态阴影通道(GetShadowMapChannel() 返回 INDEX_NONE),
表现就是”这盏灯的静态阴影忽然没了”。

25.5 什么时候该用 Stationary(实用决策表)

场景 推荐 Mobility 理由
太阳 / 天光(要日夜循环、天气) Stationary(或 Movable + Lumen) 直接光与颜色实时;间接光固定可用 24.2.3 的染色法补
室内主光、会调亮度的吊灯 Stationary 亮度可调 + 静态阴影质量高;注意 4 通道预算
完全不动的补光(走廊灯带、灯槽) Static 最省:直接光也烘焙,运行时零光源 pass
手电、爆炸光、可移动点光源 Movable 必须实时;代价是阴影开销与无烘焙 GI
移动端大量小灯 Static 为主 + 少量 Movable 移动端光源 pass 很贵(第 9 章)

25.6 避坑清单

避坑 1|移动 Stationary 灯的位置 = 触发重建光照。
Can't move 不是随便写的:位置变了,烘焙的间接光与静态阴影全都错位。编辑器会弹
“LIGHTING NEEDS TO BE REBUILT ({0} unbuilt objects)”(UnrealEngine.cpp:12662,计数来自
UWorld::SetMapNeedsLightingFullyRebuilt,LevelActor.cpp:1857)。

避坑 2|”关掉 Stationary 灯,房间还是亮的”。 关灯只移除直接光(光源 pass 里那盏灯没了),
而间接光是烘焙进 lightmap 的像素数据,与灯是否还存在完全无关。要”关灯变暗”,就得用
第 24 章的运行时染色(IndirectLightingColor/Intensity)或走 Lighting Scenario / 动态 GI。

避坑 3|Stationary 灯超过 4 盏重叠会掉静态阴影(只有 4 个烘焙通道)。
编辑器里表现为部分灯”烘焙了但没阴影”;排查方式是看灯的 ShadowMapChannel 是否为 −1,
或减少重叠、把不重要的灯降为 Movable。

避坑 4|bUseAreaShadowsForStationaryLight 不是免费的美化(EngineTypes.h:2366-2372,默认关)。
它让静态阴影”越远越软”,但远处要保持锐利就需要更高的 lightmap 分辨率——开了之后显存与烘焙时间都会涨。

避坑 5|Stationary 灯在移动端有额外代价。 移动端为”动态物体 + Stationary 方向光”准备 CSM 时,
会强制刷新全部 mesh draw command 的 lightmap 策略(RendererScene.cpp:2234-2257,
NumMobileStaticAndCSMLights 计数)。移动端方向光能少则少。

避坑 6|别把 Stationary 当”性能银弹”。 它省的是”超远 CSM + 部分阴影计算”,代价是
玩家在近处仍要付 CSM;如果项目本来就不用 CSM(室内小场景),Static 才是最优解。

25.7 动手实验室:三分钟验证三轨

  1. 改颜色免费:放一盏 Stationary 点光,样条/蓝图里让颜色从暖白渐变到青蓝 → 场景色调实时跟随,无需重烘焙;
  2. 改位置要重建:拖动它的位置 → 编辑器立刻提示 “LIGHTING NEEDS TO BE REBUILT”,烘焙后间接光才正确;
  3. 关灯不灭:把它 Visible = false → 直接光消失但墙面的烘焙反弹光仍在(对照避坑 2);
  4. 阴影双轨:把方向光设 Stationary,拖近/拖远相机并观察静态物体上的阴影:近处边缘更”锐”(CSM),
    远处随距离过渡到烘焙阴影(DeferredLightingCommon.ush:137),把 Dynamic Shadow Distance Stationary Light
    调小 → 过渡点前移,烘焙阴影接管得更早;
  5. 通道耗尽:在同一个角落挤 5 盏 Stationary 点光 → 观察其中一盏的静态阴影消失(避坑 3)。

第 26 章 编码篇(二):HQ/LQ 到底怎么压、为什么能这么压

TL;DR|UE 的 lightmap 编码是一份”定点压缩设计“,核心就四招,按重要性排序:
① 亮度换到对数域(log2/exp2 对称,量化误差从”绝对误差”变成”恒定相对误差”);
② 亮度与色度分离(色度用 U=R/L, V=G/L, W=B/L 归一化,存的时候再开平方——gamma 空间,
让 8bit 的台阶在暗部更密);③ 用”残差通道”补精度(HQ 主纹理 w 存 LogL 高 8 位、
下半区 w 存小数残差,两者合起来等效 16bit);④ Min-Max 归一化 + 每图 Scale/Add
(把实际范围铺满 0
255,代价只是几个 float uniform)。
LQ 就是”砍掉 ②的色度独立通道与 ③的残差”,只剩一组 8bit 的对数色(4 字节/纹素);
HQ 是两组 8bit(8 字节/纹素)。最后再叠一层 BC7/BC4/ASTC 块压缩降显存。

26.1 位预算总账:每个纹素到底存了什么

HQ = Complex(NUM_STORED_LIGHTMAP_COEF 的 0/1 两组),LQ = Simple(2/3 两组)——注意
GPU Lightmass 里 LQ 也是两组(LQ 色 + LQ 方向),而运行时桌面 LQ 只用第一组(LightmapCommon.ush:78)。

组 通道 HQ 装什么 LQ 装什么 源码
0 R/G/B sqrt(U) sqrt(V) sqrt(W) 色度(gamma 空间) LogR = LogL×U,LogG = LogL×V,LogB = LogL×W LightmapEncoding.cpp:265-267 / :285-287
0 A LogL 高 8 位(归一化对数亮度) 固定 255 :268 / :288
1 R/G/B SH 方向性 Dx Dy Dz(一阶) LQ 方向性(运行时通常用常数 0.6) :277-279 / :294-296
1 A LogL 的小数残差 固定 255 :279 / :297
— 每纹素合计 8 字节 4 字节 —

两组系数在贴图里的排布(LightMap.cpp:1675 注释原文):

1
2
3
// 2 coefficients are stored on the top/bottom halves of the same destination texture
non-VT:同一张贴图的 上半区 = 系数组 0,下半区 = 系数组 1 ← 这就是 23.4 里 UV0*0.5 / UV1+0.5 的由来
VT :两个相邻 layer

26.2 编码侧逐行(GPU Lightmass QuantizeLightSamples)

26.2.1 第一步:把 RGB 拆成”亮度 + 归一化色度”

1
2
3
4
5
6
7
8
9
10
// LightmapEncoding.cpp:7-30
static void GetLUVW(const float RGB[3], float& L, float& U, float& V, float& W)
{
float R = FMath::Max(0.0f, RGB[0]); // 负光照先夹到 0
float G = FMath::Max(0.0f, RGB[1]);
float B = FMath::Max(0.0f, RGB[2]);
L = 0.3f * R + 0.59f * G + 0.11f * B; // 亮度
if (L < 1e-4f) { U = V = W = 1.0f; } // 全黑时色度给 1,避免除零
else { U = R / L; V = G / L; W = B / L; } // 色度(本身无量纲)
}

关键设计:色度用的权重(0.3/0.59/0.11)与解码端的 Luminance() 是同一套权重,于是
Luminance(U,V,W) ≡ 1——这让 LQ 把三通道亮度信息合成一个 LogL 时可以无损还原:

1
2
3
4
// LQ 编码,:122-124
float LogL = FMath::Log2(L + SimpleLogBlackPoint) / SimpleLogScale + 0.5f; // SimpleLogScale = 16
float LogR = LogL * U, LogG = LogL * V, LogB = LogL * W; // 三通道都带上了亮度
// 解码端 Luminance(LogR,LogG,LogB) = LogL × (0.3U+0.59V+0.11W) = LogL ← 精确

26.2.2 HQ:对数亮度 + 平方色度 + 残差 + SH

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 量化前先定黑点与刻度,:44-47(LogScale / SimpleLogScale)
const float LogScale = 11.5f; // HQ:决定黑点
const float LogBlackPoint = FMath::Pow(2.0f, -0.5f * LogScale); // = 2^-5.75 ≈ 0.01858

// :259-279 HQ 逐纹素打包(Residual 在 :261)
float LogL = FMath::Log2(L + LogBlackPoint); // ① 取对数(HDR → 感知均匀)
LogL = LogL * CoefficientMultiply[0][3] + CoefficientAdd[0][3];// ② Min-Max 归一化到 0..1

float Residual = LogL * 255.0f - FMath::RoundToFloat(LogL * 255.0f) + 0.5f; // ③ 小数残差(+0.5 做零偏置)

DestCoefficients.Coefficients[0][0] = Sqrt(U) * 255; // ④ 色度存 sqrt(gamma 空间)
DestCoefficients.Coefficients[0][1] = Sqrt(V) * 255;
DestCoefficients.Coefficients[0][2] = Sqrt(W) * 255;
DestCoefficients.Coefficients[0][3] = LogL * 255; // 亮度高 8 位
DestCoefficients.Coefficients[1][0] = Dx * 255; // ⑤ SH 方向性 3 通道
DestCoefficients.Coefficients[1][1] = Dy * 255;
DestCoefficients.Coefficients[1][2] = Dz * 255;
DestCoefficients.Coefficients[1][3] = Residual * 255; // 亮度残差 8 位(与被复用的 A 通道)

三个容易被忽略的巧思:

1
2
3
4
5
// :221-224  ⑥ SH 常数项写死,省掉一次加法和一整个通道的信息量
OutMultiply[1][3] = 0.0f; OutAdd[1][3] = 0.282095f; // "Force SH constant term to 0.282095f. Avoids add in shader."
// :101-110 ⑦ 暗纹素抑制方向性:L 很小时方向性本来就没意义,压掉可省码值
float DampenDirectionality = FMath::Clamp(L * 100.0f, 0.0f, 1.0f);
MinCoefficient[1][ColorIndex] = Min(..., DampenDirectionality * SH[...]);

26.2.3 每图 Min-Max 归一化(Scale/Add 的真正来源)

1
2
3
4
5
6
// :203-206  量化:y = (x - Min) / (Max - Min)
CoefficientMultiply = 1.0f / Max(Max - Min, DELTA);
CoefficientAdd = -Min / Max(Max - Min, DELTA);
// :208-209 输出反向还原用的 Scale/Add(送进 shader uniform)
OutMultiply = 1.0f / CoefficientMultiply; // = Max - Min
OutAdd = -CoefficientAdd / CoefficientMultiply; // = Min

逐通道(16 个通道各一套)分别统计 Min/Max(ParallelFor 两段归约,:62-110 分块统计、:150-170 合并),
所以”每张 lightmap × 每个通道”都有自己的 Scale/Add——这就是 uniform 里
LightMapScale[2]/LightMapAdd[2] 共 4 个 float4 的来历(第 23 章 23.3 的表)。

26.2.4 顺带压的另外两张图

数据 编码手法 源码
SkyOcclusion 方向 法线 ×0.5+0.5 映射到 [0,1] 存 8bit :246-249
SkyOcclusion 可见度 **存 sqrt(长度)**(”Sqrt on length to allocate more precision near 0”) :250-252
AOMaterialMask **存 sqrt(AO)**(同上,暗部更细) :255
覆盖率 A 通道 = bIsMapped ? 255 : 0(兼作”该纹素有没有光照数据”) :244

26.3 解码侧逐行:每一步都是编码的逆运算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Shaders/Private/LightmapCommon.ush
// —— HQ(:146-187)
half LogL = Lightmap0.w; // 高 8 位
LogL += Lightmap1.w * (1.0/255) - (0.5/255); // ⊕ 残差:主位+残差 = ~16bit 精度
LogL = LogL * LightMapScale[0].w + LightMapAdd[0].w; // 还原 Min-Max(= ×(Max-Min) + Min)
half3 UVW = Lightmap0.rgb * Lightmap0.rgb * LightMapScale[0].rgb + LightMapAdd[0].rgb; // 平方 ← 逆 sqrt
half L = exp2(LogL) - LogBlackPoint; // exp2 ← 逆 log2(黑点 0.01858136 = 2^-5.75 ✓)
float4 SH = Lightmap1 * LightMapScale[1] + LightMapAdd[1]; // .w 恒为 0.282095(常数项写死)
half Directionality = max(0.0, dot(SH, float4(WorldNormal.yzx, 1)));
half3 Color = (L * Directionality) * UVW; // 全彩 HDR 间接光

// —— LQ(:91-118)
half3 LogRGB = Lightmap0.rgb * LightMapScale[0].rgb + LightMapAdd[0].rgb;
half LogL = Luminance(LogRGB); // 三通道合成的对数亮度
half L = exp2(LogL * 16 - 8) - LogBlackPoint; // ×16-8 ← 逆 "/16+0.5"
half Directionality = 0.6; // 无 SH:固定方向性

可逆性对照表(这张表能记住,编码就吃透了):

编码 解码 作用
LogL = log2(L + black) L = exp2(LogL) - black 对数域量化(黑点 2^-5.75 HQ / 2^-8 LQ)
sqrt(U) rgb * rgb gamma 空间分配(暗部台阶更密)
norm = (x-Min)/(Max-Min) x*normOut + AddOut(= Max-Min, Min) 每图归一化
Residual = frac(LogL*255)+0.5 + (w/255 - 0.5/255) 残差补精度(等效 ~16bit)
log2(L)/16 + 0.5(LQ) exp2(x*16 - 8) 固定刻度(没有残差通道可用)
SH.w 恒 0.282095 dot(SH, (n.yzx, 1)) 常数项写死,省一次加法

源码考古:残差为什么能做到”等效 16bit”?
设归一化对数亮度为 v,代码做 m = round(v*255)(存进组 0 的 A),
r = v*255 - m + 0.5(存进组 1 的 A,r∈[0,1])。
解码是 m/255 + (r_byte/255)/255 - 0.5/255:
因为 r_byte ≈ 255r = 255(v*255-m) + 127.5,代入后尾项正好是 (v*255-m)/255,
于是总和回到 v——两个 8bit 拼出归一化对数空间里 ~1/65025 的精度,
比单个 8bit(1/255)细分了约 255 倍。亮度的 coding 动态范围由此拿到”几乎无 banding”的平滑过渡。

26.4 为什么能这么压:七条原理

  1. 感知是对数的(韦伯–费希纳):把亮度取 log2 再均匀量化,误差就从”绝对值恒定”变成
    “相对误差恒定“(8bit ≈ 每级 0.4% 相对误差)。阳光到阴影跨好几个数量级,只有这样 8bit 才够用;
    而且 log2/exp2 是硬件指令,解码几乎免费。
  2. 色度可以粗,亮度必须细:人眼对亮度细节的分辨力远高于色度(这也是 YUV 4:2:0 存在的理由)。
    UE 的做法是把亮度单独拉一条通道(HQ 的 w),色度三通道只负责”颜色比例”。
  3. 残差通道 = 白送的精度:8bit 主位之外,A 通道的 8bit 不一定装得满信息,就拿来装小数残差
    (HQ 组 1 的 A)。这是”能在 8bit 里塞下 HDR 平滑过渡”的关键一招。
  4. gamma(平方)空间分配:暗部码值更密是”相对误差恒定”的必然要求;sqrt 编码 + rgb² 解码成本极低。
  5. 一阶 SH 足够 + 常数项写死:间接光是漫反射(低频方向信号),一阶 SH(3 个系数)即可表达
    “哪边更亮”;常数项固定为 0.282095 省掉一次加法和通道的有效载荷;暗纹素的方向性还被主动抑制。
  6. Min-Max 归一化:逐通道把实际范围铺满 0~255(代价只是几个 float uniform),
    避免”整张图只用掉 30 个码值”这种隐形的精度浪费。
  7. 信号本身低频 + 空间相关:lightmap 存的是间接光(多次反弹后的漫反射),天然平滑;
    4×4 块的 BC7/ASTC 压缩、以及 mip 平均都能吃到这个红利——只有阴影边界与 UV 接缝是高频,
    这也是”必须留 4 纹素 padding”的根本原因(第 17/20 章)。

26.5 压缩链全景与代价

1
2
3
4
5
烘焙 HDR 光照 (float, 任意范围)
→ LUVW 分解 + log2 + sqrt + Min-Max 归一化 + 残差 ← 本章 26.2(每次 16B → 8B(HQ) / 4B(LQ))
→ 8bit 贴图(上下半区 / VT 两层)
→ BC7(HQ 带 alpha)/ BC4(AOMask)/ ASTC(移动端) ← 显存再 ÷2~4(LightMap.cpp:1636-1642)
→ 运行时 exp2 / 平方 / SH 点积还原 ← LightmapCommon.ush:146-187
手段 压缩比 代价
LUVW + log2 + sqrt + 归一化 16B → 8B(HQ)/ 4B(LQ) 每图 4 个 float4 uniform;亮度上限受 Scale 精度限制
残差位 0 额外字节(复用 A 通道) 通道用途耦合(同一个字节编码/解码含义不同,改代码需成对改)
HQ → LQ 再 ÷2 无色度独立通道、无残差、无 SH(方向性常数 0.6)
BC7 / ASTC ÷2~4 块内污染(需 padding)、边缘轻微块状

26.6 避坑清单

避坑 1|改编码公式必须”成对改”。组 1 的 A 通道在编码侧是亮度残差、在解码侧被 Scale/Add
压成 SH 常数 0.282095——
同一个字节两种含义
。只改一边 = 亮度瞬间跑飞或方向性消失。

避坑 2|LogScale 只影响黑点,不影响刻度。HQ 的 LogScale=11.5 推导出黑点 2^-5.75;
真实刻度由 Min-Max 归一化决定。想”提高暗部精度”应该调黑点/分辨率,而不是改 LogScale 的直觉理解。
(LQ 不同:SimpleLogScale=16 是真的刻度,因为 exp2(x*16-8) 与之成对。)

避坑 3|LQ 没有方向性。Directionality = 0.6 是常数(LightmapCommon.ush:115),
所以 LQ 场景里”墙角、褶皱”的间接光会显得平——这是编码决定的,不是烘焙质量差。

避坑 4|残差通道被后处理/手工编辑破坏。如果你拿脚本直接改 lightmap 贴图(或做二次压缩),
组 1 的 A 通道(残差)往往被当作”无用 alpha”清掉 → 立刻出现大面积 banding。
同理,skyocclusion 存的是 sqrt、AOMask 存的也是 sqrt,别按 sRGB 流程处理它们(FormatSettings.SRGB = false,LightMap.cpp:1639)。

避坑 5|BC7 的 alpha 与 HQ 的残差不冲突,但别选错格式。HQ 需要 alpha(残差在那里),
所以 CompressionNoAlpha = CoefficientIndex >= LQ_LIGHTMAP_COEF_INDEX(LightMap.cpp:1640)——
HQ 组必须带 alpha,LQ 组可省。手工重导贴图时选错会直接丢残差。

26.7 动手实验室

  1. 看数据:把烘焙好的 lightmap 贴图导出为 PNG,肉眼观察——上半区偏灰紫(sqrt 色度 + 对数亮度),
    下半区更”噪”(SH + 残差),这跟直觉的”彩色光照图”完全不同,说明它是一份系数图而非图片;
  2. 验可逆:写个 20 行脚本,按 26.3 的公式把组 0 的 w 与组 1 的 w 合成 LogL,
    再 exp2 还原,和”只用组 0 的 w”对比——能直观看到 banding 的差别(残差的作用);
  3. 测 LQ 代价:同一场景烘两次(HQ / LQ),在墙角与褶皱处截图对比间接光的立体感差异;
  4. 验 Scale/Add:在 LightmapData.ush:28-46 打印某物体的 LightMapScale/Add,
    把 Max-Min 与你的场景光照范围对照,理解”归一化”到底吃掉了多少动态范围;
  5. 踩一次坑:用图像工具把 lightmap 的 alpha 抹掉再打包 → 观察 banding(对照避坑 4),然后再烘一次恢复。

第 27 章 编码篇(三):SkyOcclusion 那张图——能压吗?压完还剩什么

TL;DR|能压,而且默认就在压——它和系数图共用同一个总闸 GCompressLightmaps(LightMap.cpp:50,默认 true)。
但它跟系数图有个致命区别:系数图的 LQ 组允许丢 alpha(CompressionNoAlpha = true → BC1 4bpp),
SkyOcclusion 不允许(CompressionNoAlpha = false,LightMap.cpp:1441),因为它的 alpha 里装着天空可见度、
RGB 里装着 bent normal 方向。结果:它只能压到 8bpp,压不到 4bpp——压缩后 1 字节 / lightmap 纹素,
恰好等于整张 LQ 系数图,也就是 HQ 系数图的一半。
三条可操作的结论:① 想省钱先别生成它(bHasSkyShadowing);② 别指望它像 LQ 系数图那样减半;
③ 走 VT 时它的压缩换了一套开关(r.VT.EnableLossyCompressLightmaps,默认关)。

27.1 先定位:它是第 3 类产物,但根本不是”一张图”

第 20 章列过”一套 Lightmap = 最多 5 类数据”,第 26 章把系数图(第 1、2 类)的定点压缩讲透了。
本章补上第 3 类——SkyOcclusionTexture:

1
2
3
4
5
// LightMap.h:328(FLightMap2D 成员)
TObjectPtr<ULightMapTexture2D> SkyOcclusionTexture;

// LightMap.h:495(烘焙数据里的对应字段,注意是 4 个 uint8)
uint8 SkyOcclusion[4];

它有个容易被忽略的性质:它不是”一张好看的图”,而是一份几何信息。第 26 章那些系数图存的是”光有多少”
(亮度 + 色度 + 球谐),而这张图存的是”从这里往上看,天空被挡了多少、主要从哪个方向漏进来“。
这个差别决定了它后面所有压缩上的取舍。

27.2 编解码逐行:一个方向 + 一个可见度

烘焙端(Lightmass)的量化,源码只有 9 行,但信息量很大:

1
2
3
4
5
6
7
8
9
10
// UnrealLightmass/Private/Lighting/LightmapData.cpp:266-274
const FVector3f BentNormal(SourceSample.SkyOcclusion[0], SourceSample.SkyOcclusion[1], SourceSample.SkyOcclusion[2]);
const float BentNormalLength = BentNormal.Size();
const FVector3f NormalizedBentNormal = BentNormal.GetSafeNormal() * FVector3f(.5f) + FVector3f(.5f);

DestCoefficients.SkyOcclusion[0] = (uint8)FMath::Clamp<int32>( FMath::RoundToInt( NormalizedBentNormal[0] * 255.0f ), 0, 255 );
DestCoefficients.SkyOcclusion[1] = (uint8)FMath::Clamp<int32>( FMath::RoundToInt( NormalizedBentNormal[1] * 255.0f ), 0, 255 );
DestCoefficients.SkyOcclusion[2] = (uint8)FMath::Clamp<int32>( FMath::RoundToInt( NormalizedBentNormal[2] * 255.0f ), 0, 255 );
// Sqrt on length to allocate more precision near 0
DestCoefficients.SkyOcclusion[3] = (uint8)FMath::Clamp<int32>( FMath::RoundToInt( FMath::Sqrt(BentNormalLength) * 255.0f ), 0, 255 );

拆开读,这是两个物理量塞进一个 RGBA8:

通道 存什么 编码方式
R / G / B 归一化 bent normal 的 x / y / z n * 0.5 + 0.5(把 [-1,1] 平移到 [0,1]),2:1 打包
A 天空可见度 = length(n) sqrt(len),平方根压缩

SkyOcclusion[0..2] 本身就是”方向向量 × 可见度”的合体:取 length() 得到可见度,取 normalize() 得到方向。
一个向量同时编码了两件事——这是它最省的地方,也是后面”为什么不能丢 alpha”的根因。

解码端是严格逆运算(Shaders/Private/LightmapCommon.ush:198-210):

1
2
3
4
5
6
// LightmapCommon.ush:204-210
TextureValue = Texture2DSample( LightmapResourceCluster.SkyOcclusionTexture, LIGHTMAP_SHARED_SAMPLER(SkyOcclusionSampler), LightmapUV );
// Unpack vector
TextureValue.rgb = TextureValue.rgb * 2 - 1;
// Undo sqrt which allocated more precision toward 0
TextureValue.a = TextureValue.a * TextureValue.a;

两个细节值得单独说:

  • *2-1 之后必须重新归一化。采样点(BasePassPixelShader.usf:487-490)拿到的是
    WorldSkyBentNormalAndOcclusion,紧跟着就 normalize(...),注释直言不讳:
    *”Renormalize as vector was quantized and compressed”*(:488)。这一句就是本章的题眼:
    8bit 量化 + 块压缩都会让方向偏离单位球,所以作者明确预期了误差、并回正。
  • sqrt 不是”压缩”,是”分配精度”。可见度在物理上大量落在小值区间(被遮挡的地方),
    平方根把精度往 0 附近搬——和系数图里色度存 sqrt(第 26.2)、AOMask 存 sqrt(:277)是同一个套路。
    注意这是
    烘焙期就做掉的有损步骤
    ,跟纹理块压缩是两回事。

27.3 尺寸的坑:为什么 shader 要 ScaleLightmapUV(uv, (1,2))

这是本章最容易被忽略的一点:SkyOcclusion 贴图的高度只有系数图的一半。

1
2
3
4
5
// LightMap.cpp:1857  SkyOcclusion:W × H
Texture->Source.Init2DWithMipChain(GetSizeX(), GetSizeY(), SkyOcclusionFormat);

// LightMap.cpp:1899 系数图:W × 2H,上半区下半区各放一组系数
Texture->Source.Init2DWithMipChain(GetSizeX(), GetSizeY() * 2, BaseFormat); // Top/bottom atlased

因为系数图是 W × 2H 的”上下拼版”,顶点里存的那份 lightmap UV 是”拼版后”的坐标系;
而 SkyOcclusion(以及 AOMask)是单高度图,所以采样前必须把 UV 还原一次:

1
2
3
4
5
6
7
8
// LightmapCommon.ush:53
UV = ScaleLightmapUV(UV, float2(1.0f, 2.0f)); // Undo transform used to pack 2 lightmap coeffs in 1 texture for the non-VT default case

// BasePassPixelShader.usf:487(SkyOcclusion)
... GetSkyBentNormalAndOcclusion(LightmapVTPageTableResult, ScaleLightmapUV(LightmapUV, float2(1.0f, 2.0f)), ...)

// BasePassPixelShader.usf:948(AOMask,同样处理)
MaterialParameters.AOMaterialMask = GetAOMaterialMask(LightmapVTPageTableResult, ScaleLightmapUV(LightmapUV0, float2(1, 2)), ...);

实用含义:如果你手工导出一张 SkyOcclusion 并替换原来的,**尺寸必须是 W×H 而不是 W×2H**,
否则会整体错位半张。第 26.6 的”通道用途耦合”提醒过一次,这里是”尺寸语义耦合“,同一类坑。

27.4 能压吗?——能,而且默认就在压

直接看 EncodeSkyOcclusionTexture 的头部(这是本章要回答的问题的答案所在):

1
2
3
4
5
6
// LightMap.cpp:1439-1443
FTextureFormatSettings FormatSettings;
FormatSettings.SRGB = false;
FormatSettings.CompressionNoAlpha = false;
FormatSettings.CompressionNone = !GCompressLightmaps;
Texture->SetLayerFormatSettings(LayerIndex, FormatSettings);
  • CompressionNone = !GCompressLightmaps → 只要 GCompressLightmaps 是 true(默认,LightMap.cpp:50),
    它就走块压缩
    。这个总闸同时管着系数图、SkyOcclusion、AOMask 和静态阴影贴图
    (第 24 章已考证:静态阴影因为存距离场,被强制不压缩,是唯一的例外)。
  • SRGB = false → 存的是数据不是颜色,不能过 sRGB 曲线(否则 sqrt/exp2 全乱)。
  • CompressionNoAlpha = false → 关键,下一节展开。

所以”能不能压”的答复是:能,且默认开启。真正有信息量的问题不是”能不能”,而是”能压成什么格式“。

27.5 但格式被 alpha 卡住了(本章重点)

把四张图的格式设置并排放,差异一目了然:

1
2
3
4
5
6
7
8
9
// LightMap.cpp:1640  系数图:HQ 组(下标 0/1)保留 alpha,LQ 组(下标 2/3)可丢
FormatSettings.CompressionNoAlpha = CoefficientIndex >= LQ_LIGHTMAP_COEF_INDEX;

// LightMap.cpp:1441 SkyOcclusion:永远保留 alpha
FormatSettings.CompressionNoAlpha = false;

// LightMap.cpp:1519-1521 AOMask:保留 alpha + 显式指定 BC4
FormatSettings.CompressionNoAlpha = false;
FormatSettings.CompressionSettings = TC_Alpha; // BC4

LQ_LIGHTMAP_COEF_INDEX = 2(SceneManagement.h:357),所以系数图是”HQ 带 alpha、LQ 丢 alpha“。
CompressionNoAlpha 到底影响什么?可以拿虚拟纹理那段代码反推它的语义
(RuntimeVirtualTextureComponent.cpp:482-483):

1
2
OutFormatSettings.CompressionNoAlpha   = LayerFormat == PF_DXT1 || LayerFormat == PF_BC5 || ...;
OutFormatSettings.CompressionForceAlpha = LayerFormat == PF_DXT5;

CompressionNoAlpha 对应 BC1(PF_DXT1,4bpp),CompressionForceAlpha 对应 BC3(PF_DXT5,8bpp)。
于是四张图的压缩结果可以列成一张表(成本按 lightmap 纹素 归一,W×H 里按 H 算一个纹素行):

贴图 物理尺寸 组数 CompressionNoAlpha 压缩后格式 每 lightmap 纹素
HQ 系数图 W × 2H 2 组(LogLUVW + SH/残差) false BC3/BC7 8bpp 2.0 B
LQ 系数图 W × 2H 2 组(LogRGB) true BC1 4bpp 1.0 B
SkyOcclusion W × H 1 组 false BC3/BC7 8bpp 1.0 B
AOMask W × H 1 组(单通道) false BC4 4bpp(显式 TC_Alpha) 0.5 B
静态阴影图 W × H 1 组(1~4 通道) — 强制不压缩 1~4 B

校准:第 26.1 的位预算表给的是”每组系数的源字节“;本节补上”物理高度“这一维。
两者相乘才是真实显存——这也是为什么”HQ 是 LQ 的两倍”在表里体现为 2.0 B vs 1.0 B。

为什么 SkyOcclusion 不能丢 alpha? 回到 27.2:rgb 是方向、a 是可见度,四个通道全是数据。
BC1 只有 4bpp、没有独立 alpha,压下去等于把可见度扔了——表现就是”室内的天空光突然不再随遮挡变化”。
所以它必须付 8bpp 的钱。反观 LQ 系数图能压到 4bpp,是因为它只有一个 LogRGB 量、alpha 是空的。

顺带一个不对称:AOMask 是单通道,作者显式写了 TC_Alpha // BC4 把它钉在 BC4(4bpp);
SkyOcclusion 没有显式指定格式,靠”保留 alpha”这个约束间接决定了它落在 8bpp 那一档。
换句话说,**AOMask 的省是”主动优化”,SkyOcclusion 的贵是”被动结果”**。

27.6 压缩的代价:方向会被”块内污染”

方向类数据用块压缩,有一个绕不开的问题:压缩是块内(4×4)线性拟合,不保证结果还落在单位球面上。
作者对这个代价是明确的(BasePassPixelShader.usf:487-490):

1
2
3
4
float4 WorldSkyBentNormalAndOcclusion = GetSkyBentNormalAndOcclusion(..., ScaleLightmapUV(LightmapUV, float2(1.0f, 2.0f)), ...);
// Renormalize as vector was quantized and compressed
NormalizedBentNormal = normalize(WorldSkyBentNormalAndOcclusion.xyz);
SkyVisibility = WorldSkyBentNormalAndOcclusion.w;

“quantized and compressed” 并列出现——量化(8bit)和块压缩(BC3/BC7)都会引入误差,
所以 normalize() 不是可选项而是必需项。这也解释了为什么它不用法线贴图那套 BC5 两通道方案
(材质法线常用 BC5 存 xy、z 重建,4bpp):因为这里 4 个通道全被占满(xyz 方向 + 可见度),
塞不下”两个通道”的方案。

误差最终怎么体现?看消费端(BasePassPixelShader.usf:508-519):方向误差会通过
BentNormalWeightFactor = 1 - (1 - SkyVisibility)² 加权,混进 SkyLightingNormal 和 GeometryTerm。
可见度越低(越暗的角落),越依赖 bent normal,方向误差越容易被看出来——所以在压得很狠的大场景里,
墙角天光的”渗色方向”比亮度更容易先出问题。

27.7 最彻底的压缩:干脆别生成它

比”压到 8bpp”更省钱的办法是这张图根本不存在:

1
2
3
4
5
6
7
8
9
10
11
12
13
// LightMap.cpp:1363-1387  FLightMapPendingTexture::NeedsSkyOcclusionTexture()
if (bUObjectsCreated) { ... return SkyOcclusionTexture != nullptr; }
bool bNeedsSkyOcclusionTexture = false;
for (int32 AllocationIndex = 0; AllocationIndex < Allocations.Num(); AllocationIndex++)
{
auto& Allocation = Allocations[AllocationIndex];
if (Allocation->bHasSkyShadowing) // ← 只看这一个标志
{
bNeedsSkyOcclusionTexture = true;
break;
}
}
return bNeedsSkyOcclusionTexture;

bHasSkyShadowing(LightMap.h:519,默认 false,来自 Lightmass 的 FQuantizedLightmapData)为假时,
连贴图都不会创建(LightMap.cpp:1278-1281 只在需要时才 NewObject)。VT 路线同理——
只有 NeedsSkyOcclusionTexture() 为真才 SetLayerForType(ELightMapVirtualTextureType::SkyOcclusion, ...)
(LightMap.cpp:1340-1342)。

避坑 1|生成判据和采样判据不是同一个。生成只看 bHasSkyShadowing;
而 BasePass 的读取点挂在 #elif HQ_TEXTURE_LIGHTMAP 分支里(BasePassPixelShader.usf:484-490),
LQ 路径不读它。一个 LQ-only 的项目如果仍满足 bHasSkyShadowing,就可能白生成一张没人采样的 8bpp 图——
排查显存时值得顺手看一眼。想彻底省,最干净的做法是别让 SkyLight 需要逐纹素遮蔽(见第 24 章的天光方案)。

27.8 VT 路线:它变成第 4 层,压缩换轨

开了虚拟纹理(r.VirtualTexturedLightmaps)后,SkyOcclusion 不再是一张独立贴图,
而是变成 lightmap VT 的第 4 层。层号在 shader 里能直接读到:

1
2
3
// LightmapCommon.ush:202   VT 路线,LayerIndex = 3u
TextureValue = SampleLightmapVT( LightmapVTPageTableResult, 3u, LightmapDataIndex, LightmapResourceCluster.VTSkyOcclusionTexture, ... );
// 对照:0u/1u = 系数,2u = 静态阴影,4u = AOMask(:218)

对应的 CPU 侧(LightMap.cpp:1930-1932)把层的源格式设成 SkyOcclusionFormat(TSF_BGRA8,:1849),
而整张 VT 对象的压缩开关与独立贴图不同(LightMap.cpp:1948-1950):

1
2
3
VirtualTexture->CompressionNoAlpha = false;
VirtualTexture->CompressionNone = !GCompressLightmaps;
VirtualTexture->LossyCompressionAmount = CVarVTEnableLossyCompressLightmaps.GetValueOnAnyThread() ? TLCA_Default : TLCA_None;
  • CompressionNone 仍然听 GCompressLightmaps(总闸不变);
  • 但多了一个独立的 r.VT.EnableLossyCompressLightmaps(LightMap.cpp:111-114,默认 0)。
    CVar 的说明写得很克制:*”Lossy compression tends to have lower quality on lightmap textures, vs regular color textures.”*
    → 默认 TLCA_None,即 VT 路线默认不做额外的有损压缩,只保留常规块压缩。

还有个顺手能省的开关:r.IncludeNonVirtualTexturedLightmaps(:103-106,默认 0)——
已经在用 VT 时,不会再额外生成一套非 VT 贴图(同一份数据存两遍)。

27.9 压缩比总账

顺着第 26.4 的”七条原理”,SkyOcclusion 这一行的账是这样的(以一张 W×H 的逻辑 lightmap 为例):

环节 动作 体积变化 有无损
烘焙输出 方向 + 可见度 → RGBA8,*0.5+0.5 / sqrt 3 float + 1 float → 4 B 有损(8bit 量化 + sqrt)
块压缩 BC3/BC7(因 CompressionNoAlpha = false) 4 B → 1 B 有损(块内拟合,方向需 normalize 回正)
走到 LQ 路线 不采样 0 B(但可能仍被生成,见避坑 1) —
走 VT 路线 变第 4 层,默认 TLCA_None 与上同档 不看额外有损

一句话总结压缩比:4 B → 1 B(÷4),但压不到 LQ 系数图那种 ÷4 之后的 4bpp——
它的 1 B 是”H 只有一半 + 8bpp”两个因素叠出来的,而不是靠更狠的格式。

27.10 避坑清单

避坑 1|生成判据 ≠ 采样判据。生成只看 bHasSkyShadowing(LightMap.cpp:1380),
采样只在 HQ 分支(BasePassPixelShader.usf:484)。LQ-only 项目里可能出现”生成了却没人读”。

**避坑 2|手工替换时尺寸是 W×H,不是 W×2H**。系数图那种上下拼版不适用于它
(LightMap.cpp:1857 vs :1899),尺寸给错会整体错位半张图。

避坑 3|别按颜色纹理处理它。SRGB = false(LightMap.cpp:1440),
RGB 是 *0.5+0.5 的方向、A 是 sqrt 可见度——过一遍 sRGB 曲线会让方向和可见度双双失真。

避坑 4|别为了省显存给它换 BC1。换了就等于丢掉 A 通道里的天空可见度,
表现为”室内天光不再随遮挡变化”(这正是本章问题的答案的另一面)。

避坑 5|指望”开 VT 就能压得更狠”是想当然。VT 只是换轨道,
r.VT.EnableLossyCompressLightmaps 默认 0,要显式打开才多一层有损,且官方注释提醒它对 lightmap 质量不友好。

27.11 动手实验室

  1. 看四张图:烘焙一个含 Stationary 天光 + 室内遮挡的关卡,用第 18 章导出的思路把
    BuildData 下的纹理列出来,对比 HQ 系数图(W×2H)与 SkyOcclusion(W×H)的尺寸——把 27.3 的结论看实;
  2. 验通道:把 SkyOcclusion 导成 PNG,观察 A 通道(应呈”遮蔽负片”观感)、RGB 通道(大体是中灰偏方向性的渐变),
    确认它既不是彩色图也不是灰度图,而是方向 + 可见度;
  3. 测 compress 开关:Lightmass.ini 里把 bCompressLightmaps 关掉重烘,对比 SkyOcclusion 的显存占用——
    应该翻到 4 倍(4 B → 未压缩的 BGRA8);再和系数图对比,验证”两张图受同一个总闸控制”;
  4. 踩一次错尺寸的坑:手工把一张 SkyOcclusion 按 W×2H 喂回去,观察天光整体错位半张,
    然后按 W×H 恢复(对照避坑 2);
  5. 验 VT 换轨:在 r.VirtualTexturedLightmaps 打开的项目里,分别试
    r.VT.EnableLossyCompressLightmaps 0/1,在暗角处对比天光”渗色方向”的变化,直观感受 27.6 里
    “可见度越低越依赖 bent normal”的含义。

第 28 章 体积光照图篇:Volumetric Lightmap——当光不只贴在表面上

TL;DR|前 27 章讲的 lightmap 有个共同前提:光得有一张”皮”才存得住(UV → 纹素 → 贴图)。
可场景里一大半东西没有皮——动态角色、可移动物体、半透明材质、体积雾、粒子。它们需要”空气里到处都有光“,
这就是 Volumetric Lightmap(体积光照图,VLM)。
做法:把 Lightmass Importance Volume 切成一块块 Brick(砖),每块砖里是一个 3D 体素网格;
每个体素存 1 个环境项 + 8 个方向项(合起来就是 2 阶球谐)+ 天空 bent normal + 方向光阴影因子;
再用一张 IndirectionTexture(间接索引 3D 纹理) 当”稀疏指针表”,把世界坐标映射到”该查哪块砖”。
三条结论:① 它是”空间里的光”,不是”表面上的光”;② 它不是均匀网格,只在几何/灯光/密度体附近细分;
③ 它从出生就是量化的——环境项 R11G11B10(4 B)、SH 各 8bit(4 B × 6)、阴影 1 B,
一个体素最少 29 字节
。

28.1 来路:lightmap 只活在”皮”上

第 1 章那个”预计算哲学”有个隐藏条件:预计算结果得有个地方放。
放的地方就是物体的第二套 UV——有 UV 才有纹素,有纹素才有 FLightMap2D。
于是下面这些东西天生没有 lightmap:

  • 可移动(Movable)物体:角色的位置随时变,lightmap 绑在物体本地空间,跟着动就等于没烘;
  • 半透明材质(BLEND_Translucent 等):BasePass 的 GBuffer 都不写,更谈不上采样 lightmap;
  • 体积雾 / 高度雾:雾是”世界空间里的介质”,不住在任何网格上;
  • 粒子、Decal 里的间接光……

它们不是”lightmap 做错了”,而是”lightmap 的坐标系根本不在这”。要照亮它们,需要一个世界空间的结构。

UE 很早的答案是 Indirect Lighting Cache(ILC):在空间里撒一堆点,每个点存一份 SH,
可动物体取最近的点插值。这就是今天的第二个选项 VLM_SparseVolumeLightingSamples(见 28.2)。
它的毛病写在 WorldSettings.h:46-51 的注释里,一针见血:

1
2
3
4
5
6
7
8
// WorldSettings.h:46-51(VLM_SparseVolumeLightingSamples 的注释)
/**
* Volume lighting samples are placed on top of static surfaces at medium density,
* and everywhere else in the Lightmass Importance Volume at low density. ...
* This method requires CPU interpolation so the Indirect Lighting Cache is used to
* interpolate results for each dynamic object, adding Rendering Thread overhead.
* Volumetric Fog cannot be affected by precomputed lighting with this method.
*/

两句话点死 ILC 的两个天花板:① 插值在 CPU 上做(渲染线程开销);② 体积雾拿不到预计算光照。
VLM 就是把这两条同时解决的方案:把结构搬进 3D 纹理,让 GPU 逐像素插值。

28.2 二选一:VolumeLightingMethod

WorldSettings → Lightmass → Volume Lighting Method 就两个选项(WorldSettings.h:35-52),
默认是第一个(WorldSettings.h:220:VolumeLightingMethod(VLM_VolumetricLightmap)):

枚举 显示名 数据结构 谁插值 体积雾能用吗
VLM_VolumetricLightmap Volumetric Lightmap 3D 纹理砖 GPU 逐像素 ✅
VLM_SparseVolumeLightingSamples Sparse Volume Lighting Samples 空间点集(ILC) CPU 逐对象 ❌

VLM_VolumetricLightmap 的官方注释(WorldSettings.h:38-43)几乎就是本章的目录:

1
2
3
4
5
6
7
8
/**
* Lighting samples are computed in an adaptive grid which covers the entire Lightmass Importance Volume.
* Higher density grids are used near geometry.
* The Volumetric Lightmap is interpolated efficiently on the GPU per-pixel,
* allowing accurate indirect lighting for dynamic objects and volumetric fog.
* Positions outside of the Importance Volume reuse the border texels of the Volumetric Lightmap (clamp addressing).
* On mobile, interpolation is done on the CPU at the center of each object's bounds.
*/

逐句拆:adaptive grid(自适应网格) → 28.6;covers the entire Importance Volume(覆盖整个重要体积) → 28.3;
interpolated on the GPU per-pixel(GPU 逐像素) → 28.9;clamp addressing(越界靠 clamp 兜底) → 28.4;
On mobile, … on the CPU at the center of each object’s bounds(移动端退回逐对象 CPU 插值) → 28.9。

还有一条少见的开关 bForceVolumetricLightmapsOnly(WorldSettings.h:468-472)——
“强制预计算光照只用 VLM”,调试用。

28.3 数据模型:一张索引表 + 一堆砖

VLM 的全部数据在一个结构里(Engine/Public/PrecomputedVolumetricLightmap.h:147):

1
2
3
4
5
6
7
// PrecomputedVolumetricLightmap.h:203-208(节选)
FIntVector IndirectionTextureDimensions;
FVolumetricLightmapDataLayer IndirectionTexture; // 3D 索引纹理
// ...
int32 BrickSize;
FIntVector BrickDataDimensions;
FVolumetricLightmapBrickDataType BrickData; // 砖块数据(9 层 3D 纹理)

先看最反直觉的地方:为什么需要两层纹理?

因为 VLM 是自适应的——有的地方密(几何旁边)、有的地方疏(房间中央)。
如果直接用一张均匀 3D 纹理,只能取全局最高密度,空处全是浪费。
所以 UE 拆成两层:

  • IndirectionTexture(索引 3D 纹理):一张低分辨率的 3D 纹理,纹素存”世界坐标走到这里,该去查哪块砖“。
    格式 PF_R8G8B8A8_UINT(ImportVolumetricLightmap.cpp:1115),即 uint4(R,G,B,A):
    RGB = 砖在砖图集里的偏移,A = 这块砖覆盖几个索引格(后面 28.4 逐位拆);
  • BrickData(砖块数据):真正存光照的 9 层 3D 纹理,全部砖**拼在一张”图集”**里共享。

BrickData 的 9 层定义得很干净(PrecomputedVolumetricLightmap.h:80-86):

1
2
3
4
5
6
7
struct FVolumetricLightmapBasicBrickDataLayers
{
FVolumetricLightmapDataLayer AmbientVector; // 环境项(0 阶 SH,不归一化)
FVolumetricLightmapDataLayer SHCoefficients[6]; // 方向项(R/G/B × 2 组 RGBA8)
FVolumetricLightmapDataLayer SkyBentNormal; // 天空 bent normal(可选)
FVolumetricLightmapDataLayer DirectionalLightShadowing; // 方向光阴影因子
};

1 + 6 + 1 + 1 = 9 层。 为什么是 6?2 阶 SH 一共 9 个系数(1 常数 + 3 一阶 + 5 二阶),
被拆成”环境项(1 个)+ 方向项(8 个)”,正好 1 + 8 = 9。
方向项再按 RGB 拆成 3 份、每份 8 个分量 → 2 张 RGBA8 → 3 × 2 = 6 层。这就是它的来路。

各层的格式在导入时定死(ImportVolumetricLightmap.cpp:1073-1082):

层 导入时格式 最终格式 字节/体素
AmbientVector PF_FloatR11G11B10 同左 4 B
SHCoefficients[0..5] PF_B8G8R8A8 PF_R8G8B8A8(经 ConvertBGRA8ToRGBA8ForLayer) 4 B × 6 = 24 B
SkyBentNormal PF_B8G8R8A8 PF_R8G8B8A8 4 B
DirectionalLightShadowing PF_G8 同左 1 B

PF_FloatR11G11B10 这一行是整个 VLM 最漂亮的一手:环境项是唯一”不归一化”的量(它当分母,见 28.5),
所以不能像方向项那样压成 8bit;但也没必要用 3 个 float(12 B)。R11G11B10 = 3 通道共享 32bit,
一个体素 4 字节就装下了。CPU 侧的镜像类型是 FFloat3Packed(Core/Public/Math/PackedVector.h:15-37),
位域拆开正是 6+5 / 6+5 / 5+5 = 11+11+10:

1
2
3
4
5
6
7
8
9
10
11
12
// Core/Public/Math/PackedVector.h:18-30(节选)
union {
struct {
uint32_t xm : 6; // x-mantissa
uint32_t xe : 5; // x-exponent
uint32_t ym : 6; // y-mantissa
uint32_t ye : 5; // y-exponent
uint32_t zm : 5; // z-mantissa
uint32_t ze : 5; // z-exponent
};
uint32_t v;
};

(注意:SkyBentNormal 是可选的——没有天空阴影时压根不分配,
GetMinimumVoxelSize() 里那行注释写得很明白:”excluding SkyBentNormal because it is conditional”。)

28.4 砖为什么是 BrickSize + 1

砖块数据在砖图上不是”紧密排列”,而是每块砖外面裹一圈 1 纹素的 padding:

1
2
3
// ImportVolumetricLightmap.cpp:239-240
int32 BrickSize = VolumetricLightmapSettings.BrickSize;
int32 PaddedBrickSize = BrickSize + 1;

为什么要多这一圈?因为采样是三线性插值(trilinear):
当采样点落在砖的边界体素上,插值需要读到砖外面那一格的邻居值。
没有 padding,就会读到相邻砖(空间上并不相邻)的数据 → 边界出现明显的接缝/串色。
多一圈 1 纹素,等于给每块砖预备了”边界邻居”(导入时会把邻居砖的边界值 stitch 过来,
就是 ImportVolumetricLightmap.cpp:466 那些 StitchSourceCoordinate 在做的事)。

代价也很直白:砖越小,padding 占比越高。
BrickSize = 8 时,体素数从 8³ = 512 涨到 9³ = 729(**+42%);
BrickSize = 32 时,从 32768 涨到 35937(
+10%**)。
FVolumetricLightmapSettings::BrickSize 的注释(Programs/UnrealLightmass/Public/SceneExport.h:346-350)
说的就是这笔账:

1
2
3
4
5
/** 
* Size of a brick of unique lighting data. Must be a power of 2.
* Smaller values provide more granularity, but waste more memory due to padding.
*/
int32 BrickSize;

世界坐标 → 砖纹理 UV 的完整公式(GPU 侧,Shaders/Private/VolumetricLightmapShared.ush:25-34):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float3 ComputeVolumetricLightmapBrickTextureUVs(float3 WorldPosition)
{
// ① 世界坐标 → 索引体积 UV(越界 clamp 到 .99,这就是 28.2 注释里说的 clamp addressing)
float3 IndirectionVolumeUVs = clamp(WorldPosition * View.VolumetricLightmapWorldToUVScale
+ View.VolumetricLightmapWorldToUVAdd, 0.0f, .99f);
// ② 索引体积 UV → 索引纹理纹素坐标 → Load 出 (砖偏移.xyz, 砖跨度.w)
float3 IndirectionTextureTexelCoordinate = IndirectionVolumeUVs * View.VolumetricLightmapIndirectionTextureSize;
float4 BrickOffsetAndSize = View.VolumetricLightmapIndirectionTexture.Load(int4(IndirectionTextureTexelCoordinate, 0));

// ③ 砖内局部坐标 → 砖纹理 UV
float PaddedBrickSize = View.VolumetricLightmapBrickSize + 1;
return (BrickOffsetAndSize.xyz * PaddedBrickSize
+ frac(IndirectionTextureTexelCoordinate / BrickOffsetAndSize.w) * View.VolumetricLightmapBrickSize
+ .5f) * View.VolumetricLightmapBrickTexelSize;
}

逐行读第 ③ 步:

  • BrickOffsetAndSize.xyz * PaddedBrickSize:砖的起点(换算成”体素格”)。注意乘的是 PaddedBrickSize 而不是 BrickSize——因为砖图集里每块砖都占了 BrickSize+1 的格子;
  • frac(IndirectionTextureTexelCoordinate / BrickOffsetAndSize.w) * BrickSize:砖内局部位置。
    IndirectionTextureTexelCoordinate 除以 .w(这块砖的索引跨度)再取 frac,就把”全局索引坐标”变成”砖内 0~1 比例”,乘 BrickSize 得到砖内体素坐标;
  • + .5f:从格点边缘移到体素中心(纹理采样要打中心);
  • * BrickTexelSize:从体素坐标换算成 0~1 的 UV。

这四步是 VLM 全部精度的落脚点,也是”为什么 padding 不能省”的证明——公式里 PaddedBrickSize 和 BrickSize 同时出现,
一旦 padding 与布局不一致(比如有人手工改了 BrickSize),砖就会整体错位。

28.5 逐砖 SH 编码:环境项当”分母”

VLM 每个体素存的是辐照度(irradiance)的 2 阶 SH 投影,9 个系数(RGB × 3)。
编码在 Lightmass 侧(AdaptiveVolumetricLightmap.cpp:877):

1
2
3
4
// AdaptiveVolumetricLightmap.cpp:881
AmbientVector[Index] = FFloat3Packed(FLinearColor(Sample.HighQualityCoefficients[0][0],
Sample.HighQualityCoefficients[0][1],
Sample.HighQualityCoefficients[0][2], 0.0f));

球谐的 0 阶系数(常数项)被单独拎出来,叫 AmbientVector(环境向量),三个通道各存一个数。
剩下的 8 个方向项系数,全部除以同通道的环境项,变成 [-1,1] 的归一化比值,再 8bit 量化:

1
2
3
4
5
6
7
8
9
10
11
12
13
// AdaptiveVolumetricLightmap.cpp:917-924(节选)
const float InvAmbient = 1.0f / FMath::Max(Sample.HighQualityCoefficients[0][ChannelIndex], .0001f);

const FLinearColor Vector0Normalized =
FLinearColor(Sample.HighQualityCoefficients[1][ChannelIndex], // 一阶 3 个
Sample.HighQualityCoefficients[2][ChannelIndex],
Sample.HighQualityCoefficients[3][ChannelIndex],
Sample.HighQualityCoefficients[4][ChannelIndex]) // 二阶第 1 个
* CoefficientNormalizationScale0 // 除以 SH 基函数常数
* FLinearColor(InvAmbient, InvAmbient, InvAmbient, InvAmbient); // 除以环境项

SHCoefficients[ChannelIndex * 2 + 0][Index] =
(Vector0Normalized * FLinearColor(.5f,.5f,.5f,.5f) + FLinearColor(.5f,.5f,.5f,.5f)).QuantizeRound();

为什么敢除以环境项? 因为间接光的”方向性”本质上是相对于整体亮度的。
一间又暗又均匀的屋子和一间又亮又均匀的屋子,SH 方向项的相对分布是一回事。
把绝对量级(环境项)和相对分布(方向项)分开存,
方向项就能安全地压进 8bit 而不丢动态范围——这是同一个思想在 VLM 上的又一次运用
(对照第 26 章 lightmap 的”亮度/色度分解 + Min-Max 归一化”)。

归一化的分母不是 1,而是基函数的常数值(注释里列得很清楚,AdaptiveVolumetricLightmap.cpp:883-899):
0 阶 0.282095、一阶 0.488603、二阶 1.092548 / 0.315392 / 0.546274。
所以编码用 CoefficientNormalizationScale = 0.282095 / 基函数系数(:903-913),
解码就是反过来乘(LightMapRendering.cpp:649-666 / VolumetricLightmapShared.ush:61-69):

组 分量 反归一化比例 SHDenormalizationScales
0 R, G, B(一阶) 0.488603 / 0.282095
0 A(二阶) 1.092548 / 0.282095
1 R(二阶) 1.092548 / 0.282095
1 G(二阶) 4 × 0.315392 / 0.282095
1 B(二阶) 1.092548 / 0.282095
1 A(二阶) 2 × 0.546274 / 0.282095

源码里作者特意留了一句防错注释(AdaptiveVolumetricLightmap.cpp:901):

1
// Note: encoding behavior has to match CPU decoding in InterpolateVolumetricLightmap and GPU decoding in GetVolumetricLightmapSH3

同一份数据要能被三处解码:Lightmass 编码、编辑器 CPU 插值、运行时 GPU 插值。
所以比例表在 VolumetricLightmapShared.ush、LightMapRendering.cpp、AdaptiveVolumetricLightmap.cpp
三处逐位一致地抄了一遍——这是本章最典型的”跨端一致性”约束。

解码公式(GPU,VolumetricLightmapShared.ush:56-69)读成一句话就是:

1
方向项 = (纹理值 × 2 − 1) × 环境项[通道] × 反归一化比例

纹理里存的是 ×2−1 后的 [-1,1],乘回去才是真正的 SH 系数。最后组进 FThreeBandSHVectorRGB(:111-125),
分 SH1 / SH2 / SH3 三档取用(档位越高越贵、方向性越强,见 28.9)。

另外两个可选量也用同样的套路:
天空 bent normal 存 n × 0.5 + 0.5(AdaptiveVolumetricLightmap.cpp:936),
解码 × 2 − 1(VolumetricLightmapShared.ush:130);方向光阴影因子直接存 uint8(:939),解码原样取 .x(:136)。
bent normal 在 VLM 里是逐体素的(对照第 27 章那张逐纹素的 SkyOcclusion)——
这正是”体积雾也能吃到天光遮蔽”的关键。

28.6 自适应细分:不是均匀网格

VLM 最省的地方在 MaxRefinementLevels 和 ShouldRefineVoxel()。
它不是全空间一个密度,而是从粗到细递归细分,只在”值得”的地方往下切
(AdaptiveVolumetricLightmap.cpp:259):

1
2
3
4
5
6
7
8
9
// AdaptiveVolumetricLightmap.cpp:261-267
const bool bCellInsideImportanceVolume = Scene.IsBoxInImportanceVolume(CellBounds);

// The volumetric lightmap bounds are larger than the importance volume bounds,
// since we force the volumetric lightmap volume to have cube voxels
if (!bCellInsideImportanceVolume)
{
return false; // 重要体积之外,直接不细分
}

然后是三条细分理由(其余情况返回 false,省内存):

  1. 密度体(Density Volume)的要求(:269-304):
    采样点落在密度体里,就用它的 AllowedMipLevelRange 覆盖默认策略
    (Scene.GetVolumetricLightmapAllowedMipRange)。落在允许范围内就细分、超出就停——这是 28.7 的主角;
  2. 几何体相交(:306):DoesVoxelIntersectSceneGeometry(CellBounds, ...)——体素碰到静态几何就细分。
    旁边的注释点出了动机:*”Refine around static lights, where lighting is going to be changing rapidly”*;
  3. 静态点光/聚光靠近(:317-345):即使没碰到几何,一盏静态(Static/Stationary)的点光/聚光
    如果”比体素还小”(LightBounds.W < ExpandedBoxSphereBounds.SphereRadius)→ 直接细分
    (*”we will likely undersample it”*,太小会被欠采样);否则还要看光在这个体素里的亮度是否超过
    LightBrightnessSubdivideThreshold:
1
2
3
4
5
6
7
8
// AdaptiveVolumetricLightmap.cpp:334-341(节选)
FLinearColor DirectLighting = Light->GetDirectIntensity(SamplePosition, false);
if (DirectLighting.GetLuminance() > VolumetricLightmapSettings.LightBrightnessSubdivideThreshold)
{
// Only subdivide if the light has a significant effect on this voxel
bVoxelIntersectsScene = true;
break;
}

还有一条”减法”:Landscape 下方的砖可以被扔掉(:348-370):

1
2
3
4
// AdaptiveVolumetricLightmap.cpp:348-350
if (bVoxelIntersectsScene
&& LandscapeMappings.Num() > 0
&& VolumetricLightmapSettings.bCullBricksBelowLandscape)

但 FVolumetricLightmapSettings::bCullBricksBelowLandscape 的注释(SceneExport.h:369-370)明确警告:

1
2
3
/** Whether to cull bricks entirely below landscape.
* This can be an invalid optimization if the landscape has holes and caves that pass under landscape. */
bool bCullBricksBelowLandscape;

——地形有洞或有地下洞穴时,这个优化就是错的(洞里会没有光)。这是本章避坑表的第一条。

细分完还要减一遍砖:

  • MinBrickError(SceneExport.h:363-364):*”Bricks with RMSE below this value are culled”*,
    均方根误差太小的砖(光照太均匀、粗网格已经够用)直接丢掉;
  • TrimBricksByInterpolationError / TrimBricksForMemoryLimit(ImportVolumetricLightmap.cpp:1085-1091)
    在导入端再裁一次,后者听 VolumetricLightmapMaximumBrickMemoryMb(默认 30 MB,见 28.8)。

28.7 Density Volume:局部加密的三档 mip

VLM 的密度只有三档(注释写在 VolumetricLightmapDensityVolume.h:19-31):

1
2
3
mip0: DetailCellSize
mip1: DetailCellSize * 4
mip2: DetailCellSize * 16

VolumetricLightmapDetailCellSize 默认 200(世界单位 = 2 米,WorldSettings.h:226),
于是三档就是 200 / 800 / 3200。默认策略是”几何和静态灯附近自动用高密度”
(就是 28.6 那三条理由),而 AVolumetricLightmapDensityVolume
(Classes/Lightmass/VolumetricLightmapDensityVolume.h:15,一个 AVolume 子类)
让你手工覆盖这个策略:

1
2
3
4
5
6
7
8
9
10
11
// VolumetricLightmapDensityVolume.h:19-33(注释节选)
* By default, highest density will be placed around static geometry and static lights,
* but AllowedMipLevelRange can be used to override this behavior.
* Examples:
* [0, 3] = Volume does nothing
* [1, 3] = Volume removes highest density mip, ... which can be useful to save memory ('stat MapBuildData')
* [0, 0] = Volume forces highest density. Warning: using this on a large area can greatly increase memory and build times!
* When multiple volumes overlap, the smallest (highest density) values will be used.

UPROPERTY(EditAnywhere, Category = VolumetricLightmapDensityVolume)
FInt32Interval AllowedMipLevelRange;

三条实用姿势:

  • [1, 3] = 省钱:把某块区域的最高密度档抹掉(比如一个空旷大厅的中央)。
    官方注释直接点名这是给 stat MapBuildData 减显存用的;
  • [0, 0] = 加钱:强制最高密度。注释带着警告——“在大范围上用会大幅增加内存和烘焙时间”;
  • 重叠规则:多个体叠加时,取最密(数值最小)的那一档。

28.8 WorldSettings 参数总账

VLM 没有一堆 CVar,参数几乎全在 WorldSettings → Lightmass 面板
(WorldSettings.h,默认值在构造函数 :210-229):

参数 默认 含义 / 代价
VolumeLightingMethod VLM_VolumetricLightmap 二选一,见 28.2
VolumetricLightmapDetailCellSize 200 最高密度体素的世界尺寸。注释:*”Halving the DetailCellSize can increase memory by up to a factor of 8x”*
VolumetricLightmapMaximumBrickMemoryMb 30 砖块数据内存上限,超了就丢高密度砖,从离几何最远的开始丢
VolumetricLightmapLoadingCellSize 3200 高精度加载单元尺寸(仅 World Partition 用,EditCondition = "bWorldPartition")
VolumetricLightmapSphericalHarmonicSmoothing 0.02 SH 去振铃(de-ringing)平滑量,0 = 不平滑,1 = 最强(方向性最弱)
VolumetricLightmapLoadingRange 6400 VLM 加载范围(WP 用,WorldSettings.h:675-677)
bForceVolumetricLightmapsOnly false 强制只用 VLM(调试)

两个值得展开的:

① DetailCellSize 的 8 倍——为什么是 8 而不是 2?
因为它是三维的量:线尺寸减半 → 体积变成 2³ = 8 倍。
再叠加自适应细分的影响,所以官方注释谨慎地写”up to 8x”。
这条给出一个很实用的手感:调小一档,先按 8 倍估内存,再看自适应裁掉多少。

② SphericalHarmonicSmoothing 的存在意义——它的注释(WorldSettings.h:171-178)解释了 SH 的原罪:

1
2
3
* Whenever highly directional lighting is stored in a Spherical Harmonic, a ringing artifact occurs
* which manifests as unexpected black areas on the opposite side.
* Smoothing can reduce this artifact. Smoothing is only applied when the ringing artifact is present.

强方向性的光存进 SH 会产生”振铃”——表现为在对侧出现莫名其妙的黑斑。
默认 0.02 是个很轻的平滑(只在检测到振铃时才生效)。
参数名里还藏着一个对应物:FVolumetricLightmapSettings::WindowingTargetLaplacian
(SceneExport.h:375-376)——Lightmass 端就是用它做窗函数滤波的
(*”used to reduce Spherical Harmonic ringing via a windowing filter”*)。
面板一个、烘焙端一个,两边是同一件事。

28.9 运行时消费:GPU 逐像素 + CPU 逐对象

主路(GPU 逐像素):BasePass 里逐像素算出砖纹理 UV(BasePassPixelShader.usf:1146):

1
VolumetricLightmapBrickTextureUVs = ComputeVolumetricLightmapBrickTextureUVs(WSHackToFloat(GetWorldPosition(MaterialParameters)));

拿到 UV 后按需要的档位取用:

  • 半透明、体积雾这类只要”平均方向光”的,用 GetVolumetricLightmapSH1(VolumetricLightmapShared.ush:41,只取环境项);
  • 要方向性的用 GetVolumetricLightmapSH2(:72,环境项 + 3 个一阶);
  • 最全的用 GetVolumetricLightmapSH3(:89,环境项 + 3 + 5,完整 2 阶)。

BasePassPixelShader.usf:582 / 597 / 603 三处调用就是这三档。天光侧另有
GetVolumetricLightmapSkyBentNormal(SkyOcclusionUV3D)(:480)——
VLM 的 bent normal 同时喂给了天光遮蔽,这是”体积雾/可动物体也能有正确天光遮挡”的来源。

辅路(CPU 逐对象):给可移动物体的间接光用(LightMapRendering.cpp:616 InterpolateVolumetricLightmap)。
这条路的每一步都在 CPU 上把 28.3 的数据结构重走一遍:

1
2
3
4
5
6
7
8
9
10
11
// LightMapRendering.cpp:626-640(节选)
const FVector IndirectionDataSourceCoordinate = ComputeIndirectionCoordinate(LookupPosition, ...);
// ...
SampleIndirectionTextureWithSubLevel(IndirectionDataSourceCoordinate, ...,
IndirectionBrickOffset, IndirectionBrickSize, SubLevelIndex);

const FPrecomputedVolumetricLightmapData& VolumetricLightmapData =
*GlobalVolumetricLightmapData.CPUSubLevelBrickDataList[SubLevelIndex];

const FVector BrickTextureCoordinate = ComputeBrickTextureCoordinate(
IndirectionDataSourceCoordinate, IndirectionBrickOffset, IndirectionBrickSize, VolumetricLightmapData.BrickSize);

和 GPU 版的差别只有一个词:**SubLevelIndex。
VLM 的砖图集是
跨关卡共享的(FVolumetricLightmapBrickAtlas,PrecomputedVolumetricLightmap.h:580;
全局实例 GVolumetricLightmapBrickAtlas,:620),
所以插值时要先定位”这个位置属于哪个子关卡”(CPUSubLevelBrickDataList,:231)。
这也解释了 VLM_SparseVolumeLightingSamples 为什么费——
它没有 GPU 路,只能走这条 CPU 路**。

CPU 插值还要多做一次通道序修正,因为导入时格式从 BGRA 换成了 RGBA(LightMapRendering.cpp:662-663):

1
2
// Swap R and B channel because it was swapped at ImportVolumetricLightmap for changing format from BGRA to RGBA
Swap(SHCoefficientEncoded.R, SHCoefficientEncoded.B);

第三个消费者:Capsule Shadow(胶囊体阴影)。
可移动角色脚下的软阴影需要知道”间接光从哪个方向来”,这个方向可以直接从 VLM 反推——
CapsuleShadowRendering.cpp:116 的 FComputeLightDirectionFromVolumetricLightmapCS
就是这么干的(着色器是 CapsuleShadowShaders.usf 里的 ComputeLightDirectionFromVolumetricLightmapCS)。
没有 VLM 也没有实时天光捕获时,这条链是断的(:784 的条件判断)。

28.10 显存总账:一个体素值多少字节

回到本系列的传统项目——算账。GetMinimumVoxelSize()(PrecomputedVolumetricLightmap.cpp:188-203)
直接给了答案:

1
2
3
4
5
6
7
// PrecomputedVolumetricLightmap.cpp:188-203(节选)
int32 VoxelSize = GPixelFormats[AmbientVector.Format].BlockBytes; // R11G11B10 = 4
for (int32 i = 0; i < UE_ARRAY_COUNT(SHCoefficients); i++)
VoxelSize += GPixelFormats[SHCoefficients[i].Format].BlockBytes; // 6 × 4 = 24
// excluding SkyBentNormal because it is conditional
VoxelSize += GPixelFormats[DirectionalLightShadowing.Format].BlockBytes; // G8 = 1
return VoxelSize;
项 字节/体素
AmbientVector(R11G11B10) 4
SHCoefficients[0..5](RGBA8 × 6) 24
DirectionalLightShadowing(G8) 1
小计(无 SkyBentNormal) 29
SkyBentNormal(RGBA8,条件性) +4
小计(有 SkyBentNormal) 33

对照一下量级:一个 lightmap 纹素压缩后是 1 字节(第 27 章的结论),
一个 VLM 体素是 29~33 字节——贵了将近 30 倍。
这就是为什么 VLM 必须自适应、必须有 MaximumBrickMemoryMb 上限、必须有 Density Volume 的三档:
它天生就是个显存大户,省力的地方全在”只在对的地方放密的体素”。

再乘上 padding:一块 BrickSize = 32 的砖实际占 33³ = 35937 个体素(而不是 32³ = 32768),
而只有内部 32³ 是有效数据——约 9.7% 的体素是 padding 开销。
砖越小这个比例越高(BrickSize = 8 时是 42%,见 28.4)。

28.11 “能压吗?”——VLM 版的回答

顺着本系列的老问题问一遍:VLM 还能不能再压?

结论:它已经压过了,而且压得比 lightmap 更早、更狠——因为它没有第二层。

对照第 26、27 章会发现一个结构性的差别:
lightmap 是”烘焙产出浮点 → 引擎量化到 8bit → 块压缩(BC)“三段;
而 VLM 是”烘焙产出时就分成 4 B / 8bit / 1 B“——源码里根本没有浮点版本的 VLM。
AmbientVector 的 FFloat3Packed、SH 的 QuantizeRound()(AdaptiveVolumetricLightmap.cpp:924)
都发生在 Lightmass 写数据那一步,落地即量化。

所以”压缩手段”在 VLM 上换了一套优先级:

手段 省的是什么 代价 源码依据
调大 DetailCellSize 体素数(最直接的杠杆) 精度下降,粗网格丢方向性细节 WorldSettings.h:153-159
加 Density Volume [1,3] 抹掉指定区域的最高密度档 该区域间接光变平 VolumetricLightmapDensityVolume.h:28
调小 MaximumBrickMemoryMb 强制丢掉远端高密度砖 远处物体间接光跳变 WorldSettings.h:161-165
bCullBricksBelowLandscape 地形下方所有砖 地形有洞/洞穴时是错的 SceneExport.h:369-370
MinBrickError 调高 裁掉”光照太均匀”的砖 可能误裁掉有用的细节 SceneExport.h:363-364
换成 Sparse Volume Samples 放弃 3D 纹理,改撒点 失去 GPU 逐像素 + 体积雾 WorldSettings.h:46-51
不生成 SkyBentNormal −4 B/体素(约 −12%) 失去天光遮蔽方向性 PrecomputedVolumetricLightmap.cpp:198

**没有一个手段是”换压缩格式”**——因为在 VLM 这条路上,格式从第一天就已经是最省的姿态了
(R11G11B10 已是 3 通道 32bit 的极限,SH 已是 8bit × 6)。
这道题跟 lightmap 那道题的分水岭就在这儿:
lightmap 是”压完还能再压”;VLM 是”出生就是压完的样子”。

28.12 避坑清单

**避坑 1|地形有洞/洞穴时关掉 bCullBricksBelowLandscape**。
官方注释明确写了这”can be an invalid optimization”(SceneExport.h:369-370),
开了之后洞里会没有间接光——表现为”地下的光突然死了”。

避坑 2|DetailCellSize 减半不是贵 2 倍,是最多 8 倍。
三维是 2³。先按 8 倍估算,再靠自适应细分、MinBrickError、内存上限往回裁。

避坑 3|MaximumBrickMemoryMb 是”硬丢”不是”降精度”。
注释说的是”discarded … furthest from geometry discarded first”(WorldSettings.h:161-163)——
超过上限是
整块砖被扔掉
,所以远处会跳变,不是平滑降质。

**避坑 4|SphericalHarmonicSmoothing 是”救火”不是”调味”**。
它只在检测到振铃时才生效;发现”光背面莫名发黑”就该往上调一点,
调太多会把方向性抹平(注释:”1 = strong smooth (little directionality in lighting)”)。

避坑 5|VLM 和 lightmap 是两套账,别混着算。
VLM 归 MapBuildDataRegistry 管理、砖图集跨关卡共享(GVolumetricLightmapBrickAtlas);
它不参与 bCompressLightmaps 那套块压缩逻辑——第 24 章的”四档瘦身”对它无效。
想知道它实际花了多少,看它的导入统计日志(ImportVolumetricLightmap.cpp:1543-1548)。

避坑 6|换 BrickSize 要同时改烘焙端和运行端。
采样公式里 PaddedBrickSize 与 BrickSize 同时出现(VolumetricLightmapShared.ush:32-33),
且注释强调”Must be a power of 2”(SceneExport.h:347)——
改了不重新烘、或两边不一致,砖会整体错位。

避坑 7|移动端走的是 CPU 路。
WorldSettings.h:42 写着”On mobile, interpolation is done on the CPU at the center of each object’s bounds”——
也就是说移动端的 VLM 不是逐像素的,粒度是”每个物体包围盒中心一份”。
在移动端按”逐像素精度”评估 VLM 会高估它的效果。

28.13 动手实验室

  1. 看数据结构:烘焙一个带 Lightmass Importance Volume 的关卡,翻 MapBuildDataRegistry,
    把 IndirectionTexture 和砖数据纹理的尺寸、格式列出来——对照 28.3 的表,确认
    AmbientVector 是 R11G11B10(4 字节/体素)而不是浮点 RGB;
  2. 看自适应:用 r.VolumetricLightmap.VisualizationRadiusScale / .VisualizationMinScreenFraction
    (VisualizeVolumetricLightmap.cpp:30-44)打开可视化,在几何密集区与空旷区各看一遍——
    亲眼确认”哪密哪疏”,理解 28.6 的三条细分理由;
  3. 算 padding:把 BrickSize 从 32 改到 8,数一数砖的数量与总显存变化,
    验证 28.4 的 “+42% vs +10%” padding 占比;
  4. 验 Density Volume:在一片空旷大厅里放一个 AVolumetricLightmapDensityVolume,
    先设 AllowedMipLevelRange = [1, 3],再改成 [0, 0],
    用 stat MapBuildData 对比两次的显存——感受 28.7 里”省钱档”与”加钱档”的差距;
  5. 踩一次内存上限:把 VolumetricLightmapMaximumBrickMemoryMb 调到 1 后重烘,
    观察远处物体间接光出现的跳变(对照避坑 3);
  6. 验跨端一致性:在 VolumetricLightmapShared.ush:61-69 与
    LightMapRendering.cpp:649-666 逐行对照两张 SHDenormalizationScales 表,
    确认它们逐位相同——这是 28.5 那句”has to match”的实证;
  7. 看体积雾吃光:开 Volumetric Fog,在只有 VLM 的关卡里对比
    VolumeLightingMethod 的两个选项——Sparse 档下雾不会吃到预计算光照(WorldSettings.h:49 早就写了)。

第 29 章 答疑篇(五):单盏灯变色 / 开关——lightmap 能”支持”到哪一步

TL;DR|lightmap 里存的是”一批灯贡献的和”,不是每盏灯一份——
证据是 FLightMap::LightGuids(注释原文 *”The GUIDs of lights which this light-map stores”*,LightMap.h:59-60):
它只记录”这张图里存过哪些灯”,而纹素本身只有一组 RGB(FLightMapCoefficients,LightMap.h:491),
没有任何”每灯一格”的位置。所以”只改某一盏灯的烘焙结果”在数据层就无处安放。
于是”单盏灯变色/开关”这件事,引擎实际能做的只有三档:
① Static 灯运行时压根改不动——SetLightColor / SetIntensity 被 AreDynamicDataChangesAllowed()
(SceneComponent.h:1363-1366)直接挡掉,源码注释就写着 *”Can’t set color on a static light”*;
② Stationary 灯颜色亮度能实时改,但只有”直接光”跟着变——烘焙进 lightmap 的间接光不变,
编辑器里改 Stationary 灯的颜色甚至不会标记重烘(LightComponent.cpp:866-869 的注释写得很直白:
*”Properties that should only unbuild lighting for a Static light (can be changed dynamically on a Stationary light)”*);
③ 一盏灯如果没进烘焙(GUID 对不上),引擎会自动把它降级成动态灯画出来——“未烘焙预览”机制。
想让”关灯后房间真的暗下来”只有四条路:换整套烘焙(Lighting Scenario)、灯改 Movable + Lumen、
干脆只用 Movable 灯、或者接受”直接光没了、间接光留着”。

29.1 先定位:lightmap 存的是”和”,不是”清单”

第 26、27 章把 lightmap 的数据结构拆到了位级别,这里补一个一直没点破的性质:

1
2
3
4
5
6
7
8
9
10
11
// LightMap.h:488-497(节选)
/**
* The quantized coefficients for a single light-map texel.
*/
struct FLightMapCoefficients
{
uint8 Coverage;
uint8 Coefficients[NUM_STORED_LIGHTMAP_COEF][4];
uint8 SkyOcclusion[4];
uint8 AOMaterialMask;
};

一个纹素 = 一组 RGB(+ 方向性 SH + 天空遮蔽 + AO),就这些。
没有 PerLight[8] 之类的结构。整个 lightmap 只有一份颜色。

那”这张图是哪几盏灯烘出来的”记在哪?记在一个平级的数组里:

1
2
3
4
5
6
7
8
9
10
11
// LightMap.h:59-76(节选)
/** The GUIDs of lights which this light-map stores. */
TArray<FGuid> LightGuids;

/**
* Checks if a light is stored in this light-map.
*/
bool ContainsLight(const FGuid& LightGuid) const
{
return LightGuids.Find(LightGuid) != INDEX_NONE;
}

烘焙端同理(LightMap.h:516-517):

1
2
/** The GUIDs of lights which this light-map stores. */
TArray<FGuid> LightGuids;

这个数组是**”收据”而不是”索引”**:它只回答”这盏灯的贡献在不在里面”(布尔问题),
不能回答”这盏灯贡献了多少、能不能单独改”。导入时它是这么被填进去的(LightMap.cpp:2125-2137):

1
2
3
4
5
6
7
8
LightMap->LightGuids = SourceQuantizedData->LightGuids;
...
for (const auto& It : SourceShadowMapData)
{
const FShadowMapData2D* ShadowMapData = It.Value;
LightMap->LightGuids.AddUnique(It.Key->LightGuid);
...
}

一句话:lightmap 是”总和 + 一张灯名单”,不是”每灯一份账”。
这就注定:单灯的间接光,运行时无法单独改。

29.2 运行时能不能改:一把总闸 AreDynamicDataChangesAllowed()

所有灯光的运行时 setter 都长了同一张脸。挑三个看(LightComponent.cpp):

1
2
3
4
5
6
7
8
9
10
11
// LightComponent.cpp:1076-1087
void ULightComponent::SetIntensity(float NewIntensity)
{
// Can't set brightness on a static light
if (AreDynamicDataChangesAllowed()
&& Intensity != NewIntensity)
{
Intensity = NewIntensity;
UpdateColorAndBrightness();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// LightComponent.cpp:1136-1154
void ULightComponent::SetLightFColor(FColor NewLightColor)
{
// Can't set color on a static light
if (AreDynamicDataChangesAllowed()
&& LightColor != NewLightColor)
{
LightColor = NewLightColor;
UWorld* World = GetWorld();
if( World && World->Scene )
{
//@todo - remove from scene if brightness or color becomes 0
World->Scene->UpdateLightColorAndBrightness( this );
}
UpdateColorAndBrightnessEvent.Broadcast(*this);
}
}

SetIndirectLightingIntensity(:1089)、SetTemperature(:1157)、SetVolumetricScatteringIntensity(:1109)、
SetAffectGlobalIllumination(:124)……全都是同一把锁。这把锁长这样:

1
2
3
4
5
6
7
8
9
10
11
// SceneComponent.h:1357-1366
/**
* Determine if dynamic data is allowed to be changed.
*
* @param bIgnoreStationary Whether or not to ignore stationary mobility when checking. Default is true (i.e. - check for static mobility only).
* @return Whether or not dynamic data is allowed to be changed.
*/
inline bool AreDynamicDataChangesAllowed(bool bIgnoreStationary = true) const
{
return (IsOwnerRunningUserConstructionScript()) || !(IsRegistered() && (Mobility == EComponentMobility::Static || (!bIgnoreStationary && Mobility == EComponentMobility::Stationary)));
}

翻译成人话(默认 bIgnoreStationary = true):

Mobility AreDynamicDataChangesAllowed() 运行时 SetLightColor 等
Static false 静默无效(不是报错,是 if 直接跳过)
Stationary true ✅ 生效——但只作用于直接光
Movable true ✅ 完全生效(它本来就不进 lightmap)

所以第一个结论很硬:Static 灯在运行时”改不动”,这是引擎刻意设计的,不是 bug。
想改只能重烘(或者干脆别用 Static)。

29.3 编辑器里改一下呢:PostEditChangeProperty 的排除表

更有意思的是编辑器行为。ULightComponent::PostEditChangeProperty(LightComponent.cpp:775)
里有长长一串 PropertyName != ... 的排除表,凡是”改了不需要重烘”的属性都在里面
(CastDynamicShadows、SpecularScale、BloomScale、ShadowBias……一眼看过去都是纯运行时/纯动态的)。

最后三行是本章的核心,而且官方把意图直接写成了注释(LightComponent.cpp:866-872):

1
2
3
4
5
6
7
	// Properties that should only unbuild lighting for a Static light (can be changed dynamically on a Stationary light)
(PropertyName != GET_MEMBER_NAME_STRING_CHECKED(ULightComponent, Intensity) || Mobility == EComponentMobility::Static) &&
(PropertyName != GET_MEMBER_NAME_STRING_CHECKED(ULightComponent, LightColor) || Mobility == EComponentMobility::Static) &&
(PropertyName != GET_MEMBER_NAME_STRING_CHECKED(ULightComponent, Temperature) || Mobility == EComponentMobility::Static) )
{
InvalidateLightingCache();
}

把布尔逻辑摊开(|| 的短路在这里是”只有当 Mobility == Static 时才继续往下走“):

改的属性 Mobility = Static Mobility = Stationary
Intensity (!= Intensity)=false || (==Static)=true → true → 继续 → 失效光照缓存(要重烘) false || false → false → 短路 → 不重烘
LightColor 同上 → 要重烘 → 不重烘
Temperature 同上 → 要重烘 → 不重烘

这正是本章问题的官方答案,而且它是一行注释就说清的:

  • Static 灯的”变色”= 必须重烘(编辑器会弹出 LIGHTING NEEDS TO BE REBUILT);
  • Stationary 灯的”变色”= 官方认为这是”动态可改”的——因为直接光本就是实时的。
    代价也正藏在这句注释里:没人管你的间接光。改完颜色,直接光是新色、
    lightmap 里的间接光还是旧色,两边不一致,而且是静默的。

29.4 改了颜色就”不算数”了:GUID 换新,lightmap 不认

那”失效光照缓存”到底干了什么?InvalidateLightingCacheDetailed(LightComponent.cpp:1483):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// LightComponent.cpp:1483-1526(节选)
void ULightComponent::InvalidateLightingCacheDetailed(bool bInvalidateBuildEnqueuedLighting, bool bTranslationOnly)
{
if (HasStaticShadowing()) // == non movable
{
#if WITH_EDITOR
FStaticLightingSystemInterface::OnLightComponentUnregistered.Broadcast(this);
#endif
Modify();

BeginReleaseResource(&StaticShadowDepthMap);

// Create new guids for light.
UpdateLightGUIDs();
...
if (World != NULL && bStationary)
{
ReassignStationaryLightChannels(World, false, NULL);
}
...
MarkRenderStateDirty();
...
}
else
{
// Movable lights will have a GUID of 0
OriginalLightGuid.Invalidate();
LightGuid.Invalidate();
}
}

关键动作是 UpdateLightGUIDs()——给灯换一个新 GUID(LightComponent.cpp:271-289):

1
2
3
4
5
OriginalLightGuid = FGuid::NewGuid();
...
LightGuid = (OriginalLightGuid.IsValid() && GetOwner())
? FGuid::Combine(OriginalLightGuid, FActorInstanceGuid::GetActorInstanceGuid(*GetOwner()))
: FGuid();

(顺带一个有用的细节:Movable 灯的 GUID 是 0——它压根不参与烘焙身份识别。
另外 cook 时走 NewDeterministicGuid(GetFullName()),保证同名可复现。)

换 GUID 的后果链条是:

1
2
3
4
改属性 → InvalidateLightingCache → UpdateLightGUIDs → 灯拿到新 GUID
→ 旧 lightmap 的 LightGuids 里没有这个新 GUID
→ LightMap->ContainsLight(LightGuid) == false
→ 引擎认为"这盏灯的贡献还没烘进这张图"

也就是说,引擎判断”这盏灯烘没烘”靠的是 GUID 匹配,不是靠任何时间戳或脏标记。
这个设计很省事,但也带来两个必须知道的后果(下面两节)。

29.5 引擎的自动兜底:GetStaticInteraction 的四档

有了 GUID,引擎就能对每一个(灯 × 图元)对回答一个问题:
“这盏灯对这个网格的贡献,是已经在烘焙里了,还是得动态画?”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SceneManagement.cpp:1620-1660
ELightInteractionType FLightCacheInterface::GetStaticInteraction(const FLightSceneProxy* LightSceneProxy, const TArray<FGuid>& IrrelevantLights) const
{
if (bGlobalVolumeLightmap)
{
if (LightSceneProxy->HasStaticLighting()) { return LIT_CachedLightMap; }
else if (LightSceneProxy->HasStaticShadowing()){ return LIT_CachedSignedDistanceFieldShadowMap2D; }
else { return LIT_MAX; }
}

ELightInteractionType Ret = LIT_MAX;

// Check if the light has static lighting or shadowing.
if (LightSceneProxy->HasStaticShadowing())
{
const FGuid LightGuid = LightSceneProxy->GetLightGuid();

if (IrrelevantLights.Contains(LightGuid)) { Ret = LIT_CachedIrrelevant; }
else if (LightMap && LightMap->ContainsLight(LightGuid)) { Ret = LIT_CachedLightMap; }
else if (ShadowMap && ShadowMap->ContainsLight(LightGuid)){ Ret = LIT_CachedSignedDistanceFieldShadowMap2D; }
}

return Ret;
}

四档的含义(枚举定义在 SceneManagement.h:303-314):

档位 判定依据 含义
LIT_CachedIrrelevant 该灯在 IrrelevantLights 里 烘焙时判定”这盏灯对这个网格没贡献” → 静态部分算过了,不用再画
LIT_CachedLightMap LightMap->ContainsLight(Guid) 灯的全部分量(Static)或间接分量(Stationary)已在 lightmap
LIT_CachedSignedDistanceFieldShadowMap2D ShadowMap->ContainsLight(Guid) 只有阴影是烘焙的(典型 = Stationary 灯)→ 直接光仍要动态画
LIT_Dynamic / LIT_MAX — 需要动态渲染

IrrelevantLights 是烘焙产物的一部分,算得也很直白(StaticMeshLight.cpp:313-331):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
for(int32 LightIndex = 0;LightIndex < Mesh->RelevantLightsGuid.Num();LightIndex++)
{
FGuid LightGuid = Mesh->RelevantLightsGuid[LightIndex];

// Check if the light is stored in the light-map.
const bool bIsInLightMap = MeshBuildData.LightMap && MeshBuildData.LightMap->LightGuids.Contains(LightGuid);
// Check if the light is stored in the shadow-map.
const bool bIsInShadowMap = MeshBuildData.ShadowMap && MeshBuildData.ShadowMap->LightGuids.Contains(LightGuid);

// Add the light to the statically irrelevant light list if it is in the potentially relevant light list, but didn't contribute to the light-map.
if(!bIsInLightMap && !bIsInShadowMap)
{
MeshBuildData.IrrelevantLights.AddUnique(LightGuid);
}
}

它存在 FMeshMapBuildData::IrrelevantLights(MapBuildDataRegistry.h:60)里,跟着 MapBuildDataRegistry 一起序列化、一起流送。

消费端是 FStaticMeshSceneProxy::GetLightRelevance(StaticMeshSceneProxy.cpp:2392-2435),
它把每个 LOD 的交互类型汇成四个 bool:

1
2
3
4
if (InteractionType != LIT_CachedIrrelevant)  bRelevant = true;
if (InteractionType != LIT_CachedLightMap && InteractionType != LIT_CachedIrrelevant) bLightMapped = false;
if (InteractionType != LIT_Dynamic) bDynamic = false;
if (InteractionType != LIT_CachedSignedDistanceFieldShadowMap2D) bShadowMapped = false;

注意 bLightMapped 把 LIT_CachedIrrelevant 也算作 true——
“烘焙判定无关”和”烘焙已包含”在这里是同一类结果:静态部分已经算过了,都不需要再动态画。

29.6 “没烘过的灯”会被动态预览

现在把 29.4 的”GUID 换了”和 29.5 的”匹配不上”接起来:
一盏 Static 灯改了颜色 → 拿到新 GUID → 任何 GetStaticInteraction 都匹配不上 → 它还没烘进任何 lightmap。

这时候引擎不会让它”消失”,而是把它当动态灯画出来(这就是编辑器里”没烘过的灯也有光”的原因):

1
2
3
4
5
6
7
// LightSceneInfo.cpp:245-250
bool FLightSceneInfo::ShouldRenderLightViewIndependent() const
{
return !Proxy->GetColor().IsAlmostBlack()
// Only render lights with dynamic lighting or unbuilt static lights
&& (!Proxy->HasStaticLighting() || !IsPrecomputedLightingValid());
}

拆开读:

  • Stationary / Movable 灯:HasStaticLighting() = false → !false = true → 永远动态渲染(这就是”直接光实时”的根);
  • Static 灯:HasStaticLighting() = true → 是否渲染取决于 !IsPrecomputedLightingValid()
    → 只有”没烘好”时才动态画,一旦烘好就彻底交给 lightmap。

IsPrecomputedLightingValid() 的定义(LightSceneInfo.cpp:267-270):

1
return (bPrecomputedLightingIsValid && NumUnbuiltInteractions < GWholeSceneShadowUnbuiltInteractionThreshold) || !Proxy->HasStaticShadowing();

其中:

  • bPrecomputedLightingIsValid 来自代理:ULightComponent::IsPrecomputedLightingValid() =
    GetLightComponentMapBuildData() != NULL && HasStaticShadowing()(LightComponent.cpp:1561-1564)。
    逐灯的构建数据是 FLightComponentMapBuildData(MapBuildDataRegistry.h:150-171),
    里面就两样东西:ShadowMapChannel 和 DepthMap——再次印证:烘焙给每盏灯留的”档案”只有阴影通道;
  • GWholeSceneShadowUnbuiltInteractionThreshold 默认 500(LightSceneInfo.cpp:16-19)。
    这是个容易踩的阈值:未烘焙交互少于 500 个时,灯仍被判定为”有效”,不会触发动态预览。
    所以”加了一盏新灯、只有几百个网格受影响”时,你可能根本看不到预览效果,只会看到它”没光”。

“未烘焙”是怎么被数出来的?在 FLightPrimitiveInteraction 的构造函数里(SceneCore.cpp:218-247):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if(bCastShadow && bIsDynamic)
{
// Determine the type of dynamic shadow produced by this light.
if (PrimitiveSceneInfo->Proxy->HasStaticLighting()
&& PrimitiveSceneInfo->Proxy->CastsStaticShadow()
// Don't mark unbuilt for movable primitives which were built with lightmaps but moved into a new light's influence
&& PrimitiveSceneInfo->Proxy->GetLightmapType() != ELightmapType::ForceSurface
&& (LightSceneInfo->Proxy->HasStaticLighting() || (LightSceneInfo->Proxy->HasStaticShadowing() && !bInIsShadowMapped)))
{
// Update the game thread's counter of number of uncached static lighting interactions.
bUncachedStaticLighting = true;

if (!GUnbuiltPreviewShadowsInGame && !InLightSceneInfo->Scene->IsEditorScene())
{
bCastShadow = false; // 打包版本里默认不画预览阴影
}
LightSceneInfo->NumUnbuiltInteractions++;
...
}
}

三个要点:

  1. 判定的是”uncached static lighting interaction“——一个(静态图元 × 未烘灯)对算一个;
  2. GUnbuiltPreviewShadowsInGame 决定打包版里要不要画预览阴影(编辑器场景不受限制);
  3. ELightmapType::ForceSurface 的图元被排除在外(见 29.9)。

编辑器里还能看到对应的可视化提示(LightRendering.cpp:1855):

1
2
3
const bool bDrawPreviewIndicator = ViewFamily.EngineShowFlags.PreviewShadowsIndicator
&& !LightSceneInfo.IsPrecomputedLightingValid()
&& LightSceneProxy.HasStaticShadowing();

那个”Preview Shadows”小图标背后就是 IsPrecomputedLightingValid() 在说话。

29.7 开关灯:Visible 竟然在排除列表里

回到”开关”这件事。灯光的可见性有两个层次:

层次 字段 影响
组件可见性 bVisible(USceneComponent::GetVisiblePropertyName()) 是否被渲染(ShouldRenderLight,LightSceneInfo.cpp:201)
参与烘焙 bAffectsWorld(LightComponentBase.h:52) OnRegister 时是否向烘焙系统登记(LightComponent.cpp:329-334)
1
2
3
4
5
6
7
// LightComponent.cpp:329-334
#if WITH_EDITOR
if (bAffectsWorld && HasStaticShadowing())
{
FStaticLightingSystemInterface::OnLightComponentRegistered.Broadcast(this);
}
#endif

bAffectsWorld 只在两处被写:构造时 = true(LightComponent.cpp:439),
以及 ALight::Destroyed() 里置 false(Light.cpp:62-77)——删灯会顺带失效光照缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Light.cpp:62-77(节选)
void ALight::Destroyed()
{
if (LightComponent)
{
// Mark the light as not affecting the world before updating the shadowmap channel allocation
LightComponent->bAffectsWorld = false;
UWorld* World = GetWorld();
if (World && !World->IsGameWorld())
{
// Force stationary light channel preview to be updated on editor delete
LightComponent->InvalidateLightingCache();
}
}
Super::Destroyed();
}

而**”勾掉 Visible”这一下,在 PostEditChangeProperty 的排除列表里**(LightComponent.cpp:834):

1
PropertyName != USceneComponent::GetVisiblePropertyName().ToString() &&

也就是说:勾选/取消 Visible 不会调用 InvalidateLightingCache(),不会提示重烘。
组合起来看后果:

  • Static 灯取消 Visible → 它本来就不做动态渲染(29.6),lightmap 也没失效
    → 视觉上什么都没发生,灯还亮着。这是本章最容易踩的一个坑;
  • Stationary 灯取消 Visible → 直接光消失(它本来就是动态画的),
    但 lightmap 里的间接光原封不动 → 就是第 25 章那道自测题问的”把 Stationary 灯隐藏后房间仍然亮“;
  • Movable 灯取消 Visible → 完全消失(它没有任何烘焙分量)。

29.8 每盏灯唯一的”间接光旋钮”,但它是烘焙期的

那有没有”针对单盏灯调间接光”的参数?有——**IndirectLightingIntensity**(默认 1.0,LightComponent.cpp:465)。
但它怎么被消费的,决定了它救不了运行时:

1
2
// LightComponent.cpp:1713
OutProxyDesc.IndirectLightingScale = InLightComponent.IndirectLightingIntensity;

顺着 IndirectLightingScale 找下去,真正用它的地方几乎全在 Lightmass 里:
LightmassScene.cpp:599(间接颜色)、:974(光通量)、:1271(入射功率)、
:2140 和 :2363(bCalculateForIndirectLighting ? IndirectLightingScale : 1.0f)。

运行时只有一个特例(LightRendering.cpp:691-695):

1
2
3
4
5
// When rendering reflection captures, the direct lighting of the light is actually the indirect specular from the main view
if (View.bIsReflectionCapture)
{
Out.LightParameters.Color *= LightSceneInfo.Proxy->GetIndirectLightingScale();
}

——只在反射捕获渲染时才乘,主视图不乘。

所以结论很干脆:**IndirectLightingIntensity(以及配套的 IndirectLightingSaturation)是烘焙期旋钮,
调完要重烘才生效;它解决的是”这盏灯的反弹太强/太弱”,
不是**”运行时让这盏灯的间接光变个色”。

29.9 能做什么 / 不能做什么(决策表)

把上面所有机制收成一张表。**”变色”指运行时改变颜色/亮度,”开关”指运行时开灯关灯。**

需求 Static Stationary Movable
直接光变色 ❌(setter 被挡,要重烘) ✅ 实时 ✅ 实时
间接光变色 ❌ ❌ (lightmap 是”和”,无单灯位置) ✅(它不进 lightmap)
开灯关灯(直接光) ❌ ✅ 实时 ✅ 实时
开灯关灯(间接光) ❌ ❌ (关了也还在) ✅
阴影跟着变 ❌ 静态阴影不变;仅动态阴影部分实时 ✅

想让”关灯后房间真的暗下来”,只有这四条路:

方案 做法 代价
① Lighting Scenario 两套烘焙(开灯 / 关灯)整体切换(Level.h:575 → LevelStreaming.cpp:1105/1123) 换关时跳变、磁盘/内存翻倍
② 灯改 Movable + Lumen 该灯完全走动态 GI 放弃 lightmap 的高质量间接光,性能换自由度
③ 干脆只用 Movable 灯 从设计上避开”烘焙单灯” 同上,且失去静态阴影
④ 接受”直接光没了、间接光留着” 关灯时只在观感上做补偿(后处理压暗 / 材质参数) 物理上不严谨,但最便宜

第五条路(不推荐但要知道):把灯的 Mobility 在运行时改——但 bAffectsWorld、GUID、
阴影通道分配(ReassignStationaryLightChannels)全是编辑器期设施,运行时改 Mobility 只会让
HasStaticShadowing() 翻转、GetStaticInteraction 结果变化,产生难以预期的跳变。

29.10 避坑清单

**避坑 1|Stationary 灯改颜色是”静默不一致”**。编辑器不会提示重烘(LightComponent.cpp:867-868
就靠 || Mobility == Static 这一条放行),但 lightmap 里的间接光还是旧色。
改完务必自己开一个只有间接光的视图(关掉所有动态灯)核对一遍。

避坑 2|Static 灯取消 Visible 什么都不会发生。Visible 在失效排除列表里(:834),
而 Static 灯本来就不做动态渲染(29.6)。想让 Static 灯”关掉”,只能重烘或改用别的 Mobility。

避坑 3|GUnbuiltPreviewShadowsInGame 决定了打包版看不看得到预览阴影。
编辑器里”新加的灯有光有影”,打包出去可能只剩光没影(SceneCore.cpp:231),别被编辑器表现误导。

避坑 4|500 的阈值会让”少部分未烘焙”被当成有效。NumUnbuiltInteractions < 500 时
IsPrecomputedLightingValid() 仍返回 true(LightSceneInfo.cpp:269),
于是那盏 Static 灯既不画动态光、又不在 lightmap 里 → 表现为”这盏灯完全不亮”。

避坑 5|IndirectLightingIntensity 不是运行时参数。它走 IndirectLightingScale 进 Lightmass,
运行时只在反射捕获渲染时才乘(LightRendering.cpp:694)。调它 = 要重烘。

避坑 6|删灯会失效缓存,隐藏灯不会。ALight::Destroyed() 主动调了
InvalidateLightingCache()(Light.cpp:71),但改 Visible 不会。
用”隐藏”代替”删除”来规避重烘提示,是把问题留给了运行时。

避坑 7|ForceSurface 的图元不参与”未烘焙”计数(SceneCore.cpp:225 的
GetLightmapType() != ELightmapType::ForceSurface)。这类图元即使进了新灯的影响范围也不会被标记
uncached——新灯照它们时可能既没有烘焙光、也没有预览动态光。

29.11 动手实验室

  1. 看”和”的证据:烘焙一个含 3 盏 Static 灯的关卡,用第 18 章的导出思路把 lightmap 取出来,
    数一数——每纹素只有一组 RGB。再在 MapBuildData 里找到 LightGuids 数组,
    确认它只是个”灯名单”(对照 29.1);
  2. 验 Static 改不动:运行时(PIE)对一盏 Static 灯调 SetLightColor / SetIntensity,
    观察画面毫无变化;再对一盏 Stationary 灯调同样的接口,观察直接光变、间接光不变
    (把 Stationary 灯改成朝天花板照,最容易看出”墙面反弹还是旧色”);
  3. 验编辑器排除表:编辑器里把一盏 Static 灯的颜色改掉——应立刻出现
    “LIGHTING NEEDS TO BE REBUILT”;再把一盏 Stationary 灯的颜色改成明显不同的颜色——
    不会有提示(对照 LightComponent.cpp:866-869 那三行);
  4. 看 GUID 换新:在 UpdateLightGUIDs()(LightComponent.cpp:271)下断点,
    改一次 Static 灯的颜色,观察 OriginalLightGuid 变化;然后确认旧 lightmap 的
    LightGuids 里已经没有它了(LightMap->ContainsLight 返回 false);
  5. 看预览降级:烘焙完成后新增一盏 Static 灯(不重新烘),
    观察它被动态画出来的样子;打开/关闭
    r.Shadow.UnbuiltPreviewInGame(GUnbuiltPreviewShadowsInGame)对比阴影;
    再打开 Show → Advanced → PreviewShadowsIndicator,看那盏灯出现预览图标(LightRendering.cpp:1855);
  6. 踩一次 500 阈值:在一个只有十几个网格的小场景里新增 Static 灯,
    观察它可能”完全不亮”(对照避坑 4),再把场景复制几十份让未烘焙交互超过 500,
    观察预览突然生效;
  7. 验开关:分别隐藏一盏 Static / Stationary / Movable 灯,记录各自的观感差异,
    特别确认 “Stationary 灯关了房间还是亮的”(第 25 章那道题的实证)。

第 30 章 阴影篇:Static / Stationary 的阴影是怎么烘出来、怎么被用掉的

TL;DR|先给一条最反直觉的结论:Static 灯根本没有「阴影贴图」这回事。
TextureMapping.cpp:853-865 里 Static 灯走的是 FShadowMapData2D* ShadowMapData = NULL; 那一支——
可见度算完直接乘进光照样本(CurrentLightSample.AddWeighted(DirectLighting, AdjustedShadowFactor),:1721),
没有任何独立通道;阴影的”存储格式”就是 lightmap 系数本身。
Stationary 灯相反:直接光要留给实时算,可见度必须单独存下来,于是有了 FShadowMap2D + 4 通道预算。
CPU Lightmass 默认存有符号距离场(存”到最近阴影过渡的距离”而非 0/1,Valve 那篇
Improved Alpha-Tested Magnification 的做法);GPU Lightmass 没有实现距离场——
Scene.cpp:233 那行注释写得明明白白:// TODO: implement SDF。
落到磁盘,这张图 CompressionNone = true(ShadowMap.cpp:334):1 通道 G8 = 1 B/纹素,
2~4 通道一律 BGRA8 = 4 B/纹素,是 LQ 系数图(1 B/纹素)的 4 倍。
这就是”把它塞进 LQ 的 A 通道”这个念头的由来。
本章把两种 Mobility × 两套 Lightmass 的
烘 / 存 / 用
逐段拆开,最后对”合并进单通道”做完整账本与语义分析:
能省,但只有通道占满时才划算;而且”达到 Static 的品质”这句话本身等价于
“交出逐灯可分辨性“——关灯后阴影不会跟着消失。

30.0 一张表看清全局:那道决定命运的分支

所有阴影烘焙的分野,都发生在 CPU Lightmass 的这一个循环里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// UnrealLightmass/Private/Lighting/TextureMapping.cpp:798-866(节选)
if (ShadowSettings.bUseZeroAreaLightmapSpaceFilteredLights)
{
// ① 老式:把灯当零面积点/方向光算,再在纹理空间滤波近似半影
CalculateDirectLightingTextureMappingFiltered(...);
}
else
{
if (!Light->UseStaticLighting() // ← 不是 Static
&& (Light->LightFlags & GI_LIGHT_CASTSHADOWS)
&& (Light->LightFlags & GI_LIGHT_CASTSTATICSHADOWS)
&& (Light->LightFlags & GI_LIGHT_STORE_SEPARATE_SHADOW_FACTOR)
&& ShadowSettings.bAllowSignedDistanceFieldShadows)
{
// ② Stationary 灯:单独存一份阴影因子
if (Light->LightFlags & GI_LIGHT_USE_AREA_SHADOWS_FOR_SEPARATE_SHADOW_FACTOR)
{
// ②a 面积阴影 → 转存 sqrt(可见度)
}
else
{
// ②b 真正的有符号距离场
CalculateDirectSignedDistanceFieldLightingTextureMappingTextureSpace(...);
}
}
else if (Light->UseStaticLighting()) // ← Static 灯
{
FShadowMapData2D* ShadowMapData = NULL; // ← 关键:NULL!
CalculateDirectAreaLightingTextureMapping(..., ShadowMapData, ...);
if (Light->GetMeshAreaLight() == NULL)
{
LightMapData.AddLight(Light);
}
}
}

UseStaticLighting() 就是 Mobility == Static(第 25 章的两个判据函数)。于是四种组合落地为:

CPU Lightmass GPU Lightmass 产物
Static 灯 面积光采样算可见度 → 乘进光照值 路径追踪里顺带算 → 同样进光照值 无阴影贴图
Stationary 灯 有符号距离场(默认)/ sqrt(V)(开面积阴影) 128 spp 的 sqrt(可见度) FShadowMap2D,占 1~4 个通道

一句话:**”阴影贴图”从一开始就是为 Stationary 灯准备的,Static 灯从来不用它。**


30.1 Static 灯 · CPU Lightmass:阴影去哪儿了

30.1.1 面积光采样:半影是”采样出来的”,不是”滤出来的”

CalculateDirectAreaLightingTextureMapping(TextureMapping.cpp:1390)逐纹素做这件事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// TextureMapping.cpp:1487-1535(节选)
const FVector2f ShadowValue = CalculatePointAreaShadowing(
TextureMapping, CurrentVertex, TexelToVertex.ElementIndex,
TexelToVertex.TexelRadius, Light, MappingContext, SampleGenerator,
UnnormalizedTransmission, LightSurfaceSamples, bDebugThisTexel);

if (ShadowValue.X > 0.0f)
{
if (ShadowValue.X < ShadowValue.Y)
{
// 落在半影区 → 用更密的一组光源表面采样再算一遍
const FVector2f ShadowValuePenumbra = CalculatePointAreaShadowing(... PenumbraLightSurfaceSamples ...);
ShadowFactor = (ShadowValue.X + ShadowValuePenumbra.X) / (ShadowValue.Y + ShadowValuePenumbra.Y);
Transmission = (UnnormalizedTransmission + UnnormalizedPenumbraTransmission) / (...);
}
else
{
ShadowFactor = 1.0f; // 完全在光影外
Transmission = UnnormalizedTransmission / ShadowValue.Y;
}
}
else
{
Transmission = FLinearColor::Black;
// ShadowFactor 保持 0 —— 完全在阴影里
}

注意 ShadowValue 是 FVector2f(分子 / 分母):光源表面被采样 N 次,其中 M 次可见,则
ShadowFactor = M / N。软阴影不是模糊出来的,是对光源面积做蒙特卡洛积分积出来的——
所以离遮挡物越远、LightSourceRadius 越大,半影自然越宽。这是 Static 阴影质量高的根本原因。

半影区还会自动加密采样(GetCachedSurfaceSamples(0, true) 拿更密的一组),
这是”面积阴影越远越软”这条直觉的源码依据。

30.1.2 可见度怎么用掉:乘进光照值,不落地成图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// TextureMapping.cpp:1698-1722(节选)
const float AdjustedShadowFactor = FMath::Pow(ShadowFactor, Light->ShadowExponent);

if (ShadowMapData) // ← Stationary 的面积阴影分支才走这里
{
(*ShadowMapData)(X,Y).Visibility = AdjustedShadowFactor;
}
else // ← Static 灯
{
const FGatheredLightSample DirectLighting =
CalculatePointLighting(TextureMapping, CurrentVertex, TexelToVertex.ElementIndex,
Light, LightIntensity, Transmission);

FGatheredLightMapSample& CurrentLightSample = LightMapData(X,Y);
CurrentLightSample.AddWeighted(DirectLighting, AdjustedShadowFactor); // ← 就这一步
}

AddWeighted(DirectLighting, AdjustedShadowFactor) 是整章的题眼:
阴影因子连一字节独立存储都没有拿,它直接变成了”这一纹素的直接光有多亮”。
你最后在 lightmap 里看到的 RGB,已经是 辐照度 × 可见度 的乘积。

三个直接推论:

  1. Static 灯的数量没有上限。第 N 盏灯再来一次 AddWeighted 累加即可,不存在”通道不够”这回事;
  2. Static 灯的阴影分辨率 = lightmap 分辨率,纹素就是最小单位,放大看是台阶;
  3. Static 灯的阴影没有办法”取出来”单独操作——关灯、改色、改亮度都不会让它变化(第 29 章)。

30.1.3 一个可选的后处理:bFilterShadowFactor

算完还有一道纹理空间 3×3 滤波(:1552-1660),权重固定:

1
2
3
.5*.150  .5*.332  .5*.150
.5*.332 1.000 .5*.332
.5*.150 .5*.332 .5*.150

它的开关是 Scene.ShadowSettings.bFilterShadowFactor,梯度阈值 ShadowFactorGradientTolerance。
滤波的作用是压掉采样噪声(半影区的蒙特卡洛抖动),代价是阴影边缘会再软一点。
注意 :1588-1593 这一行:

1
2
3
4
5
6
7
if (ShadowMapData)
{
// Lower the self weight on backfaces
// We want to spread frontface values onto backfaces for shadowmaps
// where the normal falloff will happen per-pixel
CenterValueWeight = bLightIsInFrontOfTriangle ? 1.0f : .1f;
}

专门为 shadowmap 分支存在——因为 shadowmap 的法线衰减在运行时逐像素做,
背面纹素必须被正面”污染”一下才不会出现黑边。Static 灯(无 shadowmap)不需要这个处理。


30.2 Static 灯 · GPU Lightmass:殊途同归

GPU Lightmass 那边证据更直接。TransencodeShadowMap 这个 lambda 的第一行就是断言:

1
2
3
4
5
6
7
8
9
10
11
// GPULightmass/Private/Scene/Scene.cpp:2264-2271(节选)
auto TransencodeShadowMap = [&Lightmap, &ShadowMaps](
FLocalLightBuildInfo& LightBuildInfo,
FLocalLightRenderState& Light)
{
check(Light.bStationary && Light.bCastShadow); // ← 只可能是 Stationary
check(Light.ShadowMapChannel != INDEX_NONE);
FQuantizedShadowSignedDistanceFieldData2D* ShadowMap =
new FQuantizedShadowSignedDistanceFieldData2D(Lightmap.GetSize().X, Lightmap.GetSize().Y);
...
};

而”这盏灯进了这张 lightmap”的登记函数对 Static 灯只做一件事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// GPULightmass/Private/Scene/Scene.cpp:222-238
void AddLightToLightmap(FLightmap& Lightmap, FLocalLightBuildInfo& Light)
{
// For both static and stationary lights
Lightmap.LightmapObject->LightGuids.Add(Light.GetComponentUObject()->LightGuid);

if (Light.CastsStationaryShadow()) // = bStationary && bCastShadow
{
Lightmap.NumStationaryLightsPerShadowChannel[Light.ShadowMapChannel]++;
Lightmap.LightmapObject->bShadowChannelValid[Light.ShadowMapChannel] = true;
// TODO: implement SDF. For area lights and invalid channels this will be fixed to 1
Lightmap.LightmapObject->InvUniformPenumbraSize[Light.ShadowMapChannel] =
1.0f / Light.GetComponentUObject()->GetUniformPenumbraSize();
}
}

Static 灯在这里只登记了一个 GUID,别的什么都没发生。它的直接光和间接光一起,
被 LightmapPathTracingMainRG 那条路径追踪主循环算完、累加、量化——
可见度天然地包含在”这条路径有没有被挡住”里,不需要(也没有)单独的阴影 pass。

这行 // TODO: implement SDF 值得单独圈出来(:233)。
它同时说明了两件事:① GPU Lightmass 没有距离场阴影;
② 它的 InvUniformPenumbraSize 直接取灯的 GetUniformPenumbraSize(),
与 CPU 端的”面积阴影分支”语义一致。也就是说——
GPU Lightmass 的 Stationary 阴影,天然就等同于 CPU 端打开
bUseAreaShadowsForStationaryLight 的那条路
(详见 30.5)。


30.3 Static 灯 · 运行时:根本没有”用”这一步

Static 灯烘焙有效后,完全不进入实时光照渲染:

1
2
3
4
5
6
7
// Renderer/Private/LightSceneInfo.cpp:245-250
bool FLightSceneInfo::ShouldRenderLightViewIndependent() const
{
return !Proxy->GetColor().IsAlmostBlack()
// Only render lights with dynamic lighting or unbuilt static lights
&& (!Proxy->HasStaticLighting() || !IsPrecomputedLightingValid());
}

HasStaticLighting() 为 true(Static 灯)且 IsPrecomputedLightingValid() 为 true 时直接返回 false
——没有 FLightSceneInfo 进入光照列表,没有 ShadowMapChannelMask,没有 GetShadowTermsBase。

那 shader 里那条”取静态阴影通道”的代码碰到 Static 灯会怎样?看这里:

1
2
3
4
5
6
7
8
9
10
11
12
// Shaders/Private/LightmapCommon.ush:230-259(节选)
half4 GetPrecomputedShadowMasks(...)
{
#if STATICLIGHTING_TEXTUREMASK && STATICLIGHTING_SIGNEDDISTANCEFIELD
... // 采样距离场、解码
#elif HQ_TEXTURE_LIGHTMAP || LQ_TEXTURE_LIGHTMAP
// Mark as shadowed for lightmapped objects with no shadowmap
return 0; // ← 返回全 0("全阴影")
#elif ALLOW_STATIC_LIGHTING
...
#endif
}

返回 0 看着吓人,但消费端有保护:

1
2
3
// Shaders/Private/DeferredLightingCommon.ush:109-114
half UsesStaticShadowMap = dot(LightData.ShadowMapChannelMask, half4(1, 1, 1, 1));
half StaticShadowing = lerp(1, dot(PrecomputedShadowFactors, LightData.ShadowMapChannelMask), UsesStaticShadowMap);

Static 灯没有通道 → ShadowMapChannelMask 全 0 → UsesStaticShadowMap = 0 →
lerp(1, 0, 0) = 1(不受影响)。那条 return 0 是给”图元有 lightmap 但没有 shadowmap、
却落在某盏 Stationary 灯通道里”的情况用的——意思是”你没烘到,当成全阴影”(配合 :258 的注释读)。

结论:Static 灯的阴影,运行时开销是 0。它既不被采样、也不被解码、也不参与任何 lerp。
它在烘焙结束的那一刻就已经”用完了”。


30.4 Stationary 灯 · CPU Lightmass:有符号距离场五步走

30.4.1 触发条件

1
2
3
4
5
6
// TextureMapping.cpp:805-810
if (!Light->UseStaticLighting() // 不是 Static
&& (Light->LightFlags & GI_LIGHT_CASTSHADOWS)
&& (Light->LightFlags & GI_LIGHT_CASTSTATICSHADOWS)
&& (Light->LightFlags & GI_LIGHT_STORE_SEPARATE_SHADOW_FACTOR)
&& ShadowSettings.bAllowSignedDistanceFieldShadows)

四个灯 flag + 一个全局开关。其中 GI_LIGHT_STORE_SEPARATE_SHADOW_FACTOR
来自 Lightmass.cpp:280-283 的 CastStaticShadows && bStoreSeparateShadowFactor 类设置;
GI_LIGHT_USE_AREA_SHADOWS_FOR_SEPARATE_SHADOW_FACTOR 则来自
**bUseAreaShadowsForStationaryLight**(Lightmass.cpp:280-283):

1
2
3
4
5
// Editor/UnrealEd/Private/Lightmass/Lightmass.cpp:280-283
if (In->GetLightmassSettings().bUseAreaShadowsForStationaryLight)
{
Out.LightFlags |= Lightmass::GI_LIGHT_USE_AREA_SHADOWS_FOR_SEPARATE_SHADOW_FACTOR;
}

30.4.2 两条子路径

②a 面积阴影(勾了 bUseAreaShadowsForStationaryLight) ②b 距离场(默认)
算法 CalculateDirectAreaLightingTextureMapping 算 0~1 可见度 ...TextureSpace 五步生成距离场
存什么 Distance = sqrt(Visibility),注释:”Encode with more precision near 0“(:827) 到最近过渡的归一化距离 + penumbra
PenumbraSize 固定 1(:830) 按 PCSS 公式算(:2465-2467)
运行时解码 ShadowFactor = sqrt(V) → 平方 → V 按 InvUniformPenumbraSize 缩放偏置
软阴影来源 烘焙时对光源面积积分 运行时按距离场重建

30.4.3 距离场五步(CalculateDirectSignedDistanceFieldLightingTextureMappingTextureSpace,:1878)

第 1 步 · 低分辨率可见度(:1943-2020)
在最终 lightmap 分辨率上逐纹素打一条可见度射线(RAY_FLAG 无关,这里是 CPU 的
AggregateMesh->IntersectLightRay),得到 0/1 的粗可见度图。
关键是 :1967-1970 的注释:

1
2
3
4
// Only mark the texel as mapped if we are inside the light's influence
// This is important because stationary lights are assigned shadowmap channels based on overlap,
// And multiple shadowmaps on the same object may be merged together,
// but only if each one marks the area that it has valid data

“只标记影响范围内的纹素为 mapped”是通道复用能成立的前提——
两盏互不相交的灯可以共用通道 0,因为它们各自的 Coverage 互不重叠(见 30.6.2)。

第 2 步 · 决定上采样因子(:1892-1935)
先算网格的平均纹素密度(LightmapTriangleArea / TriangleArea),再:

1
2
3
4
5
6
7
const float RightTriangleSide = FMath::Sqrt(2.0f * AverageTexelDensity);
const int32 TargetUpsampleFactor = FMath::TruncToInt(
ShadowSettings.ApproximateHighResTexelsPerMaxTransitionDistance
/ (RightTriangleSide * ShadowSettings.MaxTransitionDistanceWorldSpace));
// Round up to the nearest odd factor, so each destination texel has a high resolution source texel at its center
UpsampleFactor = FMath::Clamp(TargetUpsampleFactor - TargetUpsampleFactor % 2 + 1,
ShadowSettings.MinDistanceFieldUpsampleFactor, 13);

取奇数是为了保证”每个低分辨率纹素中心恰好对应一个高分辨率样本”。
小网格高密度 → 不需要上采样;大网格低密度 → 上采样到 13×。

第 3 步 · 标记过渡邻域(:2051-2101)
检查 4 邻域,只要有一个邻居的可见度和自己不同,就给这个纹素打上
NeedsHighResSampling 并分配 UpsampleFactor² 个高分辨率样本槽位。
只有过渡带才做高精度采样,这是性能的关键。

第 4 步 · 高分辨率采样(:2176-2242)
对过渡带内的每个高分辨率样本再打一条射线,这次要最近交点距离(用来算半影):

1
2
3
4
5
6
7
8
if (Intersection.bIntersects)
{
HighResSample.SetOccluderDistance((LightRay.Start - Intersection.IntersectionVertex.WorldPosition).Size3());
}
else
{
HighResSample.SetVisible(true);
}

第 5 步 · 散射(:2252-2478)
这是”距离场”这个名字的由来。遍历过渡带上的被遮挡高分辨率样本,
把它到过渡点的世界空间距离,散射给 MaxTransitionDistanceWorldSpace 范围内的所有低分辨率纹素,
每个纹素保留最小的那个距离:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// TextureMapping.cpp:2442-2467(节选)
const float TransitionDistance = (ScatterPosition - HighResSample.GetPosition()).Size3();
const float NormalizedDistance = FMath::Clamp(TransitionDistance / ShadowSettings.MaxTransitionDistanceWorldSpace, 0.0f, 1.0f);
FSignedDistanceFieldShadowSample& FinalShadowSample = (*ShadowMapData)(LowResScatterX, LowResScatterY);

if (NormalizedDistance * .5f < FMath::Abs(FinalShadowSample.Distance - .5f))
{
// Encode the transition distance so that [.5,0] corresponds to [0,1] for shadowed texels,
// and [.5,1] corresponds to [0,1] for unshadowed texels.
// .5 of the encoded distance lies exactly on the shadow transition.
FinalShadowSample.Distance = CurrentRegion ? (NormalizedDistance) * .5f + .5f
: .5f - NormalizedDistance * .5f;

// Approximate the penumbra size using
// PenumbraSize = (ReceiverDistanceFromLight - OccluderDistanceFromLight) * LightSize / OccluderDistanceFromLight
// Which is from the paper "Percentage-Closer Soft Shadows" by Randima Fernando
const float ReceiverDistanceFromLight = (Light->LightCenterPosition(ScatterPosition, ScatterNormal) - ScatterPosition).Size3();
const float PenumbraSize = HighResSample.GetOccluderDistance() * Light->LightSourceRadius
/ (ReceiverDistanceFromLight - HighResSample.GetOccluderDistance());
FinalShadowSample.PenumbraSize = FMath::Clamp(PenumbraSize / ShadowSettings.MaxTransitionDistanceWorldSpace, 0.01f, 1.0f);
}

编码规则:0.5 恰好是阴影边界,暗侧落在 [0, 0.5),亮侧落在 (0.5, 1]。
纹素离边界越远,值越靠近 0 或 1;运行时靠”离 0.5 多远”重建出亚纹素精度的边缘位置——
这就是 Valve 那篇论文的精髓,也是 SDF 阴影比 Static 阴影”细腻”的技术来源。

一个兜底(:2023 / :1729):如果整块全被遮挡,或者未被遮挡的纹素比例低于
ShadowSettings.MinUnoccludedFraction,就直接丢弃这张 shadow map
(bIsCompletelyOccluded 或阈值判断)。省下来的通道可以给别的灯。


30.5 Stationary 灯 · GPU Lightmass:128 spp 的 sqrt(可见度)

GPU Lightmass 走的是完全不同的实现:FStationaryLightShadowTracingRGS
(LightmapRayTracing.cpp:20 绑定 LightmapPathTracing.usf 的 StationaryLightShadowTracingMainRG)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// GPULightmass/Shaders/Private/LightmapPathTracing.usf:1436-1482(节选)
UNROLL_N(2)
for (int HemisphereIndex = 0; HemisphereIndex < (bMaterialTwoSided ? 2 : 1); HemisphereIndex++)
{
float2 RandSample = float2(
Halton(StrongIntegerHash(Seed) + LightSampleIndexArray[TileIndex], 5),
Halton(StrongIntegerHash(Seed) + LightSampleIndexArray[TileIndex], 7));

float3 EffectiveWorldNormal = HemisphereIndex == 0 ? WorldNormal : -WorldNormal;

bool bRaySampleValid = GenerateOcclusionRay(LightType, LightParameters,
TranslatedWorldPosition, EffectiveWorldNormal, RandSample,
/*out*/ Ray.Origin, /*out*/ Ray.Direction, /*out*/ Ray.TMin, /*out*/ Ray.TMax);

ApplyRayBias(Ray.Origin, LightType == LIGHT_TYPE_DIRECTIONAL ? 1.0f : max(Ray.TMax - Ray.TMin, 0.0f),
EffectiveWorldNormal);

if (bRaySampleValid)
{
uint RayFlags = RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH;
FMinimalPayload MinimalPayload = TraceLightmapVisibilityRay(TLAS, RayFlags, Ray);
Visibility = max(Visibility, MinimalPayload.IsMiss() ? 1 : 0);
}
...
}

if (bAnyHemisphereRaySampleValid)
{
ShadowMask[TexelIndexInPool][ChannelIndex] = asfloat(asuint(ShadowMask[TexelIndexInPool][ChannelIndex]) + Visibility);
uint SampleCount = asuint(ShadowMaskSampleCount[TexelIndexInPool][ChannelIndex]);
SampleCount++;
ShadowMaskSampleCount[TexelIndexInPool][ChannelIndex] = asfloat(SampleCount);
}

三个细节:

  1. 每帧只打 1 条射线,靠 LightSampleIndexArray[TileIndex] 推进 Halton 序列,
    多帧累积到 StationaryLightShadowSamples(默认 128,GPULightmassSettings.h:71);
  2. 双面材质打两个半球(bMaterialTwoSided ? 2 : 1),取 max——两个半球只要有一个看得见就算可见;
  3. 用 asuint/asfloat 做整数累加(因为 UAV 是 float4)。

最后在清帧的 pass 里做归一化和编码:

1
2
3
// GPULightmass/Shaders/Private/LightmapBufferClear.usf:71
StagingShadowMask[TexelIndexInStagingPool] =
sqrt((float4)ShadowValue / max(uint4(1, 1, 1, 1), SampleCountValue)) * ValidityMask;

sqrt 是在 GPU 上做的,对应 CPU 端 :829 那句 DestShadowSample.Distance = FMath::Sqrt(...)。
回读到 CPU 后的收尾:

1
2
3
4
5
6
7
8
9
10
// GPULightmass/Private/LightmapEncoding.cpp:351-359
FQuantizedSignedDistanceFieldShadowSample ConvertToShadowSample(FLinearColor ShadowMask, int32 ChannelIndex)
{
FQuantizedSignedDistanceFieldShadowSample Sample;
// Sqrt is already done on GPU
Sample.Distance = FVector4f(ShadowMask)[ChannelIndex] * 255.0f;
Sample.Coverage = FVector4f(ShadowMask)[ChannelIndex] >= 0.0f ? 255 : 0;
Sample.PenumbraSize = 0;
return Sample;
}

30.5.1 与 CPU 的三个关键差异

CPU(默认 SDF) CPU(面积阴影) GPU Lightmass
存的是 到过渡的距离 sqrt(V) sqrt(V)
PenumbraSize PCSS 计算值 1 0
软阴影来源 运行时距离场重建 烘焙期面积积分 烘焙期 128 spp 采样
低分辨率 lightmap 下 边缘仍平滑 台阶 台阶(但采样降噪)
需要的烘焙时间 两遍射线 + 散射 光源面积采样 128 spp × 覆盖纹素

避坑(重要):GPU Lightmass 的 Sample.PenumbraSize = 0 并不影响运行时解码——
运行时用的 InvUniformPenumbraSize 来自灯组件的 GetUniformPenumbraSize()
(ShadowMap.cpp:916 / LightMap.cpp:2709),不是来自样本。
而 GetUniformPenumbraSize() 的实现是:

1
2
3
4
5
6
7
8
// DirectionalLightComponent.cpp:511-523
float UDirectionalLightComponent::GetUniformPenumbraSize() const
{
if (LightmassSettings.bUseAreaShadowsForStationaryLight)
return 1.0f; // 把 Distance 直接当可见度解释
else
return FMath::Clamp(LightmassSettings.LightSourceAngle * .05f, .0001f, 1.0f);
}

也就是说:GPU Lightmass 存的 sqrt(V),只有在 bUseAreaShadowsForStationaryLight = true
时才会被正确解码
(InvUniformPenumbraSize = 1 → ShadowFactor = Distance → 平方 → V)。
否则 InvUniformPenumbraSize 是个远大于 1 的数(默认 LightSourceAngle=1 → 20),
解码变成 saturate(0.5 + (sqrt(V) - 0.5) * 20)——相当于在 V = 0.25 处硬切二值化。
用 GPU Lightmass 烘 Stationary 阴影时,**记得勾上 bUseAreaShadowsForStationaryLight**。


30.6 Stationary 灯 · 落盘:4 通道打包 + 一条”不压缩”的硬规则

30.6.1 通道分配:ReassignStationaryLightChannels

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// LightComponent.cpp:1781-1939(节选)
// ① 只有"有静态阴影、但没有静态光照"的灯参与分配
if (LightComponent->HasStaticShadowing() && !LightComponent->HasStaticLighting())
{
if (LightComponent->bAffectsWorld
&& (LightComponent->CastShadows || LightComponent->LightFunctionMaterial)
&& LightComponent->CastStaticShadows)
{ ... }
}
// ② 建重叠图:双向 AffectsBounds 测试(:1840-1841)
// ③ 排序:方向光永远排最前(:1854-1862),其余按"重叠数降序"(:1849)
// ④ 贪心:取最小的、未被任何重叠灯占用的通道(:1894-1902)
for (int32 ChannelIndex = 0; ChannelIndex < UE_ARRAY_COUNT(bChannelUsed); ChannelIndex++)
{
if (!bChannelUsed[ChannelIndex]) { CurrentLight->Channel = ChannelIndex; break; }
}

失败(4 个通道全被重叠灯占满)时:

1
2
3
4
5
6
7
8
// LightComponent.cpp:1929-1934
if (CurrentLight->Channel == INDEX_NONE)
{
FMessageLog("LightingResults").PerformanceWarning()
->AddToken(...)
->AddToken(FTextToken::Create(NSLOCTEXT("Lightmass", "LightmassError_FailedToAllocateShadowmapChannel",
"Severe performance loss: Failed to allocate shadowmap channel for stationary light due to overlap - light will fall back to dynamic shadows!")));
}

退回全动态阴影。这就是第 25 章说的”4 通道预算”的真实代价:不是画质下降,是性能悬崖。

30.6.2 打包:多灯写同一张 RGBA,靠 Coverage 分区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ShadowMap.cpp:898-933(节选)
for (int32 ChannelIndex = 0; ChannelIndex < 4; ChannelIndex++)
{
for (const auto& ShadowMapPair : Allocation.ShadowMapData)
{
const FLightComponentMapBuildData* LightBuildData = Registry->GetLightBuildData(CurrentLight->LightGuid);
// Should have been setup by ReassignStationaryLightChannels
check(LightBuildData);

if (LightBuildData->ShadowMapChannel == ChannelIndex)
{
MaxChannelsUsed = FMath::Max(MaxChannelsUsed, ChannelIndex + 1);
bChannelUsed[ChannelIndex] = true;
// Warning - storing one penumbra size for the whole shadowmap
// even though multiple lights can share a channel
InvUniformPenumbraSize[ChannelIndex] = 1.0f / ShadowMapPair.Key->GetUniformPenumbraSize();

for (...每个纹素...)
{
if (SourceSample.Coverage > 0)
{
// Note: multiple lights can write to different parts of the destination
// due to channel assignment
DestSample.Samples[ChannelIndex] = SourceSample;
}
}
}
}
}

两盏灯共用通道 0 时,它们各自的 Coverage>0 区域互不重叠(30.4.3 第 1 步保证),
所以写进同一个字节通道也不会互相覆盖。这就是”4 通道 ≠ 只能 4 盏灯”的原因——
只要影响范围不重叠,几十盏灯也能共用通道 0。

代价是那行警告:一张 shadowmap 只存一个 penumbra size,共享通道的灯被迫用同一个值。

30.6.3 落盘格式:不压缩,而且 2 通道就付 4 通道的钱

1
2
3
4
5
6
// ShadowMap.cpp:329-334
Texture->Source.Init2DWithMipChain(GetSizeX(), GetSizeY(), NumChannelsUsed == 1 ? TSF_G8 : TSF_BGRA8);
Texture->MipGenSettings = TMGS_LeaveExistingMips;
// Uncompressed G8 or BGRA8 platform data :
Texture->CompressionSettings = NumChannelsUsed == 1 ? TC_Grayscale : TC_Default;
Texture->CompressionNone = true; // ← 硬编码

LightMap.cpp:1594-1597 的打包路径同样:

1
2
3
4
FTextureFormatSettings FormatSettings;
FormatSettings.SRGB = false;
FormatSettings.CompressionNone = true; // ← 同样硬编码
Texture->SetLayerFormatSettings(LayerIndex, FormatSettings);

为什么必须不压缩? 因为距离场是”到边界的距离”,块压缩(BC/ASTC)在 4×4 块内只有
2 个端点做线性插值,会把平滑的距离梯度打成台阶——距离场一旦被块压缩,重建出来的边缘就废了。
这是它跟系数图最本质的区别:系数图压的是颜色(错了顶多色偏),shadowmap 压的是几何量。

更要命的是这条:

1
2
// LightMap.cpp:1851 / ShadowMap.cpp:329
const ETextureSourceFormat ShadowMapFormat = NumShadowChannelsUsed == 1 ? TSF_G8 : TSF_BGRA8;

NumShadowChannelsUsed 是对整张图集取 max(LightMap.cpp:2704):

1
MaxChannelsUsed = FMath::Max(MaxChannelsUsed, ChannelIndex + 1);

只要图集里有任意一个物体的任意一个纹素被两盏重叠的 Stationary 灯照到,
整张图集(默认 1024²)就从 G8 跳到 BGRA8——1 B/纹素 变 4 B/纹素。

一颗老鼠屎坏一锅汤,这是 shadowmap 显存开销最常见的失控点。

30.6.4 补上第 27 章那张表缺的一行

第 27 章给了四张图的每纹素字节账,这里把第五张补上:

图 物理尺寸 CompressionNoAlpha 压缩后 每 lightmap 纹素
HQ 系数图 W×2H false BC3/BC7 8bpp 2.0 B
LQ 系数图 W×2H true BC1 4bpp 1.0 B
SkyOcclusion W×H false BC3/BC7 8bpp 1.0 B
AOMask W×H false BC4 4bpp 0.5 B
ShadowMap(1 通道) W×H — 不压缩 G8 8bpp 1.0 B
ShadowMap(2~4 通道) W×H — 不压缩 BGRA8 32bpp 4.0 B ←

(未计 mip,实际再 ×4/3。)

4 通道 shadowmap = LQ 系数图的 4 倍,=H Q 系数图的 2 倍。
这就是”把它塞进 LQ 的 A 通道”这个想法在数字上的合理性——你要干掉的是这一家里最贵的那口。


30.7 Stationary 灯 · 运行时:GBuffer 里的 4 个数,一次 dot

30.7.1 写入:BasePass 把 4 个因子塞进 GBuffer

编译期由策略决定(LightMapRendering.cpp:70-75):

1
2
3
4
5
6
void DistanceFieldShadowsAndLightMapPolicyImpl::ModifyCompilationEnvironment(...)
{
OutEnvironment.SetDefine(TEXT("STATICLIGHTING_TEXTUREMASK"), 1);
OutEnvironment.SetDefine(TEXT("STATICLIGHTING_SIGNEDDISTANCEFIELD"), 1);
...
}

策略选择(BasePassRendering.cpp:2183-2204):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
case LMIT_Texture:
if (bAllowHighQualityLightMaps)
{
const FShadowMapInteraction ShadowMapInteraction =
(bAllowStaticLighting && LCI && bIsLitMaterial) ? LCI->GetShadowMapInteraction(FeatureLevel) : FShadowMapInteraction();

if (ShadowMapInteraction.GetType() == SMIT_Texture)
LightMapPolicyType = LMP_DISTANCE_FIELD_SHADOWS_AND_HQ_LIGHTMAP; // ← 有 shadowmap
else
LightMapPolicyType = LMP_HQ_LIGHTMAP;
}
else if (bAllowLowQualityLightMaps)
{
LightMapPolicyType = LMP_LQ_LIGHTMAP; // ← 没有 DF 变体!
}

避坑(桌面端):LQ 分支没有 LMP_DISTANCE_FIELD_SHADOWS_AND_LQ_LIGHTMAP。
桌面端如果 r.HighQualityLightMaps=0(强制 LQ),
Stationary 的静态阴影会整体失效
——
不是变糊,是没了(WRITES_PRECSHADOWFACTOR_ZERO 为真,GBuffer 里那 4 个数被写成 0)。
移动端另有 FMobileDistanceFieldShadowsAndLQLightMapPolicy
(r.Mobile.AllowDistanceFieldShadows,LightMapRendering.cpp:181-189)可以配 LQ 使用。

30.7.2 解码:两步缩放 + 一次平方

1
2
3
4
5
6
7
8
9
// Shaders/Private/LightmapCommon.ush:241-253
half4 DistanceField = Texture2DSample(LightmapResourceCluster.StaticShadowTexture, ..., ShadowMapCoordinate);

float4 InvUniformPenumbraSizes = GetLightmapData(LightmapDataIndex).InvUniformPenumbraSizes;
float4 DistanceFieldBias = -.5f * InvUniformPenumbraSizes + .5f;

// Compute shadow factors by scaling and biasing the distance
half4 ShadowFactors = saturate(DistanceField * InvUniformPenumbraSizes + DistanceFieldBias);
return GetLightmapData(LightmapDataIndex).StaticShadowMapMasks * ShadowFactors * ShadowFactors;

展开一下:ShadowFactor = saturate(0.5 + (D - 0.5) · InvPenumbra),然后平方。

  • 面积阴影 / GPU LM 路径:InvPenumbra = 1,D = sqrt(V) → ShadowFactor = sqrt(V) → 平方 → V ✅
  • SDF 路径:InvPenumbra = 1 / penumbra(例如 20)→ D 离 0.5 只差 penumbra/2 就饱和
    → 边缘被”锐化”到 penumbra 宽度,而 D 本身的平滑梯度让边缘位置能落在纹素之间 ✅
  • 最后乘 StaticShadowMapMasks——只有 bChannelValid[i] 为真的通道才生效。

30.7.3 消费:一次 dot,然后和动态阴影混合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Shaders/Private/DeferredLightingCommon.ush:97-144(节选)
if (LightData.ShadowedBits)
{
half UsesStaticShadowMap = dot(LightData.ShadowMapChannelMask, half4(1, 1, 1, 1));
half StaticShadowing = lerp(1, dot(PrecomputedShadowFactors, LightData.ShadowMapChannelMask), UsesStaticShadowMap);

if (LightData.bRadialLight || SHADING_PATH_MOBILE)
{
OutShadow.SurfaceShadow = LightAttenuation.z * StaticShadowing; // 点/聚光:静态 × 每物体动态
}
else
{
float DynamicShadowFraction = DistanceFromCameraFade(SceneDepth, LightData);
// 方向光:近处 CSM、远处烘焙,按距离 lerp
OutShadow.SurfaceShadow = lerp(LightAttenuation.x, StaticShadowing, DynamicShadowFraction);
...
}
}

ShadowMapChannelMask 是 one-hot(LightRendering.cpp:660-664),dot 就是”取我那一通道”。
方向光那条 lerp 就是第 25 章讲的”阴影双轨”。


30.8 提案分析:把 Stationary 阴影合并,压进 LQ 的 A 通道

⚠️ 本节(30.8.1~30.8.6)在 1.9 版被更正过:它把「品质」和「存哪儿」两个正交问题混在了一起。
请先读末尾的 30.8.7 更正与补全,再回来看这里的推导。

现在回到那个具体问题:能不能让 Stationary 的阴影达到 Static 的品质,
同时不走”每灯一个通道”,而是全部压进 LQ 的 A 通道?

先把这句话拆成三个可验证的子命题:

30.8.1 子命题一:什么叫”Static 灯的阴影品质”?

这是最需要先澄清的。三种阴影模型其实是三个不同的东西:

每纹素存什么 灯数上限 边缘精度 可开关
Static(烘进系数) 已经乘过可见度的辐照度(一个 float3) 无限 = lightmap 分辨率(台阶) ❌
Stationary · SDF 到过渡的距离 + penumbra 4 通道(可复用) 亚纹素(距离场重建) ✅
Stationary · 面积阴影 / GPU LM sqrt(V) 4 通道(可复用) = lightmap 分辨率(台阶) ✅

关键洞察:所谓”Static 的品质”,本质特征是”把所有灯的可见度在烘焙期合成一个值“——
Static 灯的 AddWeighted 累加,物理上就是把每盏灯的 L_i · V_i 加成一个总和,
根本不存在”第 3 盏灯的阴影”这个可寻址的量。

所以”让 Stationary 达到 Static 的品质”,准确的翻译是:

放弃”每盏灯一个独立可见度”,改成”所有灯合成一个可见度”,以此换取灯数无上限 + 省掉那张 4 B/纹素的图。

这句话里,”品质”二字其实是中性的——它换来的是自由度(灯数),代价是可分辨性(单灯)。
Static 灯自己也是付了这个代价的(第 29 章整章都在讲这件事)。
所以你不能指望”既拿到 Static 的无限灯数,又保留 Stationary 的逐灯可开关”——
这两者在同一份数据里是互斥的。

30.8.2 子命题二:塞进 LQ 的 A 通道,账算得过来吗?

LQ 系数图是 W×2H:上半区 = 系数 2(LogRGB 颜色),下半区 = 系数 3(方向 SH),
每一半都有一个 8bit 的 A 通道。解码端确认它俩都没人用:

1
2
3
4
// LightmapCommon.ush:99-101
#if USE_LM_DIRECTIONALITY
// Alpha doesn't matter, will scaled by zero
float4 SH = Lightmap1 * GetLightmapData(LightmapDataIndex).LightMapScale[1] + GetLightmapData(LightmapDataIndex).LightMapAdd[1];

(颜色那半的 alpha 同理,LQ 只有 rgb 三个量。)

所以 LQ 里其实有两个免费的 8bit 槽位——这是好消息。坏消息在当前格式:

1
2
3
4
5
6
// LightMap.cpp:1638-1642
FTextureFormatSettings FormatSettings;
FormatSettings.SRGB = false;
FormatSettings.CompressionNoAlpha = CoefficientIndex >= LQ_LIGHTMAP_COEF_INDEX; // ← LQ 恒为 true
FormatSettings.CompressionNone = !GCompressLightmaps;
Texture->SetLayerFormatSettings(LayerIndex, FormatSettings);

CompressionNoAlpha = true → BC1(4bpp,无 alpha)。要用 alpha 就得改 false → BC3(8bpp)。
LQ 系数图从 W×2H × 4bpp = 1.0 B 涨到 W×2H × 8bpp = 2.0 B——翻倍。

四种做法的完整账(每 lightmap 纹素,未计 mip):

方案 LQ 系数图 阴影 合计 额外采样 分组能力
现状 · 1 通道 1.0 1.0(G8 不压缩) 2.0 B +1 1 盏
现状 · 2~4 通道 1.0 4.0(BGRA8 不压缩) 5.0 B +1 ≤4 盏(可复用)
A:塞进 LQ 的两个 alpha(BC1→BC3) 2.0 0 2.0 B 0 2 组
B:独立 G8/BC4 阴影层 1.0 0.5(BC4) 1.5 B +1 1 组
C:借用 AOMask 层 1.0 0(复用已存在) 1.0 B 0 1 组(与材质 AO mask 冲突,不推荐)

三条读数:

  1. 只在”现状是 2~4 通道”时才划算:5.0 → 2.0(A)或 1.5(B)。
    如果场景本来就只用到 1 个通道(2.0 B),方案 A 打平、且丢掉了逐灯能力——纯亏。
  2. 方案 B 比方案 A 更省:A 是”为了拿 1 个字节的 alpha,把整张图从 4bpp 抬到 8bpp”,
    连另一次采样也一起翻倍了。算采样带宽更明显:A = 2 次采样 × 1 B = 2 B;
    B = 2 次 × 0.5 B + 1 次 × 0.5 B = 1.5 B。
  3. **方案 A 的真正优势不是字节,是”零额外采样 + 不新增贴图槽”**——
    移动端采样器紧张、或者你就是想少一张图时,它才有意义。

平台修正(重要):上面全是按 BC(桌面) 算的。
移动端走 ASTC(第 9 章:”cook 后重映射 ASTC”),而 ASTC 的块大小与通道数无关——
4×4 块恒为 128 bit = 1 B/纹素,RGB 和 RGBA 一样。
换句话说:在移动端 ASTC 上,”多带一个 alpha”是免费的。
这一条直接把方案 A 在移动端的结论从”翻倍”翻转为”纯赚“。
反过来,第 27 章那句”LQ 系数图能压到 4bpp”在纯移动端语境下也要打个折——
得先确认目标平台的实际 cook 格式。

30.8.3 子命题三:合并成一个值,公式怎么写、会失真多少?

这是”能不能做”之外的”做出来对不对”。

真值(逐灯独立):

1
直接光 = Σ_i  L_i · NdotL_i · V_i

合并方案能拿到的(只有一个标量 V̄):

1
直接光 ≈ (Σ_i L_i · NdotL_i) · V̄

要让这两式相等,V̄ 只能是按亮度加权的平均:

1
V̄ = Σ_i (w_i · V_i) / Σ_i w_i        ,  w_i = |L_i| · NdotL_i

问题立刻来了:**w_i 用什么时刻的 L_i?**

  • 用烘焙时的 L_i → 权重烘死。运行时把 3 号灯调亮 10 倍,它的阴影在 V̄ 里还是只占原来那点权重
    → **”最亮的灯投不出最深的影”**。
  • 用运行时的 L_i → V̄ 得每帧算,那就得把 V_i 都存下来——回到多通道了。

所以合并方案必然要烘死权重,也就必然在”改亮度/改颜色”这件事上失真。
这是设计层面的硬约束,不是实现能绕过去的。

开关灯更严重:关掉灯 j,真值应该把 w_j·V_j 从分子分母里一起删掉;
但你只存了比值 A/B,删不掉。结果就是——

关掉一盏被遮挡的灯,它的阴影还留在墙上。

这正是第 29 章那道题的阴影版本。想要不失真,只能:

  • 分 2 组(用 LQ 上下半区的两个 alpha):把”同步开关的一组灯”合成一个 V̄,
    整组开关时阴影是对的,组内单灯开关仍然错;
  • 或者干脆接受”灯是固定布景”这个前提。

误差量级:加权平均在”各灯阴影区域不重叠”时误差最大。
极端例子:两盏等亮度的灯,A 照到但被挡(V=0),B 完全照到(V=1)。
真值 = 0.5·L;合并值 V̄ = 0.5 → 直接光 = 1.0·L·0.5 = 0.5·L ✅ 恰好相等。
再换个例子:A 很亮(w=9)但被挡,B 很暗(w=1)照到。
真值 = 1/10 · L_total;合并值 V̄ = 0.1 → L_total · 0.1 ✅ 也相等。

意外地,加权平均在这个模型下是精确的——因为真值就是
(Σ w_i V_i / Σ w_i) · Σ w_i。只要 NdotL_i 在运行时和烘焙时一致(静态几何下成立),
合并方案在”所有灯都开着”时与真值完全一致。误差只出现在”灯的亮度/开关发生变化”之后。

这个结论很重要:合并不是”画质打折”,是”动态性打折”。
如果你的场景是”灯布好就不动”,合并方案在数学上无损。

30.8.4 落地要改哪些地方

环节 文件 / 函数 改什么
① 决定要不要这张图 LightMap.cpp:1363 NeedsStaticShadowTexture() 加”合并模式”分支
② 合并计算 新增(或改 TextureMapping.cpp:1698-1722) 在 AddWeighted 之前,先按 w_i 累加 A = Σ w_i V_i、B = Σ w_i,存 A/B
③ 量化进 alpha LightMap.cpp:1751-1759 EncodeCoefficientTexture DestColor.A = A/B × 255(上/下两半各一个槽)
④ 放开 alpha 压缩 LightMap.cpp:1640 CompressionNoAlpha 改成按需(NeedsStaticShadowTexture() 为假时才 true)
⑤ mip / dilate LightMap.cpp GenerateLightmapMipsAndDilateColor alpha 要跟着 RGB 一起 dilate,否则 UV 边缘会漏
⑥ 运行时解码 LightmapCommon.ush:78 GetLightMapColorLQ 从 Lightmap0.a / Lightmap1.a 取回 V̄,输出给 PrecomputedShadowFactors
⑦ 灯的通道语义 LightComponent.cpp:1781 ReassignStationaryLightChannels 合并模式下不再需要通道分配(也就不可能再失败)
⑧ 策略宏 BasePassRendering.cpp:2183 / LightMapRendering.cpp:70 需要一个”LQ + 合并阴影”的新策略(现在 LQ 分支没有 DF 变体,见 30.7.1 避坑)

⑤ 是最容易翻车的一步:GenerateLightmapMipsAndDilateColor 现在的 dilate 逻辑是按 RGB + Coverage
做的,alpha 直接跟着走不一定对——阴影的 dilate 应该向外扩”保持在阴影内/外”,而不是插值出中间值,
否则 UV 岛边缘会出现一圈半影。

30.8.5 什么时候不该这么做

  1. 场景只用到 1~2 个通道:现状 2.0 B,方案 A 也是 2.0 B,白丢逐灯能力;
  2. 需要运行时开关/调亮单灯:这是 Stationary 存在的理由,别自废武功(第 29 章);
  3. 有大范围方向光:方向光的静态阴影要和 CSM 按距离 lerp(DeferredLightingCommon.ush:135),
    合并后方向光和局部光挤在同一个 V̄ 里,这条 lerp 就没法只对方向光做了;
  4. 低分辨率 lightmap + 依赖软阴影:丢掉距离场后,边缘精度退回纹素级,
    原本靠 SDF 撑着的”低分辨率也有平滑阴影”会消失。这一条在移动端尤其要验。

30.8.6 我的建议:分三步走,别一步到位

  1. 先做零风险的那一半:把 NumShadowChannelsUsed 从”整张图集取 max”
    改成”按图集分页/按区域判定”,或者干脆让打包时优先把多通道物体聚到同一张图集。
    30.6.3 那颗”老鼠屎”往往就是最大的浪费;
  2. 再评估能不能压缩:如果项目只用面积阴影 / GPU Lightmass(不依赖 SDF 重建),
    那张图存的其实是 sqrt(V) 而不是距离场,它对块压缩的敏感度远低于真距离场——
    改成 BC3(1 B/纹素,4 通道)或每通道一张 BC4(2 B/纹素)值得一试,
    5.0 B 直接降到 2.0~3.0 B,且零语义损失;
  3. 最后才考虑合并:且只在”确认灯布好就不动”的场景里做,
    平台是移动端 ASTC 时优先选方案 A(alpha 免费),桌面 BC 时优先选方案 B(独立 BC4 层)。

30.8.7 更正与补全:把「品质」和「存哪儿」拆开看(1.9 追补)

先认错。 30.8.1~30.8.6 把一个两问题当成了一个问题来分析。
「让 Stationary 的阴影达到 Static 的品质」和「不要每灯一个通道、压进 LQ 的 A」——
这两件事在源码里根本不在同一个地方发生,而且第一件引擎已经做完了。
下面三小节把事实补齐,并给出更正后的结论。

30.8.7.1 补一个 30.8 漏掉的决定性事实:Stationary 灯的直射光不在 lightmap 里

TextureMapping.cpp:1702-1722 是一个 if/else,两边做的事完全不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// TextureMapping.cpp:1694-1723(节选)
if (bIsMapped && ShadowFactor > 0.0f)
{
const float AdjustedShadowFactor = FMath::Pow(ShadowFactor, Light->ShadowExponent);
if (ShadowMapData) // ← 有阴影贴图 = Stationary
{
FShadowSample& CurrentShadowSample = (*ShadowMapData)(X,Y);
CurrentShadowSample.Visibility = AdjustedShadowFactor; // 只写可见度,一个字节都不进 lightmap
}
else // ← 没有阴影贴图 = Static
{
const FGatheredLightSample DirectLighting = CalculatePointLighting(...);
FGatheredLightMapSample& CurrentLightSample = LightMapData(X,Y);
CurrentLightSample.AddWeighted(DirectLighting, AdjustedShadowFactor); // 直射 × 可见度 折进辐照度
}
}

配合 :853-865(Static)与 :805-851(Stationary)两条分支一起看,结论非常干净:

ShadowMapData 直射光去哪了 lightmap 里装的是什么
Static(:855) NULL 烘进 lightmap(L·V 累加) 本灯直射 + 全部间接光
Stationary(:813/:844) 非 NULL 一个字节都不烘,留到运行时 只有间接光(本灯直射缺席)

三条必须记住的推论:

  1. lightmap 里没有 Stationary 灯的直射。所以它上面挂的可见度再怎么搬,都不会碰到间接光——
    30.8.3 里隐含担心的”把间接光一起压暗”根本不成立。这是本节能成立的地基。
  2. 也正因为直射不烘,Stationary 灯必须走延迟渲染。判据在
    LightSceneInfo.cpp:245-250:ShouldRenderLightViewIndependent() 要求
    !Proxy->HasStaticLighting() || !IsPrecomputedLightingValid()。
    而 HasStaticLighting() 与 HasStaticShadowing() 是两个判据:
    前者只有 Static 灯为真(”位置与参数在运行时都不变”),后者 Stationary 也是真
    (”有烘焙的直射阴影,但亮度和颜色仍可变”)——见 LightComponentBase.h:221-232 的注释。
    → Static 灯两判据皆真 → 直接不进延迟渲染(30.3 那个”零成本”的由来);
    Stationary 灯 HasStaticLighting() 为假 → 照常跑延迟光照。
  3. 于是一盏 Stationary 灯的最终画面 = lightmap(间接) + 延迟逐灯直射 × 烘焙可见度。
    可见度是乘在运行时那一半上的,不是乘在 lightmap 上的。

30.8.7.2 “Static 的品质”这半个问题,引擎已经答完了:勾 bUseAreaShadowsForStationaryLight

TextureMapping.cpp:811-836 —— 勾上 bUseAreaShadowsForStationaryLight 之后,
Stationary 灯走的是和 Static 灯完全同一个函数:

1
2
3
4
5
6
7
8
9
10
if (Light->LightFlags & GI_LIGHT_USE_AREA_SHADOWS_FOR_SEPARATE_SHADOW_FACTOR)
{
FShadowMapData2D* ShadowMapData = new FShadowMapData2D(...);
// ↓ 就是 Static 灯在 :859 调的那个函数,一模一样
CalculateDirectAreaLightingTextureMapping(..., LightMapData, ShadowMapData, ...);

// 只差最后一步:把可见度转成 SDF 编码,而不是折进 LightMapData
DestShadowSample.Distance = FMath::Sqrt(FMath::Clamp(SourceShadowSample.Visibility, 0.f, 1.f)); // :829
DestShadowSample.PenumbraSize = 1; // :830
}

同一批面积光蒙特卡洛采样:Static 灯把 L·V 加进 lightmap,Stationary 灯把 V 存进 shadow map。
半影的形状、随光源尺寸/遮挡距离变软的规律——这两条路是逐位等价的。

所以:“让 Stationary 拿到 Static 的阴影品质” **= 勾 bUseAreaShadowsForStationaryLight**。
不需要改任何架构,不需要放弃逐灯通道,代价只是半影尺寸被烘死(编码里 PenumbraSize 被写成 1)。

顺带把 30.5 那个坑解释圆了:GPU Lightmass 存 sqrt(V) 却写 PenumbraSize = 0
(LightmapEncoding.cpp:357),而解码端 saturate(D·InvUniformPenumbraSize + Bias) 需要
InvUniformPenumbraSize == 1 才能还原出 V——只有这个 flag 打开时 GetUniformPenumbraSize()
才返回 1.0
(DirectionalLightComponent.cpp:511-523)。CPU 侧 :830 直接写 1,所以 CPU 永远对。

那”压进 LQ 的 A”解决的其实是另一件事:不是品质,是寻址方式与字节数。

30.8.7.3 更正后的结论:分场景看,别一刀切

把问题重述准确之后,答案分三档:

① 单主光场景(一盏投影 Stationary 灯,典型:太阳 + 天光)——纯赚,而且零误差

这时压根不需要”合并”:alpha 里存的就是那盏灯自己的 V(用面积光路径烘 = Static 品质)。

  • 无合并 → 30.8.3 整节关于”权重烘死 / 关灯留影”的担忧全部消失;
  • 运行时公式一字不改:SurfaceShadow = lerp(CSM, V_alpha, DynamicShadowFraction);
  • 字节:shadowmap 4.0 B → 0;LQ 系数图 1.0 B → 2.0 B(BC1→BC3)。净省 3 B/纹素;
  • 移动端 ASTC:alpha 免费 → 净省 4 B/纹素,且少一次纹理采样。

这是唯一一个三头通吃的场景:品质↑、内存↓、采样↓。

② 多灯但只需要”一起开关”——分组

LQ 上下半区各有一个空闲 alpha(LightmapCommon.ush:100 的注释:”Alpha doesn’t matter”),
可以存 2 个 V̄。把”同步开关的一组灯”合成一个 V̄,整组开关时阴影正确,组内单灯开关仍错。

③ 多灯且需要逐灯控制——分层,别全合并

  • 主光(方向光/太阳)→ LQ 的 alpha:拿面积光品质 + 零通道占用;
  • 其余局部灯 → 保留 shadowmap 通道:它们数量通常不多,且更需要单独开关;
  • 这样既不碰 ReassignStationaryLightChannels 的 4 通道上限,又保住了局部灯的逐灯能力。

30.8.7.4 仍然要付的代价(不因上面的更正而消失)

  1. 逐灯不可分辨:多灯共用一个 V̄ 时,”关掉一盏被遮挡的灯,它的影子还留在墙上”(30.8.3)。
  2. 半影尺寸烘死:现在 InvUniformPenumbraSizes 是运行时缩放(LightmapCommon.ush:248-252),
    合并后 V 是最终值,改软硬只能重烘。(走面积光路径时本来也是最终值,此项影响有限。)
  3. 丢掉 SDF 的亚纹素重建:SDF 能在低分辨率 lightmap 上给出比纹素更细的边缘;
    换成标量后退回到纹素级台阶。移动端 + 低分辨率时这条最致命。
  4. bIsCompletelyOccluded 优化失效::1729-1733 现在会在”整张图全遮挡”时直接 delete ShadowMapData;
    合并后没有”单灯全遮挡”这个概念了,这张图再省不下来。
  5. dilate 语义不同:阴影的外扩应该”保持在阴影内/外”,不能像颜色那样插值出中间值(30.8.4 ⑤)。
  6. 只在 LQ 上可行:HQ 的 Lightmap0.w 装的是 LogL(LightmapCommon.ush:146),没有空位。
    所以这条路天然绑定 LQ——而桌面端 LQ 分支目前没有 LMP_DISTANCE_FIELD_SHADOWS_AND_LQ_LIGHTMAP
    变体(30.7.1 避坑),得先补一个”LQ + 合并阴影”的新策略。

30.8.7.5 一句话更正

“Stationary 拿 Static 的品质”和”不要每灯一个通道”是两个独立问题。
前者勾 bUseAreaShadowsForStationaryLight 即可,与存放方式无关,且逐位等价;
后者换的是内存与通道数,花掉的是逐灯可分辨性——
而由于 Stationary 的直射本来就在运行时算、不在 lightmap 里,
这个交换不会伤到间接光,在「每张 lightmap 只有一盏投影 Stationary 灯」时甚至是零误差纯赚。

至于「可见性」这个量本身的精确定义(以及合并时才会踩到的两个陷阱),见 30.11。


30.9 避坑清单(本章新增)

避坑 1|Static 灯没有阴影贴图,别去 ShadowMap 里找它。
TextureMapping.cpp:855 传的是 NULL,可见度在 :1721 就被 AddWeighted 乘进辐照度了。
“我要单独调 Static 灯的阴影”从数据层就不成立。

避坑 2|2 个通道就付 4 个通道的钱。 NumShadowChannelsUsed 是对整张图集取 max
(LightMap.cpp:2704),只要有一个纹素用到通道 1,整张 1024² 就从 G8 跳 BGRA8(1 B→4 B)。
排查 shadowmap 显存暴涨时,先查是不是某个角落有两盏灯重叠了。

避坑 3|GPU Lightmass 没实现 SDF。 Scene.cpp:233 的 // TODO: implement SDF 是官方留的。
它存的是 sqrt(V) + PenumbraSize = 0,必须配合 bUseAreaShadowsForStationaryLight = true
才能被正确解码(否则 InvUniformPenumbraSize 远大于 1,阴影被硬切成二值)。

避坑 4|桌面端关掉 r.HighQualityLightMaps 会丢掉 Stationary 静态阴影。
BasePassRendering.cpp:2201-2204 的 LQ 分支没有 LMP_DISTANCE_FIELD_SHADOWS_AND_LQ_LIGHTMAP,
会落到 LMP_LQ_LIGHTMAP。移动端要显式开 r.Mobile.AllowDistanceFieldShadows。

避坑 5|共享通道的灯被迫共用同一个 penumbra size。
ShadowMap.cpp:915 那行警告是认真的:”storing one penumbra size for the whole shadowmap
even though multiple lights can share a channel
“。两盏软硬差别很大的灯共用通道 0 时,
其中一盏的半影会不对。

避坑 6|全遮挡的 shadowmap 会被丢掉。 TextureMapping.cpp:2023 和 :1729 都有
MinUnoccludedFraction 判断——**”这盏灯完全照不到”是好事(省一张图),但”这盏灯照不到
是因为我摆错了”就表现为阴影凭空消失**。

避坑 7|通道分配失败是静默的性能悬崖。 ReassignStationaryLightChannels 失败时只写一条
PerformanceWarning(LightComponent.cpp:1931),画面照常(退回动态阴影),
但帧率会掉。烘焙后务必翻一遍 LightingResults 日志。

避坑 8|”合并进 alpha”在 BC 上不是免费的。 CompressionNoAlpha = true → BC1(4bpp);
要用 alpha 必须改 false → BC3(8bpp),整张 LQ 系数图翻倍。
只在 ASTC 上”多带一个 alpha”才真的不花钱。

30.10 动手实验室

  1. 证 Static 无 shadowmap:在一个只有 Static 灯的关卡烘焙,然后
    stat streaming / 内存报告里数 ShadowmapTexture 的数量——应为 0;
    再加一盏 Stationary 灯重烘,观察多出来的那张(LightMap.cpp:1876-1888);
  2. 看 4 B/纹素的跳变:让两盏 Stationary 灯的影响范围完全不重叠烘一次,
    再让它们轻微重叠烘一次,对比 shadowmap 纹理的格式与大小
    (G8 vs BGRA8,对照 LightMap.cpp:1851);
  3. 验解码公式:把 LightmapCommon.ush:252 的 ShadowFactors 输出成颜色
    (临时改 r.VisualizeLightmapTextures 或加一个 debug 视图),
    分别看 SDF 路径(InvPenumbra >> 1)与面积路径(InvPenumbra = 1)的差别;
  4. 踩一次通道分配失败:在 10 m² 的房间里摆 6 盏互相重叠的 Stationary 灯,
    烘焙后翻 LightingResults 日志,找到那条
    “Failed to allocate shadowmap channel“(LightComponent.cpp:1933);
  5. 验 GPU Lightmass 的 penumbra 坑:用 GPU Lightmass 烘一盏 Stationary 方向光,
    先不勾 bUseAreaShadowsForStationaryLight,观察阴影边缘是否变成硬切;
    勾上再烘一次对比(对照 30.5.1);
  6. 做一次字节账:按 30.6.4 的表,把你项目的实际配置(通道数 / HQ or LQ / 有没有 SkyOcclusion)
    代进去,算出”每 lightmap 纹素几字节”,再乘以图集面积,
    和 stat rhi 里看到的 lightmap 显存对照——看差多少倍 mip 系数;
  7. 验合并公式:写个小脚本,随机生成 5 盏灯的 w_i / V_i,
    比较”逐灯求和”与”加权平均后再乘”的结果——在所有灯都开时应逐位相等(30.8.3),
    然后关掉其中一盏,观察误差。

30.11 追补:「可见性」到底是什么——从 GPU Lightmass 的一次采样说起

承接 30.8.7:既然方案的核心是「按 Static 一样存可见性」,那就必须先把
可见性这个数到底是什么钉死。本节直接从 GPU Lightmass 的着色器逐行读。

至于「真要落地改哪几处代码」,见 30.12。

30.11.1 一句话定义

可见性 V = 从着色点看过去,光源表面(按立体角加权)没有被挡住的那部分占比。 值域 [0,1]。

它不是“在不在阴影里”这种二值判断,而是一个连续覆盖率:
半影里 V = 0.5 表示光源恰好被挡住一半,而不是”那里比较暗”。

30.11.2 GPU Lightmass 怎么算:128 次”往光源上扔一根针”

调度粒度是 **(纹素 × 样本)**,StationaryLightShadowSamples = 128(GPULightmassSettings.h:71)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// LightmapPathTracing.usf:1436-1482(节选)
UNROLL_N(2)
for (int HemisphereIndex = 0; HemisphereIndex < (bMaterialTwoSided ? 2 : 1); HemisphereIndex++)
{
float2 RandSample = float2(
Halton(StrongIntegerHash(Seed) + LightSampleIndexArray[TileIndex], 5),
Halton(StrongIntegerHash(Seed) + LightSampleIndexArray[TileIndex], 7));

float3 EffectiveWorldNormal = HemisphereIndex == 0 ? WorldNormal : -WorldNormal;

bool bRaySampleValid = GenerateOcclusionRay( // ← 往"光源表面上的随机一点"打
LightType, LightParameters,
TranslatedWorldPosition, EffectiveWorldNormal,
RandSample, /*out*/ Ray.Origin, /*out*/ Ray.Direction, /*out*/ Ray.TMin, /*out*/ Ray.TMax);

if (bRaySampleValid)
{
uint RayFlags = RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH; // ← any-hit,挡住即 0
FMinimalPayload MinimalPayload = TraceLightmapVisibilityRay(TLAS, RayFlags, Ray);
Visibility = max(Visibility, MinimalPayload.IsMiss() ? 1 : 0); // ← 单样本是 0/1
}
}

if (bAnyHemisphereRaySampleValid)
{
// 整数域累加:命中数 +1,样本数 +1
ShadowMask[TexelIndexInPool][ChannelIndex] = asfloat(asuint(ShadowMask[TexelIndexInPool][ChannelIndex]) + Visibility);
ShadowMaskSampleCount[TexelIndexInPool][ChannelIndex] = asfloat(++SampleCount);
}

三个必须记住的点:

  1. 光线方向不是”朝光源中心”,而是朝”光源表面上的一个随机点”。
    GenerateOcclusionRay(RayTracingOcclusionRGS.usf:85)按灯型分派:

    • 方向光 → GenerateDirectionalLightOcclusionRay(RayTracingDirectionalLight.ush:10):
      在 LightSourceAngle 张成的锥内采样(SourceRadius = sin(LightSourceAngle/2));
    • 点光 / 聚光 → GenerateSphereLightOcclusionRayWithSolidAngleSampling(RayTracingSphereLight.ush:36):
      SinThetaMax² = R² / d²(:67)——球体在该着色点张成的立体角锥内做均匀立体角采样,
      PDF 为 Result.w · saturate(dot(LightNormal, -LightDirection)) / d²(:31);
    • 矩形光 → GenerateRectLightOcclusionRay(RayTracingRectLight.ush:9)。
  2. 单条光线是二值的:RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH + IsMiss() → 非 0 即 1。
    软阴影不是靠单条光线”部分透过”实现的,而是靠多条光线打在光源的不同位置统计出来的。
    这条直接决定了后面 30.11.5 那个陷阱。

  3. 累加发生在整数域:asuint(...) + Visibility 是整数加法(避免浮点累加误差),
    SampleCount 单独记数;双面材质在两个半球之间取 max,一个样本仍只贡献一个 0/1。

最后一步归一化 + 编码,在 LightmapBufferClear.usf:66-72:

1
2
3
4
uint4 ShadowValue      = asuint(ShadowMask[TexelIndexInPool]);
uint4 SampleCountValue = asuint(ShadowMaskSampleCount[TexelIndexInPool]);
uint4 ValidityMask = saturate(SampleCountValue) * 2 - 1; // 有样本 → +1;无样本 → −1
StagingShadowMask[TexelIndexInStagingPool] = sqrt((float4)ShadowValue / max(uint4(1,1,1,1), SampleCountValue)) * ValidityMask;

V = ShadowValue / SampleCount,落盘时存的是 sqrt(V)。
回到 CPU 侧由 LightmapEncoding.cpp:351-359 量化成样本:

1
2
3
4
5
6
7
8
9
FQuantizedSignedDistanceFieldShadowSample ConvertToShadowSample(FLinearColor ShadowMask, int32 ChannelIndex)
{
FQuantizedSignedDistanceFieldShadowSample Sample;
// Sqrt is already done on GPU
Sample.Distance = FVector4f(ShadowMask)[ChannelIndex] * 255.0f;
Sample.Coverage = FVector4f(ShadowMask)[ChannelIndex] >= 0.0f ? 255 : 0;
Sample.PenumbraSize = 0;
return Sample;
}

30.11.3 CPU Lightmass 算的是同一个数

TextureMapping.cpp:1487-1535:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const FVector2f ShadowValue = CalculatePointAreaShadowing(...);   // X = 可见样本数, Y = 总样本数
if (ShadowValue.X > 0.0f)
{
if (ShadowValue.X < ShadowValue.Y) // 落在半影里
{
// Trace more shadow rays if we are in the penumbra
const TArray<FLightSurfaceSample>& PenumbraLightSurfaceSamples = Light->GetCachedSurfaceSamples(0, true);
const FVector2f ShadowValuePenumbra = CalculatePointAreaShadowing(..., PenumbraLightSurfaceSamples, ...);
// Linear combination of uniform and penumbra shadow samples
ShadowFactor = (ShadowValue.X + ShadowValuePenumbra.X) / (ShadowValue.Y + ShadowValuePenumbra.Y); // :1520
}
else
{
ShadowFactor = 1.0f; // 完全在阴影外
}
}
else
{
// The texel is completely in shadow, with an implicit shadow factor of 0.0f
}

V = X / Y——与 GPU LM 的 ShadowValue / SampleCount 定义逐位相同。
唯一差别是
采样策略
:CPU 会在半影区再取一套”偏向半影”的光源表面样本加密采样并线性合并
(:1503 的注释原话),GPU LM 则是固定 128 spp 一把梭、靠样本数硬吃噪声。

30.11.4 所以”按 Static 一样存可见性”这句话要小心

Static 灯其实并没有”存”可见性。
:1721 是 CurrentLightSample.AddWeighted(DirectLighting, AdjustedShadowFactor)——
V 当场被乘进辐照度,它作为一个独立的量立即消失了。
Static 灯之所以不需要”存”,是因为它的直射光此后再也不会被重新计算。

于是这句话有两种读法,结论完全相反:

读法 含义 结论
A:每盏灯各存各的 V(和 Static 用同样的算法,不一样的存放) V_L 用面积光蒙特卡洛算出来,存进该灯自己的通道 = 现状。勾 bUseAreaShadowsForStationaryLight 就是(30.8.7.2)
B:所有灯合成一个 V 塞进 alpha V̄ = Σ wᵢVᵢ / Σ wᵢ = 30.8.7.3 ③ 的合并方案,付掉逐灯可分辨性

一句话:V 天然是 (纹素, 灯) 的二元函数——V_L(texel)。
你不能问”这个纹素的可见性是多少”,只能问”这个纹素对这盏灯的可见性是多少”。
任何”合成一个值”的做法,本质上都是在回答一个物理上不存在的问题,误差也就从这里来。

30.11.5 补两个只在”合并”时才会遇到的实现陷阱

陷阱一:必须在线性域合并,不能在 sqrt 域合并。

GPU LM 落盘的是 sqrt(V)(LightmapBufferClear.usf:71),运行时解码要平方
(LightmapCommon.ush:253 的 ShadowFactors * ShadowFactors)。
如果合并时图省事,直接拿每盏灯的 sqrt(V_i) 做加权平均:

1
√(V̄)  ≠  Σ wᵢ·√(Vᵢ) / Σ wᵢ

由 Jensen 不等式(√ 是凹函数)E[√V] ≤ √(E[V]),解码端平方后会系统性偏亮——
半影区尤其明显,因为那里 V 的方差最大。

正确顺序:先在线性域算 V̄ = Σ wᵢVᵢ / Σ wᵢ,最后一步再 sqrt。
这也意味着合并计算必须放在量化之前的那一层(LightmapEncoding.cpp 之前),不能事后在纹理上做。

陷阱二:Coverage 的语义要重写。

现在 Coverage 是个布尔量:
Sample.Coverage = (值 >= 0) ? 255 : 0(LightmapEncoding.cpp:356),
配合 ValidityMask = saturate(SampleCount) * 2 - 1(LightmapBufferClear.usf:69)——
有样本记 +1、无样本记 −1,靠符号区分”这盏灯到底有没有覆盖到这里”。

合并之后,”覆盖”不再是一个布尔量,而是”有多少盏灯的 V 参与了合成、权重和是多少“。
Coverage 必须改成参与度/权重和(或者额外存一个 Σwᵢ),否则 mip 生成与 dilate 阶段
会把”没有灯覆盖”的区域当成”完全照亮”,UV 岛边缘会漏出一圈亮边。


30.12 追补:如果真要落地——改哪几处代码

承接 30.11。前面几节回答了「是什么」「值不值」「有什么坑」,
这一节回答最后一个问题:真要在自己的分支上改,动哪些地方、按什么顺序动。
所有落点都在本仓库验证过。

30.12.1 先看清楚:现在有三条分支,开关只有一处

TextureMapping.cpp:783 遍历这张 mapping 的所有相关灯,每盏灯按 :798-865 三选一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// TextureMapping.cpp:798-865(结构节选)
if (ShadowSettings.bUseZeroAreaLightmapSpaceFilteredLights)
{
CalculateDirectLightingTextureMappingFiltered(...); // ① 零面积 + 纹理空间滤波
}
else if (!Light->UseStaticLighting() // ② Stationary:开独立通道
&& (Light->LightFlags & GI_LIGHT_CASTSHADOWS)
&& (Light->LightFlags & GI_LIGHT_CASTSTATICSHADOWS)
&& (Light->LightFlags & GI_LIGHT_STORE_SEPARATE_SHADOW_FACTOR)
&& ShadowSettings.bAllowSignedDistanceFieldShadows)
{
FShadowMapData2D* ShadowMapData = new FShadowMapData2D(...); // :813
CalculateDirectAreaLightingTextureMapping(..., ShadowMapData, ...);
// → 再转成 FSignedDistanceFieldShadowMapData2D :818-834
}
else
{
FShadowMapData2D* ShadowMapData = NULL; // ③ Static:不存,折进辐照度
CalculateDirectAreaLightingTextureMapping(..., NULL, ...); // :855-859
}

而 GI_LIGHT_STORE_SEPARATE_SHADOW_FACTOR(SceneExport.h:886,值 0x20)是在导出场景时打的标记(Lightmass.cpp:243-252):

1
2
3
4
5
6
7
8
9
10
// Editor/UnrealEd/Private/Lightmass/Lightmass.cpp:243-252
if (In->HasStaticLighting()) // Static
{
Out.LightFlags |= Lightmass::GI_LIGHT_HASSTATICLIGHTING;
}
else if (In->HasStaticShadowing()) // Stationary ← 只有这一档拿到 STORE_SEPARATE
{
Out.LightFlags |= Lightmass::GI_LIGHT_STORE_SEPARATE_SHADOW_FACTOR;
Out.LightFlags |= Lightmass::GI_LIGHT_HASSTATICSHADOWING;
}

这就是「要不要给这盏灯单独开一个阴影通道」的总闸。
想让它不占通道,最小改动就是在这里把那个 |= 换成一个新开关。

30.12.2 第一个坑:别让它直接掉进 Static 那条路

把 GI_LIGHT_STORE_SEPARATE_SHADOW_FACTOR 去掉,这盏灯就会掉进 :855 的 else,
然后在 :1721 被 CurrentLightSample.AddWeighted(DirectLighting, AdjustedShadowFactor)
折进辐照度。

问题在于:Stationary 灯的 HasStaticLighting() 为假,
ShouldRenderLightViewIndependent(LightSceneInfo.cpp:245-250)仍为真——
运行时还要再算一遍直射光。于是同一份直射光被算两次,整个房间亮一倍。

结论:不能复用分支 ③。必须在 :1702 的 if/else 里加第三支。

30.12.3 新增第三支:既不写通道,也不折进辐照度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// TextureMapping.cpp:1702(改造后)
if (ShadowMapData) // ① 现状:逐灯独立通道
{
FShadowSample& CurrentShadowSample = (*ShadowMapData)(X,Y);
CurrentShadowSample.Visibility = AdjustedShadowFactor;
}
else if (Light->UseStaticLighting()) // ② Static:折进辐照度(现状)
{
FGatheredLightMapSample& CurrentLightSample = LightMapData(X,Y);
CurrentLightSample.AddWeighted(DirectLighting, AdjustedShadowFactor);
}
else if (bMergeVisibilityIntoLightmapAlpha) // ③ 新增:累进一张合并图
{
const FGatheredLightSample DirectLighting = CalculatePointLighting(...); // 只为取权重
MergedVisibility(X,Y).Accumulate(AdjustedShadowFactor, GetLuma(DirectLighting));
}

第三支要注意两点:

  • AdjustedShadowFactor 已经过了 ShadowExponent(:1698),
    这是每盏灯的手工软硬旋钮,必须在合并
    之前
    就应用完——否则它会被平均掉。
  • 权重 wᵢ 只能烘死。物理上正确的权重是这盏灯在该纹素上的未遮挡直射辐照度亮度
    (因为最终 V̄ 乘的是「运行时实时算出来的各灯直射光之和」)。
    代价是第三支也得调一次 CalculatePointLighting(:1718)——
    **但只取它的亮度当权重,绝不 AddWeighted**。

30.12.4 合并点就在 :707 那两张 TMap

1
2
3
// TextureMapping.cpp:707-708
TMap<const FLight*, FShadowMapData2D*> ShadowMaps;
TMap<const FLight*, FSignedDistanceFieldShadowMapData2D*> SignedDistanceFieldShadowMaps;

这是同一张 mapping 上所有灯的阴影集合——
全仓库只有这一处同时握着所有灯的可见度图,所以合并必须发生在这里(或之后)、量化之前。

30.12.5 量化顺序:先平方回线性域,再合并,最后才 sqrt

FSignedDistanceFieldShadowSample 里存的是已经 sqrt 过的值:
Distance = sqrt(V) * 255(LightmapEncoding.cpp:355;GPU LM 侧同样是 sqrt 域)。

两条路都对,但顺序不能错:

时机 正确做法
量化前合并(推荐) 直接对 float 的 Visibility 加权 → 最后一步 sqrt
量化后合并(省事) 先把 D/255 平方回线性域 → 加权平均 → 再 sqrt → 写回 8 bit

后一条不会多损失精度(8 bit 量化本来就已经发生了),
但绝不能跳过平方直接对 Distance 做加权平均——那就是 30.11.5 的 Jensen 陷阱。

30.12.6 存储端:CompressionNoAlpha 必须关掉

LQ 的两张系数图今天各有一个被废掉的 alpha 位:

  • LightmapCommon.ush:91 只读 Lightmap0.rgb;
  • :100-101 那行注释写得很直白:// Alpha doesn't matter, will scaled by zero
    (LightMapScale[1].w == 0,Lightmap1.a 被乘 0)。

而编码端是照抄的:LightMap.cpp:1754 DestColor.A = SourceCoefficients.Coefficients[CoefficientIndex][3];
——值抄进去了,但压缩时被扔掉:

1
2
// LightMap.cpp:1640
FormatSettings.CompressionNoAlpha = CoefficientIndex >= LQ_LIGHTMAP_COEF_INDEX;

改法:LQ 的这一层要把 CompressionNoAlpha 设成 false,
让 Lightmap0 真正带 alpha → 格式从无 alpha 的 BC1 变 BC3,
或另开一层 BC4(+0.5 B/纹素)。这正好对应 30.8 账本里的 5.0 B → 2.0 B / 1.5 B。

顺带:合并之后 NumShadowChannelsUsed 归零,
LightMap.cpp:1851 的 NumShadowChannelsUsed == 1 ? TSF_G8 : TSF_BGRA8
与 EncodeShadowMapTexture(:1588,CompressionNone = true 硬编码 :1596)整层都不该再生成——
要显式跳过,否则会白留一张 4 B/纹素的空图。

30.12.7 运行时:ShadowMapChannelMask 的 one-hot 语义怎么办

现状是「4 通道里挑一个」:

  • LightRendering.cpp:660-664 由 Proxy->GetShadowMapChannel()(:649)生成 one-hot 掩码;
    INDEX_NONE → 全零(:655-656);
  • LightmapCommon.ush:230-291 采样 StaticShadowTexture、SDF 解码(:248-253,含 * ShadowFactors * ShadowFactors)、乘 StaticShadowMapMasks;
  • DeferredLightingCommon.ush:110-111:UsesStaticShadowMap = dot(mask, 1),
    StaticShadowing = lerp(1, dot(PrecomputedShadowFactors, mask), UsesStaticShadowMap)。

合并后只剩一个标量 V。两种改法:

改法 做法 改动面
A(推荐):广播成 half4 GetPrecomputedShadowMasks 改从 lightmap 的 alpha 取 V,返回 half4(V,V,V,V);dot 仍能挑对通道 只改这一处,GetShadowTermsBase 一行不动
B:把掩码语义改成「有无静态阴影」 mask 从 one-hot 变布尔 要同时改 LightSceneInfo.cpp:415 的 packed bit、LightRendering.cpp:660 与所有消费 shader,面大且难回退

改法 A 有一个必须同步的坑:STATICLIGHTING_TEXTUREMASK(ShaderMaterial.h:78,
由 LightMapRendering.cpp:72 置位)一旦不再置位,
ShaderMaterialDerivedHelpers.cpp:54 会把 WRITES_PRECSHADOWFACTOR_ZERO 算成 1,
GBuffer 里那份预计算阴影因子就写 0——配合非全零的 mask,
dot(0, mask) = 0 → 整个场景全黑。

所以这里是「三处必须一起改」:LightmapCommon.ush 的取数路径、
ShaderMaterialDerivedHelpers.cpp:54 的 WRITES_PRECSHADOWFACTOR_ZERO 判据
(以及它的 shader 镜像 BasePassCommon.ush:48-52)、以及 STATICLIGHTING_TEXTUREMASK 的置位条件。
源码里那句 // Note: WRITES_PRECSHADOWFACTOR_ZERO have to match the logic here
(LightmapCommon.ush:232)说的就是这件事。

移动端走的是另一条路:GetPrimaryPrecomputedShadowMask(LightmapCommon.ush:294-328)
有自己一份 SDF 解码(:308-312),必须同步改,否则移动端直接全黑。

30.12.8 还有 4 处容易漏

  1. bIsCompletelyOccluded 的判据要跨灯(TextureMapping.cpp:1729)。
    现在是「这一盏灯把整张图全遮住 → 删掉这张 ShadowMapData」。
    合并后必须改成「所有灯加起来全遮住」,否则一张只有单灯全遮的图会被整张丢掉。
    NumMappedTexels / NumUnoccludedTexels(:1693 / :1696)同理要跨灯统计。
  2. Coverage 改成权重和(LightMap.cpp:1767 DestCoverage = SourceCoefficients.Coverage / 2)。
    它现在是个布尔,被 GenerateLightmapMipsAndDilateColor(:1808)用来做 dilate 与 mip。
    不改的话 UV 岛边缘会被 dilate 成「完全照亮」,漏出一圈亮边(30.11.5 陷阱二)。
  3. GPU Lightmass 是逐灯 dispatch 的(LightmapPathTracing.usf:1396-1484,
    每次往 ShadowMask[...][ChannelIndex] 累加)。合并后所有灯要累加进同一个 ChannelIndex,
    同时必须复核 dispatch 次数与 StationaryLightShadowSamples = 128
    (GPULightmassSettings.h:71)——别让总光线数被灯数放大。
    另外 Scene.cpp:222-238 的 AddLightToLightmap 那句 // TODO: implement SDF(:233)依然没实现,
    所以 GPU LM 侧的半影品质本来就与 CPU 不同,合并后这个差异会被平均进同一个值。
  4. ShadowExponent 与「逐灯可分辨性」一起消失(:1698)。
    合并后你没法再对某一盏灯单独调软硬,也没法在运行时单独关掉某一盏灯的阴影——
    这是这个方案真正的代价,不是 bug。

30.12.9 回归验证清单

# 检查项 期望
1 场景只有一盏投影 Stationary 灯 与改造前逐像素一致(这是唯一能拿到零误差的场景)
2 两盏影响范围重叠 只有重叠区有差异,非重叠区仍应逐像素一致
3 半影区 对比一份「在 sqrt 域合并」的错误实现,确认没有系统性偏亮
4 UV 岛边缘 + 低 mip 不能出现亮圈(这是 Coverage 没改好的特征)
5 移动端(ES3.1 / Vulkan) 同步改 GetPrimaryPrecomputedShadowMask 后不应全黑
6 关掉其中一盏 Stationary 灯 阴影不会跟着消失——这是语义代价,要提前跟美术讲清楚
7 显存统计 shadowmap 那一层应彻底消失,LQ 层字节数应上升
8 有 5+ 盏重叠 Stationary 灯的图集 不再出现「通道不够」的日志(ReassignStationaryLightChannels)

附录 A 源码地图

全部路径基于本仓库快照(2026-08-26,HEAD 6cea9bd20f8f),A.3~A.6 部分于 2026-09-10 复核。行号只用于定位;代码演进后以函数名为准。
阅读顺序建议:先 GPULightmassSettings.cpp(烘焙入口)→ LightMap.cpp(装箱/编码)→ LightmapCommon.ush(解码)→ 按专题深入。

A.1 GPU Lightmass 插件(Plugins/Experimental/GPULightmass/)

文件 关键内容
Source/GPULightmass/Private/GPULightmassSettings.cpp Launch :195(烘焙触发链入口)、UGPULightmassSubsystem
Source/GPULightmass/Public/GPULightmassSettings.h UGPULightmassSettings :33(GISamples=512/StationaryLightShadowSamples=128/bUseIrradianceCaching=true/IrradianceCacheQuality=128/TilePassesInSlowMode=1/FullSpeedMode=8/LightmapTilePoolSize=55/bCompressLightmaps)
Source/GPULightmass/Private/GPULightmass.cpp FGPULightmass :15(场景注册)、GetPrimitiveMeshMapBuildData :278
Source/GPULightmass/Private/LightmapRenderer.cpp FLightmapRenderer(4032 行,逐 tile 路径追踪、IrradianceCaching 组合 :230/:1287-1305)
Source/GPULightmass/Private/LightmapEncoding.cpp QuantizeLightSamples :35、LogScale=11.5/LogBlackPoint :44-47
Source/GPULightmass/Private/LightmapTilePool.cpp FLightmapTilePoolGPU :111(FreeHeap 线性分配)
Source/GPULightmass/Private/LightmapStorage.h FLightmap :66、GetPaddedSizeInTiles :73-79
Source/GPULightmass/Private/IrradianceCaching.h/.cpp FIrradianceCache(辐照度缓存,上限 4194304 条)
Source/GPULightmass/Private/Scene/StaticMesh.cpp AllocateLightmaps :34-82(逐 LOD 减半下限 32)
Source/GPULightmass/Private/Scene/InstancedStaticMesh.cpp ISM 打包 :108-119(宽×ceil(sqrt(N)))
Source/GPULightmass/Private/Scene/Landscape.cpp Landscape 烘焙(复用 GetTerrainExpandPatchCount)
Source/GPULightmassEditor/Private/GPULightmassEditorModule.cpp GPULM.BuildLighting 命令 :44、r.RayTracing 判定 :110

A.2 导入器与编辑器

文件 关键内容
Editor/UnrealEd/Private/Fbx/FbxMaterialImport.cpp 材质属性映射 :817-834(sDiffuse→BaseColor 等)
Editor/UnrealEd/Private/Fbx/FbxStaticMeshImport.cpp LightMapUV 命名识别 :400-403、默认 64/Index=1 :1865-1867、DstLightmapIndex=FirstOpenUVChannel :2065-2069、EnforceLightmapRestrictions :2395
Plugins/Interchange/Runtime/Source/Import/Private/Gltf/ InterchangeGltfTranslator.cpp(GLTF 导入,本 fork 存在)
Plugins/Interchange/Runtime/Source/Import/Private/InterchangeImportModule.cpp UInterchangeGLTFTranslator 注册 :111
Plugins/Enterprise/DatasmithImporter/Private/UVTools/ UUVGenerationFlattenMapping::GenerateUVs :1218、SetupGeneratedLightmapUVResolution :48-69
Plugins/Enterprise/DatasmithImporter/Private/DatasmithStaticMeshImporter.cpp GetLightmapSize :58-70
Plugins/Enterprise/DatasmithImporter/Private/DatasmithLightImporter.cpp 灯光单位映射(Candelas/Lumens/EV/Unitless)
Editor/LandscapeEditor/Private/LandscapeImportHelper.cpp Heightmap 导入(GetHeightmapImportDescriptor 等)
Classes/Components/LocalLightComponent.h IntensityUnits(cd/流明注释)
Private/Components/LocalLightComponent.cpp GetUnitsConversionFactor(”UE unit is in centimeters…100*100”)
Classes/Engine/RendererSettings.h DefaultLightUnits(r.DefaultFeature.LightUnits)
GeometryProcessingInterfaces/Public/MeshAutoUV.h IGeometryProcessing_MeshAutoUV :17-72(PatchBuilder/UVAtlas/XAtlas)
StaticMeshEditorSubsystem.cpp SetGenerateLightmapUVs :1970

A.3 渲染管线消费链(第 23 章新增)

文件 关键内容
Shaders/Private/LocalVertexFactory.ush 顶点侧 UV 变换 :867-899(GPUScene 取 ScaleBias :881、实例偏移 :883、LOD 偏移 :878)
Shaders/Private/LocalVertexFactoryCommon.ush 插值器 :39/:47;GetLightMapCoordinates :118-121(×0.5 / +0.5)
Shaders/Private/Nanite/NaniteVertexFactory.ush nointerpolation UV + 手工导数 :53-55 / :134-146
Shaders/Private/LightmapData.ush FLightmapSceneData :16-25、LIGHTMAP_SCENE_DATA_STRIDE 15 :48
Source/Runtime/Engine/Public/LightmapUniformShaderParameters.h FPrecomputedLightingUniformParameters、DataStrideInFloat4s = 15 :37-38
Source/Runtime/Engine/Private/LightmapUniformShaderParameters.cpp Setup :19(15 float4 布局 static_assert :21)
Source/Runtime/Renderer/Private/GPUScene.cpp lightmap 数据缓冲 :1020 / uploader :1245 / 逐 LCI 填充 :1406
Source/Runtime/Renderer/Private/LightMapRendering.cpp SetupLCIUniformBuffers :312、策略分发 :439、MAX_NUM_LIGHTMAP_COEF :486、VS 绑定 :545、PS 绑定 :562
Source/Runtime/Renderer/Private/LightMapRendering.h LightmapResourceCluster 绑定 :376 / 字段 :388
Source/Runtime/Engine/Private/SceneManagement.cpp 采样器 :1549(VT:aniso4 + Clamp)/ :1564(非 VT:贴图自带)
Shaders/Private/LightmapCommon.ush VT 撤销打包 :60-64;LQ :78/:86-87;HQ :132/:142-143/:146-159/:166/:187;SkyOcclusion :202-204;AOMaterialMask :218-220;静态阴影 :230/:245/:306
Shaders/Private/BasePassPixelShader.usf 入口 :1359、全彩落点 :1377、LightAccumulator :2350、GBuffer 亮度 :2368/:2379
Shaders/Private/DeferredShadingCommon.ush Encode/Decode :221-234、字段 :400、旧编码 :659/:1100-1101、解码 :791/:1196-1204
Shaders/Private/ReflectionEnvironmentComposite.ush 反射混合权重 :252-253(GBuffer 亮度的真实消费者)
Shaders/Private/DiffuseIndirectComposite.usf Lumen :516、SSGI :533、最终公式 :579/:631(不含 lightmap)
Source/Runtime/Renderer/Private/IndirectLightRendering.cpp RenderDiffuseIndirectAndAmbientOcclusion :1092、ShouldSkipView :1119
Source/Runtime/Renderer/Private/DeferredShadingRenderer.cpp BasePass :3074、合成(前):3444、RenderLights :3482、合成(后):3533、反射天光 :3543
Shaders/Private/MobileBasePassPixelShader.usf 移动端入口 :229、LQ 采样 :242、亮度 :298
Shaders/Private/MobileLightingCommon.ush 移动 IBL 混合权重 :432/:459
Shaders/Private/MobileDeferredShading.usf 移动延迟取回亮度 :132
Shaders/Private/MegaLights/MegaLightsMaterial.ush 重置 IndirectIrradiance :205
Shaders/Private/MeshDecals.usf / DeferredDecal.usf Decal 清零 IndirectIrradiance :230 / :224/:275
Source/Runtime/Engine/Private/LightMap.cpp CoordinateScale/Bias 计算 :717-723
Source/Runtime/RenderCore/Private/ShaderMaterialDerivedHelpers.cpp NEEDS_LIGHTMAP_COORDINATE :40

A.4 压缩与动态光照(第 24 章新增)

文件 关键内容
Source/Runtime/Engine/Private/LightMap.cpp HQ/LQ 系数格式 :1636-1642;阴影贴图强制不压缩 :1636;SkyOcclusion :1440;AOMaterialMask TC_Alpha(BC4) :1518-1521;LODGroup = TEXTUREGROUP_Lightmap :146/:1860;NeedsAOMaterialMaskTexture :588;VT CVar :96/:104
Source/Editor/UnrealEd/Private/StaticLightingSystem/StaticLightingSystem.cpp bCompressLightmaps 读取与 WorldSettings 与运算 :704-706
Source/Runtime/Engine/Classes/GameFramework/WorldSettings.h bCompressLightmaps :151/225(默认 true)
Source/Runtime/Engine/Private/Streaming/StreamingManagerTexture.cpp LightmapStreamingFactor 读取 :170、运行时命令 :2558
Config/BaseDeviceProfiles.ini TEXTUREGROUP_Lightmap 默认 LOD 配置 :201
Source/Runtime/Renderer/Private/SceneRendering.cpp IndirectLightingColorScale :2046、PrecomputedIndirectLightingColorScale :2050、Lumen 开启时置零 :2052-2057
Shaders/Private/BasePassPixelShader.usf 染色落点 :736;Stationary 天光用烘焙 BentNormal :487
Shaders/Private/ReflectionEnvironmentShared.ush ShouldSkyLightApplyPrecomputedBentNormalShadowing :68
Shaders/Private/DeferredLightingCommon.ush Stationary 灯取静态阴影通道 :111
Source/Runtime/Engine/Classes/Engine/Level.h bIsLightingScenario :575
Source/Runtime/Engine/Private/LevelStreaming.cpp 关卡加载/卸载传入 bIsLightingScenario :1105/:1123
Source/Runtime/Renderer/Private/RendererScene.cpp OnLevelAddedToWorld/RemovedFromWorld :4707-4728
Source/Runtime/Engine/Classes/Components/SkyLightComponent.h bRealTimeCapture :108
Source/Runtime/Engine/Private/Components/SkyLightComponent.cpp r.SkyLight.RealTimeReflectionCapture :123-128
Source/Runtime/Renderer/Private/BasePassRendering.cpp HQ/LQ/无 lightmap 策略分派 :2160-2215

A.5 Mobility 判定与阴影双轨(第 25 章新增)

文件 关键内容
Source/Runtime/Engine/Classes/Engine/EngineTypes.h Mobility 枚举定义与注释 :4076-4099;bUseAreaShadowsForStationaryLight :2366-2372
Source/Runtime/Engine/Private/Components/LightComponent.cpp HasStaticLighting :293-296、**HasStaticShadowing :298-301**、Proxy 传递 :1694
Source/Runtime/Renderer/Private/LightSceneInfo.h FLightSceneInfoCompact 的 bStaticLighting/bCastDynamicShadow/bCastStaticShadow/bIsMovable :44-48
Source/Runtime/Renderer/Private/LightSceneInfo.cpp bStaticLighting = Proxy->HasStaticLighting() :50;静态/动态阴影通道掩码 :405-425
Source/Runtime/Renderer/Private/LightRendering.cpp ShadowMapChannelMask 组装(4 通道):650-666
Shaders/Private/DeferredLightingCommon.ush 静态阴影取值 :96-111;方向光按距离 lerp 双轨阴影 :135-137
Source/Runtime/Renderer/Private/RendererScene.cpp 移动端方向光挑选排除 Static :3645;lightmap 策略随静态阴影变化 :2254/:3655
Source/Runtime/Engine/Private/UnrealEngine.cpp “LIGHTING NEEDS TO BE REBUILT” 提示 :12662
Source/Runtime/Engine/Private/LevelActor.cpp UWorld::SetMapNeedsLightingFullyRebuilt :1857

A.6 编码与量化(第 26 章新增)

文件 关键内容
Plugins/Experimental/GPULightmass/Source/GPULightmass/Private/LightmapEncoding.cpp GetLUVW :7-30(亮度/色度分解)、刻度常量 :44-47、HQ 打包 :259-279(含残差 :261、sqrt 色度 :265-267)、LQ 打包 :281-297、Min-Max 归一化 :203-209、SH 常数项写死 :221-224、暗纹素抑制方向性 :101-110、SkyOcclusion/AOMask 的 sqrt 编码 :246-255、覆盖率 :244
Shaders/Private/LightmapCommon.ush HQ 解码 :132-189(残差 :149、exp2 :159、SH :166、成色 :187);LQ 解码 :78-122(Luminance(LogRGB) :93、黑点 :96、固定方向性 :115);黑点常量 :158(0.01858136 = 2^-5.75)
Source/Runtime/Engine/Private/LightMap.cpp 上下半区排布注释 :1675;ScaleVectors/AddVectors 写入 :1694-1705;系数纹理格式(SRGB=false :1639、CompressionNoAlpha :1640、CompressionNone = !GCompressLightmaps :1641);AOMask BC4 :1521
Source/Runtime/Engine/Public/LightmapUniformShaderParameters.h LightMapScale[2] / LightMapAdd[2](每组系数 4 通道各一套 Scale/Add)

A.7 SkyOcclusion 的布局与压缩(第 27 章新增)

文件 关键内容
Programs/UnrealLightmass/Private/Lighting/LightmapData.cpp SkyOcclusion 量化 :266-274(bent normal *0.5+0.5 :268、sqrt(length) :273-274)
Source/Runtime/Engine/Private/LightMap.cpp EncodeSkyOcclusionTexture :1434(格式设置 :1439-1443、写纹素 :1490-1493、mip 生成 :1502);三处 CompressionNoAlpha 差异(系数图 :1640 / SkyOcclusion :1441 / AOMask :1519);SkyOcclusionFormat = TSF_BGRA8 :1849;尺寸 W×H :1857(对照系数图 W×2H :1899);LODGroup :1860;**NeedsSkyOcclusionTexture :1363-1387**(bHasSkyShadowing :1380);VT 层格式 :1930-1932、VT 压缩开关 :1948-1950;r.VT.EnableLossyCompressLightmaps :111-114;r.IncludeNonVirtualTexturedLightmaps :103-106;GCompressLightmaps :50;AOMask TC_Alpha // BC4 :1521
Shaders/Private/LightmapCommon.ush GetSkyBentNormalAndOcclusion :198-210(VT 层号 3u :202、非 VT 采样 :204、rgb*2-1 :208、a*a :210);ScaleLightmapUV :22-33;UV 还原注释 :53;AOMask VT 层号 4u :218
Shaders/Private/BasePassPixelShader.usf 消费点 :484-490(ScaleLightmapUV(uv,(1,2)) :487、normalize 与注释 :488-490);SkyVisibility/BentNormalWeightFactor/GeometryTerm :508-519;AOMask 同样处理 :948
Source/Runtime/Engine/Public/LightMap.h SkyOcclusionTexture :328;FLightMapCoefficients::SkyOcclusion[4] :495;bHasSkyShadowing :519
Source/Runtime/Engine/Public/SceneManagement.h LQ_LIGHTMAP_COEF_INDEX = 2 :357
Source/Runtime/Engine/Private/Components/RuntimeVirtualTextureComponent.cpp CompressionNoAlpha ↔ PF_DXT1、CompressionForceAlpha ↔ PF_DXT5 :482-483(据此反推语义)
Config/BaseDeviceProfiles.ini TEXTUREGROUP_Lightmap 默认 LOD 配置 :201

A.8 体积光照图(第 28 章新增)

文件 关键内容
Shaders/Private/VolumetricLightmapShared.ush ComputeVolumetricLightmapBrickTextureUVs :25-34(clamp .99 :28、Indirection Load :30、PaddedBrickSize = BrickSize + 1 :32、砖内 frac 定位 :33);GetVolumetricLightmapSH1/2/3 :41 / :72 / :89;反归一化表 SHDenormalizationScales0/1 :61-65 / :101-105;GetVolumetricLightmapSkyBentNormal :128-132(*2-1);GetVolumetricLightmapDirectionalLightShadowing :134-137
Source/Runtime/Engine/Public/PrecomputedVolumetricLightmap.h FVolumetricLightmapBasicBrickDataLayers :80-86(9 层:AmbientVector + SHCoefficients[6] + SkyBentNormal + DirectionalLightShadowing);FVolumetricLightmapBrickData :88(GetMinimumVoxelSize :92);FPrecomputedVolumetricLightmapData :147(IndirectionTextureDimensions :203 / IndirectionTexture :204 / BrickSize :206 / BrickDataDimensions :207);CPUSubLevelIndirectionTable :230、CPUSubLevelBrickDataList :231;GPointFilteringThreshold = .001f :338;**FVolumetricLightmapBrickTextureSet :521;FVolumetricLightmapBrickAtlas :580**、GVolumetricLightmapBrickAtlas :620
Source/Runtime/Engine/Private/PrecomputedVolumetricLightmap.cpp GetMinimumVoxelSize :188-203(4 + 24 + 1 = 29;注释 “excluding SkyBentNormal because it is conditional” :198);3D 纹理创建 :30 / :41;CreateTargetTexture :526 / :541-557;砖集格式校验 :993-999
Source/Runtime/Engine/Classes/GameFramework/WorldSettings.h EVolumeLightingMethod :35-52(两条注释就是本章目录);VolumeLightingMethod :121-123;VolumetricLightmapDetailCellSize :153-159(”up to a factor of 8x”);VolumetricLightmapMaximumBrickMemoryMb :161-165;VolumetricLightmapLoadingCellSize :167-169;**VolumetricLightmapSphericalHarmonicSmoothing :171-178;bForceVolumetricLightmapsOnly :468-472;VolumetricLightmapLoadingRange :675-677;构造默认值 :210-229**(VLM_VolumetricLightmap :220 / 200 :226 / 30 :227 / 3200 :228 / .02f :229)
Source/Runtime/Engine/Classes/Lightmass/VolumetricLightmapDensityVolume.h AVolumetricLightmapDensityVolume :15、三档 mip 与三条示例 :19-31、**AllowedMipLevelRange :32-33**
Source/Runtime/Core/Public/Math/PackedVector.h FFloat3Packed :15-37(6/5 + 6/5 + 5/5 位域 = R11G11B10 的 CPU 镜像)
Source/Programs/UnrealLightmass/Private/Lighting/AdaptiveVolumetricLightmap.cpp FIrradianceBrickData::SetFromVolumeLightingSample :877-946(AmbientVector 独立存 :881、SH 基函数注释 :883-899、跨端一致性注释 :901、归一化比例表 :903-913、按环境项归一化 :917-931、SkyBentNormal *0.5+0.5 :936、DirectionalLightShadowing :939);**ShouldRefineVoxel :259-370**(重要体积判据 :261-267、密度体 mip 覆盖 :269-304、几何相交 :306、静态点/聚光细分 :317-345、Landscape 剔除 :348-370);砖布局与细分 :463-485 / :560-598
Source/Programs/UnrealLightmass/Public/SceneExport.h FVolumetricLightmapSettings :334-377(BrickSize 幂次与 padding 注释 :346-350、MaxRefinementLevels :352-353、MinBrickError RMSE 剔除 :363-364、**bCullBricksBelowLandscape :369-370**、LightBrightnessSubdivideThreshold :372-373、WindowingTargetLaplacian :375-376)
Source/Editor/UnrealEd/Private/Lightmass/ImportVolumetricLightmap.cpp 层格式定义 :1073-1082(AmbientVector PF_FloatR11G11B10 :1075、SkyBentNormal PF_B8G8R8A8 :1076、DirectionalLightShadowing PF_G8 :1077、SHCoefficients PF_B8G8R8A8 :1081);IndirectionTexture PF_R8G8B8A8_UINT :1115;ConvertBGRA8ToRGBA8ForLayer :972(调用 :1527 / :1531);BrickSize / PaddedBrickSize :239-240;TrimBricksByInterpolationError / TrimBricksForMemoryLimit :1085-1091;砖图集布局 :1093-1110;导入统计日志 :1543-1548
Source/Runtime/Renderer/Private/LightMapRendering.cpp InterpolateVolumetricLightmap :616-711(索引定位 :626、SampleIndirectionTextureWithSubLevel :636、砖 UV ComputeBrickTextureCoordinate :640、CPU 反归一化表 :649-666、R/B 交换注释 :662-663、SkyBentNormal 解包 :695-703);GetIndirectLightingCacheParameters :725
Shaders/Private/BasePassPixelShader.usf 砖 UV 计算 :1146(ComputeVolumetricLightmapBrickTextureUVs);SH 档位调用 :582(SH1)/ :597(SH2)/ :603(SH3);天光 bent normal :480;参数传递 :561 / :741 / :1143 / :1154 / :1359
Source/Runtime/Renderer/Private/VisualizeVolumetricLightmap.cpp r.VolumetricLightmap.VisualizationRadiusScale :30-36、r.VolumetricLightmap.VisualizationMinScreenFraction :38-44、FVisualizeVolumetricLightmapParameters :51
Source/Runtime/Renderer/Private/CapsuleShadowRendering.cpp FComputeLightDirectionFromVolumetricLightmapCS :116-148、VLM 派生光方向 :784-951
Source/Runtime/Renderer/Private/BasePassRendering.cpp FSelfShadowedVolumetricLightmapPolicy :215、FPrecomputedVolumetricLightmapLightingPolicy :218、bUseVolumetricLightmap 判据 :2057 / :2177
Source/Runtime/Renderer/Private/LightMapRendering.cpp(策略) FPrecomputedVolumetricLightmapLightingPolicy :124-129、FSelfShadowedVolumetricLightmapPolicy :384-495
Shaders/Private/CapsuleShadowShaders.usf ComputeLightDirectionFromVolumetricLightmapCS(供可动物体间接光方向)

A.9 单灯变更与光照缓存失效(第 29 章新增)

文件 关键内容
Source/Runtime/Engine/Classes/Components/SceneComponent.h AreDynamicDataChangesAllowed :1357-1366(bIgnoreStationary 默认 true;Static → false)
Source/Runtime/Engine/Private/Components/LightComponent.cpp 运行时 setter 全部被同一把锁:SetIntensity :1076-1087、SetIndirectLightingIntensity :1089-1107、SetVolumetricScatteringIntensity :1109-1127、SetLightColor :1130-1134、**SetLightFColor :1136-1154、SetTemperature :1157、SetAffectGlobalIllumination :124、SetAffectReflection :114;PostEditChangeProperty :775-875(失效排除表 :808-869、Visible 在排除列表里 :834、三条决定性注释 :866-869、InvalidateLightingCache() :871);InvalidateLightingCacheDetailed :1483-1526(UpdateLightGUIDs :1497、Stationary 通道重分配 :1506、Movable GUID 归零 :1522-1525);UpdateLightGUIDs :271-289;HasStaticLighting :292-294 / HasStaticShadowing :296-299;OnRegister 烘焙登记 :329-334;bAffectsWorld = true :439;IndirectLightingScale 传递 :1713;IsPrecomputedLightingValid :1561-1564**;PropagateLightingScenarioChange :1555
Source/Runtime/Engine/Classes/Components/LightComponentBase.h bAffectsWorld :52
Source/Runtime/Engine/Private/Light.cpp ALight::Destroyed :62-77(bAffectsWorld = false + InvalidateLightingCache)
Source/Runtime/Engine/Public/LightMap.h FLightMap::LightGuids :59-60(注释 “The GUIDs of lights which this light-map stores”)、ContainsLight :68-76;**FLightMapCoefficients :488-497**(无每灯槽位);FQuantizedLightmapData::LightGuids :516-517
Source/Runtime/Engine/Private/LightMap.cpp LightGuids 组装 :2125-2137(量化数据 + 阴影贴图来源);序列化 :135 / :3406
Source/Runtime/Engine/Classes/Engine/MapBuildDataRegistry.h FMeshMapBuildData::IrrelevantLights :60;**FLightComponentMapBuildData :150-171**(逐灯构建数据只有 ShadowMapChannel :166 与 DepthMap :168)
Source/Runtime/Engine/Private/StaticMeshLight.cpp IrrelevantLights 计算 :313-331(”relevant but didn’t contribute”);InvalidateLightingCacheDetailed :250 注释”渲染线程会读 IrrelevantLights”
Source/Runtime/Engine/Public/SceneManagement.h ELightInteractionType :303-314(LIT_CachedIrrelevant / LIT_CachedLightMap / LIT_Dynamic / LIT_CachedSignedDistanceFieldShadowMap2D / LIT_MAX);FLightInteraction 工厂 :319-326
Source/Runtime/Engine/Private/SceneManagement.cpp getStaticInteraction 全链:FLightCacheInterface::GetStaticInteraction :1620-1660(GUID 匹配三分支)
Source/Runtime/Engine/Private/StaticMeshSceneProxy.cpp GetLightRelevance :2392-2435(bDynamic/bRelevant/bLightMapped/bShadowMapped)+ 逐 LOD 交互汇总;GetInteraction :2670
Source/Runtime/Renderer/Private/LightSceneInfo.cpp ShouldRenderLightViewIndependent :245-250(”Only render lights with dynamic lighting or unbuilt static lights”);**IsPrecomputedLightingValid :267-270;GWholeSceneShadowUnbuiltInteractionThreshold = 500 :16-19**;bPrecomputedLightingIsValid 来源 :66;ShouldRenderLight :201-243;GetDynamicShadowMapChannel :285-295(Stationary 通道来自 GetPreviewShadowMapChannel)
Source/Runtime/Renderer/Private/SceneCore.cpp FLightPrimitiveInteraction 未烘焙计数 :218-247(bUncachedStaticLighting :229、GUnbuiltPreviewShadowsInGame :231、NumUnbuiltInteractions++ :236、ELightmapType::ForceSurface 排除 :225);递减 :330
Source/Runtime/Renderer/Private/LightRendering.cpp PreviewShadowsIndicator 判定 :1855;反射捕获特例下的 IndirectLightingScale :691-695
Source/Runtime/Engine/Classes/Engine/EngineTypes.h ELightmapType :210-222(Default / ForceSurface / ForceVolumetric 及注释)
Source/Programs/UnrealLightmass/Private/ImportExport/LightmassScene.cpp IndirectLightingScale 的烘焙期消费:间接颜色 :599、光通量 :974、入射功率 :1271、bCalculateForIndirectLighting 分支 :2140 / :2363;FLightmassLight::IndirectColor 注释 :212
Source/Programs/UnrealLightmass/Public/SceneExport.h FLightmassLight::IndirectLightingScale :910
Source/Runtime/Renderer/Private/DeferredLightingCommon.ush Stationary 灯取静态阴影通道 :96-111(相对第 25 章)
Source/Runtime/Engine/Private/Rendering/NaniteResources.cpp Nanite 侧同样的交互判据 :1374-1377 / :1452 / :1467 / :1475

A.10 阴影的烘焙与消费(第 30 章新增)

文件 关键内容
Source/Programs/UnrealLightmass/Private/Lighting/TextureMapping.cpp 三条分支 :798-866(零面积滤波 :801 / Stationary SDF :844 / Static 的 ShadowMapData = NULL :855 + AddLight :863);CalculateDirectAreaLightingTextureMapping :1390(面积采样 :1487-1535、半影加密 :1507-1522、**ShadowExponent :1698、AddWeighted(DirectLighting, AdjustedShadowFactor) :1721、3×3 滤波 :1552-1660、背面权重 :1588-1593、全遮挡丢弃 :1729);CalculateDirectSignedDistanceFieldLightingTextureMappingTextureSpace :1878(密度与上采样因子 :1892-1935、首遍可见度 :1943-2020、MinUnoccludedFraction :2023、过渡标记 :2051-2101、高分辨率采样 :2176-2242、散射 :2252-2478、0.5 编码与 PCSS penumbra :2458-2467**);面积→SDF 转换 :818-834(sqrt :829、PenumbraSize = 1 :830);VT/图集 padding :897
Source/Programs/UnrealLightmass/Public/SceneExport.h GI_LIGHT_CASTSTATICSHADOWS :885、GI_LIGHT_STORE_SEPARATE_SHADOW_FACTOR :886、GI_LIGHT_USE_AREA_SHADOWS_FOR_SEPARATE_SHADOW_FACTOR :890
Source/Editor/UnrealEd/Private/Lightmass/Lightmass.cpp bUseAreaShadowsForStationaryLight → flag :280-283
Source/Runtime/Engine/Private/ShadowMap.cpp FShadowMap2D 构造 :513-550;Serialize :560-590(InvUniformPenumbraSize :581-584);EncodeTextures :732;**EncodeSingleTexture :883(通道打包 :898-933、penumbra 警告 :915、坐标 :977-984、mip 降采样 :996-1073、外推 :1090+);格式硬编码 :329-334(CompressionNone = true)**;ISM 版本 :602
Source/Runtime/Engine/Public/ShadowMap.h FQuantizedSignedDistanceFieldShadowSample :27-95(NumFilterableComponents = 2、Coverage);FFourDistanceFieldSamples :217-220
Source/Runtime/Engine/Private/LightMap.cpp NeedsStaticShadowTexture :1346;**EncodeShadowTexture :2676(MaxChannelsUsed 取 max :2704、InvUniformPenumbraSize :2709);EncodeShadowMapTexture :1588(CompressionNone = true :1596);EncodeCoefficientTexture :1633(CompressionNoAlpha :1640、上下半区 :1658/:1732-1759);五图格式表 :1849-1852**;VT 层格式(LQ 强制 G8):1940;EncodeAOMaskTexture :1512(TC_Alpha // BC4 :1521)
Source/Runtime/Engine/Private/Components/LightComponent.cpp ReassignStationaryLightChannels :1781-1939(参与条件 :1793-1796、重叠图 :1833-1846、方向光优先 :1854-1862、贪心 :1894-1902、失败警告 :1929-1934)
Source/Runtime/Engine/Private/Components/DirectionalLightComponent.cpp GetUniformPenumbraSize :511-523(面积阴影 → 1.0;否则 LightSourceAngle * .05)
Source/Runtime/Engine/Private/Components/PointLightComponent.cpp GetUniformPenumbraSize :202-214(SourceRadius * .005)
Source/Runtime/Engine/Private/Components/RectLightComponent.cpp GetUniformPenumbraSize :302
Engine/Plugins/Experimental/GPULightmass/Source/GPULightmass/Private/Scene/Scene.cpp AddLightToLightmap :222-238(// TODO: implement SDF :233);TransencodeShadowMap :2264-2287(check(Light.bStationary && Light.bCastShadow) :2269);stationary 灯遍历 :2291-2300;FShadowMap2D::AllocateShadowMap :2392 / :2971
.../GPULightmass/Private/LightmapEncoding.cpp ConvertToShadowSample :351-359(PenumbraSize = 0 :357、// Sqrt is already done on GPU :354)
.../GPULightmass/Shaders/Private/LightmapPathTracing.usf StationaryLightShadowTracingMainRG :1396-1484(双半球 :1436-1474、整数累加 :1478-1481)
.../GPULightmass/Shaders/Private/LightmapBufferClear.usf sqrt(ShadowValue / SampleCount) * ValidityMask :71
.../GPULightmass/Source/GPULightmass/Private/Scene/Lights.h CastsStationaryShadow() :83 / :310 / :333
.../GPULightmass/Private/LightmapRayTracing.cpp FStationaryLightShadowTracingRGS 绑定 :20
.../GPULightmass/Public/GPULightmassSettings.h StationaryLightShadowSamples = 128 :71
Source/Runtime/Renderer/Private/LightMapRendering.cpp DistanceFieldShadowsAndLightMapPolicyImpl::ModifyCompilationEnvironment :70-75(STATICLIGHTING_TEXTUREMASK / STATICLIGHTING_SIGNEDDISTANCEFIELD);移动端策略 :181-189(r.Mobile.AllowDistanceFieldShadows)
Source/Runtime/Renderer/Private/BasePassRendering.cpp 策略选择 :2183-2208(LQ 分支无 DF 变体 :2201-2204);PSO 收集 :2271-2314
Source/Runtime/Renderer/Private/LightRendering.cpp ShadowMapChannelMask one-hot :649-665
Source/Runtime/Renderer/Private/LightSceneInfo.cpp 通道位打包 :405-425;**ShouldRenderLightViewIndependent :245-250**;GetDynamicShadowMapChannel :285-295
Source/Runtime/Renderer/Private/MobileBasePassRendering.cpp 移动端方向光 mask :436-450
Source/Runtime/Renderer/Private/MobileBasePass.cpp LMP_MOBILE_DISTANCE_FIELD_SHADOWS_AND_LQ_LIGHTMAP 收集 :406-424
Shaders/Private/LightmapCommon.ush GetPrecomputedShadowMasks :230-291(解码 :248-253、无 shadowmap 返回 0 :255-259);GetPrimaryPrecomputedShadowMask :294-328(移动端);GetLightMapColorLQ :78(alpha 未用 :100)
Shaders/Private/DeferredLightingCommon.ush GetShadowTermsBase :90-149(UsesStaticShadowMap :110、径向 :120-123、方向光 lerp :135-137)
Shaders/Private/BasePassCommon.ush WRITES_PRECSHADOWFACTOR_ZERO :48-52
Source/Runtime/RenderCore/Private/ShaderMaterialDerivedHelpers.cpp WRITES_PRECSHADOWFACTOR_ZERO 的 CPU 镜像 :54

| Source/Programs/UnrealLightmass/Private/Lighting/TextureMapping.cpp | 直射进不进 lightmap 的 if/else :1702-1722(ShadowMapData 非空 → 只写 Visibility :1705;为 NULL → AddWeighted :1721);分支选择 :805-851(Stationary)/ :853-865(Static) |
| Source/Runtime/Engine/Classes/Components/LightComponentBase.h | HasStaticLighting() :225(仅 Static)与 HasStaticShadowing() :232(Stationary 亦为真)的语义差异,注释 :221-232 |
| Source/Runtime/Renderer/Private/LightSceneInfo.cpp | ShouldRenderLightViewIndependent :245-250(!HasStaticLighting() → Stationary 灯仍走延迟渲染);IsPrecomputedLightingValid :267-270 |
| Shaders/Private/LightmapCommon.ush | GetLightMapColorHQ :132(LogL = Lightmap0.w :146,故 HQ 的 A 不空);GetLightMapColorLQ :78(LQ 只读 Lightmap0.rgb :91,A 空闲) |

| .../GPULightmass/Shaders/Private/LightmapPathTracing.usf | StationaryLightShadowTracingMainRG :1396-1484(Halton 采样 :1439-1442、**GenerateOcclusionRay :1449、RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH :1463、单样本 0/1 :1470、整数累加 :1478-1481) |
| Shaders/Private/RayTracing/RayTracingOcclusionRGS.usf | GenerateOcclusionRay :85-163(方向光 :98、点/聚光 :124-144、矩形光 :149) |
| Shaders/Private/RayTracing/RayTracingSphereLight.ush | GenerateSphereLightOcclusionRayWithSolidAngleSampling :36(
SinThetaMax² = R²/d² :67、立体角 PDF :31) |
| Shaders/Private/RayTracing/RayTracingDirectionalLight.ush | GenerateDirectionalLightOcclusionRay :10 |
| Shaders/Private/RayTracing/RayTracingRectLight.ush | GenerateRectLightOcclusionRay :9 |
| .../GPULightmass/Shaders/Private/LightmapBufferClear.usf | V = ShadowValue/SampleCount 并存 sqrt(V) :66-72(ValidityMask :69) |
| .../GPULightmass/Source/GPULightmass/Private/LightmapEncoding.cpp | ConvertToShadowSample :351-359(Distance = sqrt(V)*255 :355、
Coverage 布尔语义 :356**、PenumbraSize = 0 :357) |

| Source/Programs/UnrealLightmass/Private/Lighting/TextureMapping.cpp | **逐灯阴影集合 ShadowMaps/SignedDistanceFieldShadowMaps:707-708(唯一合并点)**;三分支选择 :798-865(零面积 :801 / Stationary 独立通道 :805-851 / StaticNULL :855-859);ShadowExponent :1698;bIsCompletelyOccluded删图判据 :1729-1733 | |Source/Editor/UnrealEd/Private/Lightmass/Lightmass.cpp | **HasStaticLighting():243-247 →GI_LIGHT_HASSTATICLIGHTING;else if (HasStaticShadowing()):248-252 →GI_LIGHT_STORE_SEPARATE_SHADOW_FACTOR**(「要不要独立通道」的总闸) | | Source/Programs/UnrealLightmass/Public/SceneExport.h|GI_LIGHT_STORE_SEPARATE_SHADOW_FACTOR = 0x00000020:886 | |Source/Runtime/Engine/Private/LightMap.cpp | **CompressionNoAlpha :1640**(LQ 的 A 被丢弃);DestColor.A 照抄 :1754;DestCoverage :1767;dilate :1808;NumShadowChannelsUsed:1837-1840 /ShadowMapFormat :1851;EncodeShadowMapTexture :1588(CompressionNone = true:1596) | |Source/Runtime/Renderer/Private/LightRendering.cpp | **ShadowMapChannelMask one-hot :649-665**(GetShadowMapChannel() :649、INDEX_NONE→ 全零 :655-656) | |Source/Runtime/RenderCore/Private/ShaderMaterialDerivedHelpers.cpp | **WRITES_PRECSHADOWFACTOR_ZERO:54**(须与LightmapCommon.ush:232注释配套) | |Source/Runtime/RenderCore/Public/ShaderMaterial.h|STATICLIGHTING_TEXTUREMASK:78 | |Source/Runtime/Renderer/Private/LightMapRendering.cpp|SetDefine(TEXT(“STATICLIGHTING_TEXTUREMASK”), 1):72 | |Source/Runtime/Engine/Private/ShadowMap.cpp|MaxChannelsUsed:890 / :911 / :1169(整张图集取 max) | |Shaders/Private/LightmapCommon.ush | **GetPrecomputedShadowMasks :230-291**(// Note: WRITES_PRECSHADOWFACTOR_ZERO have to match the logic here :232、SDF 解码 :248-253);GetPrimaryPrecomputedShadowMask:294-328(移动端,解码 :308-312) | |Shaders/Private/BasePassCommon.ush|WRITES_PRECSHADOWFACTOR_ZERO` :48-52 |

附录 B 术语表(中英对照)

英文 中文(本文用词) 一句话解释 首次出现
Lightmap 光照贴图 离线烘焙的光照纹理 Ch1
Bake 烘焙 离线预计算光照 Ch1
Texel Density 纹素密度 每纹素覆盖的世界面积 Ch2
Atlas 图集 多张光照贴图拼成的共享纹理 Ch2
Chart UV 岛 UV 展开后的连通区域 Ch2
Padding 边距 UV 岛/贴图之间的缝隙 Ch2
Packing 装箱 把片装进画布 Ch2
Tile 瓦片 GPU Lightmass 的处理单元(128×128) Ch3
HQ / LQ (不译) 高质量(LogLUVW+SH)/低质量(LogRGB)编码 Ch4
LogScale (不译) 对数量化尺度(11.5) Ch4
Scale / Add (不译) 每图归一化的缩放/平移 Ch4
CoordinateScale / Bias (不译) atlas 内 UV 的缩放/偏移 Ch4
SH (不译) 球谐(方向性光照编码) Ch4
Mobility 移动性 Static/Stationary/Movable 三态 Ch1
Stationary (不译) 固定位置可变亮度的灯 Ch1
Bounce 反弹 间接光反弹次数 Ch7
Irradiance Caching 辐照度缓存 复用相邻点 GI(加速) Ch3
OIDN (不译) Intel 开源降噪器 Ch3
MinLightmapResolution (不译) 自动 UV 的最小分辨率 Ch8
LightMapCoordinateIndex (不译) Lightmap UV 通道号(默认 1) Ch2
ELightmapUVVersion (不译) UV 打包算法版本 Ch5
FAllocator2D (不译) 段式 best-fit 装箱分配器 Ch5
PackedLightAndShadowMapTextureSize (不译) Atlas 最大尺寸(默认 1024) Ch5
LightmapTilePoolSize (不译) GPU Lightmass tile 池大小 Ch3
ISM (不译) 实例化静态网格 Ch5
VT (不译) 虚拟纹理 Ch6
LightmapStreamingFactor (不译) 流送权重(0.2) Ch6
ELightUnits (不译) 灯光单位(Candela/Lumen/Lux/EV) Ch12
WorldToMeters (不译) 世界单位换算(1UU=1cm → 100) Ch12
Convert Scene (不译) FBX 导入的单位/轴向转换 Ch12
Force Front X Axis (不译) FBX 导入的轴向修正 Ch12
Heightmap 高度图 地形高度数据(导入用) Ch12
Interchange (不译) UE 新导入框架(GLTF 经此) Ch12
Datasmith (不译) 场景导入插件(无 Unity) Ch12
Light Bleeding 漏光 UV 边距不足导致的串色 Ch7
LightmapDataIndex (不译) 图元在 GPUScene lightmap 数据缓冲中的下标(含 LOD 偏移) Ch23
LightMapCoordinateScaleBias (不译) lightmap UV → atlas UV 的缩放/平移(每台一份) Ch23
LightmapResourceCluster (不译) lightmap 贴图与采样器组成的 uniform 结构 Ch23
FLightmapSceneShaderData (不译) 15×float4 的逐图元 lightmap 参数(GPU 端布局) Ch23
PrecomputedLightingBuffer (不译) 传统路径的逐图元 lightmap uniform 缓冲 Ch23
NEEDS_LIGHTMAP_COORDINATE (不译) 着色器是否需要 lightmap UV 插值器 Ch23
TEXTUREGROUP_Lightmap (不译) lightmap 贴图所属 LOD 组(控制 mip/尺寸上限) Ch24
LightmapStreamingFactor (不译) lightmap 流送权重(越小越激进,默认 0.2) Ch24
bCompressLightmaps (不译) 是否对 lightmap 做块压缩(BC/ASTC) Ch24
Lighting Scenario 光照方案 用子关卡整体切换一套烘焙光照 Ch24
IndirectLightingColor / Intensity (不译) 后处理里给预计算间接光染色/调强度 Ch24
PrecomputedIndirectLightingColorScale (不译) 上述设置送进 shader 的乘子(Lumen 下置零) Ch24
Static Shadowing 静态阴影 烘焙进 ShadowMap 通道、按灯取用的阴影因子 Ch24
HasStaticLighting (不译) 判据:Mobility == Static(直接光也被烘焙) Ch25
HasStaticShadowing (不译) 判据:Mobility != Movable(参与静态阴影与间接光烘焙) Ch25
ShadowMapChannel (不译) Lightmass 为 Stationary 灯分配的静态阴影通道(仅 4 个) Ch25
DynamicShadowFraction (不译) 方向光阴影由烘焙向 CSM 过渡的距离权重 Ch25
bUseAreaShadowsForStationaryLight (不译) Stationary 静态阴影用面积光源(越远越软) Ch25
LUVW (不译) 亮度 L 与归一化色度 U/V/W 的分解 Ch26
LogL (不译) 对数域亮度(8bit 主位 + 8bit 残差) Ch26
Residual 残差 HQ 亮度的小数部分,复用组 1 的 A 通道 Ch26
SimpleLogScale (不译) LQ 的固定对数刻度(16,对应 exp2(x*16-8)) Ch26
LogBlackPoint 黑点 对数域零点(HQ 2^-5.75 / LQ 2^-8) Ch26
QuantizeLightSamples (不译) GPU Lightmass 的逐样本量化函数 Ch26
USE_LM_DIRECTIONALITY (不译) 是否用 SH 参与方向性间接光 Ch23
Lightmap Density 视图 (不译) 编辑器密度可视化(红/蓝) Ch7
SkyOcclusionTexture 天空遮蔽图 逐纹素存 bent normal 方向 + 天空可见度(非颜色图) Ch27
BentNormal 弯曲法线 表示”天空主要从哪个方向漏进来”的归一化方向 Ch27
SkyVisibility 天空可见度 bent normal 的长度:0=全遮蔽、1=全可见 Ch27
CompressionNoAlpha (不译) 允许丢 alpha → 可用 BC1(4bpp);为 false 则须用带 alpha 的格式 Ch27
TC_Alpha (不译) 按单通道压缩,落 BC4(AOMask 显式指定) Ch27
r.VT.EnableLossyCompressLightmaps (不译) VT lightmap 额外的有损压缩开关(默认 0=关) Ch27
r.IncludeNonVirtualTexturedLightmaps (不译) 已用 VT 时是否再生成一套非 VT 贴图(默认 0) Ch27

| Volumetric Lightmap (VLM) | 体积光照图 | 世界空间 3D 体素化的预计算间接光(给动态/半透/体积雾用) | Ch28 |
| Brick | 砖 | VLM 的基本数据块,内部是 BrickSize³ 的体素网格 | Ch28 |
| IndirectionTexture | 间接索引纹理 | 低分辨率 3D 纹理,把世界坐标映射到”该查哪块砖” | Ch28 |
| PaddedBrickSize | (不译) | BrickSize + 1:每块砖外裹 1 纹素 padding,供三线性插值读边界 | Ch28 |
| AmbientVector | 环境向量 | SH 的 0 阶常数项,作为方向项归一化的分母 | Ch28 |
| SHDenormalizationScales | 反归一化比例 | 把 8bit 归一化 SH 还原成真实系数的比例表(三端一致) | Ch28 |
| QuantizeRound | (不译) | Lightmass 端把归一化 SH 量化进 8bit | Ch28 |
| MaxRefinementLevels | 最大细分层数 | 自适应细分的递归深度上限 | Ch28 |
| ShouldRefineVoxel | (不译) | 判定一个体素要不要继续细分(几何/灯/密度体三条理由) | Ch28 |
| MinBrickError | 最小砖误差 | RMSE 低于此值的砖被裁掉(光照太均匀,粗网格够用) | Ch28 |
| bCullBricksBelowLandscape | (不译) | 剔除地形下方的砖(地形有洞/洞穴时是错误优化) | Ch28 |
| Density Volume | 密度体 | AVolumetricLightmapDensityVolume,手工覆盖局部 VLM 密度 | Ch28 |
| AllowedMipLevelRange | 允许 mip 范围 | 密度体的三档 mip 允许区间([1,3] 省钱 / [0,0] 加钱) | Ch28 |
| Spherical Harmonic ringing | SH 振铃 | 强方向性光存进 SH 后在对侧出现的黑斑 | Ch28 |
| Windowing filter | 窗函数滤波 | 压制 SH 振铃的滤波(WindowingTargetLaplacian) | Ch28 |
| R11G11B10 | (不译) | 3 通道共享 32bit 的浮点格式(VLM 环境项 = 4 B/体素) | Ch28 |
| FFloat3Packed | (不译) | R11G11B10 的 CPU 侧位域镜像类型 | Ch28 |
| VolumeLightingMethod | 体积光照方式 | VLM 二选一:Volumetric Lightmap / Sparse Volume Lighting Samples | Ch28 |
| Sparse Volume Lighting Samples | 稀疏体积光照采样 | 旧方案(ILC 空间点集),只能 CPU 插值且不支持体积雾 | Ch28 |
| Indirect Lighting Cache (ILC) | 间接光缓存 | 给可动物体插值间接光的旧机制 | Ch28 |
| Capsule Shadow | 胶囊体阴影 | 可动物体脚下的软阴影,可从 VLM 反推光方向 | Ch28 |

| LightGuid | (不译) | 灯的烘焙身份标识;烘过的灯换属性会换新 GUID | Ch29 |
| ContainsLight | (不译) | 查询某灯的贡献是否在这张 lightmap 里(GUID 匹配) | Ch29 |
| IrrelevantLights | 无关灯表 | 烘焙判定”对本网格无贡献”的灯 GUID 列表(跟着 MapBuildData 存) | Ch29 |
| ELightInteractionType | 灯-图元交互类型 | LIT_CachedLightMap / LIT_CachedIrrelevant / LIT_Dynamic / LIT_CachedSignedDistanceFieldShadowMap2D | Ch29 |
| GetStaticInteraction | (不译) | 判断某灯对某图元的贡献是不是已经在烘焙里 | Ch29 |
| Uncached Static Lighting | 未烘焙静态光照 | 静态图元 × 未烘灯的交互对;会被动态预览 | Ch29 |
| GUnbuiltPreviewShadowsInGame | (不译) | 打包版是否绘制未烘焙灯的预览阴影 | Ch29 |
| PreviewShadowsIndicator | 预览阴影指示器 | 编辑器里标记”这盏灯还没烘”的小图标 | Ch29 |
| AreDynamicDataChangesAllowed | (不译) | 运行时是否允许改属性(Static 灯返回 false) | Ch29 |
| bAffectsWorld | (不译) | 灯是否参与烘焙(构造 true / Destroyed 置 false) | Ch29 |
| IndirectLightingIntensity | 间接光强度 | 烘焙期旋钮,走 IndirectLightingScale 进 Lightmass | Ch29 |
| IndirectLightingScale | (不译) | IndirectLightingIntensity 在 Lightmass 里的名字 | Ch29 |
| ELightmapType | (不译) | 逐图元指定 Surface / Volumetric lightmap(ForceSurface / ForceVolumetric) | Ch29 |
| FLightComponentMapBuildData | (不译) | 逐灯构建数据(只有阴影通道 + 深度图) | Ch29 |
| Lighting Scenario | 光照方案 | 用子关卡整体切换一套烘焙光照(关灯方案之一) | Ch24 / Ch29 |
| ReassignStationaryLightChannels | (不译) | 重新分配 Stationary 灯的静态阴影通道 | Ch25 / Ch29 |
| UpdateLightGUIDs | (不译) | 失效光照缓存时给灯换新 GUID(Movable 灯 GUID=0) | Ch29 |

| Signed Distance Field (SDF) | 有符号距离场 | 存「到最近阴影过渡的距离」而非 0/1,供运行时重建亚纹素边缘 | Ch30 |
| Coverage | 覆盖标记 | shadowmap 样本是否有效(区分「没烘到」与「全黑」) | Ch30 |
| InvUniformPenumbraSize | 反半影尺寸 | 运行时把距离场缩放偏置成 01 阴影因子的倒数参数 | Ch30 |
| PrecomputedShadowFactors | 预计算阴影因子 | GBuffer 里那 4 个 0
1 的静态阴影因子 | Ch30 |
| ShadowMapChannelMask | 阴影通道掩码 | 灯用来选中自己那一个通道的 one-hot 向量 | Ch30 |
| StaticShadowMapMasks | 静态阴影有效掩码 | 标记 shadowmap 的 4 个通道里哪些有数据 | Ch30 |
| UpsampleFactor | 上采样因子 | 距离场生成时过渡带的超高分辨率倍率(取奇数、上限 13) | Ch30 |
| MaxTransitionDistanceWorldSpace | 最大过渡距离 | 距离场散射半径(世界空间) | Ch30 |
| MinUnoccludedFraction | 最小未遮挡比例 | 低于此比例的 shadowmap 被整张丢弃(省通道) | Ch30 |
| ShadowExponent | 阴影指数 | 对可见度取幂,手工加硬/软化阴影 | Ch30 |
| CalculatePointAreaShadowing | (不译) | 对光源表面采样求可见度(面积光软阴影的源头) | Ch30 |
| TSF_G8 / TSF_BGRA8 | (不译) | shadowmap 的 1 通道 / 4 通道源格式(两者都不压缩) | Ch30 |
| CastsStationaryShadow | (不译) | GPU Lightmass 侧判据:bStationary && bCastShadow | Ch30 |

| HasStaticLighting | (不译) | 只有 Static 灯为真:位置与参数运行时都不变,可整体烘进 lightmap | Ch30 |
| HasStaticShadowing | (不译) | Static 与 Stationary 都为真:有烘焙的直射阴影(亮度/颜色仍可变) | Ch30 |
| Separate shadow factor | 独立阴影因子 | 可见度单独存一份而不折进辐照度,从而允许运行时改亮度 | Ch30 |
| Merged visibility | 合并可见度 | 把多盏灯的可见度按亮度加权压成一个标量 | Ch30 |

| Visibility | 可见性 | 从着色点看光源表面(按立体角加权)未被遮挡的占比,值域 [0,1] | Ch30 |
| Solid angle sampling | 立体角采样 | 在光源于着色点张成的锥内均匀采样方向,使 PDF 正比于立体角 | Ch30 |
| Any-hit ray | 任意命中光线 | RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH,只问挡没挡、不问挡在哪 | Ch30 |
| Penumbra | 半影 | 光源被部分遮挡的区域,此区间内 V ∈ (0,1) | Ch30 |

| ShadowMapChannelMask | 阴影通道掩码 | 由 GetShadowMapChannel() 生成的 one-hot,用来从 half4 里挑出该灯的通道 | Ch30 |
| WRITES_PRECSHADOWFACTOR_ZERO | (不译) | 无 shadowmap 时 GBuffer 里预计算阴影因子写 0 的编译期开关 | Ch30 |
| STORE_SEPARATE_SHADOW_FACTOR | 独立存阴影因子 | Lightmass 的灯标记,决定这盏灯是否独占一个阴影通道 | Ch30 |
| CompressionNoAlpha | 不压 alpha | 系数图压缩时丢弃 alpha 通道(LQ 默认开启,故 A 位是废的) | Ch30 |
| Merged visibility accumulation | 合并可见度累加 | 在 :707 的两张 TMap 之后、量化之前把所有灯的 V 加权成一个 | Ch30 |

附录 C 参考资料

Epic 官方(dev.epicgames.com):

  1. Lighting the Environment in Unreal Engine(5.0+)—— 光照总览(光源 Mobility/Lumen/VSM 导航);
  2. GPU Lightmass Global Illumination(含官方中文版)—— GPU 烘焙文档:DX12+DXR、Full Bake/Bake What You See 两模式、面板参数、已知限制;
  3. Understanding Lightmapping(5.2+)—— Lightmap UV 硬性要求:UV 岛不重叠、0-1 空间、约 4 纹素 padding;
  4. Generating Lightmap UVs(5.3)—— Build Settings 自动生成(Generate Lightmap UVs/Min Resolution/Source→Destination);
  5. Datasmith Supported Software and File Types(5.6)—— 官方清单无 Unity;
  6. Importing glTF Files Into Unreal Engine(经 Interchange)—— 本 fork 已实测可用;
  7. Migrating assets from Unity to Unreal Engine(官方迁移文档)—— FBX Exporter 包、Convert Scene + Force Front X Axis、100 倍缩放、Y-up→Z-up。

中文社区(标注:以下基于 UE4 或写作时未能访问核实,非本仓库实测):

  1. 知乎《UE5 光照烘焙—CPU Lightmass》(p/660381167)—— 烘焙全流程(关 Lumen/VSM、Lightmass 参数、Lightmap Density 视图);
  2. 知乎《UE4 LightMap 编码格式(移动端)》(p/539598416)—— HQ=RGBLogL 8:8:8:8、LQ=24bit RGB8、SimpleLogScale=16、cook 后 ASTC;
  3. 知乎《UNREAL 移动端 LightMap 自定义优化修改(六)》(p/261297862)—— 跳 SkyOcclusion、AO 写 A 通道、包体减 150MB;
  4. ldpk《Unity 光照贴图 UV 优化实战》—— UV 空间浪费与 packing 优化(跨引擎对照);
  5. masterTOB《From Unity to Unreal》(Epic 论坛)—— 一键导出整个 Unity 场景为 FBX;
  6. 知乎《全局光照引擎—Lightmap 烘焙器构件》(p/384470030)—— 自研烘焙器视角的 Lightmap 管线拆解;
  7. 知乎《命运扳机》的光与影(UFSH2025)—— UE 当烘焙器的坑(RVT 地形预渲染、Lightmass 材质导出模块重写)。

附录 D 自测练习

入门级:

  1. 用三句话 + 一个类比向同事解释”烘焙”(Lightmap)是什么。
  2. Lumen 和 Lightmap 的区别?什么场景选哪个?
  3. LightMapResolution 是”每 texel 多少世界单位”吗?它实际是什么?

进阶级:

  1. 画出一张光照贴图从烘焙到显示的七段流程(图 F1 复述)。
  2. 缺第二 UV 时引擎会怎样?为什么”没报错 = 没问题”是错的?
  3. Unity 场景导入 UE 的 100 倍缩放是哪来的?灯光单位怎么换算?
  4. 漏光(Light Bleeding)的成因与排查顺序是什么?

源码级:

  1. 在 LightmapEncoding.cpp:35(QuantizeLightSamples)找到 LogScale 常量,解释为什么要取对数。
  2. 在 LightMap.cpp:685(PostEncode)找到 CoordinateScale/Bias 的计算,说明它们解决什么问题。
  3. 在 LocalVertexFactoryCommon.ush:118 找到双纹理打包的 UV 变换,推导 UV1 为什么是 UV0 + float2(0,0.5)。
  4. 在 FbxMaterialImport.cpp:817 找到材质属性映射表,说出 Unity Smoothness 应该映射到 UE 的什么。
  5. 在 LocalVertexFactory.ush:881 找到 LightMapCoordinateScaleBias:它从哪来?为什么实例(ISM)还能再覆盖一次偏移(:883)?
  6. 在 BasePassPixelShader.usf:1377 说明延迟路径下 lightmap 全彩结果的去向,并解释为什么”关掉 Lumen 的纯烘焙场景依然亮”能反证这一点(不用读 DiffuseIndirectComposite)。
  7. DiffuseIndirectComposite.usf 里 DiffuseIndirectLighting 有几个来源?为什么 lightmap 不在其中?GBuffer 里那份 8bit 亮度的真实消费者是谁(给出两个落点)?
  8. 列举 lightmap 构建产物的 4 类贴图,指出哪一类被强制不压缩、为什么。
  9. 项目要减小 lightmap 显存,你能给出几档手段?分别减的是显存、磁盘还是带宽?
  10. 太阳设成 Static / Stationary / Movable 时,直接光、阴影、间接光分别能不能实时变化?给出判断依据的源码落点。
  11. 运行时想让「烘焙好的间接光」整体变成黄昏色调,官方钩子在哪(给出 C++ → shader 的链路)?开启动态 Lumen GI 后会发生什么?
  12. 实验:给关卡做两套 Lighting Scenario 并运行时切换,观察 MapBuildDataRegistry 的替换与切换瞬间的跳变(第 24.3 实验室)。
  13. 写出 HasStaticLighting() 与 HasStaticShadowing() 的实现,说明为什么 Stationary 是”前者 false、后者 true”的唯一一档。
  14. Stationary 方向光的阴影在近处与远处分别由谁提供?给出源码里的混合公式与淡出因子。
  15. 场景里挤了 5 盏重叠的 Stationary 灯,其中一盏没有静态阴影——用通道预算解释原因,给出两种修法。
  16. 为什么”把 Stationary 灯隐藏后房间仍然亮”?从 lightmap 的数据属性与 BasePass 采样链两方面回答。
  17. HQ 的亮度为什么要拆成”主位 8bit + 残差 8bit”?推导解码公式,说明等效精度是多少、代价是什么。
  18. 色度为什么要存 sqrt、解码再平方?这跟 sRGB 是什么关系?
  19. LQ 只有一组 8bit,它是怎么做到”发光色里同时含亮度”还能被 shader 用 Luminance() 精确还原的?
  20. 如果让你把 HQ 的亮度精度再翻一倍,你会动哪一层(编码/格式/分辨率/后处理)?各自的代价是什么?
  21. 实验:RenderDoc 截帧,对比 BasePass 之后与 DiffuseIndirectComposite 之后的 SceneColor,指出 lightmap 贡献出现在哪一步(第 23.11 实验室)。
  22. 实验:建一个室内关卡烘焙,用 Lightmap Density 视图找密度不均的物体,调整分辨率后对比显存占用。

第 27 章(SkyOcclusion):

  1. SkyOcclusion 贴图的四个通道分别装什么?为什么说它”不是一张图”而是”一份几何信息”?
  2. 同样是 lightmap 数据,为什么 LQ 系数图能压到 BC1(4bpp)、SkyOcclusion 却必须付 BC3/BC7(8bpp)?
    给出 CompressionNoAlpha 在三张图上的取值与对应源码行。
  3. SkyOcclusion 的物理尺寸为什么是 W×H 而系数图是 W×2H?shader 里哪一行在补偿这个差异,注释怎么写的?
  4. 按”生成 / 压缩 / 采样”三个环节,各给出一条真正能省 SkyOcclusion 开销的做法,并说明各自的代价。

第 28 章(体积光照图):

  1. Lightmap 和 Volumetric Lightmap 的坐标系分别在哪儿?为什么”动态角色照不亮”这件事,靠改进 lightmap 解决不了?
  2. VLM 为什么需要 IndirectionTexture 和砖数据两层纹理?只用一层会付出什么代价?
  3. 砖的 padding 为什么是 +1 而不是 +0.5?这块 padding 在 ComputeVolumetricLightmapBrickTextureUVs 的哪一项里生效?
  4. VLM 的方向项为什么敢除以环境项再压成 8bit?把 “环境项 / 方向项” 的分工类比成第 26 章的哪个做法?
  5. SHDenormalizationScales 在源码里被抄了三遍(VolumetricLightmapShared.ush / LightMapRendering.cpp / AdaptiveVolumetricLightmap.cpp)——为什么必须逐位一致?不一致会怎样?
  6. ShouldRefineVoxel 有哪三条细分理由?bCullBricksBelowLandscape 在什么场景下是错误优化?
  7. VolumetricLightmapDetailCellSize 减半为什么是”最多 8 倍内存”而不是 2 倍?”up to” 二字为什么要加上?
  8. 算一笔账:一个体素最少占多少字节?分别由哪几层构成?和”一个 lightmap 纹素 1 字节”相比差多少倍?
  9. VolumetricLightmapMaximumBrickMemoryMb 超限时是”降精度”还是”丢砖”?这个区别在观感上表现为什么?
  10. 如果要求你给 VLM 省一半显存,列三条不需要重新设计格式的做法,并说出各自的代价。
  11. 移动端与桌面端消费 VLM 的方式差在哪一句话上?为什么这会让移动端的 VLM 精度评估完全不同?

第 29 章(单灯变色 / 开关):

  1. 为什么 lightmap 无法”只改某一盏灯的间接光”?给出两条源码证据(一条来自纹素结构,一条来自 LightGuids 的注释)。
  2. AreDynamicDataChangesAllowed() 的默认参数是什么?它让哪一类灯的所有运行时 setter 静默失效?举出三个受它保护的 setter。
  3. 编辑器里改一盏 Static 灯和改一盏 Stationary 灯的颜色,行为差在哪?指出 LightComponent.cpp 里决定这个差异的那三行。
  4. 一盏 Static 灯改了属性之后,引擎是怎么”发现”旧 lightmap 不算数的?梳出完整链条(含函数名)。
  5. GetStaticInteraction 返回的四档分别是什么意思?LIT_CachedIrrelevant 为什么会被算作 bLightMapped = true?
  6. 烘焙后新增一盏 Static 灯(不重烘),它会怎样被画出来?这个”预览”由哪三个条件共同决定?为什么小场景里可能完全看不到?
  7. 勾选/取消灯的 Visible 会不会触发重烘?对 Static / Stationary / Movable 三档分别说出观感结果。
  8. 为什么”把 Stationary 灯隐藏后房间仍然亮”?从 lightmap 的数据属性和 ShouldRenderLightViewIndependent 两方面回答。
  9. IndirectLightingIntensity 能在运行时改吗?它在主视图里生效吗?指出它唯一的运行时消费点。
  10. 项目要求”关灯后房间必须真的暗下来”,给出四条方案并说明各自的代价。
  11. 实验:烘焙后新增一盏 Static 灯,分别在编辑器与 GUnbuiltPreviewShadowsInGame=1 的打包版里观察光与影,再把它所在场景的网格数量堆到 500 个以上,观察预览行为的变化(第 29.11 实验室)。

第 30 章(阴影的烘焙与消费):

  1. 用一句话说明「Static 灯为什么没有阴影贴图」,并指出那行关键源码里 ShadowMapData 被赋成了什么。
  2. CalculateDirectAreaLightingTextureMapping 里的 ShadowValue 为什么是 FVector2f(两个数)而不是一个 float?落在半影区时为什么要再算一遍?
  3. 距离场阴影存的是「可见度」还是「距离」?数值 0.5 代表什么?运行时把它变回 0~1 阴影因子的那一行公式是什么?最后为什么还要平方?
  4. 距离场五步里,哪一步决定了「只有过渡带才做高精度采样」?上采样因子为什么必须取奇数、上限为什么是 13?
  5. CPU Lightmass 的两种 Stationary 阴影(面积阴影 / SDF)在「软阴影从哪来」这件事上的本质区别是什么?各对应哪个属性开关?
  6. GPU Lightmass 的 ConvertToShadowSample 把 PenumbraSize 设成了 0,为什么运行时仍能工作?在什么情况下会出错?给出需要打开的属性名与源码依据。
  7. 一张图集里只有一处用到 2 个阴影通道,整张图的源格式会变成什么?每纹素从多少字节涨到多少?指出决定这件事的那一行代码。
  8. ReassignStationaryLightChannels 分配失败的结果是「画质下降」还是别的什么?日志里那句话的原文是什么?
  9. 为什么 shadowmap 必须 CompressionNone = true?它存的量与系数图存的量,在「能不能被块压缩」这件事上差在哪?
  10. 桌面端把 r.HighQualityLightMaps 设成 0 会丢掉什么?为什么?移动端要靠哪个 CVar 才能有「LQ + 距离场阴影」?
  11. 把多盏 Stationary 灯的阴影合并成一个值压进 alpha,正确的合并权重是什么?为什么这个权重只能烘死?
  12. 承上:在「所有灯都开着」的前提下,加权平均合并是近似还是精确?关掉其中一盏灯之后呢?用一个两灯的例子算一遍。
  13. 算账:LQ 系数图 1.0 B/纹素、4 通道 shadowmap 4.0 B/纹素。分别算出「塞进 LQ 的 alpha(改 BC3)」与「独立 BC4 层」两种方案的总字节数与每像素采样带宽,说明哪个更省、为什么。
  14. 为什么同样的「用 alpha」方案,在移动端 ASTC 上是不花钱的?
  15. 实验:造两盏影响范围轻微重叠的 Stationary 灯烘一次,再让它们完全不重叠烘一次,对比 shadowmap 纹理的源格式与内存占用(第 30.10 实验室)。
  16. TextureMapping.cpp:1702-1722 那个 if/else,两边分别做了什么?由此说明:Stationary 灯的直射光到底有没有被烘进 lightmap?
  17. 承上:既然 lightmap 里没有 Stationary 的直射,那么把它的可见度搬进 LQ 的 alpha,会不会把间接光一起压暗?为什么?(提示:想清楚可见度是乘在哪一半上的。)
  18. HasStaticLighting() 与 HasStaticShadowing() 对 Static / Stationary / Movable 三档分别返回什么?由此推出:为什么 Stationary 灯必须跑延迟渲染,而 Static 灯完全不用?
  19. 「让 Stationary 的阴影达到 Static 的品质」在 UE 里现成的做法是什么?指出它调用的是哪个函数,与 Static 灯调用的有什么关系、最后一步差在哪两行。
  20. 更正后的结论里,「单主光场景」为什么能做到零误差?它省下的 3 B/纹素是怎么算出来的?移动端 ASTC 为什么更划算?
  21. 实验:给一盏 Stationary 方向光勾上 bUseAreaShadowsForStationaryLight,用 Nid 级 lightmap 分辨率烘两次(开/关),对比阴影边缘的软硬与台阶;再用 GPU Lightmass 重复一次,确认「不勾这个 flag 会解错」的现象(第 30.10 实验室)。
  22. 用一句话给出可见性 V 的物理定义。它值域是多少?半影里 V = 0.5 意味着什么?
  23. GPU Lightmass 里,单条光线得到的可见性是多少个值?软阴影是从哪里来的——是单条光线的“部分遮挡”,还是别的东西?指出对应的 flag 与那行 IsMiss()。
  24. GenerateSphereLightOcclusionRayWithSolidAngleSampling 里 SinThetaMax² = R²/d² 这一行的几何含义是什么?为什么这叫“立体角采样”?矩形光与方向光分别走哪个函数?
  25. 写出 V 在 GPU LM 与 CPU LM 两处各自的表达式(各用源码里的变量名),并说明二者是否等价、差别仅在哪一点。
  26. 为什么「按 Static 一样存可见性」这句话有歧义?把两种读法及各自对应的结论写出来。
  27. 合并多盏灯的可见性时,为什么不能在 sqrt 域做加权平均?用 Jensen 不等式说明偏差方向,并给出正确顺序。另外说明为什么 Coverage 的语义必须改写。
  28. TextureMapping.cpp:798-865 那三条分支分别是什么?要让一盏 Stationary 灯不再独占阴影通道,改动最小的那一处在哪(给出文件与行号)?
  29. 承上:为什么不能简单地把这盏灯推进 Static 那条分支(:855)?运行时会观察到什么现象?给出判据函数与它的源码落点。
  30. 合并的权重应该取什么量?为什么这个权重只能烘死?(提示:想清楚运行时 V̄ 乘在谁身上。)
  31. 在量化之后合并可行吗?写出正确的三步顺序,并说明为什么精度不会额外损失。
  32. 运行时把 4 通道的 PrecomputedShadowFactors 换成一个标量 V,有两种改法。写出推荐的那一种,并说明为什么必须同时改 ShaderMaterialDerivedHelpers.cpp:54——不改会看到什么?
  33. 列举这次改造里「语义变了但不会报错」的三处(提示:bIsCompletelyOccluded、Coverage、逐灯 ShadowExponent),各说一句后果。