1. 项目概述:理解各向异性光照
在实时渲染领域,光照模型是塑造物体视觉质感的核心。我们熟知的Lambert、Blinn-Phong等模型,都基于一个核心假设:物体表面是各向同性的。这意味着,无论你从哪个方向观察,表面在给定光照方向下的明暗表现都是一致的,就像一块打磨光滑的塑料或石膏。然而,现实世界中存在大量表面并非如此。想象一下拉丝金属、绸缎、CD光盘背面,或者一头梳理过的秀发——当你转动视角或移动光源时,这些表面会呈现出独特的方向性高光或明暗条纹。这种特性,就是各向异性。
在Unity的Universal Render Pipeline中实现各向异性光照,本质上是在挑战标准光照模型的“均匀”假设。标准PBR工作流中的BRDF(双向反射分布函数)是为各向同性表面设计的。要实现各向异性效果,我们需要引入一个额外的向量来定义表面的“纹路”或“沟槽”方向,并让高光的形状、强度甚至颜色随着这个方向与视线/光源方向的相对关系而变化。这不仅仅是加一个法线贴图那么简单,它涉及到在Shader中对光照计算进行根本性的修改。
对于技术美术、图形程序员或任何希望提升作品视觉独特性的开发者而言,掌握URP中的各向异性实现,意味着你能创造出更具真实感和艺术表现力的材质,比如:
- 高级金属材质:逼真的拉丝不锈钢、 brushed aluminum(拉丝铝)、发动机叶片。
- 织物与毛发:天鹅绒、绸缎、丝绒的顺滑光泽,头发的高光条带。
- 特殊效果:光盘、全息图、某些类型的鳞片或羽毛表面。
- 风格化渲染:用于创造非真实但视觉上引人注目的方向性光晕或条纹。
接下来,我将从原理到实践,完整拆解在Unity URP中实现各向异性光照的几种主流方案,并分享我在实际项目中的踩坑经验和优化技巧。
2. 核心原理与方案选型
在动手写Shader之前,我们必须理解各向异性高光的物理(或近似物理)基础。核心思想是:表面的微观几何不是无序的,而是沿着一个主导方向排列的。我们可以用一个额外的向量,通常称为切线或各向异性方向,来表征这个主导方向。
2.1 各向异性光照模型解析
主流实现各向异性的方法,大多基于对经典光照模型的扩展。这里我们重点分析两种最实用、在URP中最易集成的模型:Kajiya-Kay模型和基于法线分布的修改模型。
2.1.1 Kajiya-Kay模型:经典而直观
这个模型最初为头发渲染设计,但其思想非常通用。它抛弃了传统的法线概念,转而使用切线向量和副切线向量来计算光照。其核心公式简化后可以理解为:高光强度不仅依赖于视线方向与反射方向的夹角,更依赖于视线方向在由切线和副切线构成的平面上的投影。
在Shader中,我们通常这样计算:
- 获取表面的切线方向。
- 计算视线方向与切线方向的点积。
- 计算光线方向与切线方向的点积。
- 使用一个基于这些点积的数学函数(如
sqrt(1.0 - dot(T, V)^2) * sqrt(1.0 - dot(T, L)^2) - dot(T, V)*dot(T, L))来得到一个各向异性的高光项。
它的优点是概念清晰,实现相对简单,能产生明显的方向性高光条带。缺点是物理依据相对较弱,参数调节需要经验,且容易在掠射角产生不自然的效果。
2.1.2 修改法线分布函数:更PBR的方式
现代PBR渲染的核心是微表面理论,其中的法线分布函数决定了高光的大小和形状。对于各向同性,我们常用GGX或Beckmann分布,它们只依赖于法线与半角向量的夹角。
为了实现各向异性,我们需要修改这个分布函数,使其同时依赖于半角向量与切线方向和副切线方向的夹角。以各向异性GGX分布为例,其粗糙度参数从一个标量变成了两个:沿切线方向的粗糙度和沿副切线方向的粗糙度。
公式变得更为复杂,但直观理解是:高光区域从一个圆形(各向同性)变成了一个椭圆形(各向异性)。椭圆的长轴和短轴分别由两个方向的粗糙度控制。当两个粗糙度值不同时,高光就会沿着粗糙度更低(更光滑)的方向被拉长,形成各向异性外观。
这种方式的优势在于它能更好地融入现有的PBR工作流,能量守恒更好,视觉效果也更接近真实世界的材质。在URP Lit Shader的基础上进行修改,通常采用这种思路。
2.2 URP Shader框架下的集成策略
URP提供了Lit.shader作为标准PBR模板,我们的目标是在其基础上“动手术”,而不是从头重写一个管线。这里有几个关键决策点:
策略一:自定义Lit Shader(推荐)这是最干净、最可控的方式。复制一份URP内置的Lit.shader和对应的Lit.hlsl文件到你的项目,重命名(如AnisotropicLit.shader)。然后,在关键的光照计算函数中(通常是BRDFData结构体和LightingPhysicallyBased函数),将法线分布函数替换为各向异性版本。你需要:
- 在
Attributes和Varyings结构体中确保切线向量被正确传递。 - 在片元着色器中,构建切线空间矩阵(TBN矩阵)。
- 修改
BRDFData,增加各向异性参数(如anisotropy强度,或roughnessT,roughnessB)。 - 重写
DirectBDRF函数中的分布函数和几何遮挡函数。
策略二:使用Shader Graph + Custom Function如果你更倾向于可视化编程,Shader Graph是一个选择。你可以创建一个基于PBR Master节点的Graph,然后通过Custom Function节点注入各向异性光照计算的核心HLSL代码。这种方式快速原型,但调试复杂逻辑和深度优化时会受到一些限制。你需要自己编写那个计算各向异性高光的HLSL函数块。
策略三:后处理与屏幕空间技巧这是一种“作弊”但有时很有效的方案。通过渲染时输出世界空间法线和切线到一张G-Buffer(如果开启了),在后处理Pass中,根据屏幕像素的法线、切线和深度信息,重新计算或叠加各向异性高光。这种方法不修改物体本身的材质,适用于为整个场景添加统一的各向异性“滤镜”效果,但精度和正确性不如逐物体计算,且与物体本身的粗糙度等属性结合困难。
我的选择与理由:对于追求高质量和集成的项目,我强烈推荐策略一(自定义Lit Shader)。它虽然前期工作量稍大,但能获得最好的性能控制、最正确的光照交互(包括阴影、多光源、光照探针),并且可以完美继承URP的所有特性(如GPU Instancing, SRP Batcher)。策略二适合快速验证效果或对Shader编码不熟悉的TA。策略三仅用于特定的全屏风格化效果。
3. 基于修改NDF的各向异性Lit实现详解
让我们深入策略一,一步步构建一个可用的各向异性PBR Shader。我将以修改GGX法线分布函数为例。
3.1 准备:数据结构与属性扩展
首先,创建你的Shader文件,例如AnisotropicLit.shader。在Properties块中,我们需要增加控制各向异性的参数。
Properties { // ... 原有的 _BaseColor, _BaseMap, _Metallic, _Smoothness 等属性 ... [Header(Anisotropy)] _Anisotropy("Anisotropy Strength", Range(-1, 1)) = 0 _AnisotropyMap("Anisotropy Map (RG as Direction, B as Strength)", 2D) = "white" {} _AnisotropyRotation("Anisotropy Rotation", Range(0, 360)) = 0 }_Anisotropy: 各向异性强度。正值通常表示高光沿切线方向拉伸,负值表示沿副切线方向拉伸。范围-1到1提供了灵活性。_AnisotropyMap: 一张贴图,用于逐像素控制各向异性。常见用法是RG通道存储一个方向向量(通常是切线空间下的扰动方向),B通道存储强度。如果不用贴图,可以用常量。_AnisotropyRotation: 一个全局旋转角度,用于旋转各向异性方向,方便美术调节。
接下来,我们需要修改BRDFData结构体(通常在BRDF.hlsl或你复制的对应文件中),添加各向异性数据。
struct BRDFData { float3 diffuse; float3 specular; float perceptualRoughness; float roughness; float roughness2; // roughness * roughness float grazingTerm; float normalizationTerm; // roughness^4 * 4.0 + 2.0 float roughness2MinusOne; // roughness^2 - 1.0 // 新增各向异性参数 float anisotropy; // 各向异性强度 float3 tangentWS; // 世界空间切线 float3 bitangentWS; // 世界空间副切线 };3.2 核心:各向异性GGX分布函数实现
这是最关键的数学部分。我们将替换标准的D_GGX函数。以下是各向异性GGX分布函数的一种常见实现:
// 各向异性GGX法线分布函数 float D_GGX_Anisotropic(float NdotH, float TdotH, float BdotH, float at, float ab) { // at: 切线方向的粗糙度平方, ab: 副切线方向的粗糙度平方 float a2 = at * ab; float3 v = float3(ab * TdotH, at * BdotH, a2 * NdotH); float v2 = dot(v, v); float w2 = a2 / v2; return a2 * w2 * w2 * INV_PI; // INV_PI 是 1/π } // 根据各向异性强度计算at和ab void GetAnisotropicRoughness(float roughness, float anisotropy, out float at, out float ab) { // 将各向异性强度映射到两个方向的粗糙度上 // 当anisotropy=0时,at=ab=roughness // 当anisotropy>0时,沿切线方向更光滑(at减小),沿副切线方向更粗糙(ab增大) float aspect = sqrt(1.0 - 0.9 * abs(anisotropy)); // 0.9是一个可调因子,控制拉伸程度 at = roughness / aspect; ab = roughness * aspect; // 确保数值稳定 at = max(at, 1e-4); ab = max(ab, 1e-4); at = at * at; // 转换为平方形式,供分布函数使用 ab = ab * ab; }然后,你需要修改计算直接高光贡献的函数(通常是DirectBRDF或BRDF函数)。在计算高光项时,不再使用D_GGX,而是:
- 计算半角向量
H。 - 计算
NdotH,TdotH,BdotH。 - 调用
GetAnisotropicRoughness得到at和ab。 - 调用
D_GGX_Anisotropic得到分布值。 - 几何遮挡项(G项)和菲涅尔项(F项)通常可以暂时沿用各向同性的近似,但更精确的做法是使用各向异性版本,这会更复杂。
3.3 集成:修改URP光照循环
在URP中,主光照循环在Lighting.hlsl的UniversalFragmentPBR函数中。你需要确保在计算每个光源的贡献时,使用的是你新的各向异性BRDF数据。
主要修改点在于初始化BRDFData的地方和实际计算光照的地方。你需要将世界空间的切线和副切线(可以从顶点着色器传递过来,并在片元着色器中从法线贴图或各向异性贴图中扰动)正确地赋值给brdfData.tangentWS和brdfData.bitangentWS。
同时,在片元着色器开始,你需要根据属性或贴图计算最终的anisotropy值,并存入brdfData.anisotropy。
// 在片元着色器中 void InitializeBRDFDataAnisotropic(... , out BRDFData outBRDFData) { // ... 初始化原有的diffuse, specular, roughness等 ... // 计算各向异性 float anisotropy = _Anisotropy; #if defined(_ANISOTROPYMAP) float4 anisotropySample = SAMPLE_TEXTURE2D(_AnisotropyMap, sampler_BaseMap, uv); // 假设贴图B通道是强度,RG是方向 anisotropy *= anisotropySample.b; // 如果需要,用RG通道扰动切线方向 #endif outBRDFData.anisotropy = anisotropy; // 构建或获取世界空间切/副切 float3 tangentWS = ... ; // 从input结构体或TBN矩阵获取 float3 bitangentWS = ... ; // 通常 cross(normalWS, tangentWS) * input.tangent.w outBRDFData.tangentWS = tangentWS; outBRDFData.bitangentWS = bitangentWS; }最后,确保在UniversalFragmentPBR中调用的是你修改过的、支持各向异性的BRDF计算函数。
4. 美术控制与资源制作要点
一个强大的技术方案必须便于美术人员使用和创作。各向异性材质的美术工作流需要特别设计。
4.1 各向异性贴图制作规范
一张好的各向异性贴图能极大提升效果和迭代效率。我推荐的格式是:
- RG通道:存储切线空间下的方向向量。这比存储单个角度值更直接,也避免了在Shader中进行角度到向量的转换。通常,(1,0) 表示原始切线方向,(0,1) 表示原始副切线方向。你可以用Substance Designer或Photoshop生成这种向量图。例如,一个径向刷痕的金属,其方向图可能是从中心向外发散的向量。
- B通道:存储各向异性强度。白色(1.0)表示完全强度,黑色(0.0)表示无各向异性。这允许你在同一个材质上实现各向异性强度的变化,比如金属部件边缘磨损处各向异性减弱。
- A通道:可以留作他用,比如作为高光染色(Specular Tint)的遮罩,或者与粗糙度贴图配合进行更复杂的控制。
在Shader中采样后,你需要解码RG通道:
float2 direction = anisotropySample.rg * 2.0 - 1.0; // 从[0,1]映射到[-1,1] float3 modifiedTangent = normalize(float3(direction.x, 0, direction.y)); // 假设在切线平面XZ上 // 然后使用这个modifiedTangent去扰动或替换原有的世界空间切线4.2 材质参数调节心法
调节各向异性材质时,遵循一个顺序,可以事半功倍:
- 先定基础,后调特色:首先忽略各向异性(强度设为0),像调节普通PBR材质一样,把基础色、金属度、粗糙度调到满意的程度。确保基础质感是对的。
- 强度由弱到强:将各向异性强度从0开始慢慢增加,观察高光形状的变化。通常0.2到0.6的范围就能产生明显且自然的效果。过高的值(如>0.8)会导致高光变得极其细长和刺眼,除非是做非常风格化的效果。
- 结合粗糙度理解:各向异性效果在中等粗糙度表面最为明显。如果表面非常光滑(粗糙度接近0),高光本身就很锐利,各向异性的拉伸效果可能难以察觉;如果表面非常粗糙,高光弥散,各向异性效果也会被模糊掉。尝试在粗糙度0.3-0.5的区间进行主要调节。
- 善用旋转:
_AnisotropyRotation参数可以快速旋转高光条纹的方向,而无需重新制作贴图或修改模型UV。这对于将同一材质应用到不同朝向的模型部件上非常有用。 - 观察掠射角:各向异性效果在掠射角(视线几乎平行于表面)观察时最为强烈。在调节时,多旋转摄像机到掠射角度查看效果是否自然,避免出现断裂或不连续的高光。
实操心得:对于拉丝金属,我通常将各向异性方向设置为沿UV的U方向(即模型表面的主要拉伸方向),强度在0.4左右,并配合一张轻微噪点的粗糙度贴图来打破重复感。对于头发或毛发,各向异性方向通常沿着发丝的方向(需要模型提供正确的切线),强度可以更高(0.6-0.8),并且高光颜色有时会略微偏离基础色,以模拟凯尔效应。
5. 性能考量与移动端适配
在URP中增加各向异性计算,不可避免地会增加Shader的ALU(算术逻辑单元)指令数。在性能敏感的平台上(如移动端或VR),需要进行权衡和优化。
5.1 性能开销分析与优化策略
主要开销来自:
- 额外的向量运算:计算
TdotH,BdotH,以及各向异性分布函数中更复杂的数学运算。 - 可能的贴图采样:如果使用各向异性贴图,增加了一次纹理采样。
- 更复杂的BRDF计算:各向异性的G项和F项如果也实现,开销会更大。
优化策略:
- 质量分级:为不同平台或画质等级准备不同的Shader变体。在低端移动端,可以完全关闭各向异性,回退到标准Lit。在中端,可以使用简化版的各向异性模型(如一个更廉价的近似公式)。
- 简化计算:
- 可以考虑使用一个预计算的查找表来近似各向异性分布函数,但这会引入纹理采样,需要权衡。
- 如果各向异性方向变化不大,可以在顶点着色器计算切线/副切线,而不是在片元着色器。
- 关闭或简化各向异性对阴影和间接光(光照探针)的影响。通常直接高光的各向异性是最重要的。
- 贴图优化:确保各向异性贴图使用适当的压缩格式(如BC5/DXT5NM用于方向,BC4/DXT5用于强度),并且尺寸合理。对于远处物体,可以使用更低分辨率的贴图或完全不用贴图。
- 变体剥离:使用
shader_feature而不是multi_compile来定义各向异性相关的关键字,这样Unity只会编译实际材质用到的变体到游戏包中,减少包体和运行时内存。
5.2 移动端实战注意事项
在Android/iOS的URP项目中使用各向异性Shader,要格外小心:
- 精度问题:移动端GPU(尤其是OpenGL ES)对
half精度(中精度)的支持更好。确保在片元着色器中,对于颜色和HDR值使用half3,对于向量点积、法线等使用half精度计算,除非必要才用float。不必要的高精度是移动端的性能杀手。 - 分支与循环:各向异性计算中尽量避免动态分支。如果基于
_Anisotropy强度为0时想跳过计算,使用lerp混合可能比if语句在移动端更高效(但这取决于具体GPU架构,需测试)。 - 测试,测试,再测试:必须在目标真机设备上进行性能分析。使用Unity的Frame Debugger和Profiler查看GPU耗时,重点关注使用了各向异性材质的Draw Call。如果某个复杂模型使用了各向异性材质导致GPU时间激增,考虑是否能用更简单的模型或降低材质复杂度。
- 备用方案:准备一个备用的标准Lit材质球。对于中低端机型,可以通过代码在运行时动态替换材质,确保帧率稳定。
6. 常见问题与调试技巧实录
即使按照步骤实现,你也可能会遇到各种问题。以下是我在实际项目中遇到的典型问题及其解决方法。
6.1 高频问题排查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 高光方向错误或混乱 | 1. 切线数据错误。 2. 各向异性方向贴图编码/解码错误。 3. 世界空间切线计算错误。 | 1. 在Shader中输出tangentWS作为颜色,在Scene视图检查其方向是否与模型表面走向一致。使用Debug Mode -> Tangents检查模型导入设置。2. 检查方向贴图。在PS中查看RG通道,确认向量方向。在Shader中,先输出原始采样值,再输出解码后的方向向量。 3. 确保在片元着色器中正确计算了副切线: bitangentWS = cross(normalWS, tangentWS) * input.tangent.w;(这个tangent.w存储了手性,非常重要!)。 |
| 高光在边缘断裂或不连续 | 1. 各向异性分布函数在数值不稳定区域(如NdotH接近0)。 2. 粗糙度过低,导致分母接近0。 | 1. 在分布函数中加入极小值保护:max(NdotH, 1e-5)。2. 对计算出的粗糙度参数 at和ab进行钳制:max(at, 1e-4)。3. 检查几何遮挡项(G项)是否与各向异性分布匹配,不匹配可能导致能量不守恒和边缘artifacts。 |
| 效果在特定视角下消失 | 1. 视线方向与切线方向平行时,某些模型计算失效。 2. Shader中基于视角的Fresnel项掩盖了效果。 | 1. 这是数学上的奇点,确保所有向量在计算点积前都已归一化(normalize)。2. 尝试暂时禁用或简化菲涅尔项,看各向异性高光本身是否正常。 |
| 性能开销远超预期 | 1. 使用了未优化的复杂函数。 2. 在不需要的物体上使用了高精度计算。 3. 变体数量爆炸。 | 1. 使用简化版分布函数,或预计算部分项。 2. 通过Shader LOD或Quality Settings为不同物体设置不同精度的Shader。 3. 审查Shader变体,合并或剔除不必要的特性组合。 |
| 与阴影/间接光不匹配 | 阴影和全局光照(GI)计算仍使用各向同性模型。 | 这是一个高级问题。对于阴影,可以尝试修改阴影投射Pass的深度偏移或使用自定义的阴影接收Shader来模拟方向性。对于GI,通常接受这种不匹配,因为间接光本身比较柔和,各向异性影响较小。如果必须修正,需要修改光照贴图烘焙或光照探针采样相关的Shader代码,工作量巨大。 |
6.2 调试与可视化技巧
当效果不如预期时,将中间变量可视化是最高效的调试手段。
颜色调试法:在Shader的片元输出阶段,临时将你想要检查的向量或标量作为颜色输出。
// 检查切线方向 return float4(brdfData.tangentWS * 0.5 + 0.5, 1.0); // 映射到[0,1] // 检查各向异性强度 return float4(brdfData.anisotropy.xxx, 1.0); // 检查方向贴图采样 return float4(anisotropySample.rgb, 1.0);在Game视图或Frame Debugger中查看,可以立即发现向量是否归一化、数值范围是否正确。
分离计算法:暂时屏蔽掉各向异性计算,只输出标准PBR结果,确认基础光照是正确的。然后逐步启用各向异性计算的不同部分(如先只启用方向计算,再启用分布函数),定位问题出现的具体步骤。
参考对比法:在同一个场景中,并排放置一个使用标准Lit材质的球体和一个使用你自定义的各向异性材质的球体。使用相同的HDR环境贴图和方向光,对比两者的高光形状和强度差异,这能帮你快速判断各向异性效果是否生效以及强度是否合理。
实现各向异性光照是一个从理论到实践不断调试的过程。它没有唯一的标准答案,参数和模型的细微调整都会影响最终效果。关键在于理解其物理(或艺术)意图,并掌握一套有效的调试方法。从简单的Kajiya-Kay模型开始尝试,理解方向性高光的产生原理,再过渡到更复杂的基于NDF修改的PBR方案,是一个平滑的学习曲线。在URP的框架下动手实现一遍,你对现代实时渲染管线和光照模型的理解一定会更深一层。