GameTech/URP 渲染实战
CHAPTER 08第 8 章

环境光、光照贴图与反射探针

只有直接光的画面,阴影是死黑的,金属是黑的。本章讲“直接光之外的所有光”在 URP 里如何被预先算好、存起来、再在 Shader 里取回来:球谐环境光、光照贴图、反射探针,以及环境光遮蔽怎样进入这一切。

本章你会学到
间接光的拆分:间接漫反射与间接高光各从哪里来
球谐(SH)的原理、Unity 的 7 个 float4 打包方式与 SampleSH
光照贴图相关宏:DECLARE_LIGHTMAP_OR_SH、SAMPLE_GI 等
Mixed Lighting 三种模式、Meta Pass 与自发光参与烘焙
GlossyEnvironmentReflection、粗糙度 → mip、盒体投影与探针混合
材质 AO 与 SSAO 如何作用于间接光;Matcap 作为对照

8.1 间接光:直接光之外的一切

第 5、6 章的光照模型只考虑了“从灯直接射到表面”的光。现实里,光会在场景中反复弹射:天空本身就是一个巨大的面光源,地面把阳光反射到物体的底面,红墙把旁边的白球染上一层红晕,金属表面映出周围的环境。这些“经过至少一次反弹、或者来自环境”的光统称间接光(Indirect Lighting),计算它们的过程称为全局光照(Global Illumination,GI)。

完整求解全局光照需要对每个像素在整个半球上积分入射光,而入射光又依赖于其他表面的出射光——这在实时渲染里不可能逐帧完成。实时引擎的思路是预计算 + 近似:把场景的间接光提前算好、用某种紧凑的形式存起来,渲染时只做查询。按照 BRDF 的两个部分,间接光被拆成两路:

  • 间接漫反射:漫反射对所有方向的入射光都一视同仁(只按余弦加权),所以它只需要知道“这一点、这个法线方向上,半球入射光的余弦加权总和”,也就是辐照度(Irradiance)。辐照度是一个随法线方向非常平滑变化的量,可以用很少的数据表示——球谐函数和光照贴图都是存它的。
  • 间接高光:镜面反射只关心反射方向附近一小束光,而且这一束的宽度由粗糙度决定。它需要一张能按方向查询、并且按不同粗糙度预先模糊过的“环境图”——这就是反射探针的预过滤 Cubemap。
数据来源(编辑器 / 烘焙) Shader 拿到的数据 Shader 中的入口 环境光:Skybox/Gradient/ColorLighting 窗口 › Environment Light Probe Group烘焙后按物体位置插值 光照贴图烘焙静态物体,依赖 Meta Pass 反射探针 / 环境反射Cubemap + 预过滤 mip 链 unity_SHAr … unity_SHC每个物体一组 SH 系数 unity_Lightmap(+Ind/ShadowMask)unity_LightmapST unity_SpecCube0 / 探针图集_GlossyEnvironmentCubeMap SAMPLE_GI → bakedGI间接漫反射(辐照度/π) GlossyEnvironmentReflection间接高光(按粗糙度取 mip) EnvironmentBRDF × AO= GlobalIllumination()
图 8-1URP 中间接光的数据流:左边在编辑器里准备,中间是逐物体或全局的 Shader 变量,右边是取用它们的函数。

环境光的三种来源

在 Window › Rendering › Lighting › Environment 里,Environment Lighting 的 Source 有三个选项:

  • Skybox:从天空盒材质计算环境光。天空越蓝,物体背光面越蓝;地平线以下的颜色决定物体底部的亮度。Intensity Multiplier 整体缩放。
  • Gradient:手动指定天空(Sky)、地平线(Equator)、地面(Ground)三种颜色,常用于风格化项目。
  • Color:一个常数颜色,所有方向都一样,等价于最古老的“ambient color”。

三种来源最终都会变成同一种东西:一组球谐系数,存在 RenderSettings.ambientProbe 里。场景中没有光照探针(Light Probe)的物体,Shader 拿到的 unity_SHAr 等变量就来自它;有光照探针的物体则拿到插值后的探针系数。这意味着一个重要的实际行为:环境光不是实时从天空盒采样的。运行时用脚本换了天空盒材质,环境光不会自动更新,需要调用 DynamicGI.UpdateEnvironment() 重新计算;在编辑器里,则由 Lighting 窗口的自动生成或手动 Generate Lighting 负责。

案例 8.1用 C# 构造环境光:Color、Gradient 与 Skybox入门单独打开 ↗

目标:不经过 Lighting 窗口,直接用脚本构造三种来源的球谐环境光,对比它们对物体的影响。这也是在运行时自定义环境光(比如昼夜循环)的基本手段。

SphericalHarmonicsL2 是 Unity 提供的三阶球谐结构体,有两个方便的方法:AddAmbientLight(color) 加一个常数项,AddDirectionalLight(dir, color, intensity) 加一盏方向光。“Color”模式只需要前者;对于 Gradient 和任意的天空函数,我们在球面上均匀撒点,把每个方向当成一盏小方向光加进去——这就是蒙特卡洛投影:

Assets/Cookbook/Ch08/EnvironmentTools.cs(节选:project)
/// 蒙特卡洛投影:在球面上均匀取 sampleCount 个方向,每个方向当成一盏小方向光加进 SH。
/// AddDirectionalLight 内部已经做了余弦卷积,但它的归一化是“在灯光方向上求值 = 2 × 强度”
/// (实测:AddDirectionalLight(up, 白, 1) 在 up 方向求值得 2.0)。而三阶截断的余弦波瓣在峰值处
/// 理论值是 1/4 + 1/2 + 5/16 = 17/16,所以 Unity 的结果比“辐照度/π”大 2÷(17/16) = 32/17 倍。
/// 均匀环境 L 拆成 N 盏灯时,每盏取 4L/N 才能得到 L,再乘 17/32 抵消上面的倍数。
public static SphericalHarmonicsL2 ProjectToSH(Func<Vector3, Color> radiance, int sampleCount = 4096, int seed = 1)
{
    var sh = new SphericalHarmonicsL2();
    var rnd = new System.Random(seed);
    float weight = 4f / sampleCount * (17f / 32f);
    for (int i = 0; i < sampleCount; i++)
    {
        float z = (float)rnd.NextDouble() * 2f - 1f;
        float a = (float)rnd.NextDouble() * Mathf.PI * 2f;
        float r = Mathf.Sqrt(1f - z * z);
        var d = new Vector3(r * Mathf.Cos(a), z, r * Mathf.Sin(a));
        sh.AddDirectionalLight(d, radiance(d), weight);
    }
    return sh;
}

权重的推导值得一看。假设环境是均匀的、辐射率为 L,我们希望 SH 在任何方向上求值都得到 L(URP 约定下的“辐照度 / π”,下一节解释)。把它拆成 N 盏均匀分布的小方向光,每盏强度 w,对法线 n 的 Lambert 响应总和是 w∑imax(0,n·li)≈wN·14(max(0, cosθ) 在整个球面上的平均值是 1/4),令它等于 L 得 w = 4L/N。但实测 Unity 的 AddDirectionalLight(up, 白, 1) 在 up 方向上求值得到 2.0,而三阶截断的余弦波瓣在峰值处的理论值是 1/4 + 1/2 + 5/16 = 17/16——Unity 的结果整体大了 32/17 倍。所以代码里再乘 17/32 抵消。这类“约定上的倍数”在不同引擎、不同 API 之间经常不一样,写完投影代码后务必用一个均匀环境验证一次:本例中用白色均匀环境投影后,Evaluate 在各方向上都应得到 1 附近的值。

Gradient 的环境函数只是按方向的 y 分量在三种颜色之间插值(Unity 内部的具体插值曲线未公开,这里用一个形状相近的函数):

public static Func<Vector3, Color> Gradient(Color sky, Color equator, Color ground) => d =>
    d.y >= 0 ? Color.Lerp(equator, sky, Mathf.Pow(d.y, 0.6f))
             : Color.Lerp(equator, ground, Mathf.Pow(-d.y, 0.6f));

// 使用:
RenderSettings.ambientProbe = EnvironmentTools.ProjectToSH(
    EnvironmentTools.Gradient(skyColor, equatorColor, groundColor));
图 8-2同样的主光,不同的环境光。左:Color(常数灰色,各方向一样,球体背光面平淡);中:Gradient(蓝色天空 + 暖色地面,球体底部被地面“反弹”成暖色);右:Skybox(拍摄环境的程序化天空投影成 SH)。物体使用本章案例 8.9 的 GILit。

注意中图球体下半部分的暖色——这是 Gradient 环境里 Ground 颜色的贡献,它模拟了“地面反弹光”。Color 模式下物体的立体感完全来自主光,背光面是一片均匀的灰,这就是为什么老游戏里的背光面总显得“假”。

8.2 球谐函数:用 9 个数描述一片天空

为什么辐照度可以压缩得这么狠

对一个漫反射表面,法线为 n 时的出射光正比于辐照度:

E(n)=∫ΩL(ω)max(0,n·ω)dω

这是环境辐射率 L 与一个“截断余弦”核的卷积。余弦核是一个非常宽、非常平滑的波瓣,卷积相当于一次强力的模糊:环境里再锐利的细节(太阳、窗户边缘)在辐照度里都变成了柔和的渐变。频域上看,模糊就是把高频成分滤掉。球谐函数(Spherical Harmonics,SH)是球面上的“傅里叶基”:按频率从低到高分成第 0 阶、第 1 阶、第 2 阶……第 l 阶有 2l+1 个基函数。Ramamoorthi 与 Hanrahan 在 2001 年的论文中给出了余弦核在每一阶上的衰减系数:

A^0=π,A^1=2π3,A^2=π4,A^3=0,A^4=−π24,…
1.0 0.5 l=0l=1l=2l=3l=4l=5l=6 12/31/401/241/64 前三阶(9 个基函数)几乎包含了余弦卷积后的全部能量
图 8-3余弦卷积在各阶上的衰减系数 |Âl| / π。第 3 阶及所有奇数高阶为 0,第 4 阶只剩 1/24,所以只保留 l ≤ 2 的 9 个系数就能很好地近似任意环境的辐照度。

这就是“9 个数描述一片天空”的由来:不是天空本身只有 9 个数的信息量,而是天空经过余弦卷积之后,剩下的低频部分只需要 9 个数。每个颜色通道 9 个系数,RGB 共 27 个浮点数,就能为一个物体提供方向相关的环境光。

投影与重建

把环境 L 投影到第 l 阶第 m 个基函数上,得到系数 Llm=∫ΩL(ω)Ylm(ω)dω。重建辐照度时,每一阶乘上对应的衰减系数:

E(n)π≈∑l=02∑m=−llA^lπLlmYlm(n)

为什么左边除以 π?因为 Lambert BRDF 是 albedo/π,出射辐射率 = albedo × E / π。URP 把这个 π 提前合进了 SH 系数(core 库 SphericalHarmonics.hlsl 里的 kClampedCosine0~2 分别是 1、2/3、1/4,注释写着 “pre-divided by PI”),于是 Shader 里 SampleSH(n) 的结果直接乘以 albedo(更准确地说是 brdfData.diffuse)就是间接漫反射的出射光。这与 URP 对直接光的约定一致:灯光颜色已经隐含了 π,Lambert 项不再除 π。

前三阶的实数球谐基函数(d = (x, y, z) 为单位方向):

阶基函数形状
l = 0Y00 = 0.282095常数,球面上处处相同:平均亮度
l = 1Y1,−1 = 0.488603 y,Y10 = 0.488603 z,Y11 = 0.488603 x沿三根轴的“哑铃”:哪一侧更亮
l = 2Y2,−2 = 1.092548 xy,Y2,−1 = 1.092548 yz,Y20 = 0.315392 (3z² − 1),Y21 = 1.092548 xz,Y22 = 0.546274 (x² − y²)四叶草与“甜甜圈 + 哑铃”:更细的方向变化,例如“上下都亮、四周暗”

这些常数在 core 库里定义为 kSHBasis0~4,Unity 的 SphericalHarmonicsL2 也按这 9 个函数、这个顺序存储系数。

案例 8.2把球谐基函数“画”出来入门单独打开 ↗

目标:写一个 Shader,把单位球的每个顶点沿径向推到 |Ylm(方向)| 处,正值涂暖色、负值涂冷色,得到球谐教科书上经典的花瓣图。

Assets/Cookbook/Ch08/SHBasis.shader(节选:basis)
// 实数球谐基函数,系数与 core 的 SphericalHarmonics.hlsl 中 kSHBasis0~4 一致。
// d 为单位方向;Unity 的 SphericalHarmonicsL2 也按这 9 个函数、这个顺序存系数。
float SHBasis(int l, int m, float3 d)
{
    float x = d.x, y = d.y, z = d.z;
    if (l == 0) return 0.282095;                                   // Y00 = 1/2·sqrt(1/π)
    if (l == 1)
    {
        if (m == -1) return 0.488603 * y;                          // Y1-1
        if (m == 0)  return 0.488603 * z;                          // Y10
        return 0.488603 * x;                                       // Y11
    }
    if (m == -2) return 1.092548 * x * y;                          // Y2-2
    if (m == -1) return 1.092548 * y * z;                          // Y2-1
    if (m == 0)  return 0.315392 * (3.0 * z * z - 1.0);            // Y20
    if (m == 1)  return 1.092548 * x * z;                          // Y21
    return 0.546274 * (x * x - y * y);                             // Y22
}
// 实数球谐基函数,系数与 core 的 SphericalHarmonics.hlsl 中 kSHBasis0~4 一致。
// d 为单位方向;Unity 的 SphericalHarmonicsL2 也按这 9 个函数、这个顺序存系数。
float SHBasis(int l, int m, vec3 d)
{
    float x = d.x, y = d.y, z = d.z;
    if (l == 0) return 0.282095;                                   // Y00 = 1/2·sqrt(1/π)
    if (l == 1)
    {
        if (m == -1) return 0.488603 * y;                          // Y1-1
        if (m == 0)  return 0.488603 * z;                          // Y10
        return 0.488603 * x;                                       // Y11
    }
    if (m == -2) return 1.092548 * x * y;                          // Y2-2
    if (m == -1) return 1.092548 * y * z;                          // Y2-1
    if (m == 0)  return 0.315392 * (3.0 * z * z - 1.0);            // Y20
    if (m == 1)  return 1.092548 * x * z;                          // Y21
    return 0.546274 * (x * x - y * y);                             // Y22
}

变形在顶点着色器里完成:dir * abs(y) * _Scale。变形后网格原有的法线已经失效,片元里用 cross(ddy(p), ddx(p)) 求面法线做简单明暗,并朝向相机翻转,这样就不必关心叉乘方向在不同平台上的差异。

图 8-4前三阶共 9 个实数球谐基函数,橙色为正、蓝色为负,每个物体都绕同一轴转了一个角度以显示立体感。第一行 Y00;第二行从左到右 Y1,−1(沿 y)、Y10(沿 z)、Y11(沿 x);第三行 Y2,−2 … Y22。

看着这张图很容易建立直觉:任何环境光都是这 9 个形状的加权和。L0 给出“整体有多亮”;L1 的三个哑铃组合起来,可以表示“光主要从哪个方向来”;L2 则能表示一些 L1 做不到的分布,比如“天顶亮、地面也亮、四周暗”(Y20 那样的形状)。

Unity 怎样把 27 个系数交给 Shader

直接传 27 个 float 当然可以,但 Unity 做了一个更聪明的打包:把基函数展开成 x、y、z 的多项式后,重新组合成 7 个 float4,使得求值只需要几次 dot:

变量内容求值时与之点乘的向量
unity_SHAr/Ag/Abxyz:L1 的三个系数;w:L0 系数减去 Y20 中的常数部分(n.x, n.y, n.z, 1)
unity_SHBr/Bg/BbL2 中 xy、yz、zz、zx 四项的系数(xy, yz, zz, zx)
unity_SHCrgb:L2 中 (x² − y²) 一项的系数乘以 x² − y²

求值代码就是 core 库的两个函数,SampleSH 把它们加起来再与 0 取最大值:

摘自 core 14 ShaderLibrary/SphericalHarmonics.hlsl(节选)
real3 SHEvalLinearL0L1(real3 N, real4 shAr, real4 shAg, real4 shAb)
{
    real4 vA = real4(N, 1.0);
    real3 x1;
    // Linear (L1) + constant (L0) polynomial terms
    x1.r = dot(shAr, vA);
    x1.g = dot(shAg, vA);
    x1.b = dot(shAb, vA);
    return x1;
}

