PBR 进阶材质
金属度/粗糙度模型能覆盖大多数表面,但车漆的两层反光、拉丝金属的长条高光、头发的双高光、丝绒的边缘泛光、皮肤的透红、油膜的彩虹色,都不在它的能力范围里。本章逐个拆开这些材质:先弄清楚标准模型在哪个假设上失效,再把对应的那一项 BRDF 换掉。
12.1 标准模型的边界在哪里
第 9~11 章搭起来的“金属度/粗糙度”模型,本质是一个单层微表面 BRDF:漫反射用 Lambert,镜面用 GGX 法线分布 D、Smith 可见性 V 和 Schlick 菲涅尔 F。它能这么通用,是因为背后有几条很强、却很少被明说的假设。进阶材质之所以“进阶”,就是因为它们各自破坏了其中某一条。先把这几条摆出来,后面每一节就有了明确的目标。
- 只有一层界面。光在表面上反射一次(镜面),或者钻进去被吸收、再从入射点附近散射出来(漫反射)。车漆、清漆木器、碳纤维这类“透明涂层 + 底材”的结构有两层界面,会出现两层叠在一起的高光。
- 微表面法线的分布是各向同性的。GGX 的 D 只依赖 N·H,绕法线旋转不变。拉丝金属、CD 光盘、头发、缎面都有明确的纹理方向,高光会被拉成长条。
- 表面是一张“高度场”。微表面理论把表面想象成起伏的薄膜,法线大体朝上。丝绒、毛毡上竖起来的纤维不是高度场,它们在掠射角的反光(sheen)正是标准模型最缺的那部分。
- 漫反射在入射点出射。Lambert 假设光进去后“原地”散射出来。皮肤、蜡、玉石、树叶里的光会横向走上几毫米,甚至穿透整个薄片,明暗交界会变软、带上颜色,背光处会透亮。
- 光是不相干的。菲涅尔反射率只依赖角度和折射率,与波长无关(或者只是简单地给 F0 一个颜色)。当表面盖着一层厚度与可见光波长相当的薄膜时,两束反射光会干涉,反射率随波长剧烈变化,于是出现油膜、肥皂泡上的彩虹色。
| 材质 | 破坏的假设 | 本章的做法 | 主要改动的项 |
|---|---|---|---|
| 车漆、清漆 | 单层界面 | 第二层 GGX + 能量衰减 | BRDF 叠加 |
| 拉丝金属 | 各向同性 | 各向异性 GGX(αt ≠ αb) | D、V |
| 头发 | 各向同性 + 高度场 | Kajiya-Kay 双高光 | 镜面项整体替换 |
| 丝绒、布料 | 高度场 | Charlie sheen + Neubelt 可见性 | D、V、F |
| 皮肤、蜡 | 入射点出射 | Wrap、预积分 LUT | 漫反射项 |
| 树叶、纸张 | 入射点出射(极端情况:穿透) | 快速透射近似 | 新增透射项 |
| 油膜、肥皂泡 | 不相干 | Airy 干涉 + 光谱积分 | F |
表里最后一列很关键:几乎每种材质都只动了 BRDF 里的一两项,其余部分(光源循环、阴影、环境光采样、各种 Pass)完全一样。所以本章先花一节把这些公共部分做成框架,后面每个案例只写“不一样的那几行”。
案例 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函数。
“模板式 include”:让光源循环回调材质函数
HLSL 没有函数指针,也没有虚函数,怎么让一份光源循环代码去调用每个材质各自的着色函数?办法很朴素:HLSL 的 include 是纯文本展开,只要在 #include "LightLoop.hlsl" 之前把 MaterialCtx 结构体和 ShadeOneLight 函数定义好,展开后的循环代码就能直接引用它们。这和 C++ 模板“按名字查找”的效果一样,URP 自己也大量使用这种写法(例如各个 Pass 的 hlsl 都假定调用方已经声明了 _BaseMap)。
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,要单独循环一遍,和 URPLighting.hlsl里UniversalFragmentPBR的写法一致。 - 不用自己判断
_FORWARD_PLUS关键字。Core.hlsl开头会在_FORWARD_PLUS打开时顺手定义_ADDITIONAL_LIGHTS并把USE_FORWARD_PLUS设为 1,所以只需检查_ADDITIONAL_LIGHTS。
公共顶点着色器
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 可见性项,已经约掉了 的分母:
// ---------- 各向同性 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 的着色函数:
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。物理上,点光源或方向光产生的出射辐射率是 ,其中 是垂直入射时的辐照度。Unity 的约定是:灯光的“颜色 × 强度”等于一块白色 Lambert 表面正对光源时的输出值,也就是 。URP 的 LightingPhysicallyBased 把这个 π 预先吸收进了 BRDF(漫反射项直接用 brdfData.diffuse,没有除 π;镜面项的 normalizationTerm 也相应放大了 π 倍)。我们的 BRDF 是严格按物理写的(Lambert 带 ),所以要把灯光值乘回 π,两边才能对上。
片元函数负责填写 MaterialCtx、调用光源循环,再加上间接光:
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 的积分值 。A、B 本来要查一张预积分 LUT,这里用 Karis 在 2014 年给出的移动端解析拟合,几条乘加就够:
// 环境 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:
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 }

