从”普通程序员能听懂”到”能改 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,HEAD6cea9bd20f8f - 写作原则:文中所有”源码考古”块引用的文件路径、行号、函数名均在本仓库中实际验证过;行号只用于定位,请以
路径:函数名为准 - 姊妹文档:本文是《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”转圈。每个术语第一次出现都配一句直觉解释。
前置知识三个:
- UV 与纹理:模型表面的”贴图坐标”——光照贴图也是贴图,同样需要 UV;
- 场景灯光与移动性:Static/Stationary/Movable 三态(第 1 章细讲);
- 烘焙(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 一张图看懂全局
读图方法:场景设置好了 → 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 小结
这一章记住三句话:
- Lightmap = 离线预计算的光照贴图(拍照片哲学);
- Lumen vs Lightmap = 实时 vs 离线——移动端/静态场景选 Lightmap;
- 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 | Engine/Plugins/Experimental/GPULightmass/ |
避坑:路径是
Experimental/GPULightmass/(实验性插件目录)——不是Plugins/GPULightmass。
老资料讲 UE4 的 Lightmass 是独立进程 CPU 求解器(UnrealLightmass),与 GPU Lightmass
完全不同:GPU 版在引擎进程内、用 GPU 路径追踪、逐 tile 渐进。
3.2 触发链
1 | 编辑器 Build Lighting 按钮 / 控制台 GPULM.BuildLighting |
为什么 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 | // LightmapEncoding.cpp:44-47 |
为什么取对数:亮度范围从 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 | // The textures containing the light-map data. |
运行时纹理: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 | LogL = Lightmap0.w |
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 | 网格侧(每网格): |
为什么两套:网格侧解决”这个网格的 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 | BasePass 像素着色器(BasePassPixelShader.usf:717-731): |
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 | // lightmap UV 来自顶点流的 LightMapCoordinate 通道(默认 UV1,LightMapCoordinateIndex) |
关键技巧:两个系数纹理上下打包进一张贴图(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.LightmapStreamingCVar 在
本 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 验证三件套
- 视口目检:漏光(墙角发亮)、光斑(纹素密度不足)、噪点(采样不够);
- Lightmap Density 视图(编辑器视图模式):红=密度过高、蓝=过低——全场景尽量同一色;
- 构建对比:改参数前先记录(截图/时长),每次只动一个旋钮。
7.6 避坑清单
- 漏光(Light Bleeding):90% 是 UV padding 不足——装箱 4px 对齐是默认,官方建议约 4 纹素边距;
- Stationary 灯阴影噪点:
StationaryLightShadowSamples=128不够就调高; - 大关卡烘焙慢:先
StaticLightingLevelScale,再调 IndirectLightingQuality 预览; - 烘焙结果与实机不一致:检查压缩(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 | 新 MeshDescription 路径(默认): |
默认设置(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 | 烘焙(RGBA16F tile)→ 量化 8bit(FColor)→ SetModernSettingsForNewOrChangedTexture |
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 | 资产规范(分辨率/UV 规范)→ 自动 UV(第 8 章)→ GPU Lightmass(第 3 章) |
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 避坑
- 烘焙与实机差异:分辨率/去噪/压缩在实机上观感不同——导出前用实机设置验收;
- 多场景烘焙:增量烘焙(只烘改动的场景)省时间;
- 版本管理:烘焙产物(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) |
手动修复清单(自动映射的经典坑):
- Metallic/Roughness 反了:Unity 的 Smoothness = 1 - Roughness——FBX 导出器一般已处理,但检查”金属质感全无”;
- 纹理颜色空间:Unity 的 sRGB 标记可能丢失——检查 BaseColor 的 sRGB 开关;
- 透明度:Unity Transparent → UE 要手动设 BlendMode(自动映射设了但通常要微调)。
12.4 灯光对齐:单位换算
UE 灯光单位(LocalLightComponent.h 的 IntensityUnits 注释):点光/聚光默认 Candelas(cd)或 Lumens(lm);方向光用 lux(ELightUnits,LightComponent.h)。
换算公式(GetUnitsConversionFactor 注释原文):
1 | Flux = Lm = cd·sr (光通量 = 坎德拉 × 立体角) |
与 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 | 内存问题(显存涨/包体大): |
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 | // LightmapGBuffer.usf(要点) |
烘焙真正消费的材质属性:
| 材质属性 | 烘焙参与 | 说明 |
|---|---|---|
| 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 | 地图工程(源引擎 .proj 场景) |
通用原则:① 场景信息(谁、在哪、什么灯)与 ② 几何数据(模型长什么样)分离——场景用文本/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 | p = [2, 0, 1] # (x,y,z) -> (z,x,y) |
灯光方向: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 | UnrealEditor-Cmd <项目>.uproject -run=ExportBakedLightmaps -Rebuild -SetLightmapRes=64 |
| 参数 | 作用 | 坑 |
|---|---|---|
-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;
- 坐标:轴置换用金标准回测验证,层级链用共轭保证不漂移;
- 参数:所有换算系数进 config(标定是内容团队的工作);
- 数据:GUID 命名贯穿(实例 ↔ 产物对齐锚点);
- 自动化:headless 命令 + 环境变量传参 + 数据级验证(mappings 计数);
- 病根:问题先查数据污染/开关/默认值,再查代码;
- 导出:实例级变换与网格级数据分离(对端引擎可重建)。
第 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 | 看到漏光 → ① 定位:Lighting Only 视图(确认是 Lightmap 问题) |
六项优化手段
| 手段 | 修什么 | 操作 |
|---|---|---|
| 加 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)决定单张图集上限,超出自动开新图集。
防患于未然(制作期规范)
- UV 规范:Lightmap UV 岛间距 ≥ 4px、无重叠、密度统一(第 8 章);
- 材质规范:漏光高危材质(自发光/亮色)单独验证;
- LOD 规范:简化时保留 Lightmap UV 通道(
bLerpUVs影响 Nanite LOD 的 UV 保真,EngineTypes.h:3297); - 验收规范:烘焙后 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:70sRGBToLinearTable+ 构造 :839-840); - 两者等价:实测最大偏差 ≤2.2%(8bit 观感差 0~1/255,肉眼不可辨)——颜色链路不要再动;
- 注意:雾色/后处理色 = FLinearColor 直写(无解码),与灯色的 sRGB 语义不同——
fog_inscattering_luminance直写 LinearColor 值,别套灯色的换算。
18.2 强度换算:1/π 精确对消(核心修正)
推导链(两引擎的着色公式对比):
1 | 源引擎: L = A × [Fr_DisneyDiffuse × INV_PI] × NdotL × fAtten × C_linear × fMultiply × globalIntensity |
- 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:38LightFalloffExponent); - 默认 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 | r.EyeAdaptation.MethodOverride 3 (3=Manual,PostProcessEyeAdaptation.cpp:78) |
避坑:tonemap 默认 Filmic 非 ACES——跨引擎对比观感差异的常见来源(高光去饱和),A/B 项。
18.6 Atlas 数量公式:一个可预测的预算
实测铁证(中大型场景,约 1200 实例/115 唯一网格):
1 | chart 尺寸 = LightMapResolution × 2(所有 mesh 统一) |
用法:烘焙命令带 -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 | // 正确:从 Source 取 mip 数据(自动解压,Texture.h:329/332 内联重载) |
产物清单(导出器自动出):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 | UVW = rgb² × Scale + Add(平方域颜色) |
- 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 | // FPerInstanceLightmapData(MapBuildDataRegistry.h:36) |
- 数据:
FMeshMapBuildData::PerInstanceLightmapData(每实例一条); - 分配:
FLightMap2D::AllocateInstancedLightMap(LightMap.cpp:2215)——ISM 组件每实例一套量化系数与 Bias; - 采样:运行时实例缓冲携带
LightmapUVBias,shader 里LightmapUV = UV1 × Scale + Bias后采样共享 atlas——渲染侧与普通光照贴图无差别,只是 UV 多一步偏移。
实例打包公式(GPULightmass,InstancedStaticMesh.cpp:110,已源码验证):
1 | // 所有实例的 lightmap 排进一个正方形大图: |
规模感:单实例 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 | GPU 驱动渲染(Nanite/ISM)→ 几何 DrawCall 几乎免费 → 瓶颈转移到光图采样 |
实践结论:
- Nanite 大世界:Lightmap 照常烘焙使用(
bUseNanite网格自动带 Lightmap UV);实例化(Nanite ISM)自动共享 atlas; - GPU Driven 的收益与 Lightmap 无关:省的是几何阶段(剔除/绘制),光图阶段(采样/带宽)是独立的账——别指望 GPU Driven 省光图内存;
- 光图采样在移动端是带宽大头: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 | 一张 R8G8B8A8 纹理(宽 W × 高 H) |
为什么这样分:光照的亮度动态范围巨大(阴影 0.01 ~ 阳光 500+),而颜色与方向的动态范围小——所以亮度走对数编码(A 通道)+ 残差补精度(下半 A),颜色走平方域(RGB 解码时开方还原保暗部精度),方向走 SH(下半 RGB)。
编码端源码(LightmapEncoding.ush:56-71,已逐行对照):
1 | // FinalizeLightmapIrradiance:辐照度 → 编码 |
解码端源码(LightmapCommon.ush:146-166,已逐行对照):
1 | half LogL = Lightmap0.w; // A:亮度粗值 |
20.3 压缩链:从 HDR 到 8bit 的完整链路
1 | 烘焙(GPU Lightmass tile,RGBA16F HDR) |
每步干什么:
- ① 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 | // Lightmap0.rgb = LogRGB(三分量对数颜色,直接存) |
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 | 处理:padding(岛间距)——装箱 4px 对齐(LightMap.cpp:491)+ 岛间留 ≥4px 空白 |
② 几何接缝(连续面被切开)
一个平面被切成两个岛(各自展开),光照值理论上相同,但量化/压缩后两岛的值可能差 1-2 档 → 缝线可见:
1 | 处理:① padding 让过渡区存在;② 分辨率足够高时差值 < 1/255 不可见; |
③ LOD 接缝
LOD 间 Lightmap UV 不一致 → 切换时缝线跳变:
1 | 处理:所有 LOD 用同源 Lightmap UV(简化时保留 UV 通道); |
④ 法线/方向缝(SH 方向性 + 法线贴图)
HQ 的方向性(SH)与法线贴图交互——两个岛的法线不同导致光照跳变(常见于硬边模型):
1 | 处理:硬边(烘焙法线分开的角)与软边的岛划分要与法线一致; |
排查顺序:看到缝 → ① 放大看是”颜色跳变”(几何/量化缝)还是”串色”(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 | FbxStaticMeshImport.cpp:732-736 for (int32 VertexIndex ... < Mesh->GetControlPointsCount()) |
关键在 :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 无关)。
会被重排改变的(都要当心):
bRemoveDegenerates开 → 位置重合的零面积面被删(烘焙后该处无光照数据);- NaN/Inf 顶点数据 →
ValidateAndFixData会改写/删除,坏 UV 会在此暴露; SrcLightmapIndex/DstLightmapIndex钳制(MeshDescriptionHelper.cpp:106-122):通道超界会被静默改到 0 或补建通道——勾了”生成 Lightmap UV”但忘了看通道号,可能把 UV1 写到别处;- 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 | 位置/法线相等阈值:0.00002(2e-5,cm / 归一化向量) |
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。
动手实验室:验证”顶点不共享也能并岛”
- 建一个 400×100×10 的长条,沿长边中点把顶点断开(两半各自独立顶点、位置完全重合);
- 手动把两半的 UV0 设为连续(左 0
0.5、右 0.51,共点处 UV 值精确相等);- 资产设置勾
Generate Lightmap UV,SrcLightmapIndex=0, DstLightmapIndex=1,保存;- 打开 UV1:两半应处于同一个岛内(共享 texel 行)——位置重合 + UV 连续即可并,与焊接无关;
- 把右半挪 0.1cm 再存:UV1 立刻断成两个岛——0.1cm 就把岛切开了。
21.3 BuildData(MapBuildDataRegistry):烤出来的数据住在哪、什么时候丢
21.3.1 住址:关卡的 UMapBuildDataRegistry
烘焙产物不在 StaticMesh 资产里,也不在光源组件里,而是挂在使用关卡(ULevel)上:
1 | Level.h:559 TObjectPtr<UMapBuildDataRegistry> 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
3MapBuildDataId = FGuid::Combine(OriginalMapBuildDataId, FActorInstanceGuid::GetActorInstanceGuid(*Owner))
// 注释原文:"in case of a LevelInstance Actor we must adjust it to be unique"
// → LevelInstance / WorldPartition 每个放置实例都拿到独立键,各烤各的,绝不串数据StaticMeshComponent.cpp:712GetMeshBuildData(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 | InvalidateStaticLighting:清 Mesh/Light/体积表 → 释放 GPU 资源(含 ResourceCluster,:1044-1055)→ |
② 局部失效(只留指定 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
- 室内小场景放一个长椅(Static),烘焙,保存;
Ctrl+W复制长椅 → 把副本旋转 90° 放到旁边——副本的明暗方向与原位一致(贴图跟着物体走),错得”很自然”;- 选中原长椅移动 1m——光照不再贴合(贴图错位);
- 重新烘焙 → 两者全部恢复正常。全程没有任何报错——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 | ┌──────────────────────── CPU 每帧准备 ────────────────────────┐ |
关键定位: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 | GetPrecomputedIndirectLightingAndSkyLight(MaterialParameters, ..., DiffuseDir, |
第 2 层|函数体内分流(BasePassPixelShader.usf:562-731)——按编译宏选三选一:
1 | PRECOMPUTED_IRRADIANCE_VOLUME_LIGHTING → 体积光照图(VolumetricLightmap)查 3D 纹理(:572-704) |
第 3 层|真正的贴图解码(LightmapCommon.ush:132 GetLightMapColorHQ / LightmapCommon.ush:78 GetLightMapColorLQ):上、下半区采样 → exp2(LogL*16-8) 还原亮度 → SH 点积出方向性(第 20 章已逐行拆过)→ 输出全彩光照与次表面光。这层之后,lightmap 就是纯粹的 float3 颜色了,与”贴图”再无关系。
第 4 层|天空光与合并:
1 | BasePassPixelShader.usf:736 OutDiffuseLighting *= View.PrecomputedIndirectLightingColorScale; // 全局间接光缩放 |
22.3 落点分歧:Deferred 只存亮度,颜色哪去了
22.3.1 Deferred(默认桌面):GBuffer 单通道 = 间接光亮度 × AO
GBuffer 结构里间接光字段的注释毫不掩饰(DeferredShadingCommon.ush:400):
1 | // Indirect irradiance luma |
BasePass 输出段(BasePassPixelShader.usf:2364-2384):
1 | GBuffer.IndirectIrradiance = IndirectIrradiance; // :2368 亮度的"临时寄存" |
编码公式(DeferredShadingCommon.ush:221-234):
1 | Encode: L *= View.PreExposure; return log2(L + 1/256) / 16 + 0.5; // 对数,黑点 1/256 |
源码考古:为什么注释叫 “No space for AO”?
8bit 的 GBuffer 通道是稀缺资源:法线用 2 通道、第 3 通道让位给间接光亮度(旧编码DeferredShadingCommon.ush:659OutGBufferA.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 | BasePassPixelShader.usf:1377 DiffuseColor += (DiffuseIndirectLighting * DiffuseColorForIndirect |
移动端有独立的轻量版本(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:422DecodeIndirectIrradiance),并作为背景底光参与本 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 | 最终颜色 ≈ Σ 动态直接光(DeferredLight 各光源 pass) |
反直觉但源码可证的三件事:
- 改动态光亮度不会”冲淡” lightmap——两者在不同 pass 相加,互不干扰;
- 把一个烘焙过的物体扔进纯黑房间,它依然亮——lightmap 进的是 BasePass,与场景里有没有灯无关;
- **桌面延迟下你看到的光照色调 ≈ 材质 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
- 建小室内场景 + 暖色点光(Stationary)+ 蓝色天光,烘焙;
- RenderDoc 截帧,找 BasePass 的 GBuffer 输出 MRT(编辑器窗口能直接看 GBufferA/C 纹理视图);
- 观察 GBufferA.b(或 GenericAO 通道):暖/蓝光区的数值差异远小于你预期——因为存的是
亮度×AO 的对数编码,颜色信息不在这里;- 再看 GBufferC.a(PrecomputedShadowFactors 第 0 通道):点光投影区域的物体上能看出 0/1 跳变;
- 把点光改成 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 | float2 LightMapCoordinateInput = Input.LightMapCoordinate; // :872 模型 UV1(ATTRIBUTE15, :104) |
逐行要点:
LightmapDataIndex(:878)= 图元在 GPUScene lightmap 数据缓冲里的下标 + LOD 偏移——每个 LOD 有自己的 lightmap 数据项(LOD 分辨率逐级减半,见第 3 章);- **
LightMapCoordinateScaleBias**(:881)来自 GPUScene,不是常量缓冲——这就是”GPU Driven / Bindless”时代取参的方式; - 实例可覆盖偏移(:883-884,
INSTANCE_SCENE_DATA_FLAG_HAS_LIGHTSHADOW_UV_BIAS)——ISM/HISM 里每个实例指自己那一小块(第 19 章的”一图多用”就是靠它); - 结果塞进插值器
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 | // SceneManagement.cpp:1549 VT 路径:各向异性 4x + 三向 Clamp |
Clamp 是硬性要求——Atlas 里相邻物体只隔 4 纹素 padding,用 Wrap 会直接串到邻居的岛上(第 17 章漏光专题的运行时对应物)。
23.4 像素阶段:坐标再变换 + 最多 5 次采样
拿到插值后的 atlas 坐标,像素着色器还要再做一次”压半“(LocalVertexFactoryCommon.ush:118-121):
1 | LightmapUV0 = Interpolants.LightMapCoordinate.xy * float2( 1, 0.5 ); // :120 上半区 |
为什么:非 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 | half LogL = Lightmap0.w; // :146 亮度对数(8bit) |
OutDiffuseLighting = Color(:189)。从这一行开始,lightmap 就是一个普通的 float3,和”贴图”再无关系。
23.6 校准:全彩间接光在 BasePass 就进了 SceneColor(修正 22.4 的表述)
第 22 章 22.4 写”间接漫反射在 DiffuseIndirectComposite 正式登场”,这个说法对 lightmap 不成立。源码链条是这样的:
1 | // BasePassPixelShader.usf |
: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 | Encode: L *= View.PreExposure; return log2(L + 1/256) / 16 + 0.5; |
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 | :2164 const bool bIsLitMaterial = ShadingModels.IsLit(); |
策略 → 宏 → 采样行为对照表:
| 策略 | 注入的宏 | 运行时行为 |
|---|---|---|
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 五步亲眼验证
- 准备:室内场景 + 一盏暖色 Stationary 点光 + 冷色天光,烘焙完成后删除所有动态光(保持烘焙结果);
- 截帧定位 BasePass:在 RenderDoc 里找到
BasePass(或BasePassParallel)那个 pass,勾选它的 MRT[0](SceneColor)输出; - 看 BasePass 之后的 SceneColor:应该已经能看清被暖光照亮的墙面——此时还没有跑任何光照 pass,这就是 23.6 的直接证据;
- 看 GBuffer 亮度通道:GBufferA.b(或新版编码下的 GenericAO)只有明暗、没有冷暖差异,因为它存的是
Luminance × AO的对数编码; - 对比实验:把点光改成 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 | GConfig->GetBool(TEXT("DevOptions.StaticLighting"), TEXT("bCompressLightmaps"), GCompressLightmaps, GLightmassIni); |
即:**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 | // DeferredLightingCommon.ush:111 —— Stationary 灯在自己的光源 pass 里取烘焙的阴影通道 |
结论:太阳(Directional Light)设成 Stationary 时,它的直接光与颜色可以随便动(实时 pass),
被烘焙的只有”间接光”和”静态阴影遮罩”——所以”太阳转动”真正的麻烦只有两处:间接光不跟着变、
烘焙的静态阴影遮罩不跟着转。
24.2.2 方案 A:Stationary 太阳(UE 默认答案,最省事)
- 太阳 Stationary:直接光/颜色/CSM 阴影实时 → 日出日落的角度与颜色自动正确;
- 场景间接光来自 lightmap(固定)→ 表现为”中午的间接光强度,用在黄昏“,但人眼对间接光的绝对强度不敏感;
- 静态阴影遮罩固定 → 太阳转角过大(>30°~45°)时,静态物体上的软阴影方向会穿帮;
- 适用:卡通/风格化、室内为主、或太阳角度变化范围小的项目。
24.2.3 方案 B:烘焙”中性光” + 运行时染色(最常用,代价最低)
这是把 lightmap 当”遮蔽 + 亮度图“用的思路,官方给了现成钩子:
1 | // SceneRendering.cpp:2046-2050 |
而 BasePass 里(BasePassPixelShader.usf:736)正是:
1 | OutDiffuseLighting *= View.PrecomputedIndirectLightingColorScale; // lightmap 结果在这里被整体染色/调强度 |
玩法:
- 烘焙时用中性白/灰环境(或干脆只关心几何遮蔽与光照分布);
- 运行时用后处理设置里的
IndirectLightingColor+IndirectLightingIntensity(也可在 PostProcessVolume 里按时间/天气做曲线)给整场间接光染色、调亮暗; - 天光:Stationary SkyLight 天然就是”遮蔽烘焙、颜色实时”(24.2.1 的 :487),换天空颜色不用重烘焙;
要更彻底就上 **Movable SkyLight +bRealTimeCapture**(SkyLightComponent.h:108,受r.SkyLight.RealTimeReflectionCapture控制,SkyLightComponent.cpp:123-128); - 直接光与阴影交给 Stationary/Movable 太阳 + CSM。
代价:间接光只有整体色偏,没有方向性变化(”夕阳从西边打进来,西墙内侧更暖”做不到)。
24.2.4 方案 C:多套烘焙 + 运行时切换(Lighting Scenarios)
UE 官方的”多套烘焙光照”机制:把每个时段的灯光放进独立的子关卡,勾选 **bIsLightingScenario**(Level.h:575),
加载时整关的烘焙数据(MapBuildDataRegistry)被整体替换:
1 | // LevelStreaming.cpp:1105/1123 |
- 优点:每个时段的质量都是”完整烘焙级”;切换是引擎原生支持的;
- 缺点:内存/磁盘 ×N(两套 = 两份 lightmap);切换是硬切(需要做黑屏/过场/交叉淡入淡出,或用 PostProcess 颜色渐变掩盖);
- 适用:少量离散状态(白天/夜晚、开灯/关灯、晴天/阴天),而不是连续日夜循环。
进阶(引擎不自带):有些项目在 shader 里同时采样两套 lightmap 做 lerp,实现平滑过渡。
这需要改LightmapData/uniform(双份LightmapDataIndex与 Scale/Add)与采样函数,
属于引擎改造(本 fork 未做,标注为非官方方案)。
24.2.5 方案 D:间接光交给动态 GI(Lumen / SSGI)
关掉/不依赖静态光照,间接光实时计算。这里有个引擎自带的防冲突机制(也是第 23 章 23.10 避坑 1 的重要补充):
1 | // SceneRendering.cpp:2052-2057 |
含义:开启动态 Lumen GI 的视图里,lightmap 的间接光被乘 0(连带 Static 灯的直接光也一起没,注释写得很直白)。
所以 23.10 避坑 1 说的”Lumen 与 lightmap 双计”在标准 Lumen 路径下并不会发生——引擎用这个 scale 主动让位了;
需要留意的是”Static 灯的直接光也一起消失“这个副作用。
- 优点:太阳随便动、天气随便换、颜色随便改,全实时;
- 缺点:移动端基本用不起;桌面端也有 GI 分辨率/噪点/帧预算的代价。
24.2.6 方案 E:混合方案(大多数上线项目实际在用)
1 | 静态建筑/地形 → Lightmap(中性烘焙 + 运行时染色) + Stationary/Movable 太阳的 CSM 动态阴影 |
为什么它最实用:把”连续变化”(太阳角度、颜色、强度)交给实时部分;把”昂贵且稳定的部分”(多次反弹的间接光、接触遮蔽)交给烘焙;再用后处理把两者的色调统一起来——人眼看到”整体氛围变了”,就认为是”光照变了”。
24.2.7 决策树
1 | 需要连续日夜循环? |
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 分钟)
- 建一个小关卡烘焙,记录
stat streaming或obj list class=LightMapTexture2D里的贴图大小; - 依次只改一个变量,各烘一次并对比:①HQ→LQ;②
LightMapResolution减半;③流送因子 0.2→0.05; - 记录”画质可接受的最低档”——三项叠加通常能到原始显存的 1/4 左右,但漏光会明显增加(配合第 17 章排查)。
B. 动态光照(15 分钟)
- 太阳设 Stationary,写一个从 0° 到 180° 的旋转(Matinee/Sequence 或 Tick);
- 观察:直接光与 CSM 阴影实时跟随 ✅,间接光强度不变 ⚠️,静态物体上的烘焙软阴影方向不跟随 ❌;
- 加一个 PostProcessVolume,把
IndirectLightingColor从白调到橙红并做动画 → 整场”氛围变黄昏”; - 再做一版 Lighting Scenario:把”正午灯光”和”黄昏灯光”分别放两个子关卡并勾选
bIsLightingScenario,
运行时切换加载 → 观察整套烘焙数据被替换(间接光也跟着变); - 最后打开 Lumen(动态 GI),回看烘焙间接光是否被”让位”(对照 24.2.5 的置零逻辑)。
24.4 一张图:压缩与动态的两个旋钮
1 | ┌──────────── 烘焙期旋钮(改了要重烘) ────────────┐ |
一句话总结:烘焙决定”光从哪来、被挡了多少”;运行时决定”光是什么颜色、有多亮”。 把这两件事拆开,
日夜循环与天气系统就不再需要重烘焙。
第 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 | /** Describes how often this component is allowed to move. */ |
关键那句:”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 三轨模型
25.3 两个判据函数:一切都从这两行开始
1 | // LightComponent.cpp:293-301 |
| Mobility | HasStaticLighting() |
HasStaticShadowing() |
直接光 | 间接光 | 阴影 |
|---|---|---|---|---|---|
| Static | ✅ true | ✅ true | 烘焙(不在光源 pass) | 烘焙 | 烘焙静态通道 |
| Stationary | ❌ false | ✅ true | 实时 | 烘焙 | 双轨(CSM + 静态通道) |
| Movable | ❌ false | ❌ false | 实时 | 实时 GI 或不参与 | 实时(CSM/阴影贴图) |
Stationary 就是”前者 false、后者 true”的唯一一档——这就是它的全部特殊之处。
渲染侧怎么用这两个结果:
1 | // LightSceneInfo.cpp:50 —— 送进渲染器的 compact 结构 |
为什么 Static 灯”改亮度没反应”?因为
HasStaticLighting() == true时它的直接光不在光源 pass 里,
而是和间接光一起被烘进 lightmap(BasePassPixelShader.usf:1377那条链)。运行时改 Intensity 只是改一个
渲染器根本不用的值——要能调,最低得 Stationary。
25.4 ③ 阴影双轨:方向光的距离淡出公式(源码考古)
Stationary 方向光的阴影不是”二选一”,而是按相机距离在动态与烘焙之间 lerp:
1 | // DeferredLightingCommon.ush:96-111 |
读法:
- 近处(
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 | ShadowMapChannelMask = (0?1:0)|(1?2:0)|(2?4:0)|(3?8:0) // 静态:只有 4 个通道 |
只有 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 动手实验室:三分钟验证三轨
- 改颜色免费:放一盏 Stationary 点光,样条/蓝图里让颜色从暖白渐变到青蓝 → 场景色调实时跟随,无需重烘焙;
- 改位置要重建:拖动它的位置 → 编辑器立刻提示 “LIGHTING NEEDS TO BE REBUILT”,烘焙后间接光才正确;
- 关灯不灭:把它
Visible = false→ 直接光消失但墙面的烘焙反弹光仍在(对照避坑 2); - 阴影双轨:把方向光设 Stationary,拖近/拖远相机并观察静态物体上的阴影:近处边缘更”锐”(CSM),
远处随距离过渡到烘焙阴影(DeferredLightingCommon.ush:137),把Dynamic Shadow Distance Stationary Light
调小 → 过渡点前移,烘焙阴影接管得更早; - 通道耗尽:在同一个角落挤 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/Add255,代价只是几个 float uniform)。
(把实际范围铺满 0
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 coefficients are stored on the top/bottom halves of the same destination texture |
26.2 编码侧逐行(GPU Lightmass QuantizeLightSamples)
26.2.1 第一步:把 RGB 拆成”亮度 + 归一化色度”
1 | // LightmapEncoding.cpp:7-30 |
关键设计:色度用的权重(0.3/0.59/0.11)与解码端的 Luminance() 是同一套权重,于是Luminance(U,V,W) ≡ 1——这让 LQ 把三通道亮度信息合成一个 LogL 时可以无损还原:
1 | // LQ 编码,:122-124 |
26.2.2 HQ:对数亮度 + 平方色度 + 残差 + SH
1 | // 量化前先定黑点与刻度,:44-47(LogScale / SimpleLogScale) |
三个容易被忽略的巧思:
1 | // :221-224 ⑥ SH 常数项写死,省掉一次加法和一整个通道的信息量 |
26.2.3 每图 Min-Max 归一化(Scale/Add 的真正来源)
1 | // :203-206 量化:y = (x - Min) / (Max - 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 | // Shaders/Private/LightmapCommon.ush |
可逆性对照表(这张表能记住,编码就吃透了):
| 编码 | 解码 | 作用 |
|---|---|---|
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 为什么能这么压:七条原理
- 感知是对数的(韦伯–费希纳):把亮度取
log2再均匀量化,误差就从”绝对值恒定”变成
“相对误差恒定“(8bit ≈ 每级 0.4% 相对误差)。阳光到阴影跨好几个数量级,只有这样 8bit 才够用;
而且log2/exp2是硬件指令,解码几乎免费。 - 色度可以粗,亮度必须细:人眼对亮度细节的分辨力远高于色度(这也是 YUV 4:2:0 存在的理由)。
UE 的做法是把亮度单独拉一条通道(HQ 的 w),色度三通道只负责”颜色比例”。 - 残差通道 = 白送的精度:8bit 主位之外,A 通道的 8bit 不一定装得满信息,就拿来装小数残差
(HQ 组 1 的 A)。这是”能在 8bit 里塞下 HDR 平滑过渡”的关键一招。 - gamma(平方)空间分配:暗部码值更密是”相对误差恒定”的必然要求;
sqrt编码 +rgb²解码成本极低。 - 一阶 SH 足够 + 常数项写死:间接光是漫反射(低频方向信号),一阶 SH(3 个系数)即可表达
“哪边更亮”;常数项固定为0.282095省掉一次加法和通道的有效载荷;暗纹素的方向性还被主动抑制。 - Min-Max 归一化:逐通道把实际范围铺满 0~255(代价只是几个 float uniform),
避免”整张图只用掉 30 个码值”这种隐形的精度浪费。 - 信号本身低频 + 空间相关:lightmap 存的是间接光(多次反弹后的漫反射),天然平滑;
4×4 块的 BC7/ASTC 压缩、以及 mip 平均都能吃到这个红利——只有阴影边界与 UV 接缝是高频,
这也是”必须留 4 纹素 padding”的根本原因(第 17/20 章)。
26.5 压缩链全景与代价
1 | 烘焙 HDR 光照 (float, 任意范围) |
| 手段 | 压缩比 | 代价 |
|---|---|---|
| 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 动手实验室
- 看数据:把烘焙好的 lightmap 贴图导出为 PNG,肉眼观察——上半区偏灰紫(sqrt 色度 + 对数亮度),
下半区更”噪”(SH + 残差),这跟直觉的”彩色光照图”完全不同,说明它是一份系数图而非图片; - 验可逆:写个 20 行脚本,按 26.3 的公式把组 0 的
w与组 1 的w合成LogL,
再exp2还原,和”只用组 0 的 w”对比——能直观看到 banding 的差别(残差的作用); - 测 LQ 代价:同一场景烘两次(HQ / LQ),在墙角与褶皱处截图对比间接光的立体感差异;
- 验 Scale/Add:在
LightmapData.ush:28-46打印某物体的LightMapScale/Add,
把Max-Min与你的场景光照范围对照,理解”归一化”到底吃掉了多少动态范围; - 踩一次坑:用图像工具把 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 | // LightMap.h:328(FLightMap2D 成员) |
它有个容易被忽略的性质:它不是”一张好看的图”,而是一份几何信息。第 26 章那些系数图存的是”光有多少”
(亮度 + 色度 + 球谐),而这张图存的是”从这里往上看,天空被挡了多少、主要从哪个方向漏进来“。
这个差别决定了它后面所有压缩上的取舍。
27.2 编解码逐行:一个方向 + 一个可见度
烘焙端(Lightmass)的量化,源码只有 9 行,但信息量很大:
1 | // UnrealLightmass/Private/Lighting/LightmapData.cpp:266-274 |
拆开读,这是两个物理量塞进一个 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 | // LightmapCommon.ush:204-210 |
两个细节值得单独说:
*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 | // LightMap.cpp:1857 SkyOcclusion:W × H |
因为系数图是 W × 2H 的”上下拼版”,顶点里存的那份 lightmap UV 是”拼版后”的坐标系;
而 SkyOcclusion(以及 AOMask)是单高度图,所以采样前必须把 UV 还原一次:
1 | // LightmapCommon.ush:53 |
实用含义:如果你手工导出一张 SkyOcclusion 并替换原来的,**尺寸必须是
W×H而不是W×2H**,
否则会整体错位半张。第 26.6 的”通道用途耦合”提醒过一次,这里是”尺寸语义耦合“,同一类坑。
27.4 能压吗?——能,而且默认就在压
直接看 EncodeSkyOcclusionTexture 的头部(这是本章要回答的问题的答案所在):
1 | // LightMap.cpp:1439-1443 |
CompressionNone = !GCompressLightmaps→ 只要GCompressLightmaps是true(默认,LightMap.cpp:50),
它就走块压缩。这个总闸同时管着系数图、SkyOcclusion、AOMask 和静态阴影贴图
(第 24 章已考证:静态阴影因为存距离场,被强制不压缩,是唯一的例外)。SRGB = false→ 存的是数据不是颜色,不能过 sRGB 曲线(否则sqrt/exp2全乱)。CompressionNoAlpha = false→ 关键,下一节展开。
所以”能不能压”的答复是:能,且默认开启。真正有信息量的问题不是”能不能”,而是”能压成什么格式“。
27.5 但格式被 alpha 卡住了(本章重点)
把四张图的格式设置并排放,差异一目了然:
1 | // LightMap.cpp:1640 系数图:HQ 组(下标 0/1)保留 alpha,LQ 组(下标 2/3)可丢 |
LQ_LIGHTMAP_COEF_INDEX = 2(SceneManagement.h:357),所以系数图是”HQ 带 alpha、LQ 丢 alpha“。CompressionNoAlpha 到底影响什么?可以拿虚拟纹理那段代码反推它的语义
(RuntimeVirtualTextureComponent.cpp:482-483):
1 | OutFormatSettings.CompressionNoAlpha = LayerFormat == PF_DXT1 || LayerFormat == PF_BC5 || ...; |
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 Bvs1.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 | float4 WorldSkyBentNormalAndOcclusion = GetSkyBentNormalAndOcclusion(..., ScaleLightmapUV(LightmapUV, float2(1.0f, 2.0f)), ...); |
“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 | // LightMap.cpp:1363-1387 FLightMapPendingTexture::NeedsSkyOcclusionTexture() |
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 | // LightmapCommon.ush:202 VT 路线,LayerIndex = 3u |
对应的 CPU 侧(LightMap.cpp:1930-1932)把层的源格式设成 SkyOcclusionFormat(TSF_BGRA8,:1849),
而整张 VT 对象的压缩开关与独立贴图不同(LightMap.cpp:1948-1950):
1 | VirtualTexture->CompressionNoAlpha = false; |
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:1857vs: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 动手实验室
- 看四张图:烘焙一个含 Stationary 天光 + 室内遮挡的关卡,用第 18 章导出的思路把
BuildData下的纹理列出来,对比 HQ 系数图(W×2H)与 SkyOcclusion(W×H)的尺寸——把 27.3 的结论看实; - 验通道:把 SkyOcclusion 导成 PNG,观察 A 通道(应呈”遮蔽负片”观感)、RGB 通道(大体是中灰偏方向性的渐变),
确认它既不是彩色图也不是灰度图,而是方向 + 可见度; - 测 compress 开关:
Lightmass.ini里把bCompressLightmaps关掉重烘,对比 SkyOcclusion 的显存占用——
应该翻到 4 倍(4 B → 未压缩的 BGRA8);再和系数图对比,验证”两张图受同一个总闸控制”; - 踩一次错尺寸的坑:手工把一张 SkyOcclusion 按
W×2H喂回去,观察天光整体错位半张,
然后按W×H恢复(对照避坑 2); - 验 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 | // WorldSettings.h:46-51(VLM_SparseVolumeLightingSamples 的注释) |
两句话点死 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 | /** |
逐句拆: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 | // PrecomputedVolumetricLightmap.h:203-208(节选) |
先看最反直觉的地方:为什么需要两层纹理?
因为 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 | struct FVolumetricLightmapBasicBrickDataLayers |
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 | // Core/Public/Math/PackedVector.h:18-30(节选) |
(注意:SkyBentNormal 是可选的——没有天空阴影时压根不分配,GetMinimumVoxelSize() 里那行注释写得很明白:”excluding SkyBentNormal because it is conditional”。)
28.4 砖为什么是 BrickSize + 1
砖块数据在砖图上不是”紧密排列”,而是每块砖外面裹一圈 1 纹素的 padding:
1 | // ImportVolumetricLightmap.cpp:239-240 |
为什么要多这一圈?因为采样是三线性插值(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 | /** |
世界坐标 → 砖纹理 UV 的完整公式(GPU 侧,Shaders/Private/VolumetricLightmapShared.ush:25-34):
1 | float3 ComputeVolumetricLightmapBrickTextureUVs(float3 WorldPosition) |
逐行读第 ③ 步:
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 | // AdaptiveVolumetricLightmap.cpp:881 |
球谐的 0 阶系数(常数项)被单独拎出来,叫 AmbientVector(环境向量),三个通道各存一个数。
剩下的 8 个方向项系数,全部除以同通道的环境项,变成 [-1,1] 的归一化比值,再 8bit 量化:
1 | // AdaptiveVolumetricLightmap.cpp:917-924(节选) |
为什么敢除以环境项? 因为间接光的”方向性”本质上是相对于整体亮度的。
一间又暗又均匀的屋子和一间又亮又均匀的屋子,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 | // AdaptiveVolumetricLightmap.cpp:261-267 |
然后是三条细分理由(其余情况返回 false,省内存):
- 密度体(Density Volume)的要求(
:269-304):
采样点落在密度体里,就用它的AllowedMipLevelRange覆盖默认策略
(Scene.GetVolumetricLightmapAllowedMipRange)。落在允许范围内就细分、超出就停——这是 28.7 的主角; - 几何体相交(
:306):DoesVoxelIntersectSceneGeometry(CellBounds, ...)——体素碰到静态几何就细分。
旁边的注释点出了动机:*”Refine around static lights, where lighting is going to be changing rapidly”*; - 静态点光/聚光靠近(
:317-345):即使没碰到几何,一盏静态(Static/Stationary)的点光/聚光
如果”比体素还小”(LightBounds.W < ExpandedBoxSphereBounds.SphereRadius)→ 直接细分
(*”we will likely undersample it”*,太小会被欠采样);否则还要看光在这个体素里的亮度是否超过LightBrightnessSubdivideThreshold:
1 | // AdaptiveVolumetricLightmap.cpp:334-341(节选) |
还有一条”减法”:Landscape 下方的砖可以被扔掉(:348-370):
1 | // AdaptiveVolumetricLightmap.cpp:348-350 |
但 FVolumetricLightmapSettings::bCullBricksBelowLandscape 的注释(SceneExport.h:369-370)明确警告:
1 | /** Whether to cull bricks entirely below landscape. |
——地形有洞或有地下洞穴时,这个优化就是错的(洞里会没有光)。这是本章避坑表的第一条。
细分完还要减一遍砖:
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 | mip0: DetailCellSize |
VolumetricLightmapDetailCellSize 默认 200(世界单位 = 2 米,WorldSettings.h:226),
于是三档就是 200 / 800 / 3200。默认策略是”几何和静态灯附近自动用高密度”
(就是 28.6 那三条理由),而 AVolumetricLightmapDensityVolume
(Classes/Lightmass/VolumetricLightmapDensityVolume.h:15,一个 AVolume 子类)
让你手工覆盖这个策略:
1 | // VolumetricLightmapDensityVolume.h:19-33(注释节选) |
三条实用姿势:
[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 | * Whenever highly directional lighting is stored in a Spherical Harmonic, a ringing artifact occurs |
强方向性的光存进 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 | // LightMapRendering.cpp:626-640(节选) |
和 GPU 版的差别只有一个词:**SubLevelIndex。
VLM 的砖图集是跨关卡共享的(FVolumetricLightmapBrickAtlas,PrecomputedVolumetricLightmap.h:580;
全局实例 GVolumetricLightmapBrickAtlas,:620),
所以插值时要先定位”这个位置属于哪个子关卡”(CPUSubLevelBrickDataList,:231)。
这也解释了 VLM_SparseVolumeLightingSamples 为什么费——它没有 GPU 路,只能走这条 CPU 路**。
CPU 插值还要多做一次通道序修正,因为导入时格式从 BGRA 换成了 RGBA(LightMapRendering.cpp:662-663):
1 | // Swap R and B channel because it was swapped at ImportVolumetricLightmap for changing format from BGRA to RGBA |
第三个消费者:Capsule Shadow(胶囊体阴影)。
可移动角色脚下的软阴影需要知道”间接光从哪个方向来”,这个方向可以直接从 VLM 反推——CapsuleShadowRendering.cpp:116 的 FComputeLightDirectionFromVolumetricLightmapCS
就是这么干的(着色器是 CapsuleShadowShaders.usf 里的 ComputeLightDirectionFromVolumetricLightmapCS)。
没有 VLM 也没有实时天光捕获时,这条链是断的(:784 的条件判断)。
28.10 显存总账:一个体素值多少字节
回到本系列的传统项目——算账。GetMinimumVoxelSize()(PrecomputedVolumetricLightmap.cpp:188-203)
直接给了答案:
1 | // PrecomputedVolumetricLightmap.cpp:188-203(节选) |
| 项 | 字节/体素 |
|---|---|
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 动手实验室
- 看数据结构:烘焙一个带
Lightmass Importance Volume的关卡,翻MapBuildDataRegistry,
把IndirectionTexture和砖数据纹理的尺寸、格式列出来——对照 28.3 的表,确认AmbientVector是R11G11B10(4 字节/体素)而不是浮点 RGB; - 看自适应:用
r.VolumetricLightmap.VisualizationRadiusScale/.VisualizationMinScreenFraction
(VisualizeVolumetricLightmap.cpp:30-44)打开可视化,在几何密集区与空旷区各看一遍——
亲眼确认”哪密哪疏”,理解 28.6 的三条细分理由; - 算 padding:把
BrickSize从 32 改到 8,数一数砖的数量与总显存变化,
验证 28.4 的 “+42% vs +10%” padding 占比; - 验 Density Volume:在一片空旷大厅里放一个
AVolumetricLightmapDensityVolume,
先设AllowedMipLevelRange = [1, 3],再改成[0, 0],
用stat MapBuildData对比两次的显存——感受 28.7 里”省钱档”与”加钱档”的差距; - 踩一次内存上限:把
VolumetricLightmapMaximumBrickMemoryMb调到 1 后重烘,
观察远处物体间接光出现的跳变(对照避坑 3); - 验跨端一致性:在
VolumetricLightmapShared.ush:61-69与LightMapRendering.cpp:649-666逐行对照两张SHDenormalizationScales表,
确认它们逐位相同——这是 28.5 那句”has to match”的实证; - 看体积雾吃光:开
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 | // LightMap.h:488-497(节选) |
一个纹素 = 一组 RGB(+ 方向性 SH + 天空遮蔽 + AO),就这些。
没有 PerLight[8] 之类的结构。整个 lightmap 只有一份颜色。
那”这张图是哪几盏灯烘出来的”记在哪?记在一个平级的数组里:
1 | // LightMap.h:59-76(节选) |
烘焙端同理(LightMap.h:516-517):
1 | /** The GUIDs of lights which this light-map stores. */ |
这个数组是**”收据”而不是”索引”**:它只回答”这盏灯的贡献在不在里面”(布尔问题),
不能回答”这盏灯贡献了多少、能不能单独改”。导入时它是这么被填进去的(LightMap.cpp:2125-2137):
1 | LightMap->LightGuids = SourceQuantizedData->LightGuids; |
一句话:lightmap 是”总和 + 一张灯名单”,不是”每灯一份账”。
这就注定:单灯的间接光,运行时无法单独改。
29.2 运行时能不能改:一把总闸 AreDynamicDataChangesAllowed()
所有灯光的运行时 setter 都长了同一张脸。挑三个看(LightComponent.cpp):
1 | // LightComponent.cpp:1076-1087 |
1 | // LightComponent.cpp:1136-1154 |
SetIndirectLightingIntensity(:1089)、SetTemperature(:1157)、SetVolumetricScatteringIntensity(:1109)、SetAffectGlobalIllumination(:124)……全都是同一把锁。这把锁长这样:
1 | // SceneComponent.h:1357-1366 |
翻译成人话(默认 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 | // Properties that should only unbuild lighting for a Static light (can be changed dynamically on a Stationary light) |
把布尔逻辑摊开(|| 的短路在这里是”只有当 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 | // LightComponent.cpp:1483-1526(节选) |
关键动作是 UpdateLightGUIDs()——给灯换一个新 GUID(LightComponent.cpp:271-289):
1 | OriginalLightGuid = FGuid::NewGuid(); |
(顺带一个有用的细节:Movable 灯的 GUID 是 0——它压根不参与烘焙身份识别。
另外 cook 时走 NewDeterministicGuid(GetFullName()),保证同名可复现。)
换 GUID 的后果链条是:
1 | 改属性 → InvalidateLightingCache → UpdateLightGUIDs → 灯拿到新 GUID |
也就是说,引擎判断”这盏灯烘没烘”靠的是 GUID 匹配,不是靠任何时间戳或脏标记。
这个设计很省事,但也带来两个必须知道的后果(下面两节)。
29.5 引擎的自动兜底:GetStaticInteraction 的四档
有了 GUID,引擎就能对每一个(灯 × 图元)对回答一个问题:
“这盏灯对这个网格的贡献,是已经在烘焙里了,还是得动态画?”
1 | // SceneManagement.cpp:1620-1660 |
四档的含义(枚举定义在 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 | for(int32 LightIndex = 0;LightIndex < Mesh->RelevantLightsGuid.Num();LightIndex++) |
它存在 FMeshMapBuildData::IrrelevantLights(MapBuildDataRegistry.h:60)里,跟着 MapBuildDataRegistry 一起序列化、一起流送。
消费端是 FStaticMeshSceneProxy::GetLightRelevance(StaticMeshSceneProxy.cpp:2392-2435),
它把每个 LOD 的交互类型汇成四个 bool:
1 | if (InteractionType != LIT_CachedIrrelevant) bRelevant = true; |
注意 bLightMapped 把 LIT_CachedIrrelevant 也算作 true——
“烘焙判定无关”和”烘焙已包含”在这里是同一类结果:静态部分已经算过了,都不需要再动态画。
29.6 “没烘过的灯”会被动态预览
现在把 29.4 的”GUID 换了”和 29.5 的”匹配不上”接起来:
一盏 Static 灯改了颜色 → 拿到新 GUID → 任何 GetStaticInteraction 都匹配不上 → 它还没烘进任何 lightmap。
这时候引擎不会让它”消失”,而是把它当动态灯画出来(这就是编辑器里”没烘过的灯也有光”的原因):
1 | // LightSceneInfo.cpp:245-250 |
拆开读:
- 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 | if(bCastShadow && bIsDynamic) |
三个要点:
- 判定的是”uncached static lighting interaction“——一个(静态图元 × 未烘灯)对算一个;
GUnbuiltPreviewShadowsInGame决定打包版里要不要画预览阴影(编辑器场景不受限制);ELightmapType::ForceSurface的图元被排除在外(见 29.9)。
编辑器里还能看到对应的可视化提示(LightRendering.cpp:1855):
1 | const bool bDrawPreviewIndicator = ViewFamily.EngineShowFlags.PreviewShadowsIndicator |
那个”Preview Shadows”小图标背后就是 IsPrecomputedLightingValid() 在说话。
29.7 开关灯:Visible 竟然在排除列表里
回到”开关”这件事。灯光的可见性有两个层次:
| 层次 | 字段 | 影响 |
|---|---|---|
| 组件可见性 | bVisible(USceneComponent::GetVisiblePropertyName()) |
是否被渲染(ShouldRenderLight,LightSceneInfo.cpp:201) |
| 参与烘焙 | bAffectsWorld(LightComponentBase.h:52) |
OnRegister 时是否向烘焙系统登记(LightComponent.cpp:329-334) |
1 | // LightComponent.cpp:329-334 |
bAffectsWorld 只在两处被写:构造时 = true(LightComponent.cpp:439),
以及 ALight::Destroyed() 里置 false(Light.cpp:62-77)——删灯会顺带失效光照缓存:
1 | // Light.cpp:62-77(节选) |
而**”勾掉 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 | // LightComponent.cpp:1713 |
顺着 IndirectLightingScale 找下去,真正用它的地方几乎全在 Lightmass 里:LightmassScene.cpp:599(间接颜色)、:974(光通量)、:1271(入射功率)、:2140 和 :2363(bCalculateForIndirectLighting ? IndirectLightingScale : 1.0f)。
运行时只有一个特例(LightRendering.cpp:691-695):
1 | // When rendering reflection captures, the direct lighting of the light is actually the indirect specular from the main view |
——只在反射捕获渲染时才乘,主视图不乘。
所以结论很干脆:**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 动手实验室
- 看”和”的证据:烘焙一个含 3 盏 Static 灯的关卡,用第 18 章的导出思路把 lightmap 取出来,
数一数——每纹素只有一组 RGB。再在MapBuildData里找到LightGuids数组,
确认它只是个”灯名单”(对照 29.1); - 验 Static 改不动:运行时(PIE)对一盏 Static 灯调
SetLightColor/SetIntensity,
观察画面毫无变化;再对一盏 Stationary 灯调同样的接口,观察直接光变、间接光不变
(把 Stationary 灯改成朝天花板照,最容易看出”墙面反弹还是旧色”); - 验编辑器排除表:编辑器里把一盏 Static 灯的颜色改掉——应立刻出现
“LIGHTING NEEDS TO BE REBUILT”;再把一盏 Stationary 灯的颜色改成明显不同的颜色——
不会有提示(对照LightComponent.cpp:866-869那三行); - 看 GUID 换新:在
UpdateLightGUIDs()(LightComponent.cpp:271)下断点,
改一次 Static 灯的颜色,观察OriginalLightGuid变化;然后确认旧 lightmap 的LightGuids里已经没有它了(LightMap->ContainsLight返回 false); - 看预览降级:烘焙完成后新增一盏 Static 灯(不重新烘),
观察它被动态画出来的样子;打开/关闭r.Shadow.UnbuiltPreviewInGame(GUnbuiltPreviewShadowsInGame)对比阴影;
再打开Show → Advanced → PreviewShadowsIndicator,看那盏灯出现预览图标(LightRendering.cpp:1855); - 踩一次 500 阈值:在一个只有十几个网格的小场景里新增 Static 灯,
观察它可能”完全不亮”(对照避坑 4),再把场景复制几十份让未烘焙交互超过 500,
观察预览突然生效; - 验开关:分别隐藏一盏 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 | // UnrealLightmass/Private/Lighting/TextureMapping.cpp:798-866(节选) |
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 | // TextureMapping.cpp:1487-1535(节选) |
注意 ShadowValue 是 FVector2f(分子 / 分母):光源表面被采样 N 次,其中 M 次可见,则ShadowFactor = M / N。软阴影不是模糊出来的,是对光源面积做蒙特卡洛积分积出来的——
所以离遮挡物越远、LightSourceRadius 越大,半影自然越宽。这是 Static 阴影质量高的根本原因。
半影区还会自动加密采样(GetCachedSurfaceSamples(0, true) 拿更密的一组),
这是”面积阴影越远越软”这条直觉的源码依据。
30.1.2 可见度怎么用掉:乘进光照值,不落地成图
1 | // TextureMapping.cpp:1698-1722(节选) |
AddWeighted(DirectLighting, AdjustedShadowFactor) 是整章的题眼:
阴影因子连一字节独立存储都没有拿,它直接变成了”这一纹素的直接光有多亮”。
你最后在 lightmap 里看到的 RGB,已经是 辐照度 × 可见度 的乘积。
三个直接推论:
- Static 灯的数量没有上限。第 N 盏灯再来一次
AddWeighted累加即可,不存在”通道不够”这回事; - Static 灯的阴影分辨率 = lightmap 分辨率,纹素就是最小单位,放大看是台阶;
- Static 灯的阴影没有办法”取出来”单独操作——关灯、改色、改亮度都不会让它变化(第 29 章)。
30.1.3 一个可选的后处理:bFilterShadowFactor
算完还有一道纹理空间 3×3 滤波(:1552-1660),权重固定:
1 | .5*.150 .5*.332 .5*.150 |
它的开关是 Scene.ShadowSettings.bFilterShadowFactor,梯度阈值 ShadowFactorGradientTolerance。
滤波的作用是压掉采样噪声(半影区的蒙特卡洛抖动),代价是阴影边缘会再软一点。
注意 :1588-1593 这一行:
1 | if (ShadowMapData) |
专门为 shadowmap 分支存在——因为 shadowmap 的法线衰减在运行时逐像素做,
背面纹素必须被正面”污染”一下才不会出现黑边。Static 灯(无 shadowmap)不需要这个处理。
30.2 Static 灯 · GPU Lightmass:殊途同归
GPU Lightmass 那边证据更直接。TransencodeShadowMap 这个 lambda 的第一行就是断言:
1 | // GPULightmass/Private/Scene/Scene.cpp:2264-2271(节选) |
而”这盏灯进了这张 lightmap”的登记函数对 Static 灯只做一件事:
1 | // GPULightmass/Private/Scene/Scene.cpp:222-238 |
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 | // Renderer/Private/LightSceneInfo.cpp:245-250 |
HasStaticLighting() 为 true(Static 灯)且 IsPrecomputedLightingValid() 为 true 时直接返回 false
——没有 FLightSceneInfo 进入光照列表,没有 ShadowMapChannelMask,没有 GetShadowTermsBase。
那 shader 里那条”取静态阴影通道”的代码碰到 Static 灯会怎样?看这里:
1 | // Shaders/Private/LightmapCommon.ush:230-259(节选) |
返回 0 看着吓人,但消费端有保护:
1 | // Shaders/Private/DeferredLightingCommon.ush:109-114 |
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 | // TextureMapping.cpp:805-810 |
四个灯 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 | // Editor/UnrealEd/Private/Lightmass/Lightmass.cpp:280-283 |
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 | // Only mark the texel as mapped if we are inside the light's influence |
“只标记影响范围内的纹素为 mapped”是通道复用能成立的前提——
两盏互不相交的灯可以共用通道 0,因为它们各自的 Coverage 互不重叠(见 30.6.2)。
第 2 步 · 决定上采样因子(:1892-1935)
先算网格的平均纹素密度(LightmapTriangleArea / TriangleArea),再:
1 | const float RightTriangleSide = FMath::Sqrt(2.0f * AverageTexelDensity); |
取奇数是为了保证”每个低分辨率纹素中心恰好对应一个高分辨率样本”。
小网格高密度 → 不需要上采样;大网格低密度 → 上采样到 13×。
第 3 步 · 标记过渡邻域(:2051-2101)
检查 4 邻域,只要有一个邻居的可见度和自己不同,就给这个纹素打上NeedsHighResSampling 并分配 UpsampleFactor² 个高分辨率样本槽位。
只有过渡带才做高精度采样,这是性能的关键。
第 4 步 · 高分辨率采样(:2176-2242)
对过渡带内的每个高分辨率样本再打一条射线,这次要最近交点距离(用来算半影):
1 | if (Intersection.bIntersects) |
第 5 步 · 散射(:2252-2478)
这是”距离场”这个名字的由来。遍历过渡带上的被遮挡高分辨率样本,
把它到过渡点的世界空间距离,散射给 MaxTransitionDistanceWorldSpace 范围内的所有低分辨率纹素,
每个纹素保留最小的那个距离:
1 | // TextureMapping.cpp:2442-2467(节选) |
编码规则: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 | // GPULightmass/Shaders/Private/LightmapPathTracing.usf:1436-1482(节选) |
三个细节:
- 每帧只打 1 条射线,靠
LightSampleIndexArray[TileIndex]推进 Halton 序列,
多帧累积到StationaryLightShadowSamples(默认 128,GPULightmassSettings.h:71); - 双面材质打两个半球(
bMaterialTwoSided ? 2 : 1),取max——两个半球只要有一个看得见就算可见; - 用
asuint/asfloat做整数累加(因为 UAV 是float4)。
最后在清帧的 pass 里做归一化和编码:
1 | // GPULightmass/Shaders/Private/LightmapBufferClear.usf:71 |
sqrt 是在 GPU 上做的,对应 CPU 端 :829 那句 DestShadowSample.Distance = FMath::Sqrt(...)。
回读到 CPU 后的收尾:
1 | // GPULightmass/Private/LightmapEncoding.cpp:351-359 |
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 | // LightComponent.cpp:1781-1939(节选) |
失败(4 个通道全被重叠灯占满)时:
1 | // LightComponent.cpp:1929-1934 |
退回全动态阴影。这就是第 25 章说的”4 通道预算”的真实代价:不是画质下降,是性能悬崖。
30.6.2 打包:多灯写同一张 RGBA,靠 Coverage 分区
1 | // ShadowMap.cpp:898-933(节选) |
两盏灯共用通道 0 时,它们各自的 Coverage>0 区域互不重叠(30.4.3 第 1 步保证),
所以写进同一个字节通道也不会互相覆盖。这就是”4 通道 ≠ 只能 4 盏灯”的原因——
只要影响范围不重叠,几十盏灯也能共用通道 0。
代价是那行警告:一张 shadowmap 只存一个 penumbra size,共享通道的灯被迫用同一个值。
30.6.3 落盘格式:不压缩,而且 2 通道就付 4 通道的钱
1 | // ShadowMap.cpp:329-334 |
LightMap.cpp:1594-1597 的打包路径同样:
1 | FTextureFormatSettings FormatSettings; |
为什么必须不压缩? 因为距离场是”到边界的距离”,块压缩(BC/ASTC)在 4×4 块内只有
2 个端点做线性插值,会把平滑的距离梯度打成台阶——距离场一旦被块压缩,重建出来的边缘就废了。
这是它跟系数图最本质的区别:系数图压的是颜色(错了顶多色偏),shadowmap 压的是几何量。
更要命的是这条:
1 | // LightMap.cpp:1851 / ShadowMap.cpp:329 |
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 | void DistanceFieldShadowsAndLightMapPolicyImpl::ModifyCompilationEnvironment(...) |
策略选择(BasePassRendering.cpp:2183-2204):
1 | case LMIT_Texture: |
避坑(桌面端):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 | // Shaders/Private/LightmapCommon.ush:241-253 |
展开一下: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 | // Shaders/Private/DeferredLightingCommon.ush:97-144(节选) |
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 | // LightmapCommon.ush:99-101 |
(颜色那半的 alpha 同理,LQ 只有 rgb 三个量。)
所以 LQ 里其实有两个免费的 8bit 槽位——这是好消息。坏消息在当前格式:
1 | // LightMap.cpp:1638-1642 |
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 冲突,不推荐) |
三条读数:
- 只在”现状是 2~4 通道”时才划算:5.0 → 2.0(A)或 1.5(B)。
如果场景本来就只用到 1 个通道(2.0 B),方案 A 打平、且丢掉了逐灯能力——纯亏。 - 方案 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。 - **方案 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~2 个通道:现状 2.0 B,方案 A 也是 2.0 B,白丢逐灯能力;
- 需要运行时开关/调亮单灯:这是 Stationary 存在的理由,别自废武功(第 29 章);
- 有大范围方向光:方向光的静态阴影要和 CSM 按距离 lerp(
DeferredLightingCommon.ush:135),
合并后方向光和局部光挤在同一个V̄里,这条 lerp 就没法只对方向光做了; - 低分辨率 lightmap + 依赖软阴影:丢掉距离场后,边缘精度退回纹素级,
原本靠 SDF 撑着的”低分辨率也有平滑阴影”会消失。这一条在移动端尤其要验。
30.8.6 我的建议:分三步走,别一步到位
- 先做零风险的那一半:把
NumShadowChannelsUsed从”整张图集取 max”
改成”按图集分页/按区域判定”,或者干脆让打包时优先把多通道物体聚到同一张图集。
30.6.3 那颗”老鼠屎”往往就是最大的浪费; - 再评估能不能压缩:如果项目只用面积阴影 / GPU Lightmass(不依赖 SDF 重建),
那张图存的其实是sqrt(V)而不是距离场,它对块压缩的敏感度远低于真距离场——
改成 BC3(1 B/纹素,4 通道)或每通道一张 BC4(2 B/纹素)值得一试,
5.0 B 直接降到 2.0~3.0 B,且零语义损失; - 最后才考虑合并:且只在”确认灯布好就不动”的场景里做,
平台是移动端 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 | // TextureMapping.cpp:1694-1723(节选) |
配合 :853-865(Static)与 :805-851(Stationary)两条分支一起看,结论非常干净:
ShadowMapData |
直射光去哪了 | lightmap 里装的是什么 | |
|---|---|---|---|
Static(:855) |
NULL |
烘进 lightmap(L·V 累加) |
本灯直射 + 全部间接光 |
Stationary(:813/:844) |
非 NULL |
一个字节都不烘,留到运行时 | 只有间接光(本灯直射缺席) |
三条必须记住的推论:
- lightmap 里没有 Stationary 灯的直射。所以它上面挂的可见度再怎么搬,都不会碰到间接光——
30.8.3 里隐含担心的”把间接光一起压暗”根本不成立。这是本节能成立的地基。 - 也正因为直射不烘,Stationary 灯必须走延迟渲染。判据在
LightSceneInfo.cpp:245-250:ShouldRenderLightViewIndependent()要求!Proxy->HasStaticLighting() || !IsPrecomputedLightingValid()。
而HasStaticLighting()与HasStaticShadowing()是两个判据:
前者只有 Static 灯为真(”位置与参数在运行时都不变”),后者 Stationary 也是真
(”有烘焙的直射阴影,但亮度和颜色仍可变”)——见LightComponentBase.h:221-232的注释。
→ Static 灯两判据皆真 → 直接不进延迟渲染(30.3 那个”零成本”的由来);
Stationary 灯HasStaticLighting()为假 → 照常跑延迟光照。 - 于是一盏 Stationary 灯的最终画面 = lightmap(间接) + 延迟逐灯直射 × 烘焙可见度。
可见度是乘在运行时那一半上的,不是乘在 lightmap 上的。
30.8.7.2 “Static 的品质”这半个问题,引擎已经答完了:勾 bUseAreaShadowsForStationaryLight
TextureMapping.cpp:811-836 —— 勾上 bUseAreaShadowsForStationaryLight 之后,
Stationary 灯走的是和 Static 灯完全同一个函数:
1 | if (Light->LightFlags & GI_LIGHT_USE_AREA_SHADOWS_FOR_SEPARATE_SHADOW_FACTOR) |
同一批面积光蒙特卡洛采样: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 仍然要付的代价(不因上面的更正而消失)
- 逐灯不可分辨:多灯共用一个
V̄时,”关掉一盏被遮挡的灯,它的影子还留在墙上”(30.8.3)。 - 半影尺寸烘死:现在
InvUniformPenumbraSizes是运行时缩放(LightmapCommon.ush:248-252),
合并后 V 是最终值,改软硬只能重烘。(走面积光路径时本来也是最终值,此项影响有限。) - 丢掉 SDF 的亚纹素重建:SDF 能在低分辨率 lightmap 上给出比纹素更细的边缘;
换成标量后退回到纹素级台阶。移动端 + 低分辨率时这条最致命。 bIsCompletelyOccluded优化失效::1729-1733现在会在”整张图全遮挡”时直接delete ShadowMapData;
合并后没有”单灯全遮挡”这个概念了,这张图再省不下来。- dilate 语义不同:阴影的外扩应该”保持在阴影内/外”,不能像颜色那样插值出中间值(30.8.4 ⑤)。
- 只在 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 动手实验室
- 证 Static 无 shadowmap:在一个只有 Static 灯的关卡烘焙,然后
stat streaming/ 内存报告里数ShadowmapTexture的数量——应为 0;
再加一盏 Stationary 灯重烘,观察多出来的那张(LightMap.cpp:1876-1888); - 看 4 B/纹素的跳变:让两盏 Stationary 灯的影响范围完全不重叠烘一次,
再让它们轻微重叠烘一次,对比 shadowmap 纹理的格式与大小
(G8 vs BGRA8,对照LightMap.cpp:1851); - 验解码公式:把
LightmapCommon.ush:252的ShadowFactors输出成颜色
(临时改r.VisualizeLightmapTextures或加一个 debug 视图),
分别看 SDF 路径(InvPenumbra >> 1)与面积路径(InvPenumbra = 1)的差别; - 踩一次通道分配失败:在 10 m² 的房间里摆 6 盏互相重叠的 Stationary 灯,
烘焙后翻 LightingResults 日志,找到那条
“Failed to allocate shadowmap channel“(LightComponent.cpp:1933); - 验 GPU Lightmass 的 penumbra 坑:用 GPU Lightmass 烘一盏 Stationary 方向光,
先不勾bUseAreaShadowsForStationaryLight,观察阴影边缘是否变成硬切;
勾上再烘一次对比(对照 30.5.1); - 做一次字节账:按 30.6.4 的表,把你项目的实际配置(通道数 / HQ or LQ / 有没有 SkyOcclusion)
代进去,算出”每 lightmap 纹素几字节”,再乘以图集面积,
和stat rhi里看到的 lightmap 显存对照——看差多少倍 mip 系数; - 验合并公式:写个小脚本,随机生成 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 | // LightmapPathTracing.usf:1436-1482(节选) |
三个必须记住的点:
光线方向不是”朝光源中心”,而是朝”光源表面上的一个随机点”。
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)。
- 方向光 →
单条光线是二值的:
RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH+IsMiss()→ 非 0 即 1。
软阴影不是靠单条光线”部分透过”实现的,而是靠多条光线打在光源的不同位置统计出来的。
这条直接决定了后面 30.11.5 那个陷阱。累加发生在整数域:
asuint(...) + Visibility是整数加法(避免浮点累加误差),SampleCount单独记数;双面材质在两个半球之间取max,一个样本仍只贡献一个 0/1。
最后一步归一化 + 编码,在 LightmapBufferClear.usf:66-72:
1 | uint4 ShadowValue = asuint(ShadowMask[TexelIndexInPool]); |
V = ShadowValue / SampleCount,落盘时存的是 sqrt(V)。
回到 CPU 侧由 LightmapEncoding.cpp:351-359 量化成样本:
1 | FQuantizedSignedDistanceFieldShadowSample ConvertToShadowSample(FLinearColor ShadowMask, int32 ChannelIndex) |
30.11.3 CPU Lightmass 算的是同一个数
TextureMapping.cpp:1487-1535:
1 | const FVector2f ShadowValue = CalculatePointAreaShadowing(...); // X = 可见样本数, Y = 总样本数 |
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 | // TextureMapping.cpp:798-865(结构节选) |
而 GI_LIGHT_STORE_SEPARATE_SHADOW_FACTOR(SceneExport.h:886,值 0x20)是在导出场景时打的标记(Lightmass.cpp:243-252):
1 | // Editor/UnrealEd/Private/Lightmass/Lightmass.cpp:243-252 |
这就是「要不要给这盏灯单独开一个阴影通道」的总闸。
想让它不占通道,最小改动就是在这里把那个|=换成一个新开关。
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 | // TextureMapping.cpp:1702(改造后) |
第三支要注意两点:
AdjustedShadowFactor已经过了ShadowExponent(:1698),
这是每盏灯的手工软硬旋钮,必须在合并之前就应用完——否则它会被平均掉。- 权重
wᵢ只能烘死。物理上正确的权重是这盏灯在该纹素上的未遮挡直射辐照度亮度
(因为最终V̄乘的是「运行时实时算出来的各灯直射光之和」)。
代价是第三支也得调一次CalculatePointLighting(:1718)——
**但只取它的亮度当权重,绝不AddWeighted**。
30.12.4 合并点就在 :707 那两张 TMap
1 | // TextureMapping.cpp:707-708 |
这是同一张 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 | // LightMap.cpp:1640 |
改法: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 处容易漏
bIsCompletelyOccluded的判据要跨灯(TextureMapping.cpp:1729)。
现在是「这一盏灯把整张图全遮住 → 删掉这张 ShadowMapData」。
合并后必须改成「所有灯加起来全遮住」,否则一张只有单灯全遮的图会被整张丢掉。NumMappedTexels/NumUnoccludedTexels(:1693/:1696)同理要跨灯统计。Coverage改成权重和(LightMap.cpp:1767DestCoverage = SourceCoefficients.Coverage / 2)。
它现在是个布尔,被GenerateLightmapMipsAndDilateColor(:1808)用来做 dilate 与 mip。
不改的话 UV 岛边缘会被 dilate 成「完全照亮」,漏出一圈亮边(30.11.5 陷阱二)。- 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 不同,合并后这个差异会被平均进同一个值。 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 |1 的静态阴影因子 | Ch30 |
| PrecomputedShadowFactors | 预计算阴影因子 | GBuffer 里那 4 个 0
| 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):
- Lighting the Environment in Unreal Engine(5.0+)—— 光照总览(光源 Mobility/Lumen/VSM 导航);
- GPU Lightmass Global Illumination(含官方中文版)—— GPU 烘焙文档:DX12+DXR、Full Bake/Bake What You See 两模式、面板参数、已知限制;
- Understanding Lightmapping(5.2+)—— Lightmap UV 硬性要求:UV 岛不重叠、0-1 空间、约 4 纹素 padding;
- Generating Lightmap UVs(5.3)—— Build Settings 自动生成(Generate Lightmap UVs/Min Resolution/Source→Destination);
- Datasmith Supported Software and File Types(5.6)—— 官方清单无 Unity;
- Importing glTF Files Into Unreal Engine(经 Interchange)—— 本 fork 已实测可用;
- Migrating assets from Unity to Unreal Engine(官方迁移文档)—— FBX Exporter 包、Convert Scene + Force Front X Axis、100 倍缩放、Y-up→Z-up。
中文社区(标注:以下基于 UE4 或写作时未能访问核实,非本仓库实测):
- 知乎《UE5 光照烘焙—CPU Lightmass》(p/660381167)—— 烘焙全流程(关 Lumen/VSM、Lightmass 参数、Lightmap Density 视图);
- 知乎《UE4 LightMap 编码格式(移动端)》(p/539598416)—— HQ=RGBLogL 8:8:8:8、LQ=24bit RGB8、SimpleLogScale=16、cook 后 ASTC;
- 知乎《UNREAL 移动端 LightMap 自定义优化修改(六)》(p/261297862)—— 跳 SkyOcclusion、AO 写 A 通道、包体减 150MB;
- ldpk《Unity 光照贴图 UV 优化实战》—— UV 空间浪费与 packing 优化(跨引擎对照);
- masterTOB《From Unity to Unreal》(Epic 论坛)—— 一键导出整个 Unity 场景为 FBX;
- 知乎《全局光照引擎—Lightmap 烘焙器构件》(p/384470030)—— 自研烘焙器视角的 Lightmap 管线拆解;
- 知乎《命运扳机》的光与影(UFSH2025)—— UE 当烘焙器的坑(RVT 地形预渲染、Lightmass 材质导出模块重写)。
附录 D 自测练习
入门级:
- 用三句话 + 一个类比向同事解释”烘焙”(Lightmap)是什么。
- Lumen 和 Lightmap 的区别?什么场景选哪个?
LightMapResolution是”每 texel 多少世界单位”吗?它实际是什么?
进阶级:
- 画出一张光照贴图从烘焙到显示的七段流程(图 F1 复述)。
- 缺第二 UV 时引擎会怎样?为什么”没报错 = 没问题”是错的?
- Unity 场景导入 UE 的 100 倍缩放是哪来的?灯光单位怎么换算?
- 漏光(Light Bleeding)的成因与排查顺序是什么?
源码级:
- 在
LightmapEncoding.cpp:35(QuantizeLightSamples)找到 LogScale 常量,解释为什么要取对数。 - 在
LightMap.cpp:685(PostEncode)找到 CoordinateScale/Bias 的计算,说明它们解决什么问题。 - 在
LocalVertexFactoryCommon.ush:118找到双纹理打包的 UV 变换,推导 UV1 为什么是UV0 + float2(0,0.5)。 - 在
FbxMaterialImport.cpp:817找到材质属性映射表,说出 Unity Smoothness 应该映射到 UE 的什么。 - 在
LocalVertexFactory.ush:881找到LightMapCoordinateScaleBias:它从哪来?为什么实例(ISM)还能再覆盖一次偏移(:883)? - 在
BasePassPixelShader.usf:1377说明延迟路径下 lightmap 全彩结果的去向,并解释为什么”关掉 Lumen 的纯烘焙场景依然亮”能反证这一点(不用读 DiffuseIndirectComposite)。 DiffuseIndirectComposite.usf里DiffuseIndirectLighting有几个来源?为什么 lightmap 不在其中?GBuffer 里那份 8bit 亮度的真实消费者是谁(给出两个落点)?- 列举 lightmap 构建产物的 4 类贴图,指出哪一类被强制不压缩、为什么。
- 项目要减小 lightmap 显存,你能给出几档手段?分别减的是显存、磁盘还是带宽?
- 太阳设成 Static / Stationary / Movable 时,直接光、阴影、间接光分别能不能实时变化?给出判断依据的源码落点。
- 运行时想让「烘焙好的间接光」整体变成黄昏色调,官方钩子在哪(给出 C++ → shader 的链路)?开启动态 Lumen GI 后会发生什么?
- 实验:给关卡做两套 Lighting Scenario 并运行时切换,观察
MapBuildDataRegistry的替换与切换瞬间的跳变(第 24.3 实验室)。 - 写出
HasStaticLighting()与HasStaticShadowing()的实现,说明为什么 Stationary 是”前者 false、后者 true”的唯一一档。 - Stationary 方向光的阴影在近处与远处分别由谁提供?给出源码里的混合公式与淡出因子。
- 场景里挤了 5 盏重叠的 Stationary 灯,其中一盏没有静态阴影——用通道预算解释原因,给出两种修法。
- 为什么”把 Stationary 灯隐藏后房间仍然亮”?从 lightmap 的数据属性与 BasePass 采样链两方面回答。
- HQ 的亮度为什么要拆成”主位 8bit + 残差 8bit”?推导解码公式,说明等效精度是多少、代价是什么。
- 色度为什么要存
sqrt、解码再平方?这跟 sRGB 是什么关系? - LQ 只有一组 8bit,它是怎么做到”发光色里同时含亮度”还能被 shader 用
Luminance()精确还原的? - 如果让你把 HQ 的亮度精度再翻一倍,你会动哪一层(编码/格式/分辨率/后处理)?各自的代价是什么?
- 实验:RenderDoc 截帧,对比 BasePass 之后与 DiffuseIndirectComposite 之后的 SceneColor,指出 lightmap 贡献出现在哪一步(第 23.11 实验室)。
- 实验:建一个室内关卡烘焙,用 Lightmap Density 视图找密度不均的物体,调整分辨率后对比显存占用。
第 27 章(SkyOcclusion):
- SkyOcclusion 贴图的四个通道分别装什么?为什么说它”不是一张图”而是”一份几何信息”?
- 同样是 lightmap 数据,为什么 LQ 系数图能压到 BC1(4bpp)、SkyOcclusion 却必须付 BC3/BC7(8bpp)?
给出CompressionNoAlpha在三张图上的取值与对应源码行。 - SkyOcclusion 的物理尺寸为什么是
W×H而系数图是W×2H?shader 里哪一行在补偿这个差异,注释怎么写的? - 按”生成 / 压缩 / 采样”三个环节,各给出一条真正能省 SkyOcclusion 开销的做法,并说明各自的代价。
第 28 章(体积光照图):
- Lightmap 和 Volumetric Lightmap 的坐标系分别在哪儿?为什么”动态角色照不亮”这件事,靠改进 lightmap 解决不了?
- VLM 为什么需要
IndirectionTexture和砖数据两层纹理?只用一层会付出什么代价? - 砖的 padding 为什么是
+1而不是+0.5?这块 padding 在ComputeVolumetricLightmapBrickTextureUVs的哪一项里生效? - VLM 的方向项为什么敢除以环境项再压成 8bit?把 “环境项 / 方向项” 的分工类比成第 26 章的哪个做法?
SHDenormalizationScales在源码里被抄了三遍(VolumetricLightmapShared.ush/LightMapRendering.cpp/AdaptiveVolumetricLightmap.cpp)——为什么必须逐位一致?不一致会怎样?ShouldRefineVoxel有哪三条细分理由?bCullBricksBelowLandscape在什么场景下是错误优化?VolumetricLightmapDetailCellSize减半为什么是”最多 8 倍内存”而不是 2 倍?”up to” 二字为什么要加上?- 算一笔账:一个体素最少占多少字节?分别由哪几层构成?和”一个 lightmap 纹素 1 字节”相比差多少倍?
VolumetricLightmapMaximumBrickMemoryMb超限时是”降精度”还是”丢砖”?这个区别在观感上表现为什么?- 如果要求你给 VLM 省一半显存,列三条不需要重新设计格式的做法,并说出各自的代价。
- 移动端与桌面端消费 VLM 的方式差在哪一句话上?为什么这会让移动端的 VLM 精度评估完全不同?
第 29 章(单灯变色 / 开关):
- 为什么 lightmap 无法”只改某一盏灯的间接光”?给出两条源码证据(一条来自纹素结构,一条来自
LightGuids的注释)。 AreDynamicDataChangesAllowed()的默认参数是什么?它让哪一类灯的所有运行时 setter 静默失效?举出三个受它保护的 setter。- 编辑器里改一盏 Static 灯和改一盏 Stationary 灯的颜色,行为差在哪?指出
LightComponent.cpp里决定这个差异的那三行。 - 一盏 Static 灯改了属性之后,引擎是怎么”发现”旧 lightmap 不算数的?梳出完整链条(含函数名)。
GetStaticInteraction返回的四档分别是什么意思?LIT_CachedIrrelevant为什么会被算作bLightMapped = true?- 烘焙后新增一盏 Static 灯(不重烘),它会怎样被画出来?这个”预览”由哪三个条件共同决定?为什么小场景里可能完全看不到?
- 勾选/取消灯的 Visible 会不会触发重烘?对 Static / Stationary / Movable 三档分别说出观感结果。
- 为什么”把 Stationary 灯隐藏后房间仍然亮”?从 lightmap 的数据属性和
ShouldRenderLightViewIndependent两方面回答。 IndirectLightingIntensity能在运行时改吗?它在主视图里生效吗?指出它唯一的运行时消费点。- 项目要求”关灯后房间必须真的暗下来”,给出四条方案并说明各自的代价。
- 实验:烘焙后新增一盏 Static 灯,分别在编辑器与
GUnbuiltPreviewShadowsInGame=1的打包版里观察光与影,再把它所在场景的网格数量堆到 500 个以上,观察预览行为的变化(第 29.11 实验室)。
第 30 章(阴影的烘焙与消费):
- 用一句话说明「Static 灯为什么没有阴影贴图」,并指出那行关键源码里
ShadowMapData被赋成了什么。 CalculateDirectAreaLightingTextureMapping里的ShadowValue为什么是FVector2f(两个数)而不是一个 float?落在半影区时为什么要再算一遍?- 距离场阴影存的是「可见度」还是「距离」?数值 0.5 代表什么?运行时把它变回 0~1 阴影因子的那一行公式是什么?最后为什么还要平方?
- 距离场五步里,哪一步决定了「只有过渡带才做高精度采样」?上采样因子为什么必须取奇数、上限为什么是 13?
- CPU Lightmass 的两种 Stationary 阴影(面积阴影 / SDF)在「软阴影从哪来」这件事上的本质区别是什么?各对应哪个属性开关?
- GPU Lightmass 的
ConvertToShadowSample把PenumbraSize设成了 0,为什么运行时仍能工作?在什么情况下会出错?给出需要打开的属性名与源码依据。 - 一张图集里只有一处用到 2 个阴影通道,整张图的源格式会变成什么?每纹素从多少字节涨到多少?指出决定这件事的那一行代码。
ReassignStationaryLightChannels分配失败的结果是「画质下降」还是别的什么?日志里那句话的原文是什么?- 为什么 shadowmap 必须
CompressionNone = true?它存的量与系数图存的量,在「能不能被块压缩」这件事上差在哪? - 桌面端把
r.HighQualityLightMaps设成 0 会丢掉什么?为什么?移动端要靠哪个 CVar 才能有「LQ + 距离场阴影」? - 把多盏 Stationary 灯的阴影合并成一个值压进 alpha,正确的合并权重是什么?为什么这个权重只能烘死?
- 承上:在「所有灯都开着」的前提下,加权平均合并是近似还是精确?关掉其中一盏灯之后呢?用一个两灯的例子算一遍。
- 算账:LQ 系数图 1.0 B/纹素、4 通道 shadowmap 4.0 B/纹素。分别算出「塞进 LQ 的 alpha(改 BC3)」与「独立 BC4 层」两种方案的总字节数与每像素采样带宽,说明哪个更省、为什么。
- 为什么同样的「用 alpha」方案,在移动端 ASTC 上是不花钱的?
- 实验:造两盏影响范围轻微重叠的 Stationary 灯烘一次,再让它们完全不重叠烘一次,对比 shadowmap 纹理的源格式与内存占用(第 30.10 实验室)。
TextureMapping.cpp:1702-1722那个 if/else,两边分别做了什么?由此说明:Stationary 灯的直射光到底有没有被烘进 lightmap?- 承上:既然 lightmap 里没有 Stationary 的直射,那么把它的可见度搬进 LQ 的 alpha,会不会把间接光一起压暗?为什么?(提示:想清楚可见度是乘在哪一半上的。)
HasStaticLighting()与HasStaticShadowing()对 Static / Stationary / Movable 三档分别返回什么?由此推出:为什么 Stationary 灯必须跑延迟渲染,而 Static 灯完全不用?- 「让 Stationary 的阴影达到 Static 的品质」在 UE 里现成的做法是什么?指出它调用的是哪个函数,与 Static 灯调用的有什么关系、最后一步差在哪两行。
- 更正后的结论里,「单主光场景」为什么能做到零误差?它省下的 3 B/纹素是怎么算出来的?移动端 ASTC 为什么更划算?
- 实验:给一盏 Stationary 方向光勾上
bUseAreaShadowsForStationaryLight,用 Nid 级 lightmap 分辨率烘两次(开/关),对比阴影边缘的软硬与台阶;再用 GPU Lightmass 重复一次,确认「不勾这个 flag 会解错」的现象(第 30.10 实验室)。 - 用一句话给出可见性 V 的物理定义。它值域是多少?半影里
V = 0.5意味着什么? - GPU Lightmass 里,单条光线得到的可见性是多少个值?软阴影是从哪里来的——是单条光线的“部分遮挡”,还是别的东西?指出对应的 flag 与那行
IsMiss()。 GenerateSphereLightOcclusionRayWithSolidAngleSampling里SinThetaMax² = R²/d²这一行的几何含义是什么?为什么这叫“立体角采样”?矩形光与方向光分别走哪个函数?- 写出
V在 GPU LM 与 CPU LM 两处各自的表达式(各用源码里的变量名),并说明二者是否等价、差别仅在哪一点。 - 为什么「按 Static 一样存可见性」这句话有歧义?把两种读法及各自对应的结论写出来。
- 合并多盏灯的可见性时,为什么不能在
sqrt域做加权平均?用 Jensen 不等式说明偏差方向,并给出正确顺序。另外说明为什么Coverage的语义必须改写。 TextureMapping.cpp:798-865那三条分支分别是什么?要让一盏 Stationary 灯不再独占阴影通道,改动最小的那一处在哪(给出文件与行号)?- 承上:为什么不能简单地把这盏灯推进 Static 那条分支(
:855)?运行时会观察到什么现象?给出判据函数与它的源码落点。 - 合并的权重应该取什么量?为什么这个权重只能烘死?(提示:想清楚运行时
V̄乘在谁身上。) - 在量化之后合并可行吗?写出正确的三步顺序,并说明为什么精度不会额外损失。
- 运行时把 4 通道的
PrecomputedShadowFactors换成一个标量 V,有两种改法。写出推荐的那一种,并说明为什么必须同时改ShaderMaterialDerivedHelpers.cpp:54——不改会看到什么? - 列举这次改造里「语义变了但不会报错」的三处(提示:
bIsCompletelyOccluded、Coverage、逐灯ShadowExponent),各说一句后果。