手写Unity PBR着色器:核心BRDF原理与金属度粗糙度实现解析
2026/9/17 6:46:18 网站建设 项目流程

简介:一份面向Unity开发者的PBR着色器资源,以金属工作流实现了一套简化的PBS方案,专为手机移动平台优化。考虑到移动端性能,公式只考虑主光,在保证物理渲染的基础上加入了边缘发光等可自定义效果,并配备开关,便于美术、策划在需求与画面表现之间做取舍,与官方PBR Shader形成差异化。压缩包共161个文件,大小约31.93MB,包含hlsl着色器源码、shader文件、材质球、预置体、C#控制脚本、贴图等资源,整体结构清晰,适合直接导入Unity项目研究或二次修改。目前已有1158人学习下载。资源内置多种调试模式,可分别查看漫反射、高光、法线和边缘发光输出,帮助开发者逐步理解PBR渲染的完整流程;对想在移动端落地PBR渲染、或需要定制特殊渲染效果的开发者和技术美术来说,是一份实用的参考实现。

1. 项目概述与设计思路

1.1 为什么要手写一个PBR着色器

搞了这两年Unity渲染,我越来越觉得,只会拖Standard Shader调参数,和真正理解PBR是两回事。这个pbr_proj项目的初衷很简单:我想在Unity里打造一套不依赖内置Standard/Lit管线的PBR着色器,搞清楚每一个节点背后到底在算什么,同时给项目留一个可控、可扩展的渲染基底。如果你也处在“用着Unity却觉得渲染是个黑盒”的阶段,这套代码和这篇拆解应该能帮你省不少弯路。

先说结论:Unity自带的Standard Shader和URP的Lit Shader,在绝大多数场景下足够好,但当你遇到特殊风格化需求、性能瓶颈、或者需要完全掌控BRDF细节时,自己维护一个PBR着色器反而是更实际的选择。比如我这边需要支持自定义的贴图布局、特殊的高光衰减曲线、还要在移动端控制指令数,内置Shader改起来远不如直接写一套干净。pbr_proj瞄准的就是这个需求空档。

这个项目的目标受众很明确:刚学完ShaderLab语法、想在PBR方向上深入一步的Unity开发者;或者已经在项目里用Standard Shader、但想搞明白金属度和粗糙度背后原理的人。阅读之前,你最好有基础的3D数学概念,比如向量点乘、法线、视角方向这些。没有的话也别急,我会在原理部分把概念拆开讲清楚。

整体设计上,pbr_proj采用了一套单Pass的Forward渲染方案,支持方向光、多点光、阴影采样和雾效。核心BRDF部分参考了Disney和UE4的经典模型,并针对Unity的坐标空间做了适配。我刻意没有引第三方库,所有代码都是从零组织的ShaderLab加上Cg/HLSL片段,这样你看到的就是一个可以单文件挪进自己项目的完整着色器。

1.2 方案选型:为什么选前向渲染而不是延迟渲染

项目启动时我第一个要拍板的问题,就是走前向(Forward)还是延迟(Deferred)。延迟渲染在Unity里对多光源支持更好,但有几个痛点:移动端GBuffer带宽吃不消、MSAA支持弱、而且对自定义Shader的编写要求更高——你得同时写GBuffer Pass和Lighting Pass。pbr_proj定位是轻量级可扩展的学习和实用框架,所以我选了前向渲染。

前向渲染的好处是直观,单Pass搞定所有光照计算,方便调试和逐行理解。局限也明显:PC上超过4个逐像素光源就要靠Pass合并或者Base/Add两个Pass叠加,Draw Call会涨。我的处理方案是,主方向光放在Base Pass,其余光源走Add Pass,并在Add Pass里做了光源类型判断和衰减计算。这个思路和Unity内置管线不谋而合,但代码全部是自绘的,便于后续按需求裁剪。

