GameTech/URP 渲染实战
CHAPTER 12第 12 章

PBR 进阶材质

金属度/粗糙度模型能覆盖大多数表面,但车漆的两层反光、拉丝金属的长条高光、头发的双高光、丝绒的边缘泛光、皮肤的透红、油膜的彩虹色,都不在它的能力范围里。本章逐个拆开这些材质:先弄清楚标准模型在哪个假设上失效,再把对应的那一项 BRDF 换掉。

本章你会学到
标准微表面模型的五个隐含假设,以及每种进阶材质破坏了哪一个
双层清漆的能量分配、白炉测试,并与 URP Complex Lit 逐项对照
各向异性 GGX、Kajiya-Kay 头发、Charlie 布料 sheen 的公式与实现
Wrap、预积分皮肤 LUT、薄片透射与薄膜干涉的原理和取舍

12.1 标准模型的边界在哪里

第 9~11 章搭起来的“金属度/粗糙度”模型,本质是一个单层微表面 BRDF:漫反射用 Lambert,镜面用 GGX 法线分布 D、Smith 可见性 V 和 Schlick 菲涅尔 F。它能这么通用,是因为背后有几条很强、却很少被明说的假设。进阶材质之所以“进阶”,就是因为它们各自破坏了其中某一条。先把这几条摆出来,后面每一节就有了明确的目标。

  1. 只有一层界面。光在表面上反射一次(镜面),或者钻进去被吸收、再从入射点附近散射出来(漫反射)。车漆、清漆木器、碳纤维这类“透明涂层 + 底材”的结构有两层界面,会出现两层叠在一起的高光。
  2. 微表面法线的分布是各向同性的。GGX 的 D 只依赖 N·H,绕法线旋转不变。拉丝金属、CD 光盘、头发、缎面都有明确的纹理方向,高光会被拉成长条。
  3. 表面是一张“高度场”。微表面理论把表面想象成起伏的薄膜,法线大体朝上。丝绒、毛毡上竖起来的纤维不是高度场,它们在掠射角的反光(sheen)正是标准模型最缺的那部分。
  4. 漫反射在入射点出射。Lambert 假设光进去后“原地”散射出来。皮肤、蜡、玉石、树叶里的光会横向走上几毫米,甚至穿透整个薄片,明暗交界会变软、带上颜色,背光处会透亮。
  5. 光是不相干的。菲涅尔反射率只依赖角度和折射率,与波长无关(或者只是简单地给 F0 一个颜色)。当表面盖着一层厚度与可见光波长相当的薄膜时,两束反射光会干涉,反射率随波长剧烈变化,于是出现油膜、肥皂泡上的彩虹色。
材质破坏的假设本章的做法主要改动的项
车漆、清漆单层界面第二层 GGX + 能量衰减BRDF 叠加
拉丝金属各向同性各向异性 GGX(αt ≠ αb)D、V
头发各向同性 + 高度场Kajiya-Kay 双高光镜面项整体替换
丝绒、布料高度场Charlie sheen + Neubelt 可见性D、V、F
皮肤、蜡入射点出射Wrap、预积分 LUT漫反射项
树叶、纸张入射点出射(极端情况:穿透)快速透射近似新增透射项
油膜、肥皂泡不相干Airy 干涉 + 光谱积分F

表里最后一列很关键:几乎每种材质都只动了 BRDF 里的一两项,其余部分(光源循环、阴影、环境光采样、各种 Pass)完全一样。所以本章先花一节把这些公共部分做成框架,后面每个案例只写“不一样的那几行”。

和 URP 自带材质的关系URP 14 只内置了其中一种:Complex Lit 的 Clear Coat(清漆)。各向异性、头发、布料、次表面散射、薄膜干涉在 HDRP 的 Lit/Hair/Fabric/StackLit 里才有。本章的实现全部手写,读完你会知道这些“高级开关”背后到底算了什么,也能按项目需要裁剪。

案例 12.1本章的材质框架与基准 GGX进阶单独打开 ↗

目标:写一个最小但完整的金属度工作流 Shader 作为“基准材质”,并把光源循环、顶点处理等公共代码拆成可复用的 include。它要和 URP Lit 放在一起对比,确认数值基本一致——后面所有进阶材质都以它为起点,如果基准就偏了,后面的对比都没有意义。

文件组织

本章代码放在 Assets/Cookbook/Ch12/,分四层:

  • AdvBRDF.hlsl:纯数学,只有各种 D、V、F 和辅助函数,不依赖任何 URP 结构体。函数名统一加 Adv 前缀——URP 的 Lighting.hlsl 会间接包含 core 包的 BSDF.hlsl,那里已经有 D_GGX、F_Schlick、D_Charlie 等同名函数,重名会直接编译失败。
  • Ch12Common.hlsl:顶点输入输出结构和顶点着色器,以及片元里重建 TBN 的小函数。
  • LightLoop.hlsl:主光 + 附加光的循环,兼容 Forward 与 Forward+。
  • 各个材质 Shader:只定义材质参数、MaterialCtx 结构体和 ShadeOneLight 函数。
材质 Shader struct MaterialCtx ShadeOneLight() frag():填 ctx、加间接光 LightLoop.hlsl AccumulateDirectLighting() 主光+阴影 / 附加光 / Forward+ AdvBRDF.hlsl D / V / F、KK、Charlie 薄膜、环境 BRDF 近似 URP ShaderLibrary GetMainLight GetAdditionalLight LIGHT_LOOP_BEGIN SampleSH GlossyEnvironment-Reflection 调用回调
图 12-1本章代码的分层:材质 Shader 只写“单个光源怎么着色”,光源循环与 BRDF 数学分别放在两个公共 include 里

“模板式 include”:让光源循环回调材质函数

HLSL 没有函数指针,也没有虚函数,怎么让一份光源循环代码去调用每个材质各自的着色函数?办法很朴素:HLSL 的 include 是纯文本展开,只要在 #include "LightLoop.hlsl" 之前把 MaterialCtx 结构体和 ShadeOneLight 函数定义好,展开后的循环代码就能直接引用它们。这和 C++ 模板“按名字查找”的效果一样,URP 自己也大量使用这种写法(例如各个 Pass 的 hlsl 都假定调用方已经声明了 _BaseMap)。

Assets/Cookbook/Ch12/LightLoop.hlsl(节选:loop)
half3 AccumulateDirectLighting(MaterialCtx m, float3 positionWS, float2 screenUV)
{
    half4 shadowMask = half4(1, 1, 1, 1);   // 本章不涉及 Shadowmask 烘焙,全 1 即可
    float4 shadowCoord = TransformWorldToShadowCoord(positionWS);
    Light mainLight = GetMainLight(shadowCoord, positionWS, shadowMask);
    half3 color = ShadeOneLight(m, mainLight);

#if defined(_ADDITIONAL_LIGHTS)
    // Forward+ 的 LIGHT_LOOP_BEGIN 宏内部直接引用名为 inputData 的变量
    InputData inputData = (InputData)0;
    inputData.positionWS = positionWS;
    inputData.normalizedScreenSpaceUV = screenUV;

  #if USE_FORWARD_PLUS
    // Forward+ 把“额外的方向光”单独放在列表最前面,不参与分簇
    for (uint li = 0; li < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); li++)
    {
        color += ShadeOneLight(m, GetAdditionalLight(li, positionWS, shadowMask));
    }
  #endif

    uint pixelLightCount = GetAdditionalLightsCount();
    LIGHT_LOOP_BEGIN(pixelLightCount)
        color += ShadeOneLight(m, GetAdditionalLight(lightIndex, positionWS, shadowMask));
    LIGHT_LOOP_END
#endif
    return color;
}
vec3 AccumulateDirectLighting(MaterialCtx m, vec3 positionWS, vec2 screenUV)
{
    vec4 shadowMask = vec4(1.0);   // 本章不涉及 Shadowmask 烘焙,全 1 即可
    vec4 shadowCoord = TransformWorldToShadowCoord(positionWS);
    Light mainLight = GetMainLight(shadowCoord, positionWS, shadowMask);
    vec3 color = ShadeOneLight(m, mainLight);

#if defined(_ADDITIONAL_LIGHTS)
    int pixelLightCount = GetAdditionalLightsCount();
    for (int lightIndex = 0; lightIndex < pixelLightCount; lightIndex++)
    {
        color += ShadeOneLight(m, GetAdditionalLight(lightIndex, positionWS, shadowMask));
    }
#endif
    return color;
}

这段循环有三个容易踩的坑:

  • Forward+ 的宏要求一个名叫 inputData 的变量。URP 14 的 LIGHT_LOOP_BEGIN 在 Forward+ 下展开成分簇遍历,内部直接写死了 inputData.normalizedScreenSpaceUV 和 inputData.positionWS。我们不用 URP 的 InputData 做别的事,但还是得声明一个同名变量填上这两个字段。
  • Forward+ 下额外的方向光不在簇里。它们排在附加光列表的最前面,数量是 URP_FP_DIRECTIONAL_LIGHTS_COUNT,要单独循环一遍,和 URP Lighting.hlsl 里 UniversalFragmentPBR 的写法一致。
  • 不用自己判断 _FORWARD_PLUS 关键字。Core.hlsl 开头会在 _FORWARD_PLUS 打开时顺手定义 _ADDITIONAL_LIGHTS 并把 USE_FORWARD_PLUS 设为 1,所以只需检查 _ADDITIONAL_LIGHTS。

公共顶点着色器

Assets/Cookbook/Ch12/Ch12Common.hlsl(节选:vertex)
struct Attributes
{
    float4 positionOS : POSITION;
    float3 normalOS   : NORMAL;
    float4 tangentOS  : TANGENT;
    float2 uv         : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct Varyings
{
    float4 positionCS : SV_POSITION;
    float2 uv         : TEXCOORD0;
    float3 positionWS : TEXCOORD1;
    float3 normalWS   : TEXCOORD2;
    float4 tangentWS  : TEXCOORD3;   // w = 副切线符号(镜像 UV 时为 -1)
    float  fogFactor  : TEXCOORD4;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

Varyings Ch12Vert(Attributes input)
{
    Varyings o = (Varyings)0;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, o);
    VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz);
    VertexNormalInputs nrm = GetVertexNormalInputs(input.normalOS, input.tangentOS);
    o.positionCS = pos.positionCS;
    o.positionWS = pos.positionWS;
    o.normalWS = nrm.normalWS;
    o.tangentWS = float4(nrm.tangentWS, input.tangentOS.w * GetOddNegativeScale());
    o.uv = input.uv;
    o.fogFactor = ComputeFogFactor(pos.positionCS.z);
    return o;
}

// 片元里重建正交的 T/B/N(插值后会失去正交,Gram-Schmidt 修一下)
void Ch12BuildFrame(Varyings i, out float3 T, out float3 B, out float3 N)
{
    N = normalize(i.normalWS);
    T = normalize(i.tangentWS.xyz - N * dot(i.tangentWS.xyz, N));
    B = cross(N, T) * i.tangentWS.w;
}
VARYING vec2  uv;
VARYING vec3  positionWS;
VARYING vec3  normalWS;
VARYING vec4  tangentWS;   // w = 副切线符号(镜像 UV 时为 -1)
VARYING float fogFactor;

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

// 片元里重建正交的 T/B/N(插值后会失去正交,Gram-Schmidt 修一下)
void Ch12BuildFrame(out vec3 T, out vec3 B, out vec3 N)
{
    N = normalize(normalWS);
    T = normalize(tangentWS.xyz - N * dot(tangentWS.xyz, N));
    B = cross(N, T) * tangentWS.w;
}

各向异性和头发都依赖切线,所以顶点里把 tangentOS.w(副切线方向的符号,镜像 UV 时为 −1)也传下来,并乘上 GetOddNegativeScale():物体缩放为负时,副切线要再翻一次。片元里插值后的 T、N 已经不再正交,用一次 Gram-Schmidt 修正再叉乘出 B,这一步对拉丝金属这种“高光形状完全由 T 决定”的材质尤其重要。

基准材质

先看 BRDF 本身。下面这几行就是第 9 章公式的直接翻译,AdvV_SmithGGX 是高度相关的 Smith 可见性项,已经约掉了 4N·LN·V 的分母:

Assets/Cookbook/Ch12/AdvBRDF.hlsl(节选:iso)
// ---------- 各向同性 GGX ----------
// D:Trowbridge-Reitz / GGX 法线分布
float AdvD_GGX(float NoH, float a)
{
    float a2 = a * a;
    float d = NoH * NoH * (a2 - 1.0) + 1.0;
    return a2 / (ADV_PI * d * d + 1e-7);
}

// V:高度相关 Smith 可见性项,V = G / (4 NoL NoV),分母已经约掉
float AdvV_SmithGGX(float NoV, float NoL, float a)
{
    float a2 = a * a;
    float lambdaV = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
    float lambdaL = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
    return 0.5 / (lambdaV + lambdaL + 1e-5);
}

// F:Schlick 近似
float3 AdvF_Schlick(float3 f0, float f90, float VoH)
{
    return f0 + (f90 - f0) * AdvPow5(1.0 - VoH);
}
float AdvF_Schlick(float f0, float f90, float VoH)
{
    return f0 + (f90 - f0) * AdvPow5(1.0 - VoH);
}

// 标准金属度工作流的镜面项:D·V·F(返回值还要乘 NoL 和光强)
float3 AdvSpecularGGX(float3 N, float3 V, float3 L, float a, float3 f0)
{
    float3 H = normalize(V + L);
    float NoV = max(dot(N, V), 1e-4);
    float NoL = saturate(dot(N, L));
    float NoH = saturate(dot(N, H));
    float VoH = saturate(dot(V, H));
    return AdvD_GGX(NoH, a) * AdvV_SmithGGX(NoV, NoL, a) * AdvF_Schlick(f0, 1.0, VoH);
}
// ---------- 各向同性 GGX ----------
// D:Trowbridge-Reitz / GGX 法线分布
float AdvD_GGX(float NoH, float a)
{
    float a2 = a * a;
    float d = NoH * NoH * (a2 - 1.0) + 1.0;
    return a2 / (ADV_PI * d * d + 1e-7);
}

// V:高度相关 Smith 可见性项,V = G / (4 NoL NoV),分母已经约掉
float AdvV_SmithGGX(float NoV, float NoL, float a)
{
    float a2 = a * a;
    float lambdaV = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
    float lambdaL = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
    return 0.5 / (lambdaV + lambdaL + 1e-5);
}

// F:Schlick 近似
vec3 AdvF_Schlick(vec3 f0, float f90, float VoH)
{
    return f0 + (f90 - f0) * AdvPow5(1.0 - VoH);
}
float AdvF_Schlick(float f0, float f90, float VoH)
{
    return f0 + (f90 - f0) * AdvPow5(1.0 - VoH);
}

// 标准金属度工作流的镜面项:D·V·F(返回值还要乘 NoL 和光强)
vec3 AdvSpecularGGX(vec3 N, vec3 V, vec3 L, float a, vec3 f0)
{
    vec3 H = normalize(V + L);
    float NoV = max(dot(N, V), 1e-4);
    float NoL = saturate(dot(N, L));
    float NoH = saturate(dot(N, H));
    float VoH = saturate(dot(V, H));
    return AdvD_GGX(NoH, a) * AdvV_SmithGGX(NoV, NoL, a) * AdvF_Schlick(f0, 1.0, VoH);
}
// HLSL 里标量 f0 会自动扩展成 float3,GLSL 要显式重载
vec3 AdvSpecularGGX(vec3 N, vec3 V, vec3 L, float a, float f0) { return AdvSpecularGGX(N, V, L, a, vec3(f0)); }

材质 Shader 的着色函数:

Assets/Cookbook/Ch12/BaseGGX.shader(节选:shade)
struct MaterialCtx
{
    float3 N, V;
    float3 diffuse;   // 漫反射颜色(已扣掉金属部分)
    float3 f0;        // 法向入射反射率
    float  a;         // α = 感知粗糙度²
};

// URP 的灯光约定:color 已经是“除过 π 的辐照度”,所以 BRDF 要整体乘 π
half3 ShadeOneLight(MaterialCtx m, Light light)
{
    float NoL = saturate(dot(m.N, light.direction));
    float3 brdf = m.diffuse * ADV_INV_PI + AdvSpecularGGX(m.N, m.V, light.direction, m.a, m.f0);
    return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation);
}
struct MaterialCtx
{
    vec3 N, V;
    vec3 diffuse;   // 漫反射颜色(已扣掉金属部分)
    vec3 f0;        // 法向入射反射率
    float a;        // α = 感知粗糙度²
};

// URP 的灯光约定:color 已经是“除过 π 的辐照度”,所以 BRDF 要整体乘 π
vec3 ShadeOneLight(MaterialCtx m, Light light)
{
    float NoL = saturate(dot(m.N, light.direction));
    vec3 brdf = m.diffuse * ADV_INV_PI + AdvSpecularGGX(m.N, m.V, light.direction, m.a, m.f0);
    return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation);
}

注意最后乘的那个 PI。物理上,点光源或方向光产生的出射辐射率是 Lo=f·E⊥·N·L,其中 E⊥ 是垂直入射时的辐照度。Unity 的约定是:灯光的“颜色 × 强度”等于一块白色 Lambert 表面正对光源时的输出值,也就是 E⊥/π。URP 的 LightingPhysicallyBased 把这个 π 预先吸收进了 BRDF(漫反射项直接用 brdfData.diffuse,没有除 π;镜面项的 normalizationTerm 也相应放大了 π 倍)。我们的 BRDF 是严格按物理写的(Lambert 带 1/π),所以要把灯光值乘回 π,两边才能对上。

