GameTech/URP 渲染实战

案例 14.1色阶化:从 Lambert 到 Ramp入门

目标:把平滑的漫反射切成明暗两档(或多档),比较 step、smoothstep 和 Ramp 贴图三种做法。

输入:用半 Lambert 而不是 N·L

色阶的输入通常用半 Lambert(Half Lambert)x=N·L×0.5+0.5,而不是 saturate(N·L)。后者在整个背光面都等于 0,信息全丢了;半 Lambert 把 [−1, 1] 完整地映射到 [0, 1],背光面也有变化,可以在暗部再切出第二层阴影,或者交给 Ramp 贴图画出暗部的颜色过渡。它原本是 Valve 在《半条命 2》里为了让角色暗部不死黑而发明的,在卡通渲染里则主要被当作“查表坐标”使用。

三种量化方式

step:硬边屏幕上会出锯齿 阈值 smoothstep:可控软边过渡宽度 = 2 × softness Ramp 贴图:查表档数、颜色、过渡全由美术画 x = 0x = 1
图 14-2把半 Lambert x 映射为明暗的三种方式
Assets/Cookbook/Ch14/ToonCommon.hlsl(节选:band)
// 色阶:把连续的 0~1 明暗压成“亮 / 暗”两档,softness 控制交界的宽度(0 = 硬边)
half ToonBand(half x, half threshold, half softness)
{
    return smoothstep(threshold - softness, threshold + softness, x);
}
// 色阶:把连续的 0~1 明暗压成“亮 / 暗”两档,softness 控制交界的宽度(0 = 硬边)
float ToonBand(float x, float threshold, float softness)
{
    return smoothstep(threshold - softness, threshold + softness, x);
}
Assets/Cookbook/Ch14/ToonRamp.shader(节选:frag)
half4 frag(ToonVaryings i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    half3 N = normalize(i.normalWS);
    Light light = GetMainLight();
    half NoL = dot(N, light.direction);
    half halfLambert = NoL * 0.5 + 0.5;     // 0~1,比 saturate(NoL) 更适合当查表坐标

    half3 color;
#if defined(_RAMP_LAMBERT)
    color = _BaseColor.rgb * saturate(NoL);                      // 对照:普通 Lambert
#elif defined(_RAMP_TEXTURE)
    // Ramp 贴图:横向就是“明暗 → 颜色”的映射表,美术可以随意画色阶与色相
    half3 ramp = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(clamp(halfLambert, 0.01, 0.99), 0.5)).rgb;
    color = _BaseColor.rgb * ramp;
#else
  #if defined(_RAMP_STEP)
    half band = step(_Threshold, halfLambert);                   // 硬边:会有锯齿
  #else
    // 软一点的硬边:过渡宽度至少一个像素(fwidth = 这个量在相邻像素间的变化),硬边也不会出锯齿
    half band = ToonBand(halfLambert, _Threshold, max(_Softness, fwidth(halfLambert)));
  #endif
    // 亮部用固有色,暗部用美术指定的阴影色,而不是“固有色 × 0.5”
    color = lerp(_ShadeColor.rgb, _BaseColor.rgb, band);
    // 可选的第二层阴影:在更暗的位置再切一档
    half band2 = ToonBand(halfLambert, _Shade2Threshold, _Softness);
    color = lerp(lerp(color, _ShadeColor2.rgb, _Shade2Strength), color, band2);
#endif
    color *= light.color;

    // 环境光:只取球谐的常数项(平均环境色),避免环境光把色块又“抹圆”
    color += SampleSH(half3(0, 0, 0)) * _BaseColor.rgb * _AmbientStrength;
    color = MixFog(color, i.fogFactor);
    return half4(color, 1);
}
vec4 frag()
{
    vec3 N = normalize(normalWS);
    Light light = GetMainLight();
    float NoL = dot(N, light.direction);
    float halfLambert = NoL * 0.5 + 0.5;     // 0~1,比 saturate(NoL) 更适合当查表坐标

    vec3 color;
#if defined(_RAMP_LAMBERT)
    color = _BaseColor.rgb * saturate(NoL);                      // 对照:普通 Lambert
#elif defined(_RAMP_TEXTURE)
    // Ramp 贴图:横向就是“明暗 → 颜色”的映射表,美术可以随意画色阶与色相
    vec3 ramp = texture(_RampTex, vec2(clamp(halfLambert, 0.01, 0.99), 0.5)).rgb;
    color = _BaseColor.rgb * ramp;
#else
  #if defined(_RAMP_STEP)
    float band = step(_Threshold, halfLambert);                  // 硬边:会有锯齿
  #else
    // 软一点的硬边:过渡宽度至少一个像素(fwidth = 这个量在相邻像素间的变化),硬边也不会出锯齿
    float band = ToonBand(halfLambert, _Threshold, max(_Softness, fwidth(halfLambert)));
  #endif
    // 亮部用固有色,暗部用美术指定的阴影色,而不是“固有色 × 0.5”
    color = mix(_ShadeColor.rgb, _BaseColor.rgb, band);
    // 可选的第二层阴影:在更暗的位置再切一档
    float band2 = ToonBand(halfLambert, _Shade2Threshold, _Softness);
    color = mix(mix(color, _ShadeColor2.rgb, _Shade2Strength), color, band2);
#endif
    color *= light.color;

    // 环境光:只取球谐的常数项(平均环境色),避免环境光把色块又“抹圆”
    color += SampleSH(vec3(0.0)) * _BaseColor.rgb * _AmbientStrength;
    color = MixFog(color, fogFactor);
    return vec4(color, 1.0);
}

Ramp 贴图模式下,采样坐标的 u 用 clamp(x, 0.01, 0.99) 而不是直接用 x:即使贴图的 Wrap Mode 设成了 Clamp,双线性过滤在 u = 0 和 u = 1 的最边缘处仍然会取到半个像素之外,钳一下更保险。Ramp 贴图本身要关掉 mip(贴图很窄,mip 会把色阶糊掉),本书公共贴图的导入脚本对 ramp_ 开头的文件已经这样设置。

图 14-3同一光照下的四种漫反射。从左到右:Lambert;step(硬边);smoothstep(软边,暗部使用指定的阴影色);Ramp 贴图(公共贴图 ramp_toon3,三档)

色阶硬边的锯齿

step 的边是“着色锯齿”:它来自片元着色器的输出本身不连续,而 MSAA 只对三角形边缘做多重采样、每个像素仍然只执行一次片元着色,所以 MSAA 救不了它。解决办法是让过渡宽度至少有一个像素:fwidth(x) 返回 x 在相邻像素之间的变化量(|∂x/∂u|+|∂x/∂v|),用它作为 smoothstep 的半宽,就得到一条恰好一两个像素宽的抗锯齿边,无论物体远近都一样细。这就是 ToonRamp.shader 里 max(_Softness, fwidth(halfLambert)) 的用意:美术想要更软的边时用 _Softness,否则至少保证不出锯齿。

图 14-4明暗交界的特写(低分辨率渲染后放大,关闭 MSAA)。左:step,像素台阶清晰可见;右:以 fwidth 为半宽的 smoothstep,边缘仍然锐利,但台阶被一两个像素的过渡抹平
提示fwidth、ddx、ddy 在 2×2 像素块上用差分实现,只能在片元着色器里用,而且在动态分支(依赖于不同像素的 if)内部结果未定义。把它放在分支外面先算好再用,是一个好习惯。