环境光、光照贴图与反射探针
只有直接光的画面,阴影是死黑的,金属是黑的。本章讲“直接光之外的所有光”在 URP 里如何被预先算好、存起来、再在 Shader 里取回来:球谐环境光、光照贴图、反射探针,以及环境光遮蔽怎样进入这一切。
SampleSHDECLARE_LIGHTMAP_OR_SH、SAMPLE_GI 等GlossyEnvironmentReflection、粗糙度 → mip、盒体投影与探针混合8.1 间接光:直接光之外的一切
第 5、6 章的光照模型只考虑了“从灯直接射到表面”的光。现实里,光会在场景中反复弹射:天空本身就是一个巨大的面光源,地面把阳光反射到物体的底面,红墙把旁边的白球染上一层红晕,金属表面映出周围的环境。这些“经过至少一次反弹、或者来自环境”的光统称间接光(Indirect Lighting),计算它们的过程称为全局光照(Global Illumination,GI)。
完整求解全局光照需要对每个像素在整个半球上积分入射光,而入射光又依赖于其他表面的出射光——这在实时渲染里不可能逐帧完成。实时引擎的思路是预计算 + 近似:把场景的间接光提前算好、用某种紧凑的形式存起来,渲染时只做查询。按照 BRDF 的两个部分,间接光被拆成两路:
- 间接漫反射:漫反射对所有方向的入射光都一视同仁(只按余弦加权),所以它只需要知道“这一点、这个法线方向上,半球入射光的余弦加权总和”,也就是辐照度(Irradiance)。辐照度是一个随法线方向非常平滑变化的量,可以用很少的数据表示——球谐函数和光照贴图都是存它的。
- 间接高光:镜面反射只关心反射方向附近一小束光,而且这一束的宽度由粗糙度决定。它需要一张能按方向查询、并且按不同粗糙度预先模糊过的“环境图”——这就是反射探针的预过滤 Cubemap。
环境光的三种来源
在 里,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 和任意的天空函数,我们在球面上均匀撒点,把每个方向当成一盏小方向光加进去——这就是蒙特卡洛投影:
/// 蒙特卡洛投影:在球面上均匀取 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 响应总和是 (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));

注意中图球体下半部分的暖色——这是 Gradient 环境里 Ground 颜色的贡献,它模拟了“地面反弹光”。Color 模式下物体的立体感完全来自主光,背光面是一片均匀的灰,这就是为什么老游戏里的背光面总显得“假”。
8.2 球谐函数:用 9 个数描述一片天空
为什么辐照度可以压缩得这么狠
对一个漫反射表面,法线为 n 时的出射光正比于辐照度:
这是环境辐射率 L 与一个“截断余弦”核的卷积。余弦核是一个非常宽、非常平滑的波瓣,卷积相当于一次强力的模糊:环境里再锐利的细节(太阳、窗户边缘)在辐照度里都变成了柔和的渐变。频域上看,模糊就是把高频成分滤掉。球谐函数(Spherical Harmonics,SH)是球面上的“傅里叶基”:按频率从低到高分成第 0 阶、第 1 阶、第 2 阶……第 l 阶有 2l+1 个基函数。Ramamoorthi 与 Hanrahan 在 2001 年的论文中给出了余弦核在每一阶上的衰减系数:
这就是“9 个数描述一片天空”的由来:不是天空本身只有 9 个数的信息量,而是天空经过余弦卷积之后,剩下的低频部分只需要 9 个数。每个颜色通道 9 个系数,RGB 共 27 个浮点数,就能为一个物体提供方向相关的环境光。
投影与重建
把环境 L 投影到第 l 阶第 m 个基函数上,得到系数 。重建辐照度时,每一阶乘上对应的衰减系数:
为什么左边除以 π?因为 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 = 0 | Y00 = 0.282095 | 常数,球面上处处相同:平均亮度 |
| l = 1 | Y1,−1 = 0.488603 y,Y10 = 0.488603 z,Y11 = 0.488603 x | 沿三根轴的“哑铃”:哪一侧更亮 |
| l = 2 | Y2,−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(方向)| 处,正值涂暖色、负值涂冷色,得到球谐教科书上经典的花瓣图。
// 实数球谐基函数,系数与 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)) 求面法线做简单明暗,并朝向相机翻转,这样就不必关心叉乘方向在不同平台上的差异。

看着这张图很容易建立直觉:任何环境光都是这 9 个形状的加权和。L0 给出“整体有多亮”;L1 的三个哑铃组合起来,可以表示“光主要从哪个方向来”;L2 则能表示一些 L1 做不到的分布,比如“天顶亮、地面也亮、四周暗”(Y20 那样的形状)。
Unity 怎样把 27 个系数交给 Shader
直接传 27 个 float 当然可以,但 Unity 做了一个更聪明的打包:把基函数展开成 x、y、z 的多项式后,重新组合成 7 个 float4,使得求值只需要几次 dot:
| 变量 | 内容 | 求值时与之点乘的向量 |
|---|---|---|
| unity_SHAr/Ag/Ab | xyz:L1 的三个系数;w:L0 系数减去 Y20 中的常数部分 | (n.x, n.y, n.z, 1) |
| unity_SHBr/Bg/Bb | L2 中 xy、yz、zz、zx 四项的系数 | (xy, yz, zz, zx) |
| unity_SHC | rgb:L2 中 (x² − y²) 一项的系数 | 乘以 x² − y² |
求值代码就是 core 库的两个函数,SampleSH 把它们加起来再与 0 取最大值:
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):
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、完整三阶来渲染,看清每一阶的作用。
// 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。这个小推导能帮你在需要“平均环境亮度”时取到正确的值(例如给粒子、头发卡片一个不随方向变化的环境光)。

