☰
Unity URP PBR Shader从理论到实战:手写Cook-Torrance BRDF与性能优化
2026/9/30 4:59:41 网站建设 项目流程

1. 从零搞懂PBR:为什么它成了现代渲染的标配

1.1 先聊聊我为什么决定啃PBR这块硬骨头

最开始接触Unity的URP管线时,我和很多人一样,觉得把材质往上一挂、调调Metallic和Smoothness滑块就完事了。直到有次做一个小场景,金属球在暗部看起来像塑料,布料在强光下又像涂了油,我才意识到——不搞懂PBR背后的物理逻辑,调参就是瞎蒙。

PBR全称Physically Based Rendering,中文叫基于物理的渲染。它的核心思想一句话就能概括:用物理学的规律去描述光与物体表面的交互,而不是靠美术凭感觉去凑效果。这件事的意义在于,同一个材质在不同光照环境下都能表现一致,不会出现“在这个场景好看、换个场景就崩”的情况。

URP管线是Unity推出的轻量级可编程渲染管线,相比内置管线,它的Shader编写更规范、性能更可控,特别适合移动端和中等规模的PC项目。而PBR在URP中的实现,核心就是围绕BRDF(双向反射分布函数)展开的一整套光照计算模型。

这篇笔记适合谁看?如果你已经会用Unity搭场景、挂材质,但对URP的Shader编写还停留在“改改颜色”的阶段,想真正理解PBR的数学原理和工程实现,那接下来的内容应该能帮到你。我会从理论推导讲到Shader代码落地,把每个参数背后的“为什么”都掰开说清楚。

1.2 PBR的两条核心公式线索

PBR的数学基础是渲染方程,但实际工程中我们不会去解完整的积分方程,而是把它拆解成几个可计算的部分。在URP的PBR实现里,最核心的是反射率方程:

最终颜色 = ∫ (BRDF × 入射光辐射度 × 入射方向与法线夹角余弦) dω

这个积分在实际计算中会被离散化,变成对每个光源的累加。而BRDF本身又被拆成漫反射项和高光反射项两部分。

漫反射项通常用Lambert模型,简单说就是光线打到粗糙表面后均匀向各个方向散射。高光反射项则用Cook-Torrance模型,它包含三个关键函数:法线分布函数(D)、几何遮蔽函数(G)、菲涅尔函数(F)。这三个函数分别描述了微观表面法线的分布、微面元之间的遮挡、以及不同角度下的反射率变化。

我一开始看这些公式也头大,但后来发现,你不需要手推每一个积分,只需要理解每个函数在“控制什么视觉效果”就够了。D控制高光的大小和形状,G控制边缘的暗化程度,F控制反射的强度随角度变化。理解了这层,调参就有方向了。

1.3 URP中PBR的材质参数到底在调什么

URP的Lit Shader暴露了这几个核心参数:Base Map、Metallic、Smoothness、Normal Map、Occlusion、Emission。很多人调材质就是挨个滑块拖,但每个参数在BRDF里对应什么,心里没数。

Base Map提供的是反照率(Albedo),也就是漫反射的基础颜色。注意,这里的颜色应该是“去光照”的纯颜色,不应该包含阴影或高光信息。Metallic控制的是金属度,它决定了有多少光被反射、多少光被吸收后重新散射。金属的漫反射几乎为零,而非金属的漫反射很强。Smoothness控制的是粗糙度的倒数,粗糙度越低,高光越集中越亮。

Normal Map提供的是切线空间法线,用来模拟表面微观凹凸。Occlusion是环境光遮蔽,用来压暗暗部区域。Emission是自发光,不参与BRDF计算,直接叠加到最终颜色上。

我踩过的一个坑是:把Metallic调成0.5,以为能得到“半金属”效果。实际上在PBR里,金属度是一个二元属性——要么是金属,要么是非金属,中间值只适合做过渡区域的混合,不应该作为常规材质参数。这个认知纠正后,我的材质质感立刻上了一个台阶。

2. URP管线下的Shader结构拆解

2.1 URP Shader的文件组织方式

