GameTech/URP 渲染实战
CHAPTER 09第 9 章

PBR 理论

从“光到底是什么量”讲起,推导渲染方程与 BRDF,再把 Cook-Torrance 微表面模型拆成 D、G、F 三项,逐项写成 Shader、逐项拍出来看。这一章是后面读 URP Lit 源码、手写 PBR 的全部理论基础。

本章你会学到
立体角、辐照度、辐射率的含义,以及 Shader 输出的到底是哪个量
渲染方程每一项的意义,实时渲染怎样把它拆成直接光和间接光
BRDF 的定义与性质,Lambert 里那个 1/π 从哪儿来
微表面模型:GGX、Beckmann、Smith G、Schlick 菲涅尔的公式与直觉
金属度/粗糙度工作流、α = roughness² 的由来
线性空间、HDR 与色调映射为什么是 PBR 的前提

9.1 “基于物理”到底基于什么

第 5 章的 Blinn-Phong 能做出“看起来有高光”的效果,但它有几个绕不过去的毛病。第一,参数没有物理含义:高光指数 64 和 128 差多少、高光颜色该填多少,只能凭感觉调;第二,换一套灯光环境,材质往往要重调一遍,因为模型本身不保证能量守恒——把高光调宽,它反射的总能量就凭空变多;第三,它没有菲涅尔效应:现实中几乎所有物体在掠射角下都会变得更“反光”,湖面远处像镜子、近处能看见水底,Blinn-Phong 表现不出来;第四,它的高光形状和粗糙度之间没有可靠的对应关系。

基于物理的渲染(Physically Based Rendering,PBR)不是“照片级真实”的同义词,它解决的是一致性问题:同一个材质参数,在正午阳光下、阴天、室内灯光下都应该“对”,美术不需要为每个场景重调材质。为此,PBR 在几个关键点上向物理靠拢:

  1. 用辐射度量学描述光:Shader 计算的是辐射率(Radiance),灯光、环境、材质的单位彼此咬合,而不是随手乘一些系数。
  2. 能量守恒:表面反射出去的光不能多于射进来的光;高光变宽时必须同时变暗。
  3. 微表面理论:用“粗糙度”这个统计量描述表面在像素以下尺度的凹凸,高光的形状、亮度、在掠射角的变化都由它推出来。
  4. 菲涅尔效应:反射率随角度变化,并且金属与非金属有本质区别。
  5. 线性空间 + 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):从球心看出去,一块球面面积 dA 所张的立体角是 dA/r2,单位是球面度(steradian,sr)。整个球面是 4π sr,一个半球是 2π sr。在球坐标下,天顶角 θ、方位角 φ 各变化一点点,围出的小块立体角是:

dω=dAr2=sinθdθdφ

那个 sinθ 很重要:越靠近天顶(θ→0),同样的 dθdφ 围出的面积越小。后面所有“对半球积分”的推导都要带着它。

dA r dω dω = dA / r²  整个球面 = 4π sr n ω θ φ dω = sinθ dθ dφ  半球 = 2π sr
图 9-1左:立体角是球面上一小块面积除以半径平方;右:在以法线为天顶的球坐标中,方向 ω 由天顶角 θ 和方位角 φ 确定。

从能量到辐射率

描述光的物理量有一串,名字很像,但各自回答不同的问题。下表按“越来越细”的顺序排列:

物理量符号单位回答的问题
辐射能(Radiant Energy)QJ一共有多少能量
辐射通量(Radiant Flux)ΦW每秒流过多少能量;灯泡的“功率”
辐照度(Irradiance)EW/m²单位面积上每秒接收多少能量,不管从哪个方向来
辐射强度(Radiant Intensity)IW/sr点光源朝某个方向每单位立体角发出多少
辐射率(Radiance)LW/(m²·sr)沿某一条光线传播的“亮度”:单位投影面积、单位立体角上的通量

渲染里最重要的是最后一个。辐射率的定义是:

L=d2ΦdAcosθdω

分母里的 dAcosθ 是面元在光线方向上的投影面积:同一束光斜着照到表面上,铺开的面积变大,单位面积分到的能量变少;反过来,从斜角看一块发光面,它在视线方向上“看起来”变窄了。辐射率把这个几何因素除掉,只剩下“这条光线本身有多亮”。

n θ dA dA cosθ dω(一小束方向) L = d²Φ / (dA cosθ dω) 斜射时光铺得更开: 真正“迎着光”的面积只有 dA cosθ
图 9-2辐射率的定义:一束立体角为 dω 的光以与法线成 θ 的角度照到面元 dA 上,垂直于光线的截面只有 dA cosθ。

为什么渲染偏爱辐射率?两个原因。其一,相机传感器(或人眼)的一个像素,测到的正是从某个方向射来的辐射率的平均;Shader 在片元阶段输出的颜色,物理上对应的就是“沿这条视线射向相机的辐射率”。其二,在真空(或不考虑雾、介质)时,辐射率沿光线传播保持不变——这就是为什么一面墙离你近或远,单位面积看起来一样亮,只是占的像素多少不同。正因为如此,片元着色器不需要关心相机离物体多远。

辐照度与余弦定律

表面接收到的辐照度,是把各个方向射来的辐射率按投影面积加起来:

E=∫ΩLi(ω)cosθdω

这里的 cosθ 就是 𝐧·𝐥,第 5 章 Lambert 光照里的那个点积,物理来源就在这里:光斜着打下来,能量被摊到更大的面积上。顺手算一个后面要用的积分——如果各方向射来的辐射率都等于 1,那么:

∫Ωcosθdω=∫02π∫0π/2cosθsinθdθdφ=2π·12=π

半球的立体角是 2π,但余弦加权后的“有效立体角”只有 π。记住这个 π,它马上会出现在 Lambert BRDF 里。

方向光和点光源在数学上是极限情况:所有光都来自一个方向,辐射率是一个“冲激”(Dirac δ)。这时积分退化成一项:E=E⟂(𝐧·𝐥),E⟂ 是垂直照射时的辐照度。实时渲染里绝大多数灯都按这种“精确光源”(Punctual Light)处理。

深入:辐射度量与光度学辐射度量学按能量计量;光度学(Photometry)再乘上人眼对不同波长的敏感度,单位变成流明、勒克斯、坎德拉、尼特。HDRP 的灯光直接用光度学单位,URP 的灯光强度则是一个无单位的系数,乘在颜色上。Shader 里的数学两者没有区别,区别只在灯光数值的标定方式。另外,实时渲染用 RGB 三个通道近似整条光谱,这本身就是一种近似,像色散、荧光这类与波长强相关的现象需要专门处理。

