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是光线与视线的半角向量。KsKs2分别是主、次级高光的强度,specularPowerspecularPower2是控制高光锐利度的幂指数。看到这里你就明白了,整个计算完全绕开了表面法线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 { "RenderType"="Opaque" } 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); }

上面的代码有一个关键问题:worldTangentworldBinormal从哪里来?在标准的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 sInput IN(在生成的代码中),我们可以通过IN来访问。但标准光照模型签名不直接传递Input。一个更直接的方法是将计算好的TB存入SurfaceOutput的某个未使用的字段(如SpecularGloss),但这会破坏语义。

更清晰且推荐的做法是:放弃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; }

在顶点片段着色器中,你可以在顶点阶段计算worldTangentworldBinormal,然后传递给片段阶段,在片段阶段调用这个函数。这样数据流清晰,也便于移植到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计算主要依赖TB。我们通常用法线贴图来影响漫反射和边缘光,对高光的影响需要谨慎。一种常见技巧是,用法线贴图的某个通道(如蓝色通道的扰动)来轻微地扭曲切线方向T,从而让高光产生细微的起伏和断裂,模拟毛鳞片的效果。

    // 假设从法线贴图解压出的切线空间法线是normalTS // 轻微扰动世界空间切线 float3 perturbedTangent = worldTangent + normalTS.x * worldNormal * 0.1; // 0.1是扰动强度 perturbedTangent = normalize(perturbedTangent); // 用perturbedTangent代替原始的worldTangent进行高光计算
  • 添加各向异性噪声:另一种更可控的方法是在高光计算前,对TdotHBdotH的值进行采样一张噪声贴图(或程序化噪声)的扰动。这能打破高光的均匀性,使其看起来更自然。例如,可以用头发的UV坐标去采样一张Flow Map或Anisotropic Noise Map,然后用采样的值去旋转切线方向或偏移高光计算用的UV。

4.3 性能优化考量

Kajiya-Kay模型本身计算量不大,但在移动端或大量角色同屏时仍需优化。

  1. 简化计算:如果次级高光效果不明显,可以考虑关闭。pow运算比较耗,可以预先计算一个_SpecularPower的倒数,用exp2log2来近似计算,但现代GPU上通常不是瓶颈。更关键的是减少纹理采样。
  2. 使用Shader变体:为不同质量的平台编译不同的Shader变体。例如,低配版可以关闭次级高光和边缘光,甚至用更简单的计算模型。
  3. 烘焙光照信息:对于静态或半静态光源,可以考虑将部分高光信息烘焙到顶点颜色或一张光照贴图中,在Shader中作为基础值进行混合。
  4. 在URP/HDRP中的实现:在URP中,你可以通过编写自定义的Lit着色器或使用Shader Graph来实现。在Shader Graph中,你需要自己构建切线/副法线向量,并按照上述公式搭建节点网络。HDRP则有更复杂的发丝渲染管线(如Hair Master Node),它可能集成了更先进的模型(如Marschner),但理解Kajiya-Kay仍是基础。

5. 常见问题排查与实战调试记录

在实际项目中使用这个Shader时,你肯定会遇到各种奇怪的现象。下面是我踩过的一些坑和解决方法。

5.1 高光方向错乱或断裂

这是最常见的问题,根本原因几乎都是切线数据错误

  • 症状:高光不沿着发丝方向,而是横着、斜着或者断断续续。
  • 排查步骤
    1. 检查模型导入设置:在Unity的模型导入面板,确保“切线”(Tangents)选项不是“None”或“Calculate”。通常选择“Calculate”让Unity自动计算即可,但前提是模型UV布局合理。
    2. 检查UV方向:在建模软件中,头发的UV通常应该沿着发丝的生长方向展开(U方向代表发丝走向)。如果UV是横着展的,切线方向也就错了。在Unity中可以用一个显示切线方向的调试Shader来检查。
    3. 检查Shader中的切线计算:确保在顶点着色器中,v.tangent的数据是正确的,并且tangent.w(用于决定副法线方向的手性)被正确处理。在计算副法线时,叉乘顺序cross(normal, tangent)和乘以tangent.w是关键。
    4. 使用调试颜色:在Shader中,将worldTangentworldBinormal向量(从-1到1映射到0到1)直接输出为颜色,在场景中查看。切线方向应该沿着发丝均匀变化,如果出现突兀的色块跳跃,说明顶点间的切线数据不连续或有误。

5.2 高光在特定视角下消失或过强

这通常与向量计算和归一化有关。

  • 症状:当相机转到头发侧面或背面时,高光突然没了;或者正对时高光刺眼。
  • 排查与解决
    1. 确保所有向量在计算前都已归一化lightDir,viewDir,halfVec,worldTangent,worldBinormal。片段着色器中插值后的向量长度可能不是1,必须重新normalize
    2. 检查dot操作的结果范围:使用saturate(dot(...))而不是max(0, dot(...)),两者在正数范围等价,但saturate是CG/HLSL内置函数,有时编译器优化更好。
    3. 视角依赖的衰减:可以考虑加入一个基于法线和视线夹角(NdotV)的衰减因子。当视线与头发表面近乎平行时(NdotV接近0),高光应该减弱。这可以通过pow(saturate(NdotV), 0.5)之类的乘项实现。
    4. 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),使用深度测试为LEqual(ZTest LEqual)。对于发卡(Alpha Test),渲染队列可以设为AlphaTest,并开启深度写入,这能获得正确的硬边遮挡,但可能产生锯齿。抗锯齿可以使用clip()AlphaToMask指令。

5.4 在URP中实现的注意事项

如果你要将此Shader移植到URP,核心公式不变,但框架完全不同。

  1. Shader Graph实现

    • 你需要使用PositionView Direction节点。
    • 通过Normal VectorTangent Vector节点获取数据,并用Cross Product节点计算副法线。
    • 自己搭建计算Half Vector、点积、Power节点的网络。
    • 将计算出的高光结果连接到主节点的SpecularEmission输入口(如果作为叠加效果)。
    • 缺点:Shader Graph对自定义光照模型的支持不如代码灵活,复杂的向量操作节点图会显得很臃肿。
  2. 自定义HLSL Shader(推荐)

    • 编写一个URP兼容的Unlit或Lit Shader。
    • Attributes中定义TANGENT语义。
    • Varyings结构体和顶点着色器中传递切线、副法线数据。
    • 在片段着色器中复制上面的光照计算逻辑。
    • 需要自己处理光照颜色(Light.color)和衰减,URP提供了GetMainLight()等函数来获取这些数据。
    • 这种方式性能更好,控制力最强,是项目中的首选。

调试是一个反复的过程。我的习惯是创建一个简单的测试场景:一个球体(用于对比标准材质)、一个平面、以及你的头发模型。使用方向光,并允许在运行时动态调整Shader的Properties。一边旋转光源和相机,一边调节参数,观察高光在头发曲面上的流动变化,这是最快掌握Kajiya-Kay模型美感的方式。

最后,别忘了结合后期处理。一个轻微的Bloom效果可以让头发的高光在屏幕上“绽放”开来,更加接近电影级的渲染质感。但Bloom的强度要控制好,避免让整个屏幕都泛白。头发渲染从来不是Shader单打独斗,它需要模型、贴图、Shader参数和后期效果的共同协作。当你调出一个在转动视角时,高光能像流水般在发丝间滑动、既有锐利核心又有柔和光晕的效果时,那种成就感就是对我们这些图形程序员最好的奖励。