PBR 这套东西,刚接触的时候很容易被一堆名词绕晕:BRDF、微表面、能量守恒、金属度、粗糙度、菲涅尔……每个词单拎出来都能写一篇论文。但真到项目里落地,尤其是在 URP 管线下自己写 Shader,你会发现核心问题其实就一个:怎么让材质在不同光照环境下看起来“对”。这篇笔记不打算复述教科书,而是把我自己在 URP 里从零搭一套 PBR 光照模型的过程拆开讲,包括公式怎么来的、代码怎么写、参数怎么调、哪些地方容易翻车。如果你正在用 Unity 做写实向渲染,或者想搞明白 URP 的 Lit Shader 背后到底干了什么,这篇内容应该能帮你少走一些弯路。
1. 为什么要在 URP 里手写 PBR 而不是直接用 Lit
1.1 内置 Lit Shader 的能力边界
URP 自带的 Lit Shader 其实已经覆盖了绝大多数常规需求:金属度工作流、光滑度贴图、法线贴图、阴影、环境光、额外的顶点光和附加光都支持。对于大部分项目来说,直接用它就够了,没必要重复造轮子。但问题在于,一旦你的项目有特殊需求,比如自定义光照模型、非标准材质表现、卡通和写实混合、或者需要接入自己的全局光照方案,Lit Shader 就开始显得不够用了。
我遇到过的典型场景是:项目需要一种“半写实”的材质,金属部分要遵循 PBR 的能量守恒,但非金属部分要带一点风格化的边缘光,而且这个边缘光要受主光源方向影响。这种需求用 Lit Shader 加自定义 Pass 也能凑出来,但代码会变得很脏,维护成本高。另一个场景是性能优化,Lit Shader 里有些分支在移动端是浪费的,手写一套精简版反而更快。
所以手写 PBR 的价值不在于“替代 Lit”,而在于“理解 Lit 并在此基础上做减法或加法”。你得先知道标准 PBR 是怎么算的,才能判断哪些能砍、哪些不能砍。
1.2 URP 管线的光照数据传递链路
在动手写代码之前,必须搞清楚 URP 是怎么把光照信息传给 Shader 的。这和内置管线差别很大,很多人从 Built-in 转过来会在这里卡住。
URP 里主光源的信息通过_MainLightPosition和_MainLightColor这两个全局变量传递,注意_MainLightPosition存的是方向,而且它的 w 分量有特殊含义:如果 w 为 0,表示这是平行光,xyz 就是光线方向;如果 w 不为 0,表示这是点光或聚光,xyz 是位置。这个细节如果不注意,写出来的光照在点光源下会直接错掉。
附加光(Additional Lights)在 URP 里是通过_AdditionalLightsPosition、_AdditionalLightsColor、_AdditionalLightsAttenuation这几个数组传递的,每个光源一个元素。衰减信息被压缩在_AdditionalLightsAttenuation里,需要解码才能用。这些数据在Lighting.hlsl里都有现成的函数可以调用,比如GetMainLight()和GetAdditionalLight(),我强烈建议直接用这些封装好的函数,不要自己去拼全局变量,容易出错。
还有一个容易忽略的点:URP 的阴影坐标计算和内置管线不同,需要用TransformWorldToShadowCoord()来转换,而且主光源阴影和附加光阴影的处理方式也不一样。这些在Shadows.hlsl里都有对应函数。
1.3 手写 PBR 的收益与代价
收益方面,最直接的是可控性。你可以精确控制每一个计算步骤,知道每一行代码在干什么,出问题的时候能快速定位。其次是性能,去掉不需要的分支后,移动端能省下不少 ALU 开销。第三是扩展性,想加什么效果直接往光照模型里塞,不用和 Lit Shader 的框架较劲。
代价也很明显:你得自己处理阴影、环境光、光照探针、反射探针这些原本 Lit Shader 帮你搞定的东西。尤其是环境光部分,URP 的SH(球谐)和Lightmap采样逻辑如果不熟,很容易做出“光照方向对了但环境光全黑”或者“环境光过曝”的效果。我的建议是,第一版先只做直接光照,把 BRDF 调对了,再逐步加环境光和阴影,不要一上来就全都要。
2. BRDF 的拆解:从物理公式到可执行代码
2.1 渲染方程到底在算什么
PBR 的理论基础是渲染方程,但那个积分形式对写代码没什么直接帮助。真正有用的是它的简化版本:对于每个光源,出射辐射度等于入射辐射度乘以 BRDF 再乘以入射角余弦。写成代码就是:
Lo = Li * BRDF * NdotL其中Li是光源颜色乘以强度,NdotL是法线和光线方向的点积。BRDF 本身又拆成漫反射项和镜面反射项两部分。这个结构就是所有 PBR Shader 的骨架,剩下的工作就是把漫反射项和镜面反射项分别算出来。
这里有个概念要澄清:BRDF 描述的是“给定入射方向,有多少光从某个出射方向反射出去”。但在实时渲染里,我们通常只关心出射方向等于视线方向的那一部分,也就是“从眼睛看过去,这个点反射了多少光”。所以实际计算的是 BRDF 在视线方向上的值,而不是完整的 BRDF 函数。
2.2 漫反射项:Lambert 和它的能量守恒修正
最基础的漫反射是 Lambert 模型,公式简单到不能再简单:
diffuse = albedo / PI那个除以 PI 就是能量守恒的体现。如果不除 PI,漫反射会偏亮,而且和镜面反射叠加后总能量会超过入射能量,看起来就不对。很多人手写 PBR 时漫反射偏亮,八成就是忘了这个 PI。
但 Lambert 模型有个问题:它在掠射角(光线几乎平行于表面)时表现不自然,边缘会显得过暗。所以有些实现会用 Disney 的漫反射模型或者 Hammon 的模型来做修正。不过在 URP 的常规项目里,Lambert 加一个菲涅尔补偿通常就够了,没必要上太复杂的模型。
漫反射的 albedo 就是基础颜色贴图采样出来的值。注意金属度工作流下,金属的漫反射应该是零,因为金属没有漫反射。所以代码里通常要乘一个(1 - metallic)。
2.3 镜面反射项:D、G、F 三项的物理含义
镜面反射是 PBR 里最复杂的部分,标准做法是 Cook-Torrance 模型,拆成 D、G、F 三项:
- D 项(法线分布函数):描述微表面法线的分布情况。粗糙度越低,微表面法线越集中,高光越锐利。常用的是 GGX 分布。
- G 项(几何遮蔽函数):描述微表面之间的自遮挡。光线打到一个微表面上,可能被旁边的微表面挡住,这部分能量就损失了。常用的是 Smith 模型配合 Schlick-GGX。
- F 项(菲涅尔项):描述不同角度下的反射率。垂直看表面时反射率低,掠射角时反射率高。常用的是 Schlick 近似。
这三项乘起来再除以一个归一化因子,就是完整的镜面 BRDF:
specular = (D * G * F) / (4 * NdotL * NdotV)分母那个4 * NdotL * NdotV也是能量守恒的要求,不能省。
2.4 把公式翻译成 HLSL 函数
理论说完了,直接上代码。下面是我在 URP 里用的镜面反射计算函数,每一项都拆开写了,方便对照:
// D项:GGX法线分布 float D_GGX(float NdotH, float roughness) { float a = roughness * roughness; float a2 = a * a; float d = NdotH * NdotH * (a2 - 1.0) + 1.0; return a2 / (PI * d * d); } // G项:Schlick-GGX几何遮蔽 float G_SchlickGGX(float NdotV, float roughness) { float r = roughness + 1.0; float k = (r * r) / 8.0; return NdotV / (NdotV * (1.0 - k) + k); } float G_Smith(float NdotV, float NdotL, float roughness) { return G_SchlickGGX(NdotV, roughness) * G_SchlickGGX(NdotL, roughness); } // F项:Schlick菲涅尔近似 float3 F_Schlick(float VdotH, float3 F0) { return F0 + (1.0 - F0) * pow(1.0 - VdotH, 5.0); } // 完整镜面BRDF float3 SpecularBRDF(float NdotL, float NdotV, float NdotH, float VdotH, float roughness, float3 F0) { float D = D_GGX(NdotH, roughness); float G = G_Smith(NdotV, NdotL, roughness); float3 F = F_Schlick(VdotH, F0); return (D * G * F) / (4.0 * NdotL * NdotV + 0.001); }注意分母加了个0.001防止除零,这是实战里必须加的,不然在光线和视线都平行于表面时会出现 NaN,表现为屏幕上出现黑块或者白块。
F0的计算也要注意:非金属的 F0 大约是 0.04,金属的 F0 就是 albedo 本身。所以:
float3 F0 = lerp(0.04, albedo, metallic);这个 lerp 是金属度工作流的核心,写错了金属看起来就不像金属。
3. URP 下的光照循环与阴影接入
3.1 主光源和附加光的统一处理
URP 里主光源和附加光的处理逻辑不一样,但为了代码整洁,最好写一个统一的光照计算函数,把光源方向、颜色、衰减都作为参数传进去。这样主光源和附加光可以共用同一套 BRDF 代码。
主光源用GetMainLight()获取,附加光用GetAdditionalLight()循环获取。注意附加光的数量由_AdditionalLightsCount控制,而且 URP 对附加光有 per-object 限制,默认是 4 个,可以在管线设置里改。
Light mainLight = GetMainLight(shadowCoord); float3 color = LightingPBR(normal, viewDir, albedo, metallic, roughness, mainLight.direction, mainLight.color, mainLight.shadowAttenuation); uint count = GetAdditionalLightsCount(); for (uint i = 0; i < count; i++) { Light light = GetAdditionalLight(i, positionWS, shadowCoord); color += LightingPBR(normal, viewDir, albedo, metallic, roughness, light.direction, light.color, light.distanceAttenuation * light.shadowAttenuation); }这里shadowAttenuation和distanceAttenuation要分开处理,主光源只有阴影衰减,附加光两者都有。乘在一起才是最终的光照贡献。
3.2 阴影坐标的正确计算方式
阴影这块是 URP 手写 Shader 最容易翻车的地方。主光源的阴影坐标要用TransformWorldToShadowCoord()转换,而且这个函数在Shadows.hlsl里,需要 include 对应的头文件。
float4 shadowCoord = TransformWorldToShadowCoord(positionWS);注意这个转换要在顶点着色器里做还是片元着色器里做,会影响精度。我的经验是,如果阴影贴图分辨率不高,在顶点着色器里算会导致阴影边缘出现明显的锯齿,所以建议在片元着色器里算。但这样会增加一些开销,移动端要权衡。
附加光的阴影更麻烦,需要用GetAdditionalLight()返回的shadowAttenuation,而且附加光阴影默认是关闭的,要在管线设置里开启。开启后性能开销会明显增加,移动端慎用。
3.3 环境光:SH 和 Lightmap 的取舍
直接光照做完之后,如果没有环境光,暗部会全黑,看起来很不自然。URP 里环境光有两个来源:球谐(SH)和 Lightmap。
SH 适合动态物体,采样函数是SampleSH(normal),返回的是环境光颜色。Lightmap 适合静态物体,需要用SAMPLE_GI()宏来采样,这个宏会自动处理 SH 和 Lightmap 的混合。
float3 ambient = SampleSH(normal);但 SH 采样出来的环境光是没有方向性的,暗部会显得很平。如果想要更好的效果,可以用SampleSH加上一个基于法线的方向性补偿,或者直接用反射探针。不过这些都属于进阶优化,第一版先用SampleSH就够了。
环境光还要乘上 albedo 和 AO,AO 通常来自贴图的 R 通道或者单独的 AO 贴图。注意 AO 只影响环境光,不影响直接光照,这是物理正确的做法。
4. 参数调试与常见视觉问题排查
4.1 粗糙度贴图的通道选择与转换
粗糙度贴图通常存在金属度贴图的 alpha 通道或者单独一张贴图的某个通道里。URP 的 Lit Shader 默认是从金属度贴图的 alpha 通道读光滑度(Smoothness),然后roughness = 1 - smoothness。这个转换关系很容易搞反,搞反了之后材质要么全镜面要么全哑光。
我自己的习惯是直接用粗糙度而不是光滑度,因为粗糙度在公式里用得更直接。如果贴图给的是光滑度,记得在采样后做一次1 - x。
还有一个坑:粗糙度贴图的 sRGB 设置。粗糙度是线性数据,贴图导入时要取消 sRGB 勾选,否则采样出来的值会偏小,材质看起来比预期更光滑。这个问题很隐蔽,因为视觉上只是“稍微亮了一点”,不容易发现。
4.2 金属度工作流下 F0 的常见错误
前面提到F0 = lerp(0.04, albedo, metallic),这个公式看起来简单,但实际用的时候有几个坑。
第一个坑是 metallic 贴图的通道。URP 默认从金属度贴图的 R 通道读金属度,但有些美术给的贴图是存在 B 通道或者 alpha 通道的,需要手动改采样通道。如果通道搞错了,金属部分会变成非金属,看起来像塑料。
第二个坑是 metallic 的值没有做 clamp。理论上 metallic 应该在 0 到 1 之间,但贴图压缩或者美术手绘时可能超出这个范围。超出后 F0 会变成负数或者大于 albedo,导致高光异常。所以采样后最好加一个saturate。
第三个坑是 albedo 的 sRGB 转换。albedo 贴图是 sRGB 空间,采样后 Unity 会自动转换到线性空间,但如果你在代码里手动采样或者用了SAMPLE_TEXTURE2D而不是SAMPLE_TEXTURE2D_LOD,可能会绕过这个转换。这个坑在 URP 里比较少见,因为 URP 的宏都处理好了,但自己写采样逻辑时要注意。
4.3 高光过曝和暗部死黑的排查思路
高光过曝通常有几个原因:一是 F0 算错了,金属度太高或者 albedo 太亮;二是粗糙度太低,高光过于集中;三是光源强度太高,URP 的_MainLightColor已经包含了强度,如果场景里光源强度设得很大,高光就会爆。
排查的时候可以先把光源强度降到 1,看高光是否正常。如果正常,那就是光源强度问题;如果还是爆,那就是 BRDF 参数问题。可以临时把 F0 固定成 0.04,看高光是否恢复正常,以此判断是不是金属度的问题。
暗部死黑通常是环境光没接上。先检查SampleSH是否返回了非零值,如果返回零,说明场景的 Environment Lighting 设置里 Source 选的是 Color 而且颜色是黑的,改成 Skybox 或者 Gradient 就好了。如果SampleSH正常但暗部还是黑,那可能是 AO 贴图全黑,或者 AO 乘在了直接光照上。
还有一个隐蔽的问题:如果用了 Lightmap,但物体的 Lightmap UV 没设置好,采样出来的 GI 可能是零。这种情况在场景烘焙后特别常见,检查方法是看物体的 Lighting 面板里 Lightmap 是否有值。
5. 性能优化与移动端适配
5.1 分支裁剪:哪些计算可以省
PBR 的完整计算量不小,移动端上要适当做减法。可以省的包括:
- 如果场景里没有金属材质,F0 可以直接用 0.04,省掉 lerp。
- 如果不需要环境光,
SampleSH可以省掉,但暗部会黑。 - 如果粗糙度是常量,D 项和 G 项可以预计算一部分。
- 如果光源数量固定,附加光循环可以展开。
但要注意,省掉的计算不能影响视觉正确性。比如省掉 G 项后,掠射角的高光会偏亮,这个在金属材质上很明显,非金属上不太看得出来。所以裁剪要根据项目实际材质分布来决定。
5.2 半精度浮点的使用边界
移动端 GPU 对半精度(half)浮点有优化,但半精度的精度范围有限,用错了会导致视觉瑕疵。
适合用 half 的:颜色、法线、粗糙度、金属度、NdotL、NdotV 这些值域在 0 到 1 之间的量。
不适合用 half 的:世界坐标、视线方向、光源方向这些值域可能很大的量。尤其是世界坐标,半精度下精度不够,会导致远处物体的光照出现块状瑕疵。
URP 里可以用half和float显式声明精度,也可以用real让管线自动决定。我的建议是关键计算用float,颜色和中间结果用half,这样在精度和性能之间比较平衡。
5.3 移动端阴影和附加光的取舍
移动端上阴影和附加光是两大性能杀手。阴影贴图的分辨率、级联数量、附加光数量都直接影响帧率。
我的经验是,移动端主光源阴影用 1024 或 2048 分辨率,级联用 2 级就够了。附加光最多开 2 个,而且只对重要物体开启。如果项目对性能要求极高,可以完全关掉附加光阴影,只保留主光源阴影。
还有一个技巧:把附加光的衰减计算简化。URP 默认的衰减计算包含距离衰减和角度衰减,如果项目里附加光都是点光,可以省掉角度衰减。这个优化在光源多的时候效果明显。
6. 从直接光照到间接光照的扩展思路
6.1 反射探针的接入方式
直接光照做完后,金属材质如果没有反射,看起来会很假。URP 里反射探针的采样需要用SAMPLE_TEXTURECUBE_LOD或者GlossyEnvironmentReflection函数。
float3 reflection = GlossyEnvironmentReflection(reflectDir, perceptualRoughness, 1.0);注意粗糙度会影响采样的 mip 级别,粗糙度越高,采样的 mip 越低(越模糊)。这个映射关系 URP 已经封装好了,直接用就行。
反射探针的烘焙和更新策略也很重要。静态物体用烘焙的反射探针,动态物体用实时反射探针,但实时反射探针开销大,移动端要慎用。
6.2 屏幕空间反射的可行性评估
屏幕空间反射(SSR)能提供更准确的反射,但实现复杂,性能开销也大。URP 本身不包含 SSR,需要自己写或者用第三方方案。
自己写 SSR 的核心步骤是:用深度图重建世界坐标,用法线判断反射方向,然后沿着反射方向在屏幕空间做射线步进,采样颜色缓冲。难点在于处理射线超出屏幕的情况和边缘的噪声。
我的建议是,如果项目不是特别需要精确反射,用反射探针就够了。SSR 更适合 PC 端或者主机端的高画质项目,移动端基本不用考虑。
6.3 光照探针和 Lightmap 的混合策略
动态物体用光照探针,静态物体用 Lightmap,这是常规做法。但两者混合时会出现接缝,尤其是动态物体在静态物体旁边时,光照不一致很明显。
解决方法是让动态物体也采样 Lightmap 的 GI,或者让静态物体也采样光照探针。URP 里可以用SAMPLE_GI宏同时处理两种情况,但需要正确设置物体的 Static 标记和 Lightmap UV。
还有一个细节:光照探针的采样需要用SampleProbeVolume或者SampleSH,前者更精确但开销更大。如果场景里光照探针密度不高,用SampleSH就够了。
7. 一些踩过的坑和调试技巧
7.1 法线贴图空间搞反导致的光照错误
法线贴图有切线空间和物体空间两种,URP 默认用切线空间。如果贴图是物体空间的,直接采样会导致光照方向完全错乱。
判断方法是:如果光照看起来“跟着物体转”而不是“跟着世界转”,那可能是法线空间搞反了。解决方法是把贴图导入设置里的 Texture Type 改成 Normal Map,并确认 Create from Grayscale 没有勾选。
还有一个坑是法线贴图的强度。强度太高会导致法线过度扰动,光照出现噪点;强度太低则法线效果不明显。一般 0.5 到 1.0 之间比较合适,具体看贴图质量。
7.2 顶点着色器和片元着色器的插值精度问题
URP 里顶点到片元的插值默认是linear,但如果顶点着色器里算了世界坐标,插值到片元后精度可能不够,尤其是大三角形上。
解决方法是用noperspective修饰符,或者把世界坐标的计算放到片元着色器里。后者更常用,虽然多了一次矩阵乘法,但精度有保证。
还有一个相关问题是法线的插值。如果模型法线没有归一化,插值后法线长度会小于 1,导致光照偏暗。所以片元着色器里采样法线后要normalize一次。
7.3 用 RenderDoc 抓帧定位光照异常
当光照结果和预期不符时,光看代码很难定位。这时候用 RenderDoc 抓一帧,看每个像素的中间结果最直接。
具体做法是:在 Shader 里把中间变量输出到 RenderTarget 的某个通道,比如把 NdotL 输出到 R 通道,把粗糙度输出到 G 通道,然后在 RenderDoc 里查看这些通道的值。这样能快速判断是哪个环节出了问题。
RenderDoc 还能看纹理采样的结果,如果怀疑贴图通道搞错了,直接看采样出来的值就行。这个技巧在排查金属度、粗糙度、AO 这些问题时特别有用。
7.4 移动端真机调试的注意事项
编辑器里跑得好好的 Shader,到真机上可能完全不一样。常见原因包括:精度差异、纹理压缩格式差异、GPU 架构差异。
精度差异最典型:编辑器里用 float 算没问题,真机上如果被优化成 half,就会出现块状瑕疵。解决方法是在关键计算上显式用 float,或者用precision修饰符强制精度。
纹理压缩格式差异也会导致视觉变化,尤其是法线贴图和粗糙度贴图。移动端常用的 ASTC 压缩对法线的精度影响较大,如果发现真机上法线效果变差,可以试试提高压缩质量或者改用未压缩格式。
真机调试最好用 Unity 的 Frame Debugger 或者平台自带的 GPU 调试工具,能直接看到每个 Draw Call 的状态和 Shader 的中间结果。这个比在编辑器里猜要高效得多。
8. 关于 PBR 学习路径的一点个人体会
PBR 这东西,看再多公式不如自己写一遍。我最初学的时候,把 Cook-Torrance 的论文翻来覆去看了好几遍,感觉都懂了,结果一写代码全是问题。后来逼着自己从零实现了一遍,才发现很多细节是论文里不会写的,比如除零保护、精度选择、通道顺序这些。
另一个体会是,不要一上来就追求物理正确。实时渲染里到处都是近似,能把视觉上“对”做出来就已经很好了。比如环境光,物理正确的做法是做积分,但实时里用 SH 近似就够了,没人会盯着暗部去算积分。
最后,调试工具比代码本身更重要。RenderDoc、Frame Debugger、真机 GPU 调试工具,这些能帮你快速定位问题。我见过很多人卡在一个光照 bug 上好几天,其实就是某个通道搞反了,用工具看一眼就能发现。所以学 PBR 的同时,把调试工具也用熟,效率会高很多。