片元函数负责填写 MaterialCtx、调用光源循环,再加上间接光:

Assets/Cookbook/Ch12/BaseGGX.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    float3 T, B, N; Ch12BuildFrame(i, T, B, N);
    float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);

    half4 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, TRANSFORM_TEX(i.uv, _BaseMap)) * _BaseColor;
    float perceptualRoughness = 1.0 - _Smoothness;

    MaterialCtx m;
    m.N = N; m.V = V;
    m.f0 = lerp(0.04, albedo.rgb, _Metallic);
    m.diffuse = albedo.rgb * (1.0 - _Metallic) * 0.96;
    m.a = max(perceptualRoughness * perceptualRoughness, 0.002);

    half3 color = AccumulateDirectLighting(m, i.positionWS, screenUV);

    // 间接光:漫反射用球谐,镜面用反射探针 + 环境 BRDF 近似
    float NoV = saturate(dot(N, V));
    float3 R = reflect(-V, N);
    float2 AB = AdvEnvBRDFApprox(perceptualRoughness, NoV);
    color += SampleSH(N) * m.diffuse;
    color += GlossyEnvironmentReflection(R, i.positionWS, perceptualRoughness, 1.0, screenUV) * (m.f0 * AB.x + AB.y);

    color = MixFog(color, i.fogFactor);
    return half4(color, 1);
}
void main()
{
    vec3 T, B, N; Ch12BuildFrame(T, B, N);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);

    vec4 albedo = texture(_BaseMap, TransformTex(uv, _BaseMap_ST)) * _BaseColor;
    float perceptualRoughness = 1.0 - _Smoothness;

    MaterialCtx m;
    m.N = N; m.V = V;
    m.f0 = mix(vec3(0.04), albedo.rgb, _Metallic);
    m.diffuse = albedo.rgb * (1.0 - _Metallic) * 0.96;
    m.a = max(perceptualRoughness * perceptualRoughness, 0.002);

    vec3 color = AccumulateDirectLighting(m, positionWS, screenUV);

    // 间接光:漫反射用球谐,镜面用反射探针 + 环境 BRDF 近似
    float NoV = saturate(dot(N, V));
    vec3 R = reflect(-V, N);
    vec2 AB = AdvEnvBRDFApprox(perceptualRoughness, NoV);
    color += SampleSH(N) * m.diffuse;
    color += GlossyEnvironmentReflection(R, positionWS, perceptualRoughness, 1.0) * (m.f0 * AB.x + AB.y);

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

间接光的镜面部分用的是 split-sum 近似:GlossyEnvironmentReflection 按粗糙度从反射探针的 mip 链里取预滤波的环境光(第 8 章),再乘环境 BRDF 的积分值 F0A+B。A、B 本来要查一张预积分 LUT,这里用 Karis 在 2014 年给出的移动端解析拟合,几条乘加就够:

Assets/Cookbook/Ch12/AdvBRDF.hlsl(节选:envbrdf)
// 环境 BRDF 的解析近似(Karis 2014,移动端常用):返回 F0*A + B 里的 (A, B)
// 用感知粗糙度做输入,拟合的是 split-sum 第二项的预积分 LUT
float2 AdvEnvBRDFApprox(float perceptualRoughness, float NoV)
{
    const float4 c0 = float4(-1.0, -0.0275, -0.572, 0.022);
    const float4 c1 = float4( 1.0,  0.0425,  1.040, -0.040);
    float4 r = perceptualRoughness * c0 + c1;
    float a004 = min(r.x * r.x, exp2(-9.28 * NoV)) * r.x + r.y;
    return float2(-1.04, 1.04) * a004 + r.zw;
}
// 环境 BRDF 的解析近似(Karis 2014,移动端常用):返回 F0*A + B 里的 (A, B)
// 用感知粗糙度做输入,拟合的是 split-sum 第二项的预积分 LUT
vec2 AdvEnvBRDFApprox(float perceptualRoughness, float NoV)
{
    const vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022);
    const vec4 c1 = vec4( 1.0,  0.0425,  1.040, -0.040);
    vec4 r = perceptualRoughness * c0 + c1;
    float a004 = min(r.x * r.x, exp2(-9.28 * NoV)) * r.x + r.y;
    return vec2(-1.04, 1.04) * a004 + r.zw;
}

Pass 部分(ShadowCaster、DepthOnly、DepthNormals)直接 include URP 自带的 hlsl。这几个文件只在 _ALPHATEST_ON 打开时才会读材质的 _BaseMap,其余与材质无关,可以放心复用。有一个小坑:URP 的 Shadows.hlsl 用到了 CommonMaterial.hlsl 里的 LerpWhiteTo,Lit 是经由 LitInput.hlsl 间接包含的;我们自己的 Shader 没有包含它,ShadowCaster 和 DepthNormals 两个 Pass 会报“undeclared identifier 'LerpWhiteTo'”。解决办法是在 HLSLINCLUDE 里补一行 include:

Assets/Cookbook/Ch12/BaseGGX.shader(节选:passes)
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On ZTest LEqual ColorMask 0 Cull Back
    HLSLPROGRAM
    #pragma vertex ShadowPassVertex
    #pragma fragment ShadowPassFragment
    #pragma multi_compile_instancing
    #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
    // URP 自带的阴影 Pass 只在 _ALPHATEST_ON 时才读 _BaseMap,其余部分与材质无关,直接复用
    #include "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl"
    ENDHLSL
}
Pass
{
    Name "DepthOnly"
    Tags { "LightMode"="DepthOnly" }
    ZWrite On ColorMask R Cull Back
    HLSLPROGRAM
    #pragma vertex DepthOnlyVertex
    #pragma fragment DepthOnlyFragment
    #pragma multi_compile_instancing
    #include "Packages/com.unity.render-pipelines.universal/Shaders/DepthOnlyPass.hlsl"
    ENDHLSL
}
Pass
{
    Name "DepthNormals"
    Tags { "LightMode"="DepthNormals" }
    ZWrite On Cull Back
    HLSLPROGRAM
    #pragma vertex DepthNormalsVertex
    #pragma fragment DepthNormalsFragment
    #pragma multi_compile_instancing
    #include "Packages/com.unity.render-pipelines.universal/Shaders/DepthNormalsPass.hlsl"
    ENDHLSL
}
// 投影 / 深度 / 深度法线:HLSL 版直接复用 URP 自带的 Pass,网页版用公共的同功能 Pass
UsePass "Cookbook/Common/SHADOWCASTER"
UsePass "Cookbook/Common/DEPTHONLY"
UsePass "Cookbook/Common/DEPTHNORMALS"
图 12-2基准材质与 URP Lit 对照。左:URP Lit;右:本章的 BaseGGX。上排金属(金),下排电介质(红),光滑度从左到右 0.1 → 0.95

两边的直接光高光大小、形状一致;差别主要在粗糙金属的边缘:URP 的 EnvironmentBRDFSpecular 用的是另一套更简化的近似(surfaceReduction 加 grazingTerm),在粗糙表面的掠射角给出的反射更亮。两种近似都不是“标准答案”,只要知道差异来自哪里即可。到这一步,框架就可以用了。

LightMode 用 UniversalForwardOnly本章所有着色 Pass 的 LightMode 都写成 UniversalForwardOnly 而不是 UniversalForward。两者在 Forward / Forward+ 下完全一样;区别在 Deferred 渲染路径:URP 会认为“有 UniversalForward 的 Shader 应该同时提供 UniversalGBuffer Pass”,只有前者而没有 GBuffer Pass 的不透明物体在 Deferred 下根本不会被绘制。我们的自定义 BRDF 无法写进 URP 的 G-Buffer,所以明确声明“只走 Forward”,URP 会在 Deferred 的 G-Buffer 阶段之后单独用 Forward 方式画它们(Complex Lit 也是这么做的)。
提示本章所有 Shader 的 Forward Pass 都使用同一组关键字(主光阴影、附加光、软阴影、反射探针混合、Forward+、雾、实例化),列表照抄自 URP 14 的 Lit.shader,只删掉了本章用不到的 Lightmap、Decal、SSAO 等项。真要上线时,按项目实际开启的功能把它们补回来。

12.2 清漆:两层界面的能量分配

汽车面漆的典型结构是:底层是带颜色的色漆,常常还掺了铝片或云母片(金属漆、珠光漆);上面盖一层几十微米厚的透明清漆。从远处看,你会同时看到两种高光:一个又小又亮、边缘锐利,来自平整的清漆层;另一个更大更柔、带颜色,来自底层粗糙的色漆和金属片。单层 GGX 无论怎么调粗糙度,都只能给出其中之一。

两层 BRDF 怎么叠

光打到清漆表面时,按菲涅尔定律分成两份:比例为 Fc 的光直接被清漆反射,剩下 1−Fc 折射进去,照到底层。底层反射回来的光,出来时还要再穿过一次清漆界面。如果忽略清漆内部的多次反射和吸收,最常用的近似是:

ftotal=fbase·(1−cFc)+c·fcoat,fcoat=DcVcFc

其中 c 是清漆遮罩(0 = 没有清漆),Fc 是 F0 = 0.04 的 Schlick 菲涅尔——0.04 对应折射率 1.5,是大多数透明漆、树脂的典型值。第一项的 (1−cFc) 就是“能量衰减”:被清漆反射走的那部分光,不能再算一次到底层头上。严格来说光进出各穿过一次界面,应该乘两次透射率;但实时渲染里通常只乘一次,并用视线方向的 N·V 代替 V·H 计算这个 Fc:这样它与光源无关,直接光和环境光可以共用同一个衰减系数。Khronos 的 glTF 扩展 KHR_materials_clearcoat 给出的推荐公式就是这种形式,URP 也照此实现。

清漆层(IOR≈1.5,F0=0.04,很光滑) 底层色漆 + 金属片(粗糙) 入射光 Fc:清漆的锐利高光 1−Fc 底层:更宽、带颜色的高光 底层的光要先穿过清漆、再穿出来;被清漆反射掉的部分不再参与底层计算——这就是“能量衰减”
图 12-3清漆的两层光路:清漆反射一次,底层在折射进去的那部分光上再做一次完整的 BRDF

清漆对底层的两个“副作用”

严格一点的实现还会考虑底层被清漆“泡”着的事实:

  • 底层的高光变宽。光从清漆(n = 1.5)进入再出来,出射方向的角度变化会被折射放大,底层的高光波瓣在空气里看起来更宽。URP 的做法是把底层粗糙度 α 先换成等效的 Blinn-Phong 指数 n=2/α2−2,乘以 (1/1.5)2,再换回 α。指数变小,α 变大,高光就变宽了。
  • 底层的 F0 变小、颜色更饱和。底层原本的 F0 是“相对空气”测得的;现在它的上方是折射率 1.5 的漆,界面两侧的折射率差变小,反射率随之下降。core 包里用一个二次多项式 ConvertF0ForAirInterfaceToF0ForClearCoat15Fast 做这个换算。

URP 14 的实现(摘自 ShaderLibrary/BRDF.hlsl 的 InitializeBRDFDataClearCoat):

// Modify Roughness of base layer using coat IOR
half ieta                        = lerp(1.0h, CLEAR_COAT_IETA, clearCoatMask);
half coatRoughnessScale          = Sq(ieta);
half sigma                       = RoughnessToVariance(PerceptualRoughnessToRoughness(baseBRDFData.perceptualRoughness));
baseBRDFData.perceptualRoughness = RoughnessToPerceptualRoughness(VarianceToRoughness(sigma * coatRoughnessScale));
...
// Darken/saturate base layer using coat to surface reflectance (vs. air to surface)
baseBRDFData.specular = lerp(baseBRDFData.specular, ConvertF0ForClearCoat15(baseBRDFData.specular), clearCoatMask);

其中 CLEAR_COAT_IETA 定义在 core 包的 CommonMaterial.hlsl,等于 1/1.5;RoughnessToVariance 就是上面那个 2/α2−2。直接光的混合在 Lighting.hlsl 的 LightingPhysicallyBased 里:

half brdfCoat = kDielectricSpec.r * DirectBRDFSpecular(brdfDataClearCoat, normalWS, lightDirectionWS, viewDirectionWS);
half NoV = saturate(dot(normalWS, viewDirectionWS));
half coatFresnel = kDielectricSpec.x + kDielectricSpec.a * Pow4(1.0 - NoV);
brdf = brdf * (1.0 - clearCoatMask * coatFresnel) + brdfCoat * clearCoatMask;

可以看出 URP 做了两处简化:清漆镜面项里的 F 直接用常数 0.04(kDielectricSpec.r),不随角度变化;衰减用的菲涅尔是 Pow4 而不是 Pow5,注释说明是为了和环境光部分的 fresnelTerm 保持一致。

案例 12.2金属漆:清漆 + 程序化金属片进阶单独打开 ↗

目标:实现带能量衰减、能修正底层的清漆材质,底层加一层程序化的金属片(flake)让车漆“闪”起来;用白炉测试验证能量,并与 URP Complex Lit 对比。

材质上下文多了几项:底层用的扰动法线、两层各自的粗糙度、清漆遮罩和视线方向的清漆菲涅尔:

Assets/Cookbook/Ch12/ClearCoat.shader(节选:ctx)
struct MaterialCtx
{
    float3 N, V;
    float3 flakeN;      // 带金属片扰动的底层法线
    float3 diffuse, f0;
    float  aBase;       // 底层 α(可能已被清漆修正)
    float  aCoat;       // 清漆层 α
    float  coatMask;
    float  coatFv;      // 清漆在视线方向的菲涅尔,用来衰减底层
};
struct MaterialCtx
{
    vec3 N, V;
    vec3 flakeN;      // 带金属片扰动的底层法线
    vec3 diffuse, f0;
    float aBase;      // 底层 α(可能已被清漆修正)
    float aCoat;      // 清漆层 α
    float coatMask;
    float coatFv;     // 清漆在视线方向的菲涅尔,用来衰减底层
};

单个光源的着色,和公式一一对应。底层用扰动后的金属片法线算 GGX,清漆层用宏观法线(漆面是平的):

Assets/Cookbook/Ch12/ClearCoat.shader(节选:shade)
half3 ShadeOneLight(MaterialCtx m, Light light)
{
    float3 L = light.direction;
    float3 H = normalize(m.V + L);
    float NoL = saturate(dot(m.N, L));
    float NoV = max(dot(m.N, m.V), 1e-4);
    float NoH = saturate(dot(m.N, H));
    float VoH = saturate(dot(m.V, H));

    // 底层:漫反射 + 金属片法线下的 GGX
    float3 baseBRDF = m.diffuse * ADV_INV_PI + AdvSpecularGGX(m.flakeN, m.V, L, m.aBase, m.f0);

    // 清漆层:F0 = 0.04 的电介质 GGX,用宏观法线(漆面是平的)
    float  Fc = AdvF_Schlick(0.04, 1.0, VoH);
    float  coatSpec = AdvD_GGX(NoH, m.aCoat) * AdvV_SmithGGX(NoV, NoL, m.aCoat) * Fc;

    // 能量衰减:被清漆反射掉的那部分光不会再到达底层
    float attenuation = lerp(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
    float3 brdf = baseBRDF * attenuation + coatSpec * m.coatMask;

    return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation);
}
vec3 ShadeOneLight(MaterialCtx m, Light light)
{
    vec3 L = light.direction;
    vec3 H = normalize(m.V + L);
    float NoL = saturate(dot(m.N, L));
    float NoV = max(dot(m.N, m.V), 1e-4);
    float NoH = saturate(dot(m.N, H));
    float VoH = saturate(dot(m.V, H));

    // 底层:漫反射 + 金属片法线下的 GGX
    vec3 baseBRDF = m.diffuse * ADV_INV_PI + AdvSpecularGGX(m.flakeN, m.V, L, m.aBase, m.f0);

    // 清漆层:F0 = 0.04 的电介质 GGX,用宏观法线(漆面是平的)
    float Fc = AdvF_Schlick(0.04, 1.0, VoH);
    float coatSpec = AdvD_GGX(NoH, m.aCoat) * AdvV_SmithGGX(NoV, NoL, m.aCoat) * Fc;

    // 能量衰减:被清漆反射掉的那部分光不会再到达底层
    float attenuation = mix(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
    vec3 brdf = baseBRDF * attenuation + coatSpec * m.coatMask;

    return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation);
}

金属片的做法很简单:把 UV 切成很小的格子,每格用哈希生成一个随机方向,把法线朝这个方向歪一点。哈希函数用的是常见的 frac 系列,不需要任何贴图:

Assets/Cookbook/Ch12/ClearCoat.shader(节选:flake)
// 程序化金属片:把 UV 切成很小的格子,每格一个随机倾斜的法线
float3 Hash33(float3 p)
{
    p = frac(p * float3(0.1031, 0.1030, 0.0973));
    p += dot(p, p.yxz + 33.33);
    return frac((p.xxy + p.yxx) * p.zyx);
}
float3 FlakeNormal(float2 uv, float3 T, float3 B, float3 N)
{
    float2 cell = floor(uv * _FlakeDensity);
    float3 r = Hash33(float3(cell, 7.0)) * 2.0 - 1.0;
    return normalize(N + (r.x * T + r.y * B) * _FlakeStrength);
}
// 程序化金属片:把 UV 切成很小的格子,每格一个随机倾斜的法线
vec3 Hash33(vec3 p)
{
    p = fract(p * vec3(0.1031, 0.1030, 0.0973));
    p += dot(p, p.yxz + 33.33);
    return fract((p.xxy + p.yxx) * p.zyx);
}
vec3 FlakeNormal(vec2 uv, vec3 T, vec3 B, vec3 N)
{
    vec2 cell = floor(uv * _FlakeDensity);
    vec3 r = Hash33(vec3(cell, 7.0)) * 2.0 - 1.0;
    return normalize(N + (r.x * T + r.y * B) * _FlakeStrength);
}

