ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity URP PBR Shader从原理到实战:BRDF、金属度工作流与调参指南

Unity URP PBR Shader从原理到实战:BRDF、金属度工作流与调参指南 1. 从零理解PBR为什么它成了现代渲染的标配第一次接触PBRPhysically Based Rendering基于物理的渲染是在做一个室内场景项目的时候。当时用传统的光照模型调材质金属看起来像塑料塑料看起来像纸怎么调参数都不对。后来被一位前辈点醒“你试试PBR金属度和粗糙度两个参数就能把材质质感拉开。”换到PBR工作流之后同一个场景的质感提升了一个档次而且不管换什么光照环境材质表现都稳定得多。这就是PBR的核心价值用物理规律约束渲染方程让材质在不同光照条件下都能保持视觉一致性。传统渲染模型比如Blinn-Phong是经验模型参数调出来好看就行但换一个光照环境就得重新调。PBR不一样它基于能量守恒和微表面理论材质的金属度、粗糙度、基础色这些参数是有物理含义的一旦设定好在晴天、阴天、室内灯光下都能得到合理的结果。这篇笔记面向的是已经在Unity里写过基础Shader、想系统搞懂PBR在URP管线里怎么落地的朋友。如果你还在用Standard管线或者内置管线思路是通的但URP的Shader写法有区别我会在关键位置标注出来。整篇内容会从PBR的数学原理讲起然后一步步拆解URP里怎么实现一个完整的PBR Shader最后分享我在实际项目中踩过的坑和调参经验。2. PBR的数学底座BRDF到底在算什么2.1 渲染方程与BRDF的定位PBR的理论根基是渲染方程用一句话概括就是一个着色点向观察方向发出的辐射亮度等于该点自身发出的光加上所有入射光经过BRDF作用后的反射总和。写成公式是L_o(p, ω_o) L_e(p, ω_o) ∫_Ω f_r(p, ω_i, ω_o) L_i(p, ω_i) (n · ω_i) dω_i这个积分在实际渲染里不可能实时算所以PBR做的第一件事就是把积分拆解成可以直接计算的形式。拆解的关键就是BRDFBidirectional Reflectance Distribution Function双向反射分布函数它描述了“从某个入射方向来的光有多少被反射到某个出射方向”。BRDF有两个必须遵守的物理约束能量守恒反射出去的能量不能超过入射能量和亥姆霍兹互易性交换入射和出射方向BRDF值不变。这两个约束是PBR材质看起来“对”的根本原因。2.2 微表面理论把表面拆成无数小镜子BRDF的经典实现基于微表面理论Microfacet Theory。这个理论的核心假设是任何表面在微观尺度上都是由无数微小的完美镜面组成的每个微面有自己的法线方向。宏观上看到的粗糙表面其实是这些微面法线分布比较散宏观上看到的光滑表面微面法线比较集中。基于这个假设BRDF被拆成三项D项法线分布函数Normal Distribution Function描述微面法线朝向某个方向的概率密度。粗糙度越低法线越集中高光越锐利。G项几何遮蔽函数Geometry Function描述微面之间互相遮挡的比例。掠射角下遮挡严重高光会衰减。F项菲涅尔项Fresnel Term描述反射率随观察角度变化的关系。垂直看表面反射弱掠射看反射强。最终的BRDF是这三项的乘积再除以一个归一化因子f_r (D · G · F) / (4 · (n·ω_i) · (n·ω_o))分母里的4和两个点积是归一化用的保证能量守恒。这个公式就是Cook-Torrance BRDF也是URP里Lit Shader的核心。2.3 为什么选GGX而不是BeckmannD项有很多种实现常见的有Beckmann分布和GGXTrowbridge-Reitz分布。URP默认用的是GGX原因很实际GGX的尾部衰减更慢高光边缘更柔和看起来更接近真实材质。Beckmann分布的高光衰减太快边缘会有明显的截断感金属材质尤其明显。GGX的公式是D_GGX(n, h, α) α² / (π · ((n·h)² · (α² - 1) 1)²)其中α是粗糙度的平方有些实现直接用粗糙度URP里做了映射。这个公式在粗糙度接近0的时候会趋向无穷大实际实现里会加一个极小值防止除零。2.4 菲涅尔项的近似Schlick公式菲涅尔项的精确计算很复杂Schlick给出了一个非常实用的近似F_Schlick(v, h, F0) F0 (1 - F0) · (1 - (v·h))⁵F0是垂直入射时的反射率。对于绝缘体非金属F0大约是0.04对于金属F0就是基础色本身。这个近似在大多数情况下足够准确而且计算量小是实时渲染的标配。注意URP里金属和非金属的F0处理是分开的。非金属的F0固定为0.04乘以一个specular颜色金属的F0直接用基础色。这个区分是PBR材质看起来“对”的关键后面写Shader的时候会详细说。3. URP管线下的PBR Shader结构拆解3.1 URP Shader的基本骨架URP的Shader和内置管线最大的区别是光照计算从Shader内部转移到了管线里。内置管线里你要自己写ForwardBase、ForwardAdd这些Pass自己处理光照衰减。URP把这些封装成了Lighting函数你只需要在片元着色器里调用UniversalFragmentPBR就行。一个完整的URP PBR Shader结构大概是这样的Shader Custom/PBR_Lesson { Properties { _BaseMap (Base Map, 2D) white {} _BaseColor (Base Color, Color) (1,1,1,1) _Metallic (Metallic, Range(0,1)) 0.0 _Smoothness (Smoothness, Range(0,1)) 0.5 _NormalMap (Normal Map, 2D) bump {} _NormalScale (Normal Scale, Range(0,2)) 1.0 _OcclusionMap (Occlusion Map, 2D) white {} _OcclusionStrength (Occlusion Strength, Range(0,1)) 1.0 } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } Pass { Name ForwardLit Tags { LightModeUniversalForward } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE #pragma multi_compile _ _ADDITIONAL_LIGHTS #pragma multi_compile _ _SHADOWS_SOFT #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl // ... ENDHLSL } } }这里有几个关键点RenderPipeline标签必须设为UniversalPipeline否则URP不会识别这个ShaderLightMode标签设为UniversalForward这是URP的主光照Passmulti_compile指令用来开启阴影、额外光源、软阴影这些功能不写的话这些功能不会生效。3.2 顶点着色器的输入输出结构顶点着色器的任务是把模型空间的顶点数据转换到裁剪空间同时把需要的光照计算数据传递到片元着色器。URP提供了Attributes和Varyings这两个结构体但实际项目里我建议自己定义因为默认结构体包含了很多用不到的数据会增加带宽消耗。struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; float2 uv : TEXCOORD0; float2 lightmapUV : TEXCOORD1; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; float3 normalWS : TEXCOORD2; float4 tangentWS : TEXCOORD3; float2 lightmapUV : TEXCOORD4; float3 viewDirWS : TEXCOORD5; float4 shadowCoord : TEXCOORD6; };positionWS、normalWS、viewDirWS这三个是世界空间的数据PBR的光照计算都在世界空间进行。shadowCoord是阴影采样坐标需要在顶点着色器里通过TransformWorldToShadowCoord计算。3.3 片元着色器里的PBR核心逻辑片元着色器是PBR真正干活的地方。核心步骤是采样贴图、构建SurfaceData、调用UniversalFragmentPBR。half4 frag(Varyings input) : SV_Target { // 采样基础色贴图 half4 baseMap SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); half3 baseColor baseMap.rgb * _BaseColor.rgb; // 采样法线贴图并转换到世界空间 half3 normalTS UnpackNormalScale( SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.uv), _NormalScale ); half3 normalWS TransformTangentToWorld(normalTS, half3x3(input.tangentWS.xyz, cross(input.normalWS, input.tangentWS.xyz) * input.tangentWS.w, input.normalWS)); normalWS NormalizeNormalPerPixel(normalWS); // 采样AO贴图 half occlusion lerp(1.0, SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, input.uv).r, _OcclusionStrength); // 构建SurfaceData SurfaceData surfaceData; surfaceData.albedo baseColor; surfaceData.metallic _Metallic; surfaceData.smoothness _Smoothness; surfaceData.normalTS normalTS; surfaceData.occlusion occlusion; surfaceData.alpha 1.0; surfaceData.specular half3(0,0,0); surfaceData.clearCoatMask 0; surfaceData.clearCoatSmoothness 0; surfaceData.emission 0; // 构建InputData InputData inputData; inputData.positionWS input.positionWS; inputData.normalWS normalWS; inputData.viewDirectionWS SafeNormalize(input.viewDirWS); inputData.shadowCoord input.shadowCoord; inputData.fogCoord 0; inputData.vertexLighting 0; inputData.bakedGI SampleSH(normalWS); inputData.normalizedScreenSpaceUV GetNormalizedScreenSpaceUV(input.positionCS); inputData.shadowMask 1; // 调用URP的PBR光照函数 half4 color UniversalFragmentPBR(inputData, surfaceData); return color; }这段代码里最值得注意的是SurfaceData和InputData这两个结构体。SurfaceData描述材质本身的属性颜色、金属度、粗糙度、法线、AOInputData描述着色点的几何和光照环境信息位置、法线、视线方向、阴影坐标、GI。UniversalFragmentPBR把这两个结构体作为输入内部完成所有BRDF计算和光照累加。3.4 金属度工作流 vs 镜面度工作流PBR材质有两种主流工作流金属度工作流Metallic Workflow和镜面度工作流Specular Workflow。URP的Lit Shader默认用金属度工作流我也推荐用这个。金属度工作流的核心参数是基础色Base Color、金属度Metallic、粗糙度Roughness。金属度是一个0到1的值0表示绝缘体1表示金属。实际项目中大多数材质要么是0要么是1中间值只用在过渡区域比如生锈的金属。镜面度工作流直接指定镜面反射颜色灵活度更高但容易做出物理上不合理的材质。金属度工作流用物理约束换取了易用性和一致性对美术来说更友好。实操心得如果美术同学把金属度调成0.5大概率是想要一个“半金属”的效果但物理上这种材质不存在。我的做法是让他明确这个材质到底是金属还是非金属如果是金属就调到1然后通过粗糙度和基础色来调整质感。这样出来的效果反而更可控。4. 完整实操从零写一个URP PBR Shader4.1 项目准备与文件结构先创建一个URP项目。在Package Manager里确认Universal RP已经安装然后在Project Settings的Graphics里把Scriptable Render Pipeline Settings指向URP的Asset。如果没有这个Asset在Assets里右键Create Rendering URP Asset创建一个。Shader文件我习惯放在Assets/Shaders/PBR目录下命名用PBR_Lesson.shader。配套的材质放在Assets/Materials下。测试场景里放一个球体、一个平面、一盏平行光再加一个反射探针Reflection Probe这样能看到金属材质的反射效果。4.2 Properties块的参数设计Properties块决定了材质面板上能看到哪些参数。PBR的核心参数是基础色、金属度、粗糙度、法线、AO。我还会加一个Emission参数方便做自发光材质。Properties { [MainTexture] _BaseMap (Base Map, 2D) white {} [MainColor] _BaseColor (Base Color, Color) (1,1,1,1) [Space(10)] _Metallic (Metallic, Range(0,1)) 0.0 _Smoothness (Smoothness, Range(0,1)) 0.5 [Space(10)] [Normal] _NormalMap (Normal Map, 2D) bump {} _NormalScale (Normal Scale, Range(0,2)) 1.0 [Space(10)] _OcclusionMap (Occlusion Map, 2D) white {} _OcclusionStrength (Occlusion Strength, Range(0,1)) 1.0 [Space(10)] [HDR] _EmissionColor (Emission Color, Color) (0,0,0,1) _EmissionMap (Emission Map, 2D) white {} }[MainTexture]和[MainColor]这两个Attribute是URP特有的用来告诉管线哪个是主贴图和主颜色在Shader Graph和某些工具里会用到。[Normal]标记法线贴图Unity会自动处理sRGB和压缩格式。[HDR]让颜色可以超过1做自发光的时候很有用。4.3 顶点着色器的完整实现顶点着色器要做的事情转换位置、转换法线、计算切线空间、计算阴影坐标、传递UV。Varyings vert(Attributes input) { Varyings output (Varyings)0; // 位置转换 VertexPositionInputs positionInputs GetVertexPositionInputs(input.positionOS.xyz); output.positionCS positionInputs.positionCS; output.positionWS positionInputs.positionWS; // 法线转换 VertexNormalInputs normalInputs GetVertexNormalInputs(input.normalOS, input.tangentOS); output.normalWS normalInputs.normalWS; output.tangentWS float4(normalInputs.tangentWS, input.tangentOS.w); // UV传递 output.uv input.uv; output.lightmapUV input.lightmapUV * unity_LightmapST.xy unity_LightmapST.zw; // 视线方向 output.viewDirWS GetWorldSpaceViewDir(positionInputs.positionWS); // 阴影坐标 output.shadowCoord GetShadowCoord(positionInputs); return output; }GetVertexPositionInputs和GetVertexNormalInputs是URP提供的工具函数内部处理了各种空间转换。GetWorldSpaceViewDir计算从着色点到相机的方向。GetShadowCoord根据主光源的阴影设置计算阴影采样坐标。4.4 片元着色器的逐行解析片元着色器的代码在3.3节已经给过完整版本这里补充几个关键细节。法线贴图的解码UnpackNormalScale会根据平台的压缩格式自动选择正确的解码方式。在移动平台上法线贴图通常用ETC压缩解码方式和桌面平台不一样用这个函数可以避免手动处理。切线空间的构建TransformTangentToWorld需要三个基向量切线、副切线、法线。副切线通过cross(normalWS, tangentWS.xyz) * tangentWS.w计算tangentWS.w是切线的手性handedness用来处理UV镜像的情况。AO的应用AO只影响间接光不影响直接光。URP的UniversalFragmentPBR内部会把surfaceData.occlusion应用到GI项上。如果手动写光照计算要注意这一点。阴影坐标的传递shadowCoord在顶点着色器里计算片元着色器里直接使用。如果开启级联阴影Cascade ShadowGetShadowCoord会自动选择合适的级联层级。4.5 多光源与阴影的编译指令URP的Shader通过multi_compile指令来开启不同的功能变体。不写这些指令对应的功能就不会生效。#pragma multi_compile _ _MAIN_LIGHT_SHADOWS #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS #pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS #pragma multi_compile_fragment _ _SHADOWS_SOFT #pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION #pragma multi_compile _ LIGHTMAP_ON #pragma multi_compile _ DIRLIGHTMAP_COMBINED #pragma multi_compile_fog_MAIN_LIGHT_SHADOWS开启主光源阴影_MAIN_LIGHT_SHADOWS_CASCADE开启级联阴影_ADDITIONAL_LIGHTS开启额外光源的逐像素计算_ADDITIONAL_LIGHT_SHADOWS开启额外光源阴影_SHADOWS_SOFT开启软阴影LIGHTMAP_ON开启光照贴图支持。注意multi_compile和multi_compile_fragment的区别在于前者在顶点和片元着色器里都会生成变体后者只在片元着色器里生成。阴影相关的指令用multi_compile_fragment可以节省顶点着色器的变体数量。5. 调参实战让材质看起来“对”的经验5.1 金属材质的参数范围金属材质的金属度必须是1基础色就是反射颜色。常见的金属参数材质基础色sRGB粗糙度黄金(255, 226, 155)0.1-0.3银(255, 255, 255)0.05-0.2铝(245, 245, 245)0.1-0.4铜(250, 180, 130)0.1-0.3铁(200, 200, 200)0.3-0.6生锈铁(120, 80, 50)0.6-0.9金属材质对反射探针的依赖很大。没有反射探针金属看起来就是一团黑。项目里至少放一个Reflection Probe或者开启Screen Space Reflection。5.2 非金属材质的参数范围非金属材质的金属度是0基础色是漫反射颜色。粗糙度决定了高光的锐利程度材质基础色粗糙度塑料任意0.2-0.5橡胶深色0.6-0.9木材棕色系0.5-0.8大理石浅色0.1-0.3混凝土灰色0.7-0.95皮肤肤色0.4-0.6非金属的F0固定是0.04所以高光颜色不会随基础色变化。如果想让高光带颜色需要改specular参数但URP的Lit Shader默认不暴露这个参数。5.3 粗糙度贴图的制作要点粗糙度贴图通常和金属度贴图打包在同一张图的不同通道里。URP的Lit Shader用的是金属度贴图的R通道存金属度A通道存粗糙度Smoothness。但很多项目用的是R通道金属度G通道粗糙度B通道AO的打包方式这种需要自己写Shader来采样。制作粗糙度贴图的时候不要用纯黑或纯白。纯黑粗糙度0会产生镜面反射在实时渲染里容易出现噪点纯白粗糙度1会让材质看起来像粉笔。实际材质的粗糙度通常在0.1到0.9之间。实操心得如果粗糙度贴图是手绘的建议在Substance Painter里用材质球直接导出不要手动在PS里画。手绘的粗糙度很难保证物理合理性而且和法线贴图不匹配的时候会出现奇怪的高光。5.4 法线贴图的强度控制法线贴图的强度通过_NormalScale控制。URP的UnpackNormalScale会把法线贴图的RG通道乘以这个值然后重建Z通道。强度太高会让表面看起来凹凸不平强度太低则没有效果。我的经验值是细节法线用0.5到1.0大结构法线用1.0到1.5。如果法线贴图是从高模烘焙的强度设为1.0通常就对了。如果是手绘的法线可能需要调到1.5才能看出效果。法线贴图的切线空间要和模型的UV方向匹配。如果模型是镜像UV的切线的手性会翻转法线贴图会出现左右颠倒的问题。解决办法是在导入模型的时候勾选“Calculate MikkTSpace Tangent Space”或者在Shader里处理手性。6. 常见问题与排查实录6.1 材质全黑或全白全黑通常是因为没有光源、光照强度为0、或者Shader编译失败。先检查场景里有没有Directional Light然后看Console有没有报错。如果Shader编译失败通常是include路径写错了或者HLSL语法有问题。全白通常是因为基础色贴图没有赋值、或者光照强度过高。检查材质的Base Map有没有贴图Directional Light的Intensity是不是超过了1。6.2 金属材质没有反射金属材质的反射来自Reflection Probe或者Screen Space Reflection。如果场景里没有Reflection Probe金属会反射默认的Skybox。如果Skybox也是黑的金属就是黑的。解决办法在场景里放一个Reflection ProbeType设为Baked然后Bake一下。或者开启URP的Screen Space Reflection在URP Asset的Lighting设置里。6.3 阴影出现锯齿或漏光阴影锯齿通常是因为阴影贴图分辨率不够。在URP Asset的Shadows设置里把Shadow Resolution调高或者开启Soft Shadows。漏光通常是因为阴影偏移Shadow Bias设置不当在Light组件里调整Bias和Normal Bias。注意Soft Shadows在移动平台上性能开销比较大如果目标平台是手机建议用Hard Shadows加较高的分辨率。6.4 法线贴图效果不对法线贴图效果不对的常见原因贴图没有标记为Normal Map在贴图导入设置里把Texture Type设为Normal Map、切线空间不匹配、法线强度过高或过低。如果法线贴图在模型上看起来是反的可能是切线的手性不对。在Shader里把tangentWS.w取反试试。如果法线贴图在UV接缝处有裂缝通常是MikkTSpace切线空间的问题在模型导入设置里重新计算切线。6.5 性能问题排查PBR Shader的性能开销主要来自光照计算、阴影采样、贴图采样。优化方向减少multi_compile变体数量只保留项目需要的功能用half精度代替float移动平台上尤其重要合并贴图通道把金属度、粗糙度、AO打包到一张图用LOD Group控制远处物体的Shader复杂度关闭不需要的功能比如Screen Space Occlusion问题现象可能原因排查方法材质全黑无光源/Shader编译失败检查光源和Console金属无反射无Reflection Probe添加Reflection Probe阴影锯齿阴影分辨率低提高Shadow Resolution法线反了切线手性错误取反tangentWS.w性能差变体过多/精度过高减少multi_compile用half7. 进阶方向从能用到好用7.1 清漆层Clear Coat清漆层用来模拟车漆、钢琴漆这类有透明涂层的材质。URP的Lit Shader在2021版本之后支持Clear Coat在SurfaceData里设置clearCoatMask和clearCoatSmoothness就行。清漆层会在基础BRDF之上再叠加一层镜面反射让材质看起来有“深度”。7.2 各向异性Anisotropy各向异性用来模拟拉丝金属、头发、丝绸这类有方向性高光的材质。URP的Lit Shader不直接支持各向异性需要自己写BRDF。核心思路是把GGX的粗糙度改成和切线方向相关的值让高光在某个方向上拉长。7.3 次表面散射Subsurface Scattering次表面散射用来模拟皮肤、玉石、蜡烛这类半透明材质。URP没有内置SSS但可以通过修改BRDF的漫反射项来近似。常见做法是用预积分的皮肤贴图Pre-integrated Skin或者屏幕空间的模糊。7.4 自定义BRDF的接入方式如果想在URP里用自定义BRDF有两种方式一是直接修改UniversalFragmentPBR的源码二是自己写光照循环调用自定义的BRDF函数。第一种方式简单但会污染管线代码第二种方式灵活但工作量大。我的建议是如果只是改D项或G项直接改管线源码如果要加全新的BRDF项比如布料BRDF自己写光照循环。自己写光照循环的时候可以参考UniversalFragmentPBR的实现把主光源、额外光源、GI、阴影这些部分拆开处理。8. 我在实际项目中的几点体会PBR不是“调参玄学”它的每一个参数都有物理含义。金属度只有0和1粗糙度对应微表面的法线分布基础色对应材质的反射率或漫反射率。理解这些含义之后调参就变成了“查表”而不是“试错”。反射探针的质量直接决定了金属材质的上限。我见过很多项目金属看起来像塑料就是因为反射探针没做好。要么用高质量的HDRI烘焙要么开启Screen Space Reflection不要用默认的Skybox凑合。法线贴图和粗糙度贴图的匹配度比单独的质量更重要。一张高质量的法线贴图配一张不匹配的粗糙度贴图效果还不如两张中等质量但匹配的贴图。制作的时候最好在Substance Painter里一起导出保证一致性。最后分享一个小技巧在URP里调试PBR材质的时候把Lighting Mode切到“Debug”可以看到每个光照分量的贡献。这个功能在URP的Debug窗口里能快速定位是直接光、间接光还是反射出了问题。
返回列表