Unity URP头发着色器实战:融合Kajiya-Kay与Marschner模型

📅 2026/8/10 6:43:27
Unity URP头发着色器实战:融合Kajiya-Kay与Marschner模型
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的贡献。头发着色器的调试是一个需要耐心和观察力的过程。多在不同的光照环境下顺光、侧光、逆光、多光源测试多与美术沟通反复调整参数才能达到理想的效果。最终一个优秀的头发着色器不仅仅是数学公式的堆砌更是对光的物理特性与艺术表现之间平衡的精准拿捏。