从零手写 PBR
前两章我们推过公式、读过 URP 的源码。这一章把两者合起来:不调用 URP 的任何 BRDF 函数,从一个只有主光的小 Shader 起步,一步步长成支持多光源、阴影、IBL、能量补偿和全套 Pass 的 Cookbook/Ch11/MyPBR,最后和 URP Lit 并排对比。
11.1 我们要造什么
“手写 PBR”在很多教程里止步于一个只有方向光的球:D、G、F 三个函数乘起来,高光看着挺像,就算完成了。但真正能放进项目的 PBR Shader 要回答一长串问题:附加光在 Forward+ 下怎么循环?阴影坐标在级联阴影、屏幕空间阴影下怎么取?环境光从哪来、要不要除以 π?反射探针的 mip 和粗糙度什么关系?粗糙金属为什么越粗越暗、该不该补?SSAO 读不到我的法线怎么办?烘焙时自发光为什么不发光?这些问题的答案大部分不在 BRDF 公式里,而在“Shader 与管线的契约”里。
所以本章给自己定了一条边界:BRDF 全部自己写,管线数据尽量复用。所谓 BRDF,指的是“给定入射方向、出射方向和材质参数,算出反射比例”的那部分数学:法线分布 D、可见性 V、菲涅尔 F、漫反射项,以及环境 BRDF(DFG)。这些我们一行都不调 URP。所谓管线数据,指的是灯光颜色与方向、阴影图采样、Forward+ 的分簇光源列表、球谐系数、反射探针图集、SSAO 结果——这些是 URP 每帧准备好放进全局变量的,我们直接用它提供的取数函数(GetMainLight、GetAdditionalLight、SampleSH、GlossyEnvironmentReflection 等),不重复造轮子。这样写出的 Shader 既能在 URP 的各种开关组合下正确工作,又能让你完全掌控光照模型本身。
最终成品支持的功能列在下表。每一行在后面都有对应的小节,你可以按顺序读,也可以跳到感兴趣的部分。
| 功能 | 实现位置 | 说明 |
|---|---|---|
| 直接光 BRDF | MyPBR_BRDF.hlsl | GGX + 高度相关 Smith 可见性 + Schlick 菲涅尔 + Lambert |
| 多光源 | MyPBR_Lighting.hlsl | Forward 逐物体列表、Forward+ 分簇列表、逐顶点附加光、Light Layers |
| 阴影 | 同上 + ShadowCaster Pass | 主光级联/屏幕空间阴影、附加光阴影、软阴影各档 |
| 漫反射 IBL | SAMPLE_GI | 球谐(逐像素/逐顶点/混合)或光照贴图 |
| 镜面 IBL | MySamplePrefilteredEnv | 反射探针按粗糙度选 mip、盒体投影、探针混合、Forward+ 探针图集 |
| 环境 BRDF | MyEnvDFG | Karis 解析近似,或 C# 程序生成的 DFG LUT(关键字 _DFG_LUT) |
| 能量补偿 | MyEnergyCompensation | 多次散射补偿,材质滑条 0~1 可调,便于对比 |
| 贴图 | MyPBR_Input.hlsl | Base Map、法线贴图、Mask Map(R 金属 G AO A 光滑)、自发光 |
| 其余 Pass | MyPBR_DepthPasses.hlsl 等 | ShadowCaster、DepthOnly、DepthNormals、Meta |
| 工程化 | MyPBR.shader | SRP Batcher、GPU Instancing、雾、SSAO、Rendering Debugger |
11.2 从公式到代码之前:三个约定
第 9 章推导过渲染方程和 Cook-Torrance 模型,这里不再重复推导,只把要落到代码里的公式列出来,并把三个“写代码时才会碰到”的约定讲清楚。对一盏精确光源(方向光、点光、聚光,统称 punctual light),渲染方程里的积分退化成求和:
其中 是第 k 盏灯在垂直入射时产生的辐照度(Irradiance)。
约定一:Unity 灯光颜色里藏着一个 π
Unity 的 Light 组件只给出“颜色 × 强度”,没有说它是辐照度还是别的什么物理量。要确定它的含义,最可靠的办法是看 URP 自己怎么用它。URP 的直接光漫反射写的是 brdfData.diffuse * light.color * NdotL,漫反射项里没有 1/π;它的 GGX 也写成 roughness2 / (d * d),同样没有 π。也就是说 URP 计算的是 ,再乘上 light.color。换成物理写法:
直观理解:一盏颜色为 1 的灯正对一块反照率为 1 的 Lambert 白板,白板的亮度就是 1。我们的实现保持 BRDF 的物理形式(漫反射除以 π、D 里带 π),在“乘灯光”那一步乘回 π。这样做有两个好处:BRDF 代码可以直接对照论文和第 9 章的公式检查;与 URP Lit 在同一盏灯下亮度一致,场景不用为换 Shader 重新打光。
约定二:三种“粗糙度”
美术调的是 Smoothness(光滑度);我们先换成感知粗糙度(Perceptual Roughness);GGX 公式里用的是 ;而 D 和 V 实际只用到 。平方的目的是让滑条在视觉上更均匀——如果直接把滑条当 α,光滑那一半的变化会挤在很小的区间里。代码里我们在 MyInitMaterial 一次性算好 α²,光源循环里不再重复计算。感知粗糙度还被下限截断到 0.045:理想镜面(α=0)在精确光源下的高光是一个狄拉克函数,数值上会变成无穷大或半精度溢出,截断后高光变成一个极小但有限的亮点。
约定三:一切计算在线性空间
工程设置为 Linear 色彩空间后,sRGB 贴图在采样时由硬件解码成线性值,相机目标在写入时再编码回去,Shader 里看到的全是线性量。材质面板上的 Color 属性也会被 Unity 自动转换成线性。这一点 c13 会专门讲,这里只需要记住:不要在 Shader 里手动做 pow 2.2。
案例 11.1BRDF 四件套进阶单独打开 ↗
目标:把 D、V、F 和 Lambert 写成四个独立的小函数,名字统一加 My 前缀,避免和 core 包 BSDF.hlsl 里的同名函数(如 D_GGX、F_Schlick)冲突。
// 法线分布函数 D:GGX / Trowbridge-Reitz // D(h) = a² / ( π ( (n·h)² (a² - 1) + 1 )² ) float MyD_GGX(float NoH, float a2) { float d = (NoH * a2 - NoH) * NoH + 1.0; // = NoH²(a²-1)+1,写成两次 MAD return a2 / (PI * d * d); } // 可见性项 V = G2 / (4 NoL NoV),G2 用高度相关(Height-Correlated)Smith-GGX // V = 0.5 / ( NoL * sqrt(NoV²(1-a²) + a²) + NoV * sqrt(NoL²(1-a²) + a²) ) float MyV_SmithGGXCorrelated(float NoV, float NoL, float a2) { float lambdaV = NoL * sqrt((NoV - NoV * a2) * NoV + a2); float lambdaL = NoV * sqrt((NoL - NoL * a2) * NoL + a2); return 0.5 / max(lambdaV + lambdaL, 1e-5); } // 菲涅尔 F:Schlick 近似 F = F0 + (F90 - F0)(1 - cosθ)^5 float3 MyF_Schlick(float3 f0, float f90, float cosTheta) { float x = 1.0 - cosTheta; float x2 = x * x; float x5 = x2 * x2 * x; // 手写 pow5,比 pow() 便宜且没有 pow(0,5) 的边界问题 return f0 + (f90 - f0) * x5; } // 漫反射:Lambert。能量守恒要求 ∫ f_d cosθ dω = albedo,所以要除以 π float MyFd_Lambert() { return INV_PI; }
// 法线分布函数 D:GGX / Trowbridge-Reitz // D(h) = a² / ( π ( (n·h)² (a² - 1) + 1 )² ) float MyD_GGX(float NoH, float a2) { float d = (NoH * a2 - NoH) * NoH + 1.0; // = NoH²(a²-1)+1,写成两次 MAD return a2 / (PI * d * d); } // 可见性项 V = G2 / (4 NoL NoV),G2 用高度相关(Height-Correlated)Smith-GGX // V = 0.5 / ( NoL * sqrt(NoV²(1-a²) + a²) + NoV * sqrt(NoL²(1-a²) + a²) ) float MyV_SmithGGXCorrelated(float NoV, float NoL, float a2) { float lambdaV = NoL * sqrt((NoV - NoV * a2) * NoV + a2); float lambdaL = NoV * sqrt((NoL - NoL * a2) * NoL + a2); return 0.5 / max(lambdaV + lambdaL, 1e-5); } // 菲涅尔 F:Schlick 近似 F = F0 + (F90 - F0)(1 - cosθ)^5 vec3 MyF_Schlick(vec3 f0, float f90, float cosTheta) { float x = 1.0 - cosTheta; float x2 = x * x; float x5 = x2 * x2 * x; // 手写 pow5,比 pow() 便宜且没有 pow(0,5) 的边界问题 return f0 + (f90 - f0) * x5; } // 漫反射:Lambert。能量守恒要求 ∫ f_d cosθ dω = albedo,所以要除以 π float MyFd_Lambert() { return INV_PI; }
D:GGX 法线分布
代码里分母的写法 (NoH * a2 - NoH) * NoH + 1.0 看起来绕,其实是把 拆成两次乘加(MAD),GPU 上一条指令完成一次乘加。这个式子在 时取峰值 :越光滑峰越高越窄,但积分 始终成立——光滑表面的高光“小而亮”、粗糙表面“大而暗”,总能量不变。GGX 相比 Beckmann 的长尾巴(高光周围一圈柔和的光晕)就来自分母的平方形式衰减得慢。
V:高度相关的 Smith 可见性
几何项 描述的是微表面之间的相互遮挡:从视线方向看过去,一部分微平面被前面的凸起挡住(Masking);从光源方向看过去,一部分微平面处在别人的阴影里(Shadowing)。Smith 模型用一个辅助函数 描述单个方向上被遮挡的程度,对 GGX 有解析解:
右边这个 叫“高度相关(Height-Correlated)”形式。它和常见的“可分离”形式 的区别在于:可分离形式假设“被视线遮挡”和“被光线遮挡”是独立事件,而实际上高处的微平面两样都不容易被挡、低处的两样都容易被挡,两者正相关。高度相关形式考虑了这种相关性,遮挡估计更准,也更不容易把能量“挡多了”。
BRDF 里 总是和分母 一起出现,把 用余弦展开后,二者合并成可见性项 V,分母里的 NoL、NoV 会和 里的项约掉:
这就是 MyV_SmithGGXCorrelated。这种写法最大的好处是:没有单独的 ,掠射角时不会出现“0 除以 0”。分母仍可能在 NoL 与 NoV 同时为 0 时为 0,所以加了 max(…, 1e-5)。
F:Schlick 菲涅尔
注意菲涅尔用的角度是视线与半角向量的夹角 (等于 ),因为真正发生镜面反射的是朝向 h 的那些微平面;而不是 。后者只在环境光(11.4 节)里作为近似出现。 我们取 1:任何材质在完全掠射时都接近全反射。五次方手写成 x2 * x2 * x,既省掉 pow 的 exp/log 展开,又避免某些平台上 pow(0, 5) 的未定义行为。
Lambert 漫反射
Lambert 是常数 BRDF 。为什么除以 π?因为余弦加权的半球积分 ,除以 π 后整块表面反射出去的总能量才恰好是反照率 c。第 9 章提到的 Disney Diffuse 在粗糙表面掠射角有回射增亮,但它本身不严格能量守恒,开销也更大;移动端友好的 URP Lit 也用 Lambert,这里保持一致,便于对比。
把参数准备好:MyMaterial
// 由表面参数推出 BRDF 需要的量(与光源无关,每像素算一次) struct MyMaterial { float3 diffuseColor; // 进入 Lambert 的颜色 float3 f0; // 正入射镜面反射率 float perceptualRoughness; float a2; // α² ,α = perceptualRoughness² float3 energyCompensation; // 多次散射补偿系数,由 DFG 算出,默认 1 }; MyMaterial MyInitMaterial(half3 albedo, half metallic, half perceptualRoughness) { MyMaterial m; // 金属度工作流:金属没有漫反射,基础色就是 F0;电介质 F0 固定 0.04,基础色进漫反射。 // 电介质表面先被镜面反射走约 4%,剩下的才进入次表面形成漫反射,所以乘 (1 - 0.04) m.diffuseColor = albedo * (1.0 - metallic) * (1.0 - MY_DIELECTRIC_F0); m.f0 = lerp(MY_DIELECTRIC_F0.xxx, albedo, metallic); m.perceptualRoughness = max(perceptualRoughness, MY_MIN_PERCEPTUAL_ROUGHNESS); float a = m.perceptualRoughness * m.perceptualRoughness; // 感知粗糙度平方 = GGX 的 α m.a2 = a * a; m.energyCompensation = 1.0; return m; }
// 由表面参数推出 BRDF 需要的量(与光源无关,每像素算一次) struct MyMaterial { vec3 diffuseColor; // 进入 Lambert 的颜色 vec3 f0; // 正入射镜面反射率 float perceptualRoughness; float a2; // α² ,α = perceptualRoughness² vec3 energyCompensation; // 多次散射补偿系数,由 DFG 算出,默认 1 }; MyMaterial MyInitMaterial(vec3 albedo, float metallic, float perceptualRoughness) { MyMaterial m; // 金属度工作流:金属没有漫反射,基础色就是 F0;电介质 F0 固定 0.04,基础色进漫反射。 // 电介质表面先被镜面反射走约 4%,剩下的才进入次表面形成漫反射,所以乘 (1 - 0.04) m.diffuseColor = albedo * (1.0 - metallic) * (1.0 - MY_DIELECTRIC_F0); m.f0 = mix(vec3(MY_DIELECTRIC_F0), albedo, metallic); m.perceptualRoughness = max(perceptualRoughness, MY_MIN_PERCEPTUAL_ROUGHNESS); float a = m.perceptualRoughness * m.perceptualRoughness; // 感知粗糙度平方 = GGX 的 α m.a2 = a * a; m.energyCompensation = vec3(1.0); return m; }
金属度工作流的核心就两行:diffuseColor 与 f0。金属没有次表面散射,进入表面的光很快被吸收,因此漫反射为 0,基础色全部用来当 F0(金属的颜色体现在反射里);电介质(塑料、木头、石头……)的 F0 很低且几乎不带颜色,常用 0.04 近似,基础色进入漫反射。乘 是一个最简单的能量分配:表面先镜面反射走约 4%,剩下的才进入次表面。更精细的做法是乘 或用“漫反射能量补偿”,但收益很小,URP Lit 也是这样的常数近似。
组合:直接光 BRDF
// 单个光源方向 L 上的 BRDF:分别返回漫反射项 fd 与镜面项 fs(单位 1/sr), // 还没乘入射光和 NoL —— 那是“光照”的事,不是“材质”的事。 void MyDirectBRDF(MyMaterial m, float3 N, float3 V, float3 L, out float3 fd, out float3 fs, out float NoL) { float3 H = SafeNormalize(L + V); float NoV = abs(dot(N, V)) + 1e-5; // 法线贴图会让 N 背向视线,取绝对值避免除 0 NoL = saturate(dot(N, L)); float NoH = saturate(dot(N, H)); float LoH = saturate(dot(L, H)); // = VoH,半角向量两侧对称 float D = MyD_GGX(NoH, m.a2); float Vis = MyV_SmithGGXCorrelated(NoV, NoL, m.a2); float3 F = MyF_Schlick(m.f0, 1.0, LoH); fs = (D * Vis) * F * m.energyCompensation; fd = m.diffuseColor * MyFd_Lambert(); }
// 单个光源方向 L 上的 BRDF:分别返回漫反射项 fd 与镜面项 fs(单位 1/sr), // 还没乘入射光和 NoL —— 那是“光照”的事,不是“材质”的事。 void MyDirectBRDF(MyMaterial m, vec3 N, vec3 V, vec3 L, out vec3 fd, out vec3 fs, out float NoL) { vec3 H = SafeNormalize(L + V); float NoV = abs(dot(N, V)) + 1e-5; // 法线贴图会让 N 背向视线,取绝对值避免除 0 NoL = saturate(dot(N, L)); float NoH = saturate(dot(N, H)); float LoH = saturate(dot(L, H)); // = VoH,半角向量两侧对称 float D = MyD_GGX(NoH, m.a2); float Vis = MyV_SmithGGXCorrelated(NoV, NoL, m.a2); vec3 F = MyF_Schlick(m.f0, 1.0, LoH); fs = (D * Vis) * F * m.energyCompensation; fd = m.diffuseColor * MyFd_Lambert(); }
MyDirectBRDF 只返回 BRDF 本身(单位是 1/sr),刻意不乘灯光与 NoL:BRDF 属于“材质”,乘灯光属于“光照”,两者分开后,调试视图可以单独显示每一项,c12 的清漆、各向异性也能在同一框架下替换镜面项。NoV 用了 abs(…) + 1e-5:法线贴图常会让像素法线略微背向视线,直接 saturate 会让 NoV 变成 0,V 项和环境 BRDF 都在这里出问题,表现为模型边缘出现黑点或亮点;取绝对值是 Filament 等引擎的通行做法。

