PBR 理论
从“光到底是什么量”讲起,推导渲染方程与 BRDF,再把 Cook-Torrance 微表面模型拆成 D、G、F 三项,逐项写成 Shader、逐项拍出来看。这一章是后面读 URP Lit 源码、手写 PBR 的全部理论基础。
9.1 “基于物理”到底基于什么
第 5 章的 Blinn-Phong 能做出“看起来有高光”的效果,但它有几个绕不过去的毛病。第一,参数没有物理含义:高光指数 64 和 128 差多少、高光颜色该填多少,只能凭感觉调;第二,换一套灯光环境,材质往往要重调一遍,因为模型本身不保证能量守恒——把高光调宽,它反射的总能量就凭空变多;第三,它没有菲涅尔效应:现实中几乎所有物体在掠射角下都会变得更“反光”,湖面远处像镜子、近处能看见水底,Blinn-Phong 表现不出来;第四,它的高光形状和粗糙度之间没有可靠的对应关系。
基于物理的渲染(Physically Based Rendering,PBR)不是“照片级真实”的同义词,它解决的是一致性问题:同一个材质参数,在正午阳光下、阴天、室内灯光下都应该“对”,美术不需要为每个场景重调材质。为此,PBR 在几个关键点上向物理靠拢:
- 用辐射度量学描述光:Shader 计算的是辐射率(Radiance),灯光、环境、材质的单位彼此咬合,而不是随手乘一些系数。
- 能量守恒:表面反射出去的光不能多于射进来的光;高光变宽时必须同时变暗。
- 微表面理论:用“粗糙度”这个统计量描述表面在像素以下尺度的凹凸,高光的形状、亮度、在掠射角的变化都由它推出来。
- 菲涅尔效应:反射率随角度变化,并且金属与非金属有本质区别。
- 线性空间 + HDR:所有光照在线性数值上做加法和乘法,允许远大于 1 的亮度,最后统一做色调映射和 Gamma 编码。
工业界今天使用的 PBR 模型,大体上沿用了 Disney 在 2012 年提出的“Principled BRDF”参数化思路,以及 Epic 在 2013 年为虚幻 4 做的实时化简化。Unity 的 Standard 着色器、URP 的 Lit、HDRP 的 Lit 都属于这个家族,只是在近似程度上取舍不同。本章先把理论讲完整,下一章再去看 URP 在哪些地方做了简化、简化了多少。
normalWS、lightDirectionWS、viewDirectionWS、halfDir。点积写作 ,代码里习惯写成 NoL,并且总会 saturate 到 0~1。9.2 辐射度量学:先把“光”量化
立体角
平面上用弧度衡量“张开多大的角”:弧长除以半径。三维空间里对应的概念是立体角(Solid Angle):从球心看出去,一块球面面积 所张的立体角是 ,单位是球面度(steradian,sr)。整个球面是 sr,一个半球是 sr。在球坐标下,天顶角 、方位角 各变化一点点,围出的小块立体角是:
那个 很重要:越靠近天顶(),同样的 围出的面积越小。后面所有“对半球积分”的推导都要带着它。
从能量到辐射率
描述光的物理量有一串,名字很像,但各自回答不同的问题。下表按“越来越细”的顺序排列:
| 物理量 | 符号 | 单位 | 回答的问题 |
|---|---|---|---|
| 辐射能(Radiant Energy) | Q | J | 一共有多少能量 |
| 辐射通量(Radiant Flux) | Φ | W | 每秒流过多少能量;灯泡的“功率” |
| 辐照度(Irradiance) | E | W/m² | 单位面积上每秒接收多少能量,不管从哪个方向来 |
| 辐射强度(Radiant Intensity) | I | W/sr | 点光源朝某个方向每单位立体角发出多少 |
| 辐射率(Radiance) | L | W/(m²·sr) | 沿某一条光线传播的“亮度”:单位投影面积、单位立体角上的通量 |
渲染里最重要的是最后一个。辐射率的定义是:
分母里的 是面元在光线方向上的投影面积:同一束光斜着照到表面上,铺开的面积变大,单位面积分到的能量变少;反过来,从斜角看一块发光面,它在视线方向上“看起来”变窄了。辐射率把这个几何因素除掉,只剩下“这条光线本身有多亮”。
为什么渲染偏爱辐射率?两个原因。其一,相机传感器(或人眼)的一个像素,测到的正是从某个方向射来的辐射率的平均;Shader 在片元阶段输出的颜色,物理上对应的就是“沿这条视线射向相机的辐射率”。其二,在真空(或不考虑雾、介质)时,辐射率沿光线传播保持不变——这就是为什么一面墙离你近或远,单位面积看起来一样亮,只是占的像素多少不同。正因为如此,片元着色器不需要关心相机离物体多远。
辐照度与余弦定律
表面接收到的辐照度,是把各个方向射来的辐射率按投影面积加起来:
这里的 就是 ,第 5 章 Lambert 光照里的那个点积,物理来源就在这里:光斜着打下来,能量被摊到更大的面积上。顺手算一个后面要用的积分——如果各方向射来的辐射率都等于 1,那么:
半球的立体角是 ,但余弦加权后的“有效立体角”只有 。记住这个 ,它马上会出现在 Lambert BRDF 里。
方向光和点光源在数学上是极限情况:所有光都来自一个方向,辐射率是一个“冲激”(Dirac δ)。这时积分退化成一项:, 是垂直照射时的辐照度。实时渲染里绝大多数灯都按这种“精确光源”(Punctual Light)处理。
9.3 渲染方程
1986 年 Kajiya 提出的渲染方程,把“一个点朝某个方向射出多少光”写成了一个积分。忽略随时间、波长的变化,它是:
逐项看:
- :点 朝观察方向 射出的辐射率——就是我们要算的像素颜色。
- :自发光。URP 里对应材质的 Emission,直接加上去。
- :对表面上方半球的所有入射方向 求和。
- :从 方向射来的辐射率。它可能来自灯,也可能来自别的物体反射的光——这让方程变成递归的:别处的 就是这里的 。
- :上一节的余弦项,把入射辐射率换算成辐照度。
- :双向反射分布函数(BRDF),描述材质“把从 来的光,有多少比例送往 ”。材质的全部个性都在它里面。
实时渲染怎么“解”这个积分
离线渲染器用蒙特卡洛光线追踪近似求解整个递归积分。实时渲染则把 粗暴地分成两类,各用一种便宜的办法:
直接光:来自场景里的方向光、点光、聚光灯。它们是精确光源,积分退化为对灯的求和:
其中 是第 盏灯在垂直照射时贡献的辐照度(已乘过距离衰减和阴影)。URP 里这件事由 LightingPhysicallyBased 对主光和每盏附加光各做一次。
间接光:来自天空、环境、其他物体的反射。它真的是一个积分,没法逐方向求和。实时渲染的办法是预计算:漫反射部分事先把环境光积分成球谐系数(SH)或光照贴图;镜面部分事先把环境贴图按粗糙度预卷积成一串 mip(反射探针),运行时查一次表。这一类统称基于图像的光照(Image Based Lighting,IBL)。URP 里对应 GlobalIllumination 函数,第 10 章会逐行读,第 11 章会自己实现。
所以,无论 Shader 写得多复杂,PBR 片元着色器做的事都可以概括为一行:把 BRDF 分别和“若干盏精确光源”“一份预积分的环境光”结合起来,再加上自发光。BRDF 写得对不对,决定了材质在任何光照下是否一致。
9.4 BRDF:材质的数学身份证
BRDF(Bidirectional Reflectance Distribution Function)的严格定义是:从 方向来的入射辐照度增量,在 方向上引起的出射辐射率增量之比:
分子是辐射率,分母是辐照度,所以 BRDF 的单位是 。这意味着它的值可以大于 1——一面镜子在镜面方向上的 BRDF 是无穷大(一个 δ 函数)。不要把 BRDF 理解成“反射率”,反射率是 BRDF 在半球上积分之后的结果。
一个物理上合理的 BRDF 满足三条性质:
- 非负:。
- 互易性(Helmholtz Reciprocity):交换 和 ,值不变,。很多游戏里的近似公式并不严格互易,但这不影响视觉,可以接受。
- 能量守恒:对任意观察方向,反射出去的总比例不超过 1:
叫方向反照率(Directional Albedo)。它等于 1 意味着一点能量都没吸收,小于 1 表示有能量被吸收或丢失。本章 9.10 节的“白炉测试”就是把它直接画出来。
Lambert 的 1/π
最简单的 BRDF 是理想漫反射:往各个方向反射的辐射率都一样, 是常数。设表面的反照率(吸收之后剩下多少比例被反射出去)是 ,由能量守恒等式:
这就是 Lambert BRDF 里 的来历:它不是“经验常数”,而是余弦加权半球积分 的倒数。如果漏了它,一个反照率为 0.8 的白墙在均匀环境里会反射出 倍的光,严重违反能量守恒。
游戏引擎里的 π 去哪了
读 URP 源码时你会发现,漫反射项是 albedo 而不是 albedo / PI,GGX 的公式里也找不到 。这不是写错了,而是 Unity(以及大多数引擎)对灯光强度有一个约定:一个反照率为 1 的 Lambert 表面,被颜色为 的灯垂直照射时,输出的辐射率就是 。按 Lambert 公式,,要让它等于 ,就得有 。于是整个直接光照可以写成:
漫反射的 与灯光的 抵消,镜面项则整体乘了 ——GGX 分母里的 正好被消掉。理解了这一点,第 10 章看到 DirectBRDFSpecular 里“缺了一个 π”就不会困惑。本章的可视化 Shader 为了和教科书公式对照,保留 的写法,最后再乘 lightColor,结果与 URP 的约定一致。
9.5 微表面理论
真实表面在显微尺度上并不光滑。微表面理论(Microfacet Theory)假设:表面由无数比像素小得多、但比光波长大得多的微小平面(微面元,microfacet)组成,每个微面元都是一面理想镜子。宏观上看到的高光,是这些小镜子统计意义上的总和:
- 一面小镜子要把 方向的光反射到 方向,它的法线 必须恰好等于两者的半程向量 。
- 有多少小镜子的法线朝向 ?由法线分布函数 描述。
- 朝向对了的小镜子,有些被相邻的凸起挡住了光,或者反射出去的光被挡住了,由几何遮蔽函数 描述。
- 每面小镜子反射多少比例,由菲涅尔项 描述。
把这些因素写在一起,就是 Cook-Torrance 镜面 BRDF:
分母里的 4 和两个余弦常让人困惑。直观理解: 是“每单位立体角、每单位宏观面积上,法线朝 的微面元面积”,它定义在“半程向量的方向空间”里;而 BRDF 定义在“光线方向的空间”里。从 换到 ,立体角会被反射放大,雅可比行列式是
再加上 BRDF 定义里本身除掉的入射余弦 ,以及把“微面元投影到观察方向的面积”换算到“宏观面积”时出现的 , 与 相等而约掉,最后就剩下 。完整推导可以参考 Walter 等人 2007 年关于粗糙表面微面元模型的论文,这里只要记住:这个分母不是可选的归一化系数,少了它高光的能量就是错的。
这个分母在掠射角会趋于 0,单独计算容易溢出。因此实践中常把 和分母合并成一个可见性项(Visibility):
core 包 BSDF.hlsl 里的 V_SmithJointGGX、URP 注释里说的 “D * V * F”,指的都是这个 。
9.6 法线分布函数 D
描述微面元法线的统计分布:粗糙度低时,几乎所有法线都贴近 , 在 处是一个又高又窄的尖峰;粗糙度高时,法线四散, 又矮又宽。无论形状如何,它都必须满足一个归一化条件——所有微面元投影到宏观平面上的面积之和,等于宏观面积:
这个条件就是“高光变宽必须变暗”的数学根源:分布越宽,峰值必须越低。
三种常见的 D
Blinn-Phong NDF。把第 5 章的 按上面的条件归一化,得到:
它和 Beckmann 分布形状非常接近,常用换算是 。
Beckmann。源自光学领域对粗糙表面的高斯斜率假设:
GGX / Trowbridge-Reitz。Walter 等人在 2007 年把它引入图形学(Trowbridge 与 Reitz 在 1975 年就推导过同一分布),如今是实时 PBR 的事实标准:
其中 是粗糙度参数(9.11 节解释它和材质面板上的 Smoothness 的关系)。在 处,三者峰值都约等于 : 时约为 32, 时超过 3000。这也提醒我们,高光的数值天然是 HDR 的。
GGX 的长尾是它胜出的主要原因。真实材料(尤其是金属、塑料)的高光,在亮核周围通常有一圈柔和的光晕,测量数据也显示高光衰减比高斯分布慢。Beckmann 做不出这圈光晕,看起来像“贴上去的光斑”;GGX 自然就有。代价是长尾让高光的影响范围更大,对预卷积环境贴图的采样数要求也更高。
案例 9.1BRDF 单项可视化 Shader进阶单独打开 ↗
目标:写一个可以单独输出 D、G、F、镜面项、漫反射项或完整直接光照的 Shader,用一排粗糙度递增的球把每一项“拍”出来。它是本章后面所有案例的工具。
思路:BRDF 的各项写成独立的 HLSL 函数,全部保留教科书形式(带 、不合并常数),放在 BRDFLib.hlsl;Shader 本身只取主光方向,算出 、 等点积,再按 _Mode 选择输出哪一项。为了避免和 core 包 BSDF.hlsl 里已有的 D_GGX、F_Schlick 重名(Lighting.hlsl 会间接 include 它),函数名统一加 C9_ 前缀。
先看 D 的三种实现:
// ---------- 法线分布函数 D(h) ---------- // GGX / Trowbridge-Reitz:D = α² / (π · ((n·h)²(α²-1) + 1)²) float C9_D_GGX(float NoH, float a) { float a2 = a * a; float d = NoH * NoH * (a2 - 1.0) + 1.0; return a2 / (PI * d * d); } // Beckmann:D = exp(-tan²θh / α²) / (π α² cos⁴θh) float C9_D_Beckmann(float NoH, float a) { float a2 = a * a; float c2 = max(NoH * NoH, 1e-6); float t2 = (1.0 - c2) / c2; // tan²θh return exp(-t2 / a2) / (PI * a2 * c2 * c2); } // 归一化的 Blinn-Phong NDF:D = (n+2)/(2π) · cosⁿθh,指数 n 由 α 换算:n = 2/α² - 2 float C9_D_BlinnPhong(float NoH, float a) { float n = 2.0 / max(a * a, 1e-4) - 2.0; return (n + 2.0) / (2.0 * PI) * pow(max(NoH, 0.0), n); }
// ---------- 法线分布函数 D(h) ---------- // GGX / Trowbridge-Reitz:D = α² / (π · ((n·h)²(α²-1) + 1)²) float C9_D_GGX(float NoH, float a) { float a2 = a * a; float d = NoH * NoH * (a2 - 1.0) + 1.0; return a2 / (PI * d * d); } // Beckmann:D = exp(-tan²θh / α²) / (π α² cos⁴θh) float C9_D_Beckmann(float NoH, float a) { float a2 = a * a; float c2 = max(NoH * NoH, 1e-6); float t2 = (1.0 - c2) / c2; // tan²θh return exp(-t2 / a2) / (PI * a2 * c2 * c2); } // 归一化的 Blinn-Phong NDF:D = (n+2)/(2π) · cosⁿθh,指数 n 由 α 换算:n = 2/α² - 2 float C9_D_BlinnPhong(float NoH, float a) { float n = 2.0 / max(a * a, 1e-4) - 2.0; return (n + 2.0) / (2.0 * PI) * pow(max(NoH, 0.0), n); }
然后是 Shader 主体。_F0 特意声明成 Vector 而不是 Color:在线性色彩空间下,Unity 会把材质的 Color 属性当成 sRGB 值,传进 Shader 前转换成线性值,而 F0 的参考数据(0.04、0.02 等)本身就是线性的,用 Vector 可以原样传入。_Albedo 是美术习惯在拾色器里挑的颜色,保留 Color 类型。
// 第 9 章:BRDF 单项可视化。 // 用一个 _Mode 参数切换输出 D、G、F、镜面项、漫反射项或完整的直接光照, // 配合一排粗糙度递增的小球,就能“看见”每一项随粗糙度怎么变。 Shader "Cookbook/Ch09/BRDFTerms" { Properties { [Enum(D,0,G,1,F_NoV,2,F_VoH,3,Specular,4,Diffuse,5,Full,6)] _Mode("Output", Float) = 6 [Enum(GGX,0,Beckmann,1,BlinnPhong,2)] _NDF("NDF", Float) = 0 [Enum(SmithSeparable,0,SmithHeightCorrelated,1,SchlickGGX,2,DiffX10,3)] _GModel("Geometry", Float) = 1 [Enum(Lambert,0,Disney,1)] _DiffuseModel("Diffuse", Float) = 0 _Roughness("Perceptual Roughness", Range(0.02, 1)) = 0.5 _F0("F0 (linear)", Color) = (0.04, 0.04, 0.04, 1) _Albedo("Diffuse Albedo", Color) = (0.8, 0.8, 0.8, 1) [Enum(Raw,0,Exp,1)] _Display("Display", Float) = 0 _Exposure("Exposure", Float) = 1 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } HLSLPROGRAM #pragma target 3.0 #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" #include "Assets/Cookbook/Ch09/BRDFLib.hlsl" CBUFFER_START(UnityPerMaterial) float _Mode, _NDF, _GModel, _DiffuseModel; float _Roughness; float4 _F0, _Albedo; float _Display, _Exposure; CBUFFER_END 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; VertexPositionInputs p = GetVertexPositionInputs(v.positionOS.xyz); o.positionCS = p.positionCS; o.positionWS = p.positionWS; o.normalWS = TransformObjectToWorldNormal(v.normalOS); return o; } float EvalD(float NoH, float a) { if (_NDF < 0.5) return C9_D_GGX(NoH, a); if (_NDF < 1.5) return C9_D_Beckmann(NoH, a); return C9_D_BlinnPhong(NoH, a); } float EvalG(float NoL, float NoV, float a) { if (_GModel < 0.5) return C9_G_SmithSeparable(NoL, NoV, a); if (_GModel < 1.5) return C9_G_SmithHeightCorrelated(NoL, NoV, a); if (_GModel < 2.5) return C9_G_SchlickGGX(NoL, NoV, a); // 两种 Smith 形式之差放大 10 倍,看它们在哪儿不一样 return abs(C9_G_SmithHeightCorrelated(NoL, NoV, a) - C9_G_SmithSeparable(NoL, NoV, a)) * 10.0; } half4 frag(Varyings i) : SV_Target { float3 N = normalize(i.normalWS); float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS); Light light = GetMainLight(); float3 L = light.direction; float3 H = SafeNormalize(L + V); float NoL = saturate(dot(N, L)); float NoV = max(dot(N, V), 1e-4); // 防止边缘处除零 float NoH = saturate(dot(N, H)); float VoH = saturate(dot(V, H)); float LoH = VoH; // h 是 l、v 的角平分线,两者相等 float a = _Roughness * _Roughness; // α = perceptualRoughness² float D = EvalD(NoH, a); float G = EvalG(NoL, NoV, a); float3 F = C9_F_Schlick(_F0.rgb, VoH); // Cook-Torrance 镜面 BRDF:D·G·F / (4 (n·l)(n·v)) float3 fSpec = D * G * F / max(4.0 * NoL * NoV, 1e-4); float3 fDiff = (_DiffuseModel < 0.5) ? C9_Lambert(_Albedo.rgb) : C9_DisneyDiffuse(_Albedo.rgb, NoL, NoV, LoH, _Roughness); // Unity 的灯光“颜色×强度”约定:白色 Lambert 面正对强度 1 的灯时输出 1, // 相当于入射辐照度 E = π · lightColor,所以出射辐射率 = f · π · lightColor · (n·l)。 float3 E = PI * light.color * NoL; float3 c; if (_Mode < 0.5) c = D.xxx; else if (_Mode < 1.5) c = (NoL > 0.0 ? G : 0.0).xxx; else if (_Mode < 2.5) c = C9_F_Schlick(_F0.rgb, NoV); else if (_Mode < 3.5) c = F; else if (_Mode < 4.5) c = fSpec * E; else if (_Mode < 5.5) c = fDiff * E; else c = ((1.0 - F) * fDiff + fSpec) * E; // 漫反射只拿“没被镜面反射走”的那部分 // 显示映射:Raw 直接输出(超过 1 会被截断);Exp 用 1 - e^(-x·k) 把大范围数值压进 0..1 if (_Display > 0.5) c = 1.0 - exp(-c * _Exposure); else c *= _Exposure; return half4(c, 1); } ENDHLSL } } }
// 第 9 章:BRDF 单项可视化。(GLSL 版,对应 Ch09/BRDFTerms.shader) // 用一个 _Mode 参数切换输出 D、G、F、镜面项、漫反射项或完整的直接光照, // 配合一排粗糙度递增的小球,就能“看见”每一项随粗糙度怎么变。 Shader "Cookbook/Ch09/BRDFTerms" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } GLSLPROGRAM #include "Ch09/BRDFLib.glsl" uniform float _Mode, _NDF, _GModel, _DiffuseModel; uniform float _Roughness; uniform vec4 _F0, _Albedo; uniform float _Display, _Exposure; VARYING vec3 positionWS; VARYING vec3 normalWS; #ifdef VERTEX void main() { VertexPositionInputs p = GetVertexPositionInputs(positionOS.xyz); gl_Position = p.positionCS; positionWS = p.positionWS; normalWS = TransformObjectToWorldNormal(normalOS); } #endif #ifdef FRAGMENT float EvalD(float NoH, float a) { if (_NDF < 0.5) return C9_D_GGX(NoH, a); if (_NDF < 1.5) return C9_D_Beckmann(NoH, a); return C9_D_BlinnPhong(NoH, a); } float EvalG(float NoL, float NoV, float a) { if (_GModel < 0.5) return C9_G_SmithSeparable(NoL, NoV, a); if (_GModel < 1.5) return C9_G_SmithHeightCorrelated(NoL, NoV, a); if (_GModel < 2.5) return C9_G_SchlickGGX(NoL, NoV, a); // 两种 Smith 形式之差放大 10 倍,看它们在哪儿不一样 return abs(C9_G_SmithHeightCorrelated(NoL, NoV, a) - C9_G_SmithSeparable(NoL, NoV, a)) * 10.0; } void main() { vec3 N = normalize(normalWS); vec3 V = GetWorldSpaceNormalizeViewDir(positionWS); Light light = GetMainLight(); vec3 L = light.direction; vec3 H = SafeNormalize(L + V); float NoL = saturate(dot(N, L)); float NoV = max(dot(N, V), 1e-4); // 防止边缘处除零 float NoH = saturate(dot(N, H)); float VoH = saturate(dot(V, H)); float LoH = VoH; // h 是 l、v 的角平分线,两者相等 float a = _Roughness * _Roughness; // α = perceptualRoughness² float D = EvalD(NoH, a); float G = EvalG(NoL, NoV, a); vec3 F = C9_F_Schlick(_F0.rgb, VoH); // Cook-Torrance 镜面 BRDF:D·G·F / (4 (n·l)(n·v)) vec3 fSpec = D * G * F / max(4.0 * NoL * NoV, 1e-4); vec3 fDiff = (_DiffuseModel < 0.5) ? C9_Lambert(_Albedo.rgb) : C9_DisneyDiffuse(_Albedo.rgb, NoL, NoV, LoH, _Roughness); // Unity 的灯光“颜色×强度”约定:白色 Lambert 面正对强度 1 的灯时输出 1, // 相当于入射辐照度 E = π · lightColor,所以出射辐射率 = f · π · lightColor · (n·l)。 vec3 E = PI * light.color * NoL; vec3 c; if (_Mode < 0.5) c = vec3(D); else if (_Mode < 1.5) c = vec3(NoL > 0.0 ? G : 0.0); else if (_Mode < 2.5) c = C9_F_Schlick(_F0.rgb, NoV); else if (_Mode < 3.5) c = F; else if (_Mode < 4.5) c = fSpec * E; else if (_Mode < 5.5) c = fDiff * E; else c = ((1.0 - F) * fDiff + fSpec) * E; // 漫反射只拿“没被镜面反射走”的那部分 // 显示映射:Raw 直接输出(超过 1 会被截断);Exp 用 1 - e^(-x·k) 把大范围数值压进 0..1 if (_Display > 0.5) c = 1.0 - exp(-c * _Exposure); else c *= _Exposure; fragColor = vec4(c, 1.0); } #endif ENDGLSL } } }
几个关键处:
a = _Roughness * _Roughness:材质参数是感知粗糙度,进公式前先平方得到 。NoV = max(dot(N, V), 1e-4):球的轮廓处 ,Cook-Torrance 的分母会变成 0,必须钳一下。E = PI * light.color * NoL:这就是 9.4 节的约定 。镜面和漫反射都乘它,结果与 URP 在亮度上可比。- 显示方式
Exp用 把 0 到几千的数值单调地压进 0~1,这样 D 的尖峰不会被截断成一片白,便于比较宽度。

