简介:这是一套面向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,而不是直接用Standard
1.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 直接光BRDF:D、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观感差距的主要来源之一。
完整直接光合成时,把三件套拼起来再乘以光照颜色和NdotL:
half3 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与CBUFFER
URP下写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里最少需要三个Pass:Forward、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、反射探针、额外光源,每一步都能单独验证。这样即使出问题,你也永远知道该去查哪一段代码。
本文还有配套的精品资源,点击获取