URP 渲染实战 / 第 12 章 PBR 进阶材质 / 案例 12.4
案例 12.4Kajiya-Kay 双高光头发进阶
目标:在一个球形“发帽”上实现双高光头发,对比 Blinn-Phong、单条 KK、双条 KK,并展示偏移噪声的作用。
发丝方向的选择:Unity 内置球体的切线沿纬线(水平方向),而头发应该从头顶往下垂,也就是沿经线,所以 Shader 提供一个开关,用副切线 B 当发丝方向。真实的头发模型(卡片式发片)通常会把 UV 展成“V 沿发丝方向”,同样用 B;也可以在 DCC 里把切线直接对齐发丝。
Assets/Cookbook/Ch12/HairKK.shader(节选:strand)
MaterialCtx m; m.N = N; m.V = V; m.T = _StrandAlongV > 0.5 ? B : T; // 发丝方向 half n = SAMPLE_TEXTURE2D(_ShiftTex, sampler_ShiftTex, TRANSFORM_TEX(i.uv, _ShiftTex)).r; m.shiftNoise = (n - 0.5) * _ShiftAmount; m.sparkle = saturate(n * 1.6 - 0.3); // 发丝颜色也带一点随机明暗,看起来才像一缕缕的 m.albedo = _BaseColor.rgb * lerp(0.7, 1.15, n);
偏移噪声(shift noise)是头发质感的关键:每一缕头发的偏移量不同,高光带就会参差不齐,像一缕缕的。噪声贴图在拍摄脚本里程序生成:沿 V(发丝方向)不变,沿 U 由一层低频的“发束”噪声和一层高频的“单根发丝”噪声叠加。同一张噪声还用来让发色带一点随机明暗,并作为第二条高光的闪烁遮罩。
Assets/Cookbook/Ch12/HairKK.shader(节选:shade)
half3 ShadeOneLight(MaterialCtx m, Light light) { float3 L = light.direction; float3 H = normalize(L + m.V); float NoL = dot(m.N, L); // 漫反射:发丝是半透明细圆柱,Lambert 太“硬”,压一个下限让暗部不死黑 float3 diffuse = m.albedo * lerp(0.25, 1.0, saturate(NoL)); float3 spec = 0; #if defined(_MODE_BLINNPHONG) spec = _PrimaryColor.rgb * pow(saturate(dot(m.N, H)), _PrimaryExp * 0.5); #else float3 t1 = AdvShiftTangent(m.T, m.N, _PrimaryShift + m.shiftNoise); spec = _PrimaryColor.rgb * AdvKajiyaKay(t1, H, _PrimaryExp); #if defined(_MODE_DUAL) float3 t2 = AdvShiftTangent(m.T, m.N, _SecondaryShift + m.shiftNoise); spec += _SecondaryColor.rgb * m.sparkle * AdvKajiyaKay(t2, H, _SecondaryExp); #endif #endif // 背光面不给高光(发丝本身会挡住),用平滑过渡避免硬边 spec *= smoothstep(-0.1, 0.3, NoL); return (diffuse + spec * 0.5) * light.color * (light.distanceAttenuation * light.shadowAttenuation); }
vec3 ShadeOneLight(MaterialCtx m, Light light) { vec3 L = light.direction; vec3 H = normalize(L + m.V); float NoL = dot(m.N, L); // 漫反射:发丝是半透明细圆柱,Lambert 太“硬”,压一个下限让暗部不死黑 vec3 diffuse = m.albedo * mix(0.25, 1.0, saturate(NoL)); vec3 spec = vec3(0.0); #if defined(_MODE_BLINNPHONG) spec = _PrimaryColor.rgb * pow(saturate(dot(m.N, H)), _PrimaryExp * 0.5); #else vec3 t1 = AdvShiftTangent(m.T, m.N, _PrimaryShift + m.shiftNoise); spec = _PrimaryColor.rgb * AdvKajiyaKay(t1, H, _PrimaryExp); #if defined(_MODE_DUAL) vec3 t2 = AdvShiftTangent(m.T, m.N, _SecondaryShift + m.shiftNoise); spec += _SecondaryColor.rgb * m.sparkle * AdvKajiyaKay(t2, H, _SecondaryExp); #endif #endif // 背光面不给高光(发丝本身会挡住),用平滑过渡避免硬边 spec *= smoothstep(-0.1, 0.3, NoL); return (diffuse + spec * 0.5) * light.color * (light.distanceAttenuation * light.shadowAttenuation); }
漫反射没有用 Lambert:发丝是半透明的细圆柱,光会在发丝之间多次散射,真实头发的暗部远没有 Lambert 预测的那么黑。lerp(0.25, 1, NoL) 是 Scheuermann 原文的做法,简单地给暗部一个下限。最后的 spec * 0.5 是个经验系数——Kajiya-Kay 本身不是能量守恒的 BRDF,它的亮度只能靠调。


深入Kajiya-Kay 是“看起来像”的模型,没有能量守恒,也没有真正的 TT 透射。追求写实的项目(HDRP 的 Hair Shader、各家的数字人方案)会使用 Marschner 模型或它的改进版(d'Eon 2011、Chiang 2016 等),分别计算 R、TT、TRT 三个波瓣的纵向分布 M 和方位分布 N。那些模型参数是物理量(鳞片倾角、折射率、吸收系数),代价是更多的计算和更难的调参。对卡通或中等写实度的项目,双 KK 依然是最划算的选择——第 14 章的“天使环”就是它的风格化版本。
注意真实的发型由大量带透明度的发片组成,排序、Alpha 测试与 Alpha 混合的取舍(以及“先不透明写深度、再半透明补边缘”的双 Pass 技巧)属于第 16 章的话题,本章只讨论着色。