注意最右边的球几乎被均匀照亮:粗糙度 0.9 时 , 在整个半球上变化不大,“高光”已经和漫反射分不清了。这正是粗糙金属看起来“发灰发闷”的原因。
案例 9.2GGX、Beckmann 与 Blinn-Phong 的长尾入门单独打开 ↗
目标:同一个 ,对比三种 NDF 在球面上的样子。
用案例 9.1 的 Shader,把 _NDF 分别设为 0、1、2,感知粗糙度 0.45(),Exp 显示、曝光 0.6:

后两张几乎一模一样,这说明 Blinn-Phong 高光只要做了归一化、选对了指数,本质上就是 Beckmann 的廉价替身。很多老游戏里的“Normalized Blinn-Phong”能撑起相当不错的画面,原因就在这里。真正的视觉差别来自 GGX 的长尾。
9.7 几何遮蔽 G
微面元并不是孤立的:在粗糙表面上,一个朝向正确的小镜面可能被前方的凸起挡住入射光(阴影,shadowing),或者它反射的光在离开表面前被挡住(遮蔽,masking)。 给出“既能被照到、又能被看到”的微面元比例,取值 0~1。
Smith 模型
精确计算遮挡需要知道表面的具体形状,而我们只有 这个统计分布。Smith 在 1967 年给出的近似假设“微面元的高度与法线方向无关”,由此推出单方向的遮蔽函数只取决于方向与 NDF:
是方向 与法线的夹角。正对法线时 ,,;贴近地平线时 ,。 的具体形式依赖于 NDF,上面是 GGX 的版本。
入射和出射两个方向要组合成 ,有两种常见形式:
分离形式假设阴影和遮蔽互不相关。但现实中,高处的微面元既不容易被挡光,也不容易被挡视线——两者是正相关的。Heitz 在 2014 年的论文中论证了高度相关形式(Height-Correlated)更准确,它总是略大于分离形式,差别在两个方向都接近掠射时最明显。下面是代码实现:
// ---------- 几何遮蔽 G ---------- // GGX 的 Smith Λ 函数:Λ(θ) = (-1 + sqrt(1 + α² tan²θ)) / 2 float C9_Lambda_GGX(float NoX, float a) { float c2 = max(NoX * NoX, 1e-6); float t2 = (1.0 - c2) / c2; return 0.5 * (-1.0 + sqrt(1.0 + a * a * t2)); } // 单方向遮蔽 G1 = 1 / (1 + Λ) float C9_G1_GGX(float NoX, float a) { return 1.0 / (1.0 + C9_Lambda_GGX(NoX, a)); } // Smith 分离形式:G = G1(l) · G1(v),假设“遮光”和“遮挡视线”互不相关 float C9_G_SmithSeparable(float NoL, float NoV, float a) { return C9_G1_GGX(NoL, a) * C9_G1_GGX(NoV, a); } // Smith 高度相关形式(Height-Correlated):G = 1 / (1 + Λ(l) + Λ(v)) float C9_G_SmithHeightCorrelated(float NoL, float NoV, float a) { return 1.0 / (1.0 + C9_Lambda_GGX(NoL, a) + C9_Lambda_GGX(NoV, a)); } // Schlick-GGX 近似:G1 ≈ n·x / (n·x (1-k) + k),k = α/2 float C9_G_SchlickGGX(float NoL, float NoV, float a) { float k = a * 0.5; float gl = NoL / (NoL * (1.0 - k) + k); float gv = NoV / (NoV * (1.0 - k) + k); return gl * gv; } // 可见性项 V = G / (4 (n·l)(n·v)),用高度相关 Smith 化简后的形式(Heitz 2014) // V = 0.5 / ( n·l · sqrt((n·v)²(1-α²) + α²) + n·v · sqrt((n·l)²(1-α²) + α²) ) float C9_V_SmithHeightCorrelated(float NoL, float NoV, float a) { float a2 = a * a; float lv = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2); float ll = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2); return 0.5 / max(lv + ll, 1e-5); }
// ---------- 几何遮蔽 G ---------- // GGX 的 Smith Λ 函数:Λ(θ) = (-1 + sqrt(1 + α² tan²θ)) / 2 float C9_Lambda_GGX(float NoX, float a) { float c2 = max(NoX * NoX, 1e-6); float t2 = (1.0 - c2) / c2; return 0.5 * (-1.0 + sqrt(1.0 + a * a * t2)); } // 单方向遮蔽 G1 = 1 / (1 + Λ) float C9_G1_GGX(float NoX, float a) { return 1.0 / (1.0 + C9_Lambda_GGX(NoX, a)); } // Smith 分离形式:G = G1(l) · G1(v),假设“遮光”和“遮挡视线”互不相关 float C9_G_SmithSeparable(float NoL, float NoV, float a) { return C9_G1_GGX(NoL, a) * C9_G1_GGX(NoV, a); } // Smith 高度相关形式(Height-Correlated):G = 1 / (1 + Λ(l) + Λ(v)) float C9_G_SmithHeightCorrelated(float NoL, float NoV, float a) { return 1.0 / (1.0 + C9_Lambda_GGX(NoL, a) + C9_Lambda_GGX(NoV, a)); } // Schlick-GGX 近似:G1 ≈ n·x / (n·x (1-k) + k),k = α/2 float C9_G_SchlickGGX(float NoL, float NoV, float a) { float k = a * 0.5; float gl = NoL / (NoL * (1.0 - k) + k); float gv = NoV / (NoV * (1.0 - k) + k); return gl * gv; } // 可见性项 V = G / (4 (n·l)(n·v)),用高度相关 Smith 化简后的形式(Heitz 2014) // V = 0.5 / ( n·l · sqrt((n·v)²(1-α²) + α²) + n·v · sqrt((n·l)²(1-α²) + α²) ) float C9_V_SmithHeightCorrelated(float NoL, float NoV, float a) { float a2 = a * a; float lv = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2); float ll = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2); return 0.5 / max(lv + ll, 1e-5); }
最后一个函数把高度相关的 G 与 Cook-Torrance 分母合并成可见性项 。把 代入并化简, 和分母里的余弦相互抵消,得到一个没有除零风险的形式:
core 包里的 V_SmithJointGGX 就是这个式子(只是把 的乘法重新排列了一下)。另一个流行的简化是 Schlick-GGX:
它源自 Schlick 对 Beckmann-Smith 的有理近似,Karis 在虚幻 4 的分享中建议对 GGX 取 (直接光照时还用过 的“重映射”版本来减弱粗糙表面的过亮)。它便宜,但在掠射角偏差较大。
案例 9.3看见 G:遮蔽主要发生在哪里入门单独打开 ↗
目标:用案例 9.1 的 Shader 输出 G 项(_Mode = 1,高度相关形式),观察它随粗糙度的变化;再对比分离形式与高度相关形式的差别。