两边的直接光高光大小、形状一致;差别主要在粗糙金属的边缘:URP 的 EnvironmentBRDFSpecular 用的是另一套更简化的近似(surfaceReduction 加 grazingTerm),在粗糙表面的掠射角给出的反射更亮。两种近似都不是“标准答案”,只要知道差异来自哪里即可。到这一步,框架就可以用了。
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 也是这么做的)。Lit.shader,只删掉了本章用不到的 Lightmap、Decal、SSAO 等项。真要上线时,按项目实际开启的功能把它们补回来。12.2 清漆:两层界面的能量分配
汽车面漆的典型结构是:底层是带颜色的色漆,常常还掺了铝片或云母片(金属漆、珠光漆);上面盖一层几十微米厚的透明清漆。从远处看,你会同时看到两种高光:一个又小又亮、边缘锐利,来自平整的清漆层;另一个更大更柔、带颜色,来自底层粗糙的色漆和金属片。单层 GGX 无论怎么调粗糙度,都只能给出其中之一。
两层 BRDF 怎么叠
光打到清漆表面时,按菲涅尔定律分成两份:比例为 的光直接被清漆反射,剩下 折射进去,照到底层。底层反射回来的光,出来时还要再穿过一次清漆界面。如果忽略清漆内部的多次反射和吸收,最常用的近似是:
其中 c 是清漆遮罩(0 = 没有清漆), 是 F0 = 0.04 的 Schlick 菲涅尔——0.04 对应折射率 1.5,是大多数透明漆、树脂的典型值。第一项的 就是“能量衰减”:被清漆反射走的那部分光,不能再算一次到底层头上。严格来说光进出各穿过一次界面,应该乘两次透射率;但实时渲染里通常只乘一次,并用视线方向的 代替 计算这个 :这样它与光源无关,直接光和环境光可以共用同一个衰减系数。Khronos 的 glTF 扩展 KHR_materials_clearcoat 给出的推荐公式就是这种形式,URP 也照此实现。
清漆对底层的两个“副作用”
严格一点的实现还会考虑底层被清漆“泡”着的事实:
- 底层的高光变宽。光从清漆(n = 1.5)进入再出来,出射方向的角度变化会被折射放大,底层的高光波瓣在空气里看起来更宽。URP 的做法是把底层粗糙度 α 先换成等效的 Blinn-Phong 指数 ,乘以 ,再换回 α。指数变小,α 变大,高光就变宽了。
- 底层的 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 就是上面那个 。直接光的混合在 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 对比。
材质上下文多了几项:底层用的扰动法线、两层各自的粗糙度、清漆遮罩和视线方向的清漆菲涅尔:
struct MaterialCtx { float3 N, V; float3 flakeN; // 带金属片扰动的底层法线 float3 diffuse, f0; float aBase; // 底层 α(可能已被清漆修正) float aCoat; // 清漆层 α float coatMask; float coatFv; // 清漆在视线方向的菲涅尔,用来衰减底层 };
单个光源的着色,和公式一一对应。底层用扰动后的金属片法线算 GGX,清漆层用宏观法线(漆面是平的):
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 系列,不需要任何贴图:
// 程序化金属片:把 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,然后环境光部分各采一次反射探针——清漆用自己的粗糙度和宏观法线,底层用修正后的粗糙度和金属片法线:
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 部分与基准材质相同):
// 清漆(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" } }

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