real3 SHEvalLinearL2(real3 N, real4 shBr, real4 shBg, real4 shBb, real4 shC)
{
    real3 x2;
    // 4 of the quadratic (L2) polynomials
    real4 vB = N.xyzz * N.yzzx;
    x2.r = dot(shBr, vB);
    x2.g = dot(shBg, vB);
    x2.b = dot(shBb, vB);
    // Final (5th) quadratic (L2) polynomial
    real vC = N.x * N.x - N.y * N.y;
    real3 x3 = shC.rgb * vC;
    return x2 + x3;
}

注意 N.xyzz * N.yzzx 这一行:一次乘法同时得到 xy、yz、zz、zx 四个二次项。整个三阶 SH 求值只需要大约 3 次 dot4、3 次 dot4、几次乘加——非常便宜。

逐顶点、混合与逐像素

URP 的 Lit 并不总是在像素里算完整 SH。#pragma multi_compile _ EVALUATE_SH_MIXED EVALUATE_SH_VERTEX 提供了三种模式,由 URP Asset 的 SH Evaluation Mode 设置决定(Auto 时,移动平台、XR 移动端和 Switch 选逐顶点,其余选逐像素,见 ShAutoDetect):

摘自 URP 14 ShaderLibrary/GlobalIllumination.hlsl(节选)
half3 SampleSHVertex(half3 normalWS)
{
#if defined(EVALUATE_SH_VERTEX)
    return SampleSH(normalWS);
#elif defined(EVALUATE_SH_MIXED)
    // no max since this is only L2 contribution
    return SHEvalLinearL2(normalWS, unity_SHBr, unity_SHBg, unity_SHBb, unity_SHC);
#endif
    // Fully per-pixel. Nothing to compute.
    return half3(0.0, 0.0, 0.0);
}

half3 SampleSHPixel(half3 L2Term, half3 normalWS)
{
#if defined(EVALUATE_SH_VERTEX)
    return L2Term;
#elif defined(EVALUATE_SH_MIXED)
    half3 res = L2Term + SHEvalLinearL0L1(normalWS, unity_SHAr, unity_SHAg, unity_SHAb);
    ...
    return max(half3(0, 0, 0), res);
#endif
    // Default: Evaluate SH fully per-pixel
    return SampleSH(normalWS);
}

混合模式的思路是:L2 部分变化最平缓、计算量最大,放到顶点里算;L0、L1 只需要一次 dot,放到像素里算,这样法线贴图对 L1 方向性的影响仍然保留。对自写 Shader 来说,只要照搬 Lit 的写法——顶点里 OUTPUT_SH、片元里 SAMPLE_GI——三种模式就都自动支持了(见案例 8.9)。

案例 8.3每一阶 SH 贡献了什么进阶单独打开 ↗

目标:用一个色彩强烈的环境(上方暖白、左侧红、右侧蓝、下方偏绿),分别只用 L0、L0+L1、完整三阶来渲染,看清每一阶的作用。

Assets/Cookbook/Ch08/AmbientSH.shader(节选:bands)
// unity_SHAr/Ag/Ab:xyz = L1 三个系数,w = L0 系数“减去”L2 里 Y20 的常数部分;
// unity_SHBr/Bg/Bb:L2 中 xy、yz、zz、zx 四项;unity_SHC:L2 中 x²-y² 一项(rgb 三通道)。
// 这些系数已经乘上了“余弦卷积”因子并除以 π,所以直接算出来的就是 URP 约定下的间接漫反射。
half3 EvaluateSHBands(half3 N, int bands)
{
    // L2 在整个球面上积分为 0,但 zz 项的平均值是 1/3。Unity 把 Y20 的常数部分挪进了 w,
    // 想要“纯 L0”,就把 zz 项的平均值 SHB.z/3 加回去。
    half3 L0 = half3(unity_SHAr.w + unity_SHBr.z / 3.0,
                     unity_SHAg.w + unity_SHBg.z / 3.0,
                     unity_SHAb.w + unity_SHBb.z / 3.0);
    if (bands == 0) return L0;

    half3 L1 = half3(dot(unity_SHAr.xyz, N), dot(unity_SHAg.xyz, N), dot(unity_SHAb.xyz, N));
    if (bands == 1) return max(0, L0 + L1);

    // 完整三阶:与 URP 的 SampleSH(N) 完全相同(SHEvalLinearL0L1 + SHEvalLinearL2)
    return SampleSH(N);
}
// unity_SHAr/Ag/Ab:xyz = L1 三个系数,w = L0 系数“减去”L2 里 Y20 的常数部分;
// unity_SHBr/Bg/Bb:L2 中 xy、yz、zz、zx 四项;unity_SHC:L2 中 x²-y² 一项(rgb 三通道)。
// 这些系数已经乘上了“余弦卷积”因子并除以 π,所以直接算出来的就是 URP 约定下的间接漫反射。
vec3 EvaluateSHBands(vec3 N, int bands)
{
    // L2 在整个球面上积分为 0,但 zz 项的平均值是 1/3。Unity 把 Y20 的常数部分挪进了 w,
    // 想要“纯 L0”,就把 zz 项的平均值 SHB.z/3 加回去。
    vec3 L0 = vec3(unity_SHAr.w + unity_SHBr.z / 3.0,
                   unity_SHAg.w + unity_SHBg.z / 3.0,
                   unity_SHAb.w + unity_SHBb.z / 3.0);
    if (bands == 0) return L0;

    vec3 L1 = vec3(dot(unity_SHAr.xyz, N), dot(unity_SHAg.xyz, N), dot(unity_SHAb.xyz, N));
    if (bands == 1) return max(vec3(0.0), L0 + L1);

    // 完整三阶:与 URP 的 SampleSH(N) 完全相同(SHEvalLinearL0L1 + SHEvalLinearL2)
    return SampleSH(N);
}

“只要 L0”并不是简单地取 unity_SHAr.w:Unity 把 Y20 = c(3z² − 1) 中的常数项 −c 挪进了 w,所以 w 里混着一部分 L2。L2 整体在球面上积分为 0,而 zz 项在球面上的平均值是 1/3,所以把 SHBr.z / 3 加回去,就恢复出真正的 L0。这个小推导能帮你在需要“平均环境亮度”时取到正确的值(例如给粒子、头发卡片一个不随方向变化的环境光)。

图 8-5同一个彩色环境,从左到右:只用 L0(处处同色,没有任何方向信息)、L0 + L1(左红右蓝、上亮下暗,已经有了主要的方向感)、完整三阶(球体底部出现绿色、顶部更暖,色彩过渡更丰富)。

从 L0 到 L1 是质变,从 L1 到 L2 是改进。在极度受限的情况下(例如非常低端的设备、大量的植被实例),只用 L0+L1 也是可以接受的近似;URP 的混合模式也正是基于这样的判断——L1 必须在像素里算,L2 可以降到顶点。

案例 8.49 个系数到底有多准进阶单独打开 ↗

目标:选一个高对比度的“摄影棚”环境(深灰背景 + 两块强烈的柔光箱 + 一条暖色轮廓灯),用暴力积分算出真实的辐照度,与 SH 近似并排比较。

“标准答案”通过对每个法线方向做余弦加权的半球积分得到:在半球上按余弦分布采样方向,概率密度 cosθ/π 正好与被积函数中的 cosθ/π 抵消,积分就变成了样本的简单平均。我们把结果存进一张小 Cubemap:

Assets/Cookbook/Ch08/EnvironmentTools.cs(节选:irradiance)
/// “标准答案”:对每个法线方向做余弦加权的半球积分,得到 URP 约定下的间接漫反射(E/π)。
/// 用来和 9 个系数的 SH 近似做对比。
public static Cubemap BakeIrradiance(Func<Vector3, Color> radiance, int size = 32, int samples = 1024)
{
    var cube = new Cubemap(size, TextureFormat.RGBAHalf, false) { name = "Ch08Irradiance", hideFlags = HideFlags.DontSave };
    var px = new Color[size * size];
    for (int f = 0; f < 6; f++)
    {
        for (int y = 0; y < size; y++)
            for (int x = 0; x < size; x++)
            {
                Vector3 n = FaceDirection((CubemapFace)f, (x + 0.5f) / size * 2f - 1f, (y + 0.5f) / size * 2f - 1f);
                Vector3 up = Mathf.Abs(n.y) < 0.999f ? Vector3.up : Vector3.right;
                Vector3 tx = Vector3.Normalize(Vector3.Cross(up, n)), ty = Vector3.Cross(n, tx);
                Color sum = Color.black;
                int sq = Mathf.RoundToInt(Mathf.Sqrt(samples));
                for (int i = 0; i < sq; i++)
                    for (int j = 0; j < sq; j++)
                    {
                        // 分层的余弦加权采样:pdf = cosθ/π,正好与积分里的 cosθ/π 抵消,结果就是简单平均
                        float u1 = (i + 0.5f) / sq, u2 = (j + 0.5f) / sq;
                        float r = Mathf.Sqrt(u1), phi = 2f * Mathf.PI * u2;
                        Vector3 l = tx * (r * Mathf.Cos(phi)) + ty * (r * Mathf.Sin(phi)) + n * Mathf.Sqrt(1f - u1);
                        sum += radiance(l);
                    }
                px[y * size + x] = sum / (sq * sq);
            }
        cube.SetPixels(px, (CubemapFace)f);
    }
    cube.Apply(false, false);
    return cube;
}
图 8-6左:环境本身(按法线方向取环境颜色,曝光减半),可以看到锐利的柔光箱;中:对每个方向做 1024 次余弦加权采样得到的真实辐照度;右:同一环境投影成三阶 SH 后用 SampleSH 求值。中、右两图几乎一致。

左图里锐利的柔光箱在辐照度里变成了大面积的柔和渐变,而三阶 SH 准确地复现了这种渐变。SH 近似在什么时候会出问题?当环境里有一个极强的小光源(比如把太阳也算进环境光),截断到三阶会在光源的反方向产生“振铃”(Ringing),表现为背光面出现不该有的暗环甚至负值——SampleSH 最后的 max(0, …) 就是为了兜底。实践中的做法是把太阳交给方向光,环境光里只保留天空的漫射部分。本书拍摄环境的默认天空在投影 SH 时就把太阳尖峰截掉了。

光照探针

环境光 SH 对整个场景只有一组,室内和室外的物体拿到的环境光一样。光照探针(Light Probe)解决的是“空间上的变化”:在场景里摆放一组探针点(Light Probe Group),烘焙时每个点记录该位置的入射光(包括直接光和反弹光),同样压缩成三阶 SH。运行时,Unity 把所有探针点做四面体剖分,按动态物体的位置找到所在的四面体,用重心坐标插值四个探针的 SH,作为这个物体的 unity_SHAr 等变量。

  • 插值是逐物体的(按包围盒中心或 Anchor Override 指定的点),不是逐像素的。一个很大的动态物体跨越明暗交界时,整个物体只能拿到一组 SH。Built-in 管线用 Light Probe Proxy Volume(LPPV)解决这个问题,但 URP 14 不支持 LPPV——GlobalIllumination.hlsl 的 SampleSH 里写得很直白:“LPPV is not supported”。Unity 6 的 URP 引入了 Adaptive Probe Volumes,实现逐像素的探针采样,不在本书版本范围内。
  • 探针要放在光照发生变化的地方:明暗交界、颜色强烈的墙边、楼梯上下;空旷且光照均匀的区域不需要密集摆放。
  • 对 Shader 来说,有没有探针完全透明:都是读 unity_SH*,代码不需要任何改动。

8.3 光照贴图

光照探针是“空间里的稀疏采样”,适合动态物体;对静态的墙、地面、建筑,Unity 可以做得更精细:给每个静态物体分配一块贴图区域,按纹素逐个计算光照,存成光照贴图(Lightmap)。光照贴图能表现探针做不到的东西:墙角的接触阴影、门缝漏进来的一道光、红墙在白地板上映出的一片红。

场景中的静态物体 每个物体有一套 uv1(光照贴图 UV) 打包进图集 光照贴图图集 unity_LightmapST每个物体一组缩放 xy、偏移 zw 采样坐标 = uv1 × ST.xy + ST.zw
图 8-7光照贴图图集:每个静态物体的 uv1 被缩放、平移到图集里属于它的那块区域,参数存在逐物体的 unity_LightmapST 里。

光照贴图需要什么

  • 静态标记与 Contribute GI:物体的 Static 下拉中勾选 Contribute Global Illumination,Mesh Renderer 的 Receive Global Illumination 选 Lightmaps,才会被分配光照贴图。
  • 光照贴图 UV:光照贴图要求 UV 不重叠、各岛之间留有间隙(Padding),这与颜色贴图的 UV(可以重叠、镜像、平铺)要求不同,所以单独用一套 uv1(Shader 里的 TEXCOORD1)。模型导入时勾选 Generate Lightmap UVs 可以自动生成。
  • 编码:光照贴图存的是 HDR 数据。Player Settings 的 Lightmap Encoding 决定用哪种格式:Low Quality 对应 dLDR(双倍 LDR),Normal Quality 对应 RGBM,High Quality 对应 HDR 格式。Shader 里由 UNITY_LIGHTMAP_DLDR_ENCODING、UNITY_LIGHTMAP_RGBM_ENCODING、UNITY_LIGHTMAP_FULL_HDR 这几个平台宏区分,DecodeLightmap 根据它们解码,自写 Shader 不需要关心。
  • 方向性光照贴图:Lighting Settings 的 Directional Mode 选 Directional 时,额外烘焙一张记录主导光方向的贴图(unity_LightmapInd),并启用 DIRLIGHTMAP_COMBINED。采样时用法线与主导方向做一次半 Lambert,再除以一个“重新平衡”系数,让法线贴图在光照贴图区域也能产生明暗。
摘自 core 14 ShaderLibrary/EntityLighting.hlsl(SampleDirectionalLightmap 节选)
real4 direction = SAMPLE_TEXTURE2D_LIGHTMAP(lightmapDirTex, lightmapDirSampler, LIGHTMAP_EXTRA_ARGS_USE);
...
real halfLambert = dot(normalWS, direction.xyz - 0.5) + 0.5;
bakeDiffuseLighting += illuminance * halfLambert / max(1e-4, direction.w);

Mixed Lighting 的三种模式

一盏灯的 Mode 设为 Mixed 时,它既参与烘焙,又在运行时实时照明,两者如何分工由 Lighting Settings 的 Lighting Mode 决定:

模式光照贴图里有什么运行时做什么Shader 关键字
Baked Indirect只有间接光(反弹)直接光与阴影全部实时,阴影只到 Shadow Distance 为止只有 LIGHTMAP_ON
Shadowmask间接光 + 一张 Shadowmask 贴图,记录每盏混合光的静态遮挡(最多 4 盏,分别存 RGBA)直接光实时;Shadow Distance 之外用 Shadowmask 提供远处的静态阴影SHADOWS_SHADOWMASK
Subtractive直接光 + 间接光全部烘进去只为动态物体投射主光实时阴影,在光照贴图上“减”出阴影LIGHTMAP_SHADOW_MIXING(无 Shadowmask 时 GlobalIllumination.hlsl 定义 _MIXED_LIGHTING_SUBTRACTIVE)

Subtractive 模式最便宜也最“假”:动态物体的影子落在已经烘好光照的地面上,地面需要知道“这里本来有多少主光被挡住了”。URP 的做法是估算一个 Lambert 贡献,乘以实时阴影的遮挡量,从光照贴图里减掉,再与用户指定的 Realtime Shadow Color 取较亮者:

摘自 URP 14 ShaderLibrary/GlobalIllumination.hlsl(节选)
half3 SubtractDirectMainLightFromLightmap(Light mainLight, half3 normalWS, half3 bakedGI)
{
    half shadowStrength = GetMainLightShadowStrength();
    half contributionTerm = saturate(dot(mainLight.direction, normalWS));
    half3 lambert = mainLight.color * contributionTerm;
    half3 estimatedLightContributionMaskedByInverseOfShadow = lambert * (1.0 - mainLight.shadowAttenuation);
    half3 subtractedLightmap = bakedGI - estimatedLightContributionMaskedByInverseOfShadow;

    half3 realtimeShadow = max(subtractedLightmap, _SubtractiveShadowColor.xyz);
    realtimeShadow = lerp(bakedGI, realtimeShadow, shadowStrength);
    return min(bakedGI, realtimeShadow);
}

自写 Shader 里调用 MixRealtimeAndBakedGI(mainLight, normalWS, bakedGI) 即可,它只在 LIGHTMAP_ON 且处于 Subtractive 模式时才调用上面的函数,其他情况下什么都不做。

四个宏把一切串起来

“有光照贴图就用光照贴图,没有就用 SH”这个分支,URP 用四个宏在编译期完成,自写 Shader 照抄即可:

摘自 URP 14 ShaderLibrary/Lighting.hlsl 与 GlobalIllumination.hlsl(节选)
#if defined(LIGHTMAP_ON)
    #define DECLARE_LIGHTMAP_OR_SH(lmName, shName, index) float2 lmName : TEXCOORD##index
    #define OUTPUT_LIGHTMAP_UV(lightmapUV, lightmapScaleOffset, OUT) OUT.xy = lightmapUV.xy * lightmapScaleOffset.xy + lightmapScaleOffset.zw;
    #define OUTPUT_SH(normalWS, OUT)
#else
    #define DECLARE_LIGHTMAP_OR_SH(lmName, shName, index) half3 shName : TEXCOORD##index
    #define OUTPUT_LIGHTMAP_UV(lightmapUV, lightmapScaleOffset, OUT)
    #define OUTPUT_SH(normalWS, OUT) OUT.xyz = SampleSHVertex(normalWS)
#endif

#if defined(LIGHTMAP_ON) && defined(DYNAMICLIGHTMAP_ON)
#define SAMPLE_GI(staticLmName, dynamicLmName, shName, normalWSName) SampleLightmap(staticLmName, dynamicLmName, normalWSName)
#elif defined(DYNAMICLIGHTMAP_ON)
#define SAMPLE_GI(staticLmName, dynamicLmName, shName, normalWSName) SampleLightmap(0, dynamicLmName, normalWSName)
#elif defined(LIGHTMAP_ON)
#define SAMPLE_GI(staticLmName, shName, normalWSName) SampleLightmap(staticLmName, 0, normalWSName)
#else
#define SAMPLE_GI(staticLmName, shName, normalWSName) SampleSHPixel(shName, normalWSName)
#endif
  • DECLARE_LIGHTMAP_OR_SH:在 Varyings 里占一个插值器,有光照贴图时是 float2 的光照贴图 UV,否则是 half3 的逐顶点 SH 结果——两者不会同时需要,共用一个槽位。
  • OUTPUT_LIGHTMAP_UV:在顶点里把 uv1 变换到图集坐标。
  • OUTPUT_SH:在顶点里算逐顶点 / 混合模式的 SH 部分(逐像素模式下返回 0)。
  • SAMPLE_GI:在片元里得到 bakedGI。注意它有三参数和四参数两种形式,取决于是否有动态光照贴图(DYNAMICLIGHTMAP_ON,即 Enlighten 实时 GI,使用 uv2)。宏展开后,没用到的参数名会被直接丢弃,所以即使 Varyings 里没有声明 vertexSH,SAMPLE_GI(i.staticLightmapUV, i.vertexSH, n) 也能编译通过。

Shadowmask 的采样同理:SAMPLE_SHADOWMASK(uv) 在有 Shadowmask 时采样 unity_ShadowMask,没有光照贴图时返回光照探针的遮挡 unity_ProbesOcclusion(动态物体通过探针拿到静态阴影信息),否则返回 1。结果传给 GetMainLight(shadowCoord, positionWS, shadowMask),由它在实时阴影和烘焙阴影之间按距离混合。

案例 8.5动手烘焙:用 C# 算一张最简光照贴图进阶单独打开 ↗

目标:光照贴图到底存了什么?与其看 Lighting 窗口里的结果,不如自己算一张。我们写一个只有几十行的“迷你烘焙器”:场景是一块地面加两个球,只计算天空光的遮蔽(一次弹射),结果存成一张线性 HDR 贴图,再用一个简单的 Shader 当作间接漫反射使用。

烘焙器对每个纹素做的事情与 Progressive Lightmapper 的核心完全一样:从纹素位置向上半球发射余弦加权分布的光线,看不到天空(被球挡住)的方向贡献 0,能看到的贡献该方向的天空颜色,最后求平均。由于余弦加权采样的概率密度恰好是 cosθ/π,平均值直接就是 URP 约定下的 bakedGI(辐照度/π),与 SampleSH 的含义相同,可以无缝替换。

Assets/Cookbook/Ch08/ManualLightmapBaker.cs(节选:bake)
/// size:地面边长(米),地面是中心在原点、朝 +Y 的正方形,uv(0,0) 对应 (-size/2, 0, -size/2)
/// spheres:遮挡球(xyz = 球心,w = 半径)
/// 返回值:每个纹素存的是 URP 约定下的间接漫反射 bakedGI(= 辐照度 / π),线性空间
public static Texture2D BakeFloor(int resolution, float size, Vector4[] spheres, Func<Vector3, Color> sky, int samples = 256)
{
    var tex = new Texture2D(resolution, resolution, TextureFormat.RGBAHalf, false, true)
    {
        name = "ManualLightmap", wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear, hideFlags = HideFlags.DontSave
    };
    var px = new Color[resolution * resolution];
    int sq = Mathf.Max(1, Mathf.RoundToInt(Mathf.Sqrt(samples)));
    for (int y = 0; y < resolution; y++)
        for (int x = 0; x < resolution; x++)
        {
            var p = new Vector3(((x + 0.5f) / resolution - 0.5f) * size, 0.001f, ((y + 0.5f) / resolution - 0.5f) * size);
            Color sum = Color.black;
            for (int i = 0; i < sq; i++)
                for (int j = 0; j < sq; j++)
                {
                    // 余弦加权的半球方向(法线 = +Y),pdf 与 cosθ/π 抵消 → 结果是可见天空的简单平均
                    float u1 = (i + 0.5f) / sq, u2 = (j + 0.5f) / sq;
                    float r = Mathf.Sqrt(u1), phi = 2f * Mathf.PI * u2;
                    var d = new Vector3(r * Mathf.Cos(phi), Mathf.Sqrt(1f - u1), r * Mathf.Sin(phi));
                    if (!Occluded(p, d, spheres)) sum += sky(d);
                }
            px[y * resolution + x] = sum / (sq * sq);
        }
    tex.SetPixels(px);
    tex.Apply(false, false);
    return tex;
}

// 光线与球求交:只要有一个球挡住就算被遮挡(不考虑球表面的二次反弹)
static bool Occluded(Vector3 o, Vector3 d, Vector4[] spheres)
{
    foreach (var s in spheres)
    {
        Vector3 oc = o - (Vector3)s;
        float b = Vector3.Dot(oc, d);
        float c = Vector3.Dot(oc, oc) - s.w * s.w;
        float h = b * b - c;
        if (h >= 0 && -b - Mathf.Sqrt(h) > 0) return true;
    }
    return false;
}

Shader 很简单:间接漫反射从“光照贴图”取,直接光仍然实时计算——这正是 Baked Indirect 模式的分工。

Assets/Cookbook/Ch08/ManualLightmap.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half3 N = normalize(i.normalWS);
    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv.xy).rgb * _BaseColor.rgb;
    half3 baked = SAMPLE_TEXTURE2D(_BakedGI, sampler_BakedGI, i.uv.zw).rgb;
    if (_Mode > 1.5) return half4(baked, 1);          // 直接看光照贴图里存了什么

    // 间接漫反射:光照贴图(含天空遮蔽) 或 球谐(处处一样,没有遮蔽)
    half3 indirect = _Mode > 0.5 ? baked : SampleSH(N);
    // 直接光:实时主光 + 实时阴影
    Light mainLight = GetMainLight(TransformWorldToShadowCoord(i.positionWS));
    half3 direct = mainLight.color * saturate(dot(N, mainLight.direction)) * mainLight.shadowAttenuation;
    return half4(albedo * (indirect + direct), 1);
}
void main()
{
    vec3 N = normalize(normalWS);
    vec3 albedo = texture(_BaseMap, uv.xy).rgb * _BaseColor.rgb;
    vec3 baked = texture(_BakedGI, uv.zw).rgb;
    if (_Mode > 1.5) { fragColor = vec4(baked, 1.0); return; }   // 直接看光照贴图里存了什么

    // 间接漫反射:光照贴图(含天空遮蔽) 或 球谐(处处一样,没有遮蔽)
    vec3 indirect = _Mode > 0.5 ? baked : SampleSH(N);
    // 直接光:实时主光 + 实时阴影
    Light mainLight = GetMainLight(TransformWorldToShadowCoord(positionWS));
    vec3 direct = mainLight.color * saturate(dot(N, mainLight.direction)) * mainLight.shadowAttenuation;
    fragColor = vec4(albedo * (indirect + direct), 1.0);
}
图 8-8三格使用同一片天空。左:地面的间接光来自 SH(处处相同,只有实时阴影);中:直接显示烘焙结果,球体周围出现柔和的天空遮蔽,越靠近接触点越暗;右:烘焙的间接光 + 实时主光,球的底部与地面接触处多了一圈自然的“压暗”,物体“坐”在了地面上。

对比左右两图,差别集中在球与地面接触的区域。左图里球像是浮在地面上,这是只有 SH 环境光的场景最常见的观感问题;右图多出的那圈暗部,就是光照贴图的价值。真实的烘焙器还要做很多事:多次弹射(地面的光再反弹到球的底部)、从 Meta Pass 读取每个表面的反照率决定反弹光的颜色、降噪、处理 UV 接缝处的扩边(Dilation),以及按平台编码。它们是工程上的复杂度,原理就是这几十行代码。

注意这个案例是教学演示:我们没有通过 LightmapSettings 把贴图交给引擎,而是用一个自定义贴图属性直接采样。真实项目里请使用 Unity 的烘焙器;如果需要在运行时切换整套光照贴图(例如白天/夜晚两套烘焙结果),可以研究 LightmapSettings.lightmaps 与 Renderer.lightmapIndex、lightmapScaleOffset,此时贴图必须符合项目的光照贴图编码。

8.4 Meta Pass:告诉烘焙器表面长什么样

光照贴图烘焙器需要知道每个表面的两件事:它把光反弹成什么颜色(反照率)和它自己发不发光(自发光)。Shader 里写的光照逻辑烘焙器看不懂,于是 Unity 约定了一个专门的 Pass:"LightMode" = "Meta"。烘焙时,引擎用这个 Pass 把物体“摊平”到光照贴图 UV 空间渲染两次,一次要反照率、一次要自发光。没有 Meta Pass 的 Shader,物体在烘焙器眼里是一片默认的灰色,也不会把自发光传播到周围。

Assets/Cookbook/Ch08/GILit.shader(节选:meta)
// Meta Pass:只在烘焙光照贴图时由 Unity 调用,把物体“摊平”到光照贴图 UV 空间,
// 输出 Albedo(决定反弹光的颜色)和 Emission(自发光作为光源参与烘焙)。
Pass
{
    Name "Meta"
    Tags { "LightMode"="Meta" }
    Cull Off
    HLSLPROGRAM
    #pragma vertex MetaVert
    #pragma fragment MetaFrag
    #pragma shader_feature_local_fragment _EMISSION
    #pragma shader_feature EDITOR_VISUALIZATION
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/MetaInput.hlsl"

    TEXTURE2D(_BaseMap);     SAMPLER(sampler_BaseMap);
    TEXTURE2D(_EmissionMap); SAMPLER(sampler_EmissionMap);

    struct MetaAttributes
    {
        float4 positionOS : POSITION;
        float3 normalOS   : NORMAL;
        float2 uv0        : TEXCOORD0;
        float2 uv1        : TEXCOORD1;
        float2 uv2        : TEXCOORD2;
    };

    struct MetaVaryings
    {
        float4 positionCS : SV_POSITION;
        float2 uv         : TEXCOORD0;
    #ifdef EDITOR_VISUALIZATION
        float2 VizUV      : TEXCOORD1;
        float4 LightCoord : TEXCOORD2;
    #endif
    };

    MetaVaryings MetaVert(MetaAttributes input)
    {
        MetaVaryings o = (MetaVaryings)0;
        // unity_MetaVertexControl.x/y 决定用 uv1(静态光照贴图)还是 uv2(动态光照贴图)当作光栅化位置
        o.positionCS = UnityMetaVertexPosition(input.positionOS.xyz, input.uv1, input.uv2);
        o.uv = TRANSFORM_TEX(input.uv0, _BaseMap);
    #ifdef EDITOR_VISUALIZATION
        UnityEditorVizData(input.positionOS.xyz, input.uv0, input.uv1, input.uv2, o.VizUV, o.LightCoord);
    #endif
        return o;
    }

    half4 MetaFrag(MetaVaryings i) : SV_Target
    {
        half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv).rgb * _BaseColor.rgb;
        half alpha = 1;
        BRDFData brdf;
        InitializeBRDFData(albedo, _Metallic, half3(0, 0, 0), _Smoothness, alpha, brdf);

        MetaInput meta = (MetaInput)0;
        // 与 URP Lit 相同:漫反射色 + 粗糙金属的一部分高光色,近似“这块表面把光反弹成什么颜色”
        meta.Albedo = brdf.diffuse + brdf.specular * brdf.roughness * 0.5;
    #if defined(_EMISSION)
        meta.Emission = SAMPLE_TEXTURE2D(_EmissionMap, sampler_EmissionMap, i.uv).rgb * _EmissionColor.rgb;
    #endif
    #ifdef EDITOR_VISUALIZATION
        meta.VizUV = i.VizUV;
        meta.LightCoord = i.LightCoord;
    #endif
        // unity_MetaFragmentControl.x 要 Albedo,.y 要 Emission,由烘焙器分两次调用
        return UnityMetaFragment(meta);
    }
    ENDHLSL
}
// 一个“全局光照就绪”的自写 Shader:球谐 / 光照贴图 / 动态光照贴图 / Shadowmask / 反射探针(含混合与盒体投影)/
// SSAO / 材质 AO / 自发光,并提供 Meta Pass 参与烘焙。直接光沿用 URP 的 BRDF,便于与 URP Lit 对照。
// (GLSL 版,对应 Ch08/GILit.shader)
// 网页版没有光照烘焙:光照贴图、动态光照贴图、Shadowmask、Subtractive 混合这几条分支在浏览器里永远不会打开,
// 所以下面只保留“无光照贴图”的路径(SAMPLE_GI 退化为逐像素 SampleSH),Meta Pass 也不移植。
Shader "Cookbook/Ch08/GILit"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4  _BaseMap_ST;
        uniform vec4  _BaseColor;
        uniform vec4  _EmissionColor;
        uniform float _Metallic;
        uniform float _Smoothness;
        uniform float _BumpScale;
        uniform float _OcclusionStrength;
        uniform float _DebugView;
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            GLSLPROGRAM
            // 材质开关
            #pragma shader_feature_local _NORMALMAP
            #pragma shader_feature_local_fragment _OCCLUSIONMAP
            #pragma shader_feature_local_fragment _EMISSION
            #pragma shader_feature_local_fragment _ENVIRONMENTREFLECTIONS_OFF
            // URP 管线关键字(主光阴影、软阴影、SSAO、雾)在网页版由引擎统一处理,不用声明;
            // LIGHTMAP_ON / DYNAMICLIGHTMAP_ON / SHADOWS_SHADOWMASK 等烘焙关键字网页版没有

            uniform sampler2D _BaseMap;
            uniform sampler2D _BumpMap;
            uniform sampler2D _OcclusionMap;
            uniform sampler2D _EmissionMap;

            VARYING vec2 uv;
            VARYING vec3 positionWS;
            VARYING vec3 normalWS;
            VARYING vec4 tangentWS;    // 只在 _NORMALMAP 时有意义
            // HLSL 版:有光照贴图时声明 staticLightmapUV,否则声明 vertexSH(逐顶点 SH)。网页版走逐像素 SH,两者都不需要
            VARYING float fogFactor;

            #ifdef VERTEX
            void main()
            {
                VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz);
                VertexNormalInputs nrm = GetVertexNormalInputs(normalOS, tangentOS);
                gl_Position = pos.positionCS;
                positionWS = pos.positionWS;
                uv = TransformTex(texcoord0.xy, _BaseMap_ST);
                normalWS = nrm.normalWS;
                tangentWS = vec4(nrm.tangentWS, tangentOS.w * GetOddNegativeScale());
                fogFactor = ComputeFogFactor(pos.positionCS.z);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                // ---------- 表面属性 ----------
                vec4 baseSample = texture(_BaseMap, uv) * _BaseColor;
                vec3 albedo = baseSample.rgb;
                float alpha = 1.0;
            #if defined(_OCCLUSIONMAP)
                float occlusion = LerpWhiteTo(texture(_OcclusionMap, uv).g, _OcclusionStrength);
            #else
                float occlusion = 1.0;
            #endif
            #if defined(_EMISSION)
                vec3 emission = texture(_EmissionMap, uv).rgb * _EmissionColor.rgb;
            #else
                vec3 emission = vec3(0.0);
            #endif

            #if defined(_NORMALMAP)
                vec3 normalTS = UnpackNormalScale(texture(_BumpMap, uv), _BumpScale);
                vec3 bitangent = tangentWS.w * cross(normalWS, tangentWS.xyz);
                vec3 N = TransformTangentToWorld(normalTS, mat3(tangentWS.xyz, bitangent, normalWS));
            #else
                vec3 N = normalWS;
            #endif
                N = NormalizeNormalPerPixel(N);
                vec3 viewDirWS = GetWorldSpaceNormalizeViewDir(positionWS);
                vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);

                BRDFData brdf;
                InitializeBRDFData(albedo, _Metallic, vec3(0.0), _Smoothness, alpha, brdf);

                // ---------- ① 间接漫反射:光照贴图 或 球谐 ----------
                // 网页版没有光照贴图,SAMPLE_GI 就是逐像素 SampleSH
                vec3 bakedGI = SampleSH(N);

                // ---------- ② 主光 + 阴影(含 Shadowmask / 光照探针遮挡) ----------
                vec4 shadowMask = vec4(1.0);     // SAMPLE_SHADOWMASK:没有 Shadowmask 时是 1
                vec4 shadowCoord = TransformWorldToShadowCoord(positionWS);
                Light mainLight = GetMainLight(shadowCoord, positionWS, shadowMask);

                // ---------- ③ AO:材质 AO 与 SSAO 取较小值,只压间接光;SSAO 还按 Direct Lighting Strength 压一点直接光 ----------
                AmbientOcclusionFactor aoFactor = GetScreenSpaceAmbientOcclusion(screenUV);
                aoFactor.indirectAmbientOcclusion = min(aoFactor.indirectAmbientOcclusion, occlusion);
                // 没开 SSAO 时 directAmbientOcclusion 恒为 1(HLSL 版用 _SCREEN_SPACE_OCCLUSION 关键字包住这一行)
                mainLight.color *= aoFactor.directAmbientOcclusion;

                // MixRealtimeAndBakedGI:Subtractive 模式下从光照贴图里“减掉”实时阴影区域的主光——没有光照贴图时什么都不做

                // ---------- ④ 间接高光:反射探针(网页版只有全局环境反射 unity_SpecCube0) ----------
                vec3 reflectVector = reflect(-viewDirWS, N);
                vec3 indirectSpecular = GlossyEnvironmentReflection(reflectVector, positionWS, brdf.perceptualRoughness, 1.0);

                // ---------- ⑤ 组合:与 URP 的 GlobalIllumination() 相同 ----------
                float NoV = saturate(dot(N, viewDirWS));
                float fresnelTerm = Pow4(1.0 - NoV);
                vec3 giDiffuse  = bakedGI * brdf.diffuse;
                vec3 giSpecular = indirectSpecular * EnvironmentBRDFSpecular(brdf, fresnelTerm);
                vec3 gi = (giDiffuse + giSpecular) * aoFactor.indirectAmbientOcclusion;

                vec3 direct = LightingPhysicallyBased(brdf, mainLight, N, viewDirWS);

                vec3 color = gi + direct + emission;
                // 调试视图:分别看各个分量(书中配图用)
                if (_DebugView > 0.5 && _DebugView < 1.5) color = giDiffuse * aoFactor.indirectAmbientOcclusion;
                else if (_DebugView > 1.5 && _DebugView < 2.5) color = giSpecular * aoFactor.indirectAmbientOcclusion;
                else if (_DebugView > 2.5 && _DebugView < 3.5) color = direct;
                else if (_DebugView > 3.5) color = gi + direct * occlusion + emission;   // 反例:AO 也乘到直接光上

                color = MixFog(color, fogFactor);
                fragColor = vec4(color, 1.0);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            GLSLPROGRAM
            #include "Ch08/Ch08Passes.glsl"
            #ifdef VERTEX
            void main() { gl_Position = Ch08ShadowVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = Ch08NullFrag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #include "Ch08/Ch08Passes.glsl"
            #ifdef VERTEX
            void main() { gl_Position = Ch08DepthVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = Ch08NullFrag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthNormals"
            Tags { "LightMode"="DepthNormals" }
            ZWrite On
            GLSLPROGRAM
            #define CH08_DEPTH_NORMALS
            #include "Ch08/Ch08Passes.glsl"
            #ifdef VERTEX
            void main() { gl_Position = Ch08DepthNormalsVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = Ch08DepthNormalsFrag(); }
            #endif
            ENDGLSL
        }

        // DepthNormalsOnly(Deferred 专用)与 Meta(烘焙光照贴图专用)两个 Pass 网页版用不到,没有移植
    }
}