URP的Shader和内置管线的Shader在结构上有明显区别。内置管线用Surface Shader可以自动生成光照代码,但URP不支持Surface Shader,必须手写顶点和片元着色器。这意味着你需要自己处理光照计算、阴影采样、雾效等。

一个标准的URP PBR Shader通常包含这几个部分:属性块(Properties)、子着色器(SubShader)、Pass块、HLSL代码块。属性块定义暴露给美术的参数,SubShader里定义渲染状态和Pass,HLSL代码块里引入URP的核心库并实现光照计算。

我习惯把HLSL代码拆成几个.hlsl文件:一个放结构体定义,一个放工具函数,一个放光照计算。这样复用起来方便,改起来也不会牵一发动全身。URP自带的Lighting.hlsl和BRDF.hlsl是很好的参考,建议直接读源码,比看任何教程都管用。

2.2 顶点着色器里该做什么、不该做什么

顶点着色器的职责很明确:把顶点从模型空间变换到裁剪空间,同时计算一些插值数据传给片元着色器。在URP的PBR Shader里,顶点着色器通常要输出:裁剪空间位置、世界空间位置、世界空间法线、世界空间切线、UV坐标、顶点色(如果有)。

这里有个容易忽略的点:法线和切线必须变换到世界空间,因为光照计算是在世界空间进行的。变换法线不能用普通的模型变换矩阵,要用法线矩阵(模型变换矩阵的逆转置),否则非均匀缩放会导致法线方向错误。

我见过有人在顶点着色器里做光照计算,觉得这样性能好。但在PBR里,光照计算依赖逐像素的法线和视角方向,放在顶点着色器里会导致高光破碎,尤其是低模。所以除非是做特殊风格化效果,否则光照计算一定要放在片元着色器。

2.3 片元着色器中的BRDF计算流程

片元着色器是PBR的核心战场。整个计算流程大致是这样的:先准备好所有输入数据(法线、视角方向、光照方向、粗糙度、金属度、反照率),然后对每个光源计算BRDF,累加结果,最后加上环境光贡献。

具体到代码层面,URP提供了UniversalFragmentPBR函数,你只需要把SurfaceData和InputData填好,它内部会帮你完成所有光照计算。但如果你想真正理解PBR,我建议至少手写一次完整的BRDF计算,把Cook-Torrance的三个函数都实现一遍。

手写的过程中,你会遇到几个关键问题:如何采样阴影、如何处理多光源、如何计算环境光。这些问题在URP的源码里都有答案,但只有自己写过一遍,才能真正记住。

2.4 阴影和环境光的处理细节

阴影在URP里通过ShadowCasterPass实现,但光照计算时需要采样阴影贴图。URP提供了GetMainLightShadowCoord和SampleShadowmap等函数,但要注意阴影坐标的计算需要在顶点着色器里完成,片元着色器里只做采样。

环境光在PBR里分两部分:漫反射环境光和高光环境光。漫反射环境光通常用球谐函数(SH)来近似,URP的SampleSH函数可以直接用。高光环境光则需要采样反射探针(Reflection Probe),用GlossyEnvironmentReflection函数。

这里有个性能陷阱:反射探针的采样开销不小,移动端要谨慎使用。如果场景里没有反射探针,URP会回退到一个默认的立方体贴图,效果会差很多。我的做法是在质量设置里根据目标平台动态开关反射探针。

3. 手把手实现一个URP PBR Shader

3.1 创建Shader文件与属性块定义

先在Unity里新建一个Shader文件,命名为CustomPBR.shader。属性块里定义我们需要暴露给美术的参数:

Properties { _BaseMap ("Base Map", 2D) = "white" {} _BaseColor ("Base Color", Color) = (1,1,1,1) _Metallic ("Metallic", Range(0,1)) = 0.0 _Smoothness ("Smoothness", Range(0,1)) = 0.5 _NormalMap ("Normal Map", 2D) = "bump" {} _NormalScale ("Normal Scale", Range(0,2)) = 1.0 _OcclusionMap ("Occlusion Map", 2D) = "white" {} _OcclusionStrength ("Occlusion Strength", Range(0,1)) = 1.0 _EmissionMap ("Emission Map", 2D) = "black" {} _EmissionColor ("Emission Color", Color) = (0,0,0,1) }

