1. 项目概述:为什么我们需要一个专门的头发着色器?
在Unity URP管线里做角色渲染,头发往往是那个“看起来差不多,但总差那么点意思”的部分。默认的Lit Shader或者Standard Shader,对付布料、皮肤、金属都有一套,但一遇到头发,光泽要么死板得像塑料,要么就糊成一团,完全失去了发丝应有的那种灵动、顺滑和通透感。这背后的核心原因在于,头发的微观几何结构极其特殊——它不是一块光滑的表面,而是由成千上万根细小的、半透明的圆柱体(发丝)组成的集合体。传统基于微表面理论的着色模型(比如PBR),其光照计算的基础是假设表面由无数微小的、随机朝向的镜面组成,这个假设对头发完全不成立。
所以,我们得请出两位“专家”:Kajiya-Kay模型和Marschner模型。简单来说,Kajiya-Kay是位“风格化大师”,它用一套相对简化的公式,高效地模拟出发丝的高光带,特别适合卡通、二次元或者追求性能的写实风格。而Marschner则是“物理写实派”的奠基人,它基于真实头发纤维的光学散射物理,定义了R(反射)、TT(透射-透射)和TRT(透射-反射-透射)三条主要的光路,能渲染出极其逼真的发丝光泽、边缘光(“天使环”)和次表面散射效果。我们这个项目,就是要在URP框架下,把这两位大师的智慧融合到一个可实操的着色器中,既深入理解其原理,又能亲手实现出来,解决从“塑料发”到“丝绸发”的质变问题。
2. 核心理论与模型选型:Kajiya-Kay与Marschner的异同与融合
2.1 Kajiya-Kay模型:高效的方向性高光
Kajiya-Kay模型的核心思想非常直观:它把每一根发丝看作一个细长的圆柱体。光照在这个圆柱体上的反射,最强的地方不是我们通常认为的反射角等于入射角,而是发生在切线方向(Tangent Direction)上。这里就引出了头发着色中两个至关重要的向量:切线(Tangent)和副法线(Binormal)。在Unity中,我们通常用顶点的切线(Tangent)来近似表示发丝的方向,而副法线可以通过法线(Normal)和切线叉乘得到。
Kajiya-Kay的高光计算就围绕这两个向量展开。它通常会计算两组高光:一组基于切线(Primary Specular),模拟发丝纵向的高光带;另一组基于副法线(Secondary Specular),模拟发丝因为缠绕、起伏产生的横向高光,这能增加头发的层次感和细节。它的光照模型简化如下:
float KajiyaKaySpecular(float3 T, float3 B, float3 L, float3 V, float exponent) { // T: 切线, B: 副法线, L: 光照方向, V: 视线方向 float TdotL = dot(T, L); float TdotV = dot(T, V); float sinTL = sqrt(1.0 - TdotL * TdotL); float sinTV = sqrt(1.0 - TdotV * TdotV); // 核心公式:基于正弦值计算高光 float specular = pow(sinTL * sinTV - TdotL * TdotV, exponent); return max(0, specular); }为什么选择Kajiya-Kay?它的计算量小,效果可控,通过调整高光强度、衰减指数和颜色,很容易做出从动漫风到略带写实感的头发。在移动端或对性能要求高的项目中,它是首选。
2.2 Marschner模型:物理真实的发丝散射
Marschner模型则走进了微观物理世界。它将一束光照射到单根头发纤维上的散射过程,分解为三条主要的路径:
- R路径(反射):光在头发表面直接反射。这类似于Kajiya-Kay模拟的主高光,但Marschner的R项考虑了头发角质层的倾斜角(
azimuthal偏移),使得高光带会沿着发丝方向有一个轻微的偏移,更符合观察。 - TT路径(透射-透射):光穿透头发,从另一侧穿出。这部分光构成了头发背光时的“边缘光”或“天使环”(halo),是头发通透感的关键。TT项通常是一个颜色更淡、更弥散的光晕。
- TRT路径(透射-反射-透射):光先穿透进入头发,在内壁(髓质)反射一次,再穿出。这部分贡献了头发的主色和次表面散射效果,是头发看起来“有颜色”、“有厚度”而不是一片单薄高光的原因。
实现完整的Marschner模型计算量较大,因为它涉及复杂的贝塞尔函数、高斯分布等。在实际的实时渲染中,我们通常采用其简化版或预计算查找表(LUT)的方式。核心是分别计算这三条路径的贡献,然后叠加。
为什么需要Marschner?当你需要电影级或3A游戏级的角色特写时,Kajiya-Kay的“感觉对了”但“细节不够”。Marschner能提供那种发丝根根分明、在逆光下散发出柔和光晕、色彩层次丰富的顶级质感。它是写实头发渲染的黄金标准。
2.3 融合策略:在URP中如何取舍与结合
在URP实战中,我们很少极端地二选一,更常见的是融合与分层。
- 基础层(Base Layer):使用一个简化版的、或风格化调整后的Kajiya-Kay模型,作为头发的基础高光响应。这保证了基础的光泽感和性能。
- 增强层(Enhancement Layer):引入Marschner模型的核心思想进行增强。
- R项:可以用一个更精确的、带方位角偏移的反射模型替代或叠加在Kajiya-Kay主高光上。
- TT项(边缘光):这是性价比极高的增强。我们可以在着色器中单独计算一个基于视线与光线方向的边缘光项,通常用
dot(N, L)或dot(N, V)的某种函数(如菲涅尔近似)来驱动,赋予头发一个柔和的、常为冷色调的光晕。 - TRT项(次表面散射):对于头发厚密的区域(如发髻、刘海根部),可以模拟简单的次表面散射(SSS)效果。在URP中,可以通过屏幕空间模糊(需要自定义渲染特征)或一个简化的、基于厚度图(Thickness Map)的漫反射来近似,让暗部透出头发的主色,避免死黑。
实操心得:对于大多数手游和独立游戏项目,“Kajiya-Kay主高光 + Marschner式边缘光(TT)”的组合已经能产生质的飞跃,且性能开销可控。完整的Marschner三路径计算,建议仅在PC/主机的高质量角色渲染中使用,或作为后处理效果局部应用。
3. URP管线下的着色器架构设计与关键实现
3.1 构建自定义URP Lit着色器框架
在URP中,我们不从头写一个完整的Surface Shader,而是基于其提供的Lit着色器模板进行修改,这是最稳妥高效的方式。
- 创建着色器与材质:在Unity中,创建新的Shader Graph或自定义HLSL着色器文件。对于这种需要复杂光照模型的项目,我强烈推荐使用手写HLSL代码的方式,因为Shader Graph在表达复杂的向量运算和自定义光照函数时,节点图会变得异常复杂和难以调试。创建一个
CustomHairLit.shader,并复制URP的Lit.shader作为基础。 - 定义关键输入:除了常规的
Position,Normal,Tangent,UV0外,我们必须确保模型的切线(Tangent)数据是正确的,并且代表了发丝的方向。这通常需要在三维建模软件(如Maya, Blender)中制作角色时,由美术人员精心梳理和烘焙。 - 自定义光照函数:这是核心。我们需要覆写URP的
LightingPhysicallyBased函数(或相关函数)。在这个函数内部,我们将用自己的头发光照计算函数替代标准的BRDF计算。
3.2 Kajiya-Kay模型的核心HLSL实现
让我们在一个自定义的Lighting.hlsl包含文件中实现Kajiya-Kay。关键步骤是正确获取切线空间并计算基于切线/副法线的高光。
// 在片元着色器中 void HairLighting_half( half3 albedo, half3 normalWS, half3 tangentWS, half3 viewDirWS, half3 lightDirWS, half specularPower, half specularScale, half secondarySpecularPower, half secondarySpecularScale, out half3 diffuse, out half3 specular ) { // 规范化向量 normalWS = normalize(normalWS); tangentWS = normalize(tangentWS); viewDirWS = normalize(viewDirWS); lightDirWS = normalize(lightDirWS); // 计算副法线 (Binormal) half3 binormalWS = normalize(cross(normalWS, tangentWS)); // 基础漫反射 (Lambert) half NdotL = dot(normalWS, lightDirWS); diffuse = albedo * saturate(NdotL); // Kajiya-Kay 主高光 (基于切线) half TdotL = dot(tangentWS, lightDirWS); half TdotV = dot(tangentWS, viewDirWS); half sinTL = sqrt(saturate(1.0 - TdotL * TdotL)); half sinTV = sqrt(saturate(1.0 - TdotV * TdotV)); half primarySpec = pow(saturate(sinTL * sinTV - TdotL * TdotV), specularPower); half3 primarySpecColor = primarySpec * specularScale; // Kajiya-Kay 次级高光 (基于副法线,模拟横向光泽) half BdotL = dot(binormalWS, lightDirWS); half BdotV = dot(binormalWS, viewDirWS); half sinBL = sqrt(saturate(1.0 - BdotL * BdotL)); half sinBV = sqrt(saturate(1.0 - BdotV * BdotV)); half secondarySpec = pow(saturate(sinBL * sinBV - BdotL * BdotV), secondarySpecularPower); half3 secondarySpecColor = secondarySpec * secondarySpecularScale; // 合并高光 specular = primarySpecColor + secondarySpecColor; }参数调节技巧:
specularPower:控制主高光的锐利程度。值越大,高光点越集中、越小。写实头发通常需要较高的值(如100-200),卡通风格可以低一些。secondarySpecularScale:次级高光的强度通常比主高光弱很多(例如0.1-0.3倍),用于添加微妙的层次,切忌喧宾夺主。
3.3 Marschner边缘光(TT)与次表面散射(TRT)近似实现
在同一个光照函数中,我们可以在Kajiya-Kay的基础上叠加Marschner风格的效果。
// 在光照函数中继续添加 void HairLighting_half( // ... 之前的输入参数 half edgeLightPower, half edgeLightScale, half sssPower, half sssScale, // ... 之前的输出参数 out half3 edgeLight, out half3 sss ) { // ... 之前的Kajiya-Kay计算代码 // 1. Marschner TT 边缘光近似 // 常用方法是利用视线与法线的点积,在边缘(grazing angle)增强 half fresnel = pow(1.0 - saturate(dot(normalWS, viewDirWS)), edgeLightPower); edgeLight = fresnel * edgeLightScale; // 通常边缘光使用一个偏冷或白色的颜色 edgeLight *= half3(0.8, 0.9, 1.0); // 2. Marschner TRT 次表面散射近似 (简化版) // 使用一个“假”的厚度图或通过光线步进近似。这里用一个基于光线反向的简单模拟。 // 更佳实践是使用一张预绘制的厚度图(Thickness Map)。 half wrap = 0.5; // 光线包裹系数,模拟光在纤维内部的散射 half wrappedNdotL = saturate((NdotL + wrap) / (1.0 + wrap)); // 模拟光从背面透出的效果,通常与主光方向相反时最强 half backLit = saturate(-NdotL); sss = albedo * backLit * pow(wrappedNdotL, sssPower) * sssScale; }注意事项:
- 边缘光:
edgeLightPower控制边缘光衰减的柔和度,edgeLightScale控制强度。这个效果在逆光或侧逆光时最为明显,是提升头发质感的神来之笔。 - 次表面散射:这里的简化版SSS效果很弱。对于高质量的SSS,强烈建议使用一张灰度图作为厚度图。这张图可以在建模软件中生成,越白表示头发越薄(如发梢、边缘),越黑表示越厚(如发根、发髻中心)。在着色器中采样这张图,用它来调制SSS的强度,效果会真实得多。
3.4 在URP中整合与最终颜色合成
最后,我们需要在片元着色器的主函数中,调用自定义的光照函数,并取代标准光照计算。
// 在片元着色器中 half4 frag(Varyings input) : SV_Target { // ... 获取表面数据 (SurfaceData) // ... 获取光照数据 (InputData) // 调用自定义头发光照函数 half3 hairDiffuse, hairSpecular, hairEdgeLight, hairSSS; HairLighting_half( surfaceData.albedo, inputData.normalWS, inputData.tangentWS, // 需要确保InputData中包含切线 inputData.viewDirectionWS, light.direction, _SpecularPower, _SpecularScale, _SecondarySpecularPower, _SecondarySpecularScale, _EdgeLightPower, _EdgeLightScale, _SSSPower, _SSSScale, hairDiffuse, hairSpecular, hairEdgeLight, hairSSS ); // 环境光与其它光照(如URP的全局光照GI) half3 ambient = SampleSH(inputData.normalWS); half3 gi = surfaceData.albedo * ambient; // 最终颜色合成 // 基础颜色 = 直接漫反射 + 间接光 + 次表面散射 half3 baseColor = hairDiffuse + gi + hairSSS; // 叠加高光与边缘光 half3 finalColor = baseColor + hairSpecular + hairEdgeLight; // 处理阴影、雾效等 // ... return half4(finalColor, surfaceData.alpha); }注意:以上代码是高度简化的示意,用于阐明流程。在实际的URP着色器中,你需要正确处理多光源循环、阴影衰减、烘焙光照等。通常需要修改URP的内置
Lighting.hlsl文件或创建一个完全自定义的PBR光照函数。
4. 材质参数配置与美术工作流
一个强大的着色器必须配以友好的美术工作流。我们需要在着色器中暴露一系列参数,并指导美术如何配置。
4.1 核心材质参数详解
在Properties块和对应的CBuffer中定义这些参数:
Properties { [Header(Base)] _BaseMap("Albedo", 2D) = "white" {} _BaseColor("Color", Color) = (1,1,1,1) _Smoothness("Smoothness", Range(0,1)) = 0.5 // 可能用于某些计算 [Header(Kajiya-Kay Specular)] _SpecularColor("Specular Color", Color) = (1,1,1,1) _SpecularScale("Specular Scale", Range(0, 5)) = 1.0 _SpecularPower("Specular Power", Range(1, 500)) = 100 _SecondarySpecularScale("Secondary Specular Scale", Range(0, 2)) = 0.2 _SecondarySpecularPower("Secondary Specular Power", Range(1, 500)) = 50 [Header(Marschner Enhancements)] _EdgeLightColor("Edge Light Color", Color) = (0.8, 0.9, 1.0, 1) _EdgeLightScale("Edge Light Scale", Range(0, 5)) = 1.0 _EdgeLightPower("Edge Light Power", Range(0.1, 10)) = 3.0 _SSSColor("SSS Color", Color) = (1,0.8,0.7,1) // 通常接近发色但更暖/更饱和 _SSSScale("SSS Scale", Range(0, 2)) = 0.3 _SSSPower("SSS Power", Range(0.1, 10)) = 2.0 _ThicknessMap("Thickness Map", 2D) = "white" {} // 厚度图,白色薄,黑色厚 [Header(Advanced)] _NormalMap("Normal Map", 2D) = "bump" {} _NormalScale("Normal Scale", Range(0,2)) = 1.0 }美术调节指南:
- 基础色(BaseColor):使用头发的固有色贴图。避免纯黑或纯白,保留一些色彩变化。
- 高光参数:先调
_SpecularPower,找到高光大小合适的感觉(写实调高,卡通调低)。然后用_SpecularScale控制亮度。_SpecularColor可以用来给高光染色,例如金色头发可以用淡黄色高光。 - 次级高光:保持低强度(
_SecondarySpecularScale < 0.5),它只是增加细节,不应被明显察觉为一个独立的高光。 - 边缘光:从默认的冷色调开始,
_EdgeLightScale在1左右,_EdgeLightPower在3-5之间。在逆光场景中观察,调整到出现柔和光晕但不过曝。 - SSS与厚度图:这是质感的灵魂。让美术输出一张厚度图。在着色器中,采样厚度图(
Thickness Map),越白的区域(发梢)SSS效果越强。_SSSColor通常设置为比基础色更暖、更饱和的颜色,模拟光在头发内部散射后的颜色。
4.2 纹理制作与模型要求
- 切线(Tangent)数据:这是重中之重。模型导入Unity后,必须在导入设置中确认切线(Tangents)选项被正确设置(通常为
Calculate或Import)。错误或缺失的切线数据会导致高光方向完全错误。 - 厚度图生成:可以在ZBrush、Maya或Substance Painter中生成。基本思路是:将模型法线向内挤压(Offset)一定距离,生成一个内壳,然后计算原始模型与内壳对应顶点之间的距离,并将这个距离信息烘焙到一张贴图上。距离近(发根、厚处)为黑,距离远(发梢、薄处)为白。
- 法线贴图:虽然不是必须,但一张好的法线贴图可以增加发丝束的细节和体积感,与我们的着色器高光相得益彰。
5. 性能优化与多平台适配策略
在URP中,一个功能丰富的自定义着色器必须考虑性能。
5.1 计算优化技巧
- 简化与近似:在移动平台,考虑完全移除完整的Marschner TRT项,或用一个极其简化的、基于视角的漫反射来替代。TT边缘光可以保留,因为它的计算很简单(一个
pow运算)。 - 预计算与LUT:对于完整的Marschner模型,复杂的
M、N函数计算是性能瓶颈。可以考虑将部分计算结果预烘焙到一张2D查找纹理(LUT)中,在着色器中通过tex2D采样替代实时计算,这是游戏工业的常见优化手段。 - 分支优化:避免在片元着色器中使用动态分支(如
if语句)。尽量使用lerp、saturate、step等函数来实现条件逻辑。 - 精度选择:在移动端,将大多数中间变量和参数声明为
half精度(除非需要float)。对于颜色计算,half通常足够。
5.2 多质量等级配置
一个专业的着色器应该支持多质量等级。
- 高质量(High/PC):启用所有功能(Kajiya-Kay双高光 + Marschner TT + 基于厚度图的SSS)。
- 中质量(Medium):启用Kajiya-Kay双高光 + 简化的Marschner TT边缘光。禁用或大幅简化SSS。
- 低质量(Low/Mobile):仅启用Kajiya-Kay主高光,或使用一个极度简化的、基于
dot(N, L)的类Phong高光。禁用次级高光、边缘光和SSS。
可以通过着色器变体(Shader Variants)和关键字(#pragma shader_feature)来实现。在URP的ShaderGUI中,可以根据质量设置的关键字来动态启用或禁用材质面板上的某些参数。
#pragma shader_feature _HAIR_QUALITY_HIGH _HAIR_QUALITY_MEDIUM _HAIR_QUALITY_LOW // 在片元着色器中 #if defined(_HAIR_QUALITY_HIGH) // 完整计算 #elif defined(_HAIR_QUALITY_MEDIUM) // 中等计算,省略SSS或简化 #else // LOW // 最低计算,仅基础高光 #endif5.3 与URP渲染特征的兼容性
确保你的着色器与URP的核心渲染特征良好协作:
- 阴影:正确接收和投射阴影。这通常意味着需要包含
ShadowCasterPass。 - 深度纹理与法线纹理:如果你的效果需要屏幕空间深度或法线(例如做更复杂的边缘光或SSS),需要确保URP渲染器资产中启用了
Opaque Texture和Depth Texture。 - 后处理:你的头发颜色应该在色调映射(Tonemapping)等后处理之前是正确的线性空间颜色,以确保后处理效果一致。
6. 常见问题排查与调试技巧实录
在实际开发中,你会遇到各种奇怪的问题。这里记录一些典型坑位和解决方法。
6.1 高光方向错误或闪烁
- 问题描述:头发高光带位置不对,或者随着视角移动剧烈闪烁、跳跃。
- 根本原因:99%是顶点切线(Tangent)数据问题。
- 排查步骤:
- 在着色器中输出切线方向(
tangentWS)作为颜色(例如,return float4(tangentWS * 0.5 + 0.5, 1.0);)。在场景中查看,切线方向应该沿着发丝方向平滑变化。如果出现混乱的色块或突变,说明模型切线数据有问题。 - 检查模型导入设置。确保
Tangents选项设置为Calculate或Import。对于某些从其他软件导入的、带有自定义切线的模型,Import可能更准确。 - 在三维软件中检查。确保模型的UV通道没有被破坏,因为切线计算通常依赖UV方向。尝试在建模软件中重新计算切线(在Maya中可能是“软化边”后重新“硬化边”,在Blender中检查“自动光滑”和法向/切线设置)。
- 在着色器中输出切线方向(
- 实操心得:对于复杂的、分多块建模的头发,务必确保所有头发部件的切线计算方式一致。有时需要美术在建模时专门为头发梳理一套用于着色的顶点颜色或第二套UV来存储方向信息,这比依赖自动计算的切线更可靠。
6.2 边缘光(TT)在正面过强或不自然
- 问题描述:从正面看头发,边缘光也过于明显,导致头发整体发白、发灰,失去体积感。
- 原因与解决:我们使用的
fresnel = pow(1.0 - saturate(dot(N, V)), power)近似,在正面(dot(N, V)接近1)时值很小,但在侧面仍然较强。问题可能出在:- 强度(
_EdgeLightScale)过高:先尝试调低这个值。 - 缺乏角度限制:真实的TT边缘光主要出现在逆光(
dot(N, L) < 0)或侧逆光情况。我们可以用光照方向来调制边缘光强度:half backLitFactor = saturate(-dot(normalWS, lightDirWS)); // 背光因子 edgeLight *= backLitFactor; - 颜色问题:尝试将
_EdgeLightColor调得更暗、饱和度更低,或者让它更依赖于环境光色而非固定颜色。
- 强度(
6.3 次表面散射(SSS)效果不明显或错误
- 问题描述:头发暗部没有透光感,或者透光颜色很奇怪。
- 排查与解决:
- 检查厚度图:这是最常见的原因。在材质球上显示厚度图,确认发梢、头发边缘是亮的(薄),发根、厚处是暗的(厚)。如果图是反的,需要在着色器中取反(
1.0 - thickness)。 - SSS计算依赖光源方向:确保你的SSS计算中,
backLit因子(saturate(-NdotL))是正确的。它应该在光线从背面照射时最强。 - 颜色与强度:
_SSSColor通常应该是暖色调(红、橙、黄),模拟血液和角质层下的颜色。_SSSScale需要谨慎调节,过强会让头发看起来像蜡像。 - 更高质量的SSS:如果简化版效果不佳,可以考虑实现一个屏幕空间的次表面散射(SSSS)后处理,但这会显著增加性能开销,通常用于主角特写。
- 检查厚度图:这是最常见的原因。在材质球上显示厚度图,确认发梢、头发边缘是亮的(薄),发根、厚处是暗的(厚)。如果图是反的,需要在着色器中取反(
6.4 性能热点分析与优化
- 使用Frame Debugger或RenderDoc:抓取一帧渲染,查看你的自定义着色器的耗时。重点关注片元着色器的指令数。
- 简化计算:如果发现
pow、sqrt、sin/cos等复杂函数调用过多,考虑:- 用查找表(LUT)替代。
- 用近似函数替代,例如用
x*x代替pow(x,2),用rsqrt相关技巧。 - 将某些计算从片元着色器移到顶点着色器,然后插值(前提是变化足够平滑,不会产生马赫带)。
- 减少纹理采样:确保没有不必要的纹理采样。合并贴图(如将厚度信息存入Albedo贴图的Alpha通道)是常用优化手段。
6.5 与URP内置光照的冲突
- 问题描述:启用自定义着色器后,场景中的其他光照(如点光源、聚光灯)对头发不起作用,或者效果叠加错误。
- 解决:这是因为你完全覆写了光照计算。你需要确保在你的自定义光照函数中,正确处理多光源循环。参考URP内置的
UniversalFragmentPBR函数,看看它是如何遍历InputData.lightData中的每一个可见光并累积光照结果的。你需要将你的HairLighting函数应用到每一个光源上,并将结果累加。同时,不要忘记环境光(GI)和自发光(Emission)的贡献。
头发着色器的调试是一个需要耐心和观察力的过程。多在不同的光照环境下(顺光、侧光、逆光、多光源)测试,多与美术沟通,反复调整参数,才能达到理想的效果。最终,一个优秀的头发着色器不仅仅是数学公式的堆砌,更是对光的物理特性与艺术表现之间平衡的精准拿捏。