GameTech/URP 渲染实战

案例 24.4Unlit Graph + Custom Function 的卡通 Shader进阶

目标:用 Unlit Graph 做一个三色阶的卡通 Shader:主光决定明暗分级,阴影并入暗部,受光侧有边缘光,附加光提供彩色补光。思路与第 14 章手写的卡通 Shader 相同,这里看它在 Graph 里怎么搭。

色阶化和边缘光是纯数学,放在另一个 hlsl 文件里(预览里也能正常工作,不需要预览分支):

Assets/Cookbook/Ch24/StylizedFunctions.hlsl(节选:toon)
// 把 N·L 与阴影合成的“受光量”量化成 Steps 级色阶。
// Smooth 控制每一级台阶边缘的过渡宽度(0 = 硬边)。
void ToonRamp_float(float NdotL, float Shadow, float Steps, float Smooth, out float Ramp)
{
    // 先把 [-1,1] 的 N·L 半兰伯特化到 [0,1]。
    // 阴影不直接乘上去,而是把受光量“压”到明暗交界线(0.5)以下:背光面本来就暗,
    // 阴影贴图在交界线附近的锯齿就不会被色阶量化放大成一圈难看的齿边
    float lit = min(saturate(NdotL * 0.5 + 0.5), lerp(0.45, 1.0, Shadow));
    float x = lit * Steps;
    float level = floor(x);
    // 每一级台阶的上沿用 smoothstep 过渡到下一级;Smooth 取个下限,避免 smoothstep 两端相等时除零
    float blend = smoothstep(1.0 - max(Smooth, 1e-3), 1.0, frac(x));
    // 除以 (Steps-1):Steps = 3 时输出 0 / 0.5 / 1 三个色阶
    Ramp = saturate((level + blend) / max(Steps - 1.0, 1.0));
}

void ToonRamp_half(half NdotL, half Shadow, half Steps, half Smooth, out half Ramp)
{
    float r;
    ToonRamp_float(NdotL, Shadow, Steps, Smooth, r);
    Ramp = r;
}
// 把 N·L 与阴影合成的“受光量”量化成 Steps 级色阶。
// Smooth 控制每一级台阶边缘的过渡宽度(0 = 硬边)。
void ToonRamp_float(float NdotL, float Shadow, float Steps, float Smooth, out float Ramp)
{
    // 先把 [-1,1] 的 N·L 半兰伯特化到 [0,1]。
    // 阴影不直接乘上去,而是把受光量“压”到明暗交界线(0.5)以下:背光面本来就暗,
    // 阴影贴图在交界线附近的锯齿就不会被色阶量化放大成一圈难看的齿边
    float lit = min(saturate(NdotL * 0.5 + 0.5), mix(0.45, 1.0, Shadow));
    float x = lit * Steps;
    float level = floor(x);
    // 每一级台阶的上沿用 smoothstep 过渡到下一级;Smooth 取个下限,避免 smoothstep 两端相等时除零
    float blend = smoothstep(1.0 - max(Smooth, 1e-3), 1.0, fract(x));
    // 除以 (Steps-1):Steps = 3 时输出 0 / 0.5 / 1 三个色阶
    Ramp = saturate((level + blend) / max(Steps - 1.0, 1.0));
}

void ToonRamp_half(float NdotL, float Shadow, float Steps, float Smooth, out float Ramp)
{
    float r;
    ToonRamp_float(NdotL, Shadow, Steps, Smooth, r);
    Ramp = r;
}

色阶函数里有一个值得记住的处理:阴影不是直接乘到受光量上,而是用 min(halfLambert, lerp(0.45, 1, shadow)) 把受光量“压”到明暗交界线以下。如果直接相乘,球体背光面附近阴影贴图的锯齿边缘会被色阶量化放大,在交界线旁出现一圈锯齿状的暗带;压到 0.45 以后,背光面本来就低于这个值,阴影在那里不起作用,锯齿自然消失。卡通渲染里“自阴影的锯齿”是常见问题,这是最便宜的一种解决办法。

