Unity头发渲染实战:Kajiya-Kay模型原理与Shader实现详解
1. 项目概述为什么Kajiya-Kay模型是头发渲染的“定海神针”在Unity里做角色渲染头发一直是个老大难问题。你可能会发现用标准的PBR基于物理的渲染材质去调头发怎么调都感觉不对——要么高光死板得像塑料要么就是一片油腻腻的完全没有真实发丝那种柔顺、有层次的光泽感。这背后的核心原因在于真实世界头发的微观结构与金属、塑料这类表面完全不同。每一根头发本质上都是一个微小的圆柱体光线在其表面的反射遵循着独特的物理规律而传统的微表面BRDF模型比如Cook-Torrance是为各向同性或各向异性的平面表面设计的套用在圆柱体上自然水土不服。这就是Kajiya-Kay模型登场的时候了。这个由Jim Kajiya和Tim Kay在1989年提出的经典模型虽然年头久远但至今仍是实时渲染中模拟头发高光的“黄金标准”。它聪明地绕开了复杂的物理模拟从一个更直观的几何视角出发将头发简化成一根根细长的圆柱体然后分别计算光线在圆柱体切线方向和副法线方向上的高光反射。简单来说它给了我们两个可以独立控制的高光项一个沿着发丝走向切线方向模拟发丝表面纵向的、锐利的高光条另一个垂直于发丝走向副法线方向模拟发丝内部结构如皮质层产生的更柔和、更宽泛的“次级高光”。这种双高光模型恰好捕捉到了真实头发在光照下那种标志性的、流动的“天使环”Angel Ring或“高光带”效果。我之所以选择在Unity里复现这个模型而不是直接用现成的头发Shader资源是因为理解并亲手实现一遍Kajiya-Kay是打通角色渲染任督二脉的关键一步。它能让你彻底明白头发高光背后的数学和美学之后无论是调参、魔改还是整合进更复杂的发卡Hair Card渲染管线你都能心中有数。接下来我会带你从零开始拆解模型原理手写Shader代码并分享一系列从实战中踩坑总结出来的调参技巧和优化心得。2. Kajiya-Kay模型核心原理拆解从一根圆柱体到流动的高光要写好Shader不能只当“调参侠”必须搞清楚模型背后的数学。Kajiya-Kay模型的核心思想并不复杂但理解透彻了才能灵活运用。2.1 基础光照模型兰伯特与冯氏高光的局限在深入Kajiya-Kay之前我们先看看为什么标准模型不行。兰伯特漫反射假设表面是理想粗糙的光线均匀散射这用于头发会丢失所有方向性的光泽。冯氏Phong或Blinn-Phong高光模型虽然能产生高光但其计算基于表面法线Normal和半角向量Half Vector。对于头发这种圆柱体其法线是垂直于圆柱表面的但高光的主要贡献并非来自这个“外表面”的反射而是来自沿着圆柱切线方向的“纵向”反射。直接用表面法线去算高光位置会错位并且无法形成那种沿着发丝走向的连续光带。2.2 Kajiya-Kay的革新切线T与副法线B作为反射基准Kajiya-Kay模型做了一个关键的范式转换它不再使用表面的几何法线作为计算高光的主要依据而是使用头发的切线方向Tangent和副法线方向Binormal。切线方向在Unity中这通常就是发丝的走向。你可以通过模型的顶点切线Vertex Tangent来获取。光线在圆柱体切线方向上的反射模拟的是发丝外表面角质层的镜面反射这形成了主高光。它通常比较锐利、明亮。副法线方向副法线由法线和切线叉乘得到B N × T。这个方向可以理解为垂直于发丝表面且沿着圆柱“环”的方向。光线在这个方向上的反射被用来模拟光线进入头发内部皮质层后发生的散射和次级反射这形成了次级高光。它通常更宽、更柔和颜色也可能与主高光略有不同例如更暖一些。模型为这两个方向分别定义了一个类似Phong的高光计算项。其核心公式可以简化为Specular Ks * pow(max(0, dot(T, H)), specularPower) Ks2 * pow(max(0, dot(B, H)), specularPower2)这里T是切线B是副法线H是光线与视线的半角向量。Ks和Ks2分别是主、次级高光的强度specularPower和specularPower2是控制高光锐利度的幂指数。看到这里你就明白了整个计算完全绕开了表面法线N这就是它成功的关键。2.3 在Unity中的几何意义与数据准备在Unity的Shader中我们需要在顶点着色器或片段着色器中构建正确的切线空间。通常模型导入时需要勾选“切线”Tangents选项这样Mesh数据中就会包含切线信息。在Shader中我们可以通过TANGENT语义获取顶点切线。副法线则需要在Shader中实时计算float3 binormal cross(normal, tangent.xyz) * tangent.w; // 注意tangent.w用于处理手性这个binormal就是我们的副法线方向。确保你的模型切线信息正确非常关键如果切线方向乱七八糟比如不是沿着发丝走向那么高光也会乱成一团。对于头发模型建模时务必保证UV展开和切线方向是顺着发丝生长的方向。3. Unity ShaderLab实战从零构建Kajiya-Kay着色器理论说得再多不如一行代码。我们将在Unity的URP通用渲染管线环境下手写一个包含完整Kajiya-Kay高光的Surface Shader为了兼容性和理解我们先以Built-in管线风格的Surface Shader为例其核心思想同样适用于URP的Shader Graph或HLSL代码。3.1 着色器框架与属性定义首先我们创建一个新的Shader文件并定义我们需要用到的属性。这些属性将暴露在材质面板上方便我们动态调节。Shader Custom/KajiyaKayHair { Properties { _MainTex (Albedo (RGB), 2D) white {} _Color (Color, Color) (1,1,1,1) // Kajiya-Kay Primary Specular _SpecularColor (Primary Specular Color, Color) (1,1,1,1) _SpecularPower (Primary Specular Power, Range(1, 256)) 100 _SpecularScale (Primary Specular Scale, Range(0, 2)) 0.1 // Kajiya-Kay Secondary Specular _SecondarySpecularColor (Secondary Specular Color, Color) (0.9, 0.8, 0.7, 1) _SecondarySpecularPower (Secondary Specular Power, Range(1, 256)) 50 _SecondarySpecularScale (Secondary Specular Scale, Range(0, 2)) 0.05 // Rim Light (可选增强边缘光) _RimColor (Rim Color, Color) (0.5, 0.5, 0.5, 1) _RimPower (Rim Power, Range(0.1, 10)) 3.0 _RimScale (Rim Scale, Range(0, 2)) 0.5 }注意这里我们将主高光和次级高光的颜色、强度Scale、锐度Power都分开了。这是Kajiya-Kay模型调参的核心。通常主高光更白、更锐Power值高次级高光可以带点暖黄色、更柔和Power值低。3.2 数据结构与顶点着色器接下来我们需要定义从顶点着色器传递到片段着色器的数据结构并在顶点着色器中计算必要的方向向量。SubShader { Tags { RenderTypeOpaque } LOD 200 CGPROGRAM #pragma surface surf KajiyaKay fullforwardshadows #pragma target 3.0 sampler2D _MainTex; fixed4 _Color; fixed4 _SpecularColor, _SecondarySpecularColor; half _SpecularPower, _SpecularScale, _SecondarySpecularPower, _SecondarySpecularScale; fixed4 _RimColor; half _RimPower, _RimScale; struct Input { float2 uv_MainTex; float3 viewDir; // 视图方向世界空间 float3 worldNormal; // 世界空间法线 INTERNAL_DATA // 用于让SurfaceOutput自动处理一些数据 }; // 自定义的光照模型函数 half4 LightingKajiyaKay (SurfaceOutput s, half3 lightDir, half3 viewDir, half atten) { // 这个函数我们稍后详细实现 } void surf (Input IN, inout SurfaceOutput o) { // 基础颜色采样 fixed4 c tex2D (_MainTex, IN.uv_MainTex) * _Color; o.Albedo c.rgb; o.Alpha c.a; // 法线信息如果模型有法线贴图可以在这里采样并赋值给o.Normal // o.Normal UnpackNormal(tex2D(_BumpMap, IN.uv_MainTex)); } ENDCG } FallBack Diffuse }目前只是一个框架光照模型LightingKajiyaKay是空壳。Surf函数负责处理基础颜色和法线。3.3 核心光照模型实现重头戏来了我们在LightingKajiyaKay函数中实现Kajiya-Kay计算。这里我们需要在片段着色阶段获取到正确的切线和副法线。// 自定义光照模型 half4 LightingKajiyaKay (SurfaceOutput s, half3 lightDir, half3 viewDir, half atten) { // 1. 基础漫反射 (Lambert) half NdotL max(0, dot(s.Normal, lightDir)); half3 diffuseTerm s.Albedo * _LightColor0.rgb * NdotL; // 2. 准备向量 // 将光方向和视方向归一化 lightDir normalize(lightDir); viewDir normalize(viewDir); // 计算半角向量 half3 halfVec normalize(lightDir viewDir); // 3. 获取切线 (T) 和副法线 (B) // 注意在Surface Shader中我们可以通过世界空间法线和切线映射来重建。 // 这里假设模型的顶点切线方向是正确的。更严谨的做法是从Input结构传递。 // 为了简化示例我们假设s.Normal是插值后的世界法线并需要从法线贴图或顶点数据中获取切线。 // 实际上在surf函数中我们应该将计算好的切线相关向量存入SurfaceOutput的自定义字段。 // 这是一个关键点我们稍后优化。 // 假设我们通过某种方式获得了世界空间的切线T和副法线B // float3 worldTangent ... ; // float3 worldBinormal cross(s.Normal, worldTangent) * tangentSign; // 4. 计算Kajiya-Kay高光 // 主高光基于切线T half TdotH dot(worldTangent, halfVec); half primarySpec pow(max(0, TdotH), _SpecularPower) * _SpecularScale; half3 specularTerm _SpecularColor.rgb * primarySpec; // 次级高光基于副法线B half BdotH dot(worldBinormal, halfVec); half secondarySpec pow(max(0, BdotH), _SecondarySpecularPower) * _SecondarySpecularScale; specularTerm _SecondarySpecularColor.rgb * secondarySpec; // 5. 可选的边缘光 (Rim Light) half rim 1.0 - max(0, dot(viewDir, s.Normal)); half3 rimTerm _RimColor.rgb * pow(rim, _RimPower) * _RimScale * NdotL; // 乘以NdotL使其受光照影响 // 6. 合并所有光照项 half3 finalColor (diffuseTerm specularTerm rimTerm) * atten; return half4(finalColor, s.Alpha); }上面的代码有一个关键问题worldTangent和worldBinormal从哪里来在标准的Surface Shader流程中我们需要修改Input结构和surf函数来传递这些信息。3.4 传递切线与副法线数据我们需要在顶点着色器中计算世界空间的切线和副法线然后通过Input结构传递给片段着色器。这需要用到UnityCG.cginc中的一些宏。首先修改Input结构struct Input { float2 uv_MainTex; float3 viewDir; float3 worldNormal; float3 worldTangent; // 新增世界空间切线 float3 worldBinormal; // 新增世界空间副法线 INTERNAL_DATA };然后创建一个自定义的顶点函数vertex modifier来计算这些向量void vert (inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input, o); // 计算世界空间法线、切线和副法线 float3 worldNormal UnityObjectToWorldNormal(v.normal); float3 worldTangent UnityObjectToWorldDir(v.tangent.xyz); float tangentSign v.tangent.w * unity_WorldTransformParams.w; float3 worldBinormal cross(worldNormal, worldTangent) * tangentSign; o.worldNormal worldNormal; o.worldTangent worldTangent; o.worldBinormal worldBinormal; // 视图方向可以在片段着色器或这里计算 // o.viewDir WorldSpaceViewDir(v.vertex); }并在#pragma指令中声明这个顶点函数#pragma surface surf KajiyaKay fullforwardshadows vertex:vert最后在surf函数中将这些值赋给SurfaceOutput的自定义字段我们需要扩展SurfaceOutput结构或者更简单地将它们存入Input结构并在光照模型中直接使用。由于LightingKajiyaKay函数能接收到SurfaceOutput s和Input IN在生成的代码中我们可以通过IN来访问。但标准光照模型签名不直接传递Input。一个更直接的方法是将计算好的T和B存入SurfaceOutput的某个未使用的字段如Specular或Gloss但这会破坏语义。更清晰且推荐的做法是放弃Surface Shader的自动光照模型改用顶点片段着色器Vertex-Fragment Shader这样我们可以完全控制数据流。考虑到篇幅和清晰度我们调整策略提供一个在片段着色器中计算的核心函数// 计算Kajiya-Kay高光的核心函数 half3 CalculateKajiyaKaySpecular(half3 tangent, half3 binormal, half3 halfVec, half3 normal, half3 viewDir, half NdotL) { // 主高光 half TdotH dot(tangent, halfVec); // 使用saturate防止负数pow计算更稳定 half primarySpec saturate(TdotH); primarySpec pow(primarySpec, _SpecularPower) * _SpecularScale; // 次级高光 half BdotH dot(binormal, halfVec); half secondarySpec saturate(BdotH); secondarySpec pow(secondarySpec, _SecondarySpecularPower) * _SecondarySpecularScale; // 组合 half3 specular primarySpec * _SpecularColor.rgb secondarySpec * _SecondarySpecularColor.rgb; // 可选添加基于法线的高光衰减NdotH让高光在侧面减弱更符合物理直觉 // half NdotH dot(normal, halfVec); // specular * pow(saturate(NdotH), _SpecularPower * 0.1); return specular; }在顶点片段着色器中你可以在顶点阶段计算worldTangent和worldBinormal然后传递给片段阶段在片段阶段调用这个函数。这样数据流清晰也便于移植到URP的HLSL或Shader Graph中。4. 参数调节艺术与视觉优化技巧代码写好了但调出一个好看的头发高光才是真正的挑战。Kajiya-Kay模型的几个参数相互作用需要耐心微调。4.1 主高光与次级高光的参数协同主高光和次级高光不是独立的它们共同塑造头发的体积感和质感。主高光Primary Specular颜色_SpecularColor通常接近白色或光源色。保持较白的颜色可以凸显发丝的“油亮”质感。强度_SpecularScale一般在0.05到0.2之间。太强会像金属太弱则没有存在感。对于湿润或油腻的发型如朋克造型可以调高。锐度_SpecularPower范围通常在50到200之间。值越高高光点越集中、越锐利像刚洗过的柔顺头发值越低高光越弥散像干燥或受损的发质。次级高光Secondary Specular颜色_SecondarySpecularColor可以尝试加入一点点暖色调如淡黄色0.98, 0.95, 0.9或淡橙色。这能模拟头发内部色素如黑色素对光线的吸收和再发射让高光更有层次避免死白。强度_SecondarySpecularScale通常比主高光弱约为0.02到0.1。它的作用是填充主高光周围形成光晕过渡。锐度_SecondarySpecularPower通常比主高光低在20到100之间。更柔和的光晕。调节心法先调主高光找到一个能清晰勾勒发丝走向的锐利光带。然后加入次级高光用较低强度和较低锐度去“包裹”主高光观察头发是否从一条“亮线”变成了一个“亮带”体积感是否增强。可以尝试旋转光源观察高光在头发上的流动是否自然连续。4.2 结合法线贴图与各向异性噪声纯色的Kajiya-Kay高光有时会显得过于“完美”和“塑料”。真实头发有毛鳞片、分叉和轻微的不规则。使用法线贴图一张好的头发法线贴图可以极大地增加细节。但要注意法线贴图会影响表面法线N而Kajiya-Kay计算主要依赖T和B。我们通常用法线贴图来影响漫反射和边缘光对高光的影响需要谨慎。一种常见技巧是用法线贴图的某个通道如蓝色通道的扰动来轻微地扭曲切线方向T从而让高光产生细微的起伏和断裂模拟毛鳞片的效果。// 假设从法线贴图解压出的切线空间法线是normalTS // 轻微扰动世界空间切线 float3 perturbedTangent worldTangent normalTS.x * worldNormal * 0.1; // 0.1是扰动强度 perturbedTangent normalize(perturbedTangent); // 用perturbedTangent代替原始的worldTangent进行高光计算添加各向异性噪声另一种更可控的方法是在高光计算前对TdotH或BdotH的值进行采样一张噪声贴图或程序化噪声的扰动。这能打破高光的均匀性使其看起来更自然。例如可以用头发的UV坐标去采样一张Flow Map或Anisotropic Noise Map然后用采样的值去旋转切线方向或偏移高光计算用的UV。4.3 性能优化考量Kajiya-Kay模型本身计算量不大但在移动端或大量角色同屏时仍需优化。简化计算如果次级高光效果不明显可以考虑关闭。pow运算比较耗可以预先计算一个_SpecularPower的倒数用exp2和log2来近似计算但现代GPU上通常不是瓶颈。更关键的是减少纹理采样。使用Shader变体为不同质量的平台编译不同的Shader变体。例如低配版可以关闭次级高光和边缘光甚至用更简单的计算模型。烘焙光照信息对于静态或半静态光源可以考虑将部分高光信息烘焙到顶点颜色或一张光照贴图中在Shader中作为基础值进行混合。在URP/HDRP中的实现在URP中你可以通过编写自定义的Lit着色器或使用Shader Graph来实现。在Shader Graph中你需要自己构建切线/副法线向量并按照上述公式搭建节点网络。HDRP则有更复杂的发丝渲染管线如Hair Master Node它可能集成了更先进的模型如Marschner但理解Kajiya-Kay仍是基础。5. 常见问题排查与实战调试记录在实际项目中使用这个Shader时你肯定会遇到各种奇怪的现象。下面是我踩过的一些坑和解决方法。5.1 高光方向错乱或断裂这是最常见的问题根本原因几乎都是切线数据错误。症状高光不沿着发丝方向而是横着、斜着或者断断续续。排查步骤检查模型导入设置在Unity的模型导入面板确保“切线”Tangents选项不是“None”或“Calculate”。通常选择“Calculate”让Unity自动计算即可但前提是模型UV布局合理。检查UV方向在建模软件中头发的UV通常应该沿着发丝的生长方向展开U方向代表发丝走向。如果UV是横着展的切线方向也就错了。在Unity中可以用一个显示切线方向的调试Shader来检查。检查Shader中的切线计算确保在顶点着色器中v.tangent的数据是正确的并且tangent.w用于决定副法线方向的手性被正确处理。在计算副法线时叉乘顺序cross(normal, tangent)和乘以tangent.w是关键。使用调试颜色在Shader中将worldTangent或worldBinormal向量从-1到1映射到0到1直接输出为颜色在场景中查看。切线方向应该沿着发丝均匀变化如果出现突兀的色块跳跃说明顶点间的切线数据不连续或有误。5.2 高光在特定视角下消失或过强这通常与向量计算和归一化有关。症状当相机转到头发侧面或背面时高光突然没了或者正对时高光刺眼。排查与解决确保所有向量在计算前都已归一化lightDir,viewDir,halfVec,worldTangent,worldBinormal。片段着色器中插值后的向量长度可能不是1必须重新normalize。检查dot操作的结果范围使用saturate(dot(...))而不是max(0, dot(...))两者在正数范围等价但saturate是CG/HLSL内置函数有时编译器优化更好。视角依赖的衰减可以考虑加入一个基于法线和视线夹角NdotV的衰减因子。当视线与头发表面近乎平行时NdotV接近0高光应该减弱。这可以通过pow(saturate(NdotV), 0.5)之类的乘项实现。pow函数的低值问题当dot结果接近0时pow(x, p)可能会产生数值精度问题或非预期的高频闪烁。一个技巧是给dot结果加一个非常小的偏移如pow(max(1e-5, TdotH), _SpecularPower)。5.3 与阴影、透明渲染的兼容性问题头发常常涉及半透明Alpha Blending或Alpha Test发卡。阴影问题自定义的Surface Shader如果使用了fullforwardshadows标签通常能正确投射和接收阴影。但如果高光太强在阴影区域可能仍有残留高光显得不自然。需要在光照模型中考虑阴影衰减atten已包含在上述代码中。对于半透明头发阴影投射可能需要特殊处理比如使用单独的Shadow Caster Pass并且根据Alpha Test的阈值来裁剪。排序与透明问题半透明头发渲染顺序至关重要。必须确保Shader的渲染队列Queue设置为Transparent并且处理好深度写入ZWrite和深度测试ZTest。通常半透明物体关闭深度写入ZWrite Off使用深度测试为LEqualZTest LEqual。对于发卡Alpha Test渲染队列可以设为AlphaTest并开启深度写入这能获得正确的硬边遮挡但可能产生锯齿。抗锯齿可以使用clip()或AlphaToMask指令。5.4 在URP中实现的注意事项如果你要将此Shader移植到URP核心公式不变但框架完全不同。Shader Graph实现你需要使用Position和View Direction节点。通过Normal Vector和Tangent Vector节点获取数据并用Cross Product节点计算副法线。自己搭建计算Half Vector、点积、Power节点的网络。将计算出的高光结果连接到主节点的Specular或Emission输入口如果作为叠加效果。缺点Shader Graph对自定义光照模型的支持不如代码灵活复杂的向量操作节点图会显得很臃肿。自定义HLSL Shader推荐编写一个URP兼容的Unlit或Lit Shader。在Attributes中定义TANGENT语义。在Varyings结构体和顶点着色器中传递切线、副法线数据。在片段着色器中复制上面的光照计算逻辑。需要自己处理光照颜色Light.color和衰减URP提供了GetMainLight()等函数来获取这些数据。这种方式性能更好控制力最强是项目中的首选。调试是一个反复的过程。我的习惯是创建一个简单的测试场景一个球体用于对比标准材质、一个平面、以及你的头发模型。使用方向光并允许在运行时动态调整Shader的Properties。一边旋转光源和相机一边调节参数观察高光在头发曲面上的流动变化这是最快掌握Kajiya-Kay模型美感的方式。最后别忘了结合后期处理。一个轻微的Bloom效果可以让头发的高光在屏幕上“绽放”开来更加接近电影级的渲染质感。但Bloom的强度要控制好避免让整个屏幕都泛白。头发渲染从来不是Shader单打独斗它需要模型、贴图、Shader参数和后期效果的共同协作。当你调出一个在转动视角时高光能像流水般在发丝间滑动、既有锐利核心又有柔和光晕的效果时那种成就感就是对我们这些图形程序员最好的奖励。