如果你写过一个实时渲染器,八成遇到过这种尴尬:同一个金属球,换个环境就假得离谱,高光要么灰蒙蒙一片,要么亮成廉价塑料。我开始折腾渲染那会儿,以为多调几个参数就能解决,后来才明白,真正该换的不是参数,而是光照模型本身。这篇要聊的PBR(基于物理的渲染)、BRDF(双向反射分布函数)和Cook-Torrance模型,就是我把金属球从“塑料感”救回来的那套方案。PBR不是某一个公式,而是一整套让材质在不同光照下都能保持物理正确的思路;BRDF负责描述表面怎么反射光;Cook-Torrance则是这套思路里最常用的微表面模型。它适合正在写渲染器、搭材质系统,或者调Shader调到怀疑人生的朋友。顺带说一句,网上搜PBR有时会蹦出网络里的同名缩写,含义完全不同,这里聊的是图形学里的Physically Based Rendering。
1. 项目整体设计与思路拆解
1.1 PBR到底在解决什么问题
传统Blinn-Phong模型里,高光强度靠一个shininess硬撑,材质在不同光照角度下的表现非常不稳定。你调好一盏灯下的效果,换一盏灯、换一个环境,金属就变成蜡、塑料变成陶瓷,所有参数都要重新来一遍。PBR的出发点其实很朴素:把材质参数做成“物理属性”,而不是“针对某盏灯的特调数值”。这样同一个材质,放到白天、夜晚、室内、室外,表现都应该是统一且可信的。
要做到这一点,核心约束是能量守恒。传统模型常见的毛病是反射光能量超过入射光能量,导致画面过曝、发灰。PBR要求反射光加上折射散射光的总能量不超过入射光能量。金属几乎没有次表面散射,入射光绝大部分被反射;非金属则有漫反射和透射。这一条约束直接决定了材质参数的语义:金属度、粗糙度、基础色,而不是高光系数、环境光系数。这也是我做材质系统时第一次感受到“参数有了物理含义”后的爽快感——美术同学问“这个值调大调小是什么意思”,我能直接回答“这是表面有多粗糙”,而不是“你试试看”。
1.2 BRDF:表面如何与光交互的“合同”
BRDF全称Bidirectional Reflectance Distribution Function,双向反射分布函数。它回答的问题是:给定一束光从方向l打过来,从视角方向v看过去,这个表面上单位面积反射了多少光。它不是固定值,而是四个变量的函数:入射方向、出射方向、以及对应的两个球面角。
用生活类比理解:拿一张磨砂纸和一面镜子,同一束光打上去,镜子的反射光集中在一个方向,磨砂纸则把光散得到处都是。BRDF就是描述这种“反射分布”的数学工具,它只关心表面本身的性质,跟具体光源强度无关。这也是为什么PBR能把“材质”和“光源”解耦——BRDF描述的是材质,光源强度乘上去是最终辐照度。实际开发时,写BRDF函数并不会直接处理四个角度的完整张量,而是用半程向量h、法线n、入射方向l、视线方向v做点积运算,简化到实时渲染能接受的范围。
1.3 为什么最终选了Cook-Torrance模型
BRDF是一个统称,具体怎么算有一堆模型。早期有Lambert漫反射模型,它假设表面把光均匀散射到所有方向,适合纸张、墙面这类粗糙材质。但金属、车漆、塑料表面有清晰的镜面高光,Lambert完全不够用。后来有人提出Phong模型和Blinn-Phong模型,高光效果出来了,但参数和物理量没什么对应关系。Cook-Torrance模型是Torrance和Sparrow在1967年微表面理论基础上发展的,后来Cook和Torrance把它完善成计算机图形学可用的形式。
我选择Cook-Torrance,最关键的原因是它把镜面反射拆成了三个物理过程:法线分布、几何遮蔽、菲涅尔反射。每个过程都有明确物理含义,能独立调整、独立验证。相比之下Blinn-Phong只是用点积的幂次模拟高光带,物理上说不通。Cook-Torrance模型配合GGX法线分布,是当前实时渲染事实上的标准,Unreal、Unity、Three.js都在用这套底层逻辑。选它意味着资料多、踩坑记录多、后续接IBL也顺滑。
| 特性 | Blinn-Phong | Cook-Torrance (GGX) |
|---|---|---|
| 物理基础 | 经验模型 | 微表面理论 |
| 能量守恒 | 不保证 | 显式保证 |
| 参数含义 | shininess经验值 | roughness物理值 |
| 金属表现 | 塑料感强 | 高光色受F0影响 |
| 高光拖尾 | 均匀衰减 | 长拖尾更真实 |
| IBL扩展 | 不方便 | 可配合split-sum |
2. BRDF核心细节解析与实操要点
2.1 BRDF的数学定义与物理约束
正式定义里,BRDF是反射辐亮度与入射辐照度的比值,单位是1/sr(球面度分之一)。这个定义本身就包含了物理约束:因为辐亮度和辐照度的比值有方向性,所以BRDF天然要满足两个条件,一是Helmholtz互易性,也就是交换l和v,BRDF值不变;二是能量守恒,对半球所有出射方向积分,反射能量不能超过入射能量。
实时渲染里我们不会真的对半球积分,但能量守恒这条一定要落实在公式里。一个非常常见的问题是:在漫反射项和镜面反射项之间没有做能量分配,导致漫反射加镜面反射的总反射率超过1,场景亮部死白一片。我在第一个PBR场景里就犯过这个错,最后把漫反射系数kd和镜面反射系数ks加起来判断,才发现kd + ks > 1的情况到处都有。正确做法是在计算完菲涅尔项后,让漫反射系数乘上(1 - F),把反射不掉的能量留给漫反射。
2.2 漫反射项与镜面反射项的拆分
Cook-Torrance BRDF的完整形式是漫反射和镜面反射两部分的加权和:
f = kd * f_lambert + ks * f_cook_torrance其中f_lambert是Lambert漫反射项,公式是baseColor / PI。这里的PI来自半球积分,不是拍脑袋除的。方向光从某个方向打到表面,漫反射均匀散射到半球上,积分后会出现一个PI因子,不除的话能量会整体偏亮。
镜面反射项f_cook_torrance则是:
f_spec = D(h) * F(v, h) * G(l, v, h) / (4 * (n dot l) * (n dot v))D、F、G三个字母是这套模型的灵魂。实践中,我建议先把这三个项分开写成独立函数,分别调试。光照结果不对时,挨个把D、F、G输出成灰度图,能快速看出来是哪个项算崩了。不要一上来就合在一起调,不然出了问题根本不知道是粗糙度映射错了,还是菲涅尔参数写反了。
2.3 法线分布函数:GGX是默认答案
法线分布函数(NDF)描述的是微表面法线朝向半程向量h的概率密度分布。它直接决定高光的形状:是锐利小点,还是扩散大光斑。常用的有两个:
- Beckmann分布:数学上对应高斯分布,高光衰减均匀,但是拖尾太短,金属看起来不够“润”。
- GGX分布(Trowbridge-Reitz):高光中心更亮,拖尾更长,更贴近真实材质测量数据。这也是业界主流选择。
GGX公式如下:
D_GGX(h) = alpha^2 / (PI * ((n dot h)^2 * (alpha^2 - 1) + 1)^2)这里的alpha是粗糙度的映射值。UE4里直接把alpha = roughness * roughness,也就是对美术给出的粗糙度做了一次平方映射,用来让参数感知更线性。我在项目里也沿用这个做法,因为直接拿美术给的roughness作为alpha,高光会偏弱,过渡不自然。若你用的是roughness线性值,建议也做一次平方映射,效果差异非常明显。
实现时注意分母内部的(alpha^2 - 1),当alpha接近0时,这一项会让分母趋近(n dot h)^2,D值极大,金属高光会变成非常锐利的一个点,这是对的。但极小的roughness会产生极大的高光峰值,HDR场景里配合Bloom容易过曝,需要控制对比度。
2.4 几何遮蔽项与菲涅尔项:细节决定成败
几何遮蔽项G解决的是微表面互相遮挡的问题。当光线接近掠射角时,微观凹凸会挡住入射或反射光线,导致实际反射能量远低于NDF预测值。Smith模型把G拆成两个方向独立遮蔽的乘积:
G(l, v, h) = G1(l) * G1(v)每个方向用Schlick-GGX近似:
G1(x) = (n dot x) / ((n dot x) * (1 - k) + k)这里k的取值很关键。直接光照下k = alpha / 2,IBL环境下k = (alpha + 1)^2 / 8。很多人在切IBL后高光突然过亮或过暗,问题多半出在这里——他们没换k的计算方式。这个细节文档里不常写,但我实测下来,差别非常明显。
菲涅尔项F描述的是不同入射角下反射率的变化。掠射角时所有材质都趋向于全反射,这也是为什么从侧面看水面、玻璃会有刺眼反光。Schlick近似公式:
F = F0 + (1 - F0) * pow(1 - (v dot h), 5)其中F0是正入射时的反射率。非金属通常取vec3(0.04),金属直接用baseColor作为F0,因为金属的反射光带有自身颜色。代码实现里可以用mix(vec3(0.04), baseColor, metallic)一步完成。注意pow中的底数不要用n dot v,要用v dot h,并且对底数做clamp到0到1之间,否则会出现负数的幂导致闪点。
3. Cook-Torrance模型实现过程
3.1 搭建基础材质参数体系
我习惯把所有材质参数收敛到四个核心值,再加一个可选的环境光遮蔽:
baseColor(基础色):非金属时是漫反射颜色,金属时是菲涅尔反射颜色。metallic(金属度):0到1,0代表非金属,1代表金属。中间值很少见,但在描述脏旧表面时很有用。roughness(粗糙度):0到1,0代表镜面光滑,1代表粗糙。ao(环境光遮蔽):可选的遮蔽项,只在间接光部分生效。
这四个参数直接对应美术直觉,不需要对着数值猜。实现时我建议把baseColor预乘sRGB到线性的转换。很多人会忽略这一点,导致PBR材质在偏亮的HDR环境下整体偏暗或偏灰。线性空间是PBR的入场券,在Gamma空间直接套GGX公式,出来的高光形状和颜色都是错的。
Shader侧我通常这样组织参数结构:
struct PBRMaterial { vec3 baseColor; float metallic; float roughness; float ao; };如果你是纯代码生成材质,没有贴图,建议给一个默认值集合:baseColor = vec3(0.8)、metallic = 0.0、roughness = 0.5、ao = 1.0。这样至少能看到一个柔和的漫反射球体,而不是刺眼的白色高光。
3.2 光照计算主循环:方向光部分
先看最简单的情况:单方向光。这是所有PBR照明的“骨架”,后续加多个光源、加IBL,都是在这个函数上扩充。
vec3 calculateDirectLight(PBRMaterial mat, vec3 n, vec3 v) { vec3 lightDir = normalize(u_lightPosition); vec3 lightColor = u_lightColor; float lightIntensity = u_lightIntensity; vec3 h = normalize(v + lightDir); float NdotL = max(dot(n, lightDir), 0.0); float NdotV = max(dot(n, v), 0.001); float NdotH = max(dot(n, h), 0.0); float VdotH = max(dot(v, h), 0.0); // 漫反射 vec3 diffuse = mat.baseColor / PI; // 法线分布 GGX float alpha = mat.roughness * mat.roughness; float alpha2 = alpha * alpha; float D = alpha2 / (PI * pow(NdotH * NdotH * (alpha2 - 1.0) + 1.0, 2.0)); // 几何遮蔽 float k = alpha / 2.0; float G1L = NdotL / (NdotL * (1.0 - k) + k); float G1V = NdotV / (NdotV * (1.0 - k) + k); float G = G1L * G1V; // 菲涅尔 vec3 F0 = mix(vec3(0.04), mat.baseColor, mat.metallic); vec3 F = F0 + (1.0 - F0) * pow(1.0 - VdotH, 5.0); // 镜面BRDF vec3 specular = (D * F * G) / max(4.0 * NdotV * NdotL, 0.001); // 能量分配 vec3 kD = (1.0 - F) * (1.0 - mat.metallic); return (kD * diffuse + specular) * lightColor * lightIntensity * NdotL; }这段代码有几个关键点。第一,NdotL被clamp到0,避免背光面出现负光照;第二,分母里的max(0.001)是标准保底操作,防止除零;第三,能量分配kD乘了(1 - metallic),金属没有漫反射;第四,F被用于分配漫反射能量,因为菲涅尔反射掉的能量不能进入漫反射。
我建议拿到代码后先测试三个极端参数:metallic = 0, roughness = 0做镜面非金属;metallic = 1, roughness = 0.3做金属;metallic = 0.5, roughness = 0.8做脏旧表面。三个测试球的表现应该分别是“塑料亮斑”“金属高光”“哑光混合”,如果三个看起来都像同一种材质,大概率是能量分配或Gamma空间出了问题。
3.3 参数选择与计算过程的底层逻辑
为什么kd要乘(1 - F)而不是别的系数?因为菲涅尔项已经告诉我们在当前观察角度下,有多少能量被镜面反射了,剩下的能量才可能进入漫反射。金属的F0是baseColor本身,反射率极高,漫反射能量自然趋近于零,所以kd乘(1 - metallic)后直接归零。这是PBR参数“物理正确”的核心体现,而不是人为凑出来的经验值。
粗糙度映射这里我再多说一句。美术给的roughness往往不是物理学意义上的微表面粗糙度,而是“感知粗糙度”。UE4把alpha = roughness * roughness,本质是把粗糙度感知范围拉升到更自然的区间。如果觉得高光太弱,不要急着调灯,先检查alpha映射是不是用了原始值。
F0 = vec3(0.04)这个常数也不是乱拍的。大多数非金属电介质的正入射反射率在4%左右。真实测量数据里,水约0.02,玻璃约0.04,塑料约0.03到0.05。统一取0.04在实时渲染里误差可以接受,省去每类材质单独查表的开销。
点光源、聚光灯的实现和方向光类似,只是要多算一个距离衰减。衰减公式我推荐:
attenuation = 1.0 / (1.0 + distance * distance)有些引擎用1.0 / (distance * distance),在近距离会数值爆炸,需要额外clamp。用加1的版本更稳。距离衰减不要直接乘在BRDF上,而是乘在光源辐射强度上,严格来说光照度随距离平方衰减,BRDF本身不关心光源距离。
3.4 单位、HDR与色调映射
PBR加完直接光以后,很多人发现画面要么过曝要么发灰,这时候八成是色彩空间和HDR问题。物理光照的数值范围是线性的,方向光亮度动辄几十,但普通显示器的亮度范围是0到1。如果不做色调映射直接输出,亮部直接裁成白色。
我的管线里有两个必做步骤:HDR渲染、然后应用色调映射。比如用ACES Filmic Tonemapping:
vec3 acesFilmic(vec3 x) { float a = 2.51; float b = 0.03; float c = 2.43; float d = 0.59; float e = 0.14; return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0); }如果场景偏暗,先检查光照强度是不是太弱;如果高光和暗部都发灰,再检查Gamma校正是否做对了。通常我推荐:最终颜色 = pow(acesFilmic(线性颜色), 1.0 / 2.2)。但要注意,pow到2.2的Gamma校正是在色调映射之后做,顺序反了会让画面颜色发闷。
4. 常见问题与排查技巧实录
4.1 材质整体变暗或者过曝
这是新手阶段最常踩的坑,甚至我自己在第一个PBR场景里也折腾了快两天。现象是:白色粗糙球体在单方向光下,亮部发灰,暗部死黑,怎么调灯都没有用。
排查顺序我固定如下:先检查NdotL是否被正确clamp到0以上;再检查分母4 * NdotV * NdotL是否用了max保底;然后检查F0是否在非金属时给了0.04;最后检查输出线性和Gamma。如果以上都没问题,就把kd和ks分别输出成图,看看哪个项吞掉了能量。经验上80%的过暗都是因为把baseColor当成了线性值,而它实际还是sRGB。
4.2 金属材质没有“金属感”
金属感的核心在菲涅尔项。非金属的F0是固定的灰白色0.04,金属的F0是baseColor。如果金属材质看起来像塑料,多半是metallic没进F0的mix逻辑,或者kd没有乘(1 - metallic)导致漫反射还在发光。金属不该有漫反射,看到金属表面泛着漫反射白光,那基本可以确定能量分配没做对。
还有一个容易被忽略的细节:金属对环境的反射依赖IBL。没有环境贴图时,金属表面只有光源高光,其他区域是黑的,看起来像“黑铁”。这不算BUG,是缺失了间接光。至少加一张简单的环境贴图,金属感才会出来。
4.3 粗糙度看起来不线性
调整roughness时,0到0.2之间变化剧烈,0.5到1.0之间几乎看不出区别,这是未做alpha映射的典型表现。加一行alpha = roughness * roughness能立刻改善。如果做完映射还是觉得高光不够紧凑,可以把高光的D项单独输出,看到的是一个峰值尖锐、拖尾长的亮点,那就是对的。如果看到均匀模糊的一团,检查NdotH是否用了max(0),或者alpha有没有被错误地再次平方。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 暗部死黑、过曝严重 | 能量不守恒,kd加ks超1 | 检查kD/(1-F)/(1-metallic) |
| 金属像塑料 | metallic未影响F0,或漫反射未归零 | 检查mix(F0, baseColor, metallic) |
| 高光边界锯齿严重 | 未做MSAA或分辨率不足 | 硬件抗锯齿或TAA |
| 环境光下高光异常亮 | IBL的G项k值用了直接光公式 | 切换k=(alpha+1)^2/8 |
| 粗糙度调节不明显 | 未做alpha平方映射 | alpha=roughness*roughness |
| 带纹理材质发灰 | sRGB纹理未转线性 | 采样后pow(color, 2.2) |
| 多光源时亮度翻倍 | 没有归一化光源功率 | 对每个光源单独计算并加权 |
| 掠射角高光漂移 | F项里用了n dot v而非v dot h | 改为计算VdotH |
4.5 从方向光扩展到IBL环境光
直接光搞定以后,离一个“能看的PBR场景”还差环境光。IBL的做法是把环境贴图预先卷积成漫反射辐照图和镜面反射预滤波图,再配合BRDF查找表(LUT)做split-sum近似。实时运行时,用当前法线采样漫反射图,用反射向量和粗糙度采样预滤波图,再加上LUT查出来的菲涅尔项衰减。
把IBL接进来后,我才深刻感受到Cook-Torrance模型的好处:同样的材质参数,方向光只控制高光,IBL负责填满金属的反射细节。二者共用的D、F、G计算方法完全一致,只是G项的k值要按IBL规则切换。实践顺序上,建议先实现IBL漫反射,再实现镜面预滤波,最后合并。每一步都能用Reflection Capture或者HDR全景图验证。
这套模型在项目里稳定跑起来之后,我最大的体会是:PBR不是“加一个高光公式”就完事,它是一整套从色彩空间、光照单位到材质语义的系统约束。初看觉得公式多,真正实现完一遍,再回头用Blinn-Phong反而觉得别别扭扭——参数没有物理含义,调起来全靠猜。我的建议是,如果你正卡在材质不真实的问题上,别急着堆贴图,先把手里的光照模型换成Cook-Torrance,配合GGX和能量守恒,材质表现会立刻上一个台阶。