URP 渲染实战 / 第 14 章 卡通渲染 / 案例 14.7
案例 14.7头发天使环进阶
目标:做出二次元角色头顶那圈亮环(天使环,Angel Ring),比较写实的连续发丝高光与色阶化的天使环。
天使环其实就是第 12 章 Kajiya-Kay 发丝高光的卡通版:发丝沿着头型从头顶往下垂,所有发丝的“T 垂直于 H”的位置连起来,正好是环绕头部的一圈。卡通化的处理有三步:用 KK 算出连续的高光,用阈值切成硬边色块,再用每缕头发不同的偏移量把边缘切成锯齿(像画师画的一束一束的高光)。
Assets/Cookbook/Ch14/ToonCommon.hlsl(节选:strand)
// 发丝高光的核心(Kajiya-Kay):沿发丝方向 T 的“圆柱”高光,sin(T,H) 越接近 1 越亮 half StrandSpecular(half3 T, half3 N, half3 H, half shift, half exponent) { T = normalize(T + shift * N); // 沿法线挪动切线 = 让高光带上下移动 half ToH = dot(T, H); half sinTH = sqrt(saturate(1.0 - ToH * ToH)); return smoothstep(-1.0, 0.0, ToH) * pow(sinTH, exponent); }
// 发丝高光的核心(Kajiya-Kay):沿发丝方向 T 的“圆柱”高光,sin(T,H) 越接近 1 越亮 float StrandSpecular(vec3 T, vec3 N, vec3 H, float shift, float exponent) { T = normalize(T + shift * N); // 沿法线挪动切线 = 让高光带上下移动 float ToH = dot(T, H); float sinTH = sqrt(saturate(1.0 - ToH * ToH)); return smoothstep(-1.0, 0.0, ToH) * pow(sinTH, exponent); }
Assets/Cookbook/Ch14/ToonHair.shader(节选:frag)
half4 frag(ToonVaryings i) : SV_Target { UNITY_SETUP_INSTANCE_ID(i); half3 N = normalize(i.normalWS); half3 Tm = normalize(i.tangentWS.xyz); half3 Bm = cross(N, Tm) * i.tangentWS.w; half3 T = _StrandAlongV > 0.5 ? Bm : Tm; // 发丝方向 half3 V = GetWorldSpaceNormalizeViewDir(i.positionWS); Light light = GetMainLight(); half band = ToonBand(dot(N, light.direction) * 0.5 + 0.5, 0.5, 0.02); half3 color = lerp(_ShadeColor.rgb, _BaseColor.rgb, band) * light.color; color += SampleSH(half3(0, 0, 0)) * _BaseColor.rgb * 0.3; // 天使环的“光源”:可以跟主光走,也可以固定在相机上方——后者让环始终出现在头顶, // 这是很多二次元项目的选择(美术要的是稳定的造型,而不是物理正确) half3 viewUp = normalize(UNITY_MATRIX_I_V._m01_m11_m21); half3 Lring = normalize(lerp(light.direction, normalize(V + viewUp * 0.6), _RingFollowView)); half3 H = normalize(Lring + V); half noise = SAMPLE_TEXTURE2D(_ShiftTex, sampler_ShiftTex, TRANSFORM_TEX(i.uv, _ShiftTex)).r; half shift = _RingShift + (noise - 0.5) * _RingNoise; // 每缕头发偏移不同 → 锯齿边 half s = StrandSpecular(T, N, H, shift, _RingExponent); half ring = 0; #if defined(_RING_SMOOTH) ring = s; // 写实版:连续的发丝高光 #elif defined(_RING_TOON) ring = ToonBand(s, _RingThreshold, 0.02); // 卡通版:切成硬边色块 #endif color = lerp(color, _RingColor.rgb * lerp(0.6, 1.0, band), ring * 0.9); return half4(MixFog(color, i.fogFactor), 1); }
vec4 frag() { vec3 N = normalize(normalWS); vec3 Tm = normalize(tangentWS.xyz); vec3 Bm = cross(N, Tm) * tangentWS.w; vec3 T = _StrandAlongV > 0.5 ? Bm : Tm; // 发丝方向 vec3 V = GetWorldSpaceNormalizeViewDir(positionWS); Light light = GetMainLight(); float band = ToonBand(dot(N, light.direction) * 0.5 + 0.5, 0.5, 0.02); vec3 color = mix(_ShadeColor.rgb, _BaseColor.rgb, band) * light.color; color += SampleSH(vec3(0.0)) * _BaseColor.rgb * 0.3; // 天使环的“光源”:可以跟主光走,也可以固定在相机上方——后者让环始终出现在头顶, // 这是很多二次元项目的选择(美术要的是稳定的造型,而不是物理正确) vec3 viewUp = normalize(UNITY_MATRIX_I_V[1].xyz); // 逆观察矩阵的第 2 列 = 相机的“上”(HLSL 的 _m01_m11_m21) vec3 Lring = normalize(mix(light.direction, normalize(V + viewUp * 0.6), _RingFollowView)); vec3 H = normalize(Lring + V); float noise = texture(_ShiftTex, TransformTex(uv, _ShiftTex_ST)).r; float shift = _RingShift + (noise - 0.5) * _RingNoise; // 每缕头发偏移不同 → 锯齿边 float s = StrandSpecular(T, N, H, shift, _RingExponent); float ring = 0.0; #if defined(_RING_SMOOTH) ring = s; // 写实版:连续的发丝高光 #elif defined(_RING_TOON) ring = ToonBand(s, _RingThreshold, 0.02); // 卡通版:切成硬边色块 #endif color = mix(color, _RingColor.rgb * mix(0.6, 1.0, band), ring * 0.9); return vec4(MixFog(color, fogFactor), 1.0); }
值得注意的是“天使环跟着谁走”。物理上发丝高光由光源方向决定,主光一转,环就跑到头的侧面甚至消失了。很多二次元项目希望天使环稳定地出现在头顶附近——它是角色造型的一部分,而不是光照的结果。代码里的 _RingFollowView 让高光的“光源”在主光和“相机上方”之间插值:等于 1 时,假想的光源永远位于相机略上方,天使环就始终出现在画面中头部的上半部分。这是卡通渲染“以美术意图优先”的一个典型例子。

提示很多项目的天使环直接画在贴图里(或者用一张 Matcap),并用 UV 动画让它随视角滑动。程序化做法的好处是自动适配任意发型和视角,坏处是需要合理的发丝方向(切线或 UV),对于由大量发片组成的发型,发片 UV 的方向一定要统一。