逐行解释:

  • 顶点:UnityMetaVertexPosition 根据 unity_MetaVertexControl 决定用 uv1(静态光照贴图)还是 uv2(动态光照贴图)作为光栅化位置——把 UV 当成屏幕坐标输出,物体就被“展开”成了它在光照贴图里的样子。
  • 反照率:brdf.diffuse + brdf.specular * brdf.roughness * 0.5,与 URP Lit 的 UniversalFragmentMetaLit 相同。纯漫反射只取 diffuse 部分;粗糙的金属虽然 diffuse 为 0,但它的镜面反射在粗糙时近似漫反射,所以按粗糙度加回一部分高光颜色,避免“粗糙金属不反弹任何光”。
  • 自发光:直接输出 HDR 的自发光颜色。UnityMetaFragment 根据 unity_MetaFragmentControl 返回其中之一,并对反照率应用 Lighting Settings 里的 Albedo Boost。
  • EDITOR_VISUALIZATION:Scene 视图的部分 GI 可视化模式会用到,照抄 URP 的写法即可。

自发光要参与烘焙,除了 Meta Pass 输出 Emission,还要把材质的 globalIlluminationFlags 设为 MaterialGlobalIlluminationFlags.BakedEmissive(URP Lit 的材质面板上对应 Emission 下的 Global Illumination 选项)。默认值可能是 EmissiveIsBlack,此时烘焙器会直接跳过该材质的自发光。自写 Shader 如果有自定义材质面板,记得暴露这个选项;或者在脚本里设置:

material.EnableKeyword("_EMISSION");
material.globalIlluminationFlags = MaterialGlobalIlluminationFlags.BakedEmissive;
提示验证 Meta Pass 是否正确:烘焙后打开 Lighting 窗口 Baked Lightmaps 页签,或者在 Scene 视图的绘制模式里选择 Baked Global Illumination 下的 Albedo / Emissive,看看物体在烘焙器眼里的颜色对不对。常见错误是 Meta Pass 读了带光照的颜色、忘记乘 _BaseColor,或者 Alpha Clip 材质没有在 Meta Pass 里 clip,导致镂空部分也参与了遮挡。

8.5 环境反射与反射探针

预过滤:把“积分”提前做掉

间接高光要计算的是 ∫ΩL(ω)fspec(ω,v)cosθdω。实时渲染的标准近似(Epic 的 Split Sum,第 11 章会完整推导)把它拆成两部分的乘积:一部分是“环境光在镜面波瓣范围内的加权平均”,只依赖反射方向和粗糙度,可以预先算好存进 Cubemap 的 mip 链——粗糙度越高,波瓣越宽,对应越模糊的 mip;另一部分是 BRDF 本身对所有方向的积分,只依赖粗糙度、视角和 F0,URP 用一个解析近似 EnvironmentBRDFSpecular 代替查找表。

于是 Shader 里的间接高光只剩下一件事:沿反射方向,按粗糙度选 mip 采样 Cubemap。粗糙度到 mip 的映射在 core 库里:

摘自 core 14 ShaderLibrary/ImageBasedLighting.hlsl
#ifndef UNITY_SPECCUBE_LOD_STEPS
    // This is actuall the last mip index, we generate 7 mips of convolution
    #define UNITY_SPECCUBE_LOD_STEPS 6
#endif

real PerceptualRoughnessToMipmapLevel(real perceptualRoughness, uint maxMipLevel)
{
    perceptualRoughness = perceptualRoughness * (1.7 - 0.7 * perceptualRoughness);
    return perceptualRoughness * maxMipLevel;
}
0246 00.51 mip感知粗糙度 线性:r × 6 URP:r(1.7 − 0.7r) × 6 r = 0.5 → 4.05
图 8-9感知粗糙度到 mip 的映射。URP 的曲线在中低粗糙度段上升得比线性更快:r = 0.5 时已经到 mip 4,而不是 3。

为什么不用线性映射?预过滤 Cubemap 的 mip 链是按“每级分辨率减半”生成的,而 GGX 波瓣的角度宽度与粗糙度并不是线性关系。这条二次曲线是对“哪一级 mip 的纹素大小刚好覆盖波瓣”的经验拟合(同一文件里还有一个按 N·R 计算的“精确版”,URP 默认不用)。实际效果是中等粗糙度的材质看起来更“糊”一些,与真实的 GGX 积分更接近。

GlossyEnvironmentReflection 的三条路径

URP 的间接高光入口是 GlossyEnvironmentReflection,它在 GlobalIllumination.hlsl 里有三个重载,内部根据关键字走不同的路径:

  1. Forward / Deferred,无探针混合:只采样 unity_SpecCube0,即引擎为这个物体挑选的“最重要”的反射探针;开启 _REFLECTION_PROBE_BOX_PROJECTION 时先做盒体投影。
  2. 开启探针混合(_REFLECTION_PROBE_BLENDING):同时采样 unity_SpecCube0 和 unity_SpecCube1,按物体在两个探针盒子里的位置和 Importance 计算权重;两个权重之和不足 1 的部分,用全局环境反射 _GlossyEnvironmentCubeMap 补齐。
  3. Forward+(_FORWARD_PLUS):所有可见探针被 ReflectionProbeManager 转成八面体映射,打包进一张 2D 图集 urp_ReflProbes_Atlas,每个探针的 7 级 mip 在图集里各有一块区域。像素着色器用 Forward+ 的聚类结构找到覆盖自己的探针,逐个累加权重,最多可以混合多个探针(不再局限于两个)。

Forward+ 这条路径对自写 Shader 有一个容易踩的坑。URP 在 Forward+ 下不再为每个物体设置反射探针(源码 GetPerObjectLightFlags 里,只有非 Forward+ 才添加 PerObjectData.ReflectionProbes),所以如果你的 Shader 没有声明 #pragma multi_compile _ _FORWARD_PLUS,在 Forward+ 渲染器下它会走第 1 条路径,读到的 unity_SpecCube0 并不是你期望的探针。正确的做法是像 Lit 一样声明这个关键字,并使用带屏幕 UV 参数的重载:

float2 screenUV = GetNormalizedScreenSpaceUV(input.positionCS);
half3 indirectSpecular = GlossyEnvironmentReflection(reflectVector, positionWS,
                                                     perceptualRoughness, 1.0h, screenUV);

这个 5 参数版本在 Forward 和 Forward+ 下都存在;而不带 screenUV 的 4 参数版本被 #if !USE_FORWARD_PLUS 包着,Forward+ 变体里根本不存在,用了就会编译失败。最后,材质勾掉 Environment Reflections(关键字 _ENVIRONMENTREFLECTIONS_OFF)时,函数返回常数 _GlossyEnvironmentColor,这是给低端设备省一次 Cubemap 采样的选项。

采样回来的值还要经过 DecodeHDREnvironment:Cubemap 可能以 RGBM 或 dLDR 编码存储,unity_SpecCube0_HDR(或 _GlossyEnvironmentCubeMap_HDR)携带解码参数和强度倍数(Lighting 窗口里 Environment Reflections 的 Intensity Multiplier 就在这里生效)。浮点格式的 Cubemap 解码只是乘一个强度。

案例 8.6粗糙度与 mip:两种映射的对比入门单独打开 ↗

目标:写一个只输出间接高光的 Shader,分别用 URP 的曲线和线性映射对同一个环境做粗糙度扫描。

Assets/Cookbook/Ch08/EnvReflection.shader(节选:manual)
// 手动版:全局环境反射(Lighting 窗口里的 Environment Reflections)由 URP 设置为
// _GlossyEnvironmentCubeMap,它的 mip 链是按粗糙度预过滤过的
half3 SampleEnvManual(half3 R, half perceptualRoughness, bool urpCurve)
{
    half mip = urpCurve
        ? PerceptualRoughnessToMipmapLevel(perceptualRoughness)   // r·(1.7-0.7r)·6
        : perceptualRoughness * UNITY_SPECCUBE_LOD_STEPS;          // 线性:r·6
    half4 encoded = SAMPLE_TEXTURECUBE_LOD(_GlossyEnvironmentCubeMap, sampler_GlossyEnvironmentCubeMap, R, mip);
    // HDR 解码:RGBM/dLDR 编码的 cubemap 需要 _HDR 参数还原,浮点格式时它只是乘一个强度
    return DecodeHDREnvironment(encoded, _GlossyEnvironmentCubeMap_HDR);
}
// 手动版:全局环境反射(Lighting 窗口里的 Environment Reflections)由 URP 设置为
// _GlossyEnvironmentCubeMap,它的 mip 链是按粗糙度预过滤过的
vec3 SampleEnvManual(vec3 R, float perceptualRoughness, bool urpCurve)
{
    float mip = urpCurve
        ? PerceptualRoughnessToMipmapLevel(perceptualRoughness)   // r·(1.7-0.7r)·6
        : perceptualRoughness * UNITY_SPECCUBE_LOD_STEPS;          // 线性:r·6
    vec4 encoded = textureLod(_GlossyEnvironmentCubeMap, R, mip);
    // HDR 解码:RGBM/dLDR 编码的 cubemap 需要 _HDR 参数还原,浮点格式时它只是乘一个强度
    return DecodeHDREnvironment(encoded, _GlossyEnvironmentCubeMap_HDR);
}
图 8-10感知粗糙度从左到右为 0、0.2、0.4、0.6、0.8、1.0,环境为“摄影棚”Cubemap。上排:URP 的映射 PerceptualRoughnessToMipmapLevel;下排:线性映射 r × 6。两排在两端相同,中间几列上排明显更模糊。

如果你在自写 PBR 里随手用了线性映射,同一个粗糙度值的材质会比 URP Lit 显得更光滑——这是自写 Shader 与官方 Shader “对不上”的常见原因之一。与 URP 保持一致,就用它提供的函数。

案例 8.7自定义环境反射 Cubemap进阶单独打开 ↗

目标:不用天空盒,而是用自己生成的 Cubemap 作为环境反射(例如产品展示用的“摄影棚”环境、黄昏的天空),并让环境光、背景与反射保持一致。

在编辑器里的做法:导入一张 Cubemap(HDR 全景图在导入设置里选 Texture Shape = Cube),把 Convolution Type 设为 Specular (Glossy Reflection),Unity 会为它生成按粗糙度预过滤的 mip 链;然后在 Lighting 窗口把 Environment Reflections 的 Source 改为 Custom 并指定它。在代码里,对应的是 RenderSettings.customReflectionTexture。

我们在 C# 里从一个环境函数直接生成 Cubemap,并自己完成预过滤:每一级 mip 先由 URP 的曲线反解出对应的粗糙度,再对每个纹素方向按 GGX 分布做重要性采样、按 N·L 加权平均(这正是 Split Sum 第一项的标准做法,假设 N = V = R):

Assets/Cookbook/Ch08/EnvironmentTools.cs(节选:cubemap)
/// 生成 HDR Cubemap,并按 URP 的“粗糙度 → mip”曲线对每一级 mip 做 GGX 预过滤(split-sum 的第一项)。
public static Cubemap BakeCubemap(Func<Vector3, Color> radiance, int size = 128, int samplesPerTexel = 64)
{
    var cube = new Cubemap(size, TextureFormat.RGBAHalf, true) { name = "Ch08Env", hideFlags = HideFlags.DontSave };
    int mipCount = cube.mipmapCount;
    for (int mip = 0; mip < mipCount; mip++)
    {
        int s = Mathf.Max(1, size >> mip);
        // URP: mip = r·(1.7 - 0.7r)·6,反解出这一级对应的感知粗糙度 r
        float t = Mathf.Min(mip / 6f, 1f);
        float perceptual = (1.7f - Mathf.Sqrt(2.89f - 2.8f * t)) / 1.4f;
        float alpha = Mathf.Max(perceptual * perceptual, 1e-4f);
        var px = new Color[s * s];
        for (int f = 0; f < 6; f++)
        {
            for (int y = 0; y < s; y++)
                for (int x = 0; x < s; x++)
                {
                    Vector3 n = FaceDirection((CubemapFace)f, (x + 0.5f) / s * 2f - 1f, (y + 0.5f) / s * 2f - 1f);
                    px[y * s + x] = mip == 0 ? radiance(n) : PrefilterGGX(radiance, n, alpha, samplesPerTexel);
                }
            cube.SetPixels(px, (CubemapFace)f, mip);
        }
    }
    cube.Apply(false, false);
    return cube;
}

