案例 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)。
本案例出自《URP 渲染实战》第 8 章「环境光、光照贴图与反射探针」。在章节中阅读上下文 →