GameTech/URP 渲染实战

案例 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 时,假想的光源永远位于相机略上方,天使环就始终出现在画面中头部的上半部分。这是卡通渲染“以美术意图优先”的一个典型例子。

图 14-13从左到右:无高光;连续的 Kajiya-Kay 发丝高光(写实);色阶化的天使环,边缘按发束噪声切成锯齿
提示很多项目的天使环直接画在贴图里(或者用一张 Matcap),并用 UV 动画让它随视角滑动。程序化做法的好处是自动适配任意发型和视角,坏处是需要合理的发丝方向(切线或 UV),对于由大量发片组成的发型,发片 UV 的方向一定要统一。