// 预过滤假设 N = V = R:以 N 为中心按 GGX 分布重要性采样半程向量 H,反射出 L,按 N·L 加权平均
static Color PrefilterGGX(Func<Vector3, Color> radiance, Vector3 n, float alpha, int count)
{
    Vector3 up = Mathf.Abs(n.y) < 0.999f ? Vector3.up : Vector3.right;
    Vector3 tx = Vector3.Normalize(Vector3.Cross(up, n));
    Vector3 ty = Vector3.Cross(n, tx);
    Color sum = Color.black; float wsum = 0;
    for (int i = 0; i < count; i++)
    {
        // Hammersley 低差异序列
        float u1 = (i + 0.5f) / count;
        uint bits = (uint)i;
        bits = (bits << 16) | (bits >> 16);
        bits = ((bits & 0x55555555u) << 1) | ((bits & 0xAAAAAAAAu) >> 1);
        bits = ((bits & 0x33333333u) << 2) | ((bits & 0xCCCCCCCCu) >> 2);
        bits = ((bits & 0x0F0F0F0Fu) << 4) | ((bits & 0xF0F0F0F0u) >> 4);
        bits = ((bits & 0x00FF00FFu) << 8) | ((bits & 0xFF00FF00u) >> 8);
        float u2 = bits * 2.3283064365386963e-10f;
        // GGX 的 cosθh 采样公式
        float phi = 2f * Mathf.PI * u2;
        float cosT = Mathf.Sqrt((1f - u1) / (1f + (alpha * alpha - 1f) * u1));
        float sinT = Mathf.Sqrt(1f - cosT * cosT);
        Vector3 h = tx * (sinT * Mathf.Cos(phi)) + ty * (sinT * Mathf.Sin(phi)) + n * cosT;
        Vector3 l = 2f * Vector3.Dot(n, h) * h - n;
        float ndl = Vector3.Dot(n, l);
        if (ndl <= 0) continue;
        sum += radiance(l) * ndl; wsum += ndl;
    }
    return wsum > 0 ? sum / wsum : Color.black;
}

/// Cubemap 第 face 面上 (u,v)∈[-1,1] 的像素对应的方向(与 Unity 的面朝向约定一致)
public static Vector3 FaceDirection(CubemapFace face, float u, float v)
{
    Vector3 d;
    switch (face)
    {
        case CubemapFace.PositiveX: d = new Vector3(1, -v, -u); break;
        case CubemapFace.NegativeX: d = new Vector3(-1, -v, u); break;
        case CubemapFace.PositiveY: d = new Vector3(u, 1, v); break;
        case CubemapFace.NegativeY: d = new Vector3(u, -1, -v); break;
        case CubemapFace.PositiveZ: d = new Vector3(u, -v, 1); break;
        default: d = new Vector3(-u, -v, -1); break;
    }
    return d.normalized;
}

注意 Cubemap.Apply(false, …) 的第一个参数是 false:如果让 Unity 自动生成 mip,它只会做简单的盒式降采样,各级 mip 的模糊程度与 GGX 波瓣不匹配,粗糙材质会显得“太亮太锐”。拍摄代码里把同一个环境函数同时用于三处:背景天空盒、RenderSettings.ambientProbe(案例 8.1 的投影)、RenderSettings.customReflectionTexture。

图 8-11同一组材质(镜面金属纽结、粗糙金色金属球、光滑红色塑料球)放在三种环境中。左:拍摄环境默认的程序化天空;中:自制摄影棚,深色背景里两块柔光箱在纽结上留下清晰的长条高光;右:黄昏天空,所有反射都染上了暖色。

金属对环境的依赖远大于非金属:中图里镜面金属纽结几乎是全黑的,只有柔光箱的倒影——它没有漫反射,“周围有什么”就是它的全部外观。这也是为什么做产品展示、角色展示时,环境贴图的选择和直接光同等重要。中图金色球上略显斑驳的反射来自运行时预过滤的采样数有限(每纹素 160 个样本),正式资源应当使用离线工具或 Unity 导入器生成的高质量预过滤结果。

探针混合与盒体投影

反射探针(Reflection Probe)就是摆在场景里的一个“球形摄像机”,在它的位置拍一张 Cubemap。物体使用哪个探针,由物体与探针的影响盒(Box Size)的相交关系决定。两个探针重叠时,URP 的混合权重来自 CalculateProbeWeight:物体位置到盒子各个面的距离,除以 Blend Distance,取最小值——越靠近盒子中心权重越接近 1,越靠近边缘越接近 0。探针的 Importance 决定谁“优先”:重要性高的先拿权重,剩下的才给另一个;重要性相同时,体积较小的那个优先(更小的探针通常更“局部”,更准确)。

摘自 URP 14 ShaderLibrary/GlobalIllumination.hlsl
float CalculateProbeWeight(float3 positionWS, float4 probeBoxMin, float4 probeBoxMax)
{
    float blendDistance = probeBoxMax.w;
    float3 weightDir = min(positionWS - probeBoxMin.xyz, probeBoxMax.xyz - positionWS) / blendDistance;
    return saturate(min(weightDir.x, min(weightDir.y, weightDir.z)));
}

探针还有一个更本质的问题:Cubemap 是在探针中心拍的,它默认环境“无限远”。对天空来说没问题,但对室内的墙壁就错了——站在房间角落看地板上墙的倒影,与站在房间中心看到的倒影位置不同。盒体投影(Box Projection)用一个简单的几何修正解决它:假设环境是探针影响盒的六个面,从着色点沿反射方向射出,求它打到盒子的哪一点 H,然后用“从探针中心看向 H”的方向去采样 Cubemap。

探针影响盒(假设环境就在盒子的六个面上) 探针中心 C 着色点 P(地板) 反射方向 R H:R 真正打到的点 不投影:从 C 沿 R 看→ 看到错误的位置 盒体投影:采样方向 = H − C
图 8-12盒体投影的几何意义(侧视图)。不投影时,Cubemap 按“从探针中心沿 R 看”取值,看到的是 C 正上方偏右的天花板;盒体投影先求出 R 与盒子的交点 H,再用 H − C 作为采样方向。
摘自 URP 14 ShaderLibrary/GlobalIllumination.hlsl
half3 BoxProjectedCubemapDirection(half3 reflectionWS, float3 positionWS, float4 cubemapPositionWS, float4 boxMin, float4 boxMax)
{
    // Is this probe using box projection?
    if (cubemapPositionWS.w > 0.0f)
    {
        float3 boxMinMax = (reflectionWS > 0.0f) ? boxMax.xyz : boxMin.xyz;
        half3 rbMinMax = half3(boxMinMax - positionWS) / reflectionWS;
        half fa = half(min(min(rbMinMax.x, rbMinMax.y), rbMinMax.z));
        half3 worldPos = half3(positionWS - cubemapPositionWS.xyz);
        half3 result = worldPos + reflectionWS * fa;
        return result;
    }
    else
    {
        return reflectionWS;
    }
}

这是经典的“射线与轴对齐盒求交”:沿 R 的每个分量,看它朝正方向就和 boxMax 求交、朝负方向就和 boxMin 求交,得到三个距离,取最小的那个(最先碰到的面)。worldPos + R × fa 是交点相对探针中心的向量,即新的采样方向。cubemapPositionWS.w 的符号表示该探针是否勾选了 Box Projection;而 Shader 里是否编译这段代码,由 URP Asset 的 Lighting › Reflection Probes › Box Projection 控制的 _REFLECTION_PROBE_BOX_PROJECTION 关键字决定——两处都要打开。

案例 8.8盒体投影:让地板的倒影“贴”住墙进阶单独打开 ↗

目标:搭一个 6 × 3 × 6 米的房间,墙上画网格和一条白色腰线,地板是半光滑的反射面,对比开关盒体投影的效果。

为了让对比严格可控,本案例没有用场景里的 ReflectionProbe 组件,而是把“探针”的三个参数(Cubemap、中心位置、盒子范围)直接放在材质上,Shader 里调用的仍然是上面那个 URP 函数 BoxProjectedCubemapDirection。房间中心的 Cubemap 用解析方式生成:从中心沿每个方向求与房间盒子的交点,按交点处的墙面图案着色——墙面 Shader 和 Cubemap 生成器用的是逐行对应的同一个图案函数(RoomPattern.hlsl 与 BoxProjectionDemo.cs),所以 Cubemap 里的墙与场景中的墙严格一致,倒影的任何错位都只能来自投影方式本身。

Assets/Cookbook/Ch08/BoxProjection.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    half3 N = normalize(i.normalWS);
    half3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    half3 R = reflect(-V, N);
#if defined(_BOX_PROJECTION)
    // 第 4 个分量 w > 0 表示“这个探针启用了盒体投影”,与 URP 的 unity_SpecCube0_ProbePosition 约定一致
    R = BoxProjectedCubemapDirection(R, i.positionWS, float4(_ProbePosition.xyz, 1), _BoxMin, _BoxMax);
#endif
    half mip = PerceptualRoughnessToMipmapLevel(_Roughness);
    half3 refl = SAMPLE_TEXTURECUBE_LOD(_Cube, sampler_Cube, R, mip).rgb;

    // 简化的菲涅尔:掠射角反射更强
    half NoV = saturate(dot(N, V));
    half F = _Reflectivity + (1 - _Reflectivity) * Pow4(1 - NoV) * 0.5;
    half3 diffuse = _BaseColor.rgb * SampleSH(N);
    return half4(lerp(diffuse, refl, F), 1);
}
void main()
{
    vec3 N = normalize(normalWS);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    vec3 R = reflect(-V, N);
#if defined(_BOX_PROJECTION)
    // 第 4 个分量 w > 0 表示“这个探针启用了盒体投影”,与 URP 的 unity_SpecCube0_ProbePosition 约定一致
    R = BoxProjectedCubemapDirection(R, positionWS, vec4(_ProbePosition.xyz, 1.0), _BoxMin, _BoxMax);
#endif
    float mip = PerceptualRoughnessToMipmapLevel(_Roughness);
    vec3 refl = textureLod(_Cube, R, mip).rgb;

    // 简化的菲涅尔:掠射角反射更强
    float NoV = saturate(dot(N, V));
    float F = _Reflectivity + (1.0 - _Reflectivity) * Pow4(1.0 - NoV) * 0.5;
    vec3 diffuse = _BaseColor.rgb * SampleSH(N);
    fragColor = vec4(mix(diffuse, refl, F), 1.0);
}
图 8-13左:不做盒体投影,地板上的倒影是“无限远”的环境,网格被放大、白色腰线在倒影里找不到,与真实的墙对不上;右:盒体投影,墙在地板上的倒影与墙脚严丝合缝,腰线的倒影平行地出现在墙脚下方。房间里的金属球同样受益。

盒体投影只在“环境近似是一个盒子”时准确。房间中间放着的家具、柱子不在盒子的面上,它们的倒影仍然会错位;圆形或不规则的空间也只能近似。另外,盒体投影要求探针的盒子与房间的墙对齐,盒子大了倒影会“缩”、小了会“胀”。在室外开阔场景中,应关掉探针的 Box Projection,因为环境确实接近无限远。

说明在本书的批处理拍摄流程里,脚本新建的 ReflectionProbe 组件不会被引擎即时登记进剔除结果,探针不生效,所以本案例改为由材质提供探针参数。在编辑器和运行时里正常摆放的反射探针不受此影响,GILit 等使用 GlossyEnvironmentReflection 的 Shader 会自动获得探针的盒体投影与混合。

8.6 组装:一个 GI 就绪的自写 Shader

到这里,间接光的每个零件都已经拆开看过了。最后把它们装进一个完整的 Shader:支持 SH(三种求值模式)、静态与动态光照贴图、Shadowmask 与 Subtractive、反射探针(含混合、盒体投影与 Forward+)、SSAO 与材质 AO、自发光,并提供 Meta、ShadowCaster、DepthOnly、DepthNormals(及 Deferred 下使用的 DepthNormalsOnly)等辅助 Pass。主 Pass 使用 UniversalForwardOnly,在 Deferred 渲染路径下也能被正确绘制(原因见第 7 章案例 7.2 的说明)。直接光部分借用 URP 的 LightingPhysicallyBased,这样可以与 URP Lit 逐像素对照,验证间接光的实现没有偏差。

案例 8.9GILit:自写 Shader 接入 SH、光照贴图与反射探针高阶单独打开 ↗

目标:写出与 URP Lit 在间接光上完全一致的自写 Shader,并能拆分显示各个分量。完整代码如下(较长,后面分段讲解):

