案例 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 等项。真要上线时,按项目实际开启的功能把它们补回来。