可以看到,即便在粗糙度 0.9,球的大部分区域 G 仍在 0.7 以上,遮蔽集中在轮廓( 小)和明暗交界线( 小)附近。这正是 G 的视觉作用:压暗粗糙物体边缘的高光。没有它,粗糙金属球的轮廓会出现一圈不自然的亮边。

两种形式的绝对差别不大(右图放大了 10 倍才看清),但它分布在边缘,恰好是高光最容易“闹事”的地方。HDRP、Filament 等较新的实现都使用高度相关形式;第 11 章手写 PBR 时我们也用它。
9.8 菲涅尔 F
站在湖边,往脚下看能看到水底,往远处看水面却像镜子。这就是菲涅尔(Fresnel)效应:光在两种介质的交界处,一部分反射、一部分折射,反射的比例随入射角变大而增加,掠射时趋近 100%。这个比例由 Fresnel 方程精确给出,取决于两种介质的折射率和光的偏振;对于非偏振光、从空气()射入折射率为 的电介质,垂直入射时的反射率是:
代入几个熟悉的值:水 ,;常见塑料、玻璃 ,;钻石 ,。绝大多数日常非金属的 落在 0.02~0.05 之间——这就是 URP 把电介质统一取 0.04 的依据。
金属不同。金属的折射率是复数,自由电子会强烈地反射光,并迅速吸收折射进去的那部分。金属的 通常很高(0.5 以上),而且随波长变化,所以金和铜的反射带有颜色,银和铝则接近无色。
Schlick 近似
完整的 Fresnel 方程太贵,Schlick 在 1994 年给出了一个只需要 的近似:
对电介质,它和精确解贴合得很好;对金属,它也能抓住“掠射时变白”这一主要特征(精确的金属菲涅尔在接近掠射时会先略微下降再上升,Schlick 表现不出这个凹陷,第 12 章介绍的 F82 等扩展模型就是为此而生)。代码:
// ---------- 菲涅尔 F ---------- // Schlick 近似:F = F0 + (1 - F0)(1 - cosθ)⁵ float3 C9_F_Schlick(float3 F0, float cosTheta) { float x = 1.0 - saturate(cosTheta); float x2 = x * x; return F0 + (1.0 - F0) * (x2 * x2 * x); }
用哪个角度:v·h 还是 n·v?
在微表面 BRDF 里,菲涅尔作用于微面元,所以角度是光线与微面元法线 的夹角,即 (等于 )。而在环境光(IBL)里没有单一的 ,常用 来近似。这两种用法的视觉效果很不一样:一盏方向光、一个远处的相机, 在整个物体上几乎不变, 也就几乎是常数;而 会在物体轮廓处变亮。我们平时说的“菲涅尔边缘光”,其实主要来自环境反射。
案例 9.4菲涅尔:F0 扫描与掠射高光入门单独打开 ↗
目标:先用 看不同 F0 的“边缘变亮”;再用直接光的镜面项,看 如何让逆光的高光变强。