9.3 渲染方程

1986 年 Kajiya 提出的渲染方程,把“一个点朝某个方向射出多少光”写成了一个积分。忽略随时间、波长的变化,它是:

Lo(𝐩,𝐯)=Le(𝐩,𝐯)+∫Ωf(𝐩,𝐥,𝐯)Li(𝐩,𝐥)(𝐧·𝐥)dωl

逐项看:

  • Lo(𝐩,𝐯):点 𝐩 朝观察方向 𝐯 射出的辐射率——就是我们要算的像素颜色。
  • Le:自发光。URP 里对应材质的 Emission,直接加上去。
  • ∫Ω⋯dωl:对表面上方半球的所有入射方向 𝐥 求和。
  • Li(𝐩,𝐥):从 𝐥 方向射来的辐射率。它可能来自灯,也可能来自别的物体反射的光——这让方程变成递归的:别处的 Lo 就是这里的 Li。
  • (𝐧·𝐥):上一节的余弦项,把入射辐射率换算成辐照度。
  • f(𝐩,𝐥,𝐯):双向反射分布函数(BRDF),描述材质“把从 𝐥 来的光,有多少比例送往 𝐯”。材质的全部个性都在它里面。
n Lᵢ(l) Lₒ(v) p Ω:表面上方的半球,所有入射方向 每个入射方向的贡献 = f(l,v) · Lᵢ(l) · (n·l) · dω
图 9-3渲染方程:把半球上每个方向射来的光,按 BRDF 和余弦加权后累加,得到朝相机方向射出的辐射率。

实时渲染怎么“解”这个积分

离线渲染器用蒙特卡洛光线追踪近似求解整个递归积分。实时渲染则把 Li 粗暴地分成两类,各用一种便宜的办法:

直接光:来自场景里的方向光、点光、聚光灯。它们是精确光源,积分退化为对灯的求和:

Lo(𝐯)=∑kf(𝐥k,𝐯)Ek(𝐧·𝐥k)

其中 Ek 是第 k 盏灯在垂直照射时贡献的辐照度(已乘过距离衰减和阴影)。URP 里这件事由 LightingPhysicallyBased 对主光和每盏附加光各做一次。

间接光:来自天空、环境、其他物体的反射。它真的是一个积分,没法逐方向求和。实时渲染的办法是预计算:漫反射部分事先把环境光积分成球谐系数(SH)或光照贴图;镜面部分事先把环境贴图按粗糙度预卷积成一串 mip(反射探针),运行时查一次表。这一类统称基于图像的光照(Image Based Lighting,IBL)。URP 里对应 GlobalIllumination 函数,第 10 章会逐行读,第 11 章会自己实现。

所以,无论 Shader 写得多复杂,PBR 片元着色器做的事都可以概括为一行:把 BRDF 分别和“若干盏精确光源”“一份预积分的环境光”结合起来,再加上自发光。BRDF 写得对不对,决定了材质在任何光照下是否一致。

9.4 BRDF:材质的数学身份证

BRDF(Bidirectional Reflectance Distribution Function)的严格定义是:从 𝐥 方向来的入射辐照度增量,在 𝐯 方向上引起的出射辐射率增量之比:

f(𝐥,𝐯)=dLo(𝐯)dEi(𝐥)=dLo(𝐯)Li(𝐥)(𝐧·𝐥)dωl

分子是辐射率,分母是辐照度,所以 BRDF 的单位是 sr−1。这意味着它的值可以大于 1——一面镜子在镜面方向上的 BRDF 是无穷大(一个 δ 函数)。不要把 BRDF 理解成“反射率”,反射率是 BRDF 在半球上积分之后的结果。

一个物理上合理的 BRDF 满足三条性质:

  1. 非负:f≥0。
  2. 互易性(Helmholtz Reciprocity):交换 𝐥 和 𝐯,值不变,f(𝐥,𝐯)=f(𝐯,𝐥)。很多游戏里的近似公式并不严格互易,但这不影响视觉,可以接受。
  3. 能量守恒:对任意观察方向,反射出去的总比例不超过 1:
∀𝐯:R(𝐯)=∫Ωf(𝐥,𝐯)(𝐧·𝐥)dωl≤1

R(𝐯) 叫方向反照率(Directional Albedo)。它等于 1 意味着一点能量都没吸收,小于 1 表示有能量被吸收或丢失。本章 9.10 节的“白炉测试”就是把它直接画出来。

Lambert 的 1/π

最简单的 BRDF 是理想漫反射:往各个方向反射的辐射率都一样,f 是常数。设表面的反照率(吸收之后剩下多少比例被反射出去)是 ρ,由能量守恒等式:

∫Ωfcosθdω=f·π=ρ⟹fLambert=ρπ

这就是 Lambert BRDF 里 1/π 的来历:它不是“经验常数”,而是余弦加权半球积分 π 的倒数。如果漏了它,一个反照率为 0.8 的白墙在均匀环境里会反射出 0.8π≈2.5 倍的光,严重违反能量守恒。

理想漫反射 f = ρ/π,各向同样亮 光泽反射 波瓣围绕镜面方向 理想镜面 只沿反射方向(δ 函数) 真实材质 漫反射 + 光泽高光
图 9-4BRDF 的“波瓣”示意(剖面):橙色轮廓到入射点的距离表示该方向上反射的强弱。实际材质通常是一个漫反射项加一个镜面项。

游戏引擎里的 π 去哪了

读 URP 源码时你会发现,漫反射项是 albedo 而不是 albedo / PI,GGX 的公式里也找不到 π。这不是写错了,而是 Unity(以及大多数引擎)对灯光强度有一个约定:一个反照率为 1 的 Lambert 表面,被颜色为 c 的灯垂直照射时,输出的辐射率就是 c。按 Lambert 公式,Lo=ρπE⟂,要让它等于 c,就得有 E⟂=πc。于是整个直接光照可以写成:

Lo=f·E⟂(𝐧·𝐥)=(𝐜diffπ+fs)π𝐜light(𝐧·𝐥)=(𝐜diff+πfs)𝐜light(𝐧·𝐥)