这些参数和URP自带的Lit Shader基本一致,但少了Clear Coat和Detail Map等高级选项。对于大多数项目来说,这些核心参数已经够用了。

属性块里有个细节:_BaseMap的默认值写"white",_NormalMap的默认值写"bump"。这是Unity的约定,"bump"对应一个平坦的法线贴图,避免没有法线贴图时出现奇怪的着色。

3.2 SubShader与Pass的渲染状态配置

SubShader里需要定义几个关键状态:Tags、Cull、ZWrite、ZTest、Blend。对于不透明的PBR材质,通常这样配置:

SubShader { Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" "Queue" = "Geometry" } Pass { Name "ForwardLit" Tags { "LightMode" = "UniversalForward" } Cull Back ZWrite On ZTest LEqual Blend One Zero } }

RenderPipeline标签告诉Unity这个Shader是给URP用的。LightMode设为UniversalForward表示这是前向渲染的主Pass。Cull Back表示剔除背面,ZWrite On表示写入深度,Blend One Zero表示不混合。

如果需要双面材质,把Cull Back改成Cull Off即可。但要注意,双面材质在光照计算时可能需要翻转法线,否则背面会得到错误的光照结果。这个后面会细说。

3.3 HLSL代码块:引入库与定义结构体

HLSL代码块以HLSLPROGRAM开头,ENDHLSL结尾。首先引入URP的核心库:

HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS #pragma multi_compile_fragment _ _SHADOWS_SOFT #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

这些#pragma multi_compile指令用来生成Shader变体,支持阴影、多光源、软阴影等功能。如果不加这些,阴影和多光源就不会生效。

接下来定义输入输出结构体:

struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; float3 normalWS : TEXCOORD2; float4 tangentWS : TEXCOORD3; float3 viewDirWS : TEXCOORD4; float4 shadowCoord : TEXCOORD5; };

Attributes是顶点着色器的输入,Varyings是顶点着色器的输出、片元着色器的输入。注意shadowCoord也放在Varyings里,因为阴影坐标需要在顶点着色器里计算。

3.4 顶点着色器的完整实现

顶点着色器的实现如下:

Varyings vert(Attributes input) { Varyings output = (Varyings)0; VertexPositionInputs positionInputs = GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs normalInputs = GetVertexNormalInputs(input.normalOS, input.tangentOS); output.positionCS = positionInputs.positionCS; output.positionWS = positionInputs.positionWS; output.normalWS = normalInputs.normalWS; output.tangentWS = float4(normalInputs.tangentWS, input.tangentOS.w); output.viewDirWS = GetWorldSpaceViewDir(positionInputs.positionWS); output.uv = input.uv; output.shadowCoord = GetShadowCoord(positionInputs); return output; }

GetVertexPositionInputs和GetVertexNormalInputs是URP提供的工具函数,自动处理了空间变换。GetWorldSpaceViewDir计算从顶点指向相机的方向。GetShadowCoord计算阴影坐标,内部会根据是否启用级联阴影做不同处理。

这里有个性能优化点:viewDirWS可以在片元着色器里用normalize(_WorldSpaceCameraPos - positionWS)计算,但放在顶点着色器里插值可以省一次减法。不过对于大三角形,插值后的视角方向可能不准确,需要权衡。

3.5 片元着色器:从贴图采样到BRDF计算

片元着色器是重头戏。先采样所有贴图:

half4 frag(Varyings input) : SV_Target { half4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); half3 albedo = baseMap.rgb * _BaseColor.rgb; half metallic = _Metallic * baseMap.a; half smoothness = _Smoothness; half roughness = 1.0 - smoothness; half3 normalTS = UnpackNormalScale( SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.uv), _NormalScale ); half occlusion = lerp(1.0, SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, input.uv).r, _OcclusionStrength ); half3 emission = SAMPLE_TEXTURE2D(_EmissionMap, sampler_EmissionMap, input.uv).rgb * _EmissionColor.rgb;