第二组实验换一个角度。把案例 9.1 的 Shader 设为 Specular 模式(,再乘 ),材质是 F0 = 0.04 的光滑电介质地板。相机仰角从 70° 降到 12°,灯始终放在相机的镜面方向上,让高光正好落在画面中央:
[BookShot("ch09-fresnel-angle", W = 420, H = 300, Panels = 3)] static void FresnelAngle(Shot s) { // 一块光滑的电介质地板,相机仰角从高到低;灯放在相机的镜面方向上,让高光正好落在画面中央。 // 高光中心处 v·h = sin(仰角):70° → 0.94,35° → 0.57,12° → 0.21 float e = new[] { 70f, 35f, 12f }[s.Panel]; var m = Terms(s, 4, 0.35f); m.SetVector("_F0", V(0.04f)); m.SetFloat("_Display", 1); m.SetFloat("_Exposure", 1.0f); s.Background(new Color(0.16f, 0.17f, 0.19f)); s.Grid(m, Vector3.zero, 40f, 8); float r = e * Mathf.Deg2Rad; s.LookAt(new Vector3(0, 6 * Mathf.Sin(r), -6 * Mathf.Cos(r)), Vector3.zero, 40); s.SunDir(e, 180, 1f); s.Sun.shadows = LightShadows.None; }

F 从 0.04 涨到 0.33,增大了 8 倍多;同时 项在掠射时也变大(分母里的 变小),D 的投影被拉长成竖条。三者叠加,就是开车时看到的那条刺眼的反光带。如果把 F 换成常数 0.04,第三张图会暗淡得多——这是 Blinn-Phong 永远做不出来的效果。
9.9 漫反射项与能量分配
光照到电介质表面,菲涅尔决定了有比例 的能量在界面上直接反射——这就是镜面项。剩下 的能量折射进材质内部,在里面被色素颗粒多次散射,一部分被吸收(决定了颜色),一部分从附近重新钻出来,方向近乎随机——这就是漫反射项。所以漫反射本质上是次表面散射在“出射点离入射点很近”时的极限。
由此得到两条对材质参数极其重要的推论:金属没有漫反射;电介质的高光是无色的(F0 是灰色),颜色几乎都在漫反射里。金属度工作流(9.11 节)就是把这两条写进了参数化方式。
Lambert 与 Disney 漫反射
最常用的漫反射模型仍然是 Lambert:。严格说,镜面项拿走了比例 ,漫反射应该只分到剩下的部分,常见写法是乘 ,或者更粗糙地乘一个与角度无关的 。不同引擎做法不一,URP 采用后一种(第 10 章细看)。
Disney 在 2012 年的报告中观察到,测量数据里粗糙表面在掠射时会变亮(逆反射),光滑表面在掠射时会变暗,于是提出了一个经验修正:
是感知粗糙度。当 时它退化为 Lambert。代码:
// ---------- 漫反射 ---------- // Lambert:f = ρ / π float3 C9_Lambert(float3 albedo) { return albedo * INV_PI; } // Disney(Burley 2012)漫反射:在 Lambert 基础上按粗糙度调整掠射角的明暗 // fd90 = 0.5 + 2 · (l·h)² · perceptualRoughness float3 C9_DisneyDiffuse(float3 albedo, float NoL, float NoV, float LoH, float perceptualRoughness) { float fd90 = 0.5 + 2.0 * LoH * LoH * perceptualRoughness; float lightScatter = 1.0 + (fd90 - 1.0) * pow(1.0 - NoL, 5.0); float viewScatter = 1.0 + (fd90 - 1.0) * pow(1.0 - NoV, 5.0); return albedo * INV_PI * lightScatter * viewScatter; }
// ---------- 漫反射 ---------- // Lambert:f = ρ / π vec3 C9_Lambert(vec3 albedo) { return albedo * INV_PI; } // Disney(Burley 2012)漫反射:在 Lambert 基础上按粗糙度调整掠射角的明暗 // fd90 = 0.5 + 2 · (l·h)² · perceptualRoughness vec3 C9_DisneyDiffuse(vec3 albedo, float NoL, float NoV, float LoH, float perceptualRoughness) { float fd90 = 0.5 + 2.0 * LoH * LoH * perceptualRoughness; float lightScatter = 1.0 + (fd90 - 1.0) * pow(1.0 - NoL, 5.0); float viewScatter = 1.0 + (fd90 - 1.0) * pow(1.0 - NoV, 5.0); return albedo * INV_PI * lightScatter * viewScatter; }
案例 9.5Lambert 与 Disney 漫反射对比入门单独打开 ↗
目标:把灯放在相机正后方(),这是 Disney 漫反射与 Lambert 区别最大的配置。

右图的效果你在现实中一定见过:满月看起来像一个均匀发亮的圆盘,而不是边缘变暗的球,因为月壤非常粗糙、具有逆反射特性。差别不算大,所以很多移动端管线(包括 URP Lit)只用 Lambert;HDRP 的 Lit 则使用 Disney 漫反射。
9.10 组合:完整的 BRDF 与白炉测试
把各项拼起来,本章使用的完整 BRDF 是( 为漫反射颜色):
案例 9.6镜面项与完整直接光的粗糙度扫描进阶单独打开 ↗
目标:看完整的 Cook-Torrance 镜面项和“漫反射 + 镜面”的最终结果怎样随粗糙度变化,体会能量守恒。
镜面模式的关键代码就是 Shader 片元函数里的这几行:
float3 N = normalize(i.normalWS); float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS); Light light = GetMainLight(); float3 L = light.direction; float3 H = SafeNormalize(L + V); float NoL = saturate(dot(N, L)); float NoV = max(dot(N, V), 1e-4); // 防止边缘处除零 float NoH = saturate(dot(N, H)); float VoH = saturate(dot(V, H)); float LoH = VoH; // h 是 l、v 的角平分线,两者相等 float a = _Roughness * _Roughness; // α = perceptualRoughness² float D = EvalD(NoH, a); float G = EvalG(NoL, NoV, a); float3 F = C9_F_Schlick(_F0.rgb, VoH); // Cook-Torrance 镜面 BRDF:D·G·F / (4 (n·l)(n·v)) float3 fSpec = D * G * F / max(4.0 * NoL * NoV, 1e-4); float3 fDiff = (_DiffuseModel < 0.5) ? C9_Lambert(_Albedo.rgb) : C9_DisneyDiffuse(_Albedo.rgb, NoL, NoV, LoH, _Roughness); // Unity 的灯光“颜色×强度”约定:白色 Lambert 面正对强度 1 的灯时输出 1, // 相当于入射辐照度 E = π · lightColor,所以出射辐射率 = f · π · lightColor · (n·l)。 float3 E = PI * light.color * NoL; float3 c; if (_Mode < 0.5) c = D.xxx; else if (_Mode < 1.5) c = (NoL > 0.0 ? G : 0.0).xxx; else if (_Mode < 2.5) c = C9_F_Schlick(_F0.rgb, NoV); else if (_Mode < 3.5) c = F; else if (_Mode < 4.5) c = fSpec * E; else if (_Mode < 5.5) c = fDiff * E; else c = ((1.0 - F) * fDiff + fSpec) * E; // 漫反射只拿“没被镜面反射走”的那部分 // 显示映射:Raw 直接输出(超过 1 会被截断);Exp 用 1 - e^(-x·k) 把大范围数值压进 0..1 if (_Display > 0.5) c = 1.0 - exp(-c * _Exposure); else c *= _Exposure; return half4(c, 1);
vec3 N = normalize(normalWS); vec3 V = GetWorldSpaceNormalizeViewDir(positionWS); Light light = GetMainLight(); vec3 L = light.direction; vec3 H = SafeNormalize(L + V); float NoL = saturate(dot(N, L)); float NoV = max(dot(N, V), 1e-4); // 防止边缘处除零 float NoH = saturate(dot(N, H)); float VoH = saturate(dot(V, H)); float LoH = VoH; // h 是 l、v 的角平分线,两者相等 float a = _Roughness * _Roughness; // α = perceptualRoughness² float D = EvalD(NoH, a); float G = EvalG(NoL, NoV, a); vec3 F = C9_F_Schlick(_F0.rgb, VoH); // Cook-Torrance 镜面 BRDF:D·G·F / (4 (n·l)(n·v)) vec3 fSpec = D * G * F / max(4.0 * NoL * NoV, 1e-4); vec3 fDiff = (_DiffuseModel < 0.5) ? C9_Lambert(_Albedo.rgb) : C9_DisneyDiffuse(_Albedo.rgb, NoL, NoV, LoH, _Roughness); // Unity 的灯光“颜色×强度”约定:白色 Lambert 面正对强度 1 的灯时输出 1, // 相当于入射辐照度 E = π · lightColor,所以出射辐射率 = f · π · lightColor · (n·l)。 vec3 E = PI * light.color * NoL; vec3 c; if (_Mode < 0.5) c = vec3(D); else if (_Mode < 1.5) c = vec3(NoL > 0.0 ? G : 0.0); else if (_Mode < 2.5) c = C9_F_Schlick(_F0.rgb, NoV); else if (_Mode < 3.5) c = F; else if (_Mode < 4.5) c = fSpec * E; else if (_Mode < 5.5) c = fDiff * E; else c = ((1.0 - F) * fDiff + fSpec) * E; // 漫反射只拿“没被镜面反射走”的那部分 // 显示映射:Raw 直接输出(超过 1 会被截断);Exp 用 1 - e^(-x·k) 把大范围数值压进 0..1 if (_Display > 0.5) c = 1.0 - exp(-c * _Exposure); else c *= _Exposure; fragColor = vec4(c, 1.0);