漫反射的 1/π 与灯光的 π 抵消,镜面项则整体乘了 π——GGX 分母里的 π 正好被消掉。理解了这一点,第 10 章看到 DirectBRDFSpecular 里“缺了一个 π”就不会困惑。本章的可视化 Shader 为了和教科书公式对照,保留 π 的写法,最后再乘 π·lightColor,结果与 URP 的约定一致。

9.5 微表面理论

真实表面在显微尺度上并不光滑。微表面理论(Microfacet Theory)假设:表面由无数比像素小得多、但比光波长大得多的微小平面(微面元,microfacet)组成,每个微面元都是一面理想镜子。宏观上看到的高光,是这些小镜子统计意义上的总和:

  • 一面小镜子要把 𝐥 方向的光反射到 𝐯 方向,它的法线 𝐦 必须恰好等于两者的半程向量 𝐡=𝐥+𝐯‖𝐥+𝐯‖。
  • 有多少小镜子的法线朝向 𝐡?由法线分布函数 D(𝐡) 描述。
  • 朝向对了的小镜子,有些被相邻的凸起挡住了光,或者反射出去的光被挡住了,由几何遮蔽函数 G(𝐥,𝐯) 描述。
  • 每面小镜子反射多少比例,由菲涅尔项 F(𝐯,𝐡) 描述。
m = h m = h m = h 宏观平面 n l v h h 平分 l 与 v,所以 v·h = l·h
图 9-5微表面:宏观平面下是许多朝向各异的小镜面。只有法线 m 恰好等于半程向量 h 的那些(橙色)才会把 l 方向的光反射到 v 方向。

把这些因素写在一起,就是 Cook-Torrance 镜面 BRDF:

fs(𝐥,𝐯)=D(𝐡)G(𝐥,𝐯)F(𝐯,𝐡)4(𝐧·𝐥)(𝐧·𝐯)

分母里的 4 和两个余弦常让人困惑。直观理解:D 是“每单位立体角、每单位宏观面积上,法线朝 𝐡 的微面元面积”,它定义在“半程向量的方向空间”里;而 BRDF 定义在“光线方向的空间”里。从 𝐡 换到 𝐥,立体角会被反射放大,雅可比行列式是

dωhdωl=14(𝐡·𝐥)

再加上 BRDF 定义里本身除掉的入射余弦 (𝐧·𝐥),以及把“微面元投影到观察方向的面积”换算到“宏观面积”时出现的 (𝐡·𝐯)/(𝐧·𝐯),(𝐡·𝐥) 与 (𝐡·𝐯) 相等而约掉,最后就剩下 4(𝐧·𝐥)(𝐧·𝐯)。完整推导可以参考 Walter 等人 2007 年关于粗糙表面微面元模型的论文,这里只要记住:这个分母不是可选的归一化系数,少了它高光的能量就是错的。

这个分母在掠射角会趋于 0,单独计算容易溢出。因此实践中常把 G 和分母合并成一个可见性项(Visibility):

V(𝐥,𝐯)=G(𝐥,𝐯)4(𝐧·𝐥)(𝐧·𝐯),fs=D·V·F

core 包 BSDF.hlsl 里的 V_SmithJointGGX、URP 注释里说的 “D * V * F”,指的都是这个 V。

9.6 法线分布函数 D

D(𝐦) 描述微面元法线的统计分布:粗糙度低时,几乎所有法线都贴近 𝐧,D 在 𝐡=𝐧 处是一个又高又窄的尖峰;粗糙度高时,法线四散,D 又矮又宽。无论形状如何,它都必须满足一个归一化条件——所有微面元投影到宏观平面上的面积之和,等于宏观面积:

∫ΩD(𝐦)(𝐧·𝐦)dωm=1

这个条件就是“高光变宽必须变暗”的数学根源:分布越宽,峰值必须越低。

三种常见的 D

Blinn-Phong NDF。把第 5 章的 (𝐧·𝐡)n 按上面的条件归一化,得到:

DBP(𝐡)=n+22π(𝐧·𝐡)n

它和 Beckmann 分布形状非常接近,常用换算是 n=2/α2−2。

Beckmann。源自光学领域对粗糙表面的高斯斜率假设:

DBeckmann(𝐡)=1πα2(𝐧·𝐡)4exp(−tan2θhα2)

GGX / Trowbridge-Reitz。Walter 等人在 2007 年把它引入图形学(Trowbridge 与 Reitz 在 1975 年就推导过同一分布),如今是实时 PBR 的事实标准:

DGGX(𝐡)=α2π((𝐧·𝐡)2(α2−1)+1)2

其中 α 是粗糙度参数(9.11 节解释它和材质面板上的 Smoothness 的关系)。在 𝐡=𝐧 处,三者峰值都约等于 1/(πα2):α=0.1 时约为 32,α=0.01 时超过 3000。这也提醒我们,高光的数值天然是 HDR 的。

0.0001 0.001 0.01 0.1 1 10 0 15 30 45 60 75 90 半程向量与法线的夹角 θh(度) D(h)(对数刻度) GGX / Trowbridge-Reitz Beckmann Blinn-Phong(n = 2/α² − 2)
图 9-6α = 0.3 时三种 NDF 随 θh 的变化(纵轴对数刻度)。峰值几乎相同,差别在“尾巴”:Beckmann 与 Blinn-Phong 在 40° 之后迅速衰减到零,GGX 却拖着一条长尾,衰减按幂函数而不是指数。

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 的三种实现:

Assets/Cookbook/Ch09/BRDFLib.hlsl(节选:ndf)
// ---------- 法线分布函数 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 类型。

Assets/Cookbook/Ch09/BRDFTerms.shader
// 第 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):球的轮廓处 𝐧·𝐯→0,Cook-Torrance 的分母会变成 0,必须钳一下。
  • E = PI * light.color * NoL:这就是 9.4 节的约定 E⟂=πc。镜面和漫反射都乘它,结果与 URP 在亮度上可比。
  • 显示方式 Exp 用 1−e−kx 把 0 到几千的数值单调地压进 0~1,这样 D 的尖峰不会被截断成一片白,便于比较宽度。
图 9-7只输出 GGX 的 D 项(Exp 显示),从左到右感知粗糙度为 0.1、0.3、0.5、0.7、0.9。光从左上方射来,亮斑位于 n = h 处。粗糙度越大,分布越宽、峰值越低。