白炉测试:一眼看出能量是否守恒
白炉测试(Furnace Test)是检验 BRDF 能量的经典方法:把物体放进一个各方向亮度完全相同的环境里,关掉所有直接光。一个反照率为 1、不吸收能量的材质,反射出去的光应该刚好等于照进来的光——它会和背景“融为一体”,看不见轮廓。如果某处比背景亮,说明 BRDF 在那里凭空产生了能量;比背景暗,说明能量丢了(单次散射的微表面模型在粗糙时本来就会损失一些,这就是第 11 章提到的多次散射补偿要解决的问题)。
在拍摄脚本里构造这样的环境很简单:一张所有像素都是 0.5 的立方体贴图作为反射源,球谐只保留常数项 0.5,背景色设为线性 0.5(注意相机背景色按 sRGB 解释,要用 Color.gamma 换算):
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;

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

两者整体一致。可见的差别是:URP 的清漆高光略亮、略大——它的清漆镜面项用的是 URP 自己的 DirectBRDFSpecular(一种把 D 和 V 合并的简化 GGX)乘常数 0.04,而我们用的是完整的 D·V·F,F 在正对视线时比 0.04 还小一点。这类差别属于“近似方法不同”,不是谁对谁错。
UniversalForwardOnly(见 12.1 的说明)。性能与调参建议
清漆的直接光要多算一次 D·V·F,环境光要多采一次反射探针(不同的 mip 等级),总开销大约是基准材质的 1.5 倍左右,具体以目标平台实测为准。移动端可以把底层的环境反射省掉(底层粗糙,环境反射本来就很模糊,用球谐近似即可),只保留清漆那一次采样。清漆光滑度一般取 0.85~1.0;做旧、磨砂清漆可以降到 0.6 左右,但此时两层高光会混在一起,清漆的意义就不大了。金属片的密度要与屏幕分辨率匹配:格子小于一个像素时会严重闪烁,远处应当按距离或 mip 淡出金属片强度。
12.3 各向异性:当高光被拉成一条线
拿一块拉丝不锈钢板,或者看一眼不锈钢锅底的同心圆纹,你会发现灯的倒影不是一个圆斑,而是一条垂直于纹路的亮线。原因在微观几何:拉丝在表面上刻出无数平行的细槽。沿着槽的方向,表面是光滑的;垂直于槽的方向,表面起伏很大。于是微表面法线在“横跨槽”的方向上分布很宽,在“顺着槽”的方向上分布很窄,高光自然就被拉长了——拉长的方向和槽的走向垂直。
各向异性 GGX
把 GGX 的单个粗糙度 α 拆成两个:沿切线 T 的 和沿副切线 B 的 。法线分布变成(Burley 2012):
当 时,利用 很容易验证它退化为普通 GGX。高度相关的 Smith 可见性项也有对应的各向异性版本(Heitz 2014),形式上只是把 这类量换成三维向量的长度:
// ---------- 各向异性 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 的写法看起来和公式不一样,其实是把分子分母同乘了 ,避免在 α 很小时出现大数相除;Filament 和 HDRP 都是这么写的。
两个粗糙度不方便美术调节,通常用一个“各向异性度”k ∈ (−1, 1) 加原来的 α 来派生。各家引擎的映射和正负号约定并不统一:Filament 用 ,即 k > 0 时沿 T 方向更粗糙;HDRP 使用另一套基于 的映射。本书约定 T 就是拉丝方向,k > 0 表示“沿 T 光滑、横跨 T 粗糙”,这样美术只需要把切线对准纹路就行。换引擎、换资产时,一定要先确认对方的约定,否则高光会转 90°。
案例 12.3拉丝金属与同心圆拉丝进阶单独打开 ↗
目标:用各向异性 GGX 做出直线拉丝(纽结、圆环)和同心圆拉丝(锅底、旋钮),并处理各向异性下的环境反射。
着色函数只是把 D、V 换成各向异性版本:
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)的采样坐标也要跟着改:同心圆模式下只按半径取样,条纹就变成一圈圈的同心圆:
// 选择拉丝方向;条纹贴图的纹路沿 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 都在用):把法线朝“粗糙方向”弯一弯,用弯过的法线算反射向量,再按各向同性的方式采样。
// 各向异性的环境反射:没法预滤波,常用“弯曲法线”近似(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 * 感知粗糙度):非常光滑的表面几乎不存在各向异性,不该弯。

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


