手写Unity PBR着色器:核心BRDF原理与金属度粗糙度实现解析 简介一份面向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的根基是BRDFBidirectional 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工作流——AlbedoRGB存基础颜色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 { RenderTypeOpaque QueueGeometry } HLSLINCLUDE // 公共的头文件、结构体、光照函数 ENDHLSL Pass { Name ForwardBase Tags { LightModeForwardBase } // 方向光、阴影、SH环境光 } Pass { Name ForwardAdd Tags { LightModeForwardAdd } 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的取值有讲究。直接光照用(roughness1)^2/8IBL漫反射卷积里会改回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_COORDSstruct 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)拿到综合了阴影和距离衰减的数值。这个方法对新手最友好不用自己手动做ComputeScreenPos和UNITY_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之间的代码复用更方便。后续如果再增加ForwardBaseShadowCaster或者其他自定义Pass也不会出现属性丢失。我个人在实际项目里的习惯是永远在写Pass之前先把两个Pass要用的公共变量列表整理出来用注释标注好用途和所属空间避免开发中途才发现属性重复声明的低级错误。这种工作习惯配合了PBR的整个开发流程让项目从零开始就保持可维护的代码结构。本文还有配套的精品资源点击获取