案例 11.2第一步:只有主光的最小 PBR入门单独打开 ↗
目标:在加任何复杂功能之前,用最少的代码验证 BRDF 是对的。这个 Shader 只读主光方向和颜色,没有阴影、没有环境光、没有贴图。
// 第一步:只有主光、没有阴影、没有环境光的最小 PBR。 // 目的:先单独验证 BRDF(D、V、F、Lambert 与 π 的约定)是否正确,再往上加东西。 Shader "Cookbook/Ch11/Step1_DirectPBR" { Properties { _BaseColor ("Base Color", Color) = (1, 1, 1, 1) _Metallic ("Metallic", Range(0, 1)) = 0 _Smoothness ("Smoothness", Range(0, 1)) = 0.5 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } Pass { Tags { "LightMode"="UniversalForward" } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" CBUFFER_START(UnityPerMaterial) half4 _BaseColor; half _Metallic; half _Smoothness; CBUFFER_END // MyPBR_BRDF.hlsl 里的 LUT 分支要用到这张贴图的声明(本步不启用) TEXTURE2D(_DFGLut); #include "Assets/Cookbook/Ch11/MyPBR_BRDF.hlsl" struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; struct Varyings { float4 positionCS : SV_POSITION; float3 positionWS : TEXCOORD0; float3 normalWS : TEXCOORD1; }; Varyings vert(Attributes v) { Varyings o; o.positionWS = TransformObjectToWorld(v.positionOS.xyz); o.positionCS = TransformWorldToHClip(o.positionWS); o.normalWS = TransformObjectToWorldNormal(v.normalOS); return o; } half4 frag(Varyings i) : SV_Target { float3 N = normalize(i.normalWS); float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS); Light light = GetMainLight(); MyMaterial m = MyInitMaterial(_BaseColor.rgb, _Metallic, 1.0 - _Smoothness); float3 fd, fs; float NoL; MyDirectBRDF(m, N, V, light.direction, fd, fs, NoL); // 渲染方程只剩一项:Lo = f(l,v) · E⊥ · NoL,E⊥ = π · light.color float3 Lo = (fd + fs) * PI * light.color * NoL; return half4(Lo, 1); } ENDHLSL } } }
// 第一步:只有主光、没有阴影、没有环境光的最小 PBR。(GLSL 版,对应 Ch11/Step1_DirectPBR.shader) // 目的:先单独验证 BRDF(D、V、F、Lambert 与 π 的约定)是否正确,再往上加东西。 Shader "Cookbook/Ch11/Step1_DirectPBR" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } Pass { Tags { "LightMode"="UniversalForward" } GLSLPROGRAM uniform vec4 _BaseColor; uniform float _Metallic; uniform float _Smoothness; // MyPBR_BRDF.glsl 里的 LUT 分支要用到这张贴图的声明(本步不启用) uniform sampler2D _DFGLut; #include "Ch11/MyPBR_BRDF.glsl" VARYING vec3 positionWS; VARYING vec3 normalWS; #ifdef VERTEX void main() { positionWS = TransformObjectToWorld(positionOS.xyz); gl_Position = TransformWorldToHClip(positionWS); normalWS = TransformObjectToWorldNormal(normalOS); } #endif #ifdef FRAGMENT void main() { vec3 N = normalize(normalWS); vec3 V = GetWorldSpaceNormalizeViewDir(positionWS); Light light = GetMainLight(); MyMaterial m = MyInitMaterial(_BaseColor.rgb, _Metallic, 1.0 - _Smoothness); vec3 fd, fs; float NoL; MyDirectBRDF(m, N, V, light.direction, fd, fs, NoL); // 渲染方程只剩一项:Lo = f(l,v) · E⊥ · NoL,E⊥ = π · light.color vec3 Lo = (fd + fs) * PI * light.color * NoL; fragColor = vec4(Lo, 1.0); } #endif ENDGLSL } } }
片元函数就是渲染方程的一项:(fd + fs) * PI * light.color * NoL。GetMainLight() 的无参版本只返回方向、颜色和距离衰减(方向光恒为 1,在 Forward 下它还兼做“灯光是否被 Culling Mask 剔除”的开关),没有阴影。TEXTURE2D(_DFGLut) 这一行是因为 MyPBR_BRDF.hlsl 里的 LUT 分支引用了它——即使本步不启用 _DFG_LUT,这张贴图的声明也必须存在,否则预处理后的代码在语法上不完整。