12.4 头发:圆柱上的高光
头发是各向异性的极端情况:每根发丝都是一根细长的圆柱。圆柱的法线绕着轴线转了一整圈,所以对一根发丝来说,“法线”只有一个约束——垂直于发丝方向 T。只要半角向量 H 垂直于 T,就总能在圆柱上找到一个法线正好等于 H 的点,于是高光就亮。这就是 Kajiya 和 Kay 在 1989 年提出的发丝模型的核心:
它和 Blinn-Phong 的 形式相同,只是把“与法线的接近程度”换成了“与切线的垂直程度”。在一个由很多发丝组成的发型上,结果就是一条横跨发丝、环绕头部的亮带。
为什么有两条高光
Marschner 等人在 2003 年对真实头发做了测量,发现光在发丝上的散射主要有三条路径:直接在表面反射的 R;穿进发丝、从另一侧穿出的 TT;穿进去、在内壁反射一次再从入射侧出来的 TRT。发丝表面是一片片鳞片(cuticle),鳞片相对发丝轴线倾斜了几度,于是 R 的高光往发根方向偏,TRT 往发梢方向偏,两条高光错开。R 基本不带颜色(表面反射),TRT 在发丝内部走了一趟,被色素吸收后带上了发色,而且更宽、更散。
Scheuermann 在 2004 年给出的实时方案(ATI 的 Ruby 演示)把这个观察简化成:用两次 Kajiya-Kay,各自把切线 T 沿法线方向挪一点(shift),一个往发根挪、一个往发梢挪,第二条乘发色、再用噪声遮罩打散。“沿法线挪切线”之所以能移动高光,是因为 相当于让发丝“翘起”了一个角度,满足 的位置随之沿发丝方向移动。
// ---------- 头发: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 里把切线直接对齐发丝。
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);
偏移噪声(shift noise)是头发质感的关键:每一缕头发的偏移量不同,高光带就会参差不齐,像一缕缕的。噪声贴图在拍摄脚本里程序生成:沿 V(发丝方向)不变,沿 U 由一层低频的“发束”噪声和一层高频的“单根发丝”噪声叠加。同一张噪声还用来让发色带一点随机明暗,并作为第二条高光的闪烁遮罩。
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.5 布料:不是高度场的表面
丝绒(天鹅绒)、绒布、毛毡的表面竖着无数细小的纤维。正对着看时,你看到的是纤维的末端和它们之间的缝隙,颜色深;斜着看、尤其是在物体轮廓附近,视线擦着纤维的侧面过去,每根纤维都把光往视线方向散射,于是轮廓一圈泛起一层柔和的亮光,这就是 sheen。普通 GGX 在掠射角只会给出一圈“菲涅尔边缘”,而且还要很光滑才明显;它描述不了“越掠射越亮、而且很柔和”的绒毛反光。
Charlie sheen 分布
既然掠射更亮,那么法线分布就应该在 N·H 小(H 接近切平面)时更大,正好和 GGX 反过来。Ashikhmin 最早提出用“倒过来的高斯”作为布料的分布;Sony Imageworks 的 Estevez 和 Kulla 在 2017 年给出了更方便的 Charlie 分布,它是 sin 的幂:
它是归一化的(投影面积积分为 1),α 越小,亮光越集中在掠射角附近的一圈;α 越大,亮光越往正面扩散。可见性项方面,Charlie 原文的 V 计算量较大,Filament 等引擎改用 Neubelt 和 Pettineo(2013,《教团:1886》)提出的一个非常便宜的形式:
布料的菲涅尔变化不明显(纤维的反射主要来自体散射,而不是光滑界面),所以干脆不算 F,直接用一个 sheen 颜色代替。这样,布料的镜面项就是 。
// ---------- 布料: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 的布料模型还加了两个“次表面”味道的修正:用包裹漫反射(下一节详细讲)让光渗进暗部,再乘一个 ,让暗部带上指定的颜色——纤维之间的多次散射会把光“染”成布本身的颜色。
案例 12.5丝绒:Charlie sheen 布料进阶单独打开 ↗
目标:实现布料着色,对比“把布当塑料(GGX)”、Charlie sheen、Charlie + 次表面色三种做法,并加一层程序化的平纹织物纹理。
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 来做细节:
// 程序化平纹:经纬线交替压在上面,用两组正弦的乘积近似线的明暗 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);


