光照、Lightmap 烘焙与运行时加载模块 private
光照、Lightmap 烘焙与运行时加载模块
本篇回答四个问题:光照贴图到底在哪里打、哪个脚本负责什么、运行时怎样把贴图套回随机地图,以及为什么项目没有使用全局实时 GI。
1. 先给结论
这个项目使用 Unity Progressive Lightmapper 的 Baked Lightmap,但不是把烘焙结果绑定到一张固定运行时场景。地图运行时会按 randomSeed 和 templateIndices 动态展开,因此工程采用“关卡级共享纹理 + 模板级 Renderer 映射”:
flowchart LR
A[作者场景 EditorScenes] --> B[关卡 Lightmap 场景]
B --> C[Unity Lightmapper / Generate Lighting]
C --> D[Lightmap .exr]
C --> E[Renderer 索引和 UV]
E --> F[lightmap-*.bytes]
D --> G[LevelLightmap.asset]
F --> H[MapBaker 写入 TemplateElement GUID]
G --> H
H --> I[LoadLightmapTexturesSystem]
I --> J[MapPropFactory 写回 Renderer]
必须区分三个动作:
- Unity 烘焙:Unity 的 Lightmapping/Progressive Lightmapper 计算光照贴图。
- 烘焙后整理:ObserveLightmapBaked 监听 Lightmapping.completed,生成 LevelLightmap.asset 和模板属性文件。
- 地图导出:MapBaker 导出模板、光照 GUID、NavMesh、刷怪点等数据;它不是光照计算器。
2. 源码和目录索引
| 职责 | 实际入口 |
|---|---|
| 光照和模板目录 | Assets/Editor/AssetsTools/MapEditorSettings.asset、Assets/Code/Game/Editor/MapEditorSettings.cs |
| 设置静态 GI 和 Baked 光源 | Assets/Maps/Scripts/Lightmap/MapTemplate.cs |
| 烘焙完成回调、生成关卡资产 | Assets/Maps/Scripts/Lightmap/ObserveLightmapBaked.cs |
| 自定义烘焙衰减/范围 | Assets/Maps/Scripts/Lightmap/LightFalloff.cs |
| 地图模板绑定 Lightmap GUID | Assets/Maps/Scripts/MapBaker.cs 的 CreateTemplateElement |
| 地图导出和 Maps.zip | Assets/Maps/Scripts/MapBaker.cs |
| LevelLightmap 资源格式 | Assets/Code/Game/OServices/View/Map/Light/LevelLightmap.cs |
| 纹理异步加载 | Assets/Code/Game/OServices/View/Map/Light/LoadLightmapTexturesSystem.cs |
| Renderer 属性二进制格式 | Assets/Code/Game/OServices/View/Map/Light/TemplateLightmap.cs |
| 模板展开、读取属性 | Assets/Code/Game/OServices/View/Map/MapProcessor.cs |
| 写回 Renderer | Assets/Code/Game/OServices/View/Map/Collider/MapPropFactory.cs |
| 导出前校验 | Assets/Editor/MapEditor/MapDataCheckTools.cs |
MapEditorSettings 的真实值:
PropsDirectory Assets/Maps/Resources_moved/Maps/Props
TemplateTerrainDirectory Assets/Maps/Resources_moved/Maps/Templates/Terrain
TemplateNieDirectory Assets/Maps/Resources_moved/Maps/Templates/NIE
DataDirectory Assets/Maps/Resources_moved/Maps/Data
LightmapDirectory Assets/Maps/Resources_moved/Maps/Lightmap
例如关卡 1-0:
Assets/Maps/Resources_moved/Maps/Lightmap/1-0/
lightmap-1-0.unity
LevelLightmap.asset
lightmap-terrain-*.bytes
lightmap-nie-*.bytes
lightmap-1-0/Lightmap-*_comp_light.exr
3. 到底在哪个场景里烘焙
3.1 运行时地图场景不是烘焙场景
Assets/Maps/EditorScenes/1-0-44.unity 这类场景是作者态,包含 MapRoot、模板实例、Spawner、Trigger 和 NavMesh。运行时会按随机种子选择模板,不能假设每次组合都相同。
真正的光照烘焙场景在:
Assets/Maps/Resources_moved/Maps/Lightmap/{world-level}/lightmap-{world-level}.unity
例如:
Assets/Maps/Resources_moved/Maps/Lightmap/1-0/lightmap-1-0.unity
这个场景集中摆放该关卡可能选中的 Terrain/NIE 模板,用同一套 Lighting Settings 烘焙。一个 LevelLightmap.asset 覆盖该关卡的候选模板,而不需要为每次随机组合重新计算。
当前工程的真实 Lightmap 场景截图:

3.2 哪些对象参与烘焙
MapTemplate.SetLightmap() 对 Terrain/NIE 模板做两件事:
foreach (var template in Templates().Where(t => t != null))
SetLightmapStaticProps(template, mapEditorSettings);
SetLightmapBakedLight(gameObject);
SetLightmapStaticProps 会把 PropsDirectory/Fbx 和 AssetsDirectory 下的对象设置为 StaticEditorFlags.ContributeGI;SetLightmapBakedLight 遍历模板下的 Light,强制:
light.lightmapBakeType = LightmapBakeType.Baked;
Inspector 的 Odin 按钮名称是:
设置地块为静态Lightmap/光源为Baked/关闭Spawner
注意:源码中的 DisableSpawner(template) 当前被注释掉了,所以按钮名不代表运行时一定关闭了所有 Spawner。烘焙前要自己确认动态对象没有被错误标成静态几何体。
3.3 真正执行烘焙的步骤
- 打开对应的 Lightmap/lightmap-{world-level}.unity。
- 在 MapTemplate 上执行 SetLightmap,或手工检查静态标记和光源类型。
- 打开 Unity Lighting 窗口,检查 Lighting Settings、Baked Global Illumination、Lightmap Resolution、Atlas Size 和压缩设置。
- 点击 Generate Lighting。这一步调用 Unity 内建 Lightmapping,不是 MapBaker。
- 烘焙结束后,Unity 触发 Lightmapping.completed,ObserveLightmapBaked 自动整理资产。
当前 1-0 光照场景提交的基线:
m_EnableBakedLightmaps: 1
m_EnableRealtimeLightmaps: 0
m_BakeResolution: 20
m_AtlasSize: 2048
m_AO: 1
m_BakeBackend: 1
m_PVRBounces: 0
也就是 Baked 开启、Realtime Lightmaps 关闭。新机器或新 Unity 版本不要凭记忆重设,优先对照同关卡 Lighting Settings 和 Unity 版本。
4. 烘焙完成后,脚本做了什么
4.1 ObserveLightmapBaked 生成 LevelLightmap.asset
初始化时注册:
static ObserveLightmapBaked()
{
Lightmapping.completed += OnBakedCompleted;
}
回调只处理 MapEditorSettings.LightmapDirectory 下的场景。GenerateLevelLightmap 会:
- 创建或加载 LevelLightmap.asset。
- 遍历场景根节点的 MapTemplate。
- 为每个模板生成 lightmap-terrain-.bytes 和 lightmap-nie-.bytes。
- 读取当前 LightmapSettings.lightmaps,生成纹理路径列表。
- 扫描同目录 TextAsset,建立 Guid 到 TextAsset 的字典。
- AssetDatabase.SaveAssets()。
LevelLightmap.Textures 保存 Light、Dir、ShadowMask 三类资源路径。不能只提交 LevelLightmap.asset,引用的 .exr 和 lightmap-*.bytes 也必须存在,并经过资源索引系统收集。
4.2 MapTemplate 生成模板属性文件
纹理是关卡级的,但每个模板在图集中的位置不同,所以模板要保存自己的 Renderer 属性:
new LightmapProperty {
TextureIndex = renderer.lightmapIndex,
TextureScaleOffset = renderer.lightmapScaleOffset
}
LightmapPropertiesFormatter 的二进制格式:
Int32 count
重复 count 次:
Int32 TextureIndex
Float4 TextureScaleOffset
0xffff 表示该 Renderer 不使用 Baked Lightmap。这个约定不能改成 -1,因为运行时和校验器都按 0xffff 判断。
4.3 MapBaker 关联两个 GUID
MapBaker.CreateTemplateElement 根据场景和模板类型计算:
var lightDirectory = $"{_editorSettings.LightmapDirectory}/{sceneNames[0]}-{sceneNames[1]}";
var levelLightmapPath = $"{lightDirectory}/LevelLightmap.asset";
var lightmapPropertiesPath =
$"{lightDirectory}/lightmap-{lightmapType}-{lightmapName}.bytes";
并写入:
return new TemplateElement {
Id = file,
LightmapTextures = levelLightmapGuid,
LightmapProperties = lightmapPropertiesGuid
};
如果找不到 .bytes,代码会把 levelLightmapGuid 清空并输出警告;这意味着模板导出后不会正确套用光照,不是可以忽略的普通 Warning。
5. 运行时如何恢复光照
5.1 先加载一套关卡纹理
地图处理出 LevelLightmap GUID 后,LoadLightmapTexturesSystem 监听 MapMatcher.LevelLightmap:
var lightSettingPath = Database.CfgAssetsPath.GetAssetPath(levelLightmapGuid);
var levelLightmap = AMF.AssetManager.Instance.GetAsset<LevelLightmap>(
lightSettingPath, AMF.ASSETTYPE.TEXT);
levelLightmap.TexturesList.ToObservable()
.SelectMany((textures, index) =>
textures.AsyncToLightmapData()
.ToObservable()
.Select(data => (data, index)))
.ToList()
.Select(list => list.OrderBy(t => t.index)
.Select(t => t.data).ToArray())
.Subscribe(dataList => LightmapSettings.lightmaps = dataList);
每个 Textures 通过 AMF.AssetManager 异步加载 Light、Dir、ShadowMask,组装 Unity LightmapData,最后设置 LightmapSettings.lightmaps 并发出 LightmapLoadedEvent。
5.2 把索引和 UV 写回 Renderer
MapProcessor.ProcessTemplate 读取模板 .bytes,根据 PropRendererCount 按顺序切分属性,放到 MapEntity.PropLightmapPropertes。
MapPropFactory 实例化 Prop 时执行:
for (var i = 0; i < _renderers.Length && i < properties.Length; i++)
{
_renderers[i].lightmapIndex = properties[i].TextureIndex;
_renderers[i].lightmapScaleOffset = properties[i].TextureScaleOffset;
}
正确显示依赖三个顺序同时一致:
- 烘焙场景中 Renderer 的顺序。
- MapBaker 导出的 RendererCount。
- 运行时 Prop Prefab 中 Renderer 的顺序。
源码已明确提示:Prop 渲染单元少于 FBX 会导致关卡 Lightmap 错误;多于 FBX 时必须保证 FBX 渲染单元位于 Prop 前面。
6. 为什么不用“全局光照”
“全局”有两种含义。
6.1 不是不用 GI,而是不做全局实时 GI
项目并非没有间接光,Lightmap 就是静态几何体直接光和间接光的预计算结果。当前设置明确为:
m_EnableBakedLightmaps: 1
m_EnableRealtimeLightmaps: 0
选择 Baked 而非全局实时 GI 的工程原因:
- 静态地图光照变化少,烘焙结果稳定。
- Unity 2021 的实时 GI、显存和 CPU/GPU 成本对大量模板不划算。
- 移动端需要稳定帧率,阴影、间接光和反弹光预计算更可控。
- Battle/服务端不渲染,实时 GI 不会带来服务端收益。
- 地图是动态拼装的,运行时才知道抽中了哪些模板;实时 GI 能适应任意组合,但每个客户端都要持续计算。
6.2 也不是一张全项目全局 Lightmap
不能把所有世界、关卡和模板烘焙成一张全局图:
- 资源按 Lightmap/{world-level} 分组。
- 每个关卡只需要覆盖会抽到的 Terrain/NIE 模板。
- randomSeed/templateIndices 会改变组合,固定整图烘焙不适合运行时展开。
- 全项目图集会增加纹理、加载、内存和移动端带宽成本。
- 模板复用必须保存自己的 lightmapIndex 和 scaleOffset;项目的 .bytes 格式就是为此存在的。
实际方案是:关卡范围共享一套 Lightmap 纹理,模板范围保存独立 Renderer 映射。
6.3 ForceRealtimeLight 为什么看起来没有生效
MapSettings 保留:
public bool ForceRealtimeLight = false;
但 MapProcessor.ProcessTemplate 实际是:
// if (_settings.ForceRealtimeLight)
if (false)
{
// 将模板里的 Point/Spot Light 标记为 realtime
}
这是历史遗留的预留开关。仅在 Inspector 把 ForceRealtimeLight 改为 true 不会改变运行时行为;如果要支持它,需要恢复依赖注入、修改 ProcessTemplate,并重新验证动态光源性能。
7. 自定义光源衰减:LightFalloff
LightFalloff 使用 Unity 的 Lightmapping.SetDelegate 替换 Lightmapper 获取光源数据的回调,可以只改变烘焙时的:
- 衰减类型 FalloffType
- 烘焙范围:light.range * _lightRangeCoefficient
- 烘焙强度:light.intensity * _lightIntensityCoefficient
核心逻辑:
ld.falloff = falloff;
var color = ld.color;
color.intensity *= intensityCoefficient;
ld.color = color;
ld.range *= rangeCoefficient;
lightsOutput[i] = ld;
注意:
- 这是 Editor 烘焙委托,不是运行时光源倍率系统。
- 组件启用时会全局设置 Delegate,多个对象同时存在时最后一次设置会覆盖前一次。
- 实验结束后点击“重置”,调用 Lightmapping.ResetDelegate() 恢复默认行为。
- 改动后必须重新 Generate Lighting,并重新生成 LevelLightmap 和模板 .bytes。
8. 导出前的可执行 SOP
8.1 光照烘焙 SOP
1. 确定地图属于哪个 world-level,例如 1-0。
2. 打开对应 Lightmap/lightmap-1-0.unity,而不是只打开随机运行场景。
3. 确认候选 Terrain/NIE 模板都在场景中,且名称能映射回运行时 Prefab。
4. 在 MapTemplate 上执行“设置地块为静态Lightmap/光源为Baked”。
5. 检查 Lighting Settings:Baked 开启、Realtime 关闭、分辨率和 AtlasSize 与基线一致。
6. 点击 Generate Lighting,等待 Lighting 窗口和 Console 都完成。
7. 检查目录内更新 LevelLightmap.asset、lightmap-*.bytes 和 .exr。
8. 保存 Lightmap 场景和生成资产。
8.2 地图导出 SOP
1. 回到 Assets/Maps/EditorScenes 下的目标地图场景。
2. 运行 Assets/Map/MapDataCheckAll。
3. 运行 Assets/Map/ZipCurrMapData。
4. 检查客户端 Maps/Data、外部 Maps/Data、ClientMapData/Data 和 Maps.zip。
5. 需要联调时,确认 MapBakerInfo.txt 第一行是当前服务端目录。
6. 使用 AutoCopy 或 CopyProjectMapDataToServer 同步服务端地图数据。
7. 客户端重新进入地图,检查 LightmapLoadedEvent、地图纹理和 Renderer 阴影。
9. 校验器和排错
MapDataCheckTools 已覆盖:
- LevelLightmap.asset 是否存在、是否能加载。
- TexturesList 是否为空。
- Light、Dir、ShadowMask 指向的 .exr 是否存在、可导入。
- LightmapPropertiesFiles 是否存在空 GUID、空 TextAsset 或空字节。
- 模板 LightmapProperties GUID 是否能在 LevelLightmap 字典中找到。
- 属性数组是否越界。
- TextureIndex 是否超过 TexturesList.Length。
| 现象 | 首查 |
|---|---|
| 场景全黑/只有实时灯 | LightmapSettings.lightmaps 是否为空、LevelLightmap GUID 是否进入地图实体 |
| 模板有光照,运行时实例没有 | lightmap-*.bytes、PropRendererCount、Renderer 顺序 |
| 某些物件亮度或 UV 错位 | FBX 与 Prop 的子 Renderer 数量和顺序 |
| 导出后找不到模板光照 | MapBaker 计算出的 lightmapName、NIE 命名和目录 |
| 校验报 TextureIndex 越界 | .bytes 是旧烘焙结果,重新 Generate Lighting 并生成属性文件 |
| 切图后残留上一关光照 | ResourceDestroyer、LobbySceneController 是否清空 LightmapSettings.lightmaps |
| 打开 ForceRealtimeLight 没变化 | MapProcessor 使用 if(false),开关未接入 |
| 烘焙结果忽明忽暗 | LightFalloff 是否残留自定义 Delegate、Lighting Settings 是否改变 |
10. 建议的学习顺序
不要只记菜单名称,建议跟一遍真实关卡:
- 在 lightmap-1-0.unity 中找一个 Terrain 和一个 NIE 模板,记录 Renderer 数量。
- 查看 LevelLightmap.asset 的 TexturesList 和 LightmapPropertiesFiles 数量。
- 运行 MapTemplate.ManuallyGenerateLightmapPropertiesFiles(),观察两个 .bytes 的更新时间。
- 断点阅读 MapBaker.CreateTemplateElement,确认两个 GUID 分别指向什么。
- 阅读 MapProcessor.ProcessTemplate,看 propertyIndex 如何按 rendererCount 切分。
- 阅读 LoadLightmapTexturesSystem,确认贴图加载完成事件何时发出。
- 阅读 MapPropFactory,确认 lightmapIndex 和 lightmapScaleOffset 何时写入。
- 故意复制一个错误的 Prop Renderer,运行 MapDataCheckAll,观察校验器报告。
掌握这条链路后,才能安全地修改模板、拆分图集、替换资源加载器或评估实时 GI,而不是只会重新点一次 Generate Lighting。