尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity Shader进阶:从Phong到PBR的BRDF光照模型实现与调试

Unity Shader进阶:从Phong到PBR的BRDF光照模型实现与调试 1. 项目概述从“看得懂”到“写得对”的Shader进阶之路如果你已经跟着《Unity Shader 入门精要》这本书啃到了第9章恭喜你你已经不再是那个对着Shader代码一脸懵的新手了。前面的章节我们像是在学习一门新语言的语法和单词而第9章则是真正开始用这门语言来“写作文”了。这一章的核心是复杂光照模型。它不再是简单的兰伯特Lambert或冯氏Phong光照而是将现实世界中光与物体表面交互的物理过程用数学公式更精确地模拟出来。对于技术美术TA或希望深入图形编程的开发者来说这是从“玩具Shader”迈向“生产级Shader”的关键一步。为什么这一章如此重要因为在真实的游戏开发中尤其是追求高品质画面的项目中标准着色器Standard Shader或简单的漫反射高光模型往往不够用。你需要为角色皮肤、丝绸、车漆、湿润路面等特殊材质定制光照表现。第9章介绍的BRDF双向反射分布函数思想正是这些高级材质效果的基石。理解它意味着你不仅能复现书中的代码更能举一反三去解读甚至修改Unity的URP/Lit.shader、HDRP的复杂光照代码或者去理解Substance Designer中那些PBR材质球背后的原理。简单来说学完这一章你应该能清晰地回答一个像素的最终颜色除了纹理采样到底是如何被光源方向、视线方向、表面法线、粗糙度、金属度等一系列参数精确计算出来的这个过程就是本章要拆解的核心。2. 核心光照模型原理深度拆解2.1 从Phong到BRDF光照计算的演进逻辑在进入具体公式前我们必须理清一个演进脉络。第7、8章我们实现的Blinn-Phong模型是一个经验模型。它通过“环境光漫反射高光”的简单叠加来模拟光照计算简单但物理依据不足。例如它的高光项pow(max(0, dot(N, H)), _Gloss)中_Gloss光泽度参数与真实的物理属性如粗糙度没有直接的、线性的对应关系调整起来更像是在“调感觉”。而第9章引入的Cook-Torrance BRDF模型则是一个基于微表面理论的物理模型。它的核心思想是宏观上看似平整的表面在微观上是由无数个朝向各异的小镜面微表面组成的。光线照射到表面后其反射行为由这些微表面的法线分布NDF、几何函数G和菲涅尔效应F共同决定。法线分布函数NDF描述微表面法线的朝向分布。比如表面越光滑微表面法线越集中朝向宏观法线方向表面越粗糙法线分布越散乱。这直接决定了高光反射的“形状”和“锐利度”。本章通常会介绍Beckmann和GGXTrowbridge-Reitz两种NDF后者在现代PBR流程中已成为事实标准因为它能产生更柔和、更真实的高光拖尾长尾效应。几何函数G也称阴影遮蔽函数。它模拟了微表面之间的相互遮挡。当视线方向或光线方向接近掠射角时一些微表面会被其他微表面遮挡导致反射光减弱。这个函数确保了在边缘处高光会自然衰减而不是违反物理规律地过亮。菲涅尔方程F描述了光线在不同入射角下被反射和折射的比例。一个关键特性是当视线与表面法线夹角越大即视线越“平”地看表面反射率越高。这就是为什么我们看水池近处清澈见底折射为主远处则像镜子一样反射天空。在金属材质上菲涅尔效应尤为明显。注意很多初学者会混淆“高光强度”和“粗糙度”。在Phong模型里你调的是“高光强度”和“光泽度”。在PBR的BRDF模型里你主要调的是“粗糙度”影响NDF和G而“高光强度”更多地由菲涅尔效应和基础反射率如金属的F0值决定。这是一个思维上的根本转变。2.2 关键数学公式与代码映射理论可能有些枯燥我们直接看它们如何转化为代码。假设我们已经有了以下向量均在世界空间或切线空间归一化后N: 表面法线V: 视线方向从表面点到相机L: 光线方向H: 半角向量即normalize(V L)一个典型的Cook-Torrance BRDF的镜面反射项可以表示为Specular (D * G * F) / (4 * dot(N, V) * dot(N, L))现在我们来拆解1. 法线分布函数 (D) - GGX为例GGX分布能产生更真实的高光拖尾。其公式为float D_GGX(float NdotH, float roughness) { float a roughness * roughness; float a2 a * a; float NdotH2 NdotH * NdotH; float denom (NdotH2 * (a2 - 1.0) 1.0); denom PI * denom * denom; return a2 / max(denom, 0.0000001); // 防止除零 }在Shader中roughness是粗糙度参数0表示光滑1表示粗糙。NdotH是法线与半角向量的点积。你可以看到当roughness很小时分母接近PID值可能很大产生尖锐高光当roughness为1时D值分布平缓。2. 几何函数 (G) - Smith-SchlickGGX近似为了效率常用Schlick近似来模拟Smith几何阴影函数。它分别计算光线和视线方向的几何衰减。float GeometrySchlickGGX(float NdotV, float roughness) { float r (roughness 1.0); float k (r * r) / 8.0; // 对于直接光照的常用近似 float denom NdotV * (1.0 - k) k; return NdotV / denom; } float G_Smith(float NdotV, float NdotL, float roughness) { float ggx1 GeometrySchlickGGX(NdotV, roughness); float ggx2 GeometrySchlickGGX(NdotL, roughness); return ggx1 * ggx2; }这里k的取值在不同版本的近似中可能不同对于IBL基于图像的光照计算可能会用另一个公式。书中的代码会给出具体实现。3. 菲涅尔方程 (F) - Schlick近似同样为了效率使用Schlick近似来计算菲涅尔。float3 F_Schlick(float cosTheta, float3 F0) { return F0 (1.0 - F0) * pow(1.0 - cosTheta, 5.0); }cosTheta通常是dot(H, V)或dot(N, V)。F0是基础反射率即法向入射垂直看向表面时的反射率。这是一个float3因为RGB通道可能不同。对于非金属电介质如塑料、木头、石材F0通常是一个较低的常数如0.04左右。对于金属导体F0就是其本身的颜色例如金是(1.0, 0.86, 0.57)的黄色调。这是PBR中区分金属和非金属的关键。4. 组合计算最终在片元着色器中对于每一个光源我们这样计算其贡献float NdotL max(dot(N, L), 0.0); float NdotV max(dot(N, V), 0.000001); // 防止除零 float NdotH max(dot(N, H), 0.0); float VdotH max(dot(V, H), 0.0); float D D_GGX(NdotH, roughness); float G G_Smith(NdotV, NdotL, roughness); float3 F F_Schlick(VdotH, F0); float3 numerator D * G * F; float denominator 4.0 * NdotV * NdotL; float3 specular numerator / max(denominator, 0.0000001); // 漫反射部分对于金属漫反射为0对于非金属使用兰伯特或更精确的模型如Disney的漫反射模型 float3 kd (1.0 - F) * (1.0 - metallic); // metallic是金属度参数0为非金属1为纯金属 float3 diffuse kd * albedo / PI; // albedo是反照率基础颜色 // 最终该光源的颜色贡献 float3 radiance lightColor * lightIntensity * attenuation; float3 Lo (diffuse specular) * radiance * NdotL;Lo就是该光源对当前像素的贡献。将所有光源的Lo加起来再加上环境光部分IBL就得到了最终的像素颜色。3. Unity Shaderlab实现与关键代码解析理解了原理我们来看如何在Unity的ShaderLab框架下实现它。第9章的示例Shader通常会是一个前向渲染Forward Rendering的Shader并处理多个逐像素光源。3.1 Shader属性与结构体定义首先在Properties块中我们需要定义PBR的核心属性。这与Standard Shader的参数非常相似Properties { _Color (Color, Color) (1,1,1,1) _MainTex (Albedo (RGB), 2D) white {} _Metallic (Metallic, Range(0,1)) 0.0 _Roughness (Roughness, Range(0,1)) 0.5 // 有时会将光滑度Smoothness作为参数Roughness 1 - Smoothness // _Smoothness (Smoothness, Range(0,1)) 0.5 _BumpMap (Normal Map, 2D) bump {} _BumpScale (Normal Scale, Float) 1.0 _OcclusionMap (Occlusion Map, 2D) white {} _OcclusionStrength (Occlusion Strength, Range(0.0, 1.0)) 1.0 }注意_Roughness和_Smoothness是互斥的通常用一个即可。现代工作流更倾向于使用_Roughness因为它与贴图的灰度值线性相关越白越粗糙。接着定义顶点着色器输入和输出结构体struct a2v { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; float2 texcoord : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldPos : TEXCOORD1; float3 worldNormal : TEXCOORD2; float3 worldTangent : TEXCOORD3; float3 worldBinormal : TEXCOORD4; // 对于多光源前向渲染可能需要TEXCOORD5来存储视线的世界方向 float3 worldViewDir : TEXCOORD5; };在顶点着色器中你需要计算并传递这些世界空间向量。特别注意**副法线Binormal**的计算它用于将法线贴图中的向量从切线空间变换到世界空间。v2f vert(a2v v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.texcoord, _MainTex); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.worldNormal UnityObjectToWorldNormal(v.normal); o.worldTangent UnityObjectToWorldDir(v.tangent.xyz); o.worldBinormal cross(o.worldNormal, o.worldTangent) * v.tangent.w; // tangent.w 决定副法线方向 o.worldViewDir UnityWorldSpaceViewDir(o.worldPos); return o; }v.tangent.w通常为1或-1用于处理UV镜像导致的手性handedness问题确保切线空间是正交的。3.2 片元着色器光照计算核心片元着色器是重头戏。我们一步步来构建。第一步采样纹理并准备表面数据。fixed4 frag(v2f i) : SV_Target { // 采样纹理 fixed4 albedoColor tex2D(_MainTex, i.uv) * _Color; float metallic tex2D(_MetallicGlossMap, i.uv).r * _Metallic; // 如果有金属度贴图 float roughness 1.0 - (tex2D(_MetallicGlossMap, i.uv).a * _Gloss); // 如果有光滑度贴图 // 或者直接使用参数 float metallic _Metallic; float roughness _Roughness; // 采样法线贴图并转换到世界空间 fixed4 packedNormal tex2D(_BumpMap, i.uv); float3 tangentNormal UnpackNormal(packedNormal); tangentNormal.xy * _BumpScale; tangentNormal.z sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy))); float3x3 TBN float3x3(i.worldTangent, i.worldBinormal, i.worldNormal); float3 N normalize(mul(TBN, tangentNormal)); // 世界空间法线 // 其他向量 float3 V normalize(i.worldViewDir); float3 worldPos i.worldPos;这里有几个关键点粗糙度转换如果使用光滑度贴图记得roughness 1.0 - smoothness。法线解包UnpackNormal是Unity内置函数处理了不同平台的纹理压缩格式如DXT5nm。手动计算法线Z分量在解包和缩放xy后需要重新归一化。tangentNormal.z sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)))这行代码确保了法线向量长度为1。这是一个重要的细节直接缩放xy而不修正z会导致法线长度不为1影响光照计算精度。第二步计算基础反射率F0。根据金属度混合电介质的默认F0和材质的反照率颜色。float3 F0 float3(0.04, 0.04, 0.04); // 非金属的近似F0 F0 lerp(F0, albedoColor.rgb, metallic); // 金属的F0就是其反照率颜色 float3 albedo albedoColor.rgb * (1 - metallic); // 非金属的反照率金属的漫反射为0第三步处理直接光照。通常需要循环处理所有影响该物体的逐像素光源。Unity在前向渲染中通过_WorldSpaceLightPos0和unity_LightColor等内置变量提供第一个平行光信息其他光源可能需要通过UnityWorldSpaceLightDir和遍历_LightColor0等方式获取具体取决于Shader的Pass和LightMode标签设置。这里以处理一个平行光为例float3 L normalize(_WorldSpaceLightPos0.xyz); // 平行光方向 float3 H normalize(V L); float NdotL max(dot(N, L), 0.0); float NdotV max(dot(N, V), 0.000001); float NdotH max(dot(N, H), 0.0); float VdotH max(dot(V, H), 0.0); // 调用BRDF函数 float D D_GGX(NdotH, roughness); float G G_Smith(NdotV, NdotL, roughness); float3 F F_Schlick(VdotH, F0); float3 numerator D * G * F; float denominator 4.0 * NdotV * NdotL 0.001; // 防止除零 float3 specularBRDF numerator / denominator; float3 kd (1.0 - F) * (1.0 - metallic); float3 diffuseBRDF kd * albedo / PI; float3 lightColor _LightColor0.rgb; float3 directLighting (diffuseBRDF specularBRDF) * lightColor * NdotL;对于点光源或聚光灯L需要根据像素的世界坐标和光源位置计算并加入衰减因子。第四步处理环境光照IBL。直接光照只处理了明确的直接光源。环境光来自天空盒、光照探头等同样重要且计算更为复杂通常使用预计算的环境贴图如立方体贴图和BRDF积分查找表LUT。第9章可能会介绍一个简化版本或者使用Unity内置的SH球谐函数来近似漫反射环境光并用一个粗糙度相关的CubeMap Mip层级来采样镜面反射环境光。// 简化版环境光使用SH计算漫反射环境光 float3 ambientSH ShadeSH9(float4(N, 1)); float3 ambientDiffuse ambientSH * albedo; // 环境漫反射 // 简化版镜面反射环境光采样特定Mip层级的Cubemap float3 reflectDir reflect(-V, N); float mipLevel roughness * 6.0; // 假设Cubemap有7个Mip层级0-6 float4 encodedIrradiance texCUBElod(_EnvironmentCube, float4(reflectDir, mipLevel)); float3 ambientSpecular encodedIrradiance.rgb * F_Schlick(max(dot(N, V), 0.0), F0); // 用菲涅尔项调制 float3 ambientLighting ambientDiffuse ambientSpecular;更精确的实现会使用分割求和近似Split Sum Approximation并采样一张2D的BRDF LUT贴图来组合菲涅尔和粗糙度的影响这通常是URP/HDRP内置Lit Shader的做法。第五步组合最终颜色。float3 color ambientLighting directLighting; // 加上所有光源贡献 // 应用环境光遮蔽AO float occlusion tex2D(_OcclusionMap, i.uv).g; // 通常AO信息存储在绿色通道 occlusion lerp(1.0, occlusion, _OcclusionStrength); color * occlusion; // 线性空间到伽马空间校正如果项目是伽马空间 #ifndef UNITY_COLORSPACE_GAMMA color pow(color, 1.0/2.2); #endif return float4(color, 1.0); }4. 常见问题、调试技巧与性能考量4.1 高频问题排查指南在实现这个复杂Shader的过程中你几乎一定会遇到各种奇怪的现象。下面是一个快速排查表现象可能原因排查步骤模型全黑1. 法线向量错误最常见。2. 光照向量计算错误。3. 属性值未正确传入如_Metallic为0_Roughness为1。1. 在片元着色器返回float4(N, 1)查看法线可视化。确保法线贴图正确采样TBN矩阵计算正确。2. 返回float4(L, 1)或float4(V, 1)检查向量。3. 检查材质面板参数确保贴图已赋值。高光形状奇怪或过亮1. 粗糙度参数映射错误例如把光滑度当粗糙度用。2. 几何函数G或分母计算错误。3. 菲涅尔F0值设置不当。1. 确认roughness参数范围0光滑1粗糙。可视化float4(roughness,0,0,1)。2. 分别可视化D,G,F分量看哪个值异常大。3. 检查金属度混合F0的代码lerp(float3(0.04), albedo, metallic)。边缘处有黑色或白色硬边1. 几何函数未正确模拟掠射角衰减。2. 分母4.0 * NdotV * NdotL中的点积可能为0未做保护。1. 确保NdotV和NdotL使用了max(dot(...), 0.000001)。2. 检查几何函数G_Smith的实现特别是k因子的计算是否正确。金属部分看起来像塑料菲涅尔效应不明显F0值错误。纯金属的F0应为其反照率颜色如金为黄色且菲涅尔效应强。确保metallic1时F0 albedo且漫反射部分kd为0。可视化float4(F,1)观察菲涅尔项。性能极差1. 在片元着色器中进行了复杂的循环或分支。2. 采样了过多的高分辨率纹理或Mip层级过高的Cubemap。3. BRDF计算本身复杂度高。1. 优化将可计算的内容移至顶点着色器如世界空间向量尽管会损失精度。2. 使用更简单的近似函数如用roughness的平方代替GGX中的a2计算。3. 考虑使用移动端简化版BRDF或直接使用URP的BRDF库函数。4.2 调试与可视化技巧调试Shader不能只靠猜。学会使用“调试输出”是必备技能。分量可视化这是最直接的方法。将你怀疑有问题的中间变量如N,V,roughness,D,G,F等直接作为颜色输出。例如// 检查法线 return float4(N * 0.5 0.5, 1.0); // 将法线从(-1,1)映射到(0,1)的颜色空间 // 检查粗糙度 return float4(roughness, roughness, roughness, 1.0); // 检查菲涅尔项 return float4(F, 1.0);通过观察颜色你可以判断向量方向是否正确法线可视化应该是平滑的彩色渐变数值范围是否合理粗糙度灰度图。使用Frame DebuggerUnity的Frame Debugger窗口 分析 Frame Debugger可以一步步查看Draw Call的执行结果确认当前Shader Pass、渲染状态和纹理绑定是否正确。简化测试如果Shader很复杂先从一个最简单的版本开始比如只输出纯色或纹理然后逐步添加光照计算先加漫反射再加高光再分拆D、G、F每加一步就测试一次可以快速定位问题代码段。4.3 性能优化与生产级考量自己实现的PBR Shader作为学习很棒但在实际项目中除非有特殊需求否则应优先使用URP/HDRP的Lit Shader或经过高度优化的第三方Shader。如果必须自研请考虑计算精度在移动平台将float替换为half甚至fixed可以显著提升性能。但对于法线、视线方向等关键向量使用half可能导致精度不足产生带状瑕疵需要测试权衡。纹理优化将金属度、粗糙度、环境光遮蔽AO甚至自发光Emission打包到一张纹理的不同通道例如RGBA分别对应Metallic, Roughness, AO, Emission可以减少纹理采样次数。这就是常见的MRTA或MRAO贴图。分支优化Shader中的if语句在GPU上可能效率很低尤其是在不同片段执行路径不同时。尽量使用step()、lerp()或saturate()等函数来替代条件判断。例如计算漫反射时可以用saturate(dot(N, L))代替if (dot(N, L) 0)。使用内置函数和常量Unity提供了许多优化过的内置函数和常量如Unity_PI、Pow4计算四次方、UnpackNormal等应优先使用。考虑多光源渲染路径前向渲染中逐像素光源数量增多会成倍增加计算量。对于复杂场景需要考虑延迟渲染Deferred Rendering或前向Forward渲染路径。这通常意味着你需要编写多个SubShader或Pass来适配不同的渲染管线。5. 从本章出发技术美术的Shader学习路径掌握第9章的内容相当于拿到了进入真实图形编程世界的大门钥匙。但这仅仅是开始。基于此你可以向以下几个方向深入探索深入URP/HDRP源码打开Unity的URP或HDRP包找到Lit.shader文件。你会看到一套工业级、高度优化的完整PBR实现包含了能量守恒、多光源、阴影、烘焙GI、透明混合等复杂处理。对照你学到的BRDF公式去理解源码中的每一个函数这是最好的进阶教材。探索更专业的BRDF模型GGX是主流但还有Beckmann、Phong、Ashikhmin-Shirley等模型。迪士尼Disney的BSDF双向散射分布函数模型更为复杂和艺术友好其原理论文和实现如Disney Principled BRDF值得深入研究。学习Shader Graph对于技术美术可视化编程工具Shader Graph是生产力利器。尝试在Shader Graph中复现本章的BRDF节点网络。你会对数据流和函数节点有更直观的理解。很多复杂的特效如毛玻璃、水体、皮肤次表面散射都可以用Shader Graph高效搭建原型。结合后处理与全局光照一个材质的美感不仅取决于自身Shader还与后处理如抗锯齿、Bloom、色调映射和全局光照如光照探头、光照贴图、屏幕空间反射息息相关。学习这些系统如何与你的材质交互才能做出真正融入场景的材质。性能分析与优化实战使用Unity的Profiler特别是GPU模块和RenderDoc等工具分析你写的Shader在不同平台PC、移动端的性能瓶颈。学习使用[Toggle]、[KeywordEnum]等属性来制作功能开关为不同性能需求的平台配置不同的Shader变体。我个人在实践中最深的体会是理解原理后一定要动手实现并不厌其烦地调试和可视化。图形学是一个“所见即所得”的领域一个公式写错屏幕上立刻就会给你颜色看。这种即时反馈既是挑战也是学习的最大乐趣。当你调通一个复杂的BRDF Shader看到模型在光影下呈现出逼真的金属或皮革质感时那种成就感是无与伦比的。从这一章开始试着不再仅仅满足于“跑通示例”而是去修改参数观察变化去拆分公式理解每一项的贡献去尝试实现一个书中没有的材质比如蜡、湿漉漉的沥青。这个过程才是从“学生”到“从业者”的真正蜕变。
返回列表