注意最右边的球几乎被均匀照亮:粗糙度 0.9 时 α=0.81,D 在整个半球上变化不大,“高光”已经和漫反射分不清了。这正是粗糙金属看起来“发灰发闷”的原因。

案例 9.2GGX、Beckmann 与 Blinn-Phong 的长尾入门单独打开 ↗

目标:同一个 α,对比三种 NDF 在球面上的样子。

用案例 9.1 的 Shader,把 _NDF 分别设为 0、1、2,感知粗糙度 0.45(α≈0.2),Exp 显示、曝光 0.6:

Assets/Cookbook/Ch09/Editor/Ch09Shots.cs(节选:ndfshot)
[BookShot("ch09-ndf-compare", W = 420, H = 360, Panels = 3)]
static void NdfCompare(Shot s)
{
    var m = Terms(s, 0, 0.45f);
    m.SetFloat("_NDF", s.Panel);           // GGX / Beckmann / Blinn-Phong
    m.SetFloat("_Display", 1); m.SetFloat("_Exposure", 0.6f);
    Single(s, m, 30, 25);
}
图 9-8左:GGX;中:Beckmann;右:归一化 Blinn-Phong(n = 2/α² − 2)。三者亮核大小相近,但只有 GGX 在亮核外有一大圈逐渐变暗的光晕。

后两张几乎一模一样,这说明 Blinn-Phong 高光只要做了归一化、选对了指数,本质上就是 Beckmann 的廉价替身。很多老游戏里的“Normalized Blinn-Phong”能撑起相当不错的画面,原因就在这里。真正的视觉差别来自 GGX 的长尾。

调参建议想让高光“更锐利、没有光晕”的风格化材质,可以考虑换用 Beckmann 或归一化 Blinn-Phong;想要更接近真实金属的效果,用 GGX。第 12 章还会介绍“两个 GGX 叶片混合”等进一步控制高光形状的手法。

9.7 几何遮蔽 G

微面元并不是孤立的:在粗糙表面上,一个朝向正确的小镜面可能被前方的凸起挡住入射光(阴影,shadowing),或者它反射的光在离开表面前被挡住(遮蔽,masking)。G(𝐥,𝐯) 给出“既能被照到、又能被看到”的微面元比例,取值 0~1。

入射光 阴影:光被前方的凸起挡住 本该被照亮的微面元 本该射向相机 遮蔽:反射光被后方的凸起挡住 被挡住的光并没有消失,而是在表面间多次弹射——单次散射模型把这部分能量丢掉了
图 9-9几何遮蔽的两种情况:左边入射光到不了微面元(shadowing),右边微面元反射的光出不去(masking)。光线越贴近表面,两者越严重。

Smith 模型

精确计算遮挡需要知道表面的具体形状,而我们只有 D 这个统计分布。Smith 在 1967 年给出的近似假设“微面元的高度与法线方向无关”,由此推出单方向的遮蔽函数只取决于方向与 NDF:

G1(𝐱)=11+Λ(𝐱),ΛGGX(𝐱)=−1+1+α2tan2θx2

θx 是方向 𝐱 与法线的夹角。正对法线时 tanθ=0,Λ=0,G1=1;贴近地平线时 tanθ→∞,G1→0。Λ 的具体形式依赖于 NDF,上面是 GGX 的版本。

入射和出射两个方向要组合成 G(𝐥,𝐯),有两种常见形式:

Gseparable=G1(𝐥)G1(𝐯),Gheight-correlated=11+Λ(𝐥)+Λ(𝐯)

分离形式假设阴影和遮蔽互不相关。但现实中,高处的微面元既不容易被挡光,也不容易被挡视线——两者是正相关的。Heitz 在 2014 年的论文中论证了高度相关形式(Height-Correlated)更准确,它总是略大于分离形式,差别在两个方向都接近掠射时最明显。下面是代码实现:

Assets/Cookbook/Ch09/BRDFLib.hlsl(节选:geometry)
// ---------- 几何遮蔽 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 分母合并成可见性项 V。把 Λ 代入并化简,tanθ 和分母里的余弦相互抵消,得到一个没有除零风险的形式:

VSmithGGXCorrelated=0.5(𝐧·𝐥)(𝐧·𝐯)2(1−α2)+α2+(𝐧·𝐯)(𝐧·𝐥)2(1−α2)+α2

core 包里的 V_SmithJointGGX 就是这个式子(只是把 α2 的乘法重新排列了一下)。另一个流行的简化是 Schlick-GGX:

G1(𝐱)≈𝐧·𝐱(𝐧·𝐱)(1−k)+k,k=α2

它源自 Schlick 对 Beckmann-Smith 的有理近似,Karis 在虚幻 4 的分享中建议对 GGX 取 k=α/2(直接光照时还用过 k=(r+1)2/8 的“重映射”版本来减弱粗糙表面的过亮)。它便宜,但在掠射角偏差较大。

0 0.25 0.5 0.75 1 0 15 30 45 60 75 90 入射(或观察)方向与法线的夹角 θ(度) G₁ 遮蔽比例 α = 0.1 α = 0.3 α = 0.6 α = 1.0 Schlick-GGX 近似(α = 0.6)
图 9-10GGX 的 Smith G₁ 随角度的变化。α 小时,只有非常贴近地平线才开始遮蔽;α = 1 时,45° 就已经损失约 17%。橙色虚线是 α = 0.6 时的 Schlick-GGX 近似,在大角度处略偏低。

案例 9.3看见 G:遮蔽主要发生在哪里入门单独打开 ↗

目标:用案例 9.1 的 Shader 输出 G 项(_Mode = 1,高度相关形式),观察它随粗糙度的变化;再对比分离形式与高度相关形式的差别。

图 9-11只输出高度相关 Smith G(0 黑 ~ 1 白),感知粗糙度 0.1 → 0.9。右下方的黑色月牙是 n·l ≤ 0、背光的区域。粗糙度越大,越靠近轮廓和明暗交界线的地方越暗。

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

图 9-12粗糙度 1.0、光从右侧射来。左:分离形式 G₁(l)·G₁(v);中:高度相关形式;右:两者之差放大 10 倍。差别集中在球的边缘(视线掠射)和明暗交界线(光线掠射),中心几乎为零。

两种形式的绝对差别不大(右图放大了 10 倍才看清),但它分布在边缘,恰好是高光最容易“闹事”的地方。HDRP、Filament 等较新的实现都使用高度相关形式;第 11 章手写 PBR 时我们也用它。

