GameTech/URP 渲染实战

案例 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)。