调参上,布料的 sheen 颜色通常比底色更亮、饱和度更低:深红丝绒的 sheen 偏粉白,深蓝的偏灰蓝。棉布、亚麻这类没有明显绒毛的布料,sheen 很弱,用普通的高粗糙度 GGX 也差不多;sheen 真正出彩的是丝绒、绒面皮革和毛毡。
12.6 次表面散射:让光在皮下走一段路
皮肤、蜡烛、玉石、牛奶、大理石都是“半透明”的:光从一点进入,在材质内部被散射成千上万次,最后从附近(可能几毫米之外)的另一点出来。漫反射的 Lambert 模型假设出射点就是入射点,在宏观尺度上这对大多数材质都成立;但对皮肤来说,几毫米的横向传播足以产生肉眼可见的效果:明暗交界线变软、带上红色(红光在皮下走得最远),鼻翼、耳廓这些薄而弯曲的地方被光“透”亮。
严格的次表面散射需要知道从每个入射点到每个出射点的能量传递,也就是扩散剖面(diffusion profile):在平坦、无限大的材质表面打一束极细的光,距离入射点 r 处出射的能量。d'Eon 和 Luebke 在《GPU Gems 3》第 14 章里把人类皮肤的剖面拟合成 6 个高斯函数之和,每个高斯有自己的方差和 RGB 权重;红色通道的权重集中在方差大的几项上,这正是“红光走得远”的定量描述。HDRP 和各大引擎的屏幕空间 SSS 就是在屏幕上用这样的剖面做模糊。
屏幕空间模糊需要额外的 Pass 和不少带宽。本节介绍两种只改漫反射项、零额外 Pass 的近似:Wrap Lighting 和预积分皮肤散射。
Wrap Lighting
次表面散射最直观的效果是“光绕到了背光面”。Wrap 直接在 N·L 上做文章:把余弦曲线向负方向“包”一段,让 N·L 略小于 0 的区域也有光:
分母取 而不是 ,是为了让它在整个半球上的积分和 Lambert 相同,保持能量守恒(Filament 的写法)。只取 也很常见,那样正对光源处仍为 1,但总能量会变大。
// ---------- 包裹漫反射(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,两点间的弦长是 ,那一点接收到的光是 ,按扩散剖面加权后求平均:
LUT 的横轴是 N·L(从 −1 到 1),纵轴是曲率 1/r。曲率为 0 时 r 无穷大,散射只在极小的邻域内发生,积分退化成 ,也就是 Lambert;曲率越大,红色通道向暗面渗透得越多。
案例 12.6皮肤:Wrap 与程序生成的预积分 LUT高阶单独打开 ↗
目标:在 C# 里数值积分生成预积分皮肤 LUT(同时提供编辑器菜单把它烘焙成贴图);Shader 里用屏幕空间导数估计曲率,对比 Lambert、彩色 Wrap 和 LUT 三种漫反射。
生成 LUT
先把 d'Eon 的 6 高斯剖面写出来:
// 每个高斯的方差(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),而我们只沿一个圆环做一维积分。如果照搬二维的归一化系数 ,方差大的高斯(恰恰是红光那几项)在一维积分里会被压低一个 量级,生成的 LUT 几乎看不出红色。笔者第一次实现时就踩了这个坑,改成一维的 之后才得到正常的结果,Penner 原文附带的代码也是这种写法。
积分本身就是公式的直接翻译,用 256 个采样点的中点法则:
// 在半径为 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); }
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 时要注意(菜单 会存一张 8 位 PNG 并把导入设置改为非 sRGB;要求高的项目建议存 EXR)。