这张图已经能说明很多事情:电介质的漫反射亮度不随光滑度变化(能量分配给了漫反射),高光越来越小越来越亮;金属没有漫反射,随着光滑度升高,球体的大部分区域反而变黑,只剩一个刺眼的光点。很多初学者会在这一步觉得“金属不对”,其实是对的——金属的外观几乎完全由它反射的环境决定,这正是 11.4 节 IBL 要补上的部分。
11.3 MyPBR 的骨架:属性、CBUFFER 与表面输入
从这一节开始搭建正式版本。先看 ShaderLab 层面的属性声明:
Properties { [MainTexture] _BaseMap ("Base Map", 2D) = "white" {} [MainColor] _BaseColor ("Base Color", Color) = (1, 1, 1, 1) _Metallic ("Metallic", Range(0, 1)) = 0 _Smoothness ("Smoothness", Range(0, 1)) = 0.5 [Toggle(_MASKMAP)] _UseMaskMap ("Use Mask Map", Float) = 0 [NoScaleOffset] _MaskMap ("Mask (R Metal, G AO, A Smooth)", 2D) = "white" {} _OcclusionStrength ("Occlusion Strength", Range(0, 1)) = 1 [Toggle(_NORMALMAP)] _UseNormalMap ("Use Normal Map", Float) = 0 [NoScaleOffset][Normal] _BumpMap ("Normal Map", 2D) = "bump" {} _BumpScale ("Normal Scale", Float) = 1 [Toggle(_EMISSION)] _UseEmission ("Use Emission", Float) = 0 [NoScaleOffset] _EmissionMap ("Emission Map", 2D) = "white" {} [HDR] _EmissionColor ("Emission Color", Color) = (0, 0, 0, 1) [Toggle(_DFG_LUT)] _UseDFGLut ("Use DFG LUT (else analytic)", Float) = 0 [NoScaleOffset] _DFGLut ("DFG LUT", 2D) = "black" {} _EnergyCompensation ("Multi-Scatter Compensation", Range(0, 1)) = 1 _SpecularOcclusion ("Specular Occlusion", Range(0, 1)) = 1 [Toggle(_ALPHATEST_ON)] _AlphaClip ("Alpha Clip", Float) = 0 _Cutoff ("Alpha Cutoff", Range(0, 1)) = 0.5 }
// 第 11 章最终成果:完全手写 BRDF 的 PBR Shader,可直接在项目中使用。(GLSL 版,对应 Ch11/MyPBR.shader) // 支持:Forward / Forward+、主光与附加光阴影、SH + 反射探针 IBL、DFG(解析或 LUT)、多次散射补偿、 // 法线贴图、Mask Map(R 金属 G AO A 光滑)、自发光、SSAO、Rendering Debugger。 // (HLSL 版另外支持光照贴图、Light Layers、SRP Batcher、GPU Instancing,网页版没有这些功能) Shader "Cookbook/Ch11/MyPBR" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } GLSLINCLUDE // 所有 Pass 共享同一份材质属性声明,保证 SRP Batcher 兼容 #include "Ch11/MyPBR_Input.glsl" ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } ZWrite On Cull Back GLSLPROGRAM // ---- 材质关键字(只编译用到的组合)---- #pragma shader_feature_local _NORMALMAP #pragma shader_feature_local_fragment _MASKMAP #pragma shader_feature_local_fragment _EMISSION #pragma shader_feature_local_fragment _DFG_LUT #pragma shader_feature_local_fragment _ALPHATEST_ON // ---- URP 管线关键字:_MAIN_LIGHT_SHADOWS、_ADDITIONAL_LIGHTS、_SHADOWS_SOFT 等网页版引擎始终打开 ---- // ---- 调试:网页版模拟的 Rendering Debugger ---- #pragma multi_compile_fragment _ DEBUG_DISPLAY #include "Ch11/MyPBR_ForwardPass.glsl" #include "Ch11/DebugDisplay.glsl" #ifdef VERTEX void main() { MyPBRVert(); } #endif #ifdef FRAGMENT void main() { #if defined(DEBUG_DISPLAY) // 让 Rendering Debugger 的材质视图 / 材质校验也能作用于 MyPBR: // 把我们的表面参数交给调试函数(HLSL 版是翻译成 URP 的 SurfaceData 再交给 CanDebugOverrideOutputColor) MySurface s = MySampleSurface(uv); InputData id = MyInitInputData(s.normalTS); MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness); vec4 debugColor; if (CanDebugOverrideOutputColor(s.albedo, s.alpha, m.f0, s.metallic, 1.0 - s.perceptualRoughness, s.occlusion, s.emission, id.normalWS, debugColor)) { fragColor = debugColor; return; } #endif fragColor = MyPBRFrag(); } #endif ENDGLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull Back GLSLPROGRAM #pragma shader_feature_local _ALPHATEST_ON // 方向光与点/聚光的法线偏移公式不同,URP 用 _CASTING_PUNCTUAL_LIGHT_SHADOW 关键字区分(网页版是 uniform) #include "Ch11/MyPBR_DepthPasses.glsl" #ifdef VERTEX void main() { MyShadowVert(); } #endif #ifdef FRAGMENT void main() { fragColor = MyShadowFrag(); } #endif ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R Cull Back GLSLPROGRAM #pragma shader_feature_local _ALPHATEST_ON #include "Ch11/MyPBR_DepthPasses.glsl" #ifdef VERTEX void main() { MyDepthVert(); } #endif #ifdef FRAGMENT void main() { fragColor = MyDepthFrag(); } #endif ENDGLSL } Pass { Name "DepthNormals" Tags { "LightMode"="DepthNormals" } ZWrite On Cull Back GLSLPROGRAM #pragma shader_feature_local _NORMALMAP #pragma shader_feature_local _ALPHATEST_ON #include "Ch11/MyPBR_DepthPasses.glsl" #ifdef VERTEX void main() { MyDepthNormalsVert(); } #endif #ifdef FRAGMENT void main() { fragColor = MyDepthNormalsFrag(); } #endif ENDGLSL } Pass { Name "Meta" Tags { "LightMode"="Meta" } Cull Off GLSLPROGRAM #pragma shader_feature_local_fragment _MASKMAP #pragma shader_feature_local_fragment _EMISSION #pragma shader_feature_local_fragment _ALPHATEST_ON #include "Ch11/MyPBR_MetaPass.glsl" #ifdef VERTEX void main() { MyMetaVert(); } #endif #ifdef FRAGMENT void main() { fragColor = MyMetaFrag(); } #endif ENDGLSL } } }
几个设计选择:
- 属性名沿用 URP Lit 的命名(
_BaseMap、_BaseColor、_Metallic、_Smoothness、_BumpMap、_EmissionColor……)。好处是在材质面板上把 Lit 换成 MyPBR 时,同名属性的值会被保留;另外 URP 的一些工具代码(例如 Meta Pass 相关、材质升级器)会按这些名字找属性。 - 功能开关用
[Toggle(关键字)]:勾选时 Unity 自动启用对应的shader_feature_local关键字。用代码创建材质时,SetFloat("_UseNormalMap", 1)不会触发关键字,必须另外调用EnableKeyword("_NORMALMAP")——本章配图脚本就是这么做的。 - Mask Map 的约定与本书配套贴图一致:R 金属度、G 环境光遮蔽(AO)、A 光滑度,B 通道留空。c13 会讨论为什么这样打包,以及它和 URP Lit、HDRP Mask Map 约定的异同。
// 所有材质属性(贴图除外)都放进 UnityPerMaterial,且每个 Pass 看到的布局完全一致, // 这是 SRP Batcher 兼容的硬条件。贴图本身不进 CBUFFER,但 _ST 要进。 CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; half _Metallic; half _Smoothness; half _OcclusionStrength; half _BumpScale; half4 _EmissionColor; half _Cutoff; half _EnergyCompensation; // 0 = 只算单次散射,1 = 加多次散射能量补偿 half _SpecularOcclusion; // 0 = 关,1 = 用 AO 推算镜面遮蔽 CBUFFER_END TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); TEXTURE2D(_MaskMap); // R 金属度 G AO A 光滑度(与配套贴图约定一致) TEXTURE2D(_BumpMap); SAMPLER(sampler_BumpMap); TEXTURE2D(_EmissionMap); TEXTURE2D(_DFGLut); // 程序生成的环境 BRDF 查找表(RG = A, B),用全局的 sampler_LinearClamp // Mask 与自发光借用 _BaseMap 的采样器:平铺/过滤方式一样,省采样器槽位(部分平台上限 16 个)。 // 法线贴图单独一个采样器:DepthNormals Pass 只采法线不采基础色, // 若借用 sampler_BaseMap,_BaseMap 被编译器优化掉后 Metal 会报“采样器找不到贴图”。
// 所有材质属性(贴图除外)都放进 UnityPerMaterial,且每个 Pass 看到的布局完全一致, // 这是 SRP Batcher 兼容的硬条件。贴图本身不进 CBUFFER,但 _ST 要进。 // (WebGL 没有 SRP Batcher,这里都是普通 uniform) uniform vec4 _BaseMap_ST; uniform vec4 _BaseColor; uniform float _Metallic; uniform float _Smoothness; uniform float _OcclusionStrength; uniform float _BumpScale; uniform vec4 _EmissionColor; uniform float _Cutoff; uniform float _EnergyCompensation; // 0 = 只算单次散射,1 = 加多次散射能量补偿 uniform float _SpecularOcclusion; // 0 = 关,1 = 用 AO 推算镜面遮蔽 uniform sampler2D _BaseMap; uniform sampler2D _MaskMap; // R 金属度 G AO A 光滑度(与配套贴图约定一致) uniform sampler2D _BumpMap; uniform sampler2D _EmissionMap; uniform sampler2D _DFGLut; // 程序生成的环境 BRDF 查找表(RG = A, B),贴图自身是双线性 + Clamp // HLSL 版里 Mask 与自发光借用 _BaseMap 的采样器以节省采样器槽位;GLSL 的 sampler2D 自带采样状态,没有这个问题。
SRP Batcher 兼容有两个硬条件:所有材质属性(贴图除外)都在名为 UnityPerMaterial 的 CBUFFER 里,并且同一个 Shader 的所有 Pass 看到的 CBUFFER 布局完全相同。最省心的做法是像这里一样,把 CBUFFER 放进一个 include 文件,再在 SubShader 的 HLSLINCLUDE 块里包含它,于是 ForwardLit、ShadowCaster、DepthOnly、DepthNormals、Meta 五个 Pass 自动共享同一份声明。选中 Shader 资产,Inspector 里会显示 ;运行时在 Frame Debugger 里能看到这些物体被归入同一个 “SRP Batch”。
sampler_BaseMap。结果在 Metal 上编译 DepthNormals Pass 时报错:Unrecognized sampler 'sampler_BaseMap' - does not match any texture。原因是 DepthNormals 只采样法线贴图,_BaseMap 被编译器当成无用代码删掉,于是 sampler_BaseMap 找不到“主人”。修法是给法线贴图单独声明 sampler_BumpMap。规律:借用的采样器所属的贴图,必须在每个用到它的 Pass 里都被采样,或者改用 sampler_LinearRepeat 这类内联采样器(名字里包含过滤与寻址模式即可)。// 表面参数:全部在“线性空间、物理含义明确”的单位下 struct MySurface { half3 albedo; // 基础色(金属时就是 F0 的颜色) half alpha; half metallic; half perceptualRoughness; // 感知粗糙度 = 1 - smoothness,美术直接调的那个值 half occlusion; // 材质 AO(只影响间接光) half3 normalTS; // 切线空间法线 half3 emission; }; MySurface MySampleSurface(float2 uv) { MySurface s; half4 base = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv) * _BaseColor; s.albedo = base.rgb; s.alpha = base.a; #if defined(_ALPHATEST_ON) clip(s.alpha - _Cutoff); #endif #if defined(_MASKMAP) half4 mask = SAMPLE_TEXTURE2D(_MaskMap, sampler_BaseMap, uv); s.metallic = mask.r; // 有 Mask 时金属度完全由贴图决定(与 URP Lit 一致) half smooth = mask.a * _Smoothness; // 光滑度 = 贴图 × 滑条,滑条当总强度用 s.occlusion = LerpWhiteTo(mask.g, _OcclusionStrength); #else s.metallic = _Metallic; half smooth = _Smoothness; s.occlusion = 1.0h; #endif s.perceptualRoughness = 1.0h - smooth; #if defined(_NORMALMAP) s.normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, uv), _BumpScale); #else s.normalTS = half3(0, 0, 1); #endif #if defined(_EMISSION) s.emission = SAMPLE_TEXTURE2D(_EmissionMap, sampler_BaseMap, uv).rgb * _EmissionColor.rgb; #else s.emission = 0; #endif return s; }
// 表面参数:全部在“线性空间、物理含义明确”的单位下 struct MySurface { vec3 albedo; // 基础色(金属时就是 F0 的颜色) float alpha; float metallic; float perceptualRoughness; // 感知粗糙度 = 1 - smoothness,美术直接调的那个值 float occlusion; // 材质 AO(只影响间接光) vec3 normalTS; // 切线空间法线 vec3 emission; }; #ifdef FRAGMENT MySurface MySampleSurface(vec2 uv) { MySurface s; vec4 base = texture(_BaseMap, uv) * _BaseColor; s.albedo = base.rgb; s.alpha = base.a; #if defined(_ALPHATEST_ON) if (s.alpha - _Cutoff < 0.0) discard; #endif #if defined(_MASKMAP) vec4 mask = texture(_MaskMap, uv); // (smooth 是 GLSL 的关键字(插值限定符),变量改名为 smoothness) s.metallic = mask.r; // 有 Mask 时金属度完全由贴图决定(与 URP Lit 一致) float smoothness = mask.a * _Smoothness; // 光滑度 = 贴图 × 滑条,滑条当总强度用 s.occlusion = LerpWhiteTo(mask.g, _OcclusionStrength); #else s.metallic = _Metallic; float smoothness = _Smoothness; s.occlusion = 1.0; #endif s.perceptualRoughness = 1.0 - smoothness; #if defined(_NORMALMAP) s.normalTS = UnpackNormalScale(texture(_BumpMap, uv), _BumpScale); #else s.normalTS = vec3(0, 0, 1); #endif #if defined(_EMISSION) s.emission = texture(_EmissionMap, uv).rgb * _EmissionColor.rgb; #else s.emission = vec3(0.0); #endif return s; } #endif
MySurface 是“材质在这个像素上的物理参数”,与光照无关。几个细节:
- 有 Mask 时金属度完全由贴图决定,光滑度则是“贴图 × 滑条”——这与 URP Lit 的
_METALLICSPECGLOSSMAP行为一致,滑条变成整体强度,方便美术微调而不用重画贴图。 - AO 用
LerpWhiteTo(mask.g, _OcclusionStrength),强度为 0 时 AO 恒为 1。材质 AO 只用于间接光:直接光的遮挡由阴影负责,再乘 AO 会“双重变暗”。 - 法线贴图用 core 包的
UnpackNormalScale,它会根据平台自动处理 DXT5nm(AG 通道)、BC5(RG 通道)和 ASTC 的不同存储方式,自己写tex * 2 - 1在 PC 上会出错。
案例 11.3Forward Pass:顶点、InputData 与片元进阶单独打开 ↗
目标:写出 ForwardLit Pass 的顶点、片元函数,并理解为什么我们要填 URP 的 InputData 结构体。
Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } ZWrite On Cull Back HLSLPROGRAM #pragma target 2.0 #pragma vertex MyPBRVert #pragma fragment MyPBRFrag // ---- 材质关键字(只编译用到的组合)---- #pragma shader_feature_local _NORMALMAP #pragma shader_feature_local_fragment _MASKMAP #pragma shader_feature_local_fragment _EMISSION #pragma shader_feature_local_fragment _DFG_LUT #pragma shader_feature_local_fragment _ALPHATEST_ON // ---- URP 管线关键字(抄自 URP 14 Lit.shader 的 ForwardLit Pass)---- #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS #pragma multi_compile _ EVALUATE_SH_MIXED EVALUATE_SH_VERTEX #pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS #pragma multi_compile_fragment _ _REFLECTION_PROBE_BLENDING #pragma multi_compile_fragment _ _REFLECTION_PROBE_BOX_PROJECTION #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH #pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION #pragma multi_compile_fragment _ _LIGHT_COOKIES #pragma multi_compile _ _LIGHT_LAYERS #pragma multi_compile _ _FORWARD_PLUS #include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/RenderingLayers.hlsl" // ---- Unity 内置关键字:光照贴图、雾、调试 ---- #pragma multi_compile _ LIGHTMAP_SHADOW_MIXING #pragma multi_compile _ SHADOWS_SHADOWMASK #pragma multi_compile _ DIRLIGHTMAP_COMBINED #pragma multi_compile _ LIGHTMAP_ON #pragma multi_compile_fog #pragma multi_compile_fragment _ DEBUG_DISPLAY #pragma multi_compile_instancing #include "Assets/Cookbook/Ch11/MyPBR_ForwardPass.hlsl" ENDHLSL }
Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } ZWrite On Cull Back GLSLPROGRAM // ---- 材质关键字(只编译用到的组合)---- #pragma shader_feature_local _NORMALMAP #pragma shader_feature_local_fragment _MASKMAP #pragma shader_feature_local_fragment _EMISSION #pragma shader_feature_local_fragment _DFG_LUT #pragma shader_feature_local_fragment _ALPHATEST_ON // ---- URP 管线关键字:_MAIN_LIGHT_SHADOWS、_ADDITIONAL_LIGHTS、_SHADOWS_SOFT 等网页版引擎始终打开 ---- // ---- 调试:网页版模拟的 Rendering Debugger ---- #pragma multi_compile_fragment _ DEBUG_DISPLAY #include "Ch11/MyPBR_ForwardPass.glsl" #include "Ch11/DebugDisplay.glsl" #ifdef VERTEX void main() { MyPBRVert(); } #endif #ifdef FRAGMENT void main() { #if defined(DEBUG_DISPLAY) // 让 Rendering Debugger 的材质视图 / 材质校验也能作用于 MyPBR: // 把我们的表面参数交给调试函数(HLSL 版是翻译成 URP 的 SurfaceData 再交给 CanDebugOverrideOutputColor) MySurface s = MySampleSurface(uv); InputData id = MyInitInputData(s.normalTS); MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness); vec4 debugColor; if (CanDebugOverrideOutputColor(s.albedo, s.alpha, m.f0, s.metallic, 1.0 - s.perceptualRoughness, s.occlusion, s.emission, id.normalWS, debugColor)) { fragColor = debugColor; return; } #endif fragColor = MyPBRFrag(); } #endif ENDGLSL }
关键字列表对照 URP 14 的 Shaders/Lit.shader 的 ForwardLit Pass 抄写,然后删掉我们不支持的部分(视差、细节贴图、透明混合、清漆、DOTS Instancing、实时 GI 的 DYNAMICLIGHTMAP_ON)。每一行 multi_compile 都对应 URP Asset 或场景里的一个开关:主光阴影三选一、附加光逐像素/逐顶点、软阴影档位、SSAO、反射探针混合与盒体投影、Light Cookie、Light Layers、Forward+。少声明一个,对应功能在这个 Shader 上就“静默失效”;多声明一个,变体数翻倍。c29 会讲如何统计和剥离变体。
struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; float2 texcoord : TEXCOORD0; float2 staticLightmapUV : TEXCOORD1; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; half3 normalWS : TEXCOORD2; #if defined(_NORMALMAP) half4 tangentWS : TEXCOORD3; // xyz 切线,w 副切线符号 #endif half fogFactor : TEXCOORD4; #if defined(_ADDITIONAL_LIGHTS_VERTEX) half3 vertexLight : TEXCOORD5; #endif DECLARE_LIGHTMAP_OR_SH(staticLightmapUV, vertexSH, 6); // 有光照贴图时是 lightmap UV,否则是逐顶点 SH UNITY_VERTEX_INPUT_INSTANCE_ID UNITY_VERTEX_OUTPUT_STEREO };
// WebGL 不允许结构体做 varying:HLSL 的 Varyings 拆成单独的变量(顶点输入 Attributes 由引擎固定命名:positionOS、normalOS……) VARYING vec2 uv; VARYING vec3 positionWS; VARYING vec3 normalWS; #if defined(_NORMALMAP) VARYING vec4 tangentWS; // xyz 切线,w 副切线符号 #endif VARYING float fogFactor; #if defined(_ADDITIONAL_LIGHTS_VERTEX) VARYING vec3 vertexLight; #endif // 光照贴图 UV / 逐顶点 SH:网页版没有光照贴图,SH 在片元里逐像素求
Varyings MyPBRVert(Attributes input) { Varyings o = (Varyings)0; UNITY_SETUP_INSTANCE_ID(input); UNITY_TRANSFER_INSTANCE_ID(input, o); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs nrm = GetVertexNormalInputs(input.normalOS, input.tangentOS); o.positionCS = pos.positionCS; o.positionWS = pos.positionWS; o.uv = TRANSFORM_TEX(input.texcoord, _BaseMap); o.normalWS = nrm.normalWS; #if defined(_NORMALMAP) // 镜像缩放(负缩放)会翻转副切线方向,GetOddNegativeScale 负责修正 o.tangentWS = half4(nrm.tangentWS, input.tangentOS.w * GetOddNegativeScale()); #endif o.fogFactor = ComputeFogFactor(pos.positionCS.z); #if defined(_ADDITIONAL_LIGHTS_VERTEX) // 逐顶点附加光:只累加 color * atten * NoL(漫反射的 E/π),镜面无法逐顶点 half3 vl = 0; uint n = GetAdditionalLightsCount(); for (uint i = 0u; i < n; ++i) { Light l = GetAdditionalLight(i, pos.positionWS); vl += l.color * l.distanceAttenuation * saturate(dot(nrm.normalWS, l.direction)); } o.vertexLight = vl; #endif OUTPUT_LIGHTMAP_UV(input.staticLightmapUV, unity_LightmapST, o.staticLightmapUV); OUTPUT_SH(o.normalWS, o.vertexSH); return o; }
#ifdef VERTEX void MyPBRVert() { VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz); VertexNormalInputs nrm = GetVertexNormalInputs(normalOS, tangentOS); gl_Position = pos.positionCS; positionWS = pos.positionWS; uv = TransformTex(texcoord0.xy, _BaseMap_ST); normalWS = nrm.normalWS; #if defined(_NORMALMAP) // 镜像缩放(负缩放)会翻转副切线方向,GetOddNegativeScale 负责修正 tangentWS = vec4(nrm.tangentWS, tangentOS.w * GetOddNegativeScale()); #endif fogFactor = ComputeFogFactor(pos.positionCS.z); #if defined(_ADDITIONAL_LIGHTS_VERTEX) // 逐顶点附加光:只累加 color * atten * NoL(漫反射的 E/π),镜面无法逐顶点 vec3 vl = vec3(0.0); int n = GetAdditionalLightsCount(); for (int i = 0; i < n; ++i) { Light l = GetAdditionalLight(i, pos.positionWS); vl += l.color * l.distanceAttenuation * saturate(dot(nrm.normalWS, l.direction)); } vertexLight = vl; #endif } #endif
顶点函数用 GetVertexPositionInputs / GetVertexNormalInputs 一次拿到各个空间的位置和 TBN。DECLARE_LIGHTMAP_OR_SH 是 URP 的宏:开了 LIGHTMAP_ON 时声明一个 lightmap UV,否则声明一个逐顶点 SH 插值器;配套的 OUTPUT_LIGHTMAP_UV / OUTPUT_SH 负责填值,在“SH 逐像素”模式下 OUTPUT_SH 什么都不做。逐顶点附加光(URP Asset 中 Additional Lights 设为 Per Vertex 时)我们自己写了一个 Lambert 循环——它只累加 color × 衰减 × NoL,到片元里再乘漫反射色,π 恰好与 1/π 抵消。
// 填 URP 的 InputData:它只是“像素的几何与环境数据”的容器, // 填好它,阴影、SSAO、Forward+ 的分簇循环、Rendering Debugger 都能直接用 InputData MyInitInputData(Varyings i, half3 normalTS) { InputData d = (InputData)0; d.positionWS = i.positionWS; d.positionCS = i.positionCS; #if defined(_NORMALMAP) float sgn = i.tangentWS.w; float3 bitangent = sgn * cross(i.normalWS, i.tangentWS.xyz); half3x3 tbn = half3x3(i.tangentWS.xyz, bitangent, i.normalWS); d.tangentToWorld = tbn; d.normalWS = TransformTangentToWorld(normalTS, tbn); #else d.normalWS = i.normalWS; #endif d.normalWS = NormalizeNormalPerPixel(d.normalWS); // 插值后长度不为 1,必须重新归一化 d.viewDirectionWS = GetWorldSpaceNormalizeViewDir(i.positionWS); d.shadowCoord = TransformWorldToShadowCoord(i.positionWS); // 逐像素算级联索引,级联/屏幕空间阴影都适用 d.fogCoord = InitializeInputDataFog(float4(i.positionWS, 1.0), i.fogFactor); #if defined(_ADDITIONAL_LIGHTS_VERTEX) d.vertexLighting = i.vertexLight; #endif d.bakedGI = SAMPLE_GI(i.staticLightmapUV, i.vertexSH, d.normalWS); // 光照贴图或 SH d.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(i.positionCS); d.shadowMask = SAMPLE_SHADOWMASK(i.staticLightmapUV); #if defined(DEBUG_DISPLAY) #if defined(LIGHTMAP_ON) d.staticLightmapUV = i.staticLightmapUV; #else d.vertexSH = i.vertexSH; #endif #endif return d; }
// 填 URP 的 InputData:它只是“像素的几何与环境数据”的容器, // 填好它,阴影、SSAO、附加光循环都能直接用 InputData MyInitInputData(vec3 normalTS) { InputData d = InitInputData(); d.positionWS = positionWS; d.positionCS = gl_FragCoord; #if defined(_NORMALMAP) float sgn = tangentWS.w; vec3 bitangent = sgn * cross(normalWS, tangentWS.xyz); mat3 tbn = mat3(tangentWS.xyz, bitangent, normalWS); // GLSL 的 mat3 按列构造:列就是 T/B/N d.tangentToWorld = tbn; d.normalWS = TransformTangentToWorld(normalTS, tbn); #else d.normalWS = normalWS; #endif d.normalWS = NormalizeNormalPerPixel(d.normalWS); // 插值后长度不为 1,必须重新归一化 d.viewDirectionWS = GetWorldSpaceNormalizeViewDir(positionWS); d.shadowCoord = TransformWorldToShadowCoord(positionWS); d.fogCoord = fogFactor; #if defined(_ADDITIONAL_LIGHTS_VERTEX) d.vertexLighting = vertexLight; #endif d.bakedGI = SampleSH(d.normalWS); // 网页版没有光照贴图:逐像素 SH d.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(gl_FragCoord); d.shadowMask = vec4(1.0); return d; }
InputData 是 URP 定义的“像素几何与环境数据”的容器,里面没有任何 BRDF 内容。填好它有三个直接收益:GetMainLight(inputData, shadowMask, aoFactor) 会自动处理阴影、Light Cookie 和 SSAO 的直接光部分;Forward+ 的 LIGHT_LOOP_BEGIN 宏内部会读 inputData.normalizedScreenSpaceUV 和 inputData.positionWS;Rendering Debugger 的调试函数也以它为输入。
阴影坐标我们逐像素调用 TransformWorldToShadowCoord。URP Lit 在“单级联”时会在顶点里算好再插值以省一点 ALU,但级联阴影必须逐像素选级联,屏幕空间阴影则需要屏幕坐标——TransformWorldToShadowCoord 内部已经按 _MAIN_LIGHT_SHADOWS_CASCADE、_MAIN_LIGHT_SHADOWS_SCREEN 分好了支,逐像素调用在所有模式下都正确,代价是一次矩阵乘法。
half4 MyPBRFrag(Varyings i #if defined(_WRITE_RENDERING_LAYERS) , out float4 outRenderingLayers : SV_Target1 #endif ) : SV_Target0 { UNITY_SETUP_INSTANCE_ID(i); UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i); MySurface s = MySampleSurface(i.uv); InputData id = MyInitInputData(i, s.normalTS); MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness); #if defined(DEBUG_DISPLAY) // 让 Rendering Debugger 的材质视图 / 材质校验也能作用于 MyPBR: // 把我们的表面参数翻译成 URP 的 SurfaceData,交给它的调试函数 SurfaceData sd = (SurfaceData)0; sd.albedo = s.albedo; sd.metallic = s.metallic; sd.specular = m.f0; sd.smoothness = 1.0h - s.perceptualRoughness; sd.normalTS = s.normalTS; sd.emission = s.emission; sd.occlusion = s.occlusion; sd.alpha = s.alpha; id.brdfDiffuse = m.diffuseColor; id.brdfSpecular = m.f0; half4 debugColor; if (CanDebugOverrideOutputColor(id, sd, debugColor)) return debugColor; #endif MyLightingTerms t = MyComputeLighting(s, m, id); float3 color = t.directDiffuse + t.directSpecular + t.indirectDiffuse + t.indirectSpecular + t.emission; color = MixFog(color, id.fogCoord); #if defined(_WRITE_RENDERING_LAYERS) outRenderingLayers = float4(EncodeMeshRenderingLayer(GetMeshRenderingLayer()), 0, 0, 0); #endif return half4(color, 1.0); }
vec4 MyPBRFrag() { MySurface s = MySampleSurface(uv); InputData id = MyInitInputData(s.normalTS); MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness); // HLSL 版在 DEBUG_DISPLAY 下把表面参数交给 Rendering Debugger;网页版的调试视图见 Ch11/MyPBRDebug.glsl MyLightingTerms t = MyComputeLighting(s, m, id); vec3 color = t.directDiffuse + t.directSpecular + t.indirectDiffuse + t.indirectSpecular + t.emission; color = MixFog(color, id.fogCoord); return vec4(color, 1.0); }
片元函数的主干只有四行:读表面 → 填 InputData → 推导 BRDF 参数 → 计算光照。DEBUG_DISPLAY 分支把我们的 MySurface 翻译成 URP 的 SurfaceData,交给 CanDebugOverrideOutputColor(摘自 URP 14 ShaderLibrary/Debug/Debugging3D.hlsl,我们用的是不需要 BRDFData 的三参数版本)。有了这几行,Window › Analysis › Rendering Debugger 里的 Albedo、Smoothness、法线等材质视图和材质校验对 MyPBR 同样有效——c13 会大量用到这个功能。_WRITE_RENDERING_LAYERS 来自 RenderingLayers.hlsl 的 pragma,开启 Decal Layers / Light Layers 的某些组合时,URP 要求不透明物体额外输出一个 Rendering Layer 目标。

DEBUG_DISPLAY 分支,中、右两格里的 MyPBR 物体会照常着色。unity_check 和 Inspector 的编译按钮默认只编译当前关键字组合,光照贴图、调试视图、逐顶点光这类“平时不开”的分支即使有语法错误也发现不了。配套工程的 Editor/MyPBRVariantCheck.cs(菜单 )用六组典型关键字组合强制编译每个 Pass,并把编译错误打印出来;本章配图时它报告的错误数为 0。案例 11.4多光源与阴影:Forward 与 Forward+进阶单独打开 ↗
目标:在两种渲染路径下都正确遍历附加光,接收主光与附加光阴影。
// 一盏灯对出射辐射率的贡献:Lo = f(l,v) * E⊥ * NoL // Unity 的光源约定:light.color 是“白色 Lambert 表面正对灯时看到的亮度”, // 即 Lo = albedo/π * E⊥ => E⊥ = π * light.color。所以这里乘 π,与 BRDF 里的 1/π 互相抵消。 void MyAccumulateLight(MyMaterial m, float3 N, float3 V, Light light, inout MyLightingTerms t) { float3 fd, fs; float NoL; MyDirectBRDF(m, N, V, light.direction, fd, fs, NoL); float3 E = PI * light.color * (light.distanceAttenuation * light.shadowAttenuation) * NoL; t.directDiffuse += fd * E; t.directSpecular += fs * E; }
// 一盏灯对出射辐射率的贡献:Lo = f(l,v) * E⊥ * NoL // Unity 的光源约定:light.color 是“白色 Lambert 表面正对灯时看到的亮度”, // 即 Lo = albedo/π * E⊥ => E⊥ = π * light.color。所以这里乘 π,与 BRDF 里的 1/π 互相抵消。 void MyAccumulateLight(MyMaterial m, vec3 N, vec3 V, Light light, inout MyLightingTerms t) { vec3 fd, fs; float NoL; MyDirectBRDF(m, N, V, light.direction, fd, fs, NoL); vec3 E = PI * light.color * (light.distanceAttenuation * light.shadowAttenuation) * NoL; t.directDiffuse += fd * E; t.directSpecular += fs * E; }
每盏灯的贡献都走 MyAccumulateLight:先算 BRDF,再乘 、距离衰减、阴影衰减和 NoL。light.distanceAttenuation 对点光、聚光已经包含了 URP 的平滑距离衰减和聚光锥角衰减(c06 讲过公式),对方向光是 1。
MyLightingTerms MyComputeLighting(MySurface s, inout MyMaterial m, InputData inputData) { MyLightingTerms t = (MyLightingTerms)0; float3 N = inputData.normalWS; float3 V = inputData.viewDirectionWS; float NoV = saturate(dot(N, V)) + 1e-5; // ---- 环境 BRDF 与能量补偿:只和 NoV、粗糙度有关,先算好给直接光也用 ---- float2 dfg = MyEnvDFG(NoV, m.perceptualRoughness); m.energyCompensation = lerp(1.0, MyEnergyCompensation(dfg, m.f0), _EnergyCompensation); // ---- 遮蔽:材质 AO 与 SSAO 取最小值(URP 的约定)---- AmbientOcclusionFactor ao = CreateAmbientOcclusionFactor(inputData.normalizedScreenSpaceUV, s.occlusion); half4 shadowMask = CalculateShadowMask(inputData); uint meshLayers = GetMeshRenderingLayer(); // ---- 主光 ---- Light mainLight = GetMainLight(inputData, shadowMask, ao); // 已含阴影、Light Cookie、SSAO 直接光部分 MixRealtimeAndBakedGI(mainLight, N, inputData.bakedGI); // Subtractive 光照贴图模式下扣掉主光 #if defined(_LIGHT_LAYERS) if (IsMatchingLightLayer(mainLight.layerMask, meshLayers)) #endif MyAccumulateLight(m, N, V, mainLight, t); // ---- 附加光:Forward 逐物体列表 / Forward+ 分簇列表 ---- #if defined(_ADDITIONAL_LIGHTS) uint lightCount = GetAdditionalLightsCount(); #if USE_FORWARD_PLUS // Forward+ 下,除主光外的方向光不进分簇,排在附加光数组最前面,要单独循环 for (uint lightIndex = 0; lightIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); lightIndex++) { FORWARD_PLUS_SUBTRACTIVE_LIGHT_CHECK Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, ao); #if defined(_LIGHT_LAYERS) if (IsMatchingLightLayer(light.layerMask, meshLayers)) #endif MyAccumulateLight(m, N, V, light, t); } #endif // LIGHT_LOOP_BEGIN 在 Forward 下展开成 for 循环,在 Forward+ 下展开成分簇迭代器, // 宏内部会用到名为 inputData 的变量(normalizedScreenSpaceUV、positionWS) LIGHT_LOOP_BEGIN(lightCount) Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, ao); #if defined(_LIGHT_LAYERS) if (IsMatchingLightLayer(light.layerMask, meshLayers)) #endif MyAccumulateLight(m, N, V, light, t); LIGHT_LOOP_END #endif #if defined(_ADDITIONAL_LIGHTS_VERTEX) // 逐顶点附加光只有漫反射:顶点里累加的是 color * atten * NoL,等价于 E/π t.directDiffuse += inputData.vertexLighting * m.diffuseColor; #endif // ---- 间接漫反射:SH(或光照贴图)---- // Unity 的 SH / 光照贴图里存的是 E/π(“已经除过 π 的辐照度”),直接乘漫反射色即可 t.indirectDiffuse = inputData.bakedGI * m.diffuseColor * ao.indirectAmbientOcclusion; // ---- 间接镜面:split-sum = 预滤波环境 × 环境 BRDF ---- float3 R = reflect(-V, N); float3 prefiltered = MySamplePrefilteredEnv(R, m.perceptualRoughness, inputData.positionWS, inputData.normalizedScreenSpaceUV); float3 envBRDF = m.f0 * dfg.x + dfg.y; // F90 = 1 float specOcc = lerp(ao.indirectAmbientOcclusion, MySpecularOcclusion(NoV, ao.indirectAmbientOcclusion, m.perceptualRoughness), _SpecularOcclusion); t.indirectSpecular = prefiltered * envBRDF * m.energyCompensation * specOcc; t.emission = s.emission; return t; }
MyLightingTerms MyComputeLighting(MySurface s, inout MyMaterial m, InputData inputData) { MyLightingTerms t; t.directDiffuse = vec3(0.0); t.directSpecular = vec3(0.0); t.indirectDiffuse = vec3(0.0); t.indirectSpecular = vec3(0.0); t.emission = vec3(0.0); vec3 N = inputData.normalWS; vec3 V = inputData.viewDirectionWS; float NoV = saturate(dot(N, V)) + 1e-5; // ---- 环境 BRDF 与能量补偿:只和 NoV、粗糙度有关,先算好给直接光也用 ---- vec2 dfg = MyEnvDFG(NoV, m.perceptualRoughness); m.energyCompensation = mix(vec3(1.0), MyEnergyCompensation(dfg, m.f0), _EnergyCompensation); // ---- 遮蔽:材质 AO 与 SSAO 取最小值(URP 的约定)---- AmbientOcclusionFactor ao = GetScreenSpaceAmbientOcclusion(inputData.normalizedScreenSpaceUV); ao.indirectAmbientOcclusion = min(ao.indirectAmbientOcclusion, s.occlusion); vec4 shadowMask = inputData.shadowMask; // ---- 主光 ---- Light mainLight = MyGetMainLight(inputData, shadowMask, ao); // 已含阴影、SSAO 直接光部分 // (HLSL 版这里还有 MixRealtimeAndBakedGI 与 Light Layers 判断;网页版没有光照贴图和灯光层) MyAccumulateLight(m, N, V, mainLight, t); // ---- 附加光:Forward 逐物体列表 / Forward+ 全部可见光 ---- #if defined(_ADDITIONAL_LIGHTS) // HLSL 版用 LIGHT_LOOP_BEGIN/END(Forward+ 下展开成分簇迭代器); // 网页版的 GetAdditionalLightsCount() 已经按渲染路径返回本像素要算的灯数,普通 for 循环即可 int lightCount = GetAdditionalLightsCount(); for (int lightIndex = 0; lightIndex < lightCount; lightIndex++) { Light light = MyGetAdditionalLight(lightIndex, inputData, shadowMask, ao); MyAccumulateLight(m, N, V, light, t); } #endif #if defined(_ADDITIONAL_LIGHTS_VERTEX) // 逐顶点附加光只有漫反射:顶点里累加的是 color * atten * NoL,等价于 E/π t.directDiffuse += inputData.vertexLighting * m.diffuseColor; #endif // ---- 间接漫反射:SH(或光照贴图)---- // Unity 的 SH / 光照贴图里存的是 E/π(“已经除过 π 的辐照度”),直接乘漫反射色即可 t.indirectDiffuse = inputData.bakedGI * m.diffuseColor * ao.indirectAmbientOcclusion; // ---- 间接镜面:split-sum = 预滤波环境 × 环境 BRDF ---- vec3 R = reflect(-V, N); vec3 prefiltered = MySamplePrefilteredEnv(R, m.perceptualRoughness, inputData.positionWS, inputData.normalizedScreenSpaceUV); vec3 envBRDF = m.f0 * dfg.x + dfg.y; // F90 = 1 float specOcc = mix(ao.indirectAmbientOcclusion, MySpecularOcclusion(NoV, ao.indirectAmbientOcclusion, m.perceptualRoughness), _SpecularOcclusion); t.indirectSpecular = prefiltered * envBRDF * m.energyCompensation * specOcc; t.emission = s.emission; return t; }
附加光部分是这一节的重点。LIGHT_LOOP_BEGIN 在两种路径下展开成完全不同的代码(摘自 URP 14 ShaderLibrary/RealtimeLights.hlsl):
#if USE_FORWARD_PLUS #define LIGHT_LOOP_BEGIN(lightCount) { \ uint lightIndex; \ ClusterIterator _urp_internal_clusterIterator = ClusterInit(inputData.normalizedScreenSpaceUV, inputData.positionWS, 0); \ [loop] while (ClusterNext(_urp_internal_clusterIterator, lightIndex)) { \ lightIndex += URP_FP_DIRECTIONAL_LIGHTS_COUNT; \ FORWARD_PLUS_SUBTRACTIVE_LIGHT_CHECK #elif !_USE_WEBGL1_LIGHTS #define LIGHT_LOOP_BEGIN(lightCount) \ for (uint lightIndex = 0u; lightIndex < lightCount; ++lightIndex) {
Forward 下,URP 在 CPU 端给每个物体挑出影响它的灯(按重要性排序,数量受 URP Asset 的 限制),GetAdditionalLightsCount() 返回这个数量,GetAdditionalLight(i, …) 内部再把循环序号 i 换成全局灯光索引。Forward+ 下,GetAdditionalLightsCount() 直接返回 0(宏忽略这个参数),循环靠分簇迭代器驱动;但除主光之外的方向光不参与分簇,它们排在附加光数组最前面,数量是 URP_FP_DIRECTIONAL_LIGHTS_COUNT,必须像代码里那样在 LIGHT_LOOP 之前单独循环一遍——漏掉这段,第二盏方向光在 Forward+ 下就不亮。这段写法与 URP 的 UniversalFragmentPBR 完全对应。
阴影方面,主光阴影来自 GetMainLight(inputData, shadowMask, ao),附加光阴影来自 GetAdditionalLight(i, inputData, shadowMask, ao),二者内部根据 _MAIN_LIGHT_SHADOWS*、_ADDITIONAL_LIGHT_SHADOWS、_SHADOWS_SOFT* 关键字选择采样方式;shadowMask 用于 Shadowmask 光照模式下烘焙阴影与实时阴影的混合。我们需要做的只是:声明好关键字、给出 ShadowCaster Pass(案例 11.9)、用 light.shadowAttenuation 乘到光照里。

这张对比图揭示了 Forward 路径一个常被忽视的问题:一个大物体(地板、地形、大墙面)上的灯数受“每物体上限”约束,超出的灯不会逐渐变暗,而是整盏消失,移动相机或物体时还可能跳变。Forward+ 没有这个限制,所以本书的配图默认使用 Forward+。如果项目必须用 Forward(例如要支持不支持 Forward+ 的老设备),就要把大物体切块,或者提高上限。
11.4 IBL:split-sum 的两半
直接光只是“几个方向上的光”;真实环境中,光从四面八方来。对整个半球积分环境光,就是基于图像的光照(Image-Based Lighting,IBL)。漫反射和镜面反射的处理方式完全不同,原因在于 BRDF 的形状:Lambert 是常数,积分只和法线有关,可以预先算好存起来;镜面 BRDF 依赖视线、法线、粗糙度、F0,一个四维以上的函数,没法直接预计算。
漫反射:球谐
对 Lambert,出射辐射率只是“环境光的余弦加权平均 × 反照率”。这个余弦加权平均(辐照度)是法线方向的光滑函数,用 L2 球谐(9 个系数 × RGB)就能很好地近似。URP 把环境光(天空盒或渐变/纯色设置)和光照探针都投影成 SH,存在 unity_SHAr 等 7 个 float4 里,SampleSH(normalWS) 负责求值(第 8 章讲过它的原理)。我们这里要关心的只有一件事:Unity 的 SH 求值结果是 ,也就是已经除过 π 的辐照度。所以间接漫反射直接写成 bakedGI * diffuseColor,不再除以 π。光照贴图同理,SAMPLE_GI 宏在有光照贴图时读贴图、否则读 SH,返回值的约定相同。
SH 在哪里求值由 URP Asset 的设置决定,对应关键字 EVALUATE_SH_MIXED(L2 项逐顶点、L0/L1 逐像素)和 EVALUATE_SH_VERTEX(全部逐顶点)。我们用 URP 的 OUTPUT_SH + SAMPLE_GI 组合,三种模式都自动适配。
镜面:split-sum 近似
镜面 IBL 要算的是:
这就是 Karis 在 2013 年 SIGGRAPH 课程中提出的 split-sum 近似:把“环境光 × BRDF”的积分拆成两个积分的乘积。左半边只和环境图、反射方向、粗糙度有关(假设 ),可以对每个粗糙度把 cubemap 用 GGX 波瓣卷积一遍,存进 mip 链;右半边只和 NoV、粗糙度、F0 有关,而 F0 可以提出来,于是剩下一个二维函数,可以做成查找表,也可以用解析式拟合。
案例 11.5预滤波环境图与 mip 选择进阶单独打开 ↗
// 取预滤波环境图(split-sum 的第一项) float3 MySamplePrefilteredEnv(float3 R, float perceptualRoughness, float3 positionWS, float2 screenUV) { #if USE_FORWARD_PLUS || defined(_REFLECTION_PROBE_BLENDING) // Forward+ 把所有反射探针打包进一张八面体图集,天空反射走 _GlossyEnvironmentCubeMap; // 探针混合也有一套权重逻辑。这些都是“取样”,与 BRDF 无关,直接复用 URP 的取样函数 // (它内部同样用感知粗糙度选 mip,映射公式与 MyRoughnessToMip 相同)。 return GlossyEnvironmentReflection(R, positionWS, perceptualRoughness, 1.0h, screenUV); #else #if defined(_REFLECTION_PROBE_BOX_PROJECTION) R = BoxProjectedCubemapDirection(R, positionWS, unity_SpecCube0_ProbePosition, unity_SpecCube0_BoxMin, unity_SpecCube0_BoxMax); #endif float mip = MyRoughnessToMip(perceptualRoughness); half4 encoded = SAMPLE_TEXTURECUBE_LOD(unity_SpecCube0, samplerunity_SpecCube0, R, mip); return DecodeHDREnvironment(encoded, unity_SpecCube0_HDR); // 解 RGBM / HDR 编码 #endif }
// 取预滤波环境图(split-sum 的第一项) // HLSL 版在 Forward+ / 探针混合时改用 URP 的 GlossyEnvironmentReflection(反射探针图集); // 网页版只有一个反射立方体图 unity_SpecCube0,两条路径结果相同,这里只保留手写的那条。 vec3 MySamplePrefilteredEnv(vec3 R, float perceptualRoughness, vec3 positionWS, vec2 screenUV) { float mip = MyRoughnessToMip(perceptualRoughness); vec4 encoded = textureLod(unity_SpecCube0, R, mip); return DecodeHDREnvironment(encoded, unity_SpecCube0_HDR); // 解 RGBM / HDR 编码 }
Unity 在烘焙反射探针、或者从天空盒生成默认反射时,会对 cubemap 做模拟镜面波瓣、按 mip 级递增模糊的卷积(具体滤波核以引擎实现为准),结果存在 mip 链里(core 包把最后一级的序号定义为 UNITY_SPECCUBE_LOD_STEPS = 6)。我们要做的就是把感知粗糙度映射到 mip 级:
为什么不是线性的 ?因为 GGX 波瓣的张角与粗糙度不是线性关系:低粗糙度区间里,波瓣宽度变化很快,需要更多的 mip 级来区分;高粗糙度时波瓣已经很宽,几级 mip 差别不大。 是一条在 r=0 时斜率为 1.7、r=1 时恰好为 1 的抛物线,把更多的 mip 分配给光滑端。这个映射与 URP 的 PerceptualRoughnessToMipmapLevel 相同,我们自己写一遍是为了看清它,同时保证和 URP Lit 采样同一级 mip,便于对比。
#if 的第一个分支是本章唯一“复用 URP 取样逻辑”的地方,值得解释为什么:Forward+ 下 URP 不再给每个物体绑定 unity_SpecCube0/1,而是把场景里所有反射探针的各级 mip 打包进一张八面体映射的二维图集,再加上分簇查找和按体积/重要性的权重混合;剩余权重落到天空反射 _GlossyEnvironmentCubeMap。开启 Reflection Probe Blending 时 Forward 路径也有一套双探针权重逻辑。这些都是“去哪里取环境光”的问题,和 BRDF 无关,自己重写只会制造与 URP 不一致的风险。第二个分支是传统 Forward 的单探针路径,我们手写了 mip 选择、盒体投影和 HDR 解码,作为理解 split-sum 左半的最小实现。
案例 11.6环境 BRDF:解析近似与程序生成的 DFG LUT高阶单独打开 ↗
目标:实现 split-sum 的右半,给出两种方案,并量化它们的差异。
把 Schlick 菲涅尔代入右半积分,F0 可以提到积分外面:
A、B 只是 NoV 和粗糙度的函数。方案一是 Karis 2014 年在《Physically Based Shading on Mobile》中给出的解析拟合,十几条 ALU 指令,不需要贴图;方案二是自己把积分算出来,存成一张 64×64 的 RG 贴图。
// 环境 BRDF(split-sum 的第二项):∫ f_s cosθ dω = F0 * A + F90 * B // 解析近似:Karis 2014《Physically Based Shading on Mobile》中的拟合,输入感知粗糙度 float2 MyEnvDFGApprox(float NoV, float perceptualRoughness) { 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; // x = A(乘 F0),y = B(乘 F90) } // 程序生成的 LUT:u = NoV,v = 感知粗糙度;生成代码见 DFGLut.cs float2 MyEnvDFG(float NoV, float perceptualRoughness) { #if defined(_DFG_LUT) return SAMPLE_TEXTURE2D_LOD(_DFGLut, sampler_LinearClamp, float2(NoV, perceptualRoughness), 0).rg; #else return MyEnvDFGApprox(NoV, perceptualRoughness); #endif } // 多次散射能量补偿(Kulla-Conty 思路的廉价版本,Fdez-Agüero 2019 / Filament 的做法): // 单次散射的方向反照率 Ess = A + B(取 F0 = 1),丢掉的能量是 1 - Ess。 // 把镜面整体放大 1 + F0 (1/Ess - 1),粗糙金属不再“越粗越暗”。 float3 MyEnergyCompensation(float2 dfg, float3 f0) { float Ess = max(dfg.x + dfg.y, 1e-4); return 1.0 + f0 * (1.0 / Ess - 1.0); } // 镜面遮蔽:由 AO 推一个对镜面也合理的遮蔽量(Lagarde & de Rousiers 2014 的经验公式) float MySpecularOcclusion(float NoV, float ao, float perceptualRoughness) { float a = perceptualRoughness * perceptualRoughness; return saturate(pow(abs(NoV + ao), exp2(-16.0 * a - 1.0)) - 1.0 + ao); } // 感知粗糙度 -> 预滤波 cubemap 的 mip 级。UNITY_SPECCUBE_LOD_STEPS = 6(最后一级的序号)。 // 非线性映射让低粗糙度区间分到更多 mip 级(与 URP 的取法一致,便于对照)。 float MyRoughnessToMip(float perceptualRoughness) { return perceptualRoughness * (1.7 - 0.7 * perceptualRoughness) * UNITY_SPECCUBE_LOD_STEPS; }
// 环境 BRDF(split-sum 的第二项):∫ f_s cosθ dω = F0 * A + F90 * B // 解析近似:Karis 2014《Physically Based Shading on Mobile》中的拟合,输入感知粗糙度 vec2 MyEnvDFGApprox(float NoV, float perceptualRoughness) { 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; // x = A(乘 F0),y = B(乘 F90) } // 程序生成的 LUT:u = NoV,v = 感知粗糙度;生成代码见 DFGLut.cs vec2 MyEnvDFG(float NoV, float perceptualRoughness) { #if defined(_DFG_LUT) return textureLod(_DFGLut, vec2(NoV, perceptualRoughness), 0.0).rg; #else return MyEnvDFGApprox(NoV, perceptualRoughness); #endif } // 多次散射能量补偿(Kulla-Conty 思路的廉价版本,Fdez-Agüero 2019 / Filament 的做法): // 单次散射的方向反照率 Ess = A + B(取 F0 = 1),丢掉的能量是 1 - Ess。 // 把镜面整体放大 1 + F0 (1/Ess - 1),粗糙金属不再“越粗越暗”。 vec3 MyEnergyCompensation(vec2 dfg, vec3 f0) { float Ess = max(dfg.x + dfg.y, 1e-4); return 1.0 + f0 * (1.0 / Ess - 1.0); } // 镜面遮蔽:由 AO 推一个对镜面也合理的遮蔽量(Lagarde & de Rousiers 2014 的经验公式) float MySpecularOcclusion(float NoV, float ao, float perceptualRoughness) { float a = perceptualRoughness * perceptualRoughness; return saturate(pow(abs(NoV + ao), exp2(-16.0 * a - 1.0)) - 1.0 + ao); } // 感知粗糙度 -> 预滤波 cubemap 的 mip 级。UNITY_SPECCUBE_LOD_STEPS = 6(最后一级的序号)。 // 非线性映射让低粗糙度区间分到更多 mip 级(与 URP 的取法一致,便于对照)。 float MyRoughnessToMip(float perceptualRoughness) { return perceptualRoughness * (1.7 - 0.7 * perceptualRoughness) * UNITY_SPECCUBE_LOD_STEPS; }
LUT 的生成用 C# 在 CPU 上完成,算法是标准的 GGX 重要性采样:
public static Texture2D Generate(int size = 64, int sampleCount = 512) { var tex = new Texture2D(size, size, TextureFormat.RGBAHalf, false, true) // 线性、无 mip { name = "MyPBR_DFG_LUT", wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear }; var px = new Color[size * size]; for (int y = 0; y < size; y++) { float perceptualRoughness = (y + 0.5f) / size; // 取像素中心,双线性采样时才对得上 float a = perceptualRoughness * perceptualRoughness; for (int x = 0; x < size; x++) { float NoV = (x + 0.5f) / size; Vector2 ab = Integrate(NoV, a, sampleCount); px[y * size + x] = new Color(ab.x, ab.y, 0, 1); } } tex.SetPixels(px); tex.Apply(false, false); return tex; } static Vector2 Integrate(float NoV, float a, int sampleCount) { // 切线空间里 N = (0,0,1),V 放在 xz 平面 var V = new Vector3(Mathf.Sqrt(1f - NoV * NoV), 0f, NoV); float a2 = a * a; float A = 0, B = 0; for (int i = 0; i < sampleCount; i++) { Vector2 xi = Hammersley(i, sampleCount); Vector3 H = ImportanceSampleGGX(xi, a2); Vector3 L = 2f * Vector3.Dot(V, H) * H - V; float NoL = Mathf.Clamp01(L.z); float NoH = Mathf.Clamp01(H.z); float VoH = Mathf.Clamp01(Vector3.Dot(V, H)); if (NoL <= 0f) continue; // pdf(L) = D · NoH / (4 VoH);被积函数 f_s·NoL = D·V·F·NoL // 两者相除 D 消掉:F · V · 4 · NoL · VoH / NoH float vis = SmithGGXCorrelated(NoV, NoL, a2); float gv = vis * 4f * NoL * VoH / Mathf.Max(NoH, 1e-6f); float fc = Mathf.Pow(1f - VoH, 5f); A += (1f - fc) * gv; B += fc * gv; } return new Vector2(A / sampleCount, B / sampleCount); } // 与 HLSL 的 MyV_SmithGGXCorrelated 逐字对应 static float SmithGGXCorrelated(float NoV, float NoL, float a2) { float lv = NoL * Mathf.Sqrt((NoV - NoV * a2) * NoV + a2); float ll = NoV * Mathf.Sqrt((NoL - NoL * a2) * NoL + a2); return 0.5f / Mathf.Max(lv + ll, 1e-5f); } // GGX 重要性采样:按 D(h)·NoH 的分布抽半角向量 static Vector3 ImportanceSampleGGX(Vector2 xi, float a2) { float phi = 2f * Mathf.PI * xi.x; float cosTheta = Mathf.Sqrt((1f - xi.y) / (1f + (a2 - 1f) * xi.y)); float sinTheta = Mathf.Sqrt(1f - cosTheta * cosTheta); return new Vector3(sinTheta * Mathf.Cos(phi), sinTheta * Mathf.Sin(phi), cosTheta); } // 低差异序列:比纯随机数收敛快得多,512 个样本就很平滑 static Vector2 Hammersley(int i, int n) { uint bits = (uint)i; bits = (bits << 16) | (bits >> 16); bits = ((bits & 0x55555555u) << 1) | ((bits & 0xAAAAAAAAu) >> 1); bits = ((bits & 0x33333333u) << 2) | ((bits & 0xCCCCCCCCu) >> 2); bits = ((bits & 0x0F0F0F0Fu) << 4) | ((bits & 0xF0F0F0F0u) >> 4); bits = ((bits & 0x00FF00FFu) << 8) | ((bits & 0xFF00FF00u) >> 8); return new Vector2((float)i / n, bits * 2.3283064365386963e-10f); }
几个要点。第一,被积函数里的 D 被重要性采样的概率密度消掉了:按 抽取半角向量,换算到入射方向的概率密度是 ,被积函数除以它,剩下 。第二,可见性项用的是与直接光完全相同的 SmithGGXCorrelated,这样直接光和环境光的“镜面模型”是同一个。第三,用 Hammersley 低差异序列代替随机数,512 个样本就足够平滑。第四,采样坐标取像素中心 (i + 0.5) / size,与 GPU 双线性采样的约定一致。菜单 (Editor/DFGLutBaker.cs)会把它存成资产,拖进材质的 DFG LUT 槽并勾选 Use DFG LUT 即可。

两张表大体形状一致:光滑且正视时 A≈1、B≈0(几乎全部由 F0 决定);掠射时 B 变大(菲涅尔把反射推向白色);粗糙时整体变暗。但差异图告诉我们,两者并不相同。配图脚本把逐像素误差统计打印到了日志里:在 64×64 的网格上,A 的最大差约 0.46、平均差约 0.11,B 的最大差约 0.09、平均差约 0.01,最大差出现在“高粗糙度 + 掠射角”一角。原因不是拟合不准那么简单——按 Karis 的课程笔记,这个拟合的对象是 UE4 当年的 LUT,那张表用的是可分离的 Schlick-Smith 几何项(环境光取 k = α/2);我们的表用的是高度相关 Smith,遮挡估计更少,粗糙表面在掠射时保留了更多能量。两种模型都“对”,但直接光和环境光最好用同一个模型,否则同一个材质在灯下和在环境里会显得不像同一种东西。所以 MyPBR 默认在配图中启用 LUT,解析近似留给不想多一张贴图、多一次采样的移动端。
Shader.SetGlobalTexture 设成全局贴图,并把 _DFGLut 从 Properties 里删掉(材质属性会覆盖同名全局变量)。这样不用每个材质都拖一次。本章为了让材质自包含、便于对比两种方案,保留了材质属性的写法。案例 11.7多次散射能量补偿高阶单独打开 ↗
目标:让粗糙金属不再“越粗糙越暗”,并用白炉测试验证。
Cook-Torrance 模型只考虑了光在微表面上反射一次就离开的情况。被 判为“被挡住”的光,在现实中并没有消失,而是撞到相邻的微平面上再次反射,其中相当一部分最终也离开了表面。单次散射模型把这部分能量直接丢掉了,表面越粗糙、遮挡越多,丢得越多。
Kulla 与 Conty(SIGGRAPH 2017)给出了完整的补偿方法:用一个额外的 BRDF 项 把丢失的 加回来,其中 是单次散射的方向反照率。实时渲染里常用它的一个廉价变体(Fdez-Agüero 2019,Filament 采用):注意到 F0 = 1 时环境 BRDF 恰好就是 ,DFG 表里已经有了,于是把整个镜面项放大:
对白色金属(F0 = 1),放大系数是 ,环境镜面恰好补回到 1;对电介质(F0 = 0.04)放大量很小,因为电介质的镜面本来就弱,丢失的能量大部分被菲涅尔“吸收”了。代码里这个系数在 MyComputeLighting 开头算一次,存进 m.energyCompensation,直接光和环境光的镜面都乘上它。_EnergyCompensation 滑条在 0 和 1 之间插值,方便对比。

白炉测试是检验 BRDF 能量守恒的标准方法:在一个各方向亮度相同的环境里,一个不吸收任何能量的材质应该与背景无法区分。左图上排从左数第二个球开始就能看到轮廓,越往右越暗,这就是单次散射丢失的能量;右图补偿后上排完全隐形。下排的现象同样值得注意:彩色金属多次反弹时每次都乘一次 F0,颜色会越来越饱和——这正是 Kulla-Conty 指出的“粗糙彩色金属应该更饱和”,补偿项里的 F0 因子模拟了这一点。
案例 11.8法线贴图、Mask Map 与自发光入门单独打开 ↗
目标:把表面细节接进来,看每一张贴图贡献了什么。
三张贴图的读取都已在 MySampleSurface 里完成,法线变换在 MyInitInputData 里完成:用插值后的法线、切线和 tangentWS.w 重建副切线,构成 TBN 矩阵,把切线空间法线转到世界空间后重新归一化。tangentWS.w 携带的是“副切线方向符号”,它来自模型导入时的切线计算(镜像 UV 时为 −1),顶点函数里还乘了 GetOddNegativeScale(),处理物体被负缩放镜像的情况。这些和第 7 章的内容一致。
自发光直接加到最终颜色上,不参与任何 BRDF 计算;HDR 颜色属性允许强度大于 1,配合 Bloom 产生光晕。Meta Pass(案例 11.9)会把自发光交给烘焙器,让它照亮周围的静态物体。

第三格的变化最大:剥落处从“灰白色塑料”变成了反射环境的金属,漆面保持塑料感。这正是金属度工作流的意义——同一张基础色贴图,金属度告诉 Shader “这里的颜色该放进漫反射还是放进 F0”。c13 会专门讨论这三张数据贴图怎么做、怎么检查。
案例 11.9其余四个 Pass进阶单独打开 ↗
目标:给 MyPBR 补齐投射阴影、深度预渲染、深度法线与烘焙用的 Pass,让它在 URP 的所有功能里都是“一等公民”。
Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull Back HLSLPROGRAM #pragma target 2.0 #pragma vertex MyShadowVert #pragma fragment MyShadowFrag #pragma shader_feature_local _ALPHATEST_ON #pragma multi_compile_instancing // 方向光与点/聚光的法线偏移公式不同,URP 用这个关键字区分 #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW #include "Assets/Cookbook/Ch11/MyPBR_DepthPasses.hlsl" ENDHLSL }
Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull Back GLSLPROGRAM #pragma shader_feature_local _ALPHATEST_ON // 方向光与点/聚光的法线偏移公式不同,URP 用 _CASTING_PUNCTUAL_LIGHT_SHADOW 关键字区分(网页版是 uniform) #include "Ch11/MyPBR_DepthPasses.glsl" #ifdef VERTEX void main() { MyShadowVert(); } #endif #ifdef FRAGMENT void main() { fragColor = MyShadowFrag(); } #endif ENDGLSL }
// URP 在渲染阴影图时设置这两个变量:方向光用 _LightDirection,点/聚光用 _LightPosition float3 _LightDirection; float3 _LightPosition; DepthVaryings MyShadowVert(DepthAttributes input) { DepthVaryings o = (DepthVaryings)0; UNITY_SETUP_INSTANCE_ID(input); UNITY_TRANSFER_INSTANCE_ID(input, o); float3 positionWS = TransformObjectToWorld(input.positionOS.xyz); float3 normalWS = TransformObjectToWorldNormal(input.normalOS); #if defined(_CASTING_PUNCTUAL_LIGHT_SHADOW) float3 lightDirWS = normalize(_LightPosition - positionWS); #else float3 lightDirWS = _LightDirection; #endif // 深度偏移 + 法线偏移(数值来自灯光的 Bias 设置),对抗 Shadow Acne float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirWS)); // 把被近裁剪面切掉的投射体压到近平面上(Shadow Pancaking),否则阴影会出洞 #if UNITY_REVERSED_Z positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE); #else positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE); #endif o.positionCS = positionCS; o.uv = TRANSFORM_TEX(input.texcoord, _BaseMap); return o; } half4 MyShadowFrag(DepthVaryings i) : SV_Target { UNITY_SETUP_INSTANCE_ID(i); MyAlphaClip(i.uv); return 0; }
// URP 在渲染阴影图时设置这两个变量:方向光用 _LightDirection,点/聚光用 _LightPosition // (网页版核心库 Lighting.glsl 已声明 _LightDirection、_LightPosition、_CastingPunctualLightShadow) #ifdef VERTEX void MyShadowVert() { vec3 positionWS = TransformObjectToWorld(positionOS.xyz); vec3 nWS = TransformObjectToWorldNormal(normalOS); // HLSL 里是 _CASTING_PUNCTUAL_LIGHT_SHADOW 关键字;网页版是 uniform vec3 lightDirWS = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection; // 深度偏移 + 法线偏移(数值来自灯光的 Bias 设置),对抗 Shadow Acne vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, nWS, lightDirWS)); // 把被近裁剪面切掉的投射体压到近平面上(Shadow Pancaking),否则阴影会出洞 #if UNITY_REVERSED_Z positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE); #else positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE * positionCS.w); // GL 的近平面在 z = -w #endif gl_Position = positionCS; uv = TransformTex(texcoord0.xy, _BaseMap_ST); } #endif #ifdef FRAGMENT vec4 MyShadowFrag() { MyAlphaClip(uv); return vec4(0.0); } #endif
ShadowCaster 的核心是 ApplyShadowBias:沿光线方向推远一点(Depth Bias)、沿法线方向往里缩一点(Normal Bias),两者的数值来自灯光组件的 Bias 设置,由 URP 通过 _ShadowBias 传入。_LightDirection 和 _LightPosition 不在任何 URP 头文件里声明,而是 ShadowCasterPass.hlsl 自己声明、由 C# 端 ShadowUtils.SetupShadowCasterConstantBuffer 设置的普通全局变量,所以我们也要自己声明。点光和聚光的光线方向在每个顶点都不同,所以用 _CASTING_PUNCTUAL_LIGHT_SHADOW 区分。最后把被近裁剪面切掉的顶点压到近平面上(Shadow Pancaking):方向光阴影的正交投影只覆盖级联范围附近的一段深度,位于近平面之前的投射体如果被直接裁掉,就会在阴影里留下洞。
Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R Cull Back HLSLPROGRAM #pragma target 2.0 #pragma vertex MyDepthVert #pragma fragment MyDepthFrag #pragma shader_feature_local _ALPHATEST_ON #pragma multi_compile_instancing #include "Assets/Cookbook/Ch11/MyPBR_DepthPasses.hlsl" ENDHLSL } Pass { Name "DepthNormals" Tags { "LightMode"="DepthNormals" } ZWrite On Cull Back HLSLPROGRAM #pragma target 2.0 #pragma vertex MyDepthNormalsVert #pragma fragment MyDepthNormalsFrag #pragma shader_feature_local _NORMALMAP #pragma shader_feature_local _ALPHATEST_ON #pragma multi_compile_instancing #include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/RenderingLayers.hlsl" #include "Assets/Cookbook/Ch11/MyPBR_DepthPasses.hlsl" ENDHLSL }
Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R Cull Back GLSLPROGRAM #pragma shader_feature_local _ALPHATEST_ON #include "Ch11/MyPBR_DepthPasses.glsl" #ifdef VERTEX void main() { MyDepthVert(); } #endif #ifdef FRAGMENT void main() { fragColor = MyDepthFrag(); } #endif ENDGLSL } Pass { Name "DepthNormals" Tags { "LightMode"="DepthNormals" } ZWrite On Cull Back GLSLPROGRAM #pragma shader_feature_local _NORMALMAP #pragma shader_feature_local _ALPHATEST_ON #include "Ch11/MyPBR_DepthPasses.glsl" #ifdef VERTEX void main() { MyDepthNormalsVert(); } #endif #ifdef FRAGMENT void main() { fragColor = MyDepthNormalsFrag(); } #endif ENDGLSL }
DepthVaryings MyDepthNormalsVert(DepthAttributes input) { DepthVaryings o = (DepthVaryings)0; UNITY_SETUP_INSTANCE_ID(input); UNITY_TRANSFER_INSTANCE_ID(input, o); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); o.positionCS = TransformObjectToHClip(input.positionOS.xyz); o.uv = TRANSFORM_TEX(input.texcoord, _BaseMap); VertexNormalInputs nrm = GetVertexNormalInputs(input.normalOS, input.tangentOS); o.normalWS = nrm.normalWS; #if defined(_NORMALMAP) o.tangentWS = half4(nrm.tangentWS, input.tangentOS.w * GetOddNegativeScale()); #endif return o; } void MyDepthNormalsFrag(DepthVaryings i , out half4 outNormalWS : SV_Target0 #if defined(_WRITE_RENDERING_LAYERS) , out float4 outRenderingLayers : SV_Target1 #endif ) { UNITY_SETUP_INSTANCE_ID(i); UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i); MyAlphaClip(i.uv); #if defined(_NORMALMAP) // SSAO、贴花等读 _CameraNormalsTexture,带上法线贴图细节它们才对得上 half3 normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, i.uv), _BumpScale); float3 bitangent = i.tangentWS.w * cross(i.normalWS, i.tangentWS.xyz); float3 normalWS = TransformTangentToWorld(normalTS, half3x3(i.tangentWS.xyz, bitangent, i.normalWS)); #else float3 normalWS = i.normalWS; #endif outNormalWS = half4(NormalizeNormalPerPixel(normalWS), 0.0); // URP 14 存未编码的世界法线 #if defined(_WRITE_RENDERING_LAYERS) outRenderingLayers = float4(EncodeMeshRenderingLayer(GetMeshRenderingLayer()), 0, 0, 0); #endif }
#ifdef VERTEX void MyDepthNormalsVert() { gl_Position = TransformObjectToHClip(positionOS.xyz); uv = TransformTex(texcoord0.xy, _BaseMap_ST); VertexNormalInputs nrm = GetVertexNormalInputs(normalOS, tangentOS); normalWS = nrm.normalWS; #if defined(_NORMALMAP) tangentWS = vec4(nrm.tangentWS, tangentOS.w * GetOddNegativeScale()); #endif } #endif #ifdef FRAGMENT vec4 MyDepthNormalsFrag() { MyAlphaClip(uv); #if defined(_NORMALMAP) // SSAO、贴花等读 _CameraNormalsTexture,带上法线贴图细节它们才对得上 vec3 normalTS = UnpackNormalScale(texture(_BumpMap, uv), _BumpScale); vec3 bitangent = tangentWS.w * cross(normalWS, tangentWS.xyz); vec3 n = TransformTangentToWorld(normalTS, mat3(tangentWS.xyz, bitangent, normalWS)); #else vec3 n = normalWS; #endif return vec4(NormalizeNormalPerPixel(n), 0.0); // URP 14 存未编码的世界法线 } #endif
DepthOnly 用于深度预渲染(Depth Prepass)和生成 _CameraDepthTexture(当 URP 判断不能直接拷贝深度时);DepthNormals 用于生成 _CameraNormalsTexture,SSAO、屏幕空间贴花、某些描边效果都依赖它。如果一个不透明 Shader 缺少 DepthNormals Pass,它在法线图里就是一个“洞”,SSAO 会在它周围产生错误的遮蔽。注意 DepthNormals 里同样要采样法线贴图——SSAO 读到的法线应当和着色用的法线一致,否则砖缝等细节处的遮蔽会和光照对不上。URP 14 Lit 的 DepthNormals Pass 输出的是未编码的世界空间法线(八面体编码 _GBUFFER_NORMALS_OCT 只在 GBuffer Pass 里声明),我们保持一致。

SSAO 的结果通过 _SCREEN_SPACE_OCCLUSION 关键字回到 Forward Pass:CreateAmbientOcclusionFactor 把屏幕空间 AO 和材质 AO 取最小值作为间接光遮蔽,再按 SSAO 设置中的 Direct Lighting Strength 得到直接光遮蔽(GetMainLight(inputData, …, aoFactor) 内部会乘到灯光颜色上)。
Pass { Name "Meta" Tags { "LightMode"="Meta" } Cull Off HLSLPROGRAM #pragma target 2.0 #pragma vertex MyMetaVert #pragma fragment MyMetaFrag #pragma shader_feature_local_fragment _MASKMAP #pragma shader_feature_local_fragment _EMISSION #pragma shader_feature_local_fragment _ALPHATEST_ON #pragma shader_feature EDITOR_VISUALIZATION #include "Assets/Cookbook/Ch11/MyPBR_MetaPass.hlsl" ENDHLSL }
Pass { Name "Meta" Tags { "LightMode"="Meta" } Cull Off GLSLPROGRAM #pragma shader_feature_local_fragment _MASKMAP #pragma shader_feature_local_fragment _EMISSION #pragma shader_feature_local_fragment _ALPHATEST_ON #include "Ch11/MyPBR_MetaPass.glsl" #ifdef VERTEX void main() { MyMetaVert(); } #endif #ifdef FRAGMENT void main() { fragColor = MyMetaFrag(); } #endif ENDGLSL }
MetaVaryings MyMetaVert(MetaAttributes input) { MetaVaryings o = (MetaVaryings)0; // 烘焙时 unity_MetaVertexControl 指示用哪套 UV 当“屏幕位置” o.positionCS = UnityMetaVertexPosition(input.positionOS.xyz, input.uv1, input.uv2); o.uv = TRANSFORM_TEX(input.uv0, _BaseMap); #if defined(EDITOR_VISUALIZATION) UnityEditorVizData(input.positionOS.xyz, input.uv0, input.uv1, input.uv2, o.VizUV, o.LightCoord); #endif return o; } half4 MyMetaFrag(MetaVaryings i) : SV_Target { MySurface s = MySampleSurface(i.uv); MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness); UnityMetaInput meta = (UnityMetaInput)0; // 反弹光的颜色:漫反射色为主;粗糙金属的镜面反射在烘焙器看来也像“漫反射”, // 按粗糙度加一部分 F0 进去(与 URP Lit 的经验做法相同) meta.Albedo = m.diffuseColor + m.f0 * (m.perceptualRoughness * m.perceptualRoughness) * 0.5; meta.Emission = s.emission; #if defined(EDITOR_VISUALIZATION) meta.VizUV = i.VizUV; meta.LightCoord = i.LightCoord; #endif return UnityMetaFragment(meta); }
#ifdef VERTEX // UnityMetaVertexPosition:用光照贴图 UV 当“屏幕位置”,把网格摊平到贴图空间 void MyMetaVert() { vec2 p = unity_MetaVertexControl.y > 0.5 ? texcoord2.xy * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw : texcoord1.xy * unity_LightmapST.xy + unity_LightmapST.zw; gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); uv = TransformTex(texcoord0.xy, _BaseMap_ST); } #endif #ifdef FRAGMENT vec4 MyMetaFrag() { MySurface s = MySampleSurface(uv); MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness); // 反弹光的颜色:漫反射色为主;粗糙金属的镜面反射在烘焙器看来也像“漫反射”, // 按粗糙度加一部分 F0 进去(与 URP Lit 的经验做法相同) vec3 albedo = m.diffuseColor + m.f0 * (m.perceptualRoughness * m.perceptualRoughness) * 0.5; vec3 emission = s.emission; // UnityMetaFragment:按烘焙器的请求输出反照率或自发光 if (unity_MetaFragmentControl.x > 0.5) return vec4(saturate(albedo), 1.0); if (unity_MetaFragmentControl.y > 0.5) return vec4(emission, 1.0); return vec4(0.0); } #endif
Meta Pass 只在烘焙光照贴图(或在编辑器中显示某些可视化模式)时被调用。烘焙器把物体按 lightmap UV 摊开渲染,每个纹素读两样东西:反照率(决定光线在这里反弹时带走什么颜色)和自发光(作为面光源参与烘焙)。UnityMetaVertexPosition 根据 unity_MetaVertexControl 决定用第二套还是第三套 UV 作为“屏幕位置”,UnityMetaFragment 根据 unity_MetaFragmentControl 决定输出反照率还是自发光,并处理 Lighting 窗口里的 Albedo Boost 等设置。反照率我们用漫反射色加上一部分 F0:完全光滑的金属在烘焙器看来几乎不反弹漫射光,但粗糙金属的镜面波瓣很宽,表现得像漫反射体,按 混入是 URP Lit 也采用的经验做法。没有 Meta Pass 的物体在烘焙时被当作黑色,既不反弹光,自发光也不起作用。
UniversalGBuffer Pass 的材质写 G-Buffer,其余不透明物体只有 LightMode 为 UniversalForwardOnly(或 SRPDefaultUnlit)的 Pass 会在延迟光照之后以前向方式绘制;UniversalForward 在 Deferred 下不会被绘制。MyPBR 的光照模型无法塞进 URP 固定格式的 G-Buffer(G-Buffer 只存材质参数,光照由 URP 的延迟光照 Shader 统一计算,用的是 URP 自己的 BRDF),所以如果项目用 Deferred,请把 ForwardLit Pass 的 LightMode 改成 UniversalForwardOnly——URP 的 Complex Lit 正是这样做的。案例 11.10调试视图:把光照拆开看入门单独打开 ↗
目标:写一个与 MyPBR 共用全部 HLSL 的调试 Shader,单独输出 BRDF 的每一项、光照的每一部分。
half4 DebugFrag(Varyings i) : SV_Target { MySurface s = MySampleSurface(i.uv); InputData id = MyInitInputData(i, s.normalTS); MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness); int view = (int)_View; // 1~3:只看主光方向上的单项,便于和第 9 章的公式图对照 if (view >= 1 && view <= 3) { Light L = GetMainLight(); float3 N = id.normalWS, V = id.viewDirectionWS, H = SafeNormalize(L.direction + V); float NoV = abs(dot(N, V)) + 1e-5, NoL = saturate(dot(N, L.direction)); float NoH = saturate(dot(N, H)), LoH = saturate(dot(L.direction, H)); if (view == 1) // 归一化的 D:除以峰值 1/(π α²),范围 0~1 return MyD_GGX(NoH, m.a2) * PI * m.a2 * (NoL > 0); if (view == 2) // G2 = V * 4 NoL NoV,范围 0~1 return MyV_SmithGGXCorrelated(NoV, NoL, m.a2) * 4.0 * NoL * NoV; return half4(MyF_Schlick(m.f0, 1.0, LoH) * (NoL > 0), 1); } MyLightingTerms t = MyComputeLighting(s, m, id); float3 c; switch (view) { case 4: c = t.directSpecular; break; case 5: c = t.directDiffuse; break; case 6: c = t.indirectDiffuse; break; case 7: c = t.indirectSpecular; break; case 8: c = t.directDiffuse + t.directSpecular; break; case 9: c = t.indirectDiffuse + t.indirectSpecular; break; case 10: c = MySamplePrefilteredEnv(reflect(-id.viewDirectionWS, id.normalWS), m.perceptualRoughness, id.positionWS, id.normalizedScreenSpaceUV); break; case 11: { float NoV = saturate(dot(id.normalWS, id.viewDirectionWS)); float2 dfg = MyEnvDFG(NoV, m.perceptualRoughness); c = m.f0 * dfg.x + dfg.y; break; } default: c = t.directDiffuse + t.directSpecular + t.indirectDiffuse + t.indirectSpecular + t.emission; break; } return half4(c, 1); }
vec4 DebugFrag() { MySurface s = MySampleSurface(uv); InputData id = MyInitInputData(s.normalTS); MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness); int view = int(_View); // 1~3:只看主光方向上的单项,便于和第 9 章的公式图对照 if (view >= 1 && view <= 3) { Light L = GetMainLight(); vec3 N = id.normalWS, V = id.viewDirectionWS, H = SafeNormalize(L.direction + V); float NoV = abs(dot(N, V)) + 1e-5, NoL = saturate(dot(N, L.direction)); float NoH = saturate(dot(N, H)), LoH = saturate(dot(L.direction, H)); float lit = NoL > 0.0 ? 1.0 : 0.0; if (view == 1) // 归一化的 D:除以峰值 1/(π α²),范围 0~1 return vec4(MyD_GGX(NoH, m.a2) * PI * m.a2 * lit); if (view == 2) // G2 = V * 4 NoL NoV,范围 0~1 return vec4(MyV_SmithGGXCorrelated(NoV, NoL, m.a2) * 4.0 * NoL * NoV); return vec4(MyF_Schlick(m.f0, 1.0, LoH) * lit, 1.0); } MyLightingTerms t = MyComputeLighting(s, m, id); vec3 c; switch (view) { case 4: c = t.directSpecular; break; case 5: c = t.directDiffuse; break; case 6: c = t.indirectDiffuse; break; case 7: c = t.indirectSpecular; break; case 8: c = t.directDiffuse + t.directSpecular; break; case 9: c = t.indirectDiffuse + t.indirectSpecular; break; case 10: c = MySamplePrefilteredEnv(reflect(-id.viewDirectionWS, id.normalWS), m.perceptualRoughness, id.positionWS, id.normalizedScreenSpaceUV); break; case 11: { float NoV = saturate(dot(id.normalWS, id.viewDirectionWS)); vec2 dfg = MyEnvDFG(NoV, m.perceptualRoughness); c = m.f0 * dfg.x + dfg.y; break; } default: c = t.directDiffuse + t.directSpecular + t.indirectDiffuse + t.indirectSpecular + t.emission; break; } return vec4(c, 1.0); }
调试视图用一个 _View 浮点属性加运行时分支切换,不产生变体。前三种模式只看主光方向上的单项(图 11-3 就是用它拍的);之后的模式调用与正式 Shader 完全相同的 MyComputeLighting,只是选择输出哪一部分。这样的调试 Shader 在开发 BRDF 时非常有用:任何一项出错(例如忘了乘 π、NoL 符号反了、环境图 mip 选错),都能在对应视图里一眼看出来。_View 被故意放在 CBUFFER 之外——调试 Shader 不需要 SRP Batcher 兼容,放进去反而要同步修改 MyPBR_Input.hlsl。

第三格值得多看一眼:砖墙地面是电介质,F0 只有 0.04,但远处掠射角的地面反射出了明显的天空颜色——这是菲涅尔效应,DFG 表里 B 项的贡献。很多“手写 PBR”漏掉环境镜面或者只乘 F0 而不乘 DFG,场景远处的地面就会显得“发干”。
11.5 与 URP Lit 并排对比
写完一个 PBR Shader,最直接的检验方法是和一个公认可靠的实现放在同一盏灯、同一个环境下比较。下面两张图使用完全相同的场景:方向光、程序化天空提供的 SH 与反射,6 列光滑度(0 → 1)× 3 行金属度(0、0.5、1),基础色相同。


整体亮度、高光大小、菲涅尔边缘几乎一致,说明 π 的约定、D 项和 mip 选择都对上了。可见的差异集中在左下角——粗糙的金属:MyPBR 更亮、更饱和。原因有两个,都是有意为之:
| 环节 | URP Lit(BRDF.hlsl / GlobalIllumination.hlsl) | MyPBR |
|---|---|---|
| D | GGX,常数项合并进归一化 | GGX,完整形式 |
| V × F(直接光) | V 与 F 的角度变化合并成一个近似 ,结果再乘 F0 | 高度相关 Smith V × Schlick F(随 LoH 变化) |
| 漫反射 | Lambert, | 相同 |
| 环境 BRDF | (LUT 或 Karis 近似) | |
| 多次散射 | 无 | |
| 镜面遮蔽 | AO 直接乘 | 可选 Lagarde 公式 |
URP Lit 的环境 BRDF 是一个非常便宜的经验公式(第 10 章逐项分析过),在粗糙端给出偏低的反射;再加上没有多次散射补偿,粗糙金属会明显偏暗。MyPBR 在这里更接近“物理上该有的亮度”。这不意味着 URP Lit 是错的——它在移动端的预算下做了合理取舍;而且如果你的美术已经在 URP Lit 下调好了一批材质,换成 MyPBR 后粗糙金属会变亮,需要重新评估。这也是为什么 MyPBR 把能量补偿做成 0~1 的滑条:设为 0 并改用解析近似,就能和 URP Lit 更接近。

11.6 性能、移动端与常见坑
开销在哪里
与 URP Lit 相比,MyPBR 每盏灯多出的开销主要是可见性项里的两次 sqrt 和 Schlick 的几次乘法;环境部分多出一次 LUT 采样(解析近似则是十几条 ALU)和能量补偿的一次除法。对于桌面和主机,这点差异通常不是瓶颈;对于低端移动设备、尤其是灯多的场景,可以考虑:
- 关闭
_DFG_LUT用解析近似,省一次采样和一个采样器; - 把
_EnergyCompensation设为 0 后,用#if而不是滑条控制,让编译器完全删掉补偿代码; - 把 V 项换成 Filament 提出的
V_SmithGGXCorrelatedFast(去掉 sqrt 的线性近似)或 URP 的 V×F 合并近似; - BRDF 用
half计算时注意 D 项的分母: 很小时(NoH * a2 - NoH) * NoH + 1会因为半精度舍入而变成 0,这就是 URP 在分母里写1.00001f并对 half 平台把结果 clamp 到 1000 的原因。MyPBR 全程用 float,桌面上没有问题;移植到移动端如果改成 half,需要加上类似保护。
变体数量
ForwardLit Pass 的 multi_compile 组合在理论上是几十万个变体,实际构建时 URP 会根据 URP Asset 的设置剥离用不到的(例如项目没开 Light Cookie,所有带 _LIGHT_COOKIES 的变体都会被剥掉),材质关键字用 shader_feature_local 也只编译被材质用到的组合。这和 URP Lit 是同一套机制。c29 会给出统计与剥离变体的工具。
与 Built-in 管线的差异
在 Built-in 管线里,多光源用 ForwardBase + ForwardAdd 多个 Pass 叠加,每盏附加灯都要把物体重画一遍;URP 在一个 Pass 里循环所有灯。Built-in 的环境光也走 SH 和反射探针(UNITY_SAMPLE_TEXCUBE_LOD、ShadeSH9),但没有 Forward+ 的探针图集。Built-in 的 Standard Shader 也采用“Lambert 白板亮度等于灯光颜色”的约定(其漫反射同样不除以 π),所以从 Built-in 迁移到 URP 不需要重新打光;HDRP 则使用勒克斯、流明等物理光照单位,约定不同,不能照搬本章的 π 处理。
在项目中使用 MyPBR
- 把
Assets/Cookbook/Ch11/下的MyPBR*.hlsl、MyPBR.shader、DFGLut.cs和Editor/DFGLutBaker.cs拷进项目(include 路径是Assets/Cookbook/Ch11/…,换目录时要同步修改)。 - 执行 ,得到
MyPBR_DFG_LUT.asset。如果不想多一张贴图,跳过这一步,材质上不勾 Use DFG LUT 即使用解析近似。 - 新建材质选择 ,或者在已有的 URP Lit 材质上直接切换 Shader:同名属性(基础色、法线、光滑度、自发光)会保留,Lit 的 Metallic Gloss Map 与 Occlusion Map 需要合并成一张 Mask(R 金属、G AO、A 光滑)后放进 Mask 槽。
- 按需勾选 Use Mask Map、Use Normal Map、Use Emission、Use DFG LUT。勾选框背后是
shader_feature_local关键字,没有勾选的功能不会进入编译出的变体。 - 选中 Shader 资产确认 Inspector 显示 SRP Batcher compatible;打开 Frame Debugger 确认这些物体被合进 SRP Batch。
- 如果项目用 Deferred 渲染路径,按案例 11.9 末尾“关于 Deferred”的说明把 ForwardLit 的 LightMode 改为
UniversalForwardOnly。
换 Shader 后建议在同一个场景里保留几颗 URP Lit 的参照球(中性灰电介质、白色金属、粗糙金属各一颗),与 MyPBR 材质对照检查。差异应当只出现在 11.5 节表格所列的环节;如果整体亮度不同,先检查 π 的处理,其次检查是否误开了后处理曝光。
常见坑
✗ 避免
- 漫反射除以 π,但灯光没有乘 π:整体暗了约 3 倍。
- 菲涅尔用 NoV 计算直接光:高光边缘颜色和强度都不对。
- 环境镜面只乘 F0:远处地面没有掠射反射,场景“发干”。
- Forward+ 下只写
LIGHT_LOOP,漏了额外方向光的循环。 - 缺 DepthNormals Pass:开 SSAO 后物体周围出现奇怪的遮蔽或没有遮蔽。
- 材质 AO 同时乘到直接光上:阴影里叠阴影,暗部发脏。
✓ 推荐
- 先写 Step1 那样的最小 Shader,用中性灰球和纯色背景验证 BRDF。
- 做一个调试 Shader,单独显示 D、G、F、直接光、间接光。
- 写完和 URP Lit 放在同一场景里比较,差异要能解释。
- 做白炉测试检查能量守恒。
- 关键字照抄 Lit.shader,再删去不支持的部分,并在注释里写明删了什么。
本章小结
- 手写 PBR 的边界:BRDF(D、V、F、Lambert、DFG、能量补偿)自己写;灯光、阴影、分簇、SH、反射探针等“管线数据”用 URP 提供的取数函数。
- Unity 灯光颜色对应 ;SH 与光照贴图存的是 。保持 BRDF 的物理形式,在乘灯光时乘 π,环境漫反射直接乘漫反射色。
- 高度相关 Smith 可见性项把 与 合并,数值稳定;菲涅尔用 v·h。
- Forward 的附加光受每物体上限约束;Forward+ 用分簇迭代器,并需单独循环额外的方向光。
- split-sum 把镜面 IBL 拆成“按粗糙度选 mip 的预滤波环境图”和“DFG 环境 BRDF”;后者可用解析近似或程序生成的 LUT,两者基于不同的几何项模型,差异集中在粗糙掠射区。
- 多次散射补偿系数 来自 DFG 表本身,几乎零成本,白炉测试可验证。
- 一个“能用”的 PBR Shader 还需要 ShadowCaster、DepthOnly、DepthNormals、Meta Pass,统一的 CBUFFER,以及对 Rendering Debugger 的支持。
练习
- 把
MyV_SmithGGXCorrelated换成可分离的 Schlick-GGX(),同时修改DFGLut.cs,重新生成 LUT,观察它与 Karis 解析近似的差异是否变小。 - 在
MyInitMaterial中实现 Specular 工作流:用_SpecColor直接给出 F0,漫反射乘 ,用一个shader_feature_local关键字切换。 - 实现 Specular Anti-Aliasing:用
ddx/ddy估计法线在屏幕上的变化量,按 Kaplanyan 等人的方法增大 ,对比高光闪烁是否减轻。 - 把 MyPBR 改造成支持 Deferred:把 ForwardLit 的 LightMode 改为
UniversalForwardOnly,在 Deferred 渲染路径下验证它仍能接收附加光与阴影。 - 为调试 Shader 增加一个“能量补偿系数”视图(输出
energyCompensation - 1),观察它在粗糙度和 NoV 上的分布。