常见坑写 Smith G 时,最容易犯的错是把 α 和感知粗糙度搞混:Schlick-GGX 的 k 有好几个版本,有的以感知粗糙度为输入,有的以 α 为输入,照抄公式前要确认文献的约定。另一个坑是把 G 和 Cook-Torrance 分母重复计算——如果用的是 V 形式(已经除过 4(𝐧·𝐥)(𝐧·𝐯)),就不要再除一次。

9.8 菲涅尔 F

站在湖边,往脚下看能看到水底,往远处看水面却像镜子。这就是菲涅尔(Fresnel)效应:光在两种介质的交界处,一部分反射、一部分折射,反射的比例随入射角变大而增加,掠射时趋近 100%。这个比例由 Fresnel 方程精确给出,取决于两种介质的折射率和光的偏振;对于非偏振光、从空气(n≈1)射入折射率为 n 的电介质,垂直入射时的反射率是:

F0=(n−1n+1)2

代入几个熟悉的值:水 n=1.33,F0≈0.02;常见塑料、玻璃 n≈1.5,F0=0.04;钻石 n=2.42,F0≈0.17。绝大多数日常非金属的 F0 落在 0.02~0.05 之间——这就是 URP 把电介质统一取 0.04 的依据。

金属不同。金属的折射率是复数,自由电子会强烈地反射光,并迅速吸收折射进去的那部分。金属的 F0 通常很高(0.5 以上),而且随波长变化,所以金和铜的反射带有颜色,银和铝则接近无色。

电介质:F0 由折射率算出,灰色且很暗 水 n=1.33 F0≈0.020 塑料 / 玻璃 n=1.5 F0≈0.040 石英 n=1.54 F0≈0.045 红宝石 n=1.77 F0≈0.077 钻石 n=2.42 F0≈0.172 金属:F0 高且常带颜色(常见资料中的近似线性值,仅示意) 金 (1.00, 0.71, 0.29) 银 (0.95, 0.93, 0.88) 铜 (0.95, 0.64, 0.54) 铝 (0.91, 0.92, 0.92) 铁 (0.56, 0.57, 0.58)
图 9-13F0 的直观对比(色块按线性值换算成 sRGB 显示)。电介质的 F0 由折射率算出,都很暗且是灰色;金属的 F0 高并带颜色,数值为常见 PBR 资料中的近似线性值,仅用于示意,第 13 章会讨论如何取值。

Schlick 近似

完整的 Fresnel 方程太贵,Schlick 在 1994 年给出了一个只需要 F0 的近似:

F(𝐯,𝐡)=F0+(1−F0)(1−(𝐯·𝐡))5

对电介质,它和精确解贴合得很好;对金属,它也能抓住“掠射时变白”这一主要特征(精确的金属菲涅尔在接近掠射时会先略微下降再上升,Schlick 表现不出这个凹陷,第 12 章介绍的 F82 等扩展模型就是为此而生)。代码:

Assets/Cookbook/Ch09/BRDFLib.hlsl(节选:fresnel)
// ---------- 菲涅尔 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);
}
// ---------- 菲涅尔 F ----------
// Schlick 近似:F = F0 + (1 - F0)(1 - cosθ)⁵
vec3 C9_F_Schlick(vec3 F0, float cosTheta)
{
    float x = 1.0 - saturate(cosTheta);
    float x2 = x * x;
    return F0 + (1.0 - F0) * (x2 * x2 * x);
}
0 0.2 0.4 0.6 0.8 1 0 15 30 45 60 75 90 入射角 θ(度) 反射比 F 精确菲涅尔 n = 1.5(玻璃/塑料) Schlick 近似 F0 = 0.04 精确菲涅尔 n = 1.33(水) Schlick,F0 = 0.71(金的 G 通道,示意)
图 9-14精确 Fresnel 方程与 Schlick 近似。前 60° 反射率几乎不变,之后急剧上升,90° 时都趋于 1。虚线与实线在电介质上几乎重合。

用哪个角度:v·h 还是 n·v?

在微表面 BRDF 里,菲涅尔作用于微面元,所以角度是光线与微面元法线 𝐡 的夹角,即 𝐯·𝐡(等于 𝐥·𝐡)。而在环境光(IBL)里没有单一的 𝐥,常用 𝐧·𝐯 来近似。这两种用法的视觉效果很不一样:一盏方向光、一个远处的相机,𝐡 在整个物体上几乎不变,F(𝐯·𝐡) 也就几乎是常数;而 F(𝐧·𝐯) 会在物体轮廓处变亮。我们平时说的“菲涅尔边缘光”,其实主要来自环境反射。

案例 9.4菲涅尔:F0 扫描与掠射高光入门单独打开 ↗

目标:先用 F(𝐧·𝐯) 看不同 F0 的“边缘变亮”;再用直接光的镜面项,看 F(𝐯·𝐡) 如何让逆光的高光变强。

图 9-15输出 F(n·v),从左到右 F0 = 0.02(水)、0.04(塑料)、0.17(钻石)、金、银。电介质中心很暗、边缘一圈白;金属整体就很亮,边缘也趋于白色。

第二组实验换一个角度。把案例 9.1 的 Shader 设为 Specular 模式(D·G·F/(4𝐧·𝐥𝐧·𝐯),再乘 πc𝐧·𝐥),材质是 F0 = 0.04 的光滑电介质地板。相机仰角从 70° 降到 12°,灯始终放在相机的镜面方向上,让高光正好落在画面中央:

Assets/Cookbook/Ch09/Editor/Ch09Shots.cs(节选:fresnelshot)
[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;
}
图 9-16同一块 F0 = 0.04 的地板,只改观察仰角:70°、35°、12°。高光中心处 v·h 分别约为 0.94、0.57、0.21,对应 Schlick 的 F 约为 0.040、0.054、0.33。最后一张就是“夕阳下的路面眩光”。

F 从 0.04 涨到 0.33,增大了 8 倍多;同时 V 项在掠射时也变大(分母里的 𝐧·𝐯 变小),D 的投影被拉长成竖条。三者叠加,就是开车时看到的那条刺眼的反光带。如果把 F 换成常数 0.04,第三张图会暗淡得多——这是 Blinn-Phong 永远做不出来的效果。

9.9 漫反射项与能量分配

