资讯动态

Unity PBR着色器自写实战:BRDF拆解与避坑指南

发布时间:2026/10/14 7:27:58 来源:尧图企业网站定制
简介pbr_proj是一套适用于Unity引擎的PBR着色器工程基于金属工作流实现了一套简化版PBS面向手机移动平台的实时渲染场景而设计。它适用于希望在移动端获得较高真实感材质同时又要控制性能消耗的Unity开发者、技术美术及图形程序。压缩包共161个文件整体约31.93MB包含36个asset资源、12个mat材质、6个hlsl与4个shader着色器源码、4个cs控制脚本以及prefab预制体、unity场景、png/tga贴图等多类文件目录结构清晰便于导入项目直接研究。目前该资源已被1160人学习浏览是移动端PBR实现中较具参考价值的案例。资源在物理正确的基础上保留了官方PBS的核心计算同时为满足美术或策划需求附加了边缘发光等可开关的自定义效果内置DebugMode调试模式能单独输出漫反射、高光、法线等通道便于分析PBR渲染流程此外针对移动平台性能限制公式只考虑主光这种性能取舍思路对优化移动端渲染亦有启发。1. pbr_proj 是什么一份自写 Unity PBR 着色器到底值不值PBR 着色器在 Unity 里从来不缺内置 Standard、URP 的 Lit 都能一键出效果。但真到项目里内置方案总有几处不受控移动端变体膨胀、想自定义纹理打包格式没有入口、间接光强度调不动。pbr_proj 这个标题描述的正是「自写 Unity PBR 着色器」这一类方案以金属度-粗糙度工作流为底直接光走 GGX 高光加 Lambert 漫反射间接光接球谐和反射探针整套 BRDF 摊开在你自己能改的 HLSL 文件里。读者是带着具体诉求来的嫌内置 Shader 太重想自己控流程被美术追问金属度为什么填 0.9或者正在把项目从标准管线迁到自研渲染。无论哪一种读完你都能照着代码把最小版本跑起来再按参数表调出可信的材质效果。后面会按「现象—原因—解决」把黑脸、过曝、闪点这些高频坑逐条过一遍。这份笔记能当项目落地参考不是教科书。2. 为什么不用 Standard Shader自写 PBR 的适用边界与 BRDF 拆解2.1 内置方案哪里让人难受变体、入口和黑匣子Standard Shader 一个材质球动辄几十个 keyword 变体阴影 pass、forward pass 各有各的 multi_compile编译出来的 shader variant 数量轻松上千。PC 上无所谓移动端加载和切换材质时的卡顿往往就出在这里。更现实的问题是定制入口想做「AO 存 R、粗糙度存 G、金属度存 B」的单纹理打包Standard 没有给你留口子要么改它内部实现要么在材质球外再包一层脚本。自写方案的适用边界其实很清晰不追求和 Standard 完全一致追求的是「完全看得懂、改得动」。常见做法是把 PBR 拆成一个 BRDF 函数库放在.cginc里主 Shader 只做数据准备和调用。pbr_proj 这类项目名本质就是这套思路的产物——一个纯粹的、可移植的 PBR 函数集合加一个示范 Shader。内置 Standard 对你是个黑匣子出了问题只能搜论坛猜自己的代码能用断点式的return输出逐项排查这是自写最大的隐性收益。2.2 金属度-粗糙度工作流的三个核心公式金属度-粗糙度工作流把材质属性切成两组反照率 albedo 和金属度 metallic 决定光的颜色与吸收粗糙度 roughness 决定高光的扩散程度。代码层面三个公式是地基。// 漫反射金属直接把 diffuse 归零不是用暗色模拟 float3 Diffuse_Lambert(float3 albedo, float metallic) { return albedo * (1.0 - metallic) / UNITY_PI; } // Schlick 菲涅尔F0 由金属度决定非金属固定 0.04 float3 Fresnel_Schlick(float3 F0, float VdotH) { return F0 (1.0 - F0) * pow(1.0 - VdotH, 5.0); } // GGX 法线分布注意 a 是 roughness 的平方直接代入会偏亮 float D_GGX(float NdotH, float roughness) { float a roughness * roughness; float a2 a * a; float d (NdotH * a2 - NdotH) * NdotH 1.0; return a2 / (UNITY_PI * d * d); }参数说明metallic是 0 到 1 的标量非金属取 0纯金属取 1中间值在现实材质里很少见roughness是感知粗糙度0 是镜面1 是全哑光代码里要先平方一次再参与 GGX 计算这是新手最容易忽略的地方。VdotH是视线方向与半角向量的点积菲涅尔用它来判断掠射角边缘处的反射会明显增强。直接光高光的完整式子是specular D * F * V其中V是 Smith 高光遮蔽项处理视线和光源两个方向的自遮蔽我一般单独写成函数调试时可以分别return输出能看到 D、F、V 各自长什么样。2.3 为什么选 GGX 而不是 Beckmann高光尾巴的差异旧的 Blinn-Phong 和 Beckmann 分布高光衰减快、拖尾短GGX 的拖尾更长更重更接近真实金属和塑料的反射特征。做车辆漆面、磨损金属、皮革时GGX 这条长尾巴是「看着像真的」的关键。另一个现实理由是精度GGX 在半精度浮点上的表现比 Beckmann 稳移动端写half时不容易出现明显的条带断层。这也是现代引擎普遍采用 GGX 的原因不是玄学是实测出来的差异。3. 把 pbr_proj 跑通Unity 里最小可用的 PBR 着色器完整步骤3.1 Shader 骨架Properties 与 ForwardBase 标签的正确写法以下代码基于 Unity 内置渲染管线Built-in RP也就是用CGPROGRAM的那一套。如果你在 URP 下Pass 的 LightMode 要改成UniversalForwardinclude 换成 URP 的 Core.hlsl这个区别先记住后面参数和坑是通用的。Shader Custom/pbr_proj { Properties { _BaseMap (Albedo, 2D) white {} _BumpMap (Normal Map, 2D) bump {} _BumpScale (Bump Scale, Float) 1.0 _Metallic (Metallic, Range(0, 1)) 0.0 _Roughness (Roughness, Range(0, 1)) 0.5 _AoMap (Occlusion, 2D) white {} _Emission (Emission, Color) (0, 0, 0, 0) } SubShader { Tags { RenderTypeOpaque QueueGeometry } Pass { Tags { LightModeForwardBase } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #include UnityCG.cginc #include Lighting.cginc // 2.2 的三个 BRDF 函数定义放在这里 ENDCG } } }LightMode标成ForwardBase才能拿到主方向光的_LightColor0和_WorldSpaceLightPos0这是内置管线的约定。#pragma multi_compile_fwdbase负责展开阴影和雾相关的 keyword不加的话方向光阴影采样宏UNITY_LIGHT_ATTENUATION拿不到正确的变体。Properties 里_BumpMap默认值必须是bumpUnity 会把它识别为内置法线贴图占位给纯白图会导致法线方向错乱。3.2 顶点着色器法线、切线、视角方向怎么送进片元顶点阶段的任务是把对象空间的法线、切线变换到世界空间并把视角方向算好。这里有一个关键点法线变换必须用UnityObjectToWorldNormal它内部处理了非均匀缩放的逆转置矩阵直接拿unity_ObjectToWorld乘法线会在缩放物体上翻车。struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWS : TEXCOORD1; float3 tangentWS : TEXCOORD2; float3 bitangentWS : TEXCOORD3; float3 viewDirWS : TEXCOORD4; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; o.normalWS UnityObjectToWorldNormal(v.normal); // 内置逆转置 o.tangentWS UnityObjectToWorldDir(v.tangent.xyz); o.bitangentWS cross(o.normalWS, o.tangentWS) * v.tangent.w; // w 决定方向 o.viewDirWS WorldSpaceViewDir(v.vertex); // 对象空间顶点坐标 return o; }v.tangent.w是切线的方向符号镜像 UV 的模型上它可能是 -1副法线必须乘这个值否则法线贴图在对称模型上会左右反转。WorldSpaceViewDir接收的是对象空间顶点坐标内部会自己做变换注意别把世界坐标传进去。纹理坐标直接透传不需要在顶点阶段做任何处理。3.3 片元着色器直接光、间接光与最终输出片元阶段是 PBR 的主战场。先采样反照率、法线、AO 三张贴图重建世界空间法线然后分别计算直接光和间接光。下面是完整片元代码。float4 frag (v2f i) : SV_Target { float3 albedo tex2D(_BaseMap, i.uv).rgb; float ao tex2D(_AoMap, i.uv).r; float3 normalTS UnpackNormal(tex2D(_BumpMap, i.uv)); // 法线纹理坐标转世界空间_BumpScale 控制强度 float3 normalWS normalize(i.normalWS normalTS.x * i.tangentWS * _BumpScale normalTS.y * i.bitangentWS * _BumpScale); float3 viewDirWS normalize(i.viewDirWS); float3 lightDirWS normalize(_WorldSpaceLightPos0.xyz); float NdotL saturate(dot(normalWS, lightDirWS)); float NdotV saturate(dot(normalWS, viewDirWS)); float3 H normalize(lightDirWS viewDirWS); float NdotH saturate(dot(normalWS, H)); float VdotH saturate(dot(viewDirWS, H)); float metallic _Metallic; float roughness max(_Roughness, 0.045); // 下限保护防止 GGX 分母除零 float3 F0 lerp(0.04, albedo, metallic); // 非金属 F00.04金属取 albedo float3 diffuseColor albedo * (1.0 - metallic); // 直接光漫反射乘 (1-F) 保证能量守恒高光 D*F*V float3 directDiffuse Diffuse_Lambert(diffuseColor, metallic) * (1.0 - Fresnel_Schlick(F0, VdotH)); float3 directSpecular D_GGX(NdotH, roughness) * Fresnel_Schlick(F0, VdotH) * V_SmithGGX(NdotL, NdotV, roughness); // 间接光球谐漫反射 反射探针镜面按粗糙度选 mip float3 indirectDiffuse ShadeSH9(float4(normalWS, 1.0)) * diffuseColor * ao; float3 reflectDir reflect(-viewDirWS, normalWS); float mip roughness * UNITY_SPECCUBE_LOD_STEPS; float3 indirectSpecular DecodeHDR( UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0, reflectDir, mip), unity_SpecCube0_HDR); indirectSpecular * Fresnel_Schlick(F0, NdotV) * ao; float3 color (directDiffuse directSpecular) * _LightColor0.rgb * NdotL indirectDiffuse indirectSpecular _Emission.rgb; return float4(color, 1.0); }ShadeSH9的入参必须是float4(normalWS, 1.0)第四个分量 1 表示不透明度权重写成float4(normalWS, 0)会让间接漫反射变成黑场。UNITY_SPECCUBE_LOD_STEPS内置管线下是 6粗糙度 0 采样第 0 级 mip 得到锐利反射粗糙度 1 采样第 6 级得到完全模糊的反射这个映射关系就是 PBR 间接高光的核心。DecodeHDR用于把反射探针的 RGBM 或浮点编码解码成线性颜色直接拿采样结果当颜色用会偏色甚至过曝。V_SmithGGX 的函数定义补上float V_SmithGGX(float NdotL, float NdotV, float roughness) { float a2 roughness * roughness * roughness * roughness; float lambdaV NdotL * sqrt(NdotV * NdotV * (1.0 - a2) a2); float lambdaL NdotV * sqrt(NdotL * NdotL * (1.0 - a2) a2); return 0.5 / (lambdaV lambdaL 1e-5); }注意这个最小版本没接阴影采样。要加阴影就把UNITY_LIGHT_ATTENUATION(atten, i, posWS)塞进直接光项里相乘这是内置管线常见做法。多个点光源和聚光灯需要额外的 ForwardAdd Pass这里不展开先把单方向光跑通调试成本最低。4. 参数怎么调才不翻车pbr_proj 的 5 个必调参数与纹理打包4.1 参数表范围、默认值和常见误用调参之前先把每个参数的物理含义钉死。下面是 pbr_proj 的 5 个核心参数后两列是实际项目中踩过的高频误用。参数范围默认物理含义常见误用_Metallic0 ~ 10金属度非金属 0纯金属 1用 0.5 表示半金属现实里几乎不存在_Roughness0 ~ 10.5感知粗糙度0 镜面 1 全哑光以为线性实际对高光形状影响是平方关系_BumpScale0 ~ 101.0法线贴图强度调到 3 以上边缘出现假凹凸和黑边_AoMap黑白图white环境光遮蔽只影响间接光和阴影贴图混为一谈AO 不该吃掉方向光_EmissionHDR Color0自发光不受光照影响用 LDR 颜色导致发光强度永远不够这里重点说_Roughness。代码里先a roughness * roughnessGGX 里再a2 a * a所以 roughness 从 0.5 改到 0.6高光形状的变化远大于从 0.4 改到 0.5 的变化。调参时在 0.1 到 0.3 区间用小步长 0.020.3 以上可以大步长 0.05不然很难定位到想要的效果。金属度 0.5 是一个典型的「看着像塑料但带了金属色」的尴尬值美术如果坚持要半金属通常说明他想要的是「有光泽的非金属」此时该调 roughness 而不是 metallic。4.2 用一张 ORM 纹理装下金属度、粗糙度和 AO移动端采样器数量是硬指标三张单通道图换一张 ORM 纹理能省两个采样器还省了纹理内存和加载时间。通道约定采用业界通用的 ORM 打包R 通道存 AOG 通道存粗糙度B 通道存金属度。在实际制作中我一般让美术在 DCC 工具里按这个顺序导出shader 侧只用一行拆包float3 orm tex2D(_OrmTex, i.uv).rgb; float ao orm.r; // R 通道环境光遮蔽 float roughness orm.g; // G 通道感知粗糙度 float metallic orm.b; // B 通道金属度如果你手头只有三张分离的灰度图可以用一个编辑器脚本自动打包省得美术手工拼图using UnityEditor; using UnityEngine; using System.IO; public static class OrmPacker { [MenuItem(Tools/Pack ORM)] public static void Pack() { var ao AssetDatabase.LoadAssetAtPathTexture2D(Assets/Textures/AO.png); var rough AssetDatabase.LoadAssetAtPathTexture2D(Assets/Textures/Rough.png); var metal AssetDatabase.LoadAssetAtPathTexture2D(Assets/Textures/Metal.png); var srcPixels ao.GetPixels(); var roughPixels rough.GetPixels(); var metalPixels metal.GetPixels(); var dst new Color[srcPixels.Length]; for (int i 0; i srcPixels.Length; i) { dst[i].r srcPixels[i].r; // AO 进 R dst[i].g roughPixels[i].r; // Roughness 进 G dst[i].b metalPixels[i].r; // Metallic 进 B dst[i].a 1f; } var tex new Texture2D(ao.width, ao.height, TextureFormat.RGBA32, true); tex.SetPixels(dst); File.WriteAllBytes(Assets/Textures/ORM.png, tex.EncodeToPNG()); AssetDatabase.Refresh(); } }这是个最简打包器没有做 sRGB 到线性的转换处理也没有处理 mipmap 的 alpha 阈值美术管线正式用的时候要在这两个地方补逻辑。但核心思路对了RGB 三通道各装一个标量shader 采样一次全部拿到。4.3 参考球验证场景不同光照下的调参顺序PBR 调参有点玄学很多问题其实是验证环境不对。正确做法是搭一个固定的参考球场景所有材质都在同一个环境下对比。场景结构很简单一个方向光一个带天空盒反射的相机两个球体。一个设_Metallic 0、_Roughness 0.4代表非金属一个设_Metallic 1、_Roughness 0.3代表金属。观察现象参数嫌疑调整方向高光颜色和底色一样金属感假_Metallic过高降到 0 或 0.1高光糊成一片没有形状_Roughness过高降到 0.3 以下暗部发灰、对比弱AO 被当成方向光遮蔽检查 ORM 的 R 通道是否错位边缘高光像描边_BumpScale过大降到 1.0 附近验证顺序我固定是先看非金属球的高光是否白色再看金属球的高光是否带 albedo 颜色最后看粗糙度改变时高光收缩是否平滑。三步都过了再往真实场景里放。反过来调会陷入「调金属度修高光颜色、调粗糙度修亮度」的循环。另外项目渲染管线必须开 Linear 颜色空间Gamma 空间下 PBR 结果整体偏亮尤其金属高光会像曝光过度。5. 避坑手册Unity PBR 着色器的黑脸、过曝、闪点与性能排查5.1 大面积黑脸法线变换踩了非均匀缩放的坑现象模型在正常缩放时显示正确挂到带非均匀缩放的父节点下一半以上的面变成纯黑。原因顶点着色器里直接用mul((float3x3)unity_ObjectToWorld, v.normal)变换法线。非均匀缩放不会保持法线方向点乘光源方向后 NdotL 变成负值被saturate夹到 0整块面就黑了。解决换用UnityObjectToWorldNormal(v.normal)它内部做了逆转置矩阵处理。同时在片元里给 NdotL 加saturate防止负值导致的黑脸。5.2 高光过曝死白能量不守恒与 HDR 解码错误现象金属球高光区域一片纯白颜色信息完全丢失边缘甚至有白晕扩散。原因两个。一是直接光里把 diffuse 和 specular 直接相加没让漫反射乘以(1 - F)等于一份能量用了两次二是采样反射探针后直接用返回值当颜色没走DecodeHDR解码RGBM 编码的 HDR 数据被当 LDR 显示亮度直接爆炸。解决diffuse 乘1.0 - Fresnel_Schlick(F0, VdotH)间接镜面乘Fresnel_Schlick(F0, NdotV)和 AO这两处是能量守恒的关键位置。反射探针采样结果必须过DecodeHDR再把第二个参数unity_SpecCube0_HDR传对。5.3 粗糙表面出现闪点半精度浮点的精度陷阱现象roughness 在 0.4 到 0.7 之间时高光区域出现随机闪烁的亮点像下雪一样视角一动亮点就跳。原因移动端为了性能把 BRDF 全部用half计算。GGX 的分母d (NdotH * a2 - NdotH) * NdotH 1.0在 NdotH 接近 1 时两个大数相减再平方half只有 10 位尾数精度撑不住。解决BRDF 核心计算用float只有最终输出颜色可以回到half。同时给 roughness 设下限 0.045别给 0。如果老 GPU 实在撑不住GGX 分母拆成NdotH * NdotH * (a2 - 1.0) 1.0两次乘法能缓解一部分精度问题。5.4 移动端帧率崩塌反射探针采样与变体数量现象手机上场景一多帧率直接掉一半发热明显。原因UNITY_SAMPLE_TEXCUBE_LOD在高 mip 等级采样反射探针的开销大加上#pragma multi_compile_fwdbase展开的方向光阴影变体Shader 编译体积和运行时切换成本都上去了。解决移动端把间接镜面降级为固定采样第 0 级 mip或者直接砍掉只保留ShadeSH9漫反射视觉差异在小型场景里可以接受。变体侧用#pragma skip_variants裁剪用不到的 keyword再配合 Shader Variant Collection 做预编译避免运行时首帧卡顿。5.5 法线方向反转副法线符号丢失现象同一张法线贴图在两块对称模型上一块正常一块方向相反高光位置镜像看起来像贴图左右反了。原因副法线用cross(normalWS, tangentWS)计算时没有乘tangent.w。镜像 UV 的模型切线方向信息在这个 w 分量里丢掉它副法线方向就错了。解决顶点着色器里写成o.bitangentWS cross(o.normalWS, o.tangentWS) * v.tangent.w;同时在片元里重建法线后做一次normalize。另外一个相关习惯是法线贴图导入设置里确认Create from Grayscale没被勾选否则采样出来的法线强度会比预期大很多。6. 进阶玩法三平面映射、自动打包与可信的验证流程6.1 三平面映射把 pbr_proj 用在地形和雕刻模型上UV 破损的模型比如地形、岩石、被雕刻工具重拓扑过的资产最省事的方案是不用 UV直接按世界坐标做三方向投影混合。片元里用 |法线| 的幂次做混合权重三个方向各自采样再按权重相加接缝会自然消失。float3 worldAbs abs(i.positionWS); float3 blend pow(worldAbs, 5.0); blend / (blend.x blend.y blend.z 1e-5); float3 col tex2D(_BaseMap, i.positionWS.zy).rgb * blend.x tex2D(_BaseMap, i.positionWS.xz).rgb * blend.y tex2D(_BaseMap, i.positionWS.xy).rgb * blend.z;代价是采样次数变成三倍移动端要慎用地形 Shader 里这是常见取舍。如果只在地形上用则可以只对反照率和法线做三平面粗糙度和金属度继续用 UV。6.2 自动打包 ORM 的材质面板把 4.2 的打包逻辑接进一个自定义 ShaderGUI美术在材质球上拖入三张灰度图面板按钮一键生成 ORM 并赋给_OrmTex。这样管线约定的压力不在美术身上而在工具上减少通道错位的沟通成本。6.3 可信的验证流程最后说验证。我的固定流程是Linear 色彩空间、固定天空盒、方向光强度 1.0放参考球和非金属球再放一个平板测粗糙度渐变。每次换 Unity 版本、换 GPU、甚至换驱动先跑一遍这个场景截图对比重点看金属球的边缘菲涅尔和高光的拖尾形状。这个习惯救了我至少三次「看着差不多一进场景就过曝」的翻车——问题从来不在参数而在环境没对齐。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