Assets/Cookbook/Ch08/GILit.shader
// 一个“全局光照就绪”的自写 Shader:球谐 / 光照贴图 / 动态光照贴图 / Shadowmask / 反射探针(含混合与盒体投影)/
// SSAO / 材质 AO / 自发光,并提供 Meta Pass 参与烘焙。直接光沿用 URP 的 BRDF,便于与 URP Lit 对照。
Shader "Cookbook/Ch08/GILit"
{
    Properties
    {
        [MainTexture] _BaseMap("Albedo", 2D) = "white" {}
        [MainColor] _BaseColor("Color", Color) = (1, 1, 1, 1)
        _Metallic("Metallic", Range(0, 1)) = 0
        _Smoothness("Smoothness", Range(0, 1)) = 0.5
        [Toggle(_NORMALMAP)] _UseNormalMap("Use Normal Map", Float) = 0
        [NoScaleOffset][Normal] _BumpMap("Normal Map", 2D) = "bump" {}
        _BumpScale("Normal Scale", Float) = 1
        [Toggle(_OCCLUSIONMAP)] _UseOcclusionMap("Use Occlusion Map", Float) = 0
        [NoScaleOffset] _OcclusionMap("Occlusion (G)", 2D) = "white" {}
        _OcclusionStrength("Occlusion Strength", Range(0, 1)) = 1
        [Toggle(_EMISSION)] _UseEmission("Emission", Float) = 0
        [HDR] _EmissionColor("Emission Color", Color) = (0, 0, 0, 1)
        [NoScaleOffset] _EmissionMap("Emission Map", 2D) = "white" {}
        [ToggleOff(_ENVIRONMENTREFLECTIONS_OFF)] _EnvironmentReflections("Environment Reflections", Float) = 1
        [Enum(Full,0,IndirectDiffuse,1,IndirectSpecular,2,Direct,3,AOOnDirectToo,4)] _DebugView("Debug View", Float) = 0
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
            float4 _BaseMap_ST;
            half4 _BaseColor;
            half4 _EmissionColor;
            half _Metallic;
            half _Smoothness;
            half _BumpScale;
            half _OcclusionStrength;
            half _DebugView;
        CBUFFER_END
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            HLSLPROGRAM
            #pragma target 2.0
            #pragma vertex vert
            #pragma fragment frag

            // 材质开关
            #pragma shader_feature_local _NORMALMAP
            #pragma shader_feature_local_fragment _OCCLUSIONMAP
            #pragma shader_feature_local_fragment _EMISSION
            #pragma shader_feature_local_fragment _ENVIRONMENTREFLECTIONS_OFF
            // URP 管线关键字(与 URP 14 Lit.shader 一致的子集)
            #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
            #pragma multi_compile _ EVALUATE_SH_MIXED EVALUATE_SH_VERTEX
            #pragma multi_compile_fragment _ _REFLECTION_PROBE_BLENDING
            #pragma multi_compile_fragment _ _REFLECTION_PROBE_BOX_PROJECTION
            #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
            #pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION
            #pragma multi_compile _ _FORWARD_PLUS
            // Unity 引擎根据烘焙结果自动打开的关键字
            #pragma multi_compile _ LIGHTMAP_SHADOW_MIXING
            #pragma multi_compile _ SHADOWS_SHADOWMASK
            #pragma multi_compile _ DIRLIGHTMAP_COMBINED
            #pragma multi_compile _ LIGHTMAP_ON
            #pragma multi_compile _ DYNAMICLIGHTMAP_ON
            #pragma multi_compile_fog

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

            TEXTURE2D(_BaseMap);      SAMPLER(sampler_BaseMap);
            TEXTURE2D(_BumpMap);      SAMPLER(sampler_BumpMap);
            TEXTURE2D(_OcclusionMap); SAMPLER(sampler_OcclusionMap);
            TEXTURE2D(_EmissionMap);  SAMPLER(sampler_EmissionMap);

            struct Attributes
            {
                float4 positionOS        : POSITION;
                float3 normalOS          : NORMAL;
                float4 tangentOS         : TANGENT;
                float2 uv                : TEXCOORD0;
                float2 staticLightmapUV  : TEXCOORD1;   // 光照贴图 UV(导入设置 Generate Lightmap UVs 生成的 UV2)
                float2 dynamicLightmapUV : TEXCOORD2;   // Enlighten 实时 GI 用的 UV3
            };

            struct Varyings
            {
                float4 positionCS : SV_POSITION;
                float2 uv         : TEXCOORD0;
                float3 positionWS : TEXCOORD1;
                half3  normalWS   : TEXCOORD2;
            #if defined(_NORMALMAP)
                half4  tangentWS  : TEXCOORD3;
            #endif
                // 有光照贴图时声明 float2 staticLightmapUV,否则声明 half3 vertexSH(逐顶点 SH 结果)
                DECLARE_LIGHTMAP_OR_SH(staticLightmapUV, vertexSH, 4);
            #ifdef DYNAMICLIGHTMAP_ON
                float2 dynamicLightmapUV : TEXCOORD5;
            #endif
                half fogFactor : TEXCOORD6;
            };

            Varyings vert(Attributes input)
            {
                Varyings o = (Varyings)0;
                VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz);
                VertexNormalInputs nrm = GetVertexNormalInputs(input.normalOS, input.tangentOS);
                o.positionCS = pos.positionCS;
                o.positionWS = pos.positionWS;
                o.uv = TRANSFORM_TEX(input.uv, _BaseMap);
                o.normalWS = nrm.normalWS;
            #if defined(_NORMALMAP)
                o.tangentWS = half4(nrm.tangentWS, input.tangentOS.w * GetOddNegativeScale());
            #endif
                // 光照贴图 UV:乘上 unity_LightmapST(每个物体在图集里的缩放与偏移)
                OUTPUT_LIGHTMAP_UV(input.staticLightmapUV, unity_LightmapST, o.staticLightmapUV);
            #ifdef DYNAMICLIGHTMAP_ON
                o.dynamicLightmapUV = input.dynamicLightmapUV * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw;
            #endif
                // 没有光照贴图时:EVALUATE_SH_VERTEX 在这里算完整 SH,EVALUATE_SH_MIXED 只算 L2,逐像素模式什么都不算
                OUTPUT_SH(o.normalWS, o.vertexSH);
                o.fogFactor = ComputeFogFactor(pos.positionCS.z);
                return o;
            }

            half4 frag(Varyings i) : SV_Target
            {
                // ---------- 表面属性 ----------
                half4 baseSample = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv) * _BaseColor;
                half3 albedo = baseSample.rgb;
                half alpha = 1;
            #if defined(_OCCLUSIONMAP)
                half occlusion = LerpWhiteTo(SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, i.uv).g, _OcclusionStrength);
            #else
                half occlusion = 1;
            #endif
            #if defined(_EMISSION)
                half3 emission = SAMPLE_TEXTURE2D(_EmissionMap, sampler_EmissionMap, i.uv).rgb * _EmissionColor.rgb;
            #else
                half3 emission = 0;
            #endif

            #if defined(_NORMALMAP)
                half3 normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, i.uv), _BumpScale);
                float3 bitangent = i.tangentWS.w * cross(i.normalWS, i.tangentWS.xyz);
                half3 normalWS = TransformTangentToWorld(normalTS, half3x3(i.tangentWS.xyz, bitangent, i.normalWS));
            #else
                half3 normalWS = i.normalWS;
            #endif
                normalWS = NormalizeNormalPerPixel(normalWS);
                half3 viewDirWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
                float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);

                BRDFData brdf;
                InitializeBRDFData(albedo, _Metallic, half3(0, 0, 0), _Smoothness, alpha, brdf);

                // ---------- ① 间接漫反射:光照贴图 或 球谐 ----------
            #if defined(DYNAMICLIGHTMAP_ON)
                half3 bakedGI = SAMPLE_GI(i.staticLightmapUV, i.dynamicLightmapUV, i.vertexSH, normalWS);
            #else
                half3 bakedGI = SAMPLE_GI(i.staticLightmapUV, i.vertexSH, normalWS);
            #endif

                // ---------- ② 主光 + 阴影(含 Shadowmask / 光照探针遮挡) ----------
                half4 shadowMask = SAMPLE_SHADOWMASK(i.staticLightmapUV);
                float4 shadowCoord = TransformWorldToShadowCoord(i.positionWS);
                Light mainLight = GetMainLight(shadowCoord, i.positionWS, shadowMask);

                // ---------- ③ AO:材质 AO 与 SSAO 取较小值,只压间接光;SSAO 还按 Direct Lighting Strength 压一点直接光 ----------
                AmbientOcclusionFactor aoFactor = CreateAmbientOcclusionFactor(screenUV, occlusion);
            #if defined(_SCREEN_SPACE_OCCLUSION)
                mainLight.color *= aoFactor.directAmbientOcclusion;
            #endif

                // Subtractive 模式下,从光照贴图里“减掉”实时阴影覆盖区域的主光贡献
                MixRealtimeAndBakedGI(mainLight, normalWS, bakedGI);

                // ---------- ④ 间接高光:反射探针(Forward+ 下走探针图集与聚类) ----------
                half3 reflectVector = reflect(-viewDirWS, normalWS);
                half3 indirectSpecular = GlossyEnvironmentReflection(reflectVector, i.positionWS,
                                                                     brdf.perceptualRoughness, half(1.0), screenUV);

                // ---------- ⑤ 组合:与 URP 的 GlobalIllumination() 相同 ----------
                half NoV = saturate(dot(normalWS, viewDirWS));
                half fresnelTerm = Pow4(1.0 - NoV);
                half3 giDiffuse  = bakedGI * brdf.diffuse;
                half3 giSpecular = indirectSpecular * EnvironmentBRDFSpecular(brdf, fresnelTerm);
                half3 gi = (giDiffuse + giSpecular) * aoFactor.indirectAmbientOcclusion;

                half3 direct = LightingPhysicallyBased(brdf, mainLight, normalWS, viewDirWS);

                half3 color = gi + direct + emission;
                // 调试视图:分别看各个分量(书中配图用)
                if (_DebugView > 0.5 && _DebugView < 1.5) color = giDiffuse * aoFactor.indirectAmbientOcclusion;
                else if (_DebugView > 1.5 && _DebugView < 2.5) color = giSpecular * aoFactor.indirectAmbientOcclusion;
                else if (_DebugView > 2.5 && _DebugView < 3.5) color = direct;
                else if (_DebugView > 3.5) color = gi + direct * occlusion + emission;   // 反例:AO 也乘到直接光上

                color = MixFog(color, i.fogFactor);
                return half4(color, 1);
            }
            ENDHLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            HLSLPROGRAM
            #pragma vertex Ch08ShadowVert
            #pragma fragment Ch08NullFrag
            #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
            #include "Assets/Cookbook/Ch08/Ch08Passes.hlsl"
            ENDHLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            HLSLPROGRAM
            #pragma vertex Ch08DepthVert
            #pragma fragment Ch08NullFrag
            #include "Assets/Cookbook/Ch08/Ch08Passes.hlsl"
            ENDHLSL
        }

        Pass
        {
            Name "DepthNormals"
            Tags { "LightMode"="DepthNormals" }
            ZWrite On
            HLSLPROGRAM
            #pragma vertex Ch08DepthNormalsVert
            #pragma fragment Ch08DepthNormalsFrag
            #include "Assets/Cookbook/Ch08/Ch08Passes.hlsl"
            ENDHLSL
        }

        // Deferred 渲染路径下,只有 UniversalForwardOnly 的材质由这个 Pass 提供法线(与 URP Unlit 的做法相同)
        Pass
        {
            Name "DepthNormalsOnly"
            Tags { "LightMode"="DepthNormalsOnly" }
            ZWrite On
            HLSLPROGRAM
            #pragma vertex Ch08DepthNormalsVert
            #pragma fragment Ch08DepthNormalsFrag
            #include "Assets/Cookbook/Ch08/Ch08Passes.hlsl"
            ENDHLSL
        }

        // Meta Pass:只在烘焙光照贴图时由 Unity 调用,把物体“摊平”到光照贴图 UV 空间,
        // 输出 Albedo(决定反弹光的颜色)和 Emission(自发光作为光源参与烘焙)。
        Pass
        {
            Name "Meta"
            Tags { "LightMode"="Meta" }
            Cull Off
            HLSLPROGRAM
            #pragma vertex MetaVert
            #pragma fragment MetaFrag
            #pragma shader_feature_local_fragment _EMISSION
            #pragma shader_feature EDITOR_VISUALIZATION
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/MetaInput.hlsl"

            TEXTURE2D(_BaseMap);     SAMPLER(sampler_BaseMap);
            TEXTURE2D(_EmissionMap); SAMPLER(sampler_EmissionMap);

            struct MetaAttributes
            {
                float4 positionOS : POSITION;
                float3 normalOS   : NORMAL;
                float2 uv0        : TEXCOORD0;
                float2 uv1        : TEXCOORD1;
                float2 uv2        : TEXCOORD2;
            };

            struct MetaVaryings
            {
                float4 positionCS : SV_POSITION;
                float2 uv         : TEXCOORD0;
            #ifdef EDITOR_VISUALIZATION
                float2 VizUV      : TEXCOORD1;
                float4 LightCoord : TEXCOORD2;
            #endif
            };

            MetaVaryings MetaVert(MetaAttributes input)
            {
                MetaVaryings o = (MetaVaryings)0;
                // unity_MetaVertexControl.x/y 决定用 uv1(静态光照贴图)还是 uv2(动态光照贴图)当作光栅化位置
                o.positionCS = UnityMetaVertexPosition(input.positionOS.xyz, input.uv1, input.uv2);
                o.uv = TRANSFORM_TEX(input.uv0, _BaseMap);
            #ifdef EDITOR_VISUALIZATION
                UnityEditorVizData(input.positionOS.xyz, input.uv0, input.uv1, input.uv2, o.VizUV, o.LightCoord);
            #endif
                return o;
            }

            half4 MetaFrag(MetaVaryings i) : SV_Target
            {
                half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv).rgb * _BaseColor.rgb;
                half alpha = 1;
                BRDFData brdf;
                InitializeBRDFData(albedo, _Metallic, half3(0, 0, 0), _Smoothness, alpha, brdf);

                MetaInput meta = (MetaInput)0;
                // 与 URP Lit 相同:漫反射色 + 粗糙金属的一部分高光色,近似“这块表面把光反弹成什么颜色”
                meta.Albedo = brdf.diffuse + brdf.specular * brdf.roughness * 0.5;
            #if defined(_EMISSION)
                meta.Emission = SAMPLE_TEXTURE2D(_EmissionMap, sampler_EmissionMap, i.uv).rgb * _EmissionColor.rgb;
            #endif
            #ifdef EDITOR_VISUALIZATION
                meta.VizUV = i.VizUV;
                meta.LightCoord = i.LightCoord;
            #endif
                // unity_MetaFragmentControl.x 要 Albedo,.y 要 Emission,由烘焙器分两次调用
                return UnityMetaFragment(meta);
            }
            ENDHLSL
        }
    }
}
// 一个“全局光照就绪”的自写 Shader:球谐 / 光照贴图 / 动态光照贴图 / Shadowmask / 反射探针(含混合与盒体投影)/
// SSAO / 材质 AO / 自发光,并提供 Meta Pass 参与烘焙。直接光沿用 URP 的 BRDF,便于与 URP Lit 对照。
// (GLSL 版,对应 Ch08/GILit.shader)
// 网页版没有光照烘焙:光照贴图、动态光照贴图、Shadowmask、Subtractive 混合这几条分支在浏览器里永远不会打开,
// 所以下面只保留“无光照贴图”的路径(SAMPLE_GI 退化为逐像素 SampleSH),Meta Pass 也不移植。
Shader "Cookbook/Ch08/GILit"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4  _BaseMap_ST;
        uniform vec4  _BaseColor;
        uniform vec4  _EmissionColor;
        uniform float _Metallic;
        uniform float _Smoothness;
        uniform float _BumpScale;
        uniform float _OcclusionStrength;
        uniform float _DebugView;
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }
            GLSLPROGRAM
            // 材质开关
            #pragma shader_feature_local _NORMALMAP
            #pragma shader_feature_local_fragment _OCCLUSIONMAP
            #pragma shader_feature_local_fragment _EMISSION
            #pragma shader_feature_local_fragment _ENVIRONMENTREFLECTIONS_OFF
            // URP 管线关键字(主光阴影、软阴影、SSAO、雾)在网页版由引擎统一处理,不用声明;
            // LIGHTMAP_ON / DYNAMICLIGHTMAP_ON / SHADOWS_SHADOWMASK 等烘焙关键字网页版没有

            uniform sampler2D _BaseMap;
            uniform sampler2D _BumpMap;
            uniform sampler2D _OcclusionMap;
            uniform sampler2D _EmissionMap;

            VARYING vec2 uv;
            VARYING vec3 positionWS;
            VARYING vec3 normalWS;
            VARYING vec4 tangentWS;    // 只在 _NORMALMAP 时有意义
            // HLSL 版:有光照贴图时声明 staticLightmapUV,否则声明 vertexSH(逐顶点 SH)。网页版走逐像素 SH,两者都不需要
            VARYING float fogFactor;

            #ifdef VERTEX
            void main()
            {
                VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz);
                VertexNormalInputs nrm = GetVertexNormalInputs(normalOS, tangentOS);
                gl_Position = pos.positionCS;
                positionWS = pos.positionWS;
                uv = TransformTex(texcoord0.xy, _BaseMap_ST);
                normalWS = nrm.normalWS;
                tangentWS = vec4(nrm.tangentWS, tangentOS.w * GetOddNegativeScale());
                fogFactor = ComputeFogFactor(pos.positionCS.z);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                // ---------- 表面属性 ----------
                vec4 baseSample = texture(_BaseMap, uv) * _BaseColor;
                vec3 albedo = baseSample.rgb;
                float alpha = 1.0;
            #if defined(_OCCLUSIONMAP)
                float occlusion = LerpWhiteTo(texture(_OcclusionMap, uv).g, _OcclusionStrength);
            #else
                float occlusion = 1.0;
            #endif
            #if defined(_EMISSION)
                vec3 emission = texture(_EmissionMap, uv).rgb * _EmissionColor.rgb;
            #else
                vec3 emission = vec3(0.0);
            #endif

            #if defined(_NORMALMAP)
                vec3 normalTS = UnpackNormalScale(texture(_BumpMap, uv), _BumpScale);
                vec3 bitangent = tangentWS.w * cross(normalWS, tangentWS.xyz);
                vec3 N = TransformTangentToWorld(normalTS, mat3(tangentWS.xyz, bitangent, normalWS));
            #else
                vec3 N = normalWS;
            #endif
                N = NormalizeNormalPerPixel(N);
                vec3 viewDirWS = GetWorldSpaceNormalizeViewDir(positionWS);
                vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);

                BRDFData brdf;
                InitializeBRDFData(albedo, _Metallic, vec3(0.0), _Smoothness, alpha, brdf);

                // ---------- ① 间接漫反射:光照贴图 或 球谐 ----------
                // 网页版没有光照贴图,SAMPLE_GI 就是逐像素 SampleSH
                vec3 bakedGI = SampleSH(N);

                // ---------- ② 主光 + 阴影(含 Shadowmask / 光照探针遮挡) ----------
                vec4 shadowMask = vec4(1.0);     // SAMPLE_SHADOWMASK:没有 Shadowmask 时是 1
                vec4 shadowCoord = TransformWorldToShadowCoord(positionWS);
                Light mainLight = GetMainLight(shadowCoord, positionWS, shadowMask);

                // ---------- ③ AO:材质 AO 与 SSAO 取较小值,只压间接光;SSAO 还按 Direct Lighting Strength 压一点直接光 ----------
                AmbientOcclusionFactor aoFactor = GetScreenSpaceAmbientOcclusion(screenUV);
                aoFactor.indirectAmbientOcclusion = min(aoFactor.indirectAmbientOcclusion, occlusion);
                // 没开 SSAO 时 directAmbientOcclusion 恒为 1(HLSL 版用 _SCREEN_SPACE_OCCLUSION 关键字包住这一行)
                mainLight.color *= aoFactor.directAmbientOcclusion;

                // MixRealtimeAndBakedGI:Subtractive 模式下从光照贴图里“减掉”实时阴影区域的主光——没有光照贴图时什么都不做

                // ---------- ④ 间接高光:反射探针(网页版只有全局环境反射 unity_SpecCube0) ----------
                vec3 reflectVector = reflect(-viewDirWS, N);
                vec3 indirectSpecular = GlossyEnvironmentReflection(reflectVector, positionWS, brdf.perceptualRoughness, 1.0);

                // ---------- ⑤ 组合:与 URP 的 GlobalIllumination() 相同 ----------
                float NoV = saturate(dot(N, viewDirWS));
                float fresnelTerm = Pow4(1.0 - NoV);
                vec3 giDiffuse  = bakedGI * brdf.diffuse;
                vec3 giSpecular = indirectSpecular * EnvironmentBRDFSpecular(brdf, fresnelTerm);
                vec3 gi = (giDiffuse + giSpecular) * aoFactor.indirectAmbientOcclusion;

                vec3 direct = LightingPhysicallyBased(brdf, mainLight, N, viewDirWS);

                vec3 color = gi + direct + emission;
                // 调试视图:分别看各个分量(书中配图用)
                if (_DebugView > 0.5 && _DebugView < 1.5) color = giDiffuse * aoFactor.indirectAmbientOcclusion;
                else if (_DebugView > 1.5 && _DebugView < 2.5) color = giSpecular * aoFactor.indirectAmbientOcclusion;
                else if (_DebugView > 2.5 && _DebugView < 3.5) color = direct;
                else if (_DebugView > 3.5) color = gi + direct * occlusion + emission;   // 反例:AO 也乘到直接光上

                color = MixFog(color, fogFactor);
                fragColor = vec4(color, 1.0);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0
            GLSLPROGRAM
            #include "Ch08/Ch08Passes.glsl"
            #ifdef VERTEX
            void main() { gl_Position = Ch08ShadowVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = Ch08NullFrag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R
            GLSLPROGRAM
            #include "Ch08/Ch08Passes.glsl"
            #ifdef VERTEX
            void main() { gl_Position = Ch08DepthVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = Ch08NullFrag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthNormals"
            Tags { "LightMode"="DepthNormals" }
            ZWrite On
            GLSLPROGRAM
            #define CH08_DEPTH_NORMALS
            #include "Ch08/Ch08Passes.glsl"
            #ifdef VERTEX
            void main() { gl_Position = Ch08DepthNormalsVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = Ch08DepthNormalsFrag(); }
            #endif
            ENDGLSL
        }

        // DepthNormalsOnly(Deferred 专用)与 Meta(烘焙光照贴图专用)两个 Pass 网页版用不到,没有移植
    }
}