图中右上角略带青色,这是对的:红光“搬”到暗面去了,亮面剩下的红光变少,于是相对偏青。真实皮肤上这种偏色被固有色的红掩盖,几乎察觉不到。
在 Shader 里使用
三种漫反射模式写在一个函数里:
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:
// 曲率 = 法线变化量 / 位置变化量,用屏幕空间导数估计(单位 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;
这个估计在三角形边界处会有跳变(插值法线的导数不连续),正式项目通常在 DCC 里预先计算曲率存进顶点色或贴图。_CurvatureScale 负责把“场景单位下的 1/米”换算成 LUT 的纵坐标——如果角色头部半径 0.1 m(曲率约 10 m⁻¹),要映射到 LUT 的哪一行,取决于你希望效果有多强,这本质上是一个美术参数。
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):皮肤表面有一层油脂,再下面是角质层,单一粗糙度的高光看起来像塑料,双瓣会自然很多。

注意中图和右图的区别:Wrap 对所有球一视同仁,大球和小球的交界线宽度一样,而且在阴影边缘出现了一条较硬的红线;LUT 的效果随曲率变化——这正是预积分方法的价值所在:鼻尖、耳朵、手指这些曲率大的地方散射明显,额头、脸颊这些平缓的地方几乎不变。
12.7 薄片透射
树叶、花瓣、纸灯笼、耳廓在背光时会被照亮——光直接穿过了整个物体。这在实时渲染里通常单独加一个透射项,最流行的是 Barré-Brisebois 和 Bouchard 在 GDC 2011 上分享的方案(EA DICE 的《寒霜》引擎使用),思路非常简单:如果视线正好对着“穿过物体的光”看过来,就亮。
δ 是法线扭曲量(越大,透射越不依赖视线方向,像更“浑”的介质),p 控制透射光斑的集中程度,s 是强度,a 是一个不依赖角度的“环境透射”,t 是厚度(0~1)。厚度通常来自一张美术绘制或离线烘焙的贴图(把法线反向做 AO 烘焙,就能近似得到“局部厚度”),薄的地方透得多。
案例 12.7背光的树叶进阶单独打开 ↗
目标:做一组双面的程序化树叶,对比无透射、均匀透射、按叶脉厚度透射三种情况。
叶片形状和叶脉都在 Shader 里程序生成:UV 的 y 从叶柄到叶尖,宽度按正弦的幂变化,再乘一个线性收窄;主脉是 x = 0 附近的一条细线,侧脉是斜向重复的条纹。叶脉比叶肉厚,透光少:
// 程序化叶片: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))); }
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 一致(法线偏移、点光源/方向光两种光方向、近裁剪面钳制):
// 叶片形状是程序化裁剪的,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 }