光照到电介质表面,菲涅尔决定了有比例 F 的能量在界面上直接反射——这就是镜面项。剩下 1−F 的能量折射进材质内部,在里面被色素颗粒多次散射,一部分被吸收(决定了颜色),一部分从附近重新钻出来,方向近乎随机——这就是漫反射项。所以漫反射本质上是次表面散射在“出射点离入射点很近”时的极限。

F:镜面反射 漫反射 1 − F 折射进去,散射、部分吸收后钻出 电介质(塑料、木头、石头) ✕ 立即被吸收 F:强且带颜色 没有漫反射,颜色全在镜面反射里 金属
图 9-17能量在界面上的分配。电介质:菲涅尔反射(无色)+ 次表面散射形成的漫反射(带颜色);金属:折射光被自由电子迅速吸收,只剩带颜色的镜面反射。

由此得到两条对材质参数极其重要的推论:金属没有漫反射;电介质的高光是无色的(F0 是灰色),颜色几乎都在漫反射里。金属度工作流(9.11 节)就是把这两条写进了参数化方式。

Lambert 与 Disney 漫反射

最常用的漫反射模型仍然是 Lambert:fd=ρ/π。严格说,镜面项拿走了比例 F,漫反射应该只分到剩下的部分,常见写法是乘 (1−F(𝐯·𝐡)),或者更粗糙地乘一个与角度无关的 (1−F0)。不同引擎做法不一,URP 采用后一种(第 10 章细看)。

Disney 在 2012 年的报告中观察到,测量数据里粗糙表面在掠射时会变亮(逆反射),光滑表面在掠射时会变暗,于是提出了一个经验修正:

fd=ρπ(1+(FD90−1)(1−𝐧·𝐥)5)(1+(FD90−1)(1−𝐧·𝐯)5),FD90=0.5+2(𝐥·𝐡)2r

r 是感知粗糙度。当 FD90=1 时它退化为 Lambert。代码:

Assets/Cookbook/Ch09/BRDFLib.hlsl(节选:diffuse)
// ---------- 漫反射 ----------
// 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 区别最大的配置。

图 9-18顺光照射的白球,只输出漫反射项。左:Lambert;中:Disney,粗糙度≈0;右:Disney,粗糙度 1。光滑表面的边缘比 Lambert 更暗,粗糙表面的边缘更亮,整个球显得更“平”。

右图的效果你在现实中一定见过:满月看起来像一个均匀发亮的圆盘,而不是边缘变暗的球,因为月壤非常粗糙、具有逆反射特性。差别不算大,所以很多移动端管线(包括 URP Lit)只用 Lambert;HDRP 的 Lit 则使用 Disney 漫反射。

9.10 组合:完整的 BRDF 与白炉测试

把各项拼起来,本章使用的完整 BRDF 是(𝐜diff 为漫反射颜色):

f(𝐥,𝐯)=(1−F(𝐯·𝐡))𝐜diffπ+D(𝐡)G(𝐥,𝐯)F(𝐯·𝐡)4(𝐧·𝐥)(𝐧·𝐯)

案例 9.6镜面项与完整直接光的粗糙度扫描进阶单独打开 ↗

目标:看完整的 Cook-Torrance 镜面项和“漫反射 + 镜面”的最终结果怎样随粗糙度变化,体会能量守恒。

镜面模式的关键代码就是 Shader 片元函数里的这几行:

Assets/Cookbook/Ch09/BRDFTerms.shader(节选:frag)
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);
图 9-19上:只输出镜面项(F0 = 0.9 的“白金属”,Exp 显示);下:完整直接光照,深红色塑料(F0 = 0.04,Raw 显示)。感知粗糙度均为 0.1 → 0.9,只有一盏方向光,没有环境光。

上排展示了能量守恒的直观形态:高光从一个极亮的小点,逐渐摊开成覆盖半个球的柔光,越摊越暗。下排是“真实感”更强的结果:光滑塑料的高光极小、极亮(Raw 显示下已截断为纯白),粗糙塑料的高光几乎溶进了漫反射。注意下排暗部是纯黑的——我们只算了直接光,没有环境光。真实场景中,光滑物体的“质感”主要来自环境反射,而不是这一个小亮点,这也是为什么第 8 章的反射探针对 PBR 如此重要。

案例 9.7白炉测试:BRDF 到底守不守恒高阶单独打开 ↗

目标:直接把方向反照率 R(𝐯) 画在球上,检验 BRDF 的能量是否守恒。

原理:设想把物体放进一个处处亮度都是 Lenv 的“白炉”里。由渲染方程,出射辐射率是:

Lo(𝐯)=Lenv∫Ωf(𝐥,𝐯)(𝐧·𝐥)dωl=LenvR(𝐯)

如果 BRDF 既不吸收也不丢失能量(R=1),物体就会和背景完全融为一体,看不见;如果它比背景暗,说明丢失了能量;如果比背景亮,说明凭空“造”出了能量——后一种是 BRDF 的硬伤。白炉测试是 PBR 实现者最常用的自检手段之一。

实现:不用灯光,每个像素直接用蒙特卡洛方法估算 R(𝐯)。Lambert 在半球上均匀采样;GGX 镜面则按 D(𝐡)(𝐧·𝐡) 做重要性采样(Importance Sampling),把大部分样本集中在高光波瓣里。把概率密度 p(𝐥)=D(𝐧·𝐡)/(4𝐯·𝐡) 代入估计量 f(𝐧·𝐥)/p,D 被约掉,只剩 GF(𝐯·𝐡)/((𝐧·𝐯)(𝐧·𝐡))。样本序列用 Hammersley 低差异序列,每个像素都相同,画面没有噪点。

Assets/Cookbook/Ch09/WhiteFurnace.shader(节选:estimate)
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 的影响:

图 9-20白炉测试。左一:反照率为 1 的 Lambert,完全“消失”在背景里;左二到右:GGX 镜面(F = 1),感知粗糙度 0.2、0.6、1.0。越粗糙越暗,粗糙度 1 时正对相机的中心只剩约 30% 的能量。

这个结果揭示了单次散射微表面模型的一个固有缺陷:G 项把被遮挡的光直接“扔掉”了,但现实中那些光会在微面元之间再弹几次,最终大部分仍然射出表面。粗糙度越高,丢得越多。表现在画面上,就是粗糙金属偏暗、饱和度偏高。解决办法是多次散射能量补偿(如 Kulla-Conty 方法),第 11 章会介绍思路。也可以用这个测试去检查任何一个 BRDF 实现——第 10 章就用它来检验 URP 的高光近似。