从 L0 到 L1 是质变,从 L1 到 L2 是改进。在极度受限的情况下(例如非常低端的设备、大量的植被实例),只用 L0+L1 也是可以接受的近似;URP 的混合模式也正是基于这样的判断——L1 必须在像素里算,L2 可以降到顶点。
案例 8.49 个系数到底有多准进阶单独打开 ↗
目标:选一个高对比度的“摄影棚”环境(深灰背景 + 两块强烈的柔光箱 + 一条暖色轮廓灯),用暴力积分算出真实的辐照度,与 SH 近似并排比较。
“标准答案”通过对每个法线方向做余弦加权的半球积分得到:在半球上按余弦分布采样方向,概率密度 cosθ/π 正好与被积函数中的 cosθ/π 抵消,积分就变成了样本的简单平均。我们把结果存进一张小 Cubemap:
/// “标准答案”:对每个法线方向做余弦加权的半球积分,得到 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; }

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)。光照贴图能表现探针做不到的东西:墙角的接触阴影、门缝漏进来的一道光、红墙在白地板上映出的一片红。
unity_LightmapST 里。光照贴图需要什么
- 静态标记与 Contribute GI:物体的 Static 下拉中勾选 Contribute Global Illumination,Mesh Renderer 的 Receive Global Illumination 选 Lightmaps,才会被分配光照贴图。
- 光照贴图 UV:光照贴图要求 UV 不重叠、各岛之间留有间隙(Padding),这与颜色贴图的 UV(可以重叠、镜像、平铺)要求不同,所以单独用一套 uv1(Shader 里的 TEXCOORD1)。模型导入时勾选 可以自动生成。
- 编码:光照贴图存的是 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,再除以一个“重新平衡”系数,让法线贴图在光照贴图区域也能产生明暗。
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 取较亮者:
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 照抄即可:
#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 的含义相同,可以无缝替换。
/// 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 模式的分工。
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); }

对比左右两图,差别集中在球与地面接触的区域。左图里球像是浮在地面上,这是只有 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,物体在烘焙器眼里是一片默认的灰色,也不会把自发光传播到周围。
// 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;
_BaseColor,或者 Alpha Clip 材质没有在 Meta Pass 里 clip,导致镂空部分也参与了遮挡。8.5 环境反射与反射探针
预过滤:把“积分”提前做掉
间接高光要计算的是 。实时渲染的标准近似(Epic 的 Split Sum,第 11 章会完整推导)把它拆成两部分的乘积:一部分是“环境光在镜面波瓣范围内的加权平均”,只依赖反射方向和粗糙度,可以预先算好存进 Cubemap 的 mip 链——粗糙度越高,波瓣越宽,对应越模糊的 mip;另一部分是 BRDF 本身对所有方向的积分,只依赖粗糙度、视角和 F0,URP 用一个解析近似 EnvironmentBRDFSpecular 代替查找表。
于是 Shader 里的间接高光只剩下一件事:沿反射方向,按粗糙度选 mip 采样 Cubemap。粗糙度到 mip 的映射在 core 库里:
#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; }
为什么不用线性映射?预过滤 Cubemap 的 mip 链是按“每级分辨率减半”生成的,而 GGX 波瓣的角度宽度与粗糙度并不是线性关系。这条二次曲线是对“哪一级 mip 的纹素大小刚好覆盖波瓣”的经验拟合(同一文件里还有一个按 N·R 计算的“精确版”,URP 默认不用)。实际效果是中等粗糙度的材质看起来更“糊”一些,与真实的 GGX 积分更接近。
GlossyEnvironmentReflection 的三条路径
URP 的间接高光入口是 GlossyEnvironmentReflection,它在 GlobalIllumination.hlsl 里有三个重载,内部根据关键字走不同的路径:
- Forward / Deferred,无探针混合:只采样
unity_SpecCube0,即引擎为这个物体挑选的“最重要”的反射探针;开启_REFLECTION_PROBE_BOX_PROJECTION时先做盒体投影。 - 开启探针混合(
_REFLECTION_PROBE_BLENDING):同时采样unity_SpecCube0和unity_SpecCube1,按物体在两个探针盒子里的位置和 Importance 计算权重;两个权重之和不足 1 的部分,用全局环境反射_GlossyEnvironmentCubeMap补齐。 - 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 的曲线和线性映射对同一个环境做粗糙度扫描。
// 手动版:全局环境反射(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); }