注意baseMap.a被用作金属度贴图的通道,这是URP的惯例。法线贴图用UnpackNormalScale解包,它会根据目标平台自动处理法线的编码格式。

接下来构建SurfaceData和InputData:

SurfaceData surfaceData = (SurfaceData)0; surfaceData.albedo = albedo; surfaceData.metallic = metallic; surfaceData.smoothness = smoothness; surfaceData.occlusion = occlusion; surfaceData.emission = emission; surfaceData.normalTS = normalTS; surfaceData.alpha = 1.0; InputData inputData = (InputData)0; inputData.positionWS = input.positionWS; inputData.normalWS = normalize(input.normalWS); inputData.viewDirectionWS = normalize(input.viewDirWS); inputData.shadowCoord = input.shadowCoord; inputData.fogCoord = 0; inputData.vertexLighting = 0; inputData.bakedGI = SampleSH(inputData.normalWS); inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(input.positionCS);

SurfaceData封装了所有材质属性,InputData封装了所有光照计算需要的输入。SampleSH采样球谐函数得到漫反射环境光。

最后调用URP的PBR函数:

half4 color = UniversalFragmentPBR(inputData, surfaceData); return color; }

UniversalFragmentPBR内部会完成所有BRDF计算、阴影采样、环境光叠加。如果你想手写BRDF,可以不用这个函数,自己实现Cook-Torrance模型。

3.6 手写Cook-Torrance BRDF的核心代码

手写BRDF的话,核心是这三个函数:

// 法线分布函数:GGX/Trowbridge-Reitz float D_GGX(float NdotH, float roughness) { float a = roughness * roughness; float a2 = a * a; float NdotH2 = NdotH * NdotH; float denom = NdotH2 * (a2 - 1.0) + 1.0; return a2 / (PI * denom * denom); } // 几何遮蔽函数:Smith-Schlick float G_Smith(float NdotV, float NdotL, float roughness) { float k = (roughness + 1.0) * (roughness + 1.0) / 8.0; float Gv = NdotV / (NdotV * (1.0 - k) + k); float Gl = NdotL / (NdotL * (1.0 - k) + k); return Gv * Gl; } // 菲涅尔函数:Schlick近似 float3 F_Schlick(float VdotH, float3 F0) { return F0 + (1.0 - F0) * pow(1.0 - VdotH, 5.0); }

然后组合成完整的BRDF:

float3 BRDF(float3 N, float3 V, float3 L, float3 albedo, float metallic, float roughness) { float3 H = normalize(V + L); float NdotV = saturate(dot(N, V)); float NdotL = saturate(dot(N, L)); float NdotH = saturate(dot(N, H)); float VdotH = saturate(dot(V, H)); float3 F0 = lerp(0.04, albedo, metallic); float D = D_GGX(NdotH, roughness); float G = G_Smith(NdotV, NdotL, roughness); float3 F = F_Schlick(VdotH, F0); float3 specular = (D * G * F) / (4.0 * NdotV * NdotL + 0.001); float3 kd = (1.0 - F) * (1.0 - metallic); float3 diffuse = kd * albedo / PI; return (diffuse + specular) * NdotL; }

这段代码就是PBR的核心。F0是非金属的基础反射率,通常取0.04。kd是漫反射系数,金属材质没有漫反射,所以乘以(1.0 - metallic)。分母加0.001防止除零。

4. 实操中踩过的坑与排查技巧

4.1 法线贴图方向错误导致光照异常

这是最常见的问题。表现是:物体表面看起来凹凸感很奇怪,高光位置不对,或者整体发灰。原因通常是法线贴图的切线空间和Shader的切线空间不一致。

Unity默认使用OpenGL风格的切线空间,法线贴图的绿色通道朝上。但有些美术从其他软件导出的法线贴图是DirectX风格,绿色通道朝下。这时候需要在Shader里翻转绿色通道:

normalTS.y = -normalTS.y;

或者在导入设置里勾选Flip Green Channel。我建议在导入设置里处理,而不是改Shader,因为这样美术能直观看到效果。

另一个可能的原因是模型没有切线数据。在导入设置里把Tangents设为Calculate,让Unity自动生成切线。如果模型是程序化生成的,需要手动计算切线并填充到Mesh里。

4.2 金属材质在暗部发黑的问题

金属材质在暗部应该反射环境光,但如果环境光采样不对,就会发黑。问题通常出在反射探针上。如果场景里没有反射探针,URP会用一个默认的黑色立方体贴图,导致金属暗部完全黑掉。

解决办法是在场景里放一个Reflection Probe,设置好Bake范围,然后Bake一次。如果场景是动态的,可以把Reflection Probe设为Realtime,但性能开销会大很多。移动端建议用Baked模式,配合一个简单的天空盒。

还有一个细节:SampleSH采样的漫反射环境光对金属无效,因为金属没有漫反射。所以金属的环境光完全依赖反射探针。如果反射探针质量不够,金属看起来会很脏。

4.3 多光源下性能骤降的优化思路

URP的Additional Lights默认是逐像素计算的,每多一个光源就多一次BRDF计算。如果场景里有十几个点光源,性能会崩。优化思路有几个:

第一,把不重要的光源设为Vertex模式,在顶点着色器里计算光照,虽然效果差一些,但性能好很多。在URP的Asset设置里可以配置Additional Lights的Per Object Limit和Render Mode。

第二,用光照贴图(Lightmap)烘焙静态光源。静态物体的光照完全可以用烘焙解决,运行时只需要采样Lightmap,不需要实时计算。

第三,用光照探针(Light Probe)处理动态物体。动态物体不能烘焙,但可以用光照探针采样周围的光照信息,比实时计算便宜得多。

我实测下来,一个场景里如果有超过4个实时点光源,移动端基本就扛不住了。所以我的习惯是:主光源实时,其他光源全部烘焙或走顶点模式。

4.4 常见问题速查表

问题现象可能原因排查方法解决方案
材质整体发灰环境光过强或Albedo过暗检查SampleSH和Albedo值调整环境光强度或Albedo亮度
高光破碎法线贴图未解包或切线错误检查UnpackNormal和切线数据正确解包法线,重新计算切线
金属暗部发黑缺少反射探针检查场景是否有Reflection Probe添加并Bake反射探针
阴影闪烁阴影坐标计算错误检查GetShadowCoord调用确保在顶点着色器计算阴影坐标
多光源性能差Additional Lights逐像素计算查看Frame Debugger改为顶点模式或烘焙
双面材质背面光照错误法线未翻转检查片元着色器法线方向背面翻转法线:normalWS *= sign(dot(normalWS, viewDirWS))

双面材质那个问题值得多说一句。当Cull Off时,背面三角形的法线是指向远离相机的方向,光照计算会得到错误结果。解决办法是在片元着色器里判断当前是正面还是背面,如果是背面就翻转法线:

float faceSign = sign(dot(input.normalWS, input.viewDirWS)); inputData.normalWS *= faceSign;

这样背面也能得到正确的光照。但要注意,翻转法线后切线空间也要相应调整,否则法线贴图的方向会错。

4.5 关于Shader变体爆炸的避坑经验

URP的Shader变体数量很容易失控。每个multi_compile都会让变体数量翻倍。如果你有5个multi_compile,每个2个选项,那就是32个变体。再加上shader_feature,变体数量可能上百。

变体太多的后果是:打包时间变长、包体变大、运行时加载变慢。我的做法是:只保留项目实际需要的变体。比如项目不用软阴影,就把_SHADOWS_SOFT的multi_compile去掉。不用级联阴影,就把_MAIN_LIGHT_SHADOWS_CASCADE去掉。

另外,用shader_feature代替multi_compile,因为shader_feature只会在材质实际使用时生成变体,而multi_compile会生成所有变体。这个区别在大型项目里非常明显。