提示白炉测试里球心(𝐧·𝐯=1)反而比边缘暗,这不是 bug:粗糙度 1 时,正对着看的方向反照率约为 1−ln2≈0.31,可以用 α=1 时 D=1/π 的特殊情况手算验证;掠射时,镜面波瓣被“推”向地平线方向,反而更多地被积分到。

9.11 金属度/粗糙度工作流

BRDF 需要的输入是:漫反射颜色 𝐜diff、镜面反射率 F0(RGB)、粗糙度 α。直接把这几个量暴露给美术,就是镜面/光泽度工作流(Specular/Glossiness):Albedo 贴图 + Specular 贴图(RGB 为 F0)+ Glossiness(光滑度)。它最灵活,但也最容易填出不物理的组合:比如漫反射和 F0 都很亮,总反射超过 1;或者电介质填了彩色 F0。

金属度工作流(Metallic/Roughness)利用 9.9 节的两条推论缩减了自由度:用一个 BaseColor 加一个 Metallic 标量,同时决定漫反射颜色和 F0:

F0=lerp(0.04, 𝐜base, m),𝐜diff=𝐜base(1−m)(1−0.04)
BaseColor Metallic m Smoothness s c_diff = Base·(1−m)·0.96金属 m = 1 时为 0 F0 = lerp(0.04, Base, m)电介质灰 0.04,金属取底色 r = 1 − s,α = r²感知粗糙度 → 公式粗糙度 BRDF漫反射 + D·G·F
图 9-21金属度工作流:三个直观参数换算出 BRDF 需要的漫反射颜色、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 验证两种工作流在“合法”参数下完全等价,并展示镜面工作流独有的自由度。

Assets/Cookbook/Ch09/Editor/Ch09Shots.cs(节选:workflow)
[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);
}
图 9-22左:金属度工作流,BaseColor 为金色、Metallic = 1;中:镜面工作流,Albedo 为黑、Specular 为同样的金色,两者逐像素一致;右:镜面工作流的“非物理”组合——红色漫反射加蓝色高光,金属度工作流无法表达。
常见坑用脚本 new Material(Shader.Find("Universal Render Pipeline/Lit")) 创建材质时,材质面板(ShaderGUI)不会运行,所以面板上切换工作流、勾选法线贴图时自动设置的关键字都不会被设置。要切到镜面工作流,必须自己 EnableKeyword("_SPECULAR_SETUP");用了法线贴图要开 _NORMALMAP。忘了这一步,参数填了也没用。

粗糙度:Smoothness、感知粗糙度与 α

同一个“粗糙度”在不同地方有三种写法,读源码时一定要分清:

  • Smoothness(光滑度)s:材质面板上的滑条,Unity 的习惯,1 表示镜面。
  • 感知粗糙度(perceptualRoughness)r=1−s:大多数 DCC 工具(Substance、Blender)里的 Roughness 就是它。
  • α(URP 源码里叫 roughness)α=r2:真正代入 GGX、Smith 公式的量。GGX 公式里实际出现的又是 α2,URP 叫它 roughness2。
α=r2=(1−s)2

为什么要平方?Burley 在 Disney 报告里指出,如果直接把滑条线性映射到 α,滑条的前半段(α 从 0 到 0.5)高光就已经很糊了,后半段几乎看不出变化。平方之后,滑条每走一步,人眼感受到的变化大致均匀,所以叫“感知”粗糙度。

0 0.25 0.5 0.75 1 0 0.2 0.4 0.6 0.8 1 感知粗糙度 perceptualRoughness = 1 − smoothness 数值 线性(参照) α = perceptualRoughness² α² (GGX 公式里真正出现的量)
图 9-23感知粗糙度到 α 与 α² 的映射。感知粗糙度 0.5 时,α 只有 0.25,α² 只有 0.0625——GGX 的峰值因此仍然很高。

在 half 精度下,α 太小会导致 α2 下溢成 0,GGX 变成除零。URP 在 InitializeBRDFData 里用 HALF_MIN_SQRT(2−7)和 HALF_MIN(2−14)分别给 α 和 α2 设了下限,自己写 Shader 时也要做类似的钳制。

9.12 线性空间、Gamma 与 HDR

前面所有公式都有一个默认前提:颜色值与物理能量成正比,可以相加、相乘。但图片文件和显示器并不是这样存储亮度的。

sRGB 编码

人眼对暗部的变化比亮部敏感。为了让 8 位的 256 个等级在“感知上”分布均匀,图片通常用 sRGB 曲线编码:存储值大致是线性亮度的 1/2.2 次方。于是一张 sRGB 贴图里的 0.5,对应的线性亮度只有约 0.214。

Clinear={Csrgb/12.92,Csrgb≤0.04045(Csrgb+0.0551.055)2.4,其他情况

在 Unity 的线性色彩空间(Project Settings › Player › Color Space = Linear)下,这一切是自动的:勾了 sRGB 的贴图采样时自动解码,材质 Color 属性在上传前转换成线性,最终写到帧缓冲时自动编码。Shader 里拿到的、算出的都是线性值。法线贴图、粗糙度、金属度、AO 这类“数据”贴图则必须取消 sRGB 勾选,否则会被错误地解码(第 13 章详细讨论贴图设置)。

0 0.25 0.5 0.75 1 0 0.2 0.4 0.6 0.8 1 输入值 输出值 线性(不编码) 编码 LinearToSRGB(写入屏幕) 解码 SRGBToLinear(读取贴图) 线性 0.214 ⇄ 编码 0.5
图 9-24sRGB 编码与解码曲线。读取标记为 sRGB 的贴图时,GPU 自动解码到线性;写入 sRGB 格式的渲染目标时,GPU 自动编码。中间所有光照都在线性数值上进行。

如果在 Gamma 空间做光照会怎样?两盏灯的叠加不再是能量相加,亮度过渡变硬,明暗交界线发黑,彩色光的混合发脏。下面的案例直接展示这个差别。

案例 9.9在 Gamma 空间算光照会怎样入门单独打开 ↗

目标:两盏彩色方向光照一个白球,对比线性空间与 Gamma 空间的光照结果。

做法:管线是线性的,我们“模拟”老式 Gamma 工作流:先把材质颜色和灯光颜色编码回 sRGB 数值,在编码后的数值上做 Lambert 乘加,最后再解码一次,以抵消管线最终写入 sRGB 目标时的编码,让屏幕显示的正是“在 Gamma 数值上算出来的结果”。

Assets/Cookbook/Ch09/GammaVsLinear.shader(节选:frag)
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);
}
图 9-25左:线性空间光照;右:Gamma 空间光照。两盏光(左橙右蓝)重叠的中间区域,线性空间里是自然的亮紫灰色;Gamma 空间里偏暗发脏,明暗交界线也更硬、更黑。