Assets/Cookbook/Ch24/StylizedFunctions.hlsl(节选:rim)
// 边缘光:视线与法线越垂直越亮;只在受光一侧出现时更像“轮廓被照亮”
void RimLight_float(float3 Normal, float3 ViewDir, float3 LightDir, float Power, float LightSideOnly, out float Rim)
{
    float fresnel = pow(1.0 - saturate(dot(normalize(Normal), normalize(ViewDir))), Power);
    float side = lerp(1.0, saturate(dot(Normal, LightDir) * 2.0 + 0.5), LightSideOnly);
    Rim = fresnel * side;
}

void RimLight_half(half3 Normal, half3 ViewDir, half3 LightDir, half Power, half LightSideOnly, out half Rim)
{
    float r;
    RimLight_float(Normal, ViewDir, LightDir, Power, LightSideOnly, r);
    Rim = r;
}
// 边缘光:视线与法线越垂直越亮;只在受光一侧出现时更像“轮廓被照亮”
void RimLight_float(vec3 Normal, vec3 ViewDir, vec3 LightDir, float Power, float LightSideOnly, out float Rim)
{
    float fresnel = pow(1.0 - saturate(dot(normalize(Normal), normalize(ViewDir))), Power);
    float side = mix(1.0, saturate(dot(Normal, LightDir) * 2.0 + 0.5), LightSideOnly);
    Rim = fresnel * side;
}

void RimLight_half(vec3 Normal, vec3 ViewDir, vec3 LightDir, float Power, float LightSideOnly, out float Rim)
{
    float r;
    RimLight_float(Normal, ViewDir, LightDir, Power, LightSideOnly, r);
    Rim = r;
}
Position (World)Normal (World)View Dir (World)Screen Position ƒ MainLightDir/Color/ShadowDot Productƒ RimLightPower, LightSideƒ AdditionalLightsLambert ƒ ToonRampSteps, Smooth Lerp暗部色→固有色× Light ColorAdd(叠加各项) Base Color
图 24-6卡通 Unlit Graph 的节点连线示意。ƒ 表示 Custom Function;Lerp 的 A 口是“固有色 × 暗部色”、B 口是固有色、T 口接 ToonRamp 的输出;环境光可再用 Ambient 节点(或 Baked GI 节点)加进 Add

整张图的数据流,与下面这段等价手写代码一一对应(注释里的方括号标出了对应的节点):

