
简介这是一套面向Unity引擎的PBR着色器实现采用金属工作流构建简化版PBS专为手机移动平台优化适合需要在移动端实现高质量物理渲染的Unity开发者。着色器提供了多级调试模式可分别输出漫反射颜色、高光计算、法线方向以及边缘发光效果在保持基础物理计算的同时允许叠加自定义渲染选项从而兼顾美术表现与运行性能。资源包共一百六十一个文件包含六个高级着色语言源码、四个着色器文件、十二个材质示例、四个脚本、三个预制体以及若干纹理贴图与说明文档从代码到场景预设完整覆盖压缩包大小约三十二兆。项目附有渲染公式说明与调试查看指引便于理解金属工作流在移动端的精简实现思路。目前已有约一千一百五十八人学习下载适合具备一定渲染基础、希望掌握移动端自定义扩展方法的开发者深入学习与二次改造。 打开Unity工程看到pbr_proj这个目录的时候我脑子里第一个反应是又一个把PBR当“金属光滑度”来做的人。后来自己上手维护过两个自研渲染项目之后才明白这个目录名背后的分量。PBR全称 Physically Based Rendering物理着色它不是某一个具体算法而是一套“让材质在不同光照环境下表现一致”的渲染思路。pbr_proj就是我在Unity里实现的一套轻型PBR着色器。它不是官方Universal RP Lit的替代品至少现阶段还达不到那个完成度但它能把光照模型、材质参数、性能开销这几件事讲得清清楚楚适合想深入Unity渲染细节的开发者也适合中小项目直接拿去改。这篇文章不会贴一份完整几千行Shader就收工而是把落地过程中涉及的原理、取舍、坑和调试方法一起梳理出来。按照“为什么要自研、光照模型怎么落地、URP里怎么组织Pass、实际项目里踩过哪些雷”这个顺序往下走争取看完之后你自己也能动手写一套能在移动端运行的PBR Shader。1. 为什么自研一套PBR Shader而不是直接用Standard1.1 pbr_proj的目标与适用场景官方URP Lit在材质数量不大、光照环境可控的项目里已经很好用。但当你遇到下面这些情况自研的价值就出来了美术想要一套统一的材质面板和贴图打包规范项目需要在移动端砍掉一半用不上的功能分支团队想在某个材质上做特殊的光照响应比如各向异性高光、风格化PBR混合、描边与PBR共用一套数据。这时候去改官方Lit光是追它的Keywords和依赖链就够喝一壶。pbr_proj的定位是“轻量、可改、兼容URP”。它保留金属工作流的核心参数把不需要的变体和分支尽可能删掉同时把代码结构按“直接光、间接光、材质数据、工具函数”拆开让团队后续能快速定位问题。适合的读者是已经能用ShaderLab写BasicShader、但对PBR背后BRDF理解还不深的人也适合中小团队做移动端项目时拿这一套作为渲染基础而不是啃官方一堆cginc/hlsl里的兼容代码。1.2 自研与官方链路的关键差异官方URP Lit的代码量大很大一部分原因是它要兼容不同渲染路径、不同平台、不同Unity版本。自研的好处是你可以只保留项目真正在用的那几条分支。举个典型例子官方Lit默认带_ALPHAPREMULTIPLY_ON、_RECEIVE_SHADOWS_OFF、_SCREEN_SPACE_OCCLUSION等大量开关哪怕你根本不用也会在变体编译时占用时间和包体。自研Shader如果需求明确这些开关可以直接不声明落地的变体数量能压缩到官方Shader的一半甚至更低。代价也明显阴影接收、雾效、光照贴图、Light Probe、SRP Batcher兼容这些“基础设施”都要自己接回来。我见过不少自研Shader团队Lighting写得很漂亮但物体不投影、不受阴影、不进光照贴图最后只能把场景光照全部做成假烘焙那还不如直接用官方Lit。所以自研PBR的边界要清楚你优化的是材质模型和渲染效果不是把所有管线配套都推翻重做。2. PBR核心原理与Shader落地D/G/F怎么实现2.1 金属工作流与材质参数的映射关系PBR在Unity里最常用的落地方式是金属工作流它把材质简化为几个物理量反照率BaseColor、金属度Metallic、光滑度Smoothness、法线、环境光遮蔽AO。对非金属来说反射颜色几乎和BaseColor无关反射率大概是4%左右对金属来说反射颜色直接来自BaseColor而且漫反射几乎为零。因此Shader里要把BaseColor同时喂给两个地方非金属走漫反射金属走镜面反射的F0。这个映射关系很关键很多人写自研PBR第一版时直接写F0 BaseColor结果所有材质都像金属反光。正确的简化处理是half3 f0 lerp(0.04, baseColor, metallic); half3 diffuseColor baseColor * (1 - metallic);同时还要设定颜色空间。PBR计算必须在Linear空间下进行如果一个工程还在Gamma Space下渲染贴图采样得到的sRGB数据会被当成线性数据参与光照高光和漫反射的比例会失真材质整体发灰。项目初期就在Player Settings里把Color Space切到Linear这个比后面任何调试都省事。2.2 直接光BRDFD、G、F三件套直接光照是PBR的主心骨。Cook-Torrance微表面BRDF里会用到D项法线分布、G项几何遮蔽和F项菲涅尔。这里给出一个精简但物理合理的实现对应的代码可以直接放在Shader的HLSL片段里// Schlick菲涅尔近似掠射角处反射增强 half3 F_Schlick(half3 f0, half VoH) { half f pow(1 - VoH, 5); return f0 (half3(1, 1, 1) - f0) * f; } // GGX法线分布粗糙度决定高光斑的扩散程度 half D_GGX(half NdotH, half roughness2) { half d (NdotH * NdotH) * (roughness2 - 1) 1; return roughness2 / (PI * d * d); } // Smith高度相关遮蔽近似 half V_SmithJoint(half NdotV, half NdotL, half roughness2) { half a2 roughness2; half gv NdotV * sqrt((1 - a2) * NdotL * NdotL a2); half gl NdotL * sqrt((1 - a2) * NdotV * NdotV a2); return 0.5 / max(gv gl, 1e-5); }注意这里的roughness2不是直接把美术给的Smoothness拿来用。Unity内部会把Smoothness先换算成感知粗糙度再平方作为GGX的参数通常half perceptualRoughness saturate(1 - smoothness); half roughness perceptualRoughness * perceptualRoughness;如果不做这一步直接用1 - smoothness去驱动GGX高光会集中在小半径范围看起来像油腻的塑料。这个细节是自研PBR和官方Shader观感差距的主要来源之一。完整直接光合成时把三件套拼起来再乘以光照颜色和NdotLhalf3 DirectLighting(half3 lightColor, half3 albedo, half metallic, half smoothness, half3 N, half3 L, half3 V, half NdotL) { half3 H normalize(L V); half NdotH saturate(dot(N, H)); half NdotV saturate(dot(N, V)); half VoH saturate(dot(V, H)); half3 f0 lerp(0.04, albedo, metallic); half3 F F_Schlick(f0, VoH); half perceptualRoughness saturate(1 - smoothness); half roughness perceptualRoughness * perceptualRoughness; half D D_GGX(NdotH, roughness); half Vis V_SmithJoint(NdotV, NdotL, roughness); half3 spec D * Vis * F; half3 diffuse albedo * (1 - metallic) * (half3(1, 1, 1) - F); return (diffuse spec) * lightColor * NdotL; }这段代码表达的是物理直觉菲涅尔项在高光越强的地方漫反射越弱金属材质几乎不产生漫反射粗糙度越高微表面朝向越乱高光斑越大且越弱。真实项目里还要处理多盏实时光、点光源和聚光灯的衰减但在URP里可以借助GetAdditionalLight来循环不必自己重新写衰减模型。2.3 环境光与光照贴图的接入思路直接光只是PBR的一半另一半是环境光。静态场景里PBR质感很大程度来自于天空盒反射、光照探针和光照贴图的配合。在自研Shader里我建议按三个层次接入第一个层次是漫反射环境光用球谐SH近似。URP提供了一组unity_SHAr到unity_SHAh的球谐系数也可以直接调用SampleSH(N)它能把方向转换为低频环境光颜色。这个结果适合静态物体的间接漫反射但动态物在移动端表现偏平可以配合Light Probe组来补充。第二个层次是镜面反射环境光需要反射探针Reflection Probe。URP里能调用GlossyEnvironmentReflection它会根据粗糙度选择Cubemap的Mip级别粗糙度越高采样的Mip越模糊。这个函数相当于替你把“粗糙度到反射模糊度”的映射做完了自己实现时要注意如果没有放置反射探针URP会默认用天空盒反射粗糙物体在近距离看会出现明显的过滤不足。第三个层次是烘焙光照贴图。Shader里必须为UV2和采样状态预留接口否则物体不会接收烘焙阴影和GI。核心代码类似float2 lightmapUV staticLightmapUV; half3 indirectDiffuse SampleLightmap(lightmapUV, half3(0, 0, 0));如果混合使用实时与烘焙光源还要判断LIGHTMAP_ON、DYNAMICLIGHTMAP_ON等关键字。我的建议是第一版先走通Lightmap和SH不要一下子把所有GI组合都做全否则变体数量立刻失控。3. URP下从零搭建PBR Shader的完整流程3.1 属性面板设计与纹理约定自研Shader的材质面板本质上是给美术定义一个规范。pbr_proj第一版就把属性收敛到五张主要输入BaseColor贴图、金属度贴图、光滑度控制、法线贴图、AO贴图。这里有一个Unity特有的约定建议沿用把Metallic贴图的RGB通道存金属度Alpha通道存Smoothness这样一张Mask贴图能完成两件事节省移动端的纹理采样次数。属性面板的声明要放在Properties块里但Shader内实际使用的变量需要放在常量缓冲区CBUFFER里这个后面会详细讲。贴图和变量的对应关系我整理成表格着色器属性物理含义输入形式sRGB采样_BaseMap反照率/基础色颜色贴图是_Metallic金属度标量 或 Mask贴图R通道否_Smoothness光滑度标量 或 Mask贴图A通道否_NormalMap法线扰动法线贴图否_OcclusionMap环境光遮蔽灰度贴图否法线贴图、Mask贴图、AO贴图都要在纹理导入设置里关闭sRGB否则PBR计算结果会产生偏移。颜色贴图保持sRGB打开。这个规则如果反了最典型的症状是材质发灰、高光变得浑浊。3.2 空转陷阱SRP Batcher与CBUFFERURP下写Shader最大的隐藏陷阱是SRP Batcher。它要求所有Material属性必须放在名为UnityPerMaterial的CBUFFER里且在Pass内对属性的声明不能落在常量缓冲区之外。很多自研Shader第一版没注意这个画面上所有物体都在慢速渲染你却找不到原因。正确的最小声明方式CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; half _Metallic; half _Smoothness; half _OcclusionStrength; CBUFFER_END注意_BaseMap_ST必须保留这是Unity宏TRANSFORM_TEX在采样平铺和偏移时依赖的UnityPerMaterial内置数据删掉它整个材质面板的Tiling和Offset都会失效也会破坏SRP Batcher的依赖检查。在Vertex Shader里使用TRANSFORM_TEX(v.uv, _BaseMap)可以保持和官方一致。如果你观察到Frame Debugger里Draw Call带上了[Batch]标记说明SRP Batcher生效如果看到一堆[SRP Batcher] Invalid的警告第一步就去检查CBUFFER里的变量是不是完整。3.3 阴影、深度与多Pass的正确姿势自研PBR在URP里最少需要三个PassForward、ShadowCaster、DepthOnly。只写Forward的Shader物体可以显示但不会投影也不会参与深度预处理个别情况下还会因为DepthTexture缺失导致后处理效果出错。ShadowCaster可以复用URP内置的Pass实现Pass { Name ShadowCaster Tags { LightMode ShadowCaster } ZWrite On ZTest LEqual ColorMask 0 HLSLPROGRAM #pragma vertex ShadowPassVertex #pragma fragment ShadowPassFragment #include Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl ENDHLSL }这是一个成本极低但效果明确的方案。如果材质是Alpha Test的植被或面片还要在Shader里实现_Cutoff相关的裁剪逻辑并在ShadowCaster里同步裁剪否则投影和实体形状对不上。透明材质的阴影通常要单独考虑半透明物体投影到墙上阴影形状会变得很怪工程上一般会把他们的ShadowCaster改成不透明或者干脆关闭投射。4. 实际项目踩坑实录与移动端优化建议4.1 材质发灰、高光刺眼、光照跳变怎么排查自研PBR最容易出问题的不是光照模型本身而是数据和工程配置。材质发灰首先检查Color Space是不是Linear其次是贴图sRGB标记最后怀疑法线贴图压缩格式。在一个Unity版本升级后某些ASTC法线贴图在移动端看起来会“糊”掉但iOS和Android表现不同这种时候可以用RenderDoc逐像素看输入端数值不要靠肉眼去猜。高光刺眼的原因一般是Fresnel中的F0取值过高。有一个很典型的错误把BaseColor直接当成F0金属度接近0的材质却出现金属一样白亮的高光。正确做法是用lerp(0.04, baseColor, metallic)并且把pow(1 - VoH, 5)里的VoH用saturate(dot(V, H))保护起来避免负数进入Pow函数导致边缘出现异常亮斑。光照跳变最常见的是阴影坐标算错。自研Shader在接收方向光阴影时需要在顶点着色器里计算出shadowCoord并在片段里调用MainLightRealtimeShadow(shadowCoord)。如果忘记在Forward Pass声明_MAIN_LIGHT_SHADOWS、_MAIN_LIGHT_SHADOWS_CASCADE这些Keywords或者变换坐标时没有把世界坐标转入Shadow空间物体在移动时会一闪一闪地脱离阴影。排除方法很直接把MainLightRealtimeShadow的返回值临时输出成颜色看到阴影区和非阴影区有明显分界就说明坐标链路正常。4.2 变体数量与移动端的性能取舍自研PBR的变体数量直接决定包体、编译时长和运行时的Shader切换开销。官方Lit动辄几百个变体是它兼容性太强导致的自研时不要盲目铺开关二值功能优先用shader_feature_local跨Pass共享的全局选项才考虑multi_compile。移动端画质相关的取舍我的习惯是法线贴图和Mask贴图合并采样减少Texture Fetch次数。漫反射环境光优先用SH不接实时反射探针。金属度、Smoothness、AO合并成一张Mask贴图减少一个纹理采样。关闭不必要的额外实时光源循环用_ADDITIONAL_LIGHTS关键字控制而不是每帧遍历全部光源。静态物体交给光照贴图动态物体用Light Probe场景里的实时点光源能少则少。不要迷信Surface Shader那套“Write到GBuffer更高效”的说法。移动端Forward渲染对带宽更友好PBR计算量集中在一张主Tex和一张Mask上时帧率通常能压住。上线前最好在真机上用Unity Profiler的GPU模块跑一轮Android端还可以用Simpleperf去采样热点确认瓶颈是Overdraw还是Shader指令数再决定要不要分LOD Shader。4.3 常见问题速查表现象可能原因处理方式材质发灰高光浑浊工程在Gamma空间或贴图sRGB标记错误切Linear Color Space按贴图类型修正sRGB高光刺眼像塑料反光F0直接用贴图颜色改用lerp(0.04, baseColor, metallic)物体不受阴影缺少ShadowCaster Pass加ShadowCaster / DepthOnly Pass影子跟着物体抖动闪烁ShadowCoord算错或Keywords缺失检查_MAIN_LIGHT_SHADOWS声明与变换SRP Batcher不生效属性没进CBUFFER把Material属性放进UnityPerMaterial法线方向在部分面上反了TBN矩阵没乘副切线符号用cross(normal, tangent) * tangent.w计算bitangent粗糙物体反射像镜子粗糙度没有影响反射探针Mip用GlossyEnvironmentReflection并映射Mip级别光照贴图区域全黑UV2没传或LIGHTMAP_ON未启用在顶点输入里传递staticLightmapUV并声明相关Keyword这套Shader我维护了大半年从最初在材质面板上堆各种开关到后来把不需要的关键字一个个删掉最深的体会是自研PBR的关键不在于“写一个能跑的光照模型”而在于让团队在三个月后还能改得动、调得动。如果你也准备动手我建议不要急着把功能做圆满先让一个球和一个Cube在天空盒、平行光和阴影下表现正确再逐步加Lightmap、反射探针、额外光源每一步都能单独验证。这样即使出问题你也永远知道该去查哪一段代码。本文还有配套的精品资源点击获取