扩展:用同一个透射项做玉石和蜡烛
把透射项用在封闭的实体上(玉雕、蜡烛、皮肤的耳朵),关键在厚度。一个常见的离线做法是“反向 AO”:在每个顶点处沿法线的反方向朝物体内部发射一圈射线,测量平均穿出距离,归一化后存进顶点色。厚处透得少、薄处透得多,配合附加光(例如把点光源放在玉雕背后),就有很不错的“通透”感。HDRP 的 Thickness Map 与本节的厚度含义相同。
12.8 薄膜干涉:彩虹色从哪来
肥皂泡、水面上的油膜、甲虫的外壳、回火后的钛和钢,表面会随视角变换出彩虹般的颜色。这些颜色不是色素,而是干涉:表面覆盖着一层厚度在几百纳米量级的透明薄膜,光在薄膜的上表面反射一次,另一部分折射进去、在下表面反射后再出来。两束光走过的路程不同,相位不同,叠加时有的波长相长(变亮)、有的相消(变暗),反射光的颜色就取决于膜厚和观察角度。
Airy 公式
把薄膜内无穷多次反射的级数加起来,可以得到三层结构的反射率(Airy 公式)。设 、 分别是上、下两个界面的振幅反射系数(带符号),则对波长 λ:
法向入射时 ,符号为负表示从光疏到光密介质反射时的半波损失(π 相位突变)。用带符号的振幅,半波损失就自动包含在 的符号里,不需要单独加 π。我们用 Schlick 的角度项把法向入射的反射率推广到斜入射(只是近似,忽略了偏振),再按斯涅尔定律求膜内的折射角 θ₂。
从光谱到 RGB
R(λ) 随波长变化,而显示器只有 RGB 三个通道。最偷懒的做法是只在三个代表波长(如 650、532、450 nm)各算一次,直接当作 R、G、B。这样得到的颜色饱和度过高、色相跳变生硬,因为真实的颜色是整条光谱和人眼的三种视锥响应的积分。更合理的做法是:在可见光范围内取若干个波长,把每个波长的反射率乘以 CIE 1931 配色函数 累加成 XYZ,再转换到线性 sRGB。配色函数用 Wyman、Sloan 和 Shirley 在 2013 年给出的多瓣高斯解析拟合,不需要查表。最后再除以“反射率处处为 1 的光谱”得到的 RGB,保证与波长无关的反射率被映射成中性灰。
// ---------- 薄膜干涉 ---------- // 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 实现。
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 里可以直接调用它。
膜厚加了一张噪声贴图做空间变化:真实的油膜和肥皂泡厚度从来不均匀,正是厚度的起伏造成了一圈圈的彩色条纹。


膜厚和颜色的对应有一个经验规律:100 nm 以下几乎没有颜色(只是略微变暗或变亮);200~600 nm 是一阶、二阶干涉色,颜色最鲜艳;超过 1 μm 后,相邻波长的相位差太大,各种颜色互相抵消,又趋于灰白。所以想要“鲜艳的彩虹色”,膜厚的变化范围要落在几百纳米内。
案例 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 动态分支打开关闭;在移动端对整个角色使用多次环境采样;在没有正确切线的模型上开各向异性。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。
练习
- 给清漆加一个“漆层吸收颜色”参数:底层的光在漆里走了一个来回,按 衰减,做出糖果漆(candy paint)。
- 给 Anisotropic.shader 增加一张“方向贴图”(RG 存切线空间里的拉丝方向),做出局部拉丝方向不同的金属铭牌。
- 仿照第 11 章生成环境 BRDF LUT 的方法,把布料的 DCharlie·VNeubelt 预积分成一张 LUT,替换 Cloth.shader 里的经验系数,再做一次白炉测试对比。
- 把 Skin.shader 的曲率改为从顶点色读取,写一个编辑器脚本在网格上计算每个顶点的曲率(相邻顶点法线差 / 位置差的平均)。
- 把 AdvThinFilm 预计算成一张 256×64 的 LUT(横轴 cosθ,纵轴膜厚),比较与实时计算的差异和性能。
- 组合 12.2 与 12.8:把清漆层的菲涅尔换成薄膜干涉,做一个“变色龙”车漆。