片元函数里先按开关修正底层的粗糙度和 F0,然后环境光部分各采一次反射探针——清漆用自己的粗糙度和宏观法线,底层用修正后的粗糙度和金属片法线:

Assets/Cookbook/Ch12/ClearCoat.shader(节选:frag)
half4 frag(Varyings i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    float3 T, B, N; Ch12BuildFrame(i, T, B, N);
    float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);
    float NoV = saturate(dot(N, V));

    MaterialCtx m;
    m.N = N; m.V = V;
    m.flakeN = FlakeNormal(i.uv, T, B, N);
    m.coatMask = _CoatMask;
    m.f0 = lerp(0.04, _BaseColor.rgb, _Metallic);
    m.diffuse = _BaseColor.rgb * (1.0 - _Metallic) * 0.96;

    float prBase = 1.0 - _Smoothness;
    float aBase = max(prBase * prBase, 0.002);
    if (_CoatAffectBase > 0.5)
    {
        // ① 折射让底层波瓣变宽:α → 等效 Blinn-Phong 指数,乘 (1/1.5)²,再换回来(与 URP 的做法相同)
        float ieta = lerp(1.0, 1.0 / 1.5, m.coatMask);
        float n = 2.0 / (aBase * aBase) - 2.0;
        aBase = sqrt(2.0 / (n * ieta * ieta + 2.0));
        // ② 底层 F0 原本相对空气,现在相对 IOR=1.5 的漆:数值会变小、颜色更饱和
        float3 f0Coat = saturate(m.f0 * (m.f0 * 0.526868 + 0.529324) - 0.0482256);
        m.f0 = lerp(m.f0, f0Coat, m.coatMask);
    }
    m.aBase = aBase;
    float prCoat = 1.0 - _CoatSmoothness;
    m.aCoat = max(prCoat * prCoat, 0.002);
    m.coatFv = AdvF_Schlick(0.04, 1.0, NoV);

    half3 color = AccumulateDirectLighting(m, i.positionWS, screenUV);

    // 间接光:底层用(修正后的)底层粗糙度,清漆用自己的粗糙度各采一次反射
    float prBaseEff = sqrt(m.aBase);
    float2 AB = AdvEnvBRDFApprox(prBaseEff, saturate(dot(m.flakeN, V)));
    float3 baseIndirect = SampleSH(N) * m.diffuse
        + GlossyEnvironmentReflection(reflect(-V, m.flakeN), i.positionWS, prBaseEff, 1.0, screenUV) * (m.f0 * AB.x + AB.y);
    float2 ABc = AdvEnvBRDFApprox(prCoat, NoV);
    float3 coatIndirect = GlossyEnvironmentReflection(reflect(-V, N), i.positionWS, prCoat, 1.0, screenUV) * (0.04 * ABc.x + ABc.y);

    float attenuation = lerp(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
    color += baseIndirect * attenuation + coatIndirect * m.coatMask;

    color = MixFog(color, i.fogFactor);
    return half4(color, 1);
}
void main()
{
    vec3 T, B, N; Ch12BuildFrame(T, B, N);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
    float NoV = saturate(dot(N, V));

    MaterialCtx m;
    m.N = N; m.V = V;
    m.flakeN = FlakeNormal(uv, T, B, N);
    m.coatMask = _CoatMask;
    m.f0 = mix(vec3(0.04), _BaseColor.rgb, _Metallic);
    m.diffuse = _BaseColor.rgb * (1.0 - _Metallic) * 0.96;

    float prBase = 1.0 - _Smoothness;
    float aBase = max(prBase * prBase, 0.002);
    if (_CoatAffectBase > 0.5)
    {
        // ① 折射让底层波瓣变宽:α → 等效 Blinn-Phong 指数,乘 (1/1.5)²,再换回来(与 URP 的做法相同)
        float ieta = mix(1.0, 1.0 / 1.5, m.coatMask);
        float n = 2.0 / (aBase * aBase) - 2.0;
        aBase = sqrt(2.0 / (n * ieta * ieta + 2.0));
        // ② 底层 F0 原本相对空气,现在相对 IOR=1.5 的漆:数值会变小、颜色更饱和
        vec3 f0Coat = saturate(m.f0 * (m.f0 * 0.526868 + 0.529324) - 0.0482256);
        m.f0 = mix(m.f0, f0Coat, m.coatMask);
    }
    m.aBase = aBase;
    float prCoat = 1.0 - _CoatSmoothness;
    m.aCoat = max(prCoat * prCoat, 0.002);
    m.coatFv = AdvF_Schlick(0.04, 1.0, NoV);

    vec3 color = AccumulateDirectLighting(m, positionWS, screenUV);

    // 间接光:底层用(修正后的)底层粗糙度,清漆用自己的粗糙度各采一次反射
    float prBaseEff = sqrt(m.aBase);
    vec2 AB = AdvEnvBRDFApprox(prBaseEff, saturate(dot(m.flakeN, V)));
    vec3 baseIndirect = SampleSH(N) * m.diffuse
        + GlossyEnvironmentReflection(reflect(-V, m.flakeN), positionWS, prBaseEff, 1.0) * (m.f0 * AB.x + AB.y);
    vec2 ABc = AdvEnvBRDFApprox(prCoat, NoV);
    vec3 coatIndirect = GlossyEnvironmentReflection(reflect(-V, N), positionWS, prCoat, 1.0) * (0.04 * ABc.x + ABc.y);

    float attenuation = mix(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
    color += baseIndirect * attenuation + coatIndirect * m.coatMask;

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

完整 Shader 如下(Pass 部分与基准材质相同):

Assets/Cookbook/Ch12/ClearCoat.shader
// 清漆(Clear Coat):在基础 GGX 材质上再盖一层透明的电介质 GGX。
// 两层之间的能量分配、折射对底层的影响,是本案例要讲清的重点。
Shader "Cookbook/Ch12/ClearCoat"
{
    Properties
    {
        [MainColor] _BaseColor ("Base Color", Color) = (0.6, 0.04, 0.03, 1)
        _Metallic ("Metallic", Range(0, 1)) = 0.6
        _Smoothness ("Base Smoothness", Range(0, 1)) = 0.45
        [Header(Flakes)]
        _FlakeDensity ("Flake Density (cells per UV)", Float) = 600
        _FlakeStrength ("Flake Normal Strength", Range(0, 1)) = 0.35
        [Header(Clear Coat)]
        _CoatMask ("Coat Mask", Range(0, 1)) = 1
        _CoatSmoothness ("Coat Smoothness", Range(0, 1)) = 0.95
        [Toggle] _CoatEnergy ("Energy Attenuation (1 - Fc)", Float) = 1
        [Toggle] _CoatAffectBase ("Coat Modifies Base (IOR 1.5)", Float) = 1
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
        CBUFFER_START(UnityPerMaterial)
            half4 _BaseColor;
            half  _Metallic, _Smoothness;
            float _FlakeDensity;
            half  _FlakeStrength;
            half  _CoatMask, _CoatSmoothness, _CoatEnergy, _CoatAffectBase;
        CBUFFER_END
        ENDHLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }

            HLSLPROGRAM
            #pragma target 3.5
            #pragma vertex Ch12Vert
            #pragma fragment frag
            #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
            #pragma multi_compile _ _ADDITIONAL_LIGHTS
            #pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS
            #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
            #pragma multi_compile_fragment _ _REFLECTION_PROBE_BLENDING
            #pragma multi_compile_fragment _ _REFLECTION_PROBE_BOX_PROJECTION
            #pragma multi_compile_fragment _ _LIGHT_COOKIES
            #pragma multi_compile _ _FORWARD_PLUS
            #pragma multi_compile_fog
            #pragma multi_compile_instancing

            #include "Assets/Cookbook/Ch12/Ch12Common.hlsl"

            struct MaterialCtx
            {
                float3 N, V;
                float3 flakeN;      // 带金属片扰动的底层法线
                float3 diffuse, f0;
                float  aBase;       // 底层 α(可能已被清漆修正)
                float  aCoat;       // 清漆层 α
                float  coatMask;
                float  coatFv;      // 清漆在视线方向的菲涅尔,用来衰减底层
            };

            half3 ShadeOneLight(MaterialCtx m, Light light)
            {
                float3 L = light.direction;
                float3 H = normalize(m.V + L);
                float NoL = saturate(dot(m.N, L));
                float NoV = max(dot(m.N, m.V), 1e-4);
                float NoH = saturate(dot(m.N, H));
                float VoH = saturate(dot(m.V, H));

                // 底层:漫反射 + 金属片法线下的 GGX
                float3 baseBRDF = m.diffuse * ADV_INV_PI + AdvSpecularGGX(m.flakeN, m.V, L, m.aBase, m.f0);

                // 清漆层:F0 = 0.04 的电介质 GGX,用宏观法线(漆面是平的)
                float  Fc = AdvF_Schlick(0.04, 1.0, VoH);
                float  coatSpec = AdvD_GGX(NoH, m.aCoat) * AdvV_SmithGGX(NoV, NoL, m.aCoat) * Fc;

                // 能量衰减:被清漆反射掉的那部分光不会再到达底层
                float attenuation = lerp(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
                float3 brdf = baseBRDF * attenuation + coatSpec * m.coatMask;

                return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation);
            }

            #include "Assets/Cookbook/Ch12/LightLoop.hlsl"

            // 程序化金属片:把 UV 切成很小的格子,每格一个随机倾斜的法线
            float3 Hash33(float3 p)
            {
                p = frac(p * float3(0.1031, 0.1030, 0.0973));
                p += dot(p, p.yxz + 33.33);
                return frac((p.xxy + p.yxx) * p.zyx);
            }
            float3 FlakeNormal(float2 uv, float3 T, float3 B, float3 N)
            {
                float2 cell = floor(uv * _FlakeDensity);
                float3 r = Hash33(float3(cell, 7.0)) * 2.0 - 1.0;
                return normalize(N + (r.x * T + r.y * B) * _FlakeStrength);
            }

            half4 frag(Varyings i) : SV_Target
            {
                UNITY_SETUP_INSTANCE_ID(i);
                float3 T, B, N; Ch12BuildFrame(i, T, B, N);
                float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
                float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);
                float NoV = saturate(dot(N, V));

                MaterialCtx m;
                m.N = N; m.V = V;
                m.flakeN = FlakeNormal(i.uv, T, B, N);
                m.coatMask = _CoatMask;
                m.f0 = lerp(0.04, _BaseColor.rgb, _Metallic);
                m.diffuse = _BaseColor.rgb * (1.0 - _Metallic) * 0.96;

                float prBase = 1.0 - _Smoothness;
                float aBase = max(prBase * prBase, 0.002);
                if (_CoatAffectBase > 0.5)
                {
                    // ① 折射让底层波瓣变宽:α → 等效 Blinn-Phong 指数,乘 (1/1.5)²,再换回来(与 URP 的做法相同)
                    float ieta = lerp(1.0, 1.0 / 1.5, m.coatMask);
                    float n = 2.0 / (aBase * aBase) - 2.0;
                    aBase = sqrt(2.0 / (n * ieta * ieta + 2.0));
                    // ② 底层 F0 原本相对空气,现在相对 IOR=1.5 的漆:数值会变小、颜色更饱和
                    float3 f0Coat = saturate(m.f0 * (m.f0 * 0.526868 + 0.529324) - 0.0482256);
                    m.f0 = lerp(m.f0, f0Coat, m.coatMask);
                }
                m.aBase = aBase;
                float prCoat = 1.0 - _CoatSmoothness;
                m.aCoat = max(prCoat * prCoat, 0.002);
                m.coatFv = AdvF_Schlick(0.04, 1.0, NoV);

                half3 color = AccumulateDirectLighting(m, i.positionWS, screenUV);

                // 间接光:底层用(修正后的)底层粗糙度,清漆用自己的粗糙度各采一次反射
                float prBaseEff = sqrt(m.aBase);
                float2 AB = AdvEnvBRDFApprox(prBaseEff, saturate(dot(m.flakeN, V)));
                float3 baseIndirect = SampleSH(N) * m.diffuse
                    + GlossyEnvironmentReflection(reflect(-V, m.flakeN), i.positionWS, prBaseEff, 1.0, screenUV) * (m.f0 * AB.x + AB.y);
                float2 ABc = AdvEnvBRDFApprox(prCoat, NoV);
                float3 coatIndirect = GlossyEnvironmentReflection(reflect(-V, N), i.positionWS, prCoat, 1.0, screenUV) * (0.04 * ABc.x + ABc.y);

                float attenuation = lerp(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
                color += baseIndirect * attenuation + coatIndirect * m.coatMask;

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

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On ZTest LEqual ColorMask 0 Cull Back
            HLSLPROGRAM
            #pragma vertex ShadowPassVertex
            #pragma fragment ShadowPassFragment
            #pragma multi_compile_instancing
            #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
            // URP 自带的阴影 Pass 只在 _ALPHATEST_ON 时才读 _BaseMap,其余部分与材质无关,直接复用
            #include "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl"
            ENDHLSL
        }
        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On ColorMask R Cull Back
            HLSLPROGRAM
            #pragma vertex DepthOnlyVertex
            #pragma fragment DepthOnlyFragment
            #pragma multi_compile_instancing
            #include "Packages/com.unity.render-pipelines.universal/Shaders/DepthOnlyPass.hlsl"
            ENDHLSL
        }
        Pass
        {
            Name "DepthNormals"
            Tags { "LightMode"="DepthNormals" }
            ZWrite On Cull Back
            HLSLPROGRAM
            #pragma vertex DepthNormalsVertex
            #pragma fragment DepthNormalsFragment
            #pragma multi_compile_instancing
            #include "Packages/com.unity.render-pipelines.universal/Shaders/DepthNormalsPass.hlsl"
            ENDHLSL
        }
    }
}
// 清漆(Clear Coat):在基础 GGX 材质上再盖一层透明的电介质 GGX。
// 两层之间的能量分配、折射对底层的影响,是本案例要讲清的重点。
// (GLSL 版,对应 Ch12/ClearCoat.shader)
Shader "Cookbook/Ch12/ClearCoat"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        uniform vec4  _BaseColor;
        uniform float _Metallic, _Smoothness;
        uniform float _FlakeDensity;
        uniform float _FlakeStrength;
        uniform float _CoatMask, _CoatSmoothness, _CoatEnergy, _CoatAffectBase;
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForwardOnly" }

            GLSLPROGRAM
            #include "Ch12/Ch12Common.glsl"

            #ifdef VERTEX
            void main() { Ch12Vert(); }
            #endif

            #ifdef FRAGMENT
            struct MaterialCtx
            {
                vec3 N, V;
                vec3 flakeN;      // 带金属片扰动的底层法线
                vec3 diffuse, f0;
                float aBase;      // 底层 α(可能已被清漆修正)
                float aCoat;      // 清漆层 α
                float coatMask;
                float coatFv;     // 清漆在视线方向的菲涅尔,用来衰减底层
            };

            vec3 ShadeOneLight(MaterialCtx m, Light light)
            {
                vec3 L = light.direction;
                vec3 H = normalize(m.V + L);
                float NoL = saturate(dot(m.N, L));
                float NoV = max(dot(m.N, m.V), 1e-4);
                float NoH = saturate(dot(m.N, H));
                float VoH = saturate(dot(m.V, H));

                // 底层:漫反射 + 金属片法线下的 GGX
                vec3 baseBRDF = m.diffuse * ADV_INV_PI + AdvSpecularGGX(m.flakeN, m.V, L, m.aBase, m.f0);

                // 清漆层:F0 = 0.04 的电介质 GGX,用宏观法线(漆面是平的)
                float Fc = AdvF_Schlick(0.04, 1.0, VoH);
                float coatSpec = AdvD_GGX(NoH, m.aCoat) * AdvV_SmithGGX(NoV, NoL, m.aCoat) * Fc;

                // 能量衰减:被清漆反射掉的那部分光不会再到达底层
                float attenuation = mix(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
                vec3 brdf = baseBRDF * attenuation + coatSpec * m.coatMask;

                return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation);
            }

            #include "Ch12/LightLoop.glsl"

            // 程序化金属片:把 UV 切成很小的格子,每格一个随机倾斜的法线
            vec3 Hash33(vec3 p)
            {
                p = fract(p * vec3(0.1031, 0.1030, 0.0973));
                p += dot(p, p.yxz + 33.33);
                return fract((p.xxy + p.yxx) * p.zyx);
            }
            vec3 FlakeNormal(vec2 uv, vec3 T, vec3 B, vec3 N)
            {
                vec2 cell = floor(uv * _FlakeDensity);
                vec3 r = Hash33(vec3(cell, 7.0)) * 2.0 - 1.0;
                return normalize(N + (r.x * T + r.y * B) * _FlakeStrength);
            }

            void main()
            {
                vec3 T, B, N; Ch12BuildFrame(T, B, N);
                vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
                vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
                float NoV = saturate(dot(N, V));

                MaterialCtx m;
                m.N = N; m.V = V;
                m.flakeN = FlakeNormal(uv, T, B, N);
                m.coatMask = _CoatMask;
                m.f0 = mix(vec3(0.04), _BaseColor.rgb, _Metallic);
                m.diffuse = _BaseColor.rgb * (1.0 - _Metallic) * 0.96;

                float prBase = 1.0 - _Smoothness;
                float aBase = max(prBase * prBase, 0.002);
                if (_CoatAffectBase > 0.5)
                {
                    // ① 折射让底层波瓣变宽:α → 等效 Blinn-Phong 指数,乘 (1/1.5)²,再换回来(与 URP 的做法相同)
                    float ieta = mix(1.0, 1.0 / 1.5, m.coatMask);
                    float n = 2.0 / (aBase * aBase) - 2.0;
                    aBase = sqrt(2.0 / (n * ieta * ieta + 2.0));
                    // ② 底层 F0 原本相对空气,现在相对 IOR=1.5 的漆:数值会变小、颜色更饱和
                    vec3 f0Coat = saturate(m.f0 * (m.f0 * 0.526868 + 0.529324) - 0.0482256);
                    m.f0 = mix(m.f0, f0Coat, m.coatMask);
                }
                m.aBase = aBase;
                float prCoat = 1.0 - _CoatSmoothness;
                m.aCoat = max(prCoat * prCoat, 0.002);
                m.coatFv = AdvF_Schlick(0.04, 1.0, NoV);

                vec3 color = AccumulateDirectLighting(m, positionWS, screenUV);

                // 间接光:底层用(修正后的)底层粗糙度,清漆用自己的粗糙度各采一次反射
                float prBaseEff = sqrt(m.aBase);
                vec2 AB = AdvEnvBRDFApprox(prBaseEff, saturate(dot(m.flakeN, V)));
                vec3 baseIndirect = SampleSH(N) * m.diffuse
                    + GlossyEnvironmentReflection(reflect(-V, m.flakeN), positionWS, prBaseEff, 1.0) * (m.f0 * AB.x + AB.y);
                vec2 ABc = AdvEnvBRDFApprox(prCoat, NoV);
                vec3 coatIndirect = GlossyEnvironmentReflection(reflect(-V, N), positionWS, prCoat, 1.0) * (0.04 * ABc.x + ABc.y);

                float attenuation = mix(1.0, 1.0 - m.coatMask * m.coatFv, _CoatEnergy);
                color += baseIndirect * attenuation + coatIndirect * m.coatMask;

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

        UsePass "Cookbook/Common/SHADOWCASTER"
        UsePass "Cookbook/Common/DEPTHONLY"
        UsePass "Cookbook/Common/DEPTHNORMALS"
    }
}
图 12-4金属漆。左:只有底层(金属度 0.6、光滑度 0.45、带金属片);中:加清漆(光滑度 0.95),开启能量衰减;右:加清漆但关闭能量衰减

