告别‘纸片发’!在Unity URP里用Kajiya-Kay模型手搓真实头发(附完整Shader代码)
告别‘纸片发’!在Unity URP里用Kajiya-Kay模型手搓真实头发(附完整Shader代码)
在角色渲染领域,头发一直是技术美术和独立开发者面临的重大挑战之一。想象一下,你精心设计的角色模型,却因为头发看起来像塑料片而大打折扣——这种"纸片发"效果不仅缺乏真实感,还会让整个作品的品质感直线下降。本文将带你深入理解基于物理的头发渲染原理,并手把手教你如何在Unity URP管线中实现专业级的头发渲染效果。
1. 为什么传统头发渲染会失败?
大多数开发者第一次尝试头发渲染时,往往会遇到几个典型问题:
- 平面感严重:使用简单面片+漫反射贴图的方式,头发缺乏体积感和层次感
- 高光不自然:传统Blinn-Phong模型产生的圆形高光与真实头发各向异性特性不符
- 缺乏散射效果:真实头发在逆光时会有光散射效果,而普通着色器无法模拟
- 排序问题:半透明渲染顺序错误导致视觉错乱
关键问题根源在于传统渲染方法没有考虑头发的物理特性。每根头发实际上是一个复杂的圆柱体,光线在其表面会产生特殊的光学现象:
| 现象 | 物理原理 | 视觉表现 |
|---|---|---|
| 主高光 | 光线在毛鳞片表面的直接反射 | 发梢处明亮的条状高光 |
| 次高光 | 光线进入头发内部后的次级反射 | 发根处彩色的柔和光晕 |
| 透射光 | 光线穿过头发产生的背光效果 | 逆光时的"光晕"效果 |
2. Kajiya-Kay模型的核心原理
Kajiya-Kay模型是业界广泛采用的头发渲染基础模型,其核心创新在于:
- 用切线代替法线:传统着色器使用表面法线(N)计算光照,而头发应该使用切线方向(T)作为主要参考
- 各向异性高光:通过修改半角向量计算方式,产生沿头发走向的条状高光
- 双高光系统:分别模拟毛鳞片反射(主高光)和内部散射(次高光)
以下是该模型的关键数学表达式:
// Kajiya-Kay高光项 float D_KajiyaKay(float3 T, float3 H, float shininess) { float TdotH = dot(T, H); float sinTH = sqrt(1.0 - TdotH * TdotH); return sinTH * pow(sinTH, shininess); }提示:在实际应用中,我们通常会对切线方向进行偏移处理,以模拟头发表面的不规则性。
3. URP中的完整实现方案
3.1 准备工作
首先需要设置正确的头发几何结构:
- 使用至少8层交叉面片(cross-section)构建头发体积
- 确保UV布局中V方向与头发生长方向一致
- 准备以下纹理资源:
- 基础颜色贴图(RGB)+透明度(A)
- 高光噪波贴图(控制高光随机性)
- 流向图(可选,用于复杂发型)
3.2 Shader核心结构
以下是完整的URP Shader框架:
Shader "Custom/HairURP" { Properties { _BaseMap("Base Color", 2D) = "white" {} _SpecColor1("Primary Specular", Color) = (1,1,1,1) _SpecColor2("Secondary Specular", Color) = (1,1,1,1) _SpecShininess1("Primary Smoothness", Range(0,1)) = 0.5 _SpecShininess2("Secondary Smoothness", Range(0,1)) = 0.2 _SpecOffset1("Primary Offset", Float) = 0 _SpecOffset2("Secondary Offset", Float) = 0.5 } SubShader { Tags {"Queue"="Transparent" "RenderType"="Transparent"} // 4个Pass的渲染方案 Pass { /* 深度预写入 */ } Pass { /* 不透明部分 */ } Pass { /* 半透明背面 */ } Pass { /* 半透明正面 */ } } }3.3 关键算法实现
切线偏移函数:
float3 ShiftTangent(float3 T, float3 N, float shift) { return normalize(T + shift * N); }完整的片元着色器:
half4 frag(Varyings input) : SV_Target { // 初始化表面数据 HairSurfaceData sfd = InitializeSurfaceData(input.uv); // 获取基础向量 half3 V = SafeNormalize(input.viewDirWS); half3 N = input.normalWS; half3 T = input.tangentWS.xyz; half3 B = cross(N, T) * input.tangentWS.w; // 光照计算 Light mainLight = GetMainLight(); half3 L = mainLight.direction; half3 H = normalize(L + V); // 漫反射项 half diffTerm = max(0.0, dot(N, L)); half3 diffuse = lerp(0.25, 1.0, diffTerm) * mainLight.color * sfd.albedo; // 高光项(Kajiya-Kay) half anisoNoise = SAMPLE_TEXTURE2D(_AnsioMap, sampler_AnsioMap, input.uv).r - 0.5; float3 t1 = ShiftTangent(B, N, _SpecOffset1 + anisoNoise * _SpecNoise1); float3 spec1 = _SpecColor1.rgb * pow(max(0, D_KajiyaKay(t1, H, _SpecShininess1)), _SpecPower1); float3 t2 = ShiftTangent(B, N, _SpecOffset2 + anisoNoise * _SpecNoise2); float3 spec2 = _SpecColor2.rgb * pow(max(0, D_KajiyaKay(t2, H, _SpecShininess2)), _SpecPower2); // 组合结果 half3 color = diffuse + spec1 + spec2 + SampleSH(N) * sfd.albedo; return half4(color, sfd.alpha); }4. 高级优化技巧
4.1 深度排序解决方案
头发渲染最大的挑战之一是正确处理半透明排序。我们采用4-Pass方案:
- Depth Pre-Pass:仅写入深度,剔除透明部分
- Opaque Pass:渲染不透明部分,深度测试设为Equal
- Backface Pass:渲染背面半透明,禁用深度写入
- Frontface Pass:渲染正面半透明,启用深度写入
// 示例Pass设置 Pass { Name "DepthPrePass" ZWrite On ColorMask 0 Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag half4 frag(Varyings input) : SV_Target { return 0; } ENDHLSL }4.2 性能优化策略
- LOD系统:根据距离动态减少面片数量
- 烘焙光照:将静态光照信息烘焙到顶点颜色中
- 简化计算:中远距离使用简化版Shader
- 批处理:合并相似材质的头发网格
5. 参数调优指南
实现效果后,需要通过精细调节参数达到最佳视觉效果:
主高光(Primary Specular):
- 颜色:接近光源色的冷色调
- 偏移量:0.1-0.3
- 强度:0.5-1.0
- 光滑度:较高(0.7-0.9)
次高光(Secondary Specular):
- 颜色:暖色调(金/红)
- 偏移量:0.4-0.7
- 强度:0.2-0.5
- 光滑度:较低(0.3-0.5)
注意:不同发色需要不同的参数组合。金发需要更强的次高光,而黑发则需要更明显的主高光对比。
在实际项目中,我通常会创建一个材质参数预设系统,针对不同发色保存多组参数配置。调试时最有效的方法是找一个标准光照环境(比如三光源工作室设置),然后分别观察正面光、侧光和背光情况下的表现。
