1. 项目概述为什么要在Unity里折腾各向异性GGX如果你在Unity里做过一段时间渲染特别是处理过金属、丝绸、拉丝金属这类表面你大概率会对“各向异性”这个词又爱又恨。爱的是它能瞬间让你的材质质感提升一个档次那种随着视角和光线方向变化而流动的高光是标准PBR材质给不了的。恨的是Unity内置的Standard Shader或者URP/Lit Shader虽然强大但默认只提供了各向同性的高光模型想实现拉丝金属、CD光盘、绸缎那种方向性的高光就得自己动手写Shader。这个项目标题“Unity Shader实现简单的各向异性渲染采用各向异性形式的GGX分布”直指的就是这个痛点。它的核心目标很明确不依赖复杂的第三方插件从最基础的原理出发在Unity的Shader框架内实现一个基于物理的、各向异性的高光反射模型。而“采用各向异性形式的GGX分布”这句话更是点明了技术核心——我们不是用一些取巧的假效果而是基于当前游戏和影视行业事实上的标准微表面模型GGX将其扩展为各向异性版本来获得物理正确且视觉效果高级的高光。简单来说这就是一个“造轮子”的过程但这个轮子造明白了你对PBR基于物理的渲染、对微表面理论、对Unity Shader编程的理解会上一个大台阶。它适合已经熟悉Unity基础Shader编程对表面着色器或URP的Shader Graph有基本了解并且不满足于内置材质效果想要深入定制高级渲染效果的开发者。接下来我会带你从理论到代码完整走一遍这个“造轮子”的过程。2. 核心理论拆解从各向同性GGX到各向异性在动手写代码之前我们必须把背后的数学和物理原理搞清楚。否则你只是在复制粘贴一些看不懂的公式出了问题根本无从调试。2.1 微表面模型与NDF一切高光的起点PBR渲染的核心思想之一就是用微表面模型来模拟物体表面的微观几何细节。我们假设物体表面是由无数个微小的、理想光滑的镜面组成的。这些微表面的法线方向分布决定了宏观表面的粗糙度和高光形状。法线分布函数NDF就是这个模型的核心。它描述了微表面法线朝向的概率密度。我们最熟悉的GGX也叫Trowbridge-Reitz分布就是一种NDF。它的各向同性版本公式长这样D_GGX(n, h, α) α² / (π * ((n·h)² * (α² - 1) 1)²)这里n是宏观表面法线h是半程向量光线方向l和视线方向v的中间向量α是粗糙度参数。这个函数描述的是在宏观法线为n的表面上微表面法线恰好是h的概率有多大。α越大微表面法线越分散高光就越模糊粗糙。2.2 各向异性的本质两个方向的粗糙度“各向同性”意味着在所有方向上性质都相同。一个粗糙的石膏球从任何角度看它的高光都是同样模糊的一团这就是各向同性。“各向异性”则意味着性质随方向变化。想象一个被精细拉丝过的金属板。沿着拉丝的方向表面实际上相对平滑微观沟槽平行于视线和光线时遮挡少而垂直于拉丝的方向沟槽会造成强烈的遮挡和阴影看起来就非常粗糙。因此我们需要两个粗糙度参数α_t切线方向粗糙度和α_b副切线方向粗糙度或叫次切线方向。在着色器中我们通常需要一个**切线向量Tangent**来定义表面的“方向场”。这个切线向量就是各向异性方向的基准。副切线向量Bitangent可以通过法线和切线叉乘得到。这样我们就建立了一个表面的局部坐标系切线空间T切线、B副切线、N法线。2.3 各向异性GGX NDF公式推导理解了上述概念各向异性GGX的公式就呼之欲出了。我们需要将单一粗糙度α替换为在切线方向t和副切线方向b上的投影粗糙度。将半程向量h投影到切线空间计算h在切线t和副切线b方向上的点积即dot(t, h)和dot(b, h)。这代表了h向量在各向异性主方向上的分量。构建各向异性的粗糙度表达式各向同性GGX公式中的(n·h)² * (α² - 1)这一项需要被替换为能体现方向性差异的形式。推导后的各向异性GGX NDF公式如下D_GGXaniso(n, h, α_t, α_b) 1 / (π * α_t * α_b * ((dot(t, h)/α_t)² (dot(b, h)/α_b)² (dot(n, h))²)²)这个公式看起来复杂但结构很清晰π * α_t * α_b是归一化因子确保函数积分结果为1。分母的核心是一个平方项(A B C)²。A (dot(t, h)/α_t)²半程向量在切线方向的分量除以切线方向粗糙度。如果α_t很小切线方向光滑即使dot(t, h)不大这项的值也可能很大导致最终D值变小高光锐利。B (dot(b, h)/α_b)²同理作用于副切线方向。C (dot(n, h))²与法线的点积这是各向同性的基础部分。关键理解这个公式的精妙之处在于它根据h的方向动态地混合了两个方向上的粗糙度影响。当视线和光线构成的平面与切线方向一致时dot(b, h)接近0高光形状主要受α_t控制反之则受α_b控制。这就产生了椭圆形的高光其长轴和短轴分别由α_t和α_b决定。3. Unity Shader实现全流程理论铺垫完毕我们进入实战环节。这里我以Unity URP通用渲染管线为例展示如何在一个Surface Shader或Unlit Shader变体中实现它。HLSL代码是核心。3.1 准备Shader框架与属性定义首先我们创建一个新的Unlit Shader因为它结构简单适合我们专注于光照计算。在Properties块和SubShader的Pass中我们需要定义关键的输入。Shader Custom/AnisotropicGGX { Properties { _BaseColor (Base Color, Color) (1,1,1,1) _BaseMap (Base Map, 2D) white {} // 各向异性核心参数 _RoughnessX (Roughness X (Along Tangent), Range(0.001, 1)) 0.5 _RoughnessY (Roughness Y (Along Bitangent), Range(0.001, 1)) 0.5 _Anisotropy (Anisotropy Strength, Range(-1, 1)) 0.0 _SpecularColor (Specular Color, Color) (1,1,1,1) // 法线贴图可选用于增强细节 _BumpMap (Normal Map, 2D) bump {} _BumpScale (Normal Scale, Float) 1.0 } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline} LOD 100 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 #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl // 变量声明与纹理采样器定义... TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); TEXTURE2D(_BumpMap); SAMPLER(sampler_BumpMap); float4 _BaseColor; float _RoughnessX _RoughnessY _Anisotropy; float4 _SpecularColor; float _BumpScale; // ... 后续代码 ENDHLSL } } }这里我引入了_Anisotropy这个参数。在实际美术控制中直接设置两个粗糙度可能不直观。更常见的做法是定义一个_Roughness基础粗糙度和一个_Anisotropy各向异性强度范围-1到1然后通过计算得到_RoughnessX和_RoughnessY_RoughnessX _Roughness * (1 _Anisotropy);_RoughnessY _Roughness * (1 - _Anisotropy);这样当_Anisotropy0时两者相等退化为各向同性当_Anisotropy0时切线方向更光滑副切线方向更粗糙反之亦然。3.2 顶点与片段着色器结构我们需要在顶点着色器中计算并传递切线空间信息到片段着色器。struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; // 关键需要切线数据 float2 texcoord : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; float3 normalWS : TEXCOORD2; float3 tangentWS : TEXCOORD3; // 世界空间切线 float3 bitangentWS : TEXCOORD4; // 世界空间副切线 }; Varyings vert (Attributes input) { Varyings output; VertexPositionInputs vertexInput GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs normalInput GetVertexNormalInputs(input.normalOS, input.tangentOS); output.positionCS vertexInput.positionCS; output.positionWS vertexInput.positionWS; output.normalWS normalInput.normalWS; output.tangentWS normalInput.tangentWS; output.bitangentWS normalInput.bitangentWS; output.uv input.texcoord; return output; }在片段着色器中我们将获取光照信息计算各向异性高光。3.3 核心函数各向异性GGX NDF实现这是整个Shader的灵魂。我们将其实现为一个独立的HLSL函数。// 各向异性GGX法线分布函数 float D_GGX_Anisotropic(float NdotH, float TdotH, float BdotH, float roughnessX, float roughnessY) { // 防止除零 float epsilon 1e-6; roughnessX max(roughnessX, epsilon); roughnessY max(roughnessY, epsilon); // 核心计算 float a2 roughnessX * roughnessY; float3 v float3(TdotH / roughnessX, BdotH / roughnessY, NdotH); float v2 dot(v, v); float w2 a2 / v2; return a2 * w2 * w2 * (1.0 / UNITY_PI); } // 一个更常见的、优化后的写法与上文公式直接对应 float D_GGX_Anisotropic_Alt(float NdotH, float TdotH, float BdotH, float at, float ab) { float a2 at * ab; float3 v float3(TdotH / at, BdotH / ab, NdotH); float v2 dot(v, v); return 1.0 / (UNITY_PI * a2 * v2 * v2); }实操心得这两个函数是等价的只是形式不同。我建议使用第二种_Alt版本因为它更直观地对应了理论公式便于调试和理解。在Shader中点积结果可能是负数但在NDF中我们只关心其绝对值或平方值因为法线分布是对称的。确保传入的roughnessX/Y或at/ab是大于0的值通常我们会把美术输入的粗糙度进行平方操作因为线性参数在感知上更符合美术习惯但GGX公式内部通常使用粗糙度的平方。3.4 片段着色器中的光照计算在frag函数中我们将一切组合起来。half4 frag (Varyings input) : SV_Target { // 1. 采样纹理和法线 half4 baseColor SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor; float3 normalTS UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, input.uv), _BumpScale); // 将法线从切线空间转换到世界空间 float3 normalWS normalize(mul(normalTS, float3x3(input.tangentWS, input.bitangentWS, input.normalWS))); normalWS normalize(input.normalWS); // 如果无法线贴图直接用顶点法线 // 2. 获取主光源信息 Light mainLight GetMainLight(); float3 lightDir normalize(mainLight.direction); float3 viewDir normalize(GetWorldSpaceViewDir(input.positionWS)); float3 halfDir normalize(lightDir viewDir); // 3. 计算各向异性所需的点积 float NdotL max(dot(normalWS, lightDir), 0.0); float NdotV max(dot(normalWS, viewDir), 1e-5); // 防止除零 float NdotH max(dot(normalWS, halfDir), 0.0); // 关键计算半程向量在切线和副切线上的投影 float TdotH dot(input.tangentWS, halfDir); float BdotH dot(input.bitangentWS, halfDir); // 4. 计算粗糙度参数这里演示直接使用属性也可从基础粗糙度和各向异性强度推导 float at _RoughnessX; float ab _RoughnessY; // 常见做法将线性粗糙度平方使其在GGX中分布更合理 at at * at; ab ab * ab; // 5. 计算各向异性高光仅D项 float D D_GGX_Anisotropic_Alt(NdotH, TdotH, BdotH, at, ab); // 简单的光照模型高光 D项 * 菲涅尔近似 * 几何遮挡简化 // 菲涅尔项Schlick近似 float3 F0 _SpecularColor.rgb; // 对于非金属F0约为0.04金属则为albedo float3 F F0 (1.0 - F0) * pow(1.0 - max(dot(halfDir, viewDir), 0.0) 5.0); // 几何项GGX Smith简化此处为教学使用各向同性近似严谨实现需各向异性G float k (at 1.0) * (at 1.0) / 8.0; // 使用at作为代表粗糙度 float G1 NdotV / (NdotV * (1.0 - k) k); float G2 NdotL / (NdotL * (1.0 - k) k); float G G1 * G2; // 6. 组合BRDF项 float3 specularBRDF (D * F * G) / max(4.0 * NdotV * NdotL, 1e-5); float3 specular specularBRDF * mainLight.color * NdotL; // 7. 结合漫反射Lambert float3 diffuse baseColor.rgb / UNITY_PI * mainLight.color * NdotL; // 8. 输出颜色 float3 finalColor diffuse specular; return half4(finalColor, 1.0); }注意事项上面的光照模型是一个极度简化的版本为了清晰展示各向异性NDF的集成。一个完整的PBR BRDF还应包含正确的漫反射项对于金属漫反射应为0。通常使用金属度贴图/参数来混合F0和漫反射颜色。精确的几何遮挡项G各向异性的NDF需要配套的各向异性几何项如GGX Smith各向异性版本计算会复杂很多。能量守恒确保高光漫反射不超过入射光能。多光源支持需要循环处理所有额外光源。 在正式项目中建议参考Unity URP的Lit.shader或BRDF.hlsl库将我们自制的D_GGX_Anisotropic函数替换到其BRDF计算框架中这样能获得最完整、正确的光照效果。4. 效果调试与参数解析Shader写完了但更重要的环节是调试和理解每个参数如何影响最终效果。4.1 关键参数对视觉效果的影响在Unity材质面板中调整参数观察变化_RoughnessX与_RoughnessY当两者相等时高光恢复为圆形退化为各向同性GGX。这是验证你Shader正确性的重要测试点。当_RoughnessX_RoughnessY时高光在切线方向通常由UV的U方向或模型切线定义上更锐利、更长在副切线方向上更模糊、更短。视觉上呈现横向拉伸的椭圆形高光。这模拟了横向拉丝的金属。当_RoughnessX_RoughnessY时高光在切线方向上模糊在副切线方向上锐利呈现纵向拉伸的椭圆形高光。_Anisotropy如果使用推导方式值为0无各向异性。值为正_RoughnessX减小_RoughnessY增大产生横向拉伸高光。值为负_RoughnessX增大_RoughnessY减小产生纵向拉伸高光。这个参数让美术控制起来更直观一个滑块控制拉伸方向和强度。切线方向这是效果的方向盘。模型UV的U方向或你定义的切线向量决定了高光拉伸的轴向。如果效果看起来方向不对问题几乎100%出在切线数据上。对于自定义模型务必在3D建模软件中检查并正确导出切线信息。在Unity中你可以通过创建一个简单的测试Shader将切线方向tangentWS作为颜色输出来可视化检查切线是否正确。4.2 常见问题与排查技巧实录即使代码逻辑正确你可能还是会遇到各种诡异的问题。下面是我踩过坑后总结的排查清单问题现象可能原因排查与解决方案高光形状不对称或扭曲切线T和副切线B向量没有正交归一化。在片段着色器中确保T、B、N是两两垂直的单位向量。添加代码B cross(N, T);T normalize(T); B normalize(B);高光方向与模型纹理方向不符模型切线数据错误或UV方向与预期不符。1. 在3D软件中检查模型UV和切线。2. 在Shader中可视化切线向量return float4(T*0.50.5, 1.0)。红色代表切线方向。高光在边缘处断裂或消失几何项G计算不正确或没有正确处理NdotV、NdotL为0的情况。1. 确保NdotV、NdotL有最小值保护如max(dot, 1e-5)。2. 实现更精确的Smith几何阴影函数。效果与Standard Shader相差甚远只实现了D项缺少正确的F项和G项或能量不守恒。将你的NDF函数集成到URP的BRDF框架中。复制BRDF.hlsl中的BRDFData设置和DirectBRDF函数替换其中的D项计算为你自己的各向异性版本。这是最可靠的方法。性能开销大在片段着色器中进行了复杂的矩阵运算或重复计算。1. 将T、B向量的正交归一化计算移到顶点着色器。2. 将粗糙度平方等计算提前。3. 对于移动平台考虑使用查找表LUT来近似各向异性高光。在特定角度下高光闪烁数值精度问题特别是在NdotH接近0时NDF分母可能产生极大值。1. 对NdotH、TdotH、BdotH进行clamp操作避免极端值。2. 在NDF函数中对粗糙度参数和点积结果加一个极小值如1e-6防止除零。一个至关重要的调试技巧单独输出你的NDF值。修改片段着色器直接return float4(D, D, D, 1.0)。在场景中旋转视角和灯光你应该看到一个清晰的、随角度变化的灰度图它直接反映了微表面法线分布的概率密度。这是验证你的NDF函数是否正确的“金标准”。5. 进阶集成到URP Lit框架与性能优化自己写的简化光照模型用于学习原理足够了但要投入生产环境我们必须将其无缝集成到Unity URP的渲染框架中以保证与其他光照特性阴影、烘焙GI、屏幕空间反射等的兼容性。5.1 修改URP的BRDF库这是最专业的方式。你需要创建一个自定义的BRDF库文件例如MyAnisotropicBRDF.hlsl。复制并修改从URP包路径Packages/com.unity.render-pipelines.universal/ShaderLibrary/BRDF.hlsl中找到核心的BRDF函数如DirectBRDF。替换D项找到其中计算D_GGX的地方通常在BRDF函数内部将其替换为对D_GGX_Anisotropic的调用。你需要将切线和副切线向量作为额外参数传递进来。修改数据结构可能需要扩展BRDFData结构体加入anisotropy、roughnessT、roughnessB等字段。在Lit Shader中赋值在你的自定义Lit Shader中像设置普通粗糙度一样计算并填充这些各向异性参数到BRDFData中。处理几何项为了物理正确最好也使用各向异性的几何阴影函数。GGX Smith的各向异性版本公式更复杂但原理类似根据α_t和α_b分别计算切线方向和副切线方向的遮蔽项。实操心得这一步工作量较大但收益巨大。它意味着你的各向异性材质可以完美兼容URP的渲染管线支持所有光源类型、阴影和后期效果。建议以URP提供的SimpleLit或LitShader为模板进行修改而不是从零开始。重点关注SurfaceData和BRDFData的初始化以及Lighting.hlsl中的光照计算循环。5.2 针对移动平台的优化策略各向异性BRDF计算量显著高于各向同性。在移动设备上需要一些优化技巧预计算或近似查找表LUT将D_GGX_Anisotropic函数在几个关键维度如NdotHTdotH/BdotH的比例粗糙度上的结果预计算到一张2D或3D纹理中。在片段着色器中通过三次纹理采样和线性插值来获取近似值。这是牺牲少量精度换取巨大性能提升的经典方法。拟合函数使用更简单的解析函数来近似各向异性高光。例如某些方案使用一个椭圆形的Phong或Blinn-Phong模型来模拟虽然物理不正确但在特定视角下视觉差异不大且计算极快。降低计算频率顶点着色器计算如果模型面数不高可以将切线、副切线向量的部分计算如正交化移到顶点着色器。重要性采样在 deferred rendering 或需要屏幕空间效果的场合可以只在重要性高的像素如高光区域进行精确计算。美术规范规定各向异性效果只用于中近景的重要物体如主角武器、豪华汽车漆面。对于远景物体使用各向同性材质或更低的材质质量等级。实现一个正确的各向异性GGX Shader就像亲手搭建了一座连接微观几何与宏观视觉的桥梁。从理解两个粗糙度参数如何扭曲高光椭圆到在代码中正确处理切线空间每一步都需要清晰的物理图像和严谨的数学实现。调试过程可能充满挫折但当你看到材质表面随着视角流转出那种独特而锐利的光泽时所有的努力都是值得的。这个项目最大的价值不在于代码本身而在于它强迫你去深入理解PBR的每一个环节。掌握了它你不仅能做出拉丝金属和绸缎更能触类旁通去实现头发渲染、 brushed metal 等更复杂的各向异性效果。最后一个小建议把你的Shader参数做成可调节的并实时观察变化这是学习渲染最快的方式。