左图只有底层时,金属片在粗糙高光里闪烁,整体像磨砂的金属;中图盖上清漆后多出一层锐利的高光和清晰的天空反射,底层的颜色和闪点仍然透出来,这就是车漆该有的“深度感”。右图乍看和中图差不多,只是边缘更亮一些——能量问题在普通光照下往往不显眼,需要专门的测试才能看清。

图 12-5清漆遮罩从左到右 0 → 1(底层光滑度 0.3)。遮罩可以用贴图控制,做出局部磨损、只在部分区域上漆的效果

白炉测试:一眼看出能量是否守恒

白炉测试(Furnace Test)是检验 BRDF 能量的经典方法:把物体放进一个各方向亮度完全相同的环境里,关掉所有直接光。一个反照率为 1、不吸收能量的材质,反射出去的光应该刚好等于照进来的光——它会和背景“融为一体”,看不见轮廓。如果某处比背景亮,说明 BRDF 在那里凭空产生了能量;比背景暗,说明能量丢了(单次散射的微表面模型在粗糙时本来就会损失一些,这就是第 11 章提到的多次散射补偿要解决的问题)。

在拍摄脚本里构造这样的环境很简单:一张所有像素都是 0.5 的立方体贴图作为反射源,球谐只保留常数项 0.5,背景色设为线性 0.5(注意相机背景色按 sRGB 解释,要用 Color.gamma 换算):

Assets/Cookbook/Ch12/Editor/Ch12Shots.cs(节选:furnace)
const float L = 0.5f;
var cube = new Cubemap(16, TextureFormat.RGBAHalf, true) { hideFlags = HideFlags.DontSave };
var px = new Color[16 * 16];
for (int k = 0; k < px.Length; k++) px[k] = new Color(L, L, L, 1);
for (int f = 0; f < 6; f++) cube.SetPixels(px, (CubemapFace)f);
cube.Apply(true);
s.Temp.Add(cube);
RenderSettings.customReflectionTexture = cube;
var sh = new UnityEngine.Rendering.SphericalHarmonicsL2();
sh.AddAmbientLight(new Color(L, L, L));
RenderSettings.ambientProbe = sh;
s.Background(new Color(L, L, L).gamma);   // 背景色按 sRGB 解释,要换算成线性 0.5
s.Sun.intensity = 0;
图 12-6白炉测试(白色底层,背景与环境都是线性 0.5)。左:无清漆;中:清漆 + 能量衰减;右:清漆但不做衰减,边缘明显比背景亮

左、中两图里球几乎消失,只剩下边缘一圈淡淡的暗环(粗糙底层在掠射角的能量损失);右图边缘发亮,因为在掠射角清漆的反射率接近 1,再加上一个完整的底层,总反射率超过了 1。普通场景里这种错误会表现为“物体边缘发白、看起来像蒙了一层雾”,很容易被误当成环境光太亮。

与 URP Complex Lit 对照

图 12-7左:URP Complex Lit(Clear Coat 开启);右:本案例的 ClearCoat。底层相同(蓝色、金属度 0.3、光滑度 0.4),清漆光滑度从左到右 0.6、0.8、0.95

两者整体一致。可见的差别是:URP 的清漆高光略亮、略大——它的清漆镜面项用的是 URP 自己的 DirectBRDFSpecular(一种把 D 和 V 合并的简化 GGX)乘常数 0.04,而我们用的是完整的 D·V·F,F 在正对视线时比 0.04 还小一点。这类差别属于“近似方法不同”,不是谁对谁错。

注意Complex Lit 在 URP 14 里始终走 Forward 渲染:即使渲染路径设成 Deferred,它也会在 Forward 阶段单独绘制,因为 G-Buffer 里没有位置存清漆参数。自写的清漆 Shader 同理——不写 UniversalGBuffer Pass,并把着色 Pass 的 LightMode 设为 UniversalForwardOnly(见 12.1 的说明)。

性能与调参建议

清漆的直接光要多算一次 D·V·F,环境光要多采一次反射探针(不同的 mip 等级),总开销大约是基准材质的 1.5 倍左右,具体以目标平台实测为准。移动端可以把底层的环境反射省掉(底层粗糙,环境反射本来就很模糊,用球谐近似即可),只保留清漆那一次采样。清漆光滑度一般取 0.85~1.0;做旧、磨砂清漆可以降到 0.6 左右,但此时两层高光会混在一起,清漆的意义就不大了。金属片的密度要与屏幕分辨率匹配:格子小于一个像素时会严重闪烁,远处应当按距离或 mip 淡出金属片强度。

12.3 各向异性:当高光被拉成一条线

拿一块拉丝不锈钢板,或者看一眼不锈钢锅底的同心圆纹,你会发现灯的倒影不是一个圆斑,而是一条垂直于纹路的亮线。原因在微观几何:拉丝在表面上刻出无数平行的细槽。沿着槽的方向,表面是光滑的;垂直于槽的方向,表面起伏很大。于是微表面法线在“横跨槽”的方向上分布很宽,在“顺着槽”的方向上分布很窄,高光自然就被拉长了——拉长的方向和槽的走向垂直。

T:拉丝方向(光滑,αt 小) B:横跨细槽(粗糙,αb 大) 高光沿 B 方向被拉长(垂直于纹路) 沿 B 的截面:微法线左右摆动很大 沿 T 的截面:几乎是直线
图 12-8拉丝表面:顺着纹路光滑、横跨纹路粗糙,所以高光在垂直于纹路的方向上被拉长

各向异性 GGX

把 GGX 的单个粗糙度 α 拆成两个:沿切线 T 的 αt 和沿副切线 B 的 αb。法线分布变成(Burley 2012):

D(h)=1παtαb·1((T·H)2αt2+(B·H)2αb2+(N·H)2)2

当 αt=αb=α 时,利用 (T·H)2+(B·H)2=1−(N·H)2 很容易验证它退化为普通 GGX。高度相关的 Smith 可见性项也有对应的各向异性版本(Heitz 2014),形式上只是把 αN·V 这类量换成三维向量的长度:

V=0.5N·L‖(αtT·V,αbB·V,N·V)‖+N·V‖(αtT·L,αbB·L,N·L)‖
Assets/Cookbook/Ch12/AdvBRDF.hlsl(节选:aniso)
// ---------- 各向异性 GGX(Burley 2012 / Heitz 2014)----------
// at、ab 分别是沿切线 T、副切线 B 方向的粗糙度 α
float AdvD_GGXAniso(float ToH, float BoH, float NoH, float at, float ab)
{
    float3 v = float3(ab * ToH, at * BoH, at * ab * NoH);
    float v2 = dot(v, v);
    float w2 = at * ab / max(v2, 1e-7);
    return at * ab * w2 * w2 * ADV_INV_PI;
}

float AdvV_SmithGGXAniso(float ToV, float BoV, float NoV, float ToL, float BoL, float NoL, float at, float ab)
{
    float lambdaV = NoL * length(float3(at * ToV, ab * BoV, NoV));
    float lambdaL = NoV * length(float3(at * ToL, ab * BoL, NoL));
    return 0.5 / (lambdaV + lambdaL + 1e-5);
}

// 由一个各向异性参数 k ∈ (-1,1) 拆出两个方向的粗糙度
// 本书约定:k > 0 表示“沿 T 光滑、垂直 T 粗糙”,即 T 就是拉丝(划痕)方向
void AdvAnisoRoughness(float a, float k, out float at, out float ab)
{
    at = max(a * (1.0 - k), 0.002);
    ab = max(a * (1.0 + k), 0.002);
}
// ---------- 各向异性 GGX(Burley 2012 / Heitz 2014)----------
// at、ab 分别是沿切线 T、副切线 B 方向的粗糙度 α
float AdvD_GGXAniso(float ToH, float BoH, float NoH, float at, float ab)
{
    vec3 v = vec3(ab * ToH, at * BoH, at * ab * NoH);
    float v2 = dot(v, v);
    float w2 = at * ab / max(v2, 1e-7);
    return at * ab * w2 * w2 * ADV_INV_PI;
}

float AdvV_SmithGGXAniso(float ToV, float BoV, float NoV, float ToL, float BoL, float NoL, float at, float ab)
{
    float lambdaV = NoL * length(vec3(at * ToV, ab * BoV, NoV));
    float lambdaL = NoV * length(vec3(at * ToL, ab * BoL, NoL));
    return 0.5 / (lambdaV + lambdaL + 1e-5);
}

// 由一个各向异性参数 k ∈ (-1,1) 拆出两个方向的粗糙度
// 本书约定:k > 0 表示“沿 T 光滑、垂直 T 粗糙”,即 T 就是拉丝(划痕)方向
void AdvAnisoRoughness(float a, float k, out float at, out float ab)
{
    at = max(a * (1.0 - k), 0.002);
    ab = max(a * (1.0 + k), 0.002);
}

代码里 D 的写法看起来和公式不一样,其实是把分子分母同乘了 αt2αb2,避免在 α 很小时出现大数相除;Filament 和 HDRP 都是这么写的。

两个粗糙度不方便美术调节,通常用一个“各向异性度”k ∈ (−1, 1) 加原来的 α 来派生。各家引擎的映射和正负号约定并不统一:Filament 用 αt=α(1+k),即 k > 0 时沿 T 方向更粗糙;HDRP 使用另一套基于 1±0.9k 的映射。本书约定 T 就是拉丝方向,k > 0 表示“沿 T 光滑、横跨 T 粗糙”,这样美术只需要把切线对准纹路就行。换引擎、换资产时,一定要先确认对方的约定,否则高光会转 90°。

案例 12.3拉丝金属与同心圆拉丝进阶单独打开 ↗

目标:用各向异性 GGX 做出直线拉丝(纽结、圆环)和同心圆拉丝(锅底、旋钮),并处理各向异性下的环境反射。

着色函数只是把 D、V 换成各向异性版本:

Assets/Cookbook/Ch12/Anisotropic.shader(节选:shade)
half3 ShadeOneLight(MaterialCtx m, Light light)
{
    float3 L = light.direction;
    float3 H = normalize(m.V + L);
    float NoL = saturate(dot(m.N, L));
    float NoV = max(dot(m.N, m.V), 1e-4);
    float NoH = saturate(dot(m.N, H));
    float VoH = saturate(dot(m.V, H));

    float D = AdvD_GGXAniso(dot(m.T, H), dot(m.B, H), NoH, m.at, m.ab);
    float Vis = AdvV_SmithGGXAniso(dot(m.T, m.V), dot(m.B, m.V), NoV,
                                   dot(m.T, L), dot(m.B, L), NoL, m.at, m.ab);
    float3 F = AdvF_Schlick(m.f0, 1.0, VoH);

    float3 brdf = m.diffuse * ADV_INV_PI + D * Vis * F;
    return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation);
}
vec3 ShadeOneLight(MaterialCtx m, Light light)
{
    vec3 L = light.direction;
    vec3 H = normalize(m.V + L);
    float NoL = saturate(dot(m.N, L));
    float NoV = max(dot(m.N, m.V), 1e-4);
    float NoH = saturate(dot(m.N, H));
    float VoH = saturate(dot(m.V, H));

    float D = AdvD_GGXAniso(dot(m.T, H), dot(m.B, H), NoH, m.at, m.ab);
    float Vis = AdvV_SmithGGXAniso(dot(m.T, m.V), dot(m.B, m.V), NoV,
                                   dot(m.T, L), dot(m.B, L), NoL, m.at, m.ab);
    vec3 F = AdvF_Schlick(m.f0, 1.0, VoH);

    vec3 brdf = m.diffuse * ADV_INV_PI + D * Vis * F;
    return brdf * PI * light.color * (NoL * light.distanceAttenuation * light.shadowAttenuation);
}

拉丝方向有三种来源,用一个 KeywordEnum 切换:网格切线(纹路沿 U)、网格副切线(纹路沿 V),以及“以 UV 中心为圆心的同心圆”。最后一种在片元里现算:从 UV 中心指向当前点的方向是半径方向,把它旋转 90° 就是圆周方向,再用 T、B 把这个二维方向转到世界空间。拉丝条纹贴图(公共贴图 noise_streak,纹路沿 U)的采样坐标也要跟着改:同心圆模式下只按半径取样,条纹就变成一圈圈的同心圆:

Assets/Cookbook/Ch12/Anisotropic.shader(节选:dir)
    // 选择拉丝方向;条纹贴图的纹路沿 U 方向,采样坐标也跟着转
    float2 streakUV = i.uv;
#if defined(_DIR_BITANGENT)
    float3 tmp = T; T = B; B = -tmp;
    streakUV = i.uv.yx;
#elif defined(_DIR_RADIAL)
    // 以 UV 中心为圆心的同心圆拉丝(锅底、CD 纹):切线 = 圆周方向
    float2 d = i.uv - 0.5;
    float2 c = normalize(float2(-d.y, d.x) + 1e-5);
    T = normalize(c.x * T + c.y * B);
    B = cross(N, T);
    streakUV = float2(0.5, length(d) * 2.0);   // 条纹只随半径变化 → 同心圆
#endif
    // 选择拉丝方向;条纹贴图的纹路沿 U 方向,采样坐标也跟着转
    vec2 streakUV = uv;
#if defined(_DIR_BITANGENT)
    vec3 tmp = T; T = B; B = -tmp;
    streakUV = uv.yx;
#elif defined(_DIR_RADIAL)
    // 以 UV 中心为圆心的同心圆拉丝(锅底、CD 纹):切线 = 圆周方向
    vec2 d = uv - 0.5;
    vec2 c = normalize(vec2(-d.y, d.x) + 1e-5);
    T = normalize(c.x * T + c.y * B);
    B = cross(N, T);
    streakUV = vec2(0.5, length(d) * 2.0);   // 条纹只随半径变化 → 同心圆
#endif

环境反射:弯曲法线近似

直接光好办,环境光就麻烦了:反射探针的 mip 链是按各向同性的 GGX 预滤波的,各向异性的波瓣没法从中直接取到。完全正确的做法要做多次采样或预滤波更多维度的数据,实时渲染里普遍采用一个巧妙的近似(Filament、HDRP 都在用):把法线朝“粗糙方向”弯一弯,用弯过的法线算反射向量,再按各向同性的方式采样。

