Banner

「Custom SRP」:全局光照与环境

系列第 6 篇。Note 4 与 Note 5 处理的是直接光照与遮挡——这是光从光源直接到达表面的部分。本篇关注间接光照:光在场景中反弹后到达表面的能量、来自天空的环境光、来自周围表面的反射。这部分能量在真实世界中往往占比 50% 以上,是 PBR 视觉真实感的关键来源。Custom SRP 在这一块走”接入 Unity 烘焙 GI 生态”的路线——不重造 GI 求解器,而是负责把 Lightmap、Light Probe、Reflection Probe 的烘焙结果正确地送到 Shader。

TL;DR

  • 三件套结构:静态对象用 Lightmap(单位面积辐射能量)、动态对象用 Light Probe(球谐函数表达任意方向的入射光)、镜面反射统一用 Reflection Probe(预过滤 cubemap)。各司其职、按对象类型分发。
  • 球谐函数(SH)是 GI 的核心数学工具:用 9 个系数(L0+L1)就能编码整个球面的低频光照分布,每像素只需 9 次乘加即可重建任意方向的辐射度——这是为什么 Light Probe 几乎免费。
  • Reflection Probe 的 Mip 映射对应粗糙度:高 Mip 是预先模糊过的版本,粗糙表面采样高 Mip 自然得到柔和反射。这是 IBL(Image-Based Lighting)的标准实现模式。
  • Box Projection 是视差校正:默认 Cubemap 假设 Probe 在无穷远,近距离反射会出现”贴图飘移”。Box Projection 把反射光线从 Probe 中心校正到表面位置,让室内场景的镜面反射看起来贴合墙面。
  • Meta Pass 是间接光的源头:场景中表面”反弹什么颜色的光”完全由 Meta Pass 输出的 Albedo 决定。烘焙器需要这个 Pass 才能正确求解全局光照。

1. GI 系统全景

1.1 三类对象 × 三类数据源

GI 的接入逻辑可以总结为对象类型与数据源的映射表:

对象类型 Diffuse 间接光 Specular 间接光(环境反射)
静态对象(Static, Lightmap Static) Lightmap Reflection Probe
动态对象(移动、Skinned Mesh) Light Probe (SH) Reflection Probe
大型动态对象(粒子、超大动态网格) LPPV (Light Probe Proxy Volume) Reflection Probe

镜面反射对所有对象都用 Reflection Probe——这是因为 Lightmap 与 Light Probe 都不能编码视角依赖的反射,必须依赖 Cubemap。

1.2 GI 数据流

flowchart TD
    A[场景烘焙阶段·Editor] --> B[Meta Pass 输出 Albedo / Emission]
    B --> C[Progressive Lightmapper 求解]
    C --> D[Lightmap Texture
静态对象 UV2] C --> E[Light Probe SH 系数
球面采样点] C --> F[ShadowMask Texture
遮挡数据] G[Reflection Probe 烘焙] --> H[Cubemap with Mips
预过滤粗糙度] D --> I[运行时·Shader] E --> I F --> I H --> I I --> J[GI struct
diffuse · specular · shadowMask] J --> K[IndirectBRDF
Note 3 §3.3] style A fill:#fff3e0,stroke:#f57c00 style I fill:#e3f2fd,stroke:#1976d2,stroke-width:2px style K fill:#e8f5e9,stroke:#388e3c,stroke-width:2px

整个流程横跨 编辑器烘焙阶段运行时着色阶段:编辑器把 GI 求解结果固化到资源(Lightmap 贴图、Probe 系数、ShadowMask),运行时由 Shader 按对象类型采样这些预烘焙数据。

Custom SRP 的工作集中在两端的最末环节:编辑器侧 提供正确的 Meta Pass 让烘焙器读取材质属性;运行时侧 在 Lit Shader 中正确采样并融合到 BRDF。中间的求解过程(Progressive Lightmapper、Bakery、Enlighten)由 Unity 引擎或第三方插件负责,与 SRP 解耦。

1.3 GI struct 抽象

类似于 Note 3 中 Surface / Light / BRDF 的统一抽象,GI 有自己的中间结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct GI
{
float3 diffuse; // 漫反射间接光(Lightmap or SH)
float3 specular; // 镜面反射间接光(Reflection Probe)
ShadowMask shadowMask; // 烘焙阴影数据(Note 5 §6)
};

struct ShadowMask
{
bool always;
bool distance;
float4 shadowMask;
};

GetGI(lightmapUV, surfaceWS, brdf) 是一个统一入口,根据是否定义 LIGHTMAP_ON 关键字分支到 Lightmap 或 SH 路径。Specular 部分用同一份 Reflection Probe 采样逻辑,对所有对象通用。