上排展示了能量守恒的直观形态:高光从一个极亮的小点,逐渐摊开成覆盖半个球的柔光,越摊越暗。下排是“真实感”更强的结果:光滑塑料的高光极小、极亮(Raw 显示下已截断为纯白),粗糙塑料的高光几乎溶进了漫反射。注意下排暗部是纯黑的——我们只算了直接光,没有环境光。真实场景中,光滑物体的“质感”主要来自环境反射,而不是这一个小亮点,这也是为什么第 8 章的反射探针对 PBR 如此重要。
案例 9.7白炉测试:BRDF 到底守不守恒高阶单独打开 ↗
目标:直接把方向反照率 画在球上,检验 BRDF 的能量是否守恒。
原理:设想把物体放进一个处处亮度都是 的“白炉”里。由渲染方程,出射辐射率是:
如果 BRDF 既不吸收也不丢失能量(),物体就会和背景完全融为一体,看不见;如果它比背景暗,说明丢失了能量;如果比背景亮,说明凭空“造”出了能量——后一种是 BRDF 的硬伤。白炉测试是 PBR 实现者最常用的自检手段之一。
实现:不用灯光,每个像素直接用蒙特卡洛方法估算 。Lambert 在半球上均匀采样;GGX 镜面则按 做重要性采样(Importance Sampling),把大部分样本集中在高光波瓣里。把概率密度 代入估计量 , 被约掉,只剩 。样本序列用 Hammersley 低差异序列,每个像素都相同,画面没有噪点。
float DirectionalAlbedo(float3 N, float3 V) { uint count = (uint)_SampleCount; float NoV = max(dot(N, V), 1e-4); float a = _Roughness * _Roughness; float sum = 0; for (uint i = 0; i < count; i++) { float2 xi = float2((i + 0.5) / count, RadicalInverse(i)); if (_Model < 0.5) { // Lambert:在半球上均匀采样,pdf = 1/(2π) // 估计量 = f·cosθ / pdf = (ρ/π)·cosθ·2π float cosT = xi.x, sinT = sqrt(1 - cosT * cosT), phi = TWO_PI * xi.y; float3 L = TangentToWorld(float3(sinT * cos(phi), sinT * sin(phi), cosT), N); sum += C9_Lambert(_Albedo.xxx).x * saturate(dot(N, L)) * TWO_PI; } else { // GGX 重要性采样:按 D(h)(n·h) 的分布抽半程向量 h,再反射得到 l // pdf(l) = D·(n·h) / (4 (v·h)),估计量化简为 G·F·(v·h) / ((n·v)(n·h)),这里 F = 1 float cosT = sqrt((1 - xi.x) / (1 + (a * a - 1) * xi.x)); float sinT = sqrt(1 - cosT * cosT), phi = TWO_PI * xi.y; float3 H = TangentToWorld(float3(sinT * cos(phi), sinT * sin(phi), cosT), N); float3 L = reflect(-V, H); float NoL = dot(N, L); if (NoL > 0) { float NoH = saturate(dot(N, H)), VoH = saturate(dot(V, H)); float G = C9_G_SmithHeightCorrelated(NoL, NoV, a); sum += G * VoH / (NoV * max(NoH, 1e-4)); } } } return sum / count; }
float DirectionalAlbedo(vec3 N, vec3 V) { uint count = uint(_SampleCount); float NoV = max(dot(N, V), 1e-4); float a = _Roughness * _Roughness; float sum = 0.0; for (uint i = 0u; i < count; i++) { vec2 xi = vec2((float(i) + 0.5) / float(count), RadicalInverse(i)); if (_Model < 0.5) { // Lambert:在半球上均匀采样,pdf = 1/(2π) // 估计量 = f·cosθ / pdf = (ρ/π)·cosθ·2π float cosT = xi.x, sinT = sqrt(1.0 - cosT * cosT), phi = TWO_PI * xi.y; vec3 L = TangentToWorld(vec3(sinT * cos(phi), sinT * sin(phi), cosT), N); sum += C9_Lambert(vec3(_Albedo)).x * saturate(dot(N, L)) * TWO_PI; } else { // GGX 重要性采样:按 D(h)(n·h) 的分布抽半程向量 h,再反射得到 l // pdf(l) = D·(n·h) / (4 (v·h)),估计量化简为 G·F·(v·h) / ((n·v)(n·h)),这里 F = 1 float cosT = sqrt((1.0 - xi.x) / (1.0 + (a * a - 1.0) * xi.x)); float sinT = sqrt(1.0 - cosT * cosT), phi = TWO_PI * xi.y; vec3 H = TangentToWorld(vec3(sinT * cos(phi), sinT * sin(phi), cosT), N); vec3 L = reflect(-V, H); float NoL = dot(N, L); if (NoL > 0.0) { float NoH = saturate(dot(N, H)), VoH = saturate(dot(V, H)); float G = C9_G_SmithHeightCorrelated(NoL, NoV, a); sum += G * VoH / (NoV * max(NoH, 1e-4)); } } } return sum / float(count); }
配图时背景色与 _EnvColor 用同一个灰色(两者都是 Color,Unity 会做同样的 sRGB→线性换算,所以数值严格一致),F 设为 1,只看 D 和 G 的影响:

这个结果揭示了单次散射微表面模型的一个固有缺陷:G 项把被遮挡的光直接“扔掉”了,但现实中那些光会在微面元之间再弹几次,最终大部分仍然射出表面。粗糙度越高,丢得越多。表现在画面上,就是粗糙金属偏暗、饱和度偏高。解决办法是多次散射能量补偿(如 Kulla-Conty 方法),第 11 章会介绍思路。也可以用这个测试去检查任何一个 BRDF 实现——第 10 章就用它来检验 URP 的高光近似。
9.11 金属度/粗糙度工作流
BRDF 需要的输入是:漫反射颜色 、镜面反射率 (RGB)、粗糙度 。直接把这几个量暴露给美术,就是镜面/光泽度工作流(Specular/Glossiness):Albedo 贴图 + Specular 贴图(RGB 为 F0)+ Glossiness(光滑度)。它最灵活,但也最容易填出不物理的组合:比如漫反射和 F0 都很亮,总反射超过 1;或者电介质填了彩色 F0。
金属度工作流(Metallic/Roughness)利用 9.9 节的两条推论缩减了自由度:用一个 BaseColor 加一个 Metallic 标量,同时决定漫反射颜色和 F0:
好处很明显:参数更少、更难出错、更省贴图。BaseColor 对电介质是漫反射颜色,对金属是反射颜色,语义统一;金属度大多数像素非 0 即 1,压缩友好;可以和光滑度、AO 打包进一张 Mask Map。代价是表达能力:电介质的 F0 被固定成 0.04(宝石、水这类偏离 0.04 较多的材质只能近似),也无法做出“红色漫反射配蓝色高光”这种组合。金属度在 0 和 1 之间的过渡值,物理上只对应“金属与非金属混合在一个像素里”(比如生锈的边缘),不应当用来表示“半金属”。
| 金属度/粗糙度 | 镜面/光泽度 | |
|---|---|---|
| 参数 | BaseColor、Metallic、Smoothness(或 Roughness) | Albedo、Specular(RGB)、Glossiness |
| 电介质 F0 | 固定 0.04 | 任意(可填 0.02~0.17) |
| 出错风险 | 低:能量自动守恒 | 高:容易填出总反射 > 1 |
| 贴图开销 | Metallic 单通道,可打包 | Specular 需 RGB |
| URP Lit 中 | 默认,_WorkflowMode = 1 | _SPECULAR_SETUP 关键字 |
案例 9.8同一个金球,两种工作流入门单独打开 ↗
目标:用 URP 自带的 Lit 验证两种工作流在“合法”参数下完全等价,并展示镜面工作流独有的自由度。
[BookShot("ch09-workflow", W = 420, H = 320, Panels = 3)] static void Workflow(Shot s) { s.Floor(); Material m; if (s.Panel == 0) { m = s.Lit(new Color(1f, 0.78f, 0.34f), 1f, 0.75f); // 金属度工作流:金属 = 1,底色即 F0 } else { m = s.Lit(Color.black, 0f, 0.75f); m.SetFloat("_WorkflowMode", 0); m.EnableKeyword("_SPECULAR_SETUP"); // 脚本建的材质不会跑 ShaderGUI,关键字要自己开 if (s.Panel == 1) m.SetColor("_SpecColor", new Color(1f, 0.78f, 0.34f)); // 镜面工作流:漫反射黑、镜面色 = 同样的金色 else { m.SetColor("_BaseColor", new Color(0.7f, 0.08f, 0.06f)); m.SetColor("_SpecColor", new Color(0.25f, 0.45f, 0.75f)); // 红色漫反射 + 蓝色高光:金属度工作流表达不了 } } s.Sphere(m, new Vector3(0, 0.75f, 0), 1.5f); s.LookAt(new Vector3(0, 1.35f, -4.0f), new Vector3(0, 0.7f, 0), 32); }

new Material(Shader.Find("Universal Render Pipeline/Lit")) 创建材质时,材质面板(ShaderGUI)不会运行,所以面板上切换工作流、勾选法线贴图时自动设置的关键字都不会被设置。要切到镜面工作流,必须自己 EnableKeyword("_SPECULAR_SETUP");用了法线贴图要开 _NORMALMAP。忘了这一步,参数填了也没用。粗糙度:Smoothness、感知粗糙度与 α
同一个“粗糙度”在不同地方有三种写法,读源码时一定要分清:
- Smoothness(光滑度):材质面板上的滑条,Unity 的习惯,1 表示镜面。
- 感知粗糙度(perceptualRoughness):大多数 DCC 工具(Substance、Blender)里的 Roughness 就是它。
- α(URP 源码里叫
roughness):真正代入 GGX、Smith 公式的量。GGX 公式里实际出现的又是 ,URP 叫它roughness2。
为什么要平方?Burley 在 Disney 报告里指出,如果直接把滑条线性映射到 ,滑条的前半段( 从 0 到 0.5)高光就已经很糊了,后半段几乎看不出变化。平方之后,滑条每走一步,人眼感受到的变化大致均匀,所以叫“感知”粗糙度。
在 half 精度下, 太小会导致 下溢成 0,GGX 变成除零。URP 在 InitializeBRDFData 里用 HALF_MIN_SQRT()和 HALF_MIN()分别给 和 设了下限,自己写 Shader 时也要做类似的钳制。
9.12 线性空间、Gamma 与 HDR
前面所有公式都有一个默认前提:颜色值与物理能量成正比,可以相加、相乘。但图片文件和显示器并不是这样存储亮度的。
sRGB 编码
人眼对暗部的变化比亮部敏感。为了让 8 位的 256 个等级在“感知上”分布均匀,图片通常用 sRGB 曲线编码:存储值大致是线性亮度的 1/2.2 次方。于是一张 sRGB 贴图里的 0.5,对应的线性亮度只有约 0.214。
在 Unity 的线性色彩空间(Project Settings › Player › Color Space = Linear)下,这一切是自动的:勾了 sRGB 的贴图采样时自动解码,材质 Color 属性在上传前转换成线性,最终写到帧缓冲时自动编码。Shader 里拿到的、算出的都是线性值。法线贴图、粗糙度、金属度、AO 这类“数据”贴图则必须取消 sRGB 勾选,否则会被错误地解码(第 13 章详细讨论贴图设置)。
如果在 Gamma 空间做光照会怎样?两盏灯的叠加不再是能量相加,亮度过渡变硬,明暗交界线发黑,彩色光的混合发脏。下面的案例直接展示这个差别。
案例 9.9在 Gamma 空间算光照会怎样入门单独打开 ↗
目标:两盏彩色方向光照一个白球,对比线性空间与 Gamma 空间的光照结果。
做法:管线是线性的,我们“模拟”老式 Gamma 工作流:先把材质颜色和灯光颜色编码回 sRGB 数值,在编码后的数值上做 Lambert 乘加,最后再解码一次,以抵消管线最终写入 sRGB 目标时的编码,让屏幕显示的正是“在 Gamma 数值上算出来的结果”。
half4 frag(Varyings i) : SV_Target { float3 N = normalize(i.normalWS); Light l1 = GetMainLight(); float3 L2 = normalize(_Light2Dir.xyz); // 材质颜色、灯光颜色在 Shader 里拿到的都已经是线性值 float3 albedo = _BaseColor.rgb; float3 c1 = l1.color, c2 = _Light2Color.rgb; if (_GammaSpace > 0.5) { // 错误示范:把它们变回 sRGB 编码值再去做乘法和加法 albedo = LinearToSRGB(albedo); c1 = LinearToSRGB(c1); c2 = LinearToSRGB(c2); } float3 lit = albedo * (c1 * saturate(dot(N, l1.direction)) + c2 * saturate(dot(N, L2))); if (_GammaSpace > 0.5) lit = SRGBToLinear(saturate(lit)); // 抵消管线最后的 sRGB 编码 return half4(lit, 1); }
void main() { vec3 N = normalize(normalWS); Light l1 = GetMainLight(); vec3 L2 = normalize(_Light2Dir.xyz); // 材质颜色、灯光颜色在 Shader 里拿到的都已经是线性值 vec3 albedo = _BaseColor.rgb; vec3 c1 = l1.color, c2 = _Light2Color.rgb; if (_GammaSpace > 0.5) { // 错误示范:把它们变回 sRGB 编码值再去做乘法和加法 albedo = LinearToSRGB(albedo); c1 = LinearToSRGB(c1); c2 = LinearToSRGB(c2); } vec3 lit = albedo * (c1 * saturate(dot(N, l1.direction)) + c2 * saturate(dot(N, L2))); if (_GammaSpace > 0.5) lit = SRGBToLinear(saturate(lit)); // 抵消管线最后的 sRGB 编码 fragColor = vec4(lit, 1.0); }

线性空间的结果之所以“对”,是因为光的叠加在物理上就是能量相加:50% 的橙光加 50% 的蓝光,总能量就是两者之和。Gamma 空间的数值不与能量成正比,加法失去了意义。这也是为什么“同一套美术资源,在 Gamma 项目里看起来对,搬到线性项目就偏亮偏灰”——当初在 Gamma 里调出来的参数本身就补偿了错误。新项目请一律使用线性空间。
UNITY_COLORSPACE_GAMMA 分支),但 PBR 的结果在 Gamma 空间下只能算“近似可用”。今天的目标设备基本都支持线性空间。HDR 与色调映射
辐射率没有上限。正午太阳下白墙的亮度,可能是室内暗角的数千倍;9.6 节算过,光滑表面的 GGX 峰值可以上千。如果帧缓冲只能存 0~1,这些信息在光照计算后就被截断了:高光变成一片纯白,Bloom 也无从提取“到底有多亮”。
所以 PBR 管线需要 HDR 渲染目标(URP 14 默认使用 32 位的 B10G11R11 浮点格式,可在 URP Asset 的 HDR Precision 里改为 64 位的 R16G16B16A16),让光照、半透明混合、后处理都在高动态范围下进行,最后由色调映射(Tonemapping)把它压缩到显示器能显示的 0~1,再做 sRGB 编码:
案例 9.10HDR 截断 vs 色调映射入门单独打开 ↗
目标:同一个强光场景(太阳强度 4.5、自发光强度 5),对比不做色调映射、Neutral、ACES 三种输出。

配图脚本里只多了两行:打开相机后处理,并在全局 Volume 里加入 Tonemapping 组件(完整代码见 Ch09/Editor/Ch09Shots.cs 的 Hdr 方法):
var t = s.Post().Add<Tonemapping>(); // 打开相机后处理,返回全局 Volume 的 Profile t.mode.Override(TonemappingMode.ACES); // 中间那张用 TonemappingMode.Neutral
左图的红球亮面只剩一种颜色,方块被截成了单一的浅黄,信息已经丢失,后面无论怎么调色都找不回来。色调映射之后,亮部层次被保留,但整体变暗、变灰——实际项目中通常还要配合曝光(Post Exposure)一起调。本书的配图默认关闭后处理,目的是让你看到 Shader 的原始输出;做产品时,PBR 材质必须在开启色调映射的环境下检查,否则会把“截断造成的过曝”误判为“材质太亮”。各种色调映射算法的细节见第 27 章。
9.13 从公式到源码:一张对照表
本章的每个概念,在 URP 14 的着色器库里都能找到对应的实现(有的是精确的,有的是近似)。下一章逐行阅读时,可以随时翻回这张表:
| 本章概念 | URP / core 中的位置 | 说明 |
|---|---|---|
| 精确光源求和 | Lighting.hlsl:UniversalFragmentPBR 里的主光 + 附加光循环 | 每盏灯调用一次 LightingPhysicallyBased |
radiance = lightColor * (atten * NdotL) | π 已按 Unity 约定吸收 | |
| Lambert | brdfData.diffuse | 不含 1/π,已乘 (1 − 反射率) |
| D·V·F | BRDF.hlsl:DirectBRDFSpecular | GGX 的 D 精确,V·F 合并成一个近似 |
| 标准 GGX、Smith、Schlick | core BSDF.hlsl:D_GGX、V_SmithJointGGX、F_Schlick | HDRP 用它们;URP Lit 不用 |
| α = r² | core CommonMaterial.hlsl:PerceptualRoughnessToRoughness | r = 1 − smoothness |
| 金属度工作流 | InitializeBRDFData、kDielectricSpec | F0 = lerp(0.04, albedo, metallic) |
| IBL 漫反射 | SampleSH / SampleLightmap | 预积分的辐照度 |
| IBL 镜面 | GlossyEnvironmentReflection + EnvironmentBRDF | 预卷积 mip + 解析近似 |
| 色调映射 | 后处理 Tonemapping Volume 组件 | 第 27 章 |
几个常见误区
- “PBR 就是用了 Metallic 和 Smoothness 贴图”。参数化方式只是入口。光照在 Gamma 空间、没有 HDR、环境反射缺失、贴图色彩空间设错,任何一项都会让“PBR 材质”在不同光照下表现不一致。
- “BRDF 不能大于 1”。BRDF 的单位是 ,光滑表面的镜面 BRDF 在峰值处可以是几千。不能大于 1 的是对半球积分后的方向反照率。
- “菲涅尔就是边缘光”。边缘变亮只是环境反射里 的表现;在直接光里,菲涅尔取决于 ,最明显的表现是逆光、掠射时的强烈高光。为了“好看”额外叠加一层菲涅尔边缘光,往往会把能量算重。
- “粗糙度 0 最真实”。现实中几乎没有完全光滑的表面,而且粗糙度过低时 GGX 的峰值会在 half 精度下溢出、在像素级别产生闪烁的高光走样(Specular Aliasing)。材质的光滑度通常不宜填到 1。
- “金属度填 0.5 就是半金属”。物理上没有半金属;0~1 之间的值只应出现在金属与非金属的过渡边界上,例如锈迹、磨损的油漆边缘。
本章小结
- Shader 输出的是辐射率; 来自投影面积, 决定了 Lambert 的 。
- 渲染方程 = 自发光 + 半球积分(BRDF × 入射辐射率 × 余弦);实时渲染拆成“精确光源求和”与“预积分的环境光(IBL)”。
- BRDF 的单位是 ,可以大于 1;它应当非负、互易、能量守恒(方向反照率 ≤ 1)。
- Cook-Torrance:。D 用 GGX(长尾),G 用 Smith(高度相关形式更准),F 用 Schlick。
- Unity 约定
lightColor,所以漫反射直接是 albedo,镜面项整体乘 。 - 金属无漫反射、电介质 F0≈0.04 且无色,这两条被金属度工作流写进了参数化;。
- 线性空间是 PBR 数学成立的前提;HDR 渲染目标与色调映射是 PBR 结果能被正确看见的前提。
- 白炉测试能直接检验 BRDF 的能量;单次散射 GGX 在高粗糙度下会丢失大量能量。
练习
- 在
BRDFTerms里给 G 增加一个“Schlick-GGX,”的选项,与 版本并排拍图,说明前者为什么让粗糙物体的高光变暗。 - 修改
WhiteFurnace,在 GGX 模式下把 F 换成 Schlick(F0 = 0.04),并加上 加权的 Lambert 漫反射(反照率 1)。白炉里的球还会“消失”吗?哪里会偏亮或偏暗? - 手算:感知粗糙度 0.2 的 GGX,在 处 是多少?如果这个值存进 half(最大约 65504)再乘以灯光强度 10、F0 = 1,会不会溢出?
- 用
GammaVsLinear的思路,再做一张“在 Gamma 空间做 Alpha 混合”的对比图:一块 50% 透明的白色面片盖在黑白棋盘上,两种空间里的结果有什么不同? - 查阅你常用的 DCC 工具(如 Substance Painter)导出的 Roughness 贴图,与 URP Lit 的 Smoothness 之间要做什么转换?需要平方吗?