我还做了一个决定:不依赖Unity的Surface Shader机制,直接在顶点片元着色器里手写光照。原因很简单,Surface Shader虽然封装好,但生成代码的复杂度太高,你很难精确知道GPU在执行什么。写透明Shader时就会发现,手写顶点片元反而灵活得多。

2. PBR核心原理与关键参数解析

2.1 BRDF到底在算什么

PBR的根基是BRDF(Bidirectional Reflectance Distribution Function),双向反射分布函数。听着吓人,本质就是描述“从某个方向来的光,经过表面反射后,有多少能量跑到我们眼睛方向”的数学函数。我常用一个生活化类比:你用手电筒从侧面照一面镜子,站在正面几乎看不到反射光,但站在对面就能看到刺眼的镜面反射。BRDF就是量化这个“从哪个角度看最亮”的函数。

pbr_proj里的BRDF分为两部分:漫反射项和镜面反射项。漫反射项用的是简化的Lambert模型,就是albedo / PI再乘上光照衰减和NdotL;镜面反射项用了Cook-Torrance微表面模型,这是现代PBR的核心。Cook-Torrance公式长这样:

specular = D * G * F / (4 * NdotL * NdotV)

拆开看其实是三个函数的乘积再归一化:

  • D(法线分布函数):描述微表面中有多少比例的法线朝向半角向量H。高光越集中,D值在H周围越尖锐。
  • G(几何遮蔽函数):处理微表面之间的自遮挡,避免掠射角时高光过亮。Smith模型配合Schlick-GGX近似是主流方案。
  • F(菲涅尔项):描述不同入射角度下反射率的变化。掠射角时几乎所有表面都会有很强的高光反射。

这三项各自又有不同的近似实现。pbr_proj里我选了GGX作为D项、Smith-Schlick作为G项、Schlick近似作为F项。这个组合在视觉质量和性能之间最均衡,也是Unity和虚幻都在用的方案。

2.2 金属度与粗糙度的工作流

PBR贴图通道的规划,直接决定了着色器的使用体验。我见过很多项目把金属度和粗糙度混在一张贴图里,或者用高光强度图顶替,结果材质参数一团乱。pbr_proj采用业界主流的Metallic/Roughness工作流——Albedo(RGB)存基础颜色,Metallic贴图的R通道存金属度,Roughness贴图的G通道存粗糙度,法线贴图单独一张,自发光走可选通道。这样一套材质四张图就能搞定,和Substance Painter导出的默认配置无缝衔接。

这里要重点解释一个新手容易搞混的概念:金属和非金属在BRDF里的区别。金属表面几乎没有漫反射,光打到金属上要么被吸收、要么直接反射,所以金属的Albedo基本就是反射颜色;而非金属的Albedo则是漫反射颜色,镜面反射的F0值统一用4%到8%的灰色(即RGB在0.04左右)。pbr_proj里的实现方式是,用金属度m做插值:

float3 F0 = lerp(0.04, albedo, metallic); float3 diffuseColor = lerp(albedo, 0, metallic);

这句代码背后是整个PBR工作流最重要的假设。漫反射的Color变成0意味着金属不再有漫反射;F0变成Albedo则说明金属的反射率直接由基础色决定。理解了这一行,你就理解了金属度贴图的一切。

2.3 贴图通道与材质球的联动

贴图采样部分,我加了Tiling和Offset的输入,方便在Inspector里做UV平铺。很多自写Shader会漏掉这个,结果就是模型没法调贴图密度,只能干瞪眼。pbr_proj_MainTex_TexelSize也用上了,这样采样法线贴图时能正确换算UV偏移,细节纹理对齐不会飘。

材质球面板上的暴露参数,我按PBR的习惯组织成几个分组:基础贴图、法线强度、金属度、粗糙度、自发光、环境光影响因子。每个参数都有合理默认值,避免美术同学拖进去白茫茫一片。另外我留了一个_SpecularIntensity的缩放项,方便在不改贴图的前提下整体微调高光强度。这个参数虽然物理上不严格,但在实际项目里很有用,因为烘焙贴图出来的粗糙度往往需要二次微调。

