光照、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]

必须区分三个动作:

  1. Unity 烘焙:Unity 的 Lightmapping/Progressive Lightmapper 计算光照贴图。
  2. 烘焙后整理:ObserveLightmapBaked 监听 Lightmapping.completed,生成 LevelLightmap.asset 和模板属性文件。
  3. 地图导出: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 GUIDAssets/Maps/Scripts/MapBaker.cs 的 CreateTemplateElement
地图导出和 Maps.zipAssets/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
写回 RendererAssets/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 场景截图:

lightmap-1-0 模板集合

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 真正执行烘焙的步骤

  1. 打开对应的 Lightmap/lightmap-{world-level}.unity。
  2. 在 MapTemplate 上执行 SetLightmap,或手工检查静态标记和光源类型。
  3. 打开 Unity Lighting 窗口,检查 Lighting Settings、Baked Global Illumination、Lightmap Resolution、Atlas Size 和压缩设置。
  4. 点击 Generate Lighting。这一步调用 Unity 内建 Lightmapping,不是 MapBaker。
  5. 烘焙结束后,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 会:

  1. 创建或加载 LevelLightmap.asset。
  2. 遍历场景根节点的 MapTemplate。
  3. 为每个模板生成 lightmap-terrain-.bytes 和 lightmap-nie-.bytes。
  4. 读取当前 LightmapSettings.lightmaps,生成纹理路径列表。
  5. 扫描同目录 TextAsset,建立 Guid 到 TextAsset 的字典。
  6. 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;
}

正确显示依赖三个顺序同时一致:

  1. 烘焙场景中 Renderer 的顺序。
  2. MapBaker 导出的 RendererCount。
  3. 运行时 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;

注意:

  1. 这是 Editor 烘焙委托,不是运行时光源倍率系统。
  2. 组件启用时会全局设置 Delegate,多个对象同时存在时最后一次设置会覆盖前一次。
  3. 实验结束后点击“重置”,调用 Lightmapping.ResetDelegate() 恢复默认行为。
  4. 改动后必须重新 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. 建议的学习顺序

不要只记菜单名称,建议跟一遍真实关卡:

  1. 在 lightmap-1-0.unity 中找一个 Terrain 和一个 NIE 模板,记录 Renderer 数量。
  2. 查看 LevelLightmap.asset 的 TexturesList 和 LightmapPropertiesFiles 数量。
  3. 运行 MapTemplate.ManuallyGenerateLightmapPropertiesFiles(),观察两个 .bytes 的更新时间。
  4. 断点阅读 MapBaker.CreateTemplateElement,确认两个 GUID 分别指向什么。
  5. 阅读 MapProcessor.ProcessTemplate,看 propertyIndex 如何按 rendererCount 切分。
  6. 阅读 LoadLightmapTexturesSystem,确认贴图加载完成事件何时发出。
  7. 阅读 MapPropFactory,确认 lightmapIndex 和 lightmapScaleOffset 何时写入。
  8. 故意复制一个错误的 Prop Renderer,运行 MapDataCheckAll,观察校验器报告。

掌握这条链路后,才能安全地修改模板、拆分图集、替换资源加载器或评估实时 GI,而不是只会重新点一次 Generate Lighting。

11. 相关文档