Assets/Cookbook/Ch12/Anisotropic.shader(节选:bent)
// 各向异性的环境反射:没法预滤波,常用“弯曲法线”近似(Filament 的做法)
// grooveDir 是光滑的方向(划痕走向);anisoN = V 投影到垂直于它的平面上,
// 把法线朝 anisoN 扭一扭,反射就会沿“粗糙方向”被拉长
float3 grooveDir = _Anisotropy >= 0.0 ? T : B;
float3 anisoT = cross(grooveDir, V);
float3 anisoN = cross(anisoT, grooveDir);
float bend = abs(_Anisotropy) * saturate(5.0 * pr) * _AnisoIBL;
float3 bentN = normalize(lerp(N, anisoN, bend));
float3 R = reflect(-V, bentN);
// 各向异性的环境反射:没法预滤波,常用“弯曲法线”近似(Filament 的做法)
// grooveDir 是光滑的方向(划痕走向);anisoN = V 投影到垂直于它的平面上,
// 把法线朝 anisoN 扭一扭,反射就会沿“粗糙方向”被拉长
vec3 grooveDir = _Anisotropy >= 0.0 ? T : B;
vec3 anisoT = cross(grooveDir, V);
vec3 anisoN = cross(anisoT, grooveDir);
float bend = abs(_Anisotropy) * saturate(5.0 * pr) * _AnisoIBL;
vec3 bentN = normalize(mix(N, anisoN, bend));
vec3 R = reflect(-V, bentN);

思路是:anisoN 是把视线 V 投影到“垂直于纹路方向的平面”上得到的向量。法线越往它靠,反射向量在粗糙方向上的变化就越大——相当于把各向同性的反射图案沿粗糙方向“抹开”。弯曲量乘了 saturate(5 * 感知粗糙度):非常光滑的表面几乎不存在各向异性,不该弯。

图 12-9环面纽结上的拉丝金属,纹路沿管子的长度方向(网格切线)。各向异性度从左到右 0、0.5、0.85

k = 0 时三个光源(主光与两盏点光)各留下一个圆斑;随着 k 增大,圆斑沿管子的圆周方向越拉越长,最后变成一圈圈环绕管子的亮带——正是“垂直于纹路”的方向。

图 12-10平板上的同心圆拉丝(锅底)。左:各向同性(k = 0),两盏点光各一个圆斑;右:k = 0.85,高光沿半径方向拉成放射状的亮线
图 12-11只有环境光时的各向异性球(纹路沿水平的纬线)。左:环境反射不做处理,地平线清晰锐利,看起来像普通的光滑金属;右:使用弯曲法线,地平线和太阳倒影被竖直拉开
常见坑① 切线必须连续。网格的 UV 接缝两侧切线方向不同,各向异性高光会在接缝处断开;拉丝件要专门展一套“沿纹路方向”的 UV。② 用了法线贴图时,切线也要用法线贴图扰动后的 N 重新正交化,否则 T 不在切平面内。③ 一个物体上 T 方向混乱(例如自动展 UV 的模型)时,各向异性只会产生满屏的噪点,不如直接用各向同性。④ 条纹贴图要开 mip,并尽量用各向异性过滤,远处条纹闪烁会非常明显。

12.4 头发:圆柱上的高光

头发是各向异性的极端情况:每根发丝都是一根细长的圆柱。圆柱的法线绕着轴线转了一整圈,所以对一根发丝来说,“法线”只有一个约束——垂直于发丝方向 T。只要半角向量 H 垂直于 T,就总能在圆柱上找到一个法线正好等于 H 的点,于是高光就亮。这就是 Kajiya 和 Kay 在 1989 年提出的发丝模型的核心:

fspec∝(sin∠(T,H))p=(1−(T·H)2)p

它和 Blinn-Phong 的 (N·H)p 形式相同,只是把“与法线的接近程度”换成了“与切线的垂直程度”。在一个由很多发丝组成的发型上,结果就是一条横跨发丝、环绕头部的亮带。

为什么有两条高光

Marschner 等人在 2003 年对真实头发做了测量,发现光在发丝上的散射主要有三条路径:直接在表面反射的 R;穿进发丝、从另一侧穿出的 TT;穿进去、在内壁反射一次再从入射侧出来的 TRT。发丝表面是一片片鳞片(cuticle),鳞片相对发丝轴线倾斜了几度,于是 R 的高光往发根方向偏,TRT 往发梢方向偏,两条高光错开。R 基本不带颜色(表面反射),TRT 在发丝内部走了一趟,被色素吸收后带上了发色,而且更宽、更散。

发丝(鳞片倾斜) 发根发梢 → 入射光 R:表面反射偏向发根,白色 TRT:内壁反射一次偏向发梢,带发色 TT:透射(背光时的发丝透亮)
图 12-12发丝上的三条主要光路(示意)。鳞片的倾斜让 R 与 TRT 两条高光沿发丝方向错开

Scheuermann 在 2004 年给出的实时方案(ATI 的 Ruby 演示)把这个观察简化成:用两次 Kajiya-Kay,各自把切线 T 沿法线方向挪一点(shift),一个往发根挪、一个往发梢挪,第二条乘发色、再用噪声遮罩打散。“沿法线挪切线”之所以能移动高光,是因为 T′=normalize(T+sN) 相当于让发丝“翘起”了一个角度,满足 T′⊥H 的位置随之沿发丝方向移动。

Assets/Cookbook/Ch12/AdvBRDF.hlsl(节选:kk)
// ---------- 头发:Kajiya-Kay ----------
// 沿发丝方向 T 的“圆柱”高光:sin(T,H) 越接近 1 越亮
float3 AdvShiftTangent(float3 T, float3 N, float shift)
{
    return normalize(T + shift * N);
}

float AdvKajiyaKay(float3 T, float3 H, float exponent)
{
    float ToH = dot(T, H);
    float sinTH = sqrt(max(1.0 - ToH * ToH, 0.0));
    // 背面衰减:H 跑到发丝“后面”时淡出,避免高光穿透(Scheuermann 2004 的做法)
    float dirAtten = smoothstep(-1.0, 0.0, ToH);
    return dirAtten * pow(sinTH, exponent);
}
// ---------- 头发:Kajiya-Kay ----------
// 沿发丝方向 T 的“圆柱”高光:sin(T,H) 越接近 1 越亮
vec3 AdvShiftTangent(vec3 T, vec3 N, float shift)
{
    return normalize(T + shift * N);
}

float AdvKajiyaKay(vec3 T, vec3 H, float exponent)
{
    float ToH = dot(T, H);
    float sinTH = sqrt(max(1.0 - ToH * ToH, 0.0));
    // 背面衰减:H 跑到发丝“后面”时淡出,避免高光穿透(Scheuermann 2004 的做法)
    float dirAtten = smoothstep(-1.0, 0.0, ToH);
    return dirAtten * pow(sinTH, exponent);
}

dirAtten 是 Scheuermann 原文里的一个小细节:当 H 跑到发丝“背后”(T·H < 0 的那一侧过多)时让高光淡出,防止高光从头的背面透过来。

案例 12.4Kajiya-Kay 双高光头发进阶单独打开 ↗

目标:在一个球形“发帽”上实现双高光头发,对比 Blinn-Phong、单条 KK、双条 KK,并展示偏移噪声的作用。

发丝方向的选择:Unity 内置球体的切线沿纬线(水平方向),而头发应该从头顶往下垂,也就是沿经线,所以 Shader 提供一个开关,用副切线 B 当发丝方向。真实的头发模型(卡片式发片)通常会把 UV 展成“V 沿发丝方向”,同样用 B;也可以在 DCC 里把切线直接对齐发丝。

Assets/Cookbook/Ch12/HairKK.shader(节选:strand)
MaterialCtx m;
m.N = N; m.V = V;
m.T = _StrandAlongV > 0.5 ? B : T;   // 发丝方向
half n = SAMPLE_TEXTURE2D(_ShiftTex, sampler_ShiftTex, TRANSFORM_TEX(i.uv, _ShiftTex)).r;
m.shiftNoise = (n - 0.5) * _ShiftAmount;
m.sparkle = saturate(n * 1.6 - 0.3);
// 发丝颜色也带一点随机明暗,看起来才像一缕缕的
m.albedo = _BaseColor.rgb * lerp(0.7, 1.15, n);
MaterialCtx m;
m.N = N; m.V = V;
m.T = _StrandAlongV > 0.5 ? B : T;   // 发丝方向
float n = texture(_ShiftTex, TransformTex(uv, _ShiftTex_ST)).r;
m.shiftNoise = (n - 0.5) * _ShiftAmount;
m.sparkle = saturate(n * 1.6 - 0.3);
// 发丝颜色也带一点随机明暗,看起来才像一缕缕的
m.albedo = _BaseColor.rgb * mix(0.7, 1.15, n);

偏移噪声(shift noise)是头发质感的关键:每一缕头发的偏移量不同,高光带就会参差不齐,像一缕缕的。噪声贴图在拍摄脚本里程序生成:沿 V(发丝方向)不变,沿 U 由一层低频的“发束”噪声和一层高频的“单根发丝”噪声叠加。同一张噪声还用来让发色带一点随机明暗,并作为第二条高光的闪烁遮罩。

Assets/Cookbook/Ch12/HairKK.shader(节选:shade)
half3 ShadeOneLight(MaterialCtx m, Light light)
{
    float3 L = light.direction;
    float3 H = normalize(L + m.V);
    float NoL = dot(m.N, L);

    // 漫反射:发丝是半透明细圆柱,Lambert 太“硬”,压一个下限让暗部不死黑
    float3 diffuse = m.albedo * lerp(0.25, 1.0, saturate(NoL));

    float3 spec = 0;
#if defined(_MODE_BLINNPHONG)
    spec = _PrimaryColor.rgb * pow(saturate(dot(m.N, H)), _PrimaryExp * 0.5);
#else
    float3 t1 = AdvShiftTangent(m.T, m.N, _PrimaryShift + m.shiftNoise);
    spec = _PrimaryColor.rgb * AdvKajiyaKay(t1, H, _PrimaryExp);
  #if defined(_MODE_DUAL)
    float3 t2 = AdvShiftTangent(m.T, m.N, _SecondaryShift + m.shiftNoise);
    spec += _SecondaryColor.rgb * m.sparkle * AdvKajiyaKay(t2, H, _SecondaryExp);
  #endif
#endif
    // 背光面不给高光(发丝本身会挡住),用平滑过渡避免硬边
    spec *= smoothstep(-0.1, 0.3, NoL);

    return (diffuse + spec * 0.5) * light.color * (light.distanceAttenuation * light.shadowAttenuation);
}
vec3 ShadeOneLight(MaterialCtx m, Light light)
{
    vec3 L = light.direction;
    vec3 H = normalize(L + m.V);
    float NoL = dot(m.N, L);

    // 漫反射:发丝是半透明细圆柱,Lambert 太“硬”,压一个下限让暗部不死黑
    vec3 diffuse = m.albedo * mix(0.25, 1.0, saturate(NoL));

    vec3 spec = vec3(0.0);
#if defined(_MODE_BLINNPHONG)
    spec = _PrimaryColor.rgb * pow(saturate(dot(m.N, H)), _PrimaryExp * 0.5);
#else
    vec3 t1 = AdvShiftTangent(m.T, m.N, _PrimaryShift + m.shiftNoise);
    spec = _PrimaryColor.rgb * AdvKajiyaKay(t1, H, _PrimaryExp);
  #if defined(_MODE_DUAL)
    vec3 t2 = AdvShiftTangent(m.T, m.N, _SecondaryShift + m.shiftNoise);
    spec += _SecondaryColor.rgb * m.sparkle * AdvKajiyaKay(t2, H, _SecondaryExp);
  #endif
#endif
    // 背光面不给高光(发丝本身会挡住),用平滑过渡避免硬边
    spec *= smoothstep(-0.1, 0.3, NoL);

    return (diffuse + spec * 0.5) * light.color * (light.distanceAttenuation * light.shadowAttenuation);
}

漫反射没有用 Lambert:发丝是半透明的细圆柱,光会在发丝之间多次散射,真实头发的暗部远没有 Lambert 预测的那么黑。lerp(0.25, 1, NoL) 是 Scheuermann 原文的做法,简单地给暗部一个下限。最后的 spec * 0.5 是个经验系数——Kajiya-Kay 本身不是能量守恒的 BRDF,它的亮度只能靠调。