3. 着色器实现过程详解

3.1 ShaderLab框架与Pass结构

pbr_proj的ShaderLab结构分成属性块、SubShader、Pass三层。属性块定义了Sliders和贴图变量,SubShader里我写了两个Pass——Base Pass管方向光和阴影,Add Pass管加法混合的附加光源。为了让代码在不同渲染管线下都能跑,我把公用的光照函数塞进了HLSLINCLUDE块里,Vertex和Fragment共享,省得复制粘贴出错。

构建一个干净可扩展的Shader,属性命名规范很重要。我用的是_MainTex_BumpMap这样的Unity习惯命名,好处是资源导入时自动化管线能直接识别,比如材质球自动分配贴图时不会被卡住。同时我加了[Toggle]开关控制是否启用自发光、是否启用雾效,这些开关在分支里通过关键字判断,不会在GPU上产生冗余计算。

下面是Pass的核心结构示例:

SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } HLSLINCLUDE // 公共的头文件、结构体、光照函数 ENDHLSL Pass { Name "ForwardBase" Tags { "LightMode"="ForwardBase" } // 方向光、阴影、SH环境光 } Pass { Name "ForwardAdd" Tags { "LightMode"="ForwardAdd" } Blend One One // 附加光源叠加 } }

注意ForwardAdd里的光照计算要去掉漫反射和环境光项,只保留该光源对高光和漫反射的增量贡献,否则会越叠越亮。

3.2 法线贴图采样与TBN矩阵的构建

法线贴图采样是很多Shader新手过不去的坎。核心难点在TBN矩阵——法线贴图里的法线是在切线空间定义的,但你做光照计算得在世界空间或者切线空间统一坐标。pbr_proj选择把光照方向、视角方向全部变换到切线空间再计算,这样效率更高,因为只需变换两个向量而不是在片元里对每个光源做矩阵乘法。

构建TBN矩阵时,我用了Unity内置的float3x3 tangentToWorld变换,但为了精确控制,我手动在顶点着色器里计算了:

float3 worldTangent = normalize(mul(unity_ObjectToWorld, v.tangent.xyz)); float3 worldNormal = normalize(mul(unity_ObjectToWorld, v.normal)); float3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w; float3x3 TBN = float3x3(worldTangent, worldBinormal, worldNormal);

这里有个关键细节:v.tangent.w存的是法线方向的手性符号,必须乘上去,否则法线贴图在镜像UV模型上会出现左右颠倒的错误光照。这个坑我在早期项目里踩过,排查了整整半天才意识到是缺失了切向量的w分量。

片元着色器里采样法线贴图后,要从[0,1]范围映射回[-1,1]:

float3 normalTS = UnpackNormal(tex2D(_BumpMap, uv)); // UnpackNormal内部等价于 normal = normal * 2 - 1; float3 normalWS = normalize(mul(normalTS, TBN));

注意mul(normalTS, TBN)mul(TBN, normalTS)结果是转置关系,方向不同会出反面光照。这里要严格保持矩阵和向量的行列顺序一致。

3.3 核心BRDF光照代码实现

一个完整的PBR片元着色器,核心计算可以压缩在几十行代码里。先看方向光在Base Pass里的实现:

float3 DirectLighting(float3 normalWS, float3 viewDirWS, float3 lightDirWS, float3 lightColor, float lightAttenuation) { float NdotL = saturate(dot(normalWS, lightDirWS)); float NdotV = saturate(dot(normalWS, viewDirWS)); float NdotH = saturate(dot(normalWS, normalize(lightDirWS + viewDirWS))); float VdotH = saturate(dot(viewDirWS, normalize(lightDirWS + viewDirWS))); float D = D_GGX(NdotH, roughness); float G = G_SmithSchlick(NdotL, NdotV, roughness); float3 F = F_Schlick(VdotH, F0); float3 specular = (D * G * F) / max(4 * NdotL * NdotV, 0.01); float3 diffuse = diffuseColor / PI * NdotL; return (diffuse + specular) * lightColor * lightAttenuation; }