线性空间的结果之所以“对”,是因为光的叠加在物理上就是能量相加:50% 的橙光加 50% 的蓝光,总能量就是两者之和。Gamma 空间的数值不与能量成正比,加法失去了意义。这也是为什么“同一套美术资源,在 Gamma 项目里看起来对,搬到线性项目就偏亮偏灰”——当初在 Gamma 里调出来的参数本身就补偿了错误。新项目请一律使用线性空间。

移动端注意部分很老的 OpenGL ES 2.0 设备不支持 sRGB 读写,只能用 Gamma 空间。URP 14 仍保留了 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 编码:

光照计算线性 HDR HDR 后处理Bloom、景深… 色调映射[0, ∞) → [0, 1] sRGB 编码线性 → 感知均匀 显示器 PBR 只负责第一步算对;后面三步决定“算对的数”怎么被看见
图 9-26从 HDR 光照到屏幕像素的流程。URP 中,色调映射由 Volume 里的 Tonemapping 组件控制,sRGB 编码由最终写入的目标格式完成。

案例 9.10HDR 截断 vs 色调映射入门单独打开 ↗

目标:同一个强光场景(太阳强度 4.5、自发光强度 5),对比不做色调映射、Neutral、ACES 三种输出。

图 9-27左:不做色调映射,超过 1 的数值被直接截断,红球亮面、自发光方块成了没有层次的色块;中: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
E⟂(𝐧·𝐥)radiance = lightColor * (atten * NdotL)π 已按 Unity 约定吸收
LambertbrdfData.diffuse不含 1/π,已乘 (1 − 反射率)
D·V·FBRDF.hlsl:DirectBRDFSpecularGGX 的 D 精确,V·F 合并成一个近似
标准 GGX、Smith、Schlickcore BSDF.hlsl:D_GGX、V_SmithJointGGX、F_SchlickHDRP 用它们;URP Lit 不用
α = r²core CommonMaterial.hlsl:PerceptualRoughnessToRoughnessr = 1 − smoothness
金属度工作流InitializeBRDFData、kDielectricSpecF0 = lerp(0.04, albedo, metallic)
IBL 漫反射SampleSH / SampleLightmap预积分的辐照度
IBL 镜面GlossyEnvironmentReflection + EnvironmentBRDF预卷积 mip + 解析近似
色调映射后处理 Tonemapping Volume 组件第 27 章

几个常见误区

  • “PBR 就是用了 Metallic 和 Smoothness 贴图”。参数化方式只是入口。光照在 Gamma 空间、没有 HDR、环境反射缺失、贴图色彩空间设错,任何一项都会让“PBR 材质”在不同光照下表现不一致。
  • “BRDF 不能大于 1”。BRDF 的单位是 sr−1,光滑表面的镜面 BRDF 在峰值处可以是几千。不能大于 1 的是对半球积分后的方向反照率。
  • “菲涅尔就是边缘光”。边缘变亮只是环境反射里 F(𝐧·𝐯) 的表现;在直接光里,菲涅尔取决于 𝐯·𝐡,最明显的表现是逆光、掠射时的强烈高光。为了“好看”额外叠加一层菲涅尔边缘光,往往会把能量算重。
  • “粗糙度 0 最真实”。现实中几乎没有完全光滑的表面,而且粗糙度过低时 GGX 的峰值会在 half 精度下溢出、在像素级别产生闪烁的高光走样(Specular Aliasing)。材质的光滑度通常不宜填到 1。
  • “金属度填 0.5 就是半金属”。物理上没有半金属;0~1 之间的值只应出现在金属与非金属的过渡边界上,例如锈迹、磨损的油漆边缘。

本章小结

  • Shader 输出的是辐射率;𝐧·𝐥 来自投影面积,∫cosθdω=π 决定了 Lambert 的 1/π。
  • 渲染方程 = 自发光 + 半球积分(BRDF × 入射辐射率 × 余弦);实时渲染拆成“精确光源求和”与“预积分的环境光(IBL)”。
  • BRDF 的单位是 sr−1,可以大于 1;它应当非负、互易、能量守恒(方向反照率 ≤ 1)。
  • Cook-Torrance:fs=DGF/(4𝐧·𝐥𝐧·𝐯)。D 用 GGX(长尾),G 用 Smith(高度相关形式更准),F 用 Schlick。
  • Unity 约定 E⟂=π·lightColor,所以漫反射直接是 albedo,镜面项整体乘 π。
  • 金属无漫反射、电介质 F0≈0.04 且无色,这两条被金属度工作流写进了参数化;α=(1−smoothness)2。
  • 线性空间是 PBR 数学成立的前提;HDR 渲染目标与色调映射是 PBR 结果能被正确看见的前提。
  • 白炉测试能直接检验 BRDF 的能量;单次散射 GGX 在高粗糙度下会丢失大量能量。

练习

  1. 在 BRDFTerms 里给 G 增加一个“Schlick-GGX,k=(r+1)2/8”的选项,与 k=α/2 版本并排拍图,说明前者为什么让粗糙物体的高光变暗。
  2. 修改 WhiteFurnace,在 GGX 模式下把 F 换成 Schlick(F0 = 0.04),并加上 (1−F) 加权的 Lambert 漫反射(反照率 1)。白炉里的球还会“消失”吗?哪里会偏亮或偏暗?
  3. 手算:感知粗糙度 0.2 的 GGX,在 𝐡=𝐧 处 D 是多少?如果这个值存进 half(最大约 65504)再乘以灯光强度 10、F0 = 1,会不会溢出?
  4. 用 GammaVsLinear 的思路,再做一张“在 Gamma 空间做 Alpha 混合”的对比图:一块 50% 透明的白色面片盖在黑白棋盘上,两种空间里的结果有什么不同?
  5. 查阅你常用的 DCC 工具(如 Substance Painter)导出的 Roughness 贴图,与 URP Lit 的 Smoothness 之间要做什么转换?需要平方吗?