还有一个技巧:用#pragma skip_variants跳过不需要的变体。比如:

#pragma skip_variants _ADDITIONAL_LIGHTS_VERTEX

这样就不会生成顶点光照的变体,减少变体数量。

5. PBR进阶方向与个人学习路线

5.1 从PBR延伸到IBL和球谐光照

PBR的基础是直接光照,但真实场景中大部分光照来自环境反射,也就是IBL(Image Based Lighting)。IBL的核心是把环境贴图预计算成漫反射辐照度贴图和镜面反射预过滤贴图,运行时直接采样。

URP里可以通过GlossyEnvironmentReflection函数采样反射探针,但反射探针的预计算是Unity自动完成的。如果你想自己控制IBL的质量,可以手动生成 cubemap 并做卷积。这个过程涉及球谐函数和预过滤,是PBR进阶的必经之路。

球谐光照是另一种环境光近似方法,用9个系数就能表示整个球面的光照分布。URP的SampleSH就是用的这个方法。理解球谐函数的原理,能帮你更好地控制环境光的颜色和强度。

5.2 清漆层和布料材质的扩展思路

标准PBR模型只考虑了金属和非金属,但现实中有很多特殊材质,比如清漆(Clear Coat)、布料(Cloth)、次表面散射(SSS)。URP的Lit Shader支持Clear Coat,但需要额外开启。

清漆层的原理是在基础BRDF上再叠加一层透明的镜面反射,模拟车漆或钢琴漆的效果。实现上需要两个粗糙度:基础层粗糙度和清漆层粗糙度。计算时分别算两层BRDF,然后按菲涅尔权重混合。

布料材质则用另一种BRDF模型,比如Charlie分布或Ashikhmin分布,特点是高光更柔和、边缘有绒感。URP没有内置布料Shader,需要自己写。我建议先从Clear Coat入手,理解多层BRDF的混合逻辑,再扩展到布料。

5.3 性能与效果的平衡策略

PBR的效果好,但性能开销也大。移动端上,一个全屏的PBR材质可能就跑不满60帧。平衡策略有几个方向:

第一,分级质量。高端机用完整PBR,中端机简化BRDF(比如用Blinn-Phong代替Cook-Torrance),低端机用Unlit。URP的Quality设置可以配置不同级别的Shader变体。

第二,烘焙为主,实时为辅。静态物体全部烘焙,动态物体用简化光照。这样运行时几乎没有光照计算开销。

第三,限制光源数量。主光源实时,其他光源烘焙或走顶点模式。点光源的影响范围尽量小,避免全屏计算。

第四,降低分辨率。URP支持Render Scale,可以把渲染分辨率降到0.8或0.7,然后上采样。视觉损失不大,但性能提升明显。

我个人的经验是:移动端项目里,PBR材质不要超过场景的30%,其余用简化Shader。这样既能保证核心物体的质感,又能控制整体性能。

5.4 我自己的学习路线复盘

回头看,我学PBR走了不少弯路。最开始是直接看Unity的文档,但文档只讲参数不讲原理,调参全靠猜。后来去读URP的源码,Lighting.hlsl和BRDF.hlsl翻了好几遍,才慢慢理解每个函数的作用。

再后来是手写BRDF,把Cook-Torrance的三个函数都实现一遍。写完之后,再看URP的UniversalFragmentPBR,就有一种“原来如此”的感觉。最后是看一些经典的PBR论文,比如Cook-Torrance的原始论文、Disney的BRDF模型,理解工业界和学术界的差异。

如果你也在学PBR,我的建议是:先跑通再优化,先手写再用库。不要一上来就追求完美,先把一个简单的PBR Shader跑起来,看到效果,再逐步深入。遇到不懂的函数,去查源码、查论文,不要跳过。PBR的每一个参数背后都有物理意义,理解了这些意义,调参就不再是玄学。

最后分享一个我常用的调试技巧:在片元着色器里把中间结果可视化。比如把D项、G项、F项分别输出成颜色,看看它们对最终效果的贡献。这样能直观地理解每个函数的作用,比看公式快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询