图 12-13左:Blinn-Phong,高光是一个圆斑,像塑料球;中:单条 Kajiya-Kay,高光变成环绕头部的亮带;右:双条 KK,第二条高光偏向发梢、带发色、被噪声打散
图 12-14偏移噪声的作用。左:噪声强度 0,高光带是一条光滑的环,像缎带;右:噪声强度 0.3,高光边缘参差,出现一缕一缕的感觉
深入Kajiya-Kay 是“看起来像”的模型,没有能量守恒,也没有真正的 TT 透射。追求写实的项目(HDRP 的 Hair Shader、各家的数字人方案)会使用 Marschner 模型或它的改进版(d'Eon 2011、Chiang 2016 等),分别计算 R、TT、TRT 三个波瓣的纵向分布 M 和方位分布 N。那些模型参数是物理量(鳞片倾角、折射率、吸收系数),代价是更多的计算和更难的调参。对卡通或中等写实度的项目,双 KK 依然是最划算的选择——第 14 章的“天使环”就是它的风格化版本。
注意真实的发型由大量带透明度的发片组成,排序、Alpha 测试与 Alpha 混合的取舍(以及“先不透明写深度、再半透明补边缘”的双 Pass 技巧)属于第 16 章的话题,本章只讨论着色。

12.5 布料:不是高度场的表面

丝绒(天鹅绒)、绒布、毛毡的表面竖着无数细小的纤维。正对着看时,你看到的是纤维的末端和它们之间的缝隙,颜色深;斜着看、尤其是在物体轮廓附近,视线擦着纤维的侧面过去,每根纤维都把光往视线方向散射,于是轮廓一圈泛起一层柔和的亮光,这就是 sheen。普通 GGX 在掠射角只会给出一圈“菲涅尔边缘”,而且还要很光滑才明显;它描述不了“越掠射越亮、而且很柔和”的绒毛反光。

正视:看到纤维末端与缝隙→ 颜色深 掠射:视线擦过一排纤维的侧面→ 泛起柔光(sheen)
图 12-15绒面布料的微观结构:竖立的纤维让掠射角反而更亮

Charlie sheen 分布

既然掠射更亮,那么法线分布就应该在 N·H 小(H 接近切平面)时更大,正好和 GGX 反过来。Ashikhmin 最早提出用“倒过来的高斯”作为布料的分布;Sony Imageworks 的 Estevez 和 Kulla 在 2017 年给出了更方便的 Charlie 分布,它是 sin 的幂:

DCharlie(h)=(2+1/α)sin1/αθh2π

它是归一化的(投影面积积分为 1),α 越小,亮光越集中在掠射角附近的一圈;α 越大,亮光越往正面扩散。可见性项方面,Charlie 原文的 V 计算量较大,Filament 等引擎改用 Neubelt 和 Pettineo(2013,《教团:1886》)提出的一个非常便宜的形式:

VNeubelt=14(N·L+N·V−N·LN·V)

布料的菲涅尔变化不明显(纤维的反射主要来自体散射,而不是光滑界面),所以干脆不算 F,直接用一个 sheen 颜色代替。这样,布料的镜面项就是 DCharlie·VNeubelt·csheen。

Assets/Cookbook/Ch12/AdvBRDF.hlsl(节选:cloth)
// ---------- 布料:Charlie sheen(Estevez & Kulla 2017)----------
// 形状是 sin(θh) 的幂:法线越接近垂直于 H(掠射)越亮,正好对应绒毛侧面反光
float AdvD_Charlie(float NoH, float a)
{
    float invA = 1.0 / a;
    float sin2h = max(1.0 - NoH * NoH, 1e-4);
    return (2.0 + invA) * pow(sin2h, invA * 0.5) / (2.0 * ADV_PI);
}

// Neubelt & Pettineo 2013 的布料可见性项,比 Charlie 原文的 V 便宜很多
float AdvV_Neubelt(float NoV, float NoL)
{
    return 1.0 / (4.0 * (NoL + NoV - NoL * NoV) + 1e-5);
}
// ---------- 布料:Charlie sheen(Estevez & Kulla 2017)----------
// 形状是 sin(θh) 的幂:法线越接近垂直于 H(掠射)越亮,正好对应绒毛侧面反光
float AdvD_Charlie(float NoH, float a)
{
    float invA = 1.0 / a;
    float sin2h = max(1.0 - NoH * NoH, 1e-4);
    return (2.0 + invA) * pow(sin2h, invA * 0.5) / (2.0 * ADV_PI);
}

// Neubelt & Pettineo 2013 的布料可见性项,比 Charlie 原文的 V 便宜很多
float AdvV_Neubelt(float NoV, float NoL)
{
    return 1.0 / (4.0 * (NoL + NoV - NoL * NoV) + 1e-5);
}

布料的漫反射部分,Filament 的布料模型还加了两个“次表面”味道的修正:用包裹漫反射(下一节详细讲)让光渗进暗部,再乘一个 saturate(csubsurface+N·L),让暗部带上指定的颜色——纤维之间的多次散射会把光“染”成布本身的颜色。

案例 12.5丝绒:Charlie sheen 布料进阶单独打开 ↗

目标:实现布料着色,对比“把布当塑料(GGX)”、Charlie sheen、Charlie + 次表面色三种做法,并加一层程序化的平纹织物纹理。

Assets/Cookbook/Ch12/Cloth.shader(节选:shade)
half3 ShadeOneLight(MaterialCtx m, Light light)
{
    float3 L = light.direction;
    float3 H = normalize(m.V + L);
    float NoLu = dot(m.N, L);
    float NoL = saturate(NoLu);
    float NoV = max(dot(m.N, m.V), 1e-4);
    float NoH = saturate(dot(m.N, H));
    float atten = light.distanceAttenuation * light.shadowAttenuation;

#if defined(_MODEL_GGX)
    // 对照组:把布当成普通电介质
    float3 brdf = m.albedo * ADV_INV_PI + AdvSpecularGGX(m.N, m.V, L, m.a, 0.04);
    return brdf * PI * light.color * (NoL * atten);
#else
    // 镜面:Charlie 分布 × Neubelt 可见性 × sheen 颜色(布料没有明显的菲涅尔变化,直接用颜色当 F)
    float3 Fr = AdvD_Charlie(NoH, m.sheenA) * AdvV_Neubelt(NoV, NoL) * m.sheen;
  #if defined(_MODEL_SUBSURFACE)
    // 漫反射:包裹 + 次表面色,光“渗”进暗部并带上颜色
    float3 Fd = m.albedo * ADV_INV_PI * AdvWrapDiffuse(NoLu, _Wrap);
    Fd *= saturate(_SubsurfaceColor.rgb + NoL);
    return (Fd + Fr * NoL) * PI * light.color * atten;
  #else
    float3 Fd = m.albedo * ADV_INV_PI;
    return (Fd + Fr) * PI * light.color * (NoL * atten);
  #endif
#endif
}
vec3 ShadeOneLight(MaterialCtx m, Light light)
{
    vec3 L = light.direction;
    vec3 H = normalize(m.V + L);
    float NoLu = dot(m.N, L);
    float NoL = saturate(NoLu);
    float NoV = max(dot(m.N, m.V), 1e-4);
    float NoH = saturate(dot(m.N, H));
    float atten = light.distanceAttenuation * light.shadowAttenuation;

#if defined(_MODEL_GGX)
    // 对照组:把布当成普通电介质
    vec3 brdf = m.albedo * ADV_INV_PI + AdvSpecularGGX(m.N, m.V, L, m.a, 0.04);
    return brdf * PI * light.color * (NoL * atten);
#else
    // 镜面:Charlie 分布 × Neubelt 可见性 × sheen 颜色(布料没有明显的菲涅尔变化,直接用颜色当 F)
    vec3 Fr = AdvD_Charlie(NoH, m.sheenA) * AdvV_Neubelt(NoV, NoL) * m.sheen;
  #if defined(_MODEL_SUBSURFACE)
    // 漫反射:包裹 + 次表面色,光“渗”进暗部并带上颜色
    vec3 Fd = m.albedo * ADV_INV_PI * AdvWrapDiffuse(NoLu, _Wrap);
    Fd *= saturate(_SubsurfaceColor.rgb + NoL);
    return (Fd + Fr * NoL) * PI * light.color * atten;
  #else
    vec3 Fd = m.albedo * ADV_INV_PI;
    return (Fd + Fr) * PI * light.color * (NoL * atten);
  #endif
#endif
}

注意次表面版本里 Fd 没有乘 N·L:包裹漫反射本身已经包含了余弦衰减(只是被“包”宽了),再乘一次 N·L 就把包裹的效果抵消了;镜面项 Fr 照常乘 N·L。

织物纹理用两组正弦和棋盘式的交替模拟经纬线的上下穿插,只轻微调制反照率和 sheen 颜色。它在远处会因为频率太高而闪烁,正式项目应该用法线贴图 + mip 来做细节:

Assets/Cookbook/Ch12/Cloth.shader(节选:weave)
// 程序化平纹:经纬线交替压在上面,用两组正弦的乘积近似线的明暗
float2 w = i.uv * _WeaveScale;
float warp = abs(sin(w.x * PI)) * step(0.5, frac(w.y * 0.5 + floor(w.x) * 0.5));
float weft = abs(sin(w.y * PI)) * (1.0 - step(0.5, frac(w.y * 0.5 + floor(w.x) * 0.5)));
float thread = saturate(warp + weft);
half weave = lerp(1.0, lerp(0.7, 1.1, thread), _WeaveStrength);
// 程序化平纹:经纬线交替压在上面,用两组正弦的乘积近似线的明暗
vec2 w = uv * _WeaveScale;
float warp = abs(sin(w.x * PI)) * step(0.5, fract(w.y * 0.5 + floor(w.x) * 0.5));
float weft = abs(sin(w.y * PI)) * (1.0 - step(0.5, fract(w.y * 0.5 + floor(w.x) * 0.5)));
float thread = saturate(warp + weft);
float weave = mix(1.0, mix(0.7, 1.1, thread), _WeaveStrength);
图 12-16逆光下的丝绒抱枕与纽结。左:GGX(光滑度 0.35),顶部出现塑料似的高光;中:Charlie sheen,轮廓和掠射区域泛起粉色柔光,正面保持深色;右:再加包裹漫反射与次表面色,明暗交界更软、暗部更红
图 12-17sheen 粗糙度从左到右 0.15 → 1.0。粗糙度小时 sheen 只是轮廓上的一条细边,大时铺满整个受光面,像蒙了一层绒
关于环境光的 sheen直接光部分是严格的公式,环境光部分就不是了。正确的做法和 split-sum 一样:把 DCharlieVNeubelt 对半球积分,预先算成一张以 (N·V, α) 为坐标的 LUT(Filament 把它放在 DFG 表的第三个通道里)。本案例为了不再引入一张贴图,用了一个随掠射角增强的经验系数,代码里已经注明。需要精确时,可以参照第 11 章生成环境 BRDF LUT 的方法,把积分核换成布料的 D·V 再算一张。

调参上,布料的 sheen 颜色通常比底色更亮、饱和度更低:深红丝绒的 sheen 偏粉白,深蓝的偏灰蓝。棉布、亚麻这类没有明显绒毛的布料,sheen 很弱,用普通的高粗糙度 GGX 也差不多;sheen 真正出彩的是丝绒、绒面皮革和毛毡。

12.6 次表面散射:让光在皮下走一段路

皮肤、蜡烛、玉石、牛奶、大理石都是“半透明”的:光从一点进入,在材质内部被散射成千上万次,最后从附近(可能几毫米之外)的另一点出来。漫反射的 Lambert 模型假设出射点就是入射点,在宏观尺度上这对大多数材质都成立;但对皮肤来说,几毫米的横向传播足以产生肉眼可见的效果:明暗交界线变软、带上红色(红光在皮下走得最远),鼻翼、耳廓这些薄而弯曲的地方被光“透”亮。

严格的次表面散射需要知道从每个入射点到每个出射点的能量传递,也就是扩散剖面(diffusion profile)R(r):在平坦、无限大的材质表面打一束极细的光,距离入射点 r 处出射的能量。d'Eon 和 Luebke 在《GPU Gems 3》第 14 章里把人类皮肤的剖面拟合成 6 个高斯函数之和,每个高斯有自己的方差和 RGB 权重;红色通道的权重集中在方差大的几项上,这正是“红光走得远”的定量描述。HDRP 和各大引擎的屏幕空间 SSS 就是在屏幕上用这样的剖面做模糊。

屏幕空间模糊需要额外的 Pass 和不少带宽。本节介绍两种只改漫反射项、零额外 Pass 的近似:Wrap Lighting 和预积分皮肤散射。

Wrap Lighting

次表面散射最直观的效果是“光绕到了背光面”。Wrap 直接在 N·L 上做文章:把余弦曲线向负方向“包”一段,让 N·L 略小于 0 的区域也有光:

fwrap=saturate(N·L+w(1+w)2)

分母取 (1+w)2 而不是 1+w,是为了让它在整个半球上的积分和 Lambert 相同,保持能量守恒(Filament 的写法)。只取 1+w 也很常见,那样正对光源处仍为 1,但总能量会变大。

Assets/Cookbook/Ch12/AdvBRDF.hlsl(节选:wrap)
// ---------- 包裹漫反射(Wrap Lighting),w ∈ [0,1] ----------
// 除以 (1+w)² 是为了让半球积分仍然 ≈ 能量守恒
float AdvWrapDiffuse(float NoL_unclamped, float w)
{
    return saturate((NoL_unclamped + w) / ((1.0 + w) * (1.0 + w)));
}
// ---------- 包裹漫反射(Wrap Lighting),w ∈ [0,1] ----------
// 除以 (1+w)² 是为了让半球积分仍然 ≈ 能量守恒
float AdvWrapDiffuse(float NoL_unclamped, float w)
{
    return saturate((NoL_unclamped + w) / ((1.0 + w) * (1.0 + w)));
}

皮肤用 Wrap 时有个常见的增强:每个颜色通道的包裹量不同。红色包得最多,绿色次之,蓝色最少,交界线附近就自然出现一条红色的过渡带。这是对“红光散射距离更远”的粗略模仿,便宜而有效。

预积分皮肤散射

Penner 在 2011 年(SIGGRAPH 课程与《GPU Pro 2》)提出了一个更“物理”的思路:表面越弯曲,散射造成的明暗变化越明显——平面上的散射只是把光挪了个位置,明暗分布不变;而在一个小球上,散射会把亮面的光带进暗面。既然效果只取决于两个量:N·L 和曲率 1/r,那就可以预先把散射积分算好,存成一张以它们为坐标的二维 LUT。

具体的积分在一个半径为 r 的圆环上进行(可以理解为球的一个截面):圆环上某点的法线与光线夹角为 θ,环上另一点与它相隔角度 x,两点间的弦长是 2rsin(x/2),那一点接收到的光是 saturate(cos(θ+x)),按扩散剖面加权后求平均:

D(θ,r)=∫−ππsaturate(cos(θ+x))R(2rsinx2)dx∫−ππR(2rsinx2)dx
r 着色点(夹角 θ) 环上另一点(相隔 x) 弦长 2r·sin(x/2) 平行光 蓝:散射距离短绿红:散射最远 扩散剖面 R(d) 的示意
图 12-18预积分的几何意义:着色点的出射光,是圆环上所有点接收的光按扩散剖面(随弦长衰减)加权后的平均

LUT 的横轴是 N·L(从 −1 到 1),纵轴是曲率 1/r。曲率为 0 时 r 无穷大,散射只在极小的邻域内发生,积分退化成 saturate(cosθ),也就是 Lambert;曲率越大,红色通道向暗面渗透得越多。

案例 12.6皮肤:Wrap 与程序生成的预积分 LUT高阶单独打开 ↗

目标:在 C# 里数值积分生成预积分皮肤 LUT(同时提供编辑器菜单把它烘焙成贴图);Shader 里用屏幕空间导数估计曲率,对比 Lambert、彩色 Wrap 和 LUT 三种漫反射。

生成 LUT

先把 d'Eon 的 6 高斯剖面写出来:

Assets/Cookbook/Ch12/Editor/SkinLutBaker.cs(节选:profile)
// 每个高斯的方差(mm²)与 RGB 权重
static readonly float[] Variance = { 0.0064f, 0.0484f, 0.187f, 0.567f, 1.99f, 7.41f };
static readonly Vector3[] Weight =
{
    new Vector3(0.233f, 0.455f, 0.649f),
    new Vector3(0.100f, 0.336f, 0.344f),
    new Vector3(0.118f, 0.198f, 0.000f),
    new Vector3(0.113f, 0.007f, 0.007f),
    new Vector3(0.358f, 0.004f, 0.000f),
    new Vector3(0.078f, 0.000f, 0.000f),
};

// 散射剖面 R(d):距离入射点 d 毫米处的出射能量
// 注意归一化用的是一维高斯 1/sqrt(2πv):我们只沿圆环(一维)积分,
// 若用二维的 1/(2πv),宽高斯(红光的长距离散射)会被严重低估
static Vector3 Profile(float d)
{
    Vector3 r = Vector3.zero;
    for (int k = 0; k < 6; k++)
    {
        float v = Variance[k];
        float g = Mathf.Exp(-d * d / (2f * v)) / Mathf.Sqrt(2f * Mathf.PI * v);
        r += Weight[k] * g;
    }
    return r;
}

这里有个容易出错的细节:原文的 6 个高斯是二维归一化的(在平面上积分为 1),而我们只沿一个圆环做一维积分。如果照搬二维的归一化系数 1/(2πv),方差大的高斯(恰恰是红光那几项)在一维积分里会被压低一个 v 量级,生成的 LUT 几乎看不出红色。笔者第一次实现时就踩了这个坑,改成一维的 1/2πv 之后才得到正常的结果,Penner 原文附带的代码也是这种写法。

积分本身就是公式的直接翻译,用 256 个采样点的中点法则:

Assets/Cookbook/Ch12/Editor/SkinLutBaker.cs(节选:integrate)
// 在半径为 r 的圆环上积分:入射点与出射点夹角 x,弦长 2r·sin(x/2)
// D(θ) = ∫ saturate(cos(θ + x)) R(2r sin(x/2)) dx / ∫ R(2r sin(x/2)) dx
public static Color Integrate(float cosTheta, float invRadius)
{
    float theta = Mathf.Acos(Mathf.Clamp(cosTheta, -1f, 1f));
    float r = 1f / Mathf.Max(invRadius, 1e-4f);
    Vector3 num = Vector3.zero, den = Vector3.zero;
    const int Steps = 256;
    float dx = 2f * Mathf.PI / Steps;
    for (int s = 0; s < Steps; s++)
    {
        float x = -Mathf.PI + (s + 0.5f) * dx;
        Vector3 p = Profile(Mathf.Abs(2f * r * Mathf.Sin(x * 0.5f)));
        num += p * Mathf.Max(0f, Mathf.Cos(theta + x));
        den += p;
    }
    return new Color(num.x / den.x, num.y / den.y, num.z / den.z, 1f);
}
Assets/Cookbook/Ch12/Editor/SkinLutBaker.cs(节选:generate)
public static Texture2D Generate(int width = 256, int height = 64)
{
    var tex = new Texture2D(width, height, TextureFormat.RGBAHalf, false, true)   // 半精度 + 线性:暗部不出色带
    {
        name = "SkinLUT", wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear
    };
    var px = new Color[width * height];
    for (int y = 0; y < height; y++)
    {
        float invR = (y + 0.5f) / height * MaxInvRadius;
        for (int x = 0; x < width; x++)
        {
            float ndl = (x + 0.5f) / width * 2f - 1f;
            px[y * width + x] = Integrate(ndl, invR);
        }
    }
    tex.SetPixels(px);
    tex.Apply();
    return tex;
}

纵轴 v ∈ [0, 1] 映射到 1/r ∈ [0, 1] mm⁻¹,也就是半径从无穷大到 1 mm。贴图用 RGBAHalf 且标记为线性:LUT 里是数据而不是颜色,不能让 GPU 做 sRGB 解码;8 位精度在暗部会出色带,存成 PNG 时要注意(菜单 Cookbook › Ch12 › Bake Skin LUT 会存一张 8 位 PNG 并把导入设置改为非 sRGB;要求高的项目建议存 EXR)。

图 12-19程序生成的预积分皮肤 LUT。横轴:N·L 从 −1(左)到 1(右);纵轴:曲率从 0(下)到 1 mm⁻¹(上)。底部一行就是 Lambert,越往上红色越向暗面渗透

图中右上角略带青色,这是对的:红光“搬”到暗面去了,亮面剩下的红光变少,于是相对偏青。真实皮肤上这种偏色被固有色的红掩盖,几乎察觉不到。

在 Shader 里使用

三种漫反射模式写在一个函数里:

Assets/Cookbook/Ch12/Skin.shader(节选:diffuse)
float3 SkinDiffuse(MaterialCtx m, float NoLu, float shadow)
{
#if defined(_DIFFUSE_WRAP)
    // 每个通道包裹量不同:红光在皮下走得最远,所以红通道包得最多
    // 阴影同样折进 N·L:阴影里的点按“刚好背光”处理,包裹项让阴影边缘也透出一点红
    float3 w = _Wrap * _ScatterColor.rgb;
    float ndl = lerp(min(NoLu, 0.0), NoLu, shadow);
    return saturate((ndl + w) / (1.0 + w));
#elif defined(_DIFFUSE_LUT)
    // 预积分 LUT:u = N·L 映射到 0~1,v = 曲率
    // 阴影先折进 N·L 再查表,让阴影边缘也带上散射的红色过渡
    float u = lerp(min(NoLu, 0.0), NoLu, shadow) * 0.5 + 0.5;
    return SAMPLE_TEXTURE2D_LOD(_SkinLUT, sampler_SkinLUT, float2(u, m.curvature), 0).rgb;
#else
    return saturate(NoLu) * shadow;
#endif
}
vec3 SkinDiffuse(MaterialCtx m, float NoLu, float shadow)
{
#if defined(_DIFFUSE_WRAP)
    // 每个通道包裹量不同:红光在皮下走得最远,所以红通道包得最多
    // 阴影同样折进 N·L:阴影里的点按“刚好背光”处理,包裹项让阴影边缘也透出一点红
    vec3 w = _Wrap * _ScatterColor.rgb;
    float ndl = mix(min(NoLu, 0.0), NoLu, shadow);
    return saturate((ndl + w) / (1.0 + w));
#elif defined(_DIFFUSE_LUT)
    // 预积分 LUT:u = N·L 映射到 0~1,v = 曲率
    // 阴影先折进 N·L 再查表,让阴影边缘也带上散射的红色过渡
    float u = mix(min(NoLu, 0.0), NoLu, shadow) * 0.5 + 0.5;
    return textureLod(_SkinLUT, vec2(u, m.curvature), 0.0).rgb;
#else
    return vec3(saturate(NoLu) * shadow);
#endif
}

阴影的处理值得一提。如果像 Lambert 那样最后乘阴影,阴影边缘会是一条生硬的线,散射效果被一刀切掉。这里把阴影“折”进 N·L:处于阴影中的点按“刚好背光”(N·L 取 min(N·L, 0))去查表,阴影边缘就也有了红色的过渡。Penner 的原始方案更进一步,为阴影的半影再做一张 LUT;对大多数项目,这个折中已经足够。

曲率用屏幕空间导数估计:fwidth(N) 是相邻像素间法线的变化量,fwidth(positionWS) 是相邻像素间位置的变化量,二者相除就是“每米弯多少”,即 1/r:

Assets/Cookbook/Ch12/Skin.shader(节选:curvature)
// 曲率 = 法线变化量 / 位置变化量,用屏幕空间导数估计(单位 1/米)
float curv = length(fwidth(N)) / max(length(fwidth(i.positionWS)), 1e-5);
curv = saturate(curv * _CurvatureScale);   // 换算到 LUT 的 0~1,比例取决于场景单位和 LUT 的定义
if (_Curvature >= 0) curv = _Curvature;
// 曲率 = 法线变化量 / 位置变化量,用屏幕空间导数估计(单位 1/米)
float curv = length(fwidth(N)) / max(length(fwidth(positionWS)), 1e-5);
curv = saturate(curv * _CurvatureScale);   // 换算到 LUT 的 0~1,比例取决于场景单位和 LUT 的定义
if (_Curvature >= 0.0) curv = _Curvature;

这个估计在三角形边界处会有跳变(插值法线的导数不连续),正式项目通常在 DCC 里预先计算曲率存进顶点色或贴图。_CurvatureScale 负责把“场景单位下的 1/米”换算成 LUT 的纵坐标——如果角色头部半径 0.1 m(曲率约 10 m⁻¹),要映射到 LUT 的哪一行,取决于你希望效果有多强,这本质上是一个美术参数。

Assets/Cookbook/Ch12/Skin.shader(节选:shade)
half3 ShadeOneLight(MaterialCtx m, Light light)
{
    float3 L = light.direction;
    float NoLu = dot(m.N, L);
    float NoL = saturate(NoLu);
    float3 diffuse = m.albedo * SkinDiffuse(m, NoLu, light.shadowAttenuation);

    // 双瓣高光:一个偏光滑、一个偏粗糙,各占一半,模拟皮脂层 + 角质层
    float3 spec = 0.5 * AdvSpecularGGX(m.N, m.V, L, m.a, _SpecularF0)
                + 0.5 * AdvSpecularGGX(m.N, m.V, L, saturate(m.a * 2.2), _SpecularF0);
    spec *= PI * NoL * light.shadowAttenuation;

    return (diffuse + spec) * light.color * light.distanceAttenuation;
}
vec3 ShadeOneLight(MaterialCtx m, Light light)
{
    vec3 L = light.direction;
    float NoLu = dot(m.N, L);
    float NoL = saturate(NoLu);
    vec3 diffuse = m.albedo * SkinDiffuse(m, NoLu, light.shadowAttenuation);

    // 双瓣高光:一个偏光滑、一个偏粗糙,各占一半,模拟皮脂层 + 角质层
    vec3 spec = 0.5 * AdvSpecularGGX(m.N, m.V, L, m.a, _SpecularF0)
              + 0.5 * AdvSpecularGGX(m.N, m.V, L, saturate(m.a * 2.2), _SpecularF0);
    spec *= PI * NoL * light.shadowAttenuation;

    return (diffuse + spec) * light.color * light.distanceAttenuation;
}

高光用了两个 GGX 波瓣各占一半,F0 取 0.028(皮肤折射率约 1.4):皮肤表面有一层油脂,再下面是角质层,单一粗糙度的高光看起来像塑料,双瓣会自然很多。

图 12-20不同大小的“皮肤”球,侧光照明,环境光压暗以便观察明暗交界。左:Lambert;中:彩色 Wrap(红色包裹最多);右:预积分 LUT,小球(曲率大)明显更“透”、交界线更红更软

注意中图和右图的区别:Wrap 对所有球一视同仁,大球和小球的交界线宽度一样,而且在阴影边缘出现了一条较硬的红线;LUT 的效果随曲率变化——这正是预积分方法的价值所在:鼻尖、耳朵、手指这些曲率大的地方散射明显,额头、脸颊这些平缓的地方几乎不变。

常见坑① LUT 贴图一定要关掉 sRGB 和压缩,Wrap Mode 设为 Clamp,否则 u = 0 和 u = 1 处会与另一侧插值。② 法线贴图带来的高频细节会让曲率估计严重失真,算曲率时要用几何法线(未经法线贴图扰动的插值法线),查 LUT 的 N·L 则可以用扰动后的法线。③ 预积分只处理“曲率造成的散射”,处理不了薄处的透光,那是下一节的内容。

12.7 薄片透射

树叶、花瓣、纸灯笼、耳廓在背光时会被照亮——光直接穿过了整个物体。这在实时渲染里通常单独加一个透射项,最流行的是 Barré-Brisebois 和 Bouchard 在 GDC 2011 上分享的方案(EA DICE 的《寒霜》引擎使用),思路非常简单:如果视线正好对着“穿过物体的光”看过来,就亮。

叶片(法线 N 朝向相机一侧) 光从背后照来(L 指向光源) −(L + N·distortion) 相机:V 越接近透射方向越亮
图 12-21快速透射近似:把光方向沿法线“扭”一点代表内部散射的偏折,看视线与透射方向的接近程度
Itrans=(s·saturate(V·−normalize(L+Nδ))p+a)·(1−t)

δ 是法线扭曲量(越大,透射越不依赖视线方向,像更“浑”的介质),p 控制透射光斑的集中程度,s 是强度,a 是一个不依赖角度的“环境透射”,t 是厚度(0~1)。厚度通常来自一张美术绘制或离线烘焙的贴图(把法线反向做 AO 烘焙,就能近似得到“局部厚度”),薄的地方透得多。

案例 12.7背光的树叶进阶单独打开 ↗

目标:做一组双面的程序化树叶,对比无透射、均匀透射、按叶脉厚度透射三种情况。

叶片形状和叶脉都在 Shader 里程序生成:UV 的 y 从叶柄到叶尖,宽度按正弦的幂变化,再乘一个线性收窄;主脉是 x = 0 附近的一条细线,侧脉是斜向重复的条纹。叶脉比叶肉厚,透光少:

Assets/Cookbook/Ch12/Translucent.shader(节选:leaf)
// 程序化叶片:UV 的 y 从叶柄(0)到叶尖(1)。返回 x=是否在叶内(符号距离),y=叶脉强度(0~1)
float2 LeafShape(float2 uv)
{
    float x = uv.x - 0.5, y = uv.y;
    float halfWidth = 0.44 * pow(saturate(sin(PI * y)), 0.75) * (1.0 - 0.35 * y);
    float inside = halfWidth - abs(x);
    float mid = 1.0 - smoothstep(0.004, 0.018, abs(x));                       // 主脉
    float f = frac((y - abs(x) * 0.9) * 7.0);                                 // 斜向重复的侧脉
    float side = 1.0 - smoothstep(0.03, 0.09, min(f, 1.0 - f));
    side *= smoothstep(0.02, 0.06, abs(x)) * smoothstep(0.0, 0.1, inside);    // 侧脉,离边缘淡出
    return float2(inside, saturate(max(mid, side * 0.65)));
}
// 程序化叶片:UV 的 y 从叶柄(0)到叶尖(1)。返回 x=是否在叶内(符号距离),y=叶脉强度(0~1)
vec2 LeafShape(vec2 uv)
{
    float x = uv.x - 0.5, y = uv.y;
    float halfWidth = 0.44 * pow(saturate(sin(PI * y)), 0.75) * (1.0 - 0.35 * y);
    float inside = halfWidth - abs(x);
    float mid = 1.0 - smoothstep(0.004, 0.018, abs(x));                       // 主脉
    float f = fract((y - abs(x) * 0.9) * 7.0);                                // 斜向重复的侧脉
    float side = 1.0 - smoothstep(0.03, 0.09, min(f, 1.0 - f));
    side *= smoothstep(0.02, 0.06, abs(x)) * smoothstep(0.0, 0.1, inside);    // 侧脉,离边缘淡出
    return vec2(inside, saturate(max(mid, side * 0.65)));
}
Assets/Cookbook/Ch12/Translucent.shader(节选:shade)
half3 ShadeOneLight(MaterialCtx m, Light light)
{
    float3 L = light.direction;
    float NoL = saturate(dot(m.N, L));
    float3 front = (m.albedo * ADV_INV_PI + AdvSpecularGGX(m.N, m.V, L, m.a, 0.04)) * PI * NoL;

    // 透射:把光方向沿法线“扭”一点(模拟光在叶片内部的散射偏折),
    // 再看视线与 -L' 的接近程度——正对着光源看时最亮
    float3 Lt = normalize(L + m.N * _Distortion);
    float VoLt = pow(saturate(dot(m.V, -Lt)), _TransPower) * _TransScale;
    float3 trans = (VoLt + _TransAmbient) * (1.0 - m.thickness) * _TransColor.rgb * m.albedo * _Transmission;

    // 透射光走的是“叶片背面”的阴影:正面阴影图对它没有意义,这里只用距离衰减
    return front * light.color * (light.distanceAttenuation * light.shadowAttenuation)
         + trans * light.color * light.distanceAttenuation;
}
vec3 ShadeOneLight(MaterialCtx m, Light light)
{
    vec3 L = light.direction;
    float NoL = saturate(dot(m.N, L));
    vec3 front = (m.albedo * ADV_INV_PI + AdvSpecularGGX(m.N, m.V, L, m.a, 0.04)) * PI * NoL;

    // 透射:把光方向沿法线“扭”一点(模拟光在叶片内部的散射偏折),
    // 再看视线与 -L' 的接近程度——正对着光源看时最亮
    vec3 Lt = normalize(L + m.N * _Distortion);
    float VoLt = pow(saturate(dot(m.V, -Lt)), _TransPower) * _TransScale;
    vec3 trans = (VoLt + _TransAmbient) * (1.0 - m.thickness) * _TransColor.rgb * m.albedo * _Transmission;

    // 透射光走的是“叶片背面”的阴影:正面阴影图对它没有意义,这里只用距离衰减
    return front * light.color * (light.distanceAttenuation * light.shadowAttenuation)
         + trans * light.color * light.distanceAttenuation;
}

透射项没有乘阴影衰减:从正面看,叶片背后接收的光才是透射的来源,而正面的阴影图查询结果反映的是“正面是否被挡”,用在这里没有意义(单片叶子时,它甚至会被自己挡住)。更完整的做法是从光源一侧采样阴影图,得到“光穿进物体之前是否被挡”,那需要把采样点挪到背面,留作练习。

叶片双面渲染(Cull Off)时,背面的法线要翻转,否则背面的光照是反的。片元着色器可以用 SV_IsFrontFace 语义拿到当前片元是否是正面。形状是程序化裁剪的,URP 自带的 ShadowCaster 不知道这件事,阴影会是一个方块,所以这里手写了一个带 clip 的阴影 Pass,写法与 URP 的 ShadowCasterPass.hlsl 一致(法线偏移、点光源/方向光两种光方向、近裁剪面钳制):

Assets/Cookbook/Ch12/Translucent.shader(节选:shadowpass)
// 叶片形状是程序化裁剪的,URP 自带的 ShadowCaster 不知道,所以自己写一个带 clip 的
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On ZTest LEqual ColorMask 0 Cull Off
    HLSLPROGRAM
    #pragma vertex vert
    #pragma fragment frag
    #pragma multi_compile_instancing
    #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl"
    float3 _LightDirection;
    float3 _LightPosition;
    struct A { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID };
    struct V { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; };
    V vert(A v)
    {
        UNITY_SETUP_INSTANCE_ID(v);
        V o;
        float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
        float3 normalWS = TransformObjectToWorldNormal(v.normalOS);
    #if _CASTING_PUNCTUAL_LIGHT_SHADOW
        float3 L = normalize(_LightPosition - positionWS);
    #else
        float3 L = _LightDirection;
    #endif
        o.positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, L));
    #if UNITY_REVERSED_Z
        o.positionCS.z = min(o.positionCS.z, UNITY_NEAR_CLIP_VALUE);
    #else
        o.positionCS.z = max(o.positionCS.z, UNITY_NEAR_CLIP_VALUE);
    #endif
        o.uv = v.uv;
        return o;
    }
    half4 frag(V i) : SV_Target { clip(LeafShape(i.uv).x); return 0; }
    ENDHLSL
}
// 叶片形状是程序化裁剪的,URP 自带的 ShadowCaster 不知道,所以自己写一个带 clip 的
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On ZTest LEqual ColorMask 0 Cull Off
    GLSLPROGRAM
    VARYING vec2 uv;
    #ifdef VERTEX
    void main()
    {
        vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
        vec3 normalWS = TransformObjectToWorldNormal(normalOS);
        // _CASTING_PUNCTUAL_LIGHT_SHADOW 在 GLSL 版里是个 uniform
        vec3 L = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection;
        vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, L));
        // WebGL 深度不反转(UNITY_REVERSED_Z = 0),走 max 分支
        positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE * positionCS.w);
        uv = texcoord0.xy;
        gl_Position = positionCS;
    }
    #endif
    #ifdef FRAGMENT
    void main() { if (LeafShape(uv).x < 0.0) discard; fragColor = vec4(0.0); }
    #endif
    ENDGLSL
}
图 12-22太阳在叶片后方、朝相机照来。左:无透射,只有一点环境光,叶片发暗;中:均匀厚度的透射,叶肉和叶脉一起透亮;右:按叶脉厚度透射,叶脉透光少,在亮的叶肉中显出暗色纹路