关键字

Assets/Cookbook/Ch08/GILit.shader(节选:keywords)
// 材质开关
#pragma shader_feature_local _NORMALMAP
#pragma shader_feature_local_fragment _OCCLUSIONMAP
#pragma shader_feature_local_fragment _EMISSION
#pragma shader_feature_local_fragment _ENVIRONMENTREFLECTIONS_OFF
// URP 管线关键字(与 URP 14 Lit.shader 一致的子集)
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
#pragma multi_compile _ EVALUATE_SH_MIXED EVALUATE_SH_VERTEX
#pragma multi_compile_fragment _ _REFLECTION_PROBE_BLENDING
#pragma multi_compile_fragment _ _REFLECTION_PROBE_BOX_PROJECTION
#pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
#pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION
#pragma multi_compile _ _FORWARD_PLUS
// Unity 引擎根据烘焙结果自动打开的关键字
#pragma multi_compile _ LIGHTMAP_SHADOW_MIXING
#pragma multi_compile _ SHADOWS_SHADOWMASK
#pragma multi_compile _ DIRLIGHTMAP_COMBINED
#pragma multi_compile _ LIGHTMAP_ON
#pragma multi_compile _ DYNAMICLIGHTMAP_ON
#pragma multi_compile_fog
// 材质开关
#pragma shader_feature_local _NORMALMAP
#pragma shader_feature_local_fragment _OCCLUSIONMAP
#pragma shader_feature_local_fragment _EMISSION
#pragma shader_feature_local_fragment _ENVIRONMENTREFLECTIONS_OFF
// URP 管线关键字(主光阴影、软阴影、SSAO、雾)在网页版由引擎统一处理,不用声明;
// LIGHTMAP_ON / DYNAMICLIGHTMAP_ON / SHADOWS_SHADOWMASK 等烘焙关键字网页版没有

关键字分三类。材质开关用 shader_feature_local,只编译材质实际用到的组合;URP 管线关键字(阴影、SH 模式、探针、SSAO、Forward+)用 multi_compile,由 URP 在运行时根据 Asset 和 Renderer 设置全局开关;最后一组由引擎根据物体的烘焙状态逐物体打开。列表以 URP 14 的 Lit.shader 为准,我们只保留了与 GI 相关的子集(省去了附加光、光照层、贴花、光照 Cookie 等),实际项目可以按需补齐。变体数量在这里会迅速膨胀,第 29 章会讲怎样剥离不需要的变体。

插值器与顶点

Assets/Cookbook/Ch08/GILit.shader(节选:structs)
struct Attributes
{
    float4 positionOS        : POSITION;
    float3 normalOS          : NORMAL;
    float4 tangentOS         : TANGENT;
    float2 uv                : TEXCOORD0;
    float2 staticLightmapUV  : TEXCOORD1;   // 光照贴图 UV(导入设置 Generate Lightmap UVs 生成的 UV2)
    float2 dynamicLightmapUV : TEXCOORD2;   // Enlighten 实时 GI 用的 UV3
};

struct Varyings
{
    float4 positionCS : SV_POSITION;
    float2 uv         : TEXCOORD0;
    float3 positionWS : TEXCOORD1;
    half3  normalWS   : TEXCOORD2;
#if defined(_NORMALMAP)
    half4  tangentWS  : TEXCOORD3;
#endif
    // 有光照贴图时声明 float2 staticLightmapUV,否则声明 half3 vertexSH(逐顶点 SH 结果)
    DECLARE_LIGHTMAP_OR_SH(staticLightmapUV, vertexSH, 4);
#ifdef DYNAMICLIGHTMAP_ON
    float2 dynamicLightmapUV : TEXCOORD5;
#endif
    half fogFactor : TEXCOORD6;
};
VARYING vec2 uv;
VARYING vec3 positionWS;
VARYING vec3 normalWS;
VARYING vec4 tangentWS;    // 只在 _NORMALMAP 时有意义
// HLSL 版:有光照贴图时声明 staticLightmapUV,否则声明 vertexSH(逐顶点 SH)。网页版走逐像素 SH,两者都不需要
VARYING float fogFactor;
Assets/Cookbook/Ch08/GILit.shader(节选:vert)
Varyings vert(Attributes input)
{
    Varyings o = (Varyings)0;
    VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz);
    VertexNormalInputs nrm = GetVertexNormalInputs(input.normalOS, input.tangentOS);
    o.positionCS = pos.positionCS;
    o.positionWS = pos.positionWS;
    o.uv = TRANSFORM_TEX(input.uv, _BaseMap);
    o.normalWS = nrm.normalWS;
#if defined(_NORMALMAP)
    o.tangentWS = half4(nrm.tangentWS, input.tangentOS.w * GetOddNegativeScale());
#endif
    // 光照贴图 UV:乘上 unity_LightmapST(每个物体在图集里的缩放与偏移)
    OUTPUT_LIGHTMAP_UV(input.staticLightmapUV, unity_LightmapST, o.staticLightmapUV);
#ifdef DYNAMICLIGHTMAP_ON
    o.dynamicLightmapUV = input.dynamicLightmapUV * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw;
#endif
    // 没有光照贴图时:EVALUATE_SH_VERTEX 在这里算完整 SH,EVALUATE_SH_MIXED 只算 L2,逐像素模式什么都不算
    OUTPUT_SH(o.normalWS, o.vertexSH);
    o.fogFactor = ComputeFogFactor(pos.positionCS.z);
    return o;
}
void main()
{
    VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz);
    VertexNormalInputs nrm = GetVertexNormalInputs(normalOS, tangentOS);
    gl_Position = pos.positionCS;
    positionWS = pos.positionWS;
    uv = TransformTex(texcoord0.xy, _BaseMap_ST);
    normalWS = nrm.normalWS;
    tangentWS = vec4(nrm.tangentWS, tangentOS.w * GetOddNegativeScale());
    fogFactor = ComputeFogFactor(pos.positionCS.z);
}

Attributes 里多了 TEXCOORD1 和 TEXCOORD2:分别是静态光照贴图 UV 与动态光照贴图 UV。即使你的模型现在没有烘焙,也应该声明它们,否则一旦场景开始烘焙,物体会因为缺少 UV 而拿到错误的光照贴图坐标。顶点着色器里的 OUTPUT_LIGHTMAP_UV 与 OUTPUT_SH 会根据关键字二选一。

片元:五步走

Assets/Cookbook/Ch08/GILit.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    // ---------- 表面属性 ----------
    half4 baseSample = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv) * _BaseColor;
    half3 albedo = baseSample.rgb;
    half alpha = 1;
#if defined(_OCCLUSIONMAP)
    half occlusion = LerpWhiteTo(SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, i.uv).g, _OcclusionStrength);
#else
    half occlusion = 1;
#endif
#if defined(_EMISSION)
    half3 emission = SAMPLE_TEXTURE2D(_EmissionMap, sampler_EmissionMap, i.uv).rgb * _EmissionColor.rgb;
#else
    half3 emission = 0;
#endif

#if defined(_NORMALMAP)
    half3 normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, i.uv), _BumpScale);
    float3 bitangent = i.tangentWS.w * cross(i.normalWS, i.tangentWS.xyz);
    half3 normalWS = TransformTangentToWorld(normalTS, half3x3(i.tangentWS.xyz, bitangent, i.normalWS));
#else
    half3 normalWS = i.normalWS;
#endif
    normalWS = NormalizeNormalPerPixel(normalWS);
    half3 viewDirWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
    float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);

    BRDFData brdf;
    InitializeBRDFData(albedo, _Metallic, half3(0, 0, 0), _Smoothness, alpha, brdf);

    // ---------- ① 间接漫反射:光照贴图 或 球谐 ----------
#if defined(DYNAMICLIGHTMAP_ON)
    half3 bakedGI = SAMPLE_GI(i.staticLightmapUV, i.dynamicLightmapUV, i.vertexSH, normalWS);
#else
    half3 bakedGI = SAMPLE_GI(i.staticLightmapUV, i.vertexSH, normalWS);
#endif

    // ---------- ② 主光 + 阴影(含 Shadowmask / 光照探针遮挡) ----------
    half4 shadowMask = SAMPLE_SHADOWMASK(i.staticLightmapUV);
    float4 shadowCoord = TransformWorldToShadowCoord(i.positionWS);
    Light mainLight = GetMainLight(shadowCoord, i.positionWS, shadowMask);

    // ---------- ③ AO:材质 AO 与 SSAO 取较小值,只压间接光;SSAO 还按 Direct Lighting Strength 压一点直接光 ----------
    AmbientOcclusionFactor aoFactor = CreateAmbientOcclusionFactor(screenUV, occlusion);
#if defined(_SCREEN_SPACE_OCCLUSION)
    mainLight.color *= aoFactor.directAmbientOcclusion;
#endif

    // Subtractive 模式下,从光照贴图里“减掉”实时阴影覆盖区域的主光贡献
    MixRealtimeAndBakedGI(mainLight, normalWS, bakedGI);

    // ---------- ④ 间接高光:反射探针(Forward+ 下走探针图集与聚类) ----------
    half3 reflectVector = reflect(-viewDirWS, normalWS);
    half3 indirectSpecular = GlossyEnvironmentReflection(reflectVector, i.positionWS,
                                                         brdf.perceptualRoughness, half(1.0), screenUV);

    // ---------- ⑤ 组合:与 URP 的 GlobalIllumination() 相同 ----------
    half NoV = saturate(dot(normalWS, viewDirWS));
    half fresnelTerm = Pow4(1.0 - NoV);
    half3 giDiffuse  = bakedGI * brdf.diffuse;
    half3 giSpecular = indirectSpecular * EnvironmentBRDFSpecular(brdf, fresnelTerm);
    half3 gi = (giDiffuse + giSpecular) * aoFactor.indirectAmbientOcclusion;

    half3 direct = LightingPhysicallyBased(brdf, mainLight, normalWS, viewDirWS);

    half3 color = gi + direct + emission;
    // 调试视图:分别看各个分量(书中配图用)
    if (_DebugView > 0.5 && _DebugView < 1.5) color = giDiffuse * aoFactor.indirectAmbientOcclusion;
    else if (_DebugView > 1.5 && _DebugView < 2.5) color = giSpecular * aoFactor.indirectAmbientOcclusion;
    else if (_DebugView > 2.5 && _DebugView < 3.5) color = direct;
    else if (_DebugView > 3.5) color = gi + direct * occlusion + emission;   // 反例:AO 也乘到直接光上

    color = MixFog(color, i.fogFactor);
    return half4(color, 1);
}
void main()
{
    // ---------- 表面属性 ----------
    vec4 baseSample = texture(_BaseMap, uv) * _BaseColor;
    vec3 albedo = baseSample.rgb;
    float alpha = 1.0;
#if defined(_OCCLUSIONMAP)
    float occlusion = LerpWhiteTo(texture(_OcclusionMap, uv).g, _OcclusionStrength);
#else
    float occlusion = 1.0;
#endif
#if defined(_EMISSION)
    vec3 emission = texture(_EmissionMap, uv).rgb * _EmissionColor.rgb;
#else
    vec3 emission = vec3(0.0);
#endif

#if defined(_NORMALMAP)
    vec3 normalTS = UnpackNormalScale(texture(_BumpMap, uv), _BumpScale);
    vec3 bitangent = tangentWS.w * cross(normalWS, tangentWS.xyz);
    vec3 N = TransformTangentToWorld(normalTS, mat3(tangentWS.xyz, bitangent, normalWS));
#else
    vec3 N = normalWS;
#endif
    N = NormalizeNormalPerPixel(N);
    vec3 viewDirWS = GetWorldSpaceNormalizeViewDir(positionWS);
    vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);

    BRDFData brdf;
    InitializeBRDFData(albedo, _Metallic, vec3(0.0), _Smoothness, alpha, brdf);

    // ---------- ① 间接漫反射:光照贴图 或 球谐 ----------
    // 网页版没有光照贴图,SAMPLE_GI 就是逐像素 SampleSH
    vec3 bakedGI = SampleSH(N);

    // ---------- ② 主光 + 阴影(含 Shadowmask / 光照探针遮挡) ----------
    vec4 shadowMask = vec4(1.0);     // SAMPLE_SHADOWMASK:没有 Shadowmask 时是 1
    vec4 shadowCoord = TransformWorldToShadowCoord(positionWS);
    Light mainLight = GetMainLight(shadowCoord, positionWS, shadowMask);

    // ---------- ③ AO:材质 AO 与 SSAO 取较小值,只压间接光;SSAO 还按 Direct Lighting Strength 压一点直接光 ----------
    AmbientOcclusionFactor aoFactor = GetScreenSpaceAmbientOcclusion(screenUV);
    aoFactor.indirectAmbientOcclusion = min(aoFactor.indirectAmbientOcclusion, occlusion);
    // 没开 SSAO 时 directAmbientOcclusion 恒为 1(HLSL 版用 _SCREEN_SPACE_OCCLUSION 关键字包住这一行)
    mainLight.color *= aoFactor.directAmbientOcclusion;

    // MixRealtimeAndBakedGI:Subtractive 模式下从光照贴图里“减掉”实时阴影区域的主光——没有光照贴图时什么都不做

    // ---------- ④ 间接高光:反射探针(网页版只有全局环境反射 unity_SpecCube0) ----------
    vec3 reflectVector = reflect(-viewDirWS, N);
    vec3 indirectSpecular = GlossyEnvironmentReflection(reflectVector, positionWS, brdf.perceptualRoughness, 1.0);

    // ---------- ⑤ 组合:与 URP 的 GlobalIllumination() 相同 ----------
    float NoV = saturate(dot(N, viewDirWS));
    float fresnelTerm = Pow4(1.0 - NoV);
    vec3 giDiffuse  = bakedGI * brdf.diffuse;
    vec3 giSpecular = indirectSpecular * EnvironmentBRDFSpecular(brdf, fresnelTerm);
    vec3 gi = (giDiffuse + giSpecular) * aoFactor.indirectAmbientOcclusion;

    vec3 direct = LightingPhysicallyBased(brdf, mainLight, N, viewDirWS);

    vec3 color = gi + direct + emission;
    // 调试视图:分别看各个分量(书中配图用)
    if (_DebugView > 0.5 && _DebugView < 1.5) color = giDiffuse * aoFactor.indirectAmbientOcclusion;
    else if (_DebugView > 1.5 && _DebugView < 2.5) color = giSpecular * aoFactor.indirectAmbientOcclusion;
    else if (_DebugView > 2.5 && _DebugView < 3.5) color = direct;
    else if (_DebugView > 3.5) color = gi + direct * occlusion + emission;   // 反例:AO 也乘到直接光上

    color = MixFog(color, fogFactor);
    fragColor = vec4(color, 1.0);
}
  1. 间接漫反射:SAMPLE_GI 得到 bakedGI,来自光照贴图或 SH,无需关心是哪一个。
  2. 主光:SAMPLE_SHADOWMASK 取得烘焙阴影信息,传给 GetMainLight,由它混合实时阴影与 Shadowmask。
  3. AO:CreateAmbientOcclusionFactor 合并材质 AO 与 SSAO(详见 8.7 节);开启 SSAO 时按 Direct Lighting Strength 衰减主光。
  4. Subtractive 修正:MixRealtimeAndBakedGI,只在 Subtractive 模式下生效。
  5. 间接高光与组合:GlossyEnvironmentReflection 采样探针;漫反射部分乘 brdf.diffuse,高光部分乘 EnvironmentBRDFSpecular,两者之和再乘间接 AO。这几行与 URP 的 GlobalIllumination() 函数逐项相同,我们把它展开写,是为了能单独输出每个分量。

