在Unity中,烘焙LightMap采用的是一个场景烘焙一组LightMap。而对于大世界场景来说,没办法把世界上所有的物体在同一场景下烘焙。Unity提供的解决办法是通过SubScene来解决,就是分场景烘焙,然后再通过加载卸载Scene的方式来实现。但有时候有这样的需求,同一组室内场景可能在多个地方存在,美术希望烘焙好一组物体,能复制到各个地方,并且能很好地预览,这样使用SubScene来说就比较麻烦了。
先说一下 unity的LightMap机制,烘焙分为动态物体和静态物体,动态物体走的是GI,通过环境光,LightProb等这些算出三维光照数据,然后计算动态物体的球谐光照。对于静态物体来说就会烘焙成 lightmap,一般有几组三张贴图(color,dir以及shadow)。静态物体的MeshRender上会有个lightmapIndex存放采用第几组lightmap,还有个lightmapScaleOffset存放uv偏移,通过这两个数据就能显示正确。
知道LightMap的原理后就比较简单了,我们只需要存好我们需要使用的数据,然后设置对应的位置就能正确显示了。
首先,我们定义好我们的数据结构,我们期望在一个prefab上挂一个我们的脚本,然后加载这个prefab上所有的MeshRender。我们就需要一个这样的ScriptObject。
public class CustomLightMapDataMap : ScriptableObject
{
public MeshLightmapData[] LightMapDatas = null;
}
[Serializable]
public struct CustomLightmapData
{
/// <summary>
/// The color for lightmap.
/// </summary>
public Texture2D LightmapColor;
/// <summary>
/// The dir for lightmap.
/// </summary>
public Texture2D LightmapDir;
/// <summary>
/// The shadowmask for lightmap.
/// </summary>
public Texture2D ShadowMask;
/// <summary>
/// Initializes a new instance of the <see cref="CustomLightmapData"/> struct.
/// </summary>
/// <param name="data">lightmapdata.</param>
public CustomLightmapData(LightmapData data)
{
this.LightmapColor = data.lightmapColor;
this.LightmapDir = data.lightmapDir;
this.ShadowMask = data.shadowMask;
}
public bool IsA(LightmapData data)
{
return this.LightmapColor == data.lightmapColor &&
this.LightmapDir == data.lightmapDir &&
this.ShadowMask == data.shadowMask;
}
public LightmapData GetLightmapData()
{
LightmapData data = new LightmapData();
data.lightmapColor = this.LightmapColor;
data.lightmapDir = this.LightmapDir;
data.shadowMask = this.ShadowMask;
return data;
}
}
[Serializable]
public struct MeshLightmapData
{
public Vector4 LightmapScaleOffset;
public CustomLightmapData LightmapData;
}
然后再在编辑器上弄一个菜单,选中物体就能自动干这件事情。
[MenuItem("Window/LightMapGenerate")]
private static void Generated()
{
string outputPath = "Assets/LightMapPrefab";
var lightmapPath = GetLightMapPath();
if (!string.IsNullOrEmpty(lightmapPath))
{
outputPath = Path.GetDirectoryName(lightmapPath);
}
GameObject obj = Selection.activeGameObject;
if (obj == null)
{
return;
}
var dataMap = (CustomLightMapDataMap)ScriptableObject.CreateInstance(typeof(CustomLightMapDataMap));
var renders = obj.GetComponentsInChildren<MeshRenderer>();
List<MeshLightmapData> datas = new List<MeshLightmapData>();
var lightmaps = LightmapSettings.lightmaps;
foreach (var render in renders)
{
if (render.lightmapIndex < 0 || render.lightmapIndex >= lightmaps.Length)
{
Debug.LogError("lightmap error:" + render.gameObject.name);
return;
}
var data = new MeshLightmapData()
{
LightmapScaleOffset = render.lightmapScaleOffset,
LightmapData = new CustomLightmapData(lightmaps[render.lightmapIndex]),
};
datas.Add(data);
}
dataMap.LightMapDatas = datas.ToArray();
var loader = obj.GetComponent<LightMapDataLoader>();
if (loader == null)
{
loader = obj.AddComponent<LightMapDataLoader>();
}
outputPath = Path.Combine(outputPath, obj.name + ".asset");
AssetDatabase.CreateAsset(dataMap, outputPath);
AssetDatabase.SaveAssets();
loader.Asset = AssetDatabase.LoadAssetAtPath<CustomLightMapDataMap>(outputPath);
}
private static string GetLightMapPath()
{
var lightmaps = LightmapSettings.lightmaps;
if (lightmaps.Length == 0)
{
return string.Empty;
}
return AssetDatabase.GetAssetPath(lightmaps[0].lightmapColor);
}
数据保存好了,我们只需要加载就好了。加载除了要加载MeshRender上的数据,还要设置好场景的LightMap。这里还有个特别重要的问题就是卸载,在物体销毁时,我们要处理场景的lightmap,这里需要通过一个计数器去干这件事情,当引用计数为0了,我们就去清理lightmap贴图数据。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
[ExecuteInEditMode]
public class LightMapDataLoader : MonoBehaviour
{
private static Dictionary<CustomLightmapData, int> lightmapDataRefenceCount = new Dictionary<CustomLightmapData, int>();
[SerializeField]
private CustomLightMapDataMap asset;
private HashSet<CustomLightmapData> lightmapDatas = new HashSet<CustomLightmapData>();
public CustomLightMapDataMap Asset
{
get { return this.asset; }
set { this.asset = value; }
}
public static void Clear()
{
lightmapDataRefenceCount.Clear();
}
// Start is called before the first frame update
private void Awake()
{
if (this.asset != null)
{
var lightmaps = LightmapSettings.lightmaps;
var renders = this.GetComponentsInChildren<MeshRenderer>();
var datas = this.asset.LightMapDatas;
if (datas.Length != renders.Length)
{
return;
}
List<LightmapData> lightmapList = new List<LightmapData>(lightmaps);
for (int i = 0; i < datas.Length; i++)
{
var lightMapIndex = -1;
var nullIndex = -1;
LightmapData currentData = null;
for (int j = lightmapList.Count - 1; j >= 0; j--)
{
var lightmap = lightmapList[j];
if (datas[i].LightmapData.IsA(lightmap))
{
lightMapIndex = j;
currentData = lightmap;
}
if (lightmap.lightmapColor == null &&
lightmap.lightmapDir == null &&
lightmap.shadowMask == null)
{
nullIndex = j;
}
}
if (lightMapIndex == -1)
{
currentData = datas[i].LightmapData.GetLightmapData();
if (nullIndex == -1)
{
lightmapList.Add(currentData);
lightMapIndex = lightmapList.Count - 1;
}
else
{
lightmapList[nullIndex] = currentData;
lightMapIndex = nullIndex;
}
}
this.lightmapDatas.Add(datas[i].LightmapData);
renders[i].lightmapIndex = lightMapIndex;
renders[i].lightmapScaleOffset = datas[i].LightmapScaleOffset;
}
foreach (var data in this.lightmapDatas)
{
if (!lightmapDataRefenceCount.TryGetValue(data, out var count))
{
count = 0;
}
else
{
lightmapDataRefenceCount.Remove(data);
}
count++;
lightmapDataRefenceCount.Add(data, count);
}
LightmapSettings.lightmaps = lightmapList.ToArray();
}
}
private void OnDestroy()
{
foreach (var data in this.lightmapDatas)
{
if (lightmapDataRefenceCount.TryGetValue(data, out var count))
{
count--;
lightmapDataRefenceCount.Remove(data);
if (count == 0)
{
var lightmaps = LightmapSettings.lightmaps;
for (int i = 0; i < lightmaps.Length; i++)
{
if (data.IsA(lightmaps[i]))
{
lightmaps[i].lightmapColor = null;
lightmaps[i].lightmapDir = null;
lightmaps[i].shadowMask = null;
}
}
LightmapSettings.lightmaps = lightmaps;
}
else
{
lightmapDataRefenceCount.Add(data, count);
}
}
}
}
}
做好这些事情之后,我们就可以在场景中烘焙一组物体,然后选中Root,点击Window/LightMapGenerate,会帮你组织好数据,挂好脚本。你可以把这个物体复制到任何地方都是显示正确,也可以保存成prefab通过程序加载和销毁。
分区域烘焙时交界处的阴影处理
分区域烘焙 LightMap 时,交界处最容易出现的问题是阴影断开、漏光、间接光颜色不连续。这个问题的核心不在于运行时怎么加载,而在于烘焙时如果只把当前区域当成一个孤岛,隔壁区域的遮挡物、灯光和 GI bounce 都没有参与计算,那么当前区域边缘自然就拿不到正确的阴影和间接光。
我的处理原则是:区域可以分开保存,但烘焙时不能分开看。也就是说,每个区域烘焙时都要带上周围区域作为上下文,但最终只导出当前区域自己的 LightMap 绑定数据。
Bake Chunk_A:
Target : Chunk_A
Context : Chunk_A 周围一圈或多圈邻区
Compute : Target + Context 一起参与阴影、遮挡和 GI
Export : 只保存 Chunk_A 的 Renderer 映射、LightMap Index、Scale Offset 和被引用的贴图
Runtime : 只加载 Chunk_A 自己的 LightMap 数据
具体可以拆成几个步骤:
- 烘焙时加载邻区上下文。例如烘 A 区时,不只加载 A 区,还加载周围 8 个 Chunk,邻区几何和灯光只作为 shadow caster / GI contributor 参与计算。导出阶段只采集 A 区 Renderer 的 lightmapIndex、lightmapScaleOffset 和它实际引用到的 LightMap 贴图。
- 给区域边界留 overlap 或 guard band。切块不要刚好卡在可见边界,可以让每个区域多带一圈缓冲范围。运行时只使用中间有效区,缓冲区用于保证边缘阴影和 GI 过渡连续。
- 跨边界的大物体不要硬切。如果建筑、山体、墙体或大型遮挡物横跨多个区域,最好让它归属到一个稳定区域,或者做一个专门的边界代理物。最怕的是投影物在 B 区,但烘 A 区时没加载它,A 区地面就永远缺那块阴影。
- Context 可以用低成本代理。邻区不一定要用完整美术模型,可以用简化 Mesh、低 lightmap scale 或只保留关键遮挡体。目标是让阴影和 GI 正确,不是把邻区也高质量烘一遍。
- 控制 UV padding 和 dilation。区域边缘和高对比阴影处要避免 UV chart 太贴,LightMap padding / dilation 不够时,mipmap 后会串色,边缘就会出现脏边或断层。
- 大尺度间接光用 Probe 辅助。LightMap 负责静态细节,跨区域的大范围环境色、角色受光和动态物体受光,建议配合 Light Probe / Probe Volume / SH Grid / Reflection Probe 处理。这样区域流式加载时,不会因为某个 Chunk 的 LightMap 切换导致整体光照突然变色。
在当前这套 Prefab LightMap 数据保存方案里,可以把“目标区”和“上下文区”分开记录:目标区的 Renderer 进入导出列表,上下文区只在 BakeScene 中临时存在。烘焙结束后,导出工具只保存目标区相关的 Renderer 绑定关系;如果某张 LightMap 贴图被目标区 Renderer 引用,就一起保存,否则可以丢弃。这样运行时还是按区域加载 LightMap,但烘焙结果会继承邻区阴影和 GI 信息。
简单说就是:单区输出,多区参与。这是分块 LightMap 里处理接缝阴影最稳的方式。