扩展:用同一个透射项做玉石和蜡烛

把透射项用在封闭的实体上(玉雕、蜡烛、皮肤的耳朵),关键在厚度。一个常见的离线做法是“反向 AO”:在每个顶点处沿法线的反方向朝物体内部发射一圈射线,测量平均穿出距离,归一化后存进顶点色。厚处透得少、薄处透得多,配合附加光(例如把点光源放在玉雕背后),就有很不错的“通透”感。HDRP 的 Thickness Map 与本节的厚度含义相同。

12.8 薄膜干涉:彩虹色从哪来

肥皂泡、水面上的油膜、甲虫的外壳、回火后的钛和钢,表面会随视角变换出彩虹般的颜色。这些颜色不是色素,而是干涉:表面覆盖着一层厚度在几百纳米量级的透明薄膜,光在薄膜的上表面反射一次,另一部分折射进去、在下表面反射后再出来。两束光走过的路程不同,相位不同,叠加时有的波长相长(变亮)、有的相消(变暗),反射光的颜色就取决于膜厚和观察角度。

薄膜:折射率 n₂,厚度 d(几百 nm) 基底:折射率 n₃ 空气 n₁ = 1 ① 上表面反射 ② 下表面反射,多走了一段光程 θ₂ 两束光的光程差 OPD = 2·n₂·d·cosθ₂;在 λ 下相位差 δ = 2π·OPD/λ
图 12-23薄膜干涉:两束反射光的光程差决定了每个波长是相长还是相消

Airy 公式

把薄膜内无穷多次反射的级数加起来,可以得到三层结构的反射率(Airy 公式)。设 r12、r23 分别是上、下两个界面的振幅反射系数(带符号),则对波长 λ:

R(λ)=r122+r232+2r12r23cosδ1+r122r232+2r12r23cosδ,δ=2πλ·2n2dcosθ2

法向入射时 rab=(na−nb)/(na+nb),符号为负表示从光疏到光密介质反射时的半波损失(π 相位突变)。用带符号的振幅,半波损失就自动包含在 r12r23 的符号里,不需要单独加 π。我们用 Schlick 的角度项把法向入射的反射率推广到斜入射(只是近似,忽略了偏振),再按斯涅尔定律求膜内的折射角 θ₂。

从光谱到 RGB

R(λ) 随波长变化,而显示器只有 RGB 三个通道。最偷懒的做法是只在三个代表波长(如 650、532、450 nm)各算一次,直接当作 R、G、B。这样得到的颜色饱和度过高、色相跳变生硬,因为真实的颜色是整条光谱和人眼的三种视锥响应的积分。更合理的做法是:在可见光范围内取若干个波长,把每个波长的反射率乘以 CIE 1931 配色函数 x¯,y¯,z¯ 累加成 XYZ,再转换到线性 sRGB。配色函数用 Wyman、Sloan 和 Shirley 在 2013 年给出的多瓣高斯解析拟合,不需要查表。最后再除以“反射率处处为 1 的光谱”得到的 RGB,保证与波长无关的反射率被映射成中性灰。

Assets/Cookbook/Ch12/AdvBRDF.hlsl(节选:thinfilm)
// ---------- 薄膜干涉 ----------
// CIE 1931 配色函数的多瓣高斯拟合(Wyman, Sloan & Shirley 2013)
float AdvG(float x, float mu, float s1, float s2)
{
    float t = (x - mu) / (x < mu ? s1 : s2);
    return exp(-0.5 * t * t);
}
float3 AdvWavelengthToXYZ(float lambda)
{
    float x = 1.056 * AdvG(lambda, 599.8, 37.9, 31.0) + 0.362 * AdvG(lambda, 442.0, 16.0, 26.7)
            - 0.065 * AdvG(lambda, 501.1, 20.4, 26.2);
    float y = 0.821 * AdvG(lambda, 568.8, 46.9, 40.5) + 0.286 * AdvG(lambda, 530.9, 16.3, 31.1);
    float z = 1.217 * AdvG(lambda, 437.0, 11.8, 36.0) + 0.681 * AdvG(lambda, 459.0, 26.0, 13.8);
    return float3(x, y, z);
}
float3 AdvXYZToLinearSRGB(float3 c)
{
    return float3( 3.2406 * c.x - 1.5372 * c.y - 0.4986 * c.z,
                  -0.9689 * c.x + 1.8758 * c.y + 0.0415 * c.z,
                   0.0557 * c.x - 0.2040 * c.y + 1.0570 * c.z);
}

