ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零搞懂URP下的PBR渲染:原理、Shader实现与避坑指南

从零搞懂URP下的PBR渲染:原理、Shader实现与避坑指南 1. 从零搞懂PBR为什么URP管线下的渲染看起来更真实很多人第一次接触PBR这个词是在游戏引擎的材质面板里看到一堆滑块——金属度、光滑度、环境光遮蔽调来调去也不知道背后发生了什么。我当初也是这样直到被一个金属盔甲的反光问题折磨了整整两天才下决心把PBR的底层逻辑彻底搞清楚。这篇笔记就是那段时间的产物基于URP管线从原理到Shader代码把PBR拆开揉碎讲一遍。PBR全称Physically Based Rendering中文叫基于物理的渲染。它的核心思想其实一句话就能概括用物理学的规律来描述光线和物体表面的交互而不是靠美术凭感觉调参数。在PBR之前我们用的是Lambert、Blinn-Phong这类经验模型它们能出效果但换个光照环境就崩了——你在A场景调好的材质放到B场景里要么太亮要么太暗反光位置也不对。PBR解决的就是这个“跨场景一致性”的问题。URP是Unity的通用渲染管线相比内置管线它的Shader编写方式、光照数据的传递方式都有很大变化。很多人在内置管线里写过PBR Shader转到URP之后发现原来的代码完全跑不通光照方向不对、阴影丢失、多光源失效这些都是常见问题。这篇笔记会覆盖PBR的核心理论、URP管线的光照数据传递机制、完整的Shader实现代码以及实际调参中踩过的坑。适合谁看如果你已经写过基本的URP Shader知道顶点片元着色器的基本结构但对PBR的数学原理一知半解或者想自己手写一套PBR Shader而不是依赖Shader Graph那这篇内容正好对路。如果你完全没接触过Shader建议先把URP的基础Shader结构过一遍再回来。2. PBR核心理论拆解能量守恒与BRDF到底在说什么2.1 能量守恒入射光不会凭空消失PBR最基础的一条物理原则是能量守恒。光线打到物体表面一部分被反射高光一部分被吸收后重新发射出来漫反射还有一部分可能透射过去。反射和吸收的能量加起来不能超过入射光的总能量。这个约束听起来简单但它直接决定了材质参数之间的联动关系。在传统的Blinn-Phong模型里你可以把高光强度调到10同时漫反射颜色也是纯白结果就是物体比光源还亮看起来像自发光。PBR不允许这种情况发生——金属度越高漫反射越弱因为金属几乎不产生次表面散射所有被吸收的光都以反射形式重新发射。这就是为什么在PBR材质里你把金属度拉到1漫反射颜色会自动变黑。具体到数学上能量守恒体现在BRDF双向反射分布函数的归一化因子上。漫反射项的BRDF是albedo/π那个除以π就是归一化系数保证半球积分结果为albedo。高光项的BRDF在微表面模型下也有类似的归一化处理。如果你自己写Shader时忘了这个除以π材质就会明显偏亮。2.2 BRDF描述光线如何从表面弹开BRDF是PBR的核心数学工具它回答的问题是从方向ωi入射的光有多少能量会反射到方向ωo。完整的BRDF由漫反射项和高光项组成f(l, v) f_diffuse(l, v) f_specular(l, v)漫反射项通常用Lambert模型简单说就是光线被均匀地散射到各个方向跟观察角度无关。高光项就复杂了目前URP默认使用的是Cook-Torrance微表面模型它把物体表面看成由无数微小的镜面组成每个微镜面的朝向服从某种统计分布。Cook-Torrance高光项的公式是f_specular D(H) * F(V, H) * G(L, V, H) / (4 * NdotL * NdotV)其中D是法线分布函数描述微镜面朝向的统计规律F是菲涅尔项描述不同角度下的反射率G是几何遮蔽项描述微镜面之间的遮挡。分母的4 * NdotL * NdotV是归一化因子。这三个函数各有多种实现方案URP默认用的是GGX分布、Schlick菲涅尔近似和Smith几何遮蔽。选择GGX的原因是它的长尾分布更符合真实材质的测量数据金属表面的高光拖尾效果更自然。Schlick近似则是因为它计算量小且精度足够Smith几何遮蔽和GGX分布搭配有解析解性能上更优。2.3 菲涅尔效应为什么水面近处透明远处反光菲涅尔效应是PBR里最直观也最容易被忽略的现象。你站在湖边低头看脚下的水是透明的能看到水底的石头抬头看远处的水面却像镜子一样反射着天空。这就是菲涅尔效应——反射率随观察角度变化掠射角下反射率趋近于1。在Shader里菲涅尔项通常用Schlick近似来计算F F0 (1 - F0) * pow(1 - VdotH, 5)F0是垂直入射时的反射率对于非金属大约是0.04对于金属则等于albedo颜色。这个公式的意思是当视线方向与半角向量越接近垂直VdotH趋近于1F越接近F0当视线越掠射VdotH趋近于0F越接近1。实际调参时F0这个值很关键。很多人把非金属的F0设成0.5甚至更高结果就是塑料看起来像金属。记住非金属的F0默认就是0.04不要随便改。金属的F0就是它的albedo这也是为什么金属的漫反射为0——所有能量都走反射通道了。2.4 金属度工作流 vs 镜面反射工作流PBR材质有两种常见的工作流金属度工作流和镜面反射工作流。URP默认的Lit Shader用的是金属度工作流你只需要提供albedo、金属度、光滑度三个核心参数F0和漫反射颜色由引擎自动推导。金属度工作流的推导逻辑是这样的如果金属度0那么F00.04漫反射albedo如果金属度1那么F0albedo漫反射0。中间值则做线性插值。这种工作流的优点是参数少、不易出错缺点是对于某些特殊材质比如多层涂层表现力不够。镜面反射工作流则允许你直接指定F0和漫反射颜色灵活性更高但参数之间容易产生物理上不合理的结果。我在实际项目里基本都用金属度工作流只有在做车漆、清漆这类需要单独控制镜面反射颜色的材质时才会切到镜面反射工作流。3. URP管线下的光照数据传递跟内置管线有什么不同3.1 URP的光照模式与主光源获取内置管线里我们习惯用_WorldSpaceLightPos0和_LightColor0这两个内置变量来获取主光源的方向和颜色。但在URP里这两个变量虽然还能用但语义变了——_WorldSpaceLightPos0在URP里存储的是主光源的方向已经是归一化的而不是位置。如果你按内置管线的习惯去减顶点位置结果就全错了。URP获取主光源的正确方式是使用GetMainLight()函数Light mainLight GetMainLight(); float3 lightDir mainLight.direction; float3 lightColor mainLight.color; float shadowAtten mainLight.shadowAttenuation;这个函数定义在Lighting.hlsl里它会自动处理光源方向、颜色、阴影衰减、距离衰减等信息。如果你需要获取光源的位置比如点光源可以用GetMainLight()返回的Light结构体里的position字段。3.2 多光源循环与附加光源处理URP默认只支持一个主光源通常是方向光加上若干个附加光源点光源、聚光灯。附加光源的处理需要用GetAdditionalLightsCount()和GetAdditionalLight()int additionalLightsCount GetAdditionalLightsCount(); for (int i 0; i additionalLightsCount; i) { Light light GetAdditionalLight(i, positionWS); // 计算该光源的贡献 }这里有个坑附加光源的索引和实际光源的对应关系不是固定的URP会根据光源的重要性动态排序。所以你不能假设索引0就是某个特定的点光源。另外附加光源默认不产生阴影如果需要阴影要在URP Asset里开启Additional Light Shadows。还有一个性能相关的点URP的附加光源是在片元着色器里逐像素计算的如果场景里有大量点光源性能开销会很大。我通常会把附加光源数量限制在4个以内超过的光源用光照探针或者烘焙光照来替代。3.3 阴影采样与级联阴影贴图URP的阴影采样比内置管线简单很多直接用MainLight的shadowAttenuation就行。但要注意这个值只有在片元着色器里才是正确的顶点着色器里拿到的是插值后的近似值。级联阴影贴图Cascaded Shadow Maps是URP处理大场景阴影的方案它把视锥体分成几个层级近处用高分辨率阴影贴图远处用低分辨率。在Shader里你不需要手动处理级联切换URP会自动根据深度选择正确的级联。但如果你自己写了自定义的阴影采样逻辑就需要用ComputeCascadeIndex()来计算当前像素属于哪个级联。阴影采样还有一个常见问题是阴影痤疮Shadow Acne表现为物体表面出现条纹状的阴影。解决方法是在采样阴影贴图时加上一个深度偏移URP的Shadow.hlsl里已经内置了这个偏移但如果你自己写阴影采样记得加上float bias max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);3.4 环境光照与球谐函数URP的环境光照通过球谐函数Spherical Harmonics来传递。SampleSH()函数可以获取某个法线方向上的环境光照颜色float3 ambient SampleSH(normalWS);球谐函数的好处是用极小的存储空间通常9个系数就能近似整个半球的环境光照分布。缺点是精度有限对于高频变化的环境光比如窗户透进来的光表现不够好。如果需要更高精度的环境反射可以用Reflection Probe通过SampleReflectionProbe()来采样。我在实际项目里发现很多人在URP下做PBR时忘了加环境光贡献结果物体背光面全黑看起来很不自然。记住PBR的最终颜色 直接光照贡献 间接光照贡献间接光照包括环境光和反射探针缺一不可。4. 手写URP PBR Shader从顶点到片元的完整实现4.1 Shader框架搭建与属性定义先搭好URP Shader的基本框架。跟内置管线不同URP Shader需要引入Core.hlsl和Lighting.hlsl并且要声明RenderPipeline标签Shader Custom/URP_PBR { Properties { _BaseMap (Albedo 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 { 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 // ... ENDHLSL } } }这里的关键点是multi_compile指令它们决定了Shader会编译出哪些变体。_MAIN_LIGHT_SHADOWS控制主光源阴影_ADDITIONAL_LIGHTS控制附加光源_SHADOWS_SOFT控制软阴影。如果你不声明这些变体即使URP Asset里开了对应功能Shader里也拿不到数据。4.2 顶点着色器法线变换与切线空间顶点着色器的主要工作是把顶点位置从对象空间变换到裁剪空间同时把法线、切线、UV等数据传递到片元着色器。URP提供了TransformObjectToHClip()等工具函数比手动乘矩阵方便很多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 normalInputs.tangentWS; output.bitangentWS normalInputs.bitangentWS; output.uv TRANSFORM_TEX(input.uv, _BaseMap); return output; }法线变换这里有个容易出错的地方法线不能用MVP矩阵直接变换因为法线是方向向量非均匀缩放会破坏其垂直性。正确做法是用逆转置矩阵。URP的GetVertexNormalInputs()内部已经处理了这个问题直接调用即可。切线空间的计算也需要留意。如果模型没有导入切线数据GetVertexNormalInputs()会返回一个默认的切线可能导致法线贴图效果异常。在模型导入设置里确保“Tangents”选项是“Calculate”或“Import”。4.3 片元着色器直接光照计算片元着色器是PBR的核心需要计算直接光照和间接光照的贡献。先看直接光照部分float4 frag(Varyings input) : SV_Target { // 采样贴图 float4 baseMap SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); float3 albedo baseMap.rgb * _BaseColor.rgb; float metallic SAMPLE_TEXTURE2D(_MetallicMap, sampler_MetallicMap, input.uv).r * _Metallic; float smoothness SAMPLE_TEXTURE2D(_SmoothnessMap, sampler_SmoothnessMap, input.uv).r * _Smoothness; float occlusion lerp(1.0, SAMPLE_TEXTURE2D(_OcclusionMap, sampler_OcclusionMap, input.uv).r, _OcclusionStrength); // 法线贴图采样 float3 normalTS UnpackNormalScale(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.uv), _NormalScale); float3 normalWS TransformTangentToWorld(normalTS, float3x3(input.tangentWS, input.bitangentWS, input.normalWS)); normalWS normalize(normalWS); // 视角方向 float3 viewDirWS normalize(GetWorldSpaceViewDir(input.positionWS)); // 计算F0和漫反射颜色 float3 F0 lerp(0.04, albedo, metallic); float3 diffuseColor albedo * (1 - metallic); // 粗糙度 float roughness 1 - smoothness; float alpha roughness * roughness; // 主光源 Light mainLight GetMainLight(TransformWorldToShadowCoord(input.positionWS)); float3 lightDir mainLight.direction; float3 lightColor mainLight.color * mainLight.shadowAttenuation; // 计算BRDF float3 H normalize(lightDir viewDirWS); float NdotL saturate(dot(normalWS, lightDir)); float NdotV saturate(dot(normalWS, viewDirWS)); float NdotH saturate(dot(normalWS, H)); float VdotH saturate(dot(viewDirWS, H)); // D项GGX法线分布 float a2 alpha * alpha; float d (NdotH * NdotH) * (a2 - 1) 1; float D a2 / (PI * d * d); // F项Schlick菲涅尔 float3 F F0 (1 - F0) * pow(1 - VdotH, 5); // G项Smith几何遮蔽 float k alpha / 2; float G1L NdotL / (NdotL * (1 - k) k); float G1V NdotV / (NdotV * (1 - k) k); float G G1L * G1V; // 高光BRDF float3 specular (D * F * G) / (4 * NdotL * NdotV 0.001); // 漫反射BRDF float3 diffuse diffuseColor / PI; // 直接光照贡献 float3 directLighting (diffuse specular) * lightColor * NdotL; // ...间接光照和附加光源 }这段代码里有几个关键点需要解释。第一alpha roughness * roughness这是UE4提出的经验映射目的是让粗糙度的感知更线性。如果你直接用roughness中间值的高光会显得过于锐利。第二分母加了0.001防止除零虽然理论上NdotL和NdotV不会同时为0但浮点精度问题可能导致极小值。第三G项用的是Smith-Joint模型比分离的Smith模型更准确但计算量稍大。4.4 间接光照与环境反射间接光照部分包括环境漫反射和环境镜面反射// 环境漫反射 float3 ambient SampleSH(normalWS); float3 indirectDiffuse ambient * diffuseColor * occlusion; // 环境镜面反射 float3 reflectDir reflect(-viewDirWS, normalWS); float mip roughness * 6; // 假设反射探针有7级mip float3 indirectSpecular SAMPLE_TEXTURE2D_LOD(_ReflectionProbe, sampler_ReflectionProbe, reflectDir, mip).rgb; indirectSpecular * F0 * occlusion; // 间接光照贡献 float3 indirectLighting indirectDiffuse indirectSpecular;环境镜面反射这里我用了一个简化的写法直接采样反射探针的贴图。实际项目中URP提供了SampleReflectionProbe()函数它会自动处理反射探针的混合和mip选择。但如果你想要更精细的控制可以手动计算mip级别。mip级别的计算有个经验公式mip roughness * (mipCount - 1)。粗糙度越高采样的mip级别越高反射越模糊。这个映射不是线性的因为GGX分布的方差和粗糙度不是线性关系。UE4用的是roughness * (1.2 - 0.2 * roughness)这样的映射你可以根据实际效果调整。4.5 附加光源循环与最终合成最后把附加光源的贡献加上并合成最终颜色// 附加光源 #ifdef _ADDITIONAL_LIGHTS int additionalLightsCount GetAdditionalLightsCount(); for (int i 0; i additionalLightsCount; i) { Light light GetAdditionalLight(i, input.positionWS); float3 lightDir light.direction; float3 lightColor light.color * light.distanceAttenuation * light.shadowAttenuation; // 同样的BRDF计算 float3 H normalize(lightDir viewDirWS); float NdotL saturate(dot(normalWS, lightDir)); // ...重复上面的BRDF计算 directLighting (diffuse specular) * lightColor * NdotL; } #endif // 最终颜色 float3 finalColor directLighting indirectLighting; // 色调映射 finalColor ACESTonemap(finalColor); // 伽马校正 finalColor LinearToSRGB(finalColor); return float4(finalColor, baseMap.a * _BaseColor.a);色调映射这一步很多人会忽略但它对最终效果影响很大。PBR计算出来的颜色是HDR的亮度可能超过1直接输出到LDR屏幕会被截断导致高光区域一片死白。ACES色调映射是目前业界标准它能把HDR颜色平滑地映射到LDR范围保留高光和暗部的细节。伽马校正也是必须的。PBR的所有计算都在线性空间进行但显示器是sRGB空间的所以最后要把线性颜色转回sRGB。URP的LinearToSRGB()函数就是做这个的。如果你在URP Asset里开了SRGB Write这一步可以省略但手动做更可控。5. 实操避坑指南那些文档里不会写的经验5.1 金属度贴图与光滑度贴图的通道打包在实际项目中为了节省显存和带宽通常会把多张贴图打包到一张RGB贴图的不同通道里。常见的打包方案是R通道放金属度G通道放光滑度B通道放环境光遮蔽A通道放高度或者细节遮罩。这种打包方式在Shader里采样一次就能拿到三个参数比分别采样三张贴图快很多。但要注意不同通道的贴图分辨率可能不同打包时要统一到最高分辨率否则低分辨率的通道会有锯齿。还有一个坑是sRGB空间的问题。金属度和光滑度是线性数据不应该做sRGB校正但环境光遮蔽如果是从烘焙软件导出的可能是sRGB空间的。在Unity的贴图导入设置里要把金属度/光滑度贴图的sRGB选项关掉环境光遮蔽贴图则根据导出设置决定。5.2 法线贴图的缩放与混合法线贴图的缩放参数_NormalScale控制法线扰动的强度。很多人把它设成1就完事了但实际上不同模型的UV密度不同同样的法线贴图在不同模型上的表现可能差异很大。UV密度高的模型法线扰动看起来更强烈需要适当降低缩放值。法线贴图的混合也是个常见需求比如角色身上同时有皮肤法线和伤口法线。简单的线性插值会导致法线长度不为1需要重新归一化。更好的做法是用Whiteout混合或者Partial Derivative混合前者适合细节叠加后者适合大范围的法线融合。5.3 高光锯齿与抗锯齿方案PBR的高光在粗糙度较低时非常锐利容易产生锯齿。MSAA对高光锯齿的效果有限因为高光是在片元着色器里计算的MSAA只对抗锯齿几何边缘有效。解决方案有几种一是提高渲染分辨率二是用TAA时间抗锯齿三是手动在Shader里对高光做模糊。TAA是目前最常用的方案它通过在多帧之间抖动采样位置来累积抗锯齿效果。但TAA也有副作用快速移动的物体会产生鬼影。URP的TAA实现里有一个“Motion Vector”选项开启后可以减轻鬼影但需要模型输出运动矢量。如果项目不支持TAA可以在Shader里对高光项做一个基于粗糙度的模糊。具体做法是在计算D项时把alpha值加上一个跟屏幕像素导数相关的偏移float alpha roughness * roughness; float alphaPrime clamp(alpha screenSpaceDerivative, 0.001, 1.0);这个screenSpaceDerivative可以通过ddx和ddy计算NdotH的导数来得到。这样高光边缘会稍微模糊一点锯齿感会明显减轻。5.4 性能优化变体管理与计算简化URP Shader的变体数量很容易失控。每多一个multi_compile变体数量就翻倍。我见过一个项目编译出了上万个变体打包时间长达几个小时。控制变体数量的方法有几种一是用shader_feature代替multi_compile只编译实际用到的变体二是用#pragma skip_variants跳过不需要的变体三是把一些功能拆到不同的Shader里按需切换。计算简化方面如果项目对性能要求很高可以考虑用近似公式替换精确计算。比如菲涅尔项可以用F0 (1-F0) * pow(1-VdotH, 5)的近似但pow(5)可以用乘法链代替x2 xx; x5 x2x2*x。这样能省几个指令周期。还有一个容易忽略的性能点是纹理采样。每多一次纹理采样就多一次内存访问。如果金属度和光滑度打包在一张贴图里就能省一次采样。环境光遮蔽也可以打包进去进一步减少采样次数。5.5 常见问题速查表问题现象可能原因排查方向物体全黑光照方向错误或阴影采样失败检查GetMainLight()的调用确认阴影变体已声明高光位置不对法线贴图未正确解包或切线空间错误检查UnpackNormalScale的调用确认模型有切线数据金属看起来像塑料F0值设置错误确认金属度贴图已正确采样F0 lerp(0.04, albedo, metallic)背光面全黑缺少环境光贡献检查SampleSH()的调用确认Lighting设置里开了环境光高光有锯齿粗糙度过低或分辨率不足提高粗糙度开启TAA或在Shader里做高光模糊多光源下变暗附加光源未正确累加检查_ADDITIONAL_LIGHTS变体确认GetAdditionalLightsCount()的返回值阴影有条纹阴影偏移不足增大阴影偏移值或开启软阴影反射探针不生效反射探针未烘焙或Shader未采样检查反射探针的烘焙状态确认Shader里有采样逻辑这张表是我在实际项目中遇到过的典型问题基本上覆盖了80%的PBR Shader调试场景。遇到问题时按表排查能省不少时间。6. 从PBR延伸出去还能怎么玩PBR Shader写完之后还有很多可以扩展的方向。比如清漆层Clear Coat可以模拟车漆效果在标准PBR之上再叠加一层高光各向异性Anisotropic可以模拟拉丝金属高光会沿着特定方向拉伸次表面散射Subsurface Scattering可以模拟皮肤、玉石这类半透明材质。URP本身也提供了一些高级功能比如Decal投影、光照层Light Layers、屏幕空间反射SSR。这些功能可以和PBR Shader结合使用进一步提升画面表现力。但要注意每增加一个功能Shader的复杂度和性能开销都会上升需要根据项目定位做取舍。我个人在实际操作中的体会是PBR的核心价值不在于公式有多复杂而在于它提供了一套可预测、可复用的材质描述体系。你调好的材质换到另一个场景、另一个光照环境下表现基本一致。这种一致性对于团队协作和项目迭代来说比单纯的画面效果重要得多。踩过几次坑之后我现在拿到一个新材质第一反应不是调参数而是先检查金属度、光滑度、法线这三张核心贴图有没有问题。这三张对了效果基本就对了。
返回列表