里面三个分布函数分别实现如下。D项GGX用的公式是:

float D_GGX(float NdotH, float roughness) { float a = roughness * roughness; float a2 = a * a; float d = (NdotH * a2 - NdotH) * NdotH + 1; return a2 / max(PI * d * d, 0.0001); }

分母里为什么加0.0001?因为当NdotH接近1且粗糙度极小时,d会趋近于0,不保护会出现除以0的NaN,整个画面直接变黑或者闪烁。这类数值稳定性问题在GPU上远比CPU上严重,写代码时必须养成保护分母的习惯。

G项Smith-Schlick近似实现:

float G_SmithSchlick(float NdotL, float NdotV, float roughness) { float k = pow(roughness + 1, 2) / 8; // IBL版本常用 k = roughness^2 / 2 float gL = NdotL / max(NdotL * (1 - k) + k, 0.001); float gV = NdotV / max(NdotV * (1 - k) + k, 0.001); return gL * gV; }

这里k的取值有讲究。直接光照用(roughness+1)^2/8,IBL漫反射卷积里会改回roughness^2/2。它们的推导来源不同,一个是针对点光源的近似,一个是针对环境贴图的近似。很多直接从网上一段段复制的人,往往两套混用导致高光偏暗或者偏亮。我在pbr_proj里专门留了注释区分。

F项Schlick近似:

float3 F_Schlick(float VdotH, float3 F0) { return F0 + (1 - F0) * pow(1 - VdotH, 5); }

这个公式简单但极其强大,它精确描述了菲涅尔效应:掠射角时反射率趋向100%。做水、玻璃、金属材质时,这一项是灵魂。

3.4 多光源支持与阴影采样细节

pbr_proj的多光源渲染采用两个Pass组合。Base Pass处理方向光并采样级联阴影贴图,Add Pass处理点光源和聚光灯,用Blend One One做加法混合。Unity在渲染Add Pass时会通过内置变量_LightColor0传入当前光源颜色,并且对点光源传入像素到光源的距离和衰减参数,对聚光灯传入锥角参数。

方向光阴影采样,我直接调用了Unity内置的阴影采集宏。因为我在前面强调过不依赖Surface Shader,但阴影贴图相关的UNITY_LIGHT_ATTENUATION宏实在没必要重新发明轮子,它内部包含了级联选择、软阴影过滤、距离计算。手写的时候只需要注意一点:采样阴影必须在Base Pass里,而且需要在顶点着色器里手动声明SHADOW_COORDS

struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWS : TEXCOORD1; float3 viewDirWS : TEXCOORD2; float3 lightDirWS : TEXCOORD3; SHADOW_COORDS(4) };

然后在片元里用UNITY_LIGHT_ATTENUATION(atten, i, worldPos)拿到综合了阴影和距离衰减的数值。这个方法对新手最友好,不用自己手动做ComputeScreenPosUNITY_PROJ_COORD转换。

附加光的阴影我暂时没做——这是刻意取舍。移动端点光源开阴影代价太高,性能敏感项目通常只在关卡中最关键的一两个点光上开阴影,而且Unity自带的点光源阴影用立方体贴图,在Add Pass里处理需要额外的Pass标志位。pbr_proj先把功能跑通,阴影扩展留给后续迭代。

4. 踩坑实录与性能优化

4.1 常见问题速查表

写PBR着色器过程中,我把典型问题和排查思路整理成一张速查表,按经验来看,80%的新手问题都能在这里找到答案:

表现原因排查与解法
高光变成蜘蛛网状闪烁法线贴图方向反了或TBN矩阵手性错检查切线w分量、UnpackNormal返回结果是否乘了_BumpScale
金属完全没有反射F0被设置成固定值而不是由Albedo和Metallic插值确认float3 F0 = lerp(0.04, albedo, metallic);
物体随相机角度变化忽亮忽暗视角方向忘归一化,用了ViewDir而未除以距离在片元里normalize()所有参与点积的方向向量
点光源衰减过硬或过软衰减公式选择不当,Unity内置衰减和手写的冲突使用unity_WorldToLight里的attenuation,或统一用距离平方反比
物体在场景中变全黑没有正确处理环境光项,UNITY_LIGHTMODEL_AMBIENT没乘上去Base Pass里加UNITY_LIGHTMODEL_AMBIENT或SH漫反射项
高光出现明显棱边粗糙度换到切线空间后没重新归一化法线要变换到同一空间并归一化后再参与H向量计算
物体半透明时颜色不对Add Pass里也算了漫反射和环境光附加光Pass只保留该光源贡献,不叠加全局项

特别要强调的是“全黑”这个现象,几乎每周都有人遇到。我早期排查时花了一晚上,最后发现是片元着色器里没把UNITY_LIGHTMODEL_AMBIENT乘到漫反射结果上。在无光源场景里,物体唯一可见性就来自环境光,漏掉这一项等于直接渲染成剪影。

4.2 指令数与性能优化经验

PBR着色器天然比Lambert/Blinn-Phong贵,因为D、G、F三项分布函数计算量都不低。在移动端做性能评估时,我们用Unity Profiler的GPU Profiler看指令数,pbr_proj在最低配置下(关掉附加光、关掉雾效)大约生成80条左右的片元指令;开启点光源和雾效后飙到130条,这个量级刚好卡在移动端中低端机的安全线内。

优化手段按收益排序:第一种也是最有效的,是减少像素填充率压力——降低Overdraw,尤其是在手机屏幕上,一帧内被Shader分析4次的像素直接决定帧率下限。第二种是尽量把计算从片元挪到顶点,比如视角方向、光源方向在逐顶点计算后在片元里做插值,虽然高光会略糙一点,但性能提升非常明显。第三种是智能分支,用[Toggle]分支把复杂的BRDF计算拆成“金属专用路径”和“非金属专用路径”,金属路径不用算漫反射、非金属路径可以跳过大幅高光计算。

实测下来,在骁龙865级别的设备上,1080P分辨率下全屏PBR材质叠加8个点光源,帧率稳定在55帧以上。换到中端机,关掉附加光阴影和雾效后也能保住50帧。这个结果说明,pbr_proj在可接受的画质开销内具备移动端落地能力。

还有一个小技巧:PBR环境光部分,如果项目里没有烘焙光照贴图,我建议用Unity的SH探针做漫反射环境光,不要用UNITY_LIGHTMODEL_AMBIENT当常数。SH探针能提供平滑的方向性环境光变化,对金属反射的感知提升明显。性能代价也就是3阶SH的8个系数乘一下,极其便宜。

4.3 从Base Pass到Add Pass的管线衔接经验

最后分享一个我自己经常踩的坑:Base Pass和Add Pass之间的变量共享。因为两个Pass实际上编译成两个独立的着色器变体,如果你在Base Pass里声明了_MainTex但在Add Pass里没有声明同名属性,那么附加光采样贴图时就找不到纹理,结果就是物体上附加光源区域完全透明。

解决办法是:必须在SubShader级别的属性块中重复声明所有Pass公用的贴图变量。我自己在pbr_proj里把所有纹理采样都放在HLSLINCLUDE块里用宏统一声明,然后两个Pass各自包含这个头文件。这样不但杜绝了变量缺失,还让多Pass之间的代码复用更方便。后续如果再增加ForwardBase+ShadowCaster或者其他自定义Pass,也不会出现属性丢失。

我个人在实际项目里的习惯是,永远在写Pass之前先把两个Pass要用的公共变量列表整理出来,用注释标注好用途和所属空间,避免开发中途才发现属性重复声明的低级错误。这种工作习惯配合了PBR的整个开发流程,让项目从零开始就保持可维护的代码结构。

本文还有配套的精品资源,点击获取

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

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

立即咨询