Assets/Cookbook/Ch24/SGToonEquivalent.shader(节选:graph)
half4 frag(Varyings i) : SV_Target
{
    // [Position 节点 World] [Normal Vector 节点 World] [View Direction 节点 World] [Screen Position 节点 Default]
    float3 positionWS = i.positionWS;
    float3 normalWS = normalize(i.normalWS);
    float3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS);
    float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);

    // [Custom Function: MainLight]
    float3 lightDir, lightColor; float distAtten, shadowAtten;
    if (_UseHalf > 0.5)
    {
        half3 d, c; half da, sa;
        MainLight_half(positionWS, d, c, da, sa);
        lightDir = d; lightColor = c; distAtten = da; shadowAtten = sa;
    }
    else MainLight_float(positionWS, lightDir, lightColor, distAtten, shadowAtten);

    // [Dot Product] → [Custom Function: ToonRamp]
    float NdotL = dot(normalWS, lightDir);
    float ramp;
    if (_UseHalf > 0.5) { half r; ToonRamp_half(NdotL, shadowAtten, _Steps, _Smooth, r); ramp = r; }
    else ToonRamp_float(NdotL, shadowAtten, _Steps, _Smooth, ramp);

    // [Lerp] 阴影色 ↔ 固有色,再乘主光颜色
    float3 albedo = _BaseColor.rgb;
    float3 toon = lerp(albedo * _ShadowTint.rgb, albedo, ramp) * lightColor;

    // [Custom Function: RimLight] 只在受光侧出现
    float rim;
    RimLight_float(normalWS, viewWS, lightDir, _RimPower, 1, rim);
    rim *= step(0.5, ramp);   // 阴影里不出边缘光

    // [Custom Function: AdditionalLightsLambert] 点光、聚光
    float3 extra = 0;
    if (_AddLights > 0.5)
    {
        AdditionalLightsLambert_float(positionWS, normalWS, screenUV, extra);
        half3 extraHalf; AdditionalLightsLambert_half(positionWS, normalWS, screenUV, extraHalf);
        extra = _UseHalf > 0.5 ? (float3)extraHalf : extra;
    }

    // [Ambient 节点(Equator/Sky)] 这里用等价的 SampleSH
    #if defined(SHADERGRAPH_PREVIEW)
        float3 ambient = 0.2;
    #else
        float3 ambient = SampleSH(normalWS);
    #endif

    float3 color = toon + albedo * (ambient * 0.6 + extra) + rim * _RimColor.rgb;
    return half4(color, 1);   // → Master Stack 的 Base Color
}
vec4 frag()
{
    // [Position 节点 World] [Normal Vector 节点 World] [View Direction 节点 World] [Screen Position 节点 Default]
    vec3 posWS = positionWS;
    vec3 nWS = normalize(normalWS);
    vec3 viewWS = GetWorldSpaceNormalizeViewDir(posWS);
    vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord);

    // [Custom Function: MainLight]
    vec3 lightDir, lightColor; float distAtten, shadowAtten;
    if (_UseHalf > 0.5) MainLight_half(posWS, lightDir, lightColor, distAtten, shadowAtten);
    else MainLight_float(posWS, lightDir, lightColor, distAtten, shadowAtten);

    // [Dot Product] → [Custom Function: ToonRamp]
    float NdotL = dot(nWS, lightDir);
    float ramp;
    if (_UseHalf > 0.5) ToonRamp_half(NdotL, shadowAtten, _Steps, _Smooth, ramp);
    else ToonRamp_float(NdotL, shadowAtten, _Steps, _Smooth, ramp);

    // [Lerp] 阴影色 ↔ 固有色,再乘主光颜色
    vec3 albedo = _BaseColor.rgb;
    vec3 toon = mix(albedo * _ShadowTint.rgb, albedo, ramp) * lightColor;

    // [Custom Function: RimLight] 只在受光侧出现
    float rim;
    RimLight_float(nWS, viewWS, lightDir, _RimPower, 1.0, rim);
    rim *= step(0.5, ramp);   // 阴影里不出边缘光

    // [Custom Function: AdditionalLightsLambert] 点光、聚光
    vec3 extra = vec3(0.0);
    if (_AddLights > 0.5)
    {
        AdditionalLightsLambert_float(posWS, nWS, screenUV, extra);
        vec3 extraHalf; AdditionalLightsLambert_half(posWS, nWS, screenUV, extraHalf);
        extra = _UseHalf > 0.5 ? extraHalf : extra;
    }

    // [Ambient 节点(Equator/Sky)] 这里用等价的 SampleSH
#if defined(SHADERGRAPH_PREVIEW)
    vec3 ambient = vec3(0.2);
#else
    vec3 ambient = SampleSH(nWS);
#endif

    vec3 color = toon + albedo * (ambient * 0.6 + extra) + rim * _RimColor.rgb;
    return vec4(color, 1);   // → Master Stack 的 Base Color
}
图 24-7卡通 Shader(由等价手写 Shader 渲染)。左:两级色阶,无边缘光;中:三级色阶 + 受光侧边缘光(这一格调用的是 _half 版本的函数,结果与 _float 一致);右:再加入一粉一蓝两盏点光,由 AdditionalLightsLambert 提供彩色补光

Unlit 图要投射阴影,需要在 Graph Settings 勾上 Cast Shadows(生成 ShadowCaster Pass);要被 SSAO、景深等效果正确识别,还需要 DepthOnly/DepthNormals Pass——Unlit Target 会自动生成它们,这是 Shader Graph 相对手写的一大便利(等价 Shader 里这些 Pass 是手写的,复用了第 23 章的 Ch23Common.hlsl)。