PerceptualRoughnessToMipmapLevel;下排:线性映射 r × 6。两排在两端相同,中间几列上排明显更模糊。如果你在自写 PBR 里随手用了线性映射,同一个粗糙度值的材质会比 URP Lit 显得更光滑——这是自写 Shader 与官方 Shader “对不上”的常见原因之一。与 URP 保持一致,就用它提供的函数。
案例 8.7自定义环境反射 Cubemap进阶单独打开 ↗
目标:不用天空盒,而是用自己生成的 Cubemap 作为环境反射(例如产品展示用的“摄影棚”环境、黄昏的天空),并让环境光、背景与反射保持一致。
在编辑器里的做法:导入一张 Cubemap(HDR 全景图在导入设置里选 Texture Shape = Cube),把 设为 Specular (Glossy Reflection),Unity 会为它生成按粗糙度预过滤的 mip 链;然后在 Lighting 窗口把 Environment Reflections 的 Source 改为 Custom 并指定它。在代码里,对应的是 RenderSettings.customReflectionTexture。
我们在 C# 里从一个环境函数直接生成 Cubemap,并自己完成预过滤:每一级 mip 先由 URP 的曲线反解出对应的粗糙度,再对每个纹素方向按 GGX 分布做重要性采样、按 N·L 加权平均(这正是 Split Sum 第一项的标准做法,假设 N = V = R):
/// 生成 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。

金属对环境的依赖远大于非金属:中图里镜面金属纽结几乎是全黑的,只有柔光箱的倒影——它没有漫反射,“周围有什么”就是它的全部外观。这也是为什么做产品展示、角色展示时,环境贴图的选择和直接光同等重要。中图金色球上略显斑驳的反射来自运行时预过滤的采样数有限(每纹素 160 个样本),正式资源应当使用离线工具或 Unity 导入器生成的高质量预过滤结果。
探针混合与盒体投影
反射探针(Reflection Probe)就是摆在场景里的一个“球形摄像机”,在它的位置拍一张 Cubemap。物体使用哪个探针,由物体与探针的影响盒(Box Size)的相交关系决定。两个探针重叠时,URP 的混合权重来自 CalculateProbeWeight:物体位置到盒子各个面的距离,除以 Blend Distance,取最小值——越靠近盒子中心权重越接近 1,越靠近边缘越接近 0。探针的 Importance 决定谁“优先”:重要性高的先拿权重,剩下的才给另一个;重要性相同时,体积较小的那个优先(更小的探针通常更“局部”,更准确)。
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。
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 的 控制的 _REFLECTION_PROBE_BOX_PROJECTION 关键字决定——两处都要打开。
案例 8.8盒体投影:让地板的倒影“贴”住墙进阶单独打开 ↗
目标:搭一个 6 × 3 × 6 米的房间,墙上画网格和一条白色腰线,地板是半光滑的反射面,对比开关盒体投影的效果。
为了让对比严格可控,本案例没有用场景里的 ReflectionProbe 组件,而是把“探针”的三个参数(Cubemap、中心位置、盒子范围)直接放在材质上,Shader 里调用的仍然是上面那个 URP 函数 BoxProjectedCubemapDirection。房间中心的 Cubemap 用解析方式生成:从中心沿每个方向求与房间盒子的交点,按交点处的墙面图案着色——墙面 Shader 和 Cubemap 生成器用的是逐行对应的同一个图案函数(RoomPattern.hlsl 与 BoxProjectionDemo.cs),所以 Cubemap 里的墙与场景中的墙严格一致,倒影的任何错位都只能来自投影方式本身。
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); }

盒体投影只在“环境近似是一个盒子”时准确。房间中间放着的家具、柱子不在盒子的面上,它们的倒影仍然会错位;圆形或不规则的空间也只能近似。另外,盒体投影要求探针的盒子与房间的墙对齐,盒子大了倒影会“缩”、小了会“胀”。在室外开阔场景中,应关掉探针的 Box Projection,因为环境确实接近无限远。
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,并能拆分显示各个分量。完整代码如下(较长,后面分段讲解):
// 一个“全局光照就绪”的自写 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 网页版用不到,没有移植 } }
关键字
// 材质开关 #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 章会讲怎样剥离不需要的变体。
插值器与顶点
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; }
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 会根据关键字二选一。
片元:五步走
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); }
- 间接漫反射:
SAMPLE_GI得到 bakedGI,来自光照贴图或 SH,无需关心是哪一个。 - 主光:
SAMPLE_SHADOWMASK取得烘焙阴影信息,传给GetMainLight,由它混合实时阴影与 Shadowmask。 - AO:
CreateAmbientOcclusionFactor合并材质 AO 与 SSAO(详见 8.7 节);开启 SSAO 时按 Direct Lighting Strength 衰减主光。 - Subtractive 修正:
MixRealtimeAndBakedGI,只在 Subtractive 模式下生效。 - 间接高光与组合:
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 时会再展开。


_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,每帧根据深度与法线纹理估算遮挡,能表现物体之间的遮挡(接触阴影),但只看得到屏幕上可见的几何,有噪声和屏幕边缘问题。
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 中 即可。

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

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