// 单个波长下“空气(1) / 薄膜(n2,厚 d 纳米) / 基底”三层结构的反射率(Airy 公式)
// r12、r23 是带符号的振幅反射系数,符号里已经包含了半波损失(π 相位)
float AdvAiryReflectance(float r12, float r23, float opd, float lambda)
{
    float delta = 2.0 * ADV_PI * opd / lambda;   // 两束光的相位差
    float c = cos(delta);
    float num = r12 * r12 + r23 * r23 + 2.0 * r12 * r23 * c;
    float den = 1.0 + r12 * r12 * r23 * r23 + 2.0 * r12 * r23 * c;
    return num / den;
}

// 返回线性 sRGB 下的反射率颜色。cosTheta1:入射角余弦;thickness:膜厚(nm);
// filmIOR:膜折射率;baseF0:基底在空气中的 F0(金属取其颜色亮度即可)
float3 AdvThinFilm(float cosTheta1, float thickness, float filmIOR, float baseF0)
{
    // 斯涅尔定律求膜内折射角
    float sin2 = (1.0 - cosTheta1 * cosTheta1) / (filmIOR * filmIOR);
    float cosTheta2 = sqrt(saturate(1.0 - sin2));
    float opd = 2.0 * filmIOR * thickness * cosTheta2;  // 光程差

    // 法向入射时的振幅反射系数,再用 Schlick 的角度项放大到掠射角
    float r12n = (1.0 - filmIOR) / (1.0 + filmIOR);                 // 空气→膜:负号=半波损失
    float baseIOR = (1.0 + sqrt(baseF0)) / (1.0 - sqrt(min(baseF0, 0.99)));
    float r23n = (filmIOR - baseIOR) / (filmIOR + baseIOR);
    float r12 = -sqrt(AdvF_Schlick(r12n * r12n, 1.0, cosTheta1));
    float r23 = sign(r23n) * sqrt(AdvF_Schlick(r23n * r23n, 1.0, cosTheta2));

    float3 xyz = 0, xyzWhite = 0;
    const int N = 16;
    [unroll] for (int i = 0; i < N; i++)
    {
        float lambda = 400.0 + 300.0 * (i + 0.5) / N;   // 400~700nm 等间隔采样
        float3 cmf = AdvWavelengthToXYZ(lambda);
        xyz      += cmf * AdvAiryReflectance(r12, r23, opd, lambda);
        xyzWhite += cmf;
    }
    // 用“反射率处处为 1”的光谱做归一化:常数反射率 → 中性灰
    float3 rgb = AdvXYZToLinearSRGB(xyz) / AdvXYZToLinearSRGB(xyzWhite);
    return max(rgb, 0.0);
}
// ---------- 薄膜干涉 ----------
// CIE 1931 配色函数的多瓣高斯拟合(Wyman, Sloan & Shirley 2013)
float AdvG(float x, float mu, float s1, float s2)
{
    float t = (x - mu) / (x < mu ? s1 : s2);
    return exp(-0.5 * t * t);
}
vec3 AdvWavelengthToXYZ(float lambda)
{
    float x = 1.056 * AdvG(lambda, 599.8, 37.9, 31.0) + 0.362 * AdvG(lambda, 442.0, 16.0, 26.7)
            - 0.065 * AdvG(lambda, 501.1, 20.4, 26.2);
    float y = 0.821 * AdvG(lambda, 568.8, 46.9, 40.5) + 0.286 * AdvG(lambda, 530.9, 16.3, 31.1);
    float z = 1.217 * AdvG(lambda, 437.0, 11.8, 36.0) + 0.681 * AdvG(lambda, 459.0, 26.0, 13.8);
    return vec3(x, y, z);
}
vec3 AdvXYZToLinearSRGB(vec3 c)
{
    return vec3( 3.2406 * c.x - 1.5372 * c.y - 0.4986 * c.z,
                -0.9689 * c.x + 1.8758 * c.y + 0.0415 * c.z,
                 0.0557 * c.x - 0.2040 * c.y + 1.0570 * c.z);
}

// 单个波长下“空气(1) / 薄膜(n2,厚 d 纳米) / 基底”三层结构的反射率(Airy 公式)
// r12、r23 是带符号的振幅反射系数,符号里已经包含了半波损失(π 相位)
float AdvAiryReflectance(float r12, float r23, float opd, float lambda)
{
    float delta = 2.0 * ADV_PI * opd / lambda;   // 两束光的相位差
    float c = cos(delta);
    float num = r12 * r12 + r23 * r23 + 2.0 * r12 * r23 * c;
    float den = 1.0 + r12 * r12 * r23 * r23 + 2.0 * r12 * r23 * c;
    return num / den;
}

// 返回线性 sRGB 下的反射率颜色。cosTheta1:入射角余弦;thickness:膜厚(nm);
// filmIOR:膜折射率;baseF0:基底在空气中的 F0(金属取其颜色亮度即可)
vec3 AdvThinFilm(float cosTheta1, float thickness, float filmIOR, float baseF0)
{
    // 斯涅尔定律求膜内折射角
    float sin2 = (1.0 - cosTheta1 * cosTheta1) / (filmIOR * filmIOR);
    float cosTheta2 = sqrt(saturate(1.0 - sin2));
    float opd = 2.0 * filmIOR * thickness * cosTheta2;  // 光程差

    // 法向入射时的振幅反射系数,再用 Schlick 的角度项放大到掠射角
    float r12n = (1.0 - filmIOR) / (1.0 + filmIOR);                 // 空气→膜:负号=半波损失
    float baseIOR = (1.0 + sqrt(baseF0)) / (1.0 - sqrt(min(baseF0, 0.99)));
    float r23n = (filmIOR - baseIOR) / (filmIOR + baseIOR);
    float r12 = -sqrt(AdvF_Schlick(r12n * r12n, 1.0, cosTheta1));
    float r23 = sign(r23n) * sqrt(AdvF_Schlick(r23n * r23n, 1.0, cosTheta2));

    vec3 xyz = vec3(0.0), xyzWhite = vec3(0.0);
    const int N = 16;
    for (int i = 0; i < N; i++)
    {
        float lambda = 400.0 + 300.0 * (float(i) + 0.5) / float(N);   // 400~700nm 等间隔采样
        vec3 cmf = AdvWavelengthToXYZ(lambda);
        xyz      += cmf * AdvAiryReflectance(r12, r23, opd, lambda);
        xyzWhite += cmf;
    }
    // 用“反射率处处为 1”的光谱做归一化:常数反射率 → 中性灰
    vec3 rgb = AdvXYZToLinearSRGB(xyz) / AdvXYZToLinearSRGB(xyzWhite);
    return max(rgb, 0.0);
}

16 个波长、每个都要算两次 exp(配色函数)和一次 cos,单就 F 而言比 Schlick 贵了一两个数量级。xyzWhite 和配色函数的值与像素无关,编译器会把整个循环展开并常量折叠掉相当一部分,但开销仍然明显。移动端或大面积使用时,应该把结果预计算成一张以(cosθ,膜厚)为坐标的二维 LUT。

案例 12.8油膜与彩虹色高阶单独打开 ↗

目标:用干涉反射率替换 GGX 里的 Schlick 菲涅尔,做出油膜效果;对比 3 波长、16 波长光谱积分,以及 core 包里 HDRP 使用的 Belcour 实现。

Assets/Cookbook/Ch12/ThinFilm.shader(节选:film)
float3 FilmFresnel(float cosTheta, float thickness)
{
#if defined(_FILM_RGB3)
    // 偷懒版:只在 R/G/B 三个代表波长上算一次 Airy 反射率
    float sin2 = (1.0 - cosTheta * cosTheta) / (_FilmIOR * _FilmIOR);
    float opd = 2.0 * _FilmIOR * thickness * sqrt(saturate(1.0 - sin2));
    float r12 = -sqrt(AdvF_Schlick(Sq((1.0 - _FilmIOR) / (1.0 + _FilmIOR)), 1.0, cosTheta));
    float baseIOR = (1.0 + sqrt(_BaseF0)) / (1.0 - sqrt(min(_BaseF0, 0.99)));
    float r23 = sign(_FilmIOR - baseIOR) * sqrt(Sq((_FilmIOR - baseIOR) / (_FilmIOR + baseIOR)));
    return float3(AdvAiryReflectance(r12, r23, opd, 650.0),
                  AdvAiryReflectance(r12, r23, opd, 532.0),
                  AdvAiryReflectance(r12, r23, opd, 450.0));
#elif defined(_FILM_BELCOUR)
    // core 包里 HDRP 用的 Belcour & Barla 2017 实现;它的厚度参数是 0~1 的归一化值
    return EvalIridescence(1.0, cosTheta, saturate(thickness / 1000.0), _BaseF0.xxx);
#else
    return AdvThinFilm(cosTheta, thickness, _FilmIOR, _BaseF0);
#endif
}
vec3 FilmFresnel(float cosTheta, float thickness)
{
#if defined(_FILM_RGB3)
    // 偷懒版:只在 R/G/B 三个代表波长上算一次 Airy 反射率
    float sin2 = (1.0 - cosTheta * cosTheta) / (_FilmIOR * _FilmIOR);
    float opd = 2.0 * _FilmIOR * thickness * sqrt(saturate(1.0 - sin2));
    float r12 = -sqrt(AdvF_Schlick(Sq((1.0 - _FilmIOR) / (1.0 + _FilmIOR)), 1.0, cosTheta));
    float baseIOR = (1.0 + sqrt(_BaseF0)) / (1.0 - sqrt(min(_BaseF0, 0.99)));
    float r23 = sign(_FilmIOR - baseIOR) * sqrt(Sq((_FilmIOR - baseIOR) / (_FilmIOR + baseIOR)));
    return vec3(AdvAiryReflectance(r12, r23, opd, 650.0),
                AdvAiryReflectance(r12, r23, opd, 532.0),
                AdvAiryReflectance(r12, r23, opd, 450.0));
#elif defined(_FILM_BELCOUR)
    // core 包里 HDRP 用的 Belcour & Barla 2017 实现;它的厚度参数是 0~1 的归一化值
    return EvalIridescence(1.0, cosTheta, saturate(thickness / 1000.0), vec3(_BaseF0));
#else
    return AdvThinFilm(cosTheta, thickness, _FilmIOR, _BaseF0);
#endif
}

core 包的 BSDF.hlsl 里有一个现成的 EvalIridescence(Belcour 与 Barla 2017 的实现,HDRP 的 Iridescence 材质就用它),它在傅里叶空间里对配色函数做了解析积分,比逐波长采样更高效、也更准确。它的厚度参数是一个 0~1 的归一化值,内部还用它同时推导出膜的折射率,所以和我们的“纳米 + 折射率”参数不能一一对应,对比时只看颜色的“风格”即可。因为 URP 的 Lighting.hlsl 已经间接包含了 BSDF.hlsl,在 URP Shader 里可以直接调用它。

膜厚加了一张噪声贴图做空间变化:真实的油膜和肥皂泡厚度从来不均匀,正是厚度的起伏造成了一圈圈的彩色条纹。

图 12-24膜厚从左到右 150、250、350、450、550、650 nm(膜折射率 1.6,基底 F0 = 0.3)。同一个球上,中心与边缘的颜色也不同:视角越斜,膜内的光程差越小
图 12-25膜厚 450 ± 250 nm 的油膜纽结。左:16 波长光谱积分;中:只算 3 个代表波长,颜色偏紫绿、过渡生硬;右:core 包的 EvalIridescence(Belcour 2017)

膜厚和颜色的对应有一个经验规律:100 nm 以下几乎没有颜色(只是略微变暗或变亮);200~600 nm 是一阶、二阶干涉色,颜色最鲜艳;超过 1 μm 后,相邻波长的相位差太大,各种颜色互相抵消,又趋于灰白。所以想要“鲜艳的彩虹色”,膜厚的变化范围要落在几百纳米内。

调参建议基底越暗,干涉色越显眼:肥皂泡之所以五彩斑斓,是因为它背后是透出来的环境,自身几乎没有漫反射。做甲虫壳、改装车的“变色龙漆”时,可以把薄膜干涉放在清漆层上(把清漆的 F 换成干涉反射率),底层用深色金属——这就是把 12.2 节和本节组合起来。

案例 12.9材质展台与取舍进阶单独打开 ↗

目标:把本章的材质放进同一个场景,检查它们在同一套光照下是否协调,并总结每种材质的代价与适用场合。

放在一起看,最容易发现的问题是亮度不协调。本章各材质有的严格守恒(基准、清漆、各向异性),有的只是“看起来对”(Kajiya-Kay、布料的环境 sheen、透射项),后者的亮度全靠参数。建议在项目里固定一套“标准光照场景”(一盏主光、一个已知的环境),所有材质都在这个场景里调,再拿 URP Lit 的同色球做参照。

材质相对基准的额外开销额外资源适用场合
清漆多一组 D·V·F,多一次反射探针采样可选:清漆遮罩贴图车漆、清漆木器、碳纤维、涂层塑料
各向异性D、V 稍贵,需要切线拉丝方向(切线或方向贴图)拉丝金属、锅具、CD、缎面
Kajiya-Kay 头发两次 pow,很便宜偏移噪声贴图头发、毛发、动物毛
Charlie 布料与 GGX 相当(精确 IBL 需要 LUT)丝绒、绒布、毛毡
预积分皮肤一次 LUT 采样,曲率估计LUT 贴图角色皮肤、蜡
薄片透射一次 pow厚度贴图植物、纸、耳朵、玉石
薄膜干涉(16 波长)显著,建议做成 LUT膜厚贴图肥皂泡、油膜、甲虫、回火金属

表中的“开销”只是指令层面的相对比较,实际帧时间取决于平台、屏幕占比和带宽,务必在目标设备上用 Profiler 或 GPU 抓帧实测,不要凭感觉。

✓ 推荐
只在真正需要的材质上用进阶模型;把各个特性做成 shader_feature_local 开关,未启用时编译成基准材质;把高价的计算(薄膜、布料 IBL)预积分成 LUT。
✗ 避免
做一个“全能 Uber Shader”把所有特性都用 if 动态分支打开关闭;在移动端对整个角色使用多次环境采样;在没有正确切线的模型上开各向异性。
和 Built-in 管线的差异Built-in 管线时代,类似的效果通常写成 Surface Shader 的自定义光照函数(LightingXXX)。URP 没有 Surface Shader,自定义 BRDF 只能像本章这样写完整的顶点/片元着色器,或者在 Shader Graph 里用 Custom Function 节点(第 24 章)。本章的“模板式 include”结构可以直接移植到 Custom Function 里:把 ShadeOneLight 和光源循环包装成一个函数即可。

本章小结

  • 标准金属度模型隐含五个假设:单层界面、各向同性、高度场表面、入射点出射、光不相干。每种进阶材质破坏其中一个,对应地只需替换 BRDF 的一两项。
  • 清漆 = 底层 × (1 − c·Fc) + c·清漆 GGX;用白炉测试检验能量。URP 14 的 Complex Lit 采用同一公式,并修正底层的粗糙度与 F0。
  • 各向异性 GGX 用 αt、αb 两个粗糙度,高光沿粗糙方向(垂直于纹路)拉长;环境反射用弯曲法线近似。切线的连续性决定成败。
  • Kajiya-Kay 把高光条件从“N 接近 H”换成“T 垂直于 H”;双高光与切线偏移来自 Marschner 测量到的 R 与 TRT 两条光路。
  • Charlie sheen 的分布在掠射角最大,配合 Neubelt 可见性和 sheen 颜色描述绒面布料;包裹漫反射与次表面色让暗部更柔和。
  • Wrap 便宜但与曲率无关;预积分 LUT 把“N·L × 曲率”的散射积分预先算好,小而弯曲的地方散射更明显。生成 LUT 时注意一维积分要用一维归一化的高斯。
  • 薄片透射用“视线与扭曲后的透射方向的接近程度 × (1 − 厚度)”近似;薄膜干涉用 Airy 公式计算各波长的反射率,再经 CIE 配色函数积分为 RGB。

练习

  1. 给清漆加一个“漆层吸收颜色”参数:底层的光在漆里走了一个来回,按 c(1/N·V+1/N·L) 衰减,做出糖果漆(candy paint)。
  2. 给 Anisotropic.shader 增加一张“方向贴图”(RG 存切线空间里的拉丝方向),做出局部拉丝方向不同的金属铭牌。
  3. 仿照第 11 章生成环境 BRDF LUT 的方法,把布料的 DCharlie·VNeubelt 预积分成一张 LUT,替换 Cloth.shader 里的经验系数,再做一次白炉测试对比。
  4. 把 Skin.shader 的曲率改为从顶点色读取,写一个编辑器脚本在网格上计算每个顶点的曲率(相邻顶点法线差 / 位置差的平均)。
  5. 把 AdvThinFilm 预计算成一张 256×64 的 LUT(横轴 cosθ,纵轴膜厚),比较与实时计算的差异和性能。
  6. 组合 12.2 与 12.8:把清漆层的菲涅尔换成薄膜干涉,做一个“变色龙”车漆。