EnvironmentBRDFSpecular 的内容只有两行:surfaceReduction = 1 / (roughness² + 1),以及在 F0(brdf.specular)和掠射颜色(grazingTerm)之间按 fresnelTerm = (1 − N·V)⁴ 插值。它是对 Split Sum 中“BRDF 积分”那一项的解析近似,第 10 章逐行读 URP Lit 时会再展开。

图 8-14左:URP Lit;右:GILit。五个球依次为红色粗糙塑料、蓝色中等光滑塑料、白色光滑塑料、金色金属、镜面银色金属,参数相同。两张图在间接漫反射、间接高光、阴影上一致,说明我们复刻的 GI 路径与 URP Lit 等价。
图 8-15用 _DebugView 拆分 GILit 的光照。从左到右:仅直接光(金属球除高光外全黑)、仅间接漫反射(金属球全黑,因为金属没有漫反射)、仅间接高光(金属球完整映出天空与地面)、全部合成。

图 8-15 是理解 PBR 外观的好材料:橙色纽结(非金属)的颜色主要来自直接光和间接漫反射,间接高光只在掠射角给它添了一层淡淡的天光;银色球(金属)在直接光下几乎只有一个高光点,它的外观几乎完全由环境反射决定。如果你的金属材质看起来又黑又假,先检查环境反射,而不是调灯光。

8.7 AO 如何进入间接光

环境光遮蔽(Ambient Occlusion,AO)描述一个点“有多少方向能看到开阔的环境”。墙角、缝隙、物体与地面的接触处,被周围几何挡住了大部分方向,接收到的环境光理应更少。SH 和反射探针都是“不知道周围几何”的数据(探针在一个点拍摄,SH 对整个物体只有一组),AO 就是为它们补上的遮挡信息。URP 中 AO 有两个来源:

  • 材质 AO 贴图:离线烘焙,记录物体自身凹陷处的遮挡(例如砖缝)。URP Lit 从 _OcclusionMap 的 G 通道读取,_OcclusionStrength 控制强度。它只知道物体自己,不知道周围的物体。
  • 屏幕空间 AO(SSAO):在 Renderer 上添加 Screen Space Ambient Occlusion Feature,每帧根据深度与法线纹理估算遮挡,能表现物体之间的遮挡(接触阴影),但只看得到屏幕上可见的几何,有噪声和屏幕边缘问题。
摘自 URP 14 ShaderLibrary/AmbientOcclusion.hlsl(节选)
AmbientOcclusionFactor GetScreenSpaceAmbientOcclusion(float2 normalizedScreenSpaceUV)
{
    AmbientOcclusionFactor aoFactor;
    #if defined(_SCREEN_SPACE_OCCLUSION) && !defined(_SURFACE_TYPE_TRANSPARENT)
        float ssao = saturate(SampleAmbientOcclusion(normalizedScreenSpaceUV) + (1.0 - _AmbientOcclusionParam.x));
        aoFactor.indirectAmbientOcclusion = ssao;
        aoFactor.directAmbientOcclusion = lerp(half(1.0), ssao, _AmbientOcclusionParam.w);
    #else
        aoFactor.directAmbientOcclusion = half(1.0);
        aoFactor.indirectAmbientOcclusion = half(1.0);
    #endif
    ...
}

AmbientOcclusionFactor CreateAmbientOcclusionFactor(float2 normalizedScreenSpaceUV, half occlusion)
{
    AmbientOcclusionFactor aoFactor = GetScreenSpaceAmbientOcclusion(normalizedScreenSpaceUV);
    aoFactor.indirectAmbientOcclusion = min(aoFactor.indirectAmbientOcclusion, occlusion);
    return aoFactor;
}

规则很清楚:

  • 间接光 AO = min(SSAO, 材质 AO)。取最小值而不是相乘,是因为两者往往描述的是同一处遮挡(砖缝在两张图里都会暗),相乘会让它暗两次。
  • 直接光 AO 只来自 SSAO,并且用 _AmbientOcclusionParam.w(SSAO Feature 上的 Direct Lighting Strength,默认 0.25)打了折扣。材质 AO 不影响直接光。
  • 在 GlobalIllumination() 中,间接 AO 同时乘到间接漫反射和间接高光上——后者是对“镜面遮蔽(Specular Occlusion)”的粗略近似。

为什么材质 AO 不该乘到直接光上?直接光的遮挡是阴影的职责:一束阳光能不能照进砖缝,取决于光的方向,阴影贴图(或第 7 章的视差自阴影)会正确地回答这个问题。AO 是对“所有方向的平均可见性”的统计,乘到方向性很强的直接光上,会让朝着阳光的砖缝也变暗,画面变脏、变平。SSAO 对直接光的那一点影响(Direct Lighting Strength)是一个美术上的折中,用来增强接触感,默认值很小。

案例 8.10材质 AO 与 SSAO进阶单独打开 ↗

目标:同一个场景(砖墙 + 白色方块 + 白色球),对比无 AO、只有材质 AO、材质 AO + SSAO 三种情况。为了让环境光成为主角,主光强度调得较低。GILit 中 AO 相关的代码就是片元第 ③ 步的两行,没有更多内容。SSAO 在 URP 14 中是 internal 类,拍摄脚本用反射添加,并把半径从默认的 0.035 米调到 0.25 米以匹配本场景的尺度;在你的项目里,直接在 Universal Renderer Data 的 Inspector 中 Add Renderer Feature › Screen Space Ambient Occlusion 即可。

图 8-16左:无 AO;中:材质 AO(砖墙的 Mask 贴图 G 通道),砖缝与砖块倒角处的环境光变暗,砖墙更有层次;右:材质 AO + SSAO,方块、球与墙面、地面之间出现了接触阴影,物体不再像“贴”在背景上。

右图同时暴露了 SSAO 的典型问题:方块边缘有颗粒状的噪点,这是采样数有限、模糊不足造成的;如果相机移动,屏幕边缘还会出现遮挡“突然消失”的现象。SSAO 的参数(半径、强度、采样数、模糊质量、降采样)需要按场景尺度和目标平台调整,第 28 章会讲它的原理并实现一个简化版。移动端上 SSAO 开销较高(一次 DepthNormals Pass + 若干全屏 Pass),中低端设备通常关闭,改用烘焙 AO 或光照贴图里的遮蔽。

注意自写 Shader 想要被 SSAO 正确处理,必须提供 DepthNormals Pass(SSAO 的 Depth Source 默认是 Depth Normals)。没有这个 Pass 的物体在 SSAO 里是“隐形”的:它不会遮挡别人,自己身上的 AO 也会根据背后物体的深度胡乱计算。GILit 的 DepthNormals Pass 写在 Ch08Passes.hlsl 中;由于 GILit 的主 Pass 是 UniversalForwardOnly,Deferred 路径下 URP 会改用 DepthNormalsOnly 这个 LightMode 取法线,所以 GILit 同时提供了这两个 Pass(与 URP Unlit 的做法相同)。

8.8 Matcap:一个有用的对照

Matcap(Material Capture,也叫 Lit Sphere)是一种完全不同的“环境光照”:美术画(或渲染)一张打好光的球体图片,Shader 把物体观察空间法线的 xy 当作 UV 去采样它。观察空间里,朝向相机的法线落在图片中心,朝左的落在左边缘——物体上每一点都“借用”了球上同样朝向那一点的颜色。

案例 8.11Matcap 与环境光照的对比入门单独打开 ↗

目标:实现带透视修正的 Matcap,并通过移动相机说明它与真正的环境光照的本质区别。

Assets/Cookbook/Ch08/Matcap.shader(节选:matcap)
half4 frag(Varyings i) : SV_Target
{
    float3 n = normalize(i.normalVS);
    float2 uv;
    if (_PerspectiveFix > 0.5)
    {
        // 透视修正:用“视线方向”构造一个局部坐标系,再把法线投影进去。
        // 否则物体在画面边缘时,Matcap 会被明显拉扯(只有正交相机下 n.xy 才是准确的)
        float3 v = normalize(i.positionVS);          // 观察空间里从相机指向表面
        float3 r = cross(v, n);
        uv = float2(-r.y, r.x);
    }
    else
    {
        uv = n.xy;                                   // 经典写法:直接取观察空间法线的 xy
    }
    uv = uv * 0.5 + 0.5;
    half3 c = SAMPLE_TEXTURE2D(_MatCap, sampler_MatCap, uv).rgb * _Tint.rgb;
    return half4(c, 1);
}
void main()
{
    vec3 n = normalize(normalVS);
    vec2 uv;
    if (_PerspectiveFix > 0.5)
    {
        // 透视修正:用“视线方向”构造一个局部坐标系,再把法线投影进去。
        // 否则物体在画面边缘时,Matcap 会被明显拉扯(只有正交相机下 n.xy 才是准确的)
        vec3 v = normalize(positionVS);          // 观察空间里从相机指向表面
        vec3 r = cross(v, n);
        uv = vec2(-r.y, r.x);
    }
    else
    {
        uv = n.xy;                               // 经典写法:直接取观察空间法线的 xy
    }
    uv = uv * 0.5 + 0.5;
    vec3 c = texture(_MatCap, uv).rgb * _Tint.rgb;
    fragColor = vec4(c, 1.0);
}

经典写法 uv = n.xy * 0.5 + 0.5 只在正交相机下准确;透视相机下,处在画面边缘的物体,视线方向与相机前方不一致,Matcap 会被明显拉扯。修正的思路是用“从相机指向该点的方向”构造局部坐标系:cross(v, n) 得到一个与视线和法线都垂直的向量,旋转 90° 后作为 UV。物体在画面中心时,它与经典写法完全一致。

图 8-17左:Matcap,从正面看;中:同一个 Matcap,相机绕到另一侧——高光和轮廓光的位置相对于画面完全没变;右:GILit 从同一侧看,光照随着世界中的主光和天空正确变化,阴影也投在地面上。
MatcapSH + 反射探针(GILit)
开销一次纹理采样SH 求值 + Cubemap 采样 + BRDF
光照与场景固定在相机上,换方向不变来自世界,随物体位置和朝向变化
阴影、遮挡没有实时阴影、AO、光照贴图
适合雕刻预览、UI 里的 3D 图标、风格化角色、低端设备的特效需要融入场景的一切物体

Matcap 并非“错误”的技术——它的可控性极强,美术画什么就是什么,第 15 章还会把它用于风格化渲染。只是要清楚它不是环境光照:它不知道物体在场景里的哪里、周围是什么,也不会和其他物体产生任何光照上的联系。

8.9 常见问题与对照

排错清单

现象常见原因
自写 Shader 背光面全黑没有加环境光:漏写 SampleSH / SAMPLE_GI;或 Lighting 窗口环境强度为 0
烘焙后物体变黑或光照错乱Attributes 没有声明 TEXCOORD1;缺少 LIGHTMAP_ON 等关键字;模型没有生成光照贴图 UV
烘焙后物体颜色不对、自发光不照亮周围Meta Pass 缺失或输出错误;材质 globalIlluminationFlags 不是 BakedEmissive
Forward+ 下反射探针无效Shader 没有 multi_compile _ _FORWARD_PLUS,或没有使用带 screenUV 的 GlossyEnvironmentReflection
换了天空盒,环境光不变环境光 SH 需要重新生成:编辑器里 Generate Lighting,运行时 DynamicGI.UpdateEnvironment()
金属在室内反射出天空没有放置反射探针,或探针没有烘焙;室内反射需要 Box Projection
同一粗糙度下自写 PBR 比 Lit 更“亮滑”粗糙度 → mip 用了线性映射;或忘了 EnvironmentBRDFSpecular 中的 surfaceReduction
SSAO 对自写物体无效缺少 DepthNormals Pass

移动端建议

  • SH Evaluation Mode 在移动端默认逐顶点,法线贴图对环境光的影响会消失;对画质要求高的角色可以选择 Mixed。
  • 反射探针分辨率 64~128 往往足够,开启探针混合和 Forward+ 都会增加采样次数。环境反射对低端设备可以直接关闭(材质上勾掉 Environment Reflections),换成常数颜色。
  • 光照贴图是移动端最划算的 GI:运行时只需一次采样。注意控制图集数量与分辨率,Lightmap Encoding 在移动端通常选择较低的质量以节省内存。
  • SSAO 在中低端设备上通常关闭,接触阴影交给光照贴图或手工 Blob 阴影(第 23 章)。

与 Built-in 管线的对照

Built-inURP 14
ShadeSH9(float4(n, 1))SampleSH(n);顶点/像素拆分用 SampleSHVertex / SampleSHPixel
UNITY_LIGHTMAP_ON 路径下 DecodeLightmap(UNITY_SAMPLE_TEX2D(unity_Lightmap, uv))SampleLightmap(staticUV, dynamicUV, n),或统一用 SAMPLE_GI
UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0, R, mip) + DecodeHDRGlossyEnvironmentReflection(R, positionWS, perceptualRoughness, occlusion, screenUV)
BoxProjectedCubemapDirection(UnityStandardUtils.cginc)同名函数,位于 GlobalIllumination.hlsl,参数含义相同
#include "UnityMetaPass.cginc"、UnityMetaVertexPositionMetaInput.hlsl、UnityMetaVertexPosition、UnityMetaFragment
Light Probe Proxy Volume不支持(URP 14)

本章小结

  • 间接光拆成两路:间接漫反射只依赖法线(辐照度,平滑、低频),间接高光依赖反射方向和粗糙度(预过滤 Cubemap)。
  • 余弦卷积滤掉了环境的高频,所以三阶 SH 的 9 个系数足以近似辐照度;Unity 把 27 个系数打包成 7 个 float4,SampleSH 只需几次 dot;URP 的 SH 系数已经除以 π。
  • Skybox / Gradient / Color 三种环境光来源最终都变成 RenderSettings.ambientProbe;光照探针提供逐物体的 SH;运行时修改环境需要重新生成。
  • 光照贴图用 uv1 和 unity_LightmapST 寻址;DECLARE_LIGHTMAP_OR_SH、OUTPUT_LIGHTMAP_UV、OUTPUT_SH、SAMPLE_GI 四个宏在编译期选择光照贴图或 SH;Mixed Lighting 有 Baked Indirect、Shadowmask、Subtractive 三种分工。
  • Meta Pass 为烘焙器提供反照率与自发光;自发光参与烘焙还需要材质的 GI 标记。
  • GlossyEnvironmentReflection 按 PerceptualRoughnessToMipmapLevel 选 mip,支持探针混合、盒体投影与 Forward+ 图集;自写 Shader 要声明 _FORWARD_PLUS 并使用带 screenUV 的重载。
  • 间接 AO = min(SSAO, 材质 AO),同时作用于间接漫反射与间接高光;材质 AO 不作用于直接光,SSAO 只按 Direct Lighting Strength 轻微影响直接光。

练习

  1. 用案例 8.1 的投影函数,做一个随时间变化的“昼夜”环境光:每帧(或每隔几帧)根据太阳高度生成天空函数并投影成 SH。思考投影的开销,以及怎样把它分摊到多帧。
  2. 修改 AmbientSH.shader,加入“只看 L2”的模式(完整 SH 减去 L0+L1),观察哪些方向上 L2 为负,并与图 8-4 中 Y20、Y22 的形状对照。
  3. 给案例 8.5 的迷你烘焙器加入“一次反弹”:球体表面也计算天空可见性,地面采样光线打到球上时,取球面该点的(反照率 × 天空可见性)作为入射光。比较球底部附近地面的颜色变化。
  4. 在 GILit 中实现 URP Lit 没有的“镜面遮蔽”:用 GetSpecularOcclusion 一类的近似(例如 Lagarde 的公式,按 N·V、AO 和粗糙度计算)替代“直接乘 AO”,对比砖缝里的高光。
  5. (挑战)在一个真实的场景里摆放两个重叠的反射探针,开启 Probe Blending,分别在 Forward 和 Forward+ 下用 GILit 观察混合区的过渡,并用 Frame Debugger 查看 Forward+ 下 urp_ReflProbes_Atlas 的内容。