2. Lightmap:静态对象的烘焙漫反射

2.1 Lightmap UV 的传递

每个标记为 Lightmap Static 的 Mesh 需要一套独立的 UV 通道(典型是 UV2,对应 TEXCOORD1)。烘焙器在这个 UV 空间中为每个 texel 求解到达该位置的总漫反射能量。

引擎通过 unity_LightmapST 把每对象在全局 Lightmap Atlas 中的 offset/scale 传递给 Shader。这个变量是 UnityPerDraw CBUFFER 的固定字段(Note 2 §4.1 已强调),由引擎根据 PerObjectData.Lightmaps 自动填充:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct Attributes
{
float3 positionOS : POSITION;
float3 normalOS : NORMAL;
float2 baseUV : TEXCOORD0;
GI_ATTRIBUTE_DATA // 展开为 float2 lightmapUV : TEXCOORD1
UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct Varyings
{
float4 positionCS : SV_POSITION;
float3 positionWS : VAR_POSITION;
float3 normalWS : VAR_NORMAL;
GI_VARYINGS_DATA // 展开为 float2 lightmapUV : VAR_LIGHT_MAP_UV
// ...
};

Varyings LitPassVertex(Attributes input)
{
Varyings output;
UNITY_SETUP_INSTANCE_ID(input);
TRANSFER_GI_DATA(input, output); // 应用 unity_LightmapST 变换
// ... 其他变换
return output;
}

GI_ATTRIBUTE_DATA / GI_VARYINGS_DATA / TRANSFER_GI_DATA 是 Catlike 实现的宏抽象——只在 LIGHTMAP_ON 关键字定义时展开实际代码,否则展开为空。这避免了为非 lightmap 对象传递无用 UV 通道的开销。

2.2 Lightmap 采样

运行时采样直接从 unity_Lightmap 读取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
TEXTURE2D(unity_Lightmap);
SAMPLER(samplerunity_Lightmap);

float3 SampleLightMap(float2 lightMapUV)
{
#if defined(LIGHTMAP_ON)
return SampleSingleLightmap(
TEXTURE2D_ARGS(unity_Lightmap, samplerunity_Lightmap),
lightMapUV,
float4(1.0, 1.0, 0.0, 0.0),
#if defined(UNITY_LIGHTMAP_FULL_HDR)
false,
#else
true,
#endif
float4(LIGHTMAP_HDR_MULTIPLIER, LIGHTMAP_HDR_EXPONENT, 0.0, 0.0));
#else
return 0.0;
#endif
}

SampleSingleLightmap 是 RP Core Library 提供的标准函数,处理 HDR 编码、压缩格式解码、texelSize 等细节。UNITY_LIGHTMAP_FULL_HDR 决定 Lightmap 是否使用 RGB16F(高质量)或 RGBM 编码(兼容性高,但精度有限)。

2.3 Directional Lightmap

普通 Lightmap 只存储漫反射光照强度,不知道光来自哪个方向——这意味着 Normal Map 在 Lightmap-only 对象上无效(漫反射不响应法线扰动)。

Directional Lightmap 用第二张贴图(unity_LightmapInd)存储每 texel 的主导光方向。Shader 端把方向信息与表面法线点积,让 Normal Map 重新生效:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
float3 SampleLightProbe(Surface surfaceWS)
{
#if defined(LIGHTMAP_ON)
return 0.0; // Lightmap 路径不走 SH
#else
// ... SH 采样
#endif
}

float3 GetGI_Diffuse(float2 lightmapUV, Surface surfaceWS)
{
#if defined(LIGHTMAP_ON)
return SampleLightMap(lightmapUV);
#else
return SampleLightProbe(surfaceWS);
#endif
}

实践中 Catlike 的 Custom SRP 对 Directional Lightmap 的处理简化为标准 Lightmap 采样(不解码方向数据)——这是工程取舍:完整 Directional Lightmap 解码每像素需要额外采样 + 复杂方向计算,但视觉收益在大多数静态场景中并不显著(因为大场景 Lightmap 分辨率本身就低)。

2.4 GPU Instancing 与 Lightmap

Lightmap 的 unity_LightmapSTUnityPerDraw CBUFFER 中是 per-object 数据。这意味着即使两个对象使用同一张 Lightmap Atlas,只要它们在 Atlas 中的位置(offset/scale)不同,就不能合并为同一 instance batch

这是个隐性的性能陷阱:场景中 100 个相同 Mesh 的静态柱子,本来期望走 GPU Instancing,但因为 Lightmap UV offset 各不相同——每个柱子单独 batch。规避方法有限:

  • 静态对象用 SRP Batcher 而非 Instancing:SRP Batcher 不要求 instance buffer 一致,per-object 数据通过 UnityPerDraw 的 offset 切换即可。这是 SRP Batcher 在静态场景中的天然优势
  • 真的需要 Instancing 时:把多个相同 Mesh 合并到一张 Atlas 子区域,让它们共享相同的 unity_LightmapST——但这需要美术工具配合

3. Light Probe:动态对象的球谐光照

3.1 球谐函数的数学基础

球谐函数(Spherical Harmonics, SH) 是定义在球面上的一组正交基函数,类似 Fourier 级数对周期函数的展开。任意球面函数 可以展开为 SH 基函数的线性组合:

其中 是 SH 基函数, 是系数(实数)。截断到第 阶时,需要 个系数。

实时渲染使用 二阶 SH(L0 + L1),共 4 个系数(每个 RGB 通道),存储成本 12 个浮点数。这足以表达低频光照分布——粗略的”前后亮度”、”左右亮度”、”上下亮度”——而高频细节(强方向光、锐利阴影)会被平均掉,这正是 GI 漫反射所需要的特性。

Custom SRP 沿用 Unity 内置约定使用 L1+L2(共 9 系数),对应 unity_SHAr/g/bunity_SHBr/g/bunity_SHC 7 个 vec4 变量(注意 Unity 把 RGB 三个通道的 SH 拆开存储以便 GPU 并行处理)。

3.2 SH 解码

给定表面法线 ,从 SH 系数重建该方向的辐射度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
float3 SampleLightProbe(Surface surfaceWS)
{
#if defined(LIGHTMAP_ON)
return 0.0;
#else
float4 coefficients[7];
coefficients[0] = unity_SHAr;
coefficients[1] = unity_SHAg;
coefficients[2] = unity_SHAb;
coefficients[3] = unity_SHBr;
coefficients[4] = unity_SHBg;
coefficients[5] = unity_SHBb;
coefficients[6] = unity_SHC;

return max(0.0, SampleSH9(coefficients, surfaceWS.normal));
#endif
}

SampleSH9 是 RP Core Library 函数,展开为约 30 行 ALU——按 SH 基函数权重对系数做加权求和。整个 SH 解码每像素 ~30 ALU、零纹理采样——这就是 SH 几乎免费的根本原因。

max(0.0, ...) 是必要的——SH 的低频近似可能在某些方向上产生负值,这在物理上无意义,截断到 0 是工程惯例。

💡 球谐振铃(SH Ringing)的数学本质:这种产生负值的现象在信号处理中有正式名称——SH Ringing,是球面上的吉布斯现象(Gibbs Phenomenon)。当使用低阶(L1/L2)连续函数去拟合高频高对比度光照(比如一个极亮的聚光灯直射白墙、强烈逆光环境)时,截断的低阶 SH 必然在原信号陡变区域的反向产生过冲——亮的一面更亮、背光面跌入负值。这是 Fourier 截断级数对方波拟合时同样会出现的振荡现象,球面版本由 Ramamoorthi 与 Hanrahan 在 2001 年首次形式化。截断到 0 是实时渲染中最廉价的工程 Hack——HDRP 等高端管线会引入 SH 窗化(windowing)函数(Hanning / Lanczos)在烘焙阶段对系数做加权衰减,主动牺牲部分锐度换取负值消除,但这会让所有 SH 都变得更柔和,是质量层面的取舍。Custom SRP 选择最廉价的截断方案——这是教学路线的合理决策,但读者应该知道这是个 trade-off 而不是”理所当然”的代码。

max(0.0, ...) 这条小小的 saturate,背后承载的是这一整段球谐数学的物理边界。

3.3 Light Probe 数据来源

每个动态对象在每帧渲染前,引擎会:

  1. 找到对象世界位置周围的 4 个最近 Light Probe
  2. 用四面体重心坐标插值 4 个 Probe 的 SH 系数
  3. 把插值结果写入对象的 UnityPerDraw CBUFFER(unity_SHAr/g/b 等字段)

这个流程通过 PerObjectData.LightProbe 在 RendererListDesc 中声明启用。引擎自动处理插值,Shader 端只需采样系数。

实践要点:

  • Light Probe 网格密度决定动态对象 GI 质量:稀疏网格在过渡区会产生明显的光照跳变(角色从一个 Probe 走到另一个 Probe 时颜色突变)
  • Probe 应该放在角色路径上:而不是均匀网格——空中的 Probe 浪费烘焙时间
  • 室内/室外过渡处加密:光照变化最剧烈的位置需要更密的 Probe 分布

3.4 Light Probe Proxy Volume(LPPV)

单个对象只采样最近的 4 个 Probe——对小对象足够,但对大型动态对象(粒子系统、超大可移动物体、可破坏建筑),不同部位应该接收不同光照。LPPV 通过在对象包围盒内插值生成一个体积纹理,每个像素根据世界位置采样对应位置的 SH。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
float3 SampleLightProbe(Surface surfaceWS)
{
#if defined(LIGHTMAP_ON)
return 0.0;
#else
if (unity_ProbeVolumeParams.x)
{
// 使用 LPPV
return SampleProbeVolumeSH4(
TEXTURE3D_ARGS(unity_ProbeVolumeSH, samplerunity_ProbeVolumeSH),
surfaceWS.position, surfaceWS.normal,
unity_ProbeVolumeWorldToObject,
unity_ProbeVolumeParams.y, unity_ProbeVolumeParams.z,
unity_ProbeVolumeMin.xyz, unity_ProbeVolumeSizeInv.xyz);
}
else
{
// 标准单 Probe
float4 coefficients[7] = { ... };
return max(0.0, SampleSH9(coefficients, surfaceWS.normal));
}
#endif
}

unity_ProbeVolumeParams.x 是开关——引擎根据对象是否启用 LPPV 自动设置。LPPV 需要在 RendererListDesc 中声明 PerObjectData.LightProbeProxyVolume 标志位。

📱 LPPV 在移动端的隐形带宽杀手:LPPV 的核心数据载体是 3D 纹理(Texture3D),依赖硬件的三线性插值在体素之间平滑过渡。这在桌面端 GPU 上几乎免费——但在移动端 TBR 架构下,3D 纹理是显存带宽的灾难。原因是 3D 纹理的内存布局是按 Z-order 或线性切片存储,邻近 voxel 的内存局部性(Memory Locality)极差——shader 在三线性插值时每像素需要读取 8 个相邻 voxel,这 8 个 voxel 在物理内存上分散在多张缓存行中,极易引发多次 Cache Miss,每次 miss 都是一次主存往返。在移动端这意味着:原本期望几个像素的低频光照查询变成几十次显存访问,整个 PostFX 和着色阶段的带宽预算被一个大型 LPPV 物体吃光。实测某些 Mali GPU 上,开启 LPPV 的大动态对象会让该 frame 的整体带宽消耗翻倍。移动端项目应对大型动态对象,通常宁愿将其拆分为多个子 Mesh 走传统 Light Probe,也要极其谨慎地开启 LPPV——把 LPPV 视为高端平台特性,与 SSR、Compute Shader 同等谨慎对待。


4. Reflection Probe:环境镜面反射

4.1 IBL 采样原理

镜面反射需要知道”从某个方向看过来是什么颜色”——这正是 Cubemap 的功能。Reflection Probe 在每个采样点烘焙一个 Cubemap,Shader 运行时根据反射光线方向采样这个 Cubemap:

其中 是视线方向(从表面到相机), 是表面法线。 是反射方向,用它采样 Cubemap 即得镜面反射颜色。

但完美镜面反射只有在 roughness = 0 时正确——粗糙表面应该模糊采样。

4.2 Mip Chain 编码粗糙度

预过滤 Cubemap:烘焙阶段给同一 Cubemap 生成多级 Mip,每一级用越来越大的卷积核模糊。Mip 0 是清晰的、Mip N 是几乎全部模糊为环境平均色的。

Shader 运行时根据材质 roughness 计算应该采样哪一级 Mip:

1
2
3
4
5
6
7
8
9
10
float3 SampleEnvironment(Surface surfaceWS, BRDF brdf)
{
float3 uvw = reflect(-surfaceWS.viewDirection, surfaceWS.normal);
float mip = PerceptualRoughnessToMipmapLevel(brdf.perceptualRoughness);

float4 environment = SAMPLE_TEXTURECUBE_LOD(
unity_SpecCube0, samplerunity_SpecCube0, uvw, mip);

return DecodeHDREnvironment(environment, unity_SpecCube0_HDR);
}

PerceptualRoughnessToMipmapLevel 把 perceptualRoughness(=镜面,=完全粗糙)映射到 Mip 索引。这个映射不是线性的——按 Karis 2014 的 GGX 拟合公式:

非线性映射让中等粗糙度(perceptualRoughness ≈ 0.5)对应到合理的 Mip,避免 0/1 极端外的视觉突变。

DecodeHDREnvironment 处理 HDR 编码:Cubemap 通常用 RGBM 编码(M 通道存储指数)以低开销存储 HDR 数据,运行时解码为线性 RGB。

4.3 Box Projection:视差校正

默认 Cubemap 假设 Probe 位于无穷远——意味着无论表面在哪里,反射方向都从原点采样。这在室外开放场景没问题(天空盒的几何确实远),但在室内场景会立刻露馅:

  • 房间四面墙的 Probe 烘焙在房间中心
  • 一面有镜子的墙边,反射应该显示对面墙面(贴在镜子上)
  • 但默认采样总是从 Probe 中心(房间中心)出发,反射出来的位置是错的——视觉上像”反射图像在玻璃后面浮动”

Box Projection 通过 Probe 的包围盒(BoxProjection AABB)校正反射方向:

1
2
3
4
5
6
7
8
9
10
11
12
13
float3 BoxProjection(float3 direction, float3 position,
float4 cubemapPosition, float3 boxMin, float3 boxMax)
{
if (cubemapPosition.w > 0.0)
{
// 计算反射光线与 Box 各面的相交距离,取最近正值
float3 factors = ((direction > 0.0 ? boxMax : boxMin) - position) / direction;
float scalar = min(min(factors.x, factors.y), factors.z);
// 校正方向:从 Probe 中心指向交点
direction = direction * scalar + (position - cubemapPosition.xyz);
}
return direction;
}

cubemapPosition.w 是开关——unity_SpecCube0_BoxMin/BoxMax 配合,引擎根据 Probe 配置自动填充。开启 Box Projection 的 Probe 在 Inspector 中标记 “Box Projection”。

工程实践:室内场景全部使用 Box Projection;室外开放场景关闭以节省每像素几次 ALU。

⚠️ Box Projection 的几何前提:Box Projection 的整个数学推导严格假设房间是一个 AABB(轴对齐包围盒)——即六个面分别垂直于 X/Y/Z 三个世界轴。这条假设需要在与美术沟通规范时作为硬性约束传达,否则会出现以下问题:(1) 圆柱形大厅、穹顶建筑(教堂、剧院、体育馆内部):圆形墙面无法用一个 AABB 描述,反射会在曲面与盒边界的不匹配处产生严重撕裂,反射图像在墙面上”跳跃”;(2) 倾斜走廊或非正交墙面的复古建筑:墙面与 Box 法线不平行,反射的 mapping 错位,越靠近墙面错位越明显;(3) 多层穿插的复杂空间:用一个 AABB 包不住整个空间,必须强制切分为多个 Probe,过渡区出现明显的 Probe 边界瑕疵。遇到这些场景时只有两条出路:回退到无穷远 Cubemap(牺牲视差校正质量但避免撕裂),或引入屏幕空间反射 SSR(每像素几十次光线步进,移动端通常无法承担)。给美术的实操规范:室内场景几何尽量做成 AABB 友好(直角房间为主);曲面区域规划为”非反射焦点”(避免镜面材质放置在那里);如果艺术上必须有曲面+镜面(教堂铜镜),单独标注为高端平台特性,移动端使用降级方案(无视差 + 模糊度提升)。这种”防患于未然”的几何规范沟通,能避免项目后期发现”反射就是不对”时被迫返工美术或上 SSR。

4.4 Reflection Probe Blending

物体在两个 Probe 影响范围交叠区时,需要在两个 Probe 之间插值。Unity 支持最多两层 Probe(unity_SpecCube0 + unity_SpecCube1),通过 unity_SpecCube0_BoxMin.w 字段(或类似机制)传递混合权重:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float3 SampleEnvironment(Surface surfaceWS, BRDF brdf)
{
float3 uvw = reflect(-surfaceWS.viewDirection, surfaceWS.normal);
uvw = BoxProjection(uvw, surfaceWS.position,
unity_SpecCube0_ProbePosition,
unity_SpecCube0_BoxMin.xyz,
unity_SpecCube0_BoxMax.xyz);

float mip = PerceptualRoughnessToMipmapLevel(brdf.perceptualRoughness);
float4 environment = SAMPLE_TEXTURECUBE_LOD(
unity_SpecCube0, samplerunity_SpecCube0, uvw, mip);

return DecodeHDREnvironment(environment, unity_SpecCube0_HDR);
}

完整版 Probe Blending 会在 unity_SpecCube0 与 unity_SpecCube1 之间按权重 lerp。Custom SRP 简化实现只采样 SpecCube0——这是教学路线的取舍。完整 Blending 主要在 HDRP 等高端管线中使用。

4.5 PerObjectData 声明

要让引擎为每个对象正确填充 Reflection Probe 数据,必须在 RendererListDesc 中声明:

1
2
3
4
5
6
7
8
9
10
11
12
new RendererListDesc(shaderTagId, cullingResults, camera)
{
rendererConfiguration =
PerObjectData.ReflectionProbes | // unity_SpecCube0 / 1 数据
PerObjectData.Lightmaps | // unity_LightmapST
PerObjectData.LightProbe | // unity_SHAr/g/b 等
PerObjectData.OcclusionProbe | // unity_ProbesOcclusion
PerObjectData.LightProbeProxyVolume | // unity_ProbeVolume*
PerObjectData.OcclusionProbeProxyVolume |
PerObjectData.ShadowMask, // unity_ShadowMask
// ...
};

漏掉某项 → 对应 Shader 变量保持默认值(通常是 0 或单位矩阵)→ 渲染结果错误但 Shader 编译不会报错——这是最难诊断的一类 bug。RendererListDesc 的 rendererConfiguration 字段是 GI 接入的”开关总闸”。


5. Ambient 环境光

Ambient 是最简单的间接光层级——给所有动态对象一个统一的”基础亮度”,避免阴影区完全死黑。Unity Lighting 设置中的三种模式:

模式 数据形式 视觉特点
Skybox 来自 Skybox 的 SH 采样 自动匹配天空颜色,最物理
Gradient 三个手动颜色(天 / 地平线 / 地) 美术可调,适合写实风格
Color 单一颜色 最廉价,扁平美术风格

Ambient 的实现统一通过 SH——三种模式都生成一组等效的 SH 系数(对 Skybox 模式是真正的球面采样、对 Gradient 是手动配置的等效 SH),然后通过 unity_SHAr/g/b 等变量传给 Shader。这意味着 Ambient 与 Light Probe 共享同一个 SH 解码路径——Shader 端不需要区分两者。

Skybox 模式是最自然的选择:当美术调整 Skybox 后,Ambient 自动跟随,无需手动调整。Lighting 设置中的 “Auto Generate” 启用时引擎会持续跟踪 Skybox 变化。

Glossy Environment Reflection(Skybox 作为低分辨率 Cubemap 给所有对象用作 Reflection Probe fallback)也属于这一类——当对象不在任何 Reflection Probe 范围内时,引擎自动使用 Skybox 卷积版本作为 unity_SpecCube0。


6. Meta Pass:GI 烘焙的源头

6.1 Meta Pass 的角色

GI 烘焙器需要知道”场景中每个表面把什么颜色的光反弹出去”——这个数据由 Meta Pass 提供。烘焙器在运行 Progressive Lightmapper 时,对每个 Lightmap texel 执行类似 path tracing 的过程,每次光线击中表面时调用该表面 Shader 的 Meta Pass,读取该位置的反射率(Albedo)与自发光(Emission)。

HLSL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
struct MetaVaryings
{
float4 positionCS : SV_POSITION;
float2 baseUV : VAR_BASE_UV;
};

MetaVaryings MetaPassVertex(Attributes input)
{
MetaVaryings output;
output.positionCS = UnityMetaVertexPosition(
input.positionOS, input.lightMapUV,
unity_LightmapST, unity_DynamicLightmapST);
output.baseUV = TransformBaseUV(input.baseUV);
return output;
}

float4 MetaPassFragment(MetaVaryings input) : SV_TARGET
{
InputConfig config = GetInputConfig(input.positionCS, input.baseUV);
float4 base = GetBase(config);
Surface surface;
surface.color = base.rgb;
surface.metallic = GetMetallic(config);
surface.smoothness = GetSmoothness(config);
BRDF brdf = GetBRDF(surface);

float4 meta = 0.0;

if (unity_MetaFragmentControl.x)
{
// 输出 Albedo(含 specular 贡献的间接光近似)
meta = float4(brdf.diffuse, 1.0);
meta.rgb += brdf.specular *
brdf.roughness * 0.5; // 高粗糙度时镜面反射也参与漫反射 GI
meta.rgb = min(PositivePow(meta.rgb, unity_OneOverOutputBoost),
unity_MaxOutputValue);
}
else if (unity_MetaFragmentControl.y)
{
// 输出 Emission
meta = float4(GetEmission(config), 1.0);
}

return meta;
}

unity_MetaFragmentControl.x/y 是烘焙器的二选一信号——同一个 Meta Pass 被调用两次,一次用于 Albedo 输出、一次用于 Emission。Catlike 实现把镜面反射在高粗糙度时按比例加到 Albedo 中,让磨砂金属表面也能正确参与 GI 反弹。

UnityMetaVertexPosition 是关键——它把 vertex 从世界空间投影到 Lightmap UV 空间,让 Meta Pass 渲染到 Lightmap 而不是相机视图。这个函数处理了 Mesh 的 lightmap UV 与 dynamic lightmap UV 的复杂映射逻辑。

6.2 不写 Meta Pass 的后果

如果 Shader 没有 Meta Pass:

  • Lightmap 烘焙结果偏黑:烘焙器找不到合法的 Albedo 输出,默认用纯黑——意味着该材质表面”不反射光”,整个场景的间接光照会显著偏暗
  • Emission 不参与 GI:自发光物体(霓虹灯、火焰)应该照亮周围,但只有静态烘焙的发光物才会贡献 GI——如果 Meta Pass 缺失,发光物变成视觉上的发光物但物理上的纯黑物
  • GI 仅来源于天空盒:Ambient + Skybox Reflection 仍然有效,但场景内表面之间的反弹完全消失

实践原则:任何有静态对象的项目,所有 Lit Shader 必须写 Meta Pass——这是 GI 接入的硬性要求。

6.3 Cull Off 与单面写入

Meta Pass 通常配置 Cull Off——因为 Lightmap 渲染需要从所有方向接收信息,背面剔除会丢失某些表面贡献。但这意味着双面 Mesh(草、织物)的两面会写入同一 Lightmap texel——美术需要在 UV 布局上避免重叠。


7. LOD 与 GI 的协同

7.1 Cross-fade 对 GI 的影响

Note 2 §5.2 介绍了 LOD Cross-fade Dithering——通过 dither alpha 让 LOD 切换平滑。这个机制对 GI 数据的影响值得单独说明:

  • Lightmap UV:每个 LOD 级别有自己的 lightmap UV,烘焙时各自独立。Cross-fade 期间两个 LOD 的 Lightmap 不会冲突
  • Light Probe SH 系数:所有 LOD 共享同一对象的 SH 数据,无需特殊处理
  • Reflection Probe 配置:同上
  • Per-object data 一致性unity_ProbeOcclusion 等 per-object 字段对一个对象的所有 LOD 是一致的——LOD 只是几何细节级别,不改变其在世界中的位置/光照属性

7.2 LOD Group 与 Static 标记的微妙关系

Lightmap Static 标记与 LOD Group 的组合需要谨慎:

  • LOD0 标记 Static、LOD1+ 不标记:常见但有暗坑——LOD0 走 Lightmap、LOD1+ 走 Light Probe,过渡时光照风格切换可能出现可见差异
  • 所有 LOD 都 Static:所有级别都进入 Lightmap 烘焙,但只有 LOD0 通常被烘焙器选中(基于屏幕尺寸优先级)。Lightmap UV 必须为每个 LOD 单独设置
  • 所有 LOD 都不 Static:全部走 Light Probe,行为一致——这是动态对象的标准做法

工程推荐:远距离不变的大型场景元素(地形、建筑外墙)所有 LOD 都标 Static 并设 lightmap UV;动态/可破坏对象(角色、敌人、可拾取物)一律不标 Static。


8. TA Takeaway

8.1 GI 数据成本图谱

GI 在每像素的总成本:

1
2
3
4
5
6
7
8
Diffuse Indirect:
├─ Lightmap 路径: 1 sample (~5 cycle) + HDR decode (~3 ALU)
└─ SH 路径: 0 sample + ~30 ALU (SampleSH9)

Specular Indirect:
└─ Reflection Probe: 1 cubemap sample + Mip LOD + Box Projection (~10 ALU)

Total per-pixel: ~15-50 ALU + 1-2 samples

GI 是 PBR Lit Shader 中性价比最高的视觉投资——用约 15% 的 fragment 成本提供约 50% 的视觉真实感来源。在性能预算紧张的项目中,可以裁剪直接光质量(PCF、cascade),但应该尽可能保留 GI 完整性

8.2 烘焙时间是项目效率的隐形杀手

Lightmap 烘焙在大场景中可能耗时数小时。核心影响因素:

  • Lightmap 分辨率:512²、1024²、2048²,分辨率每翻倍烘焙时间约 3-4 倍
  • Lightmap 数量:场景被切分为多张 Atlas,每张独立烘焙
  • Indirect Samples:决定 GI 求解的精度,提高样本数线性增加时间
  • 场景几何复杂度:Meta Pass 调用次数 = 顶点数 × 烘焙采样数

实践建议:

  • 项目早期定下 lightmap 分辨率上限(典型 256-512 texels/m),后期不要轻易上调
  • 场景调整频繁时降低 Indirect Samples(开发期用低质量、发布前才用高质量)
  • 烘焙阶段使用 GPU Lightmapper(CUDA / Metal)比 CPU 快 5-10×
  • 启用 Lightmap Mode = Distance Shadowmask(Note 5 §6.3),保留实时阴影质量同时节省烘焙时间

8.3 Probe 布置的工程艺术

Light Probe 布置不是”摆得越多越好”——而是让 Probe 落在光照变化最剧烈的位置

  • 室内/室外过渡:门口、窗边、洞口的两侧各放一个,否则角色穿越时光照会突变
  • 不同材质区域分界:水边、火光附近、彩色玻璃下方,Probe 应该贴近边界两侧
  • 避免无意义的密集:纯白墙体内部不需要密 Probe——光照梯度极小
  • 垂直分布:在多层建筑中,每层楼至少一组 Probe(人眼对垂直光照变化敏感)

Reflection Probe 布置原则更直接:

  • 每个独立空间一个:每个房间、每片户外区域单独一个
  • 位置在视觉中心:人眼最常关注的视点附近,而不是几何中心
  • Resolution 与房间大小匹配:小房间 64²、大房间 128²、户外 256²

8.4 PerObjectData 声明的工程纪律

GI 接入的最常见 bug 类型不是 Shader 错误,而是 PerObjectData 漏声明——Shader 编译通过、运行无报错、画面看起来”差点意思”但说不清差在哪。建立两条工程纪律:

  1. 新项目模板预设全部 GI 相关 flag:把 ReflectionProbes / Lightmaps / LightProbe / OcclusionProbe / LightProbeProxyVolume / OcclusionProbeProxyVolume / ShadowMask 全部默认开启。性能担心是次要的——引擎只对实际需要的对象填充数据,未使用的标志几乎零成本
  2. Shader 端 fallback 防御:所有 GI 采样在 LIGHTMAP_ON 等关键字未定义时给出合理默认值(漫反射用 SH、镜面用 Reflection Probe),避免单一路径失效导致整片黑色

8.5 实践原则

  • 静态场景必写 Meta Pass:缺失会导致整体偏暗、Emission 失效
  • 室内场景启用 Box Projection:消除”反射图像漂浮”现象
  • 大动态对象启用 LPPV:避免单 Probe 对大型粒子系统的光照失真
  • Lightmap Mode 用 Distance Shadowmask:质量与烘焙时间最佳平衡
  • Auto Generate Lighting 在大项目中关闭:防止 Editor 频繁烘焙拖慢工作流;改为手动触发
  • GI 调试用 Scene View 模式:Baked Lightmap / Indirect / Light Probes 等可视化是诊断 GI 问题的首选工具

关键 API 速查

1
2
3
4
5
6
7
8
9
10
11
12
// PerObjectData 声明(GI 接入的总开关)
new RendererListDesc(shaderTagId, cullingResults, camera)
{
rendererConfiguration =
PerObjectData.ReflectionProbes |
PerObjectData.Lightmaps |
PerObjectData.LightProbe |
PerObjectData.OcclusionProbe |
PerObjectData.LightProbeProxyVolume |
PerObjectData.OcclusionProbeProxyVolume |
PerObjectData.ShadowMask,
};
HLSL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// Lightmap 采样
TEXTURE2D(unity_Lightmap);
SAMPLER(samplerunity_Lightmap);

float3 SampleLightMap(float2 lightMapUV);
float3 SampleSingleLightmap(/* RP Core function */);

// Light Probe SH(unity_SH* 在 UnityPerDraw CBUFFER)
float4 unity_SHAr, unity_SHAg, unity_SHAb; // L1 系数
float4 unity_SHBr, unity_SHBg, unity_SHBb; // L2 子集
float4 unity_SHC; // L2 残余

float3 SampleSH9(float4 coefficients[7], float3 normal);

// LPPV
float4 unity_ProbeVolumeParams;
float4x4 unity_ProbeVolumeWorldToObject;
float4 unity_ProbeVolumeSizeInv;
float4 unity_ProbeVolumeMin;
TEXTURE3D(unity_ProbeVolumeSH);

float3 SampleProbeVolumeSH4(/* RP Core function */);

// Reflection Probe
TEXTURECUBE(unity_SpecCube0);
SAMPLER(samplerunity_SpecCube0);
float4 unity_SpecCube0_HDR;
float4 unity_SpecCube0_ProbePosition;
float4 unity_SpecCube0_BoxMin, unity_SpecCube0_BoxMax;

float3 BoxProjection(direction, position, cubemapPos, boxMin, boxMax);
float PerceptualRoughnessToMipmapLevel(perceptualRoughness);
float3 DecodeHDREnvironment(envSample, hdrParams);

// Lightmap 关键字
#pragma multi_compile _ LIGHTMAP_ON
#pragma multi_compile _ DIRLIGHTMAP_COMBINED // 仅 Directional Lightmap

// Meta Pass 必备
Pass {
Tags { "LightMode" = "Meta" }
Cull Off
HLSLPROGRAM
#pragma vertex MetaPassVertex
#pragma fragment MetaPassFragment
ENDHLSL
}

float4 unity_MetaFragmentControl; // .x: albedo flag, .y: emission flag
float unity_OneOverOutputBoost;
float unity_MaxOutputValue;

// GI struct 标准接口
struct GI { float3 diffuse, specular; ShadowMask shadowMask; };
GI GetGI(float2 lightmapUV, Surface surfaceWS, BRDF brdf);