GameTech/URP 渲染实战

案例 14.3边缘光:菲涅尔与屏幕空间深度进阶

目标:实现三种边缘光——纯菲涅尔、只在受光侧出现的菲涅尔、屏幕空间等宽的深度边缘光,并比较它们的特点。

菲涅尔边缘光

最简单的边缘光就是 1−N·V:法线越垂直于视线,越靠近轮廓,值越大。对它做一次色阶,就得到一条贴着轮廓的亮线:

Assets/Cookbook/Ch14/ToonCommon.hlsl(节选:rim)
// 菲涅尔边缘光:越靠近轮廓(N·V → 0)越亮,再做一次色阶得到“一条线”
half FresnelRim(half3 N, half3 V, half width, half softness)
{
    half f = 1.0 - saturate(dot(N, V));
    return ToonBand(f, 1.0 - width, softness);
}
// 菲涅尔边缘光:越靠近轮廓(N·V → 0)越亮,再做一次色阶得到“一条线”
float FresnelRim(vec3 N, vec3 V, float width, float softness)
{
    float f = 1.0 - saturate(dot(N, V));
    return ToonBand(f, 1.0 - width, softness);
}

纯菲涅尔边缘光会均匀地出现在整个轮廓上,看起来像物体自己在发光,和场景光照毫无关系。卡通渲染里更常见的是让它只出现在受光的一侧(乘一个 N·L 的色阶),像是主光从侧后方“勾”出来的轮廓。

屏幕空间深度边缘光

菲涅尔边缘光有两个缺点:宽度随着曲率变化(平面几乎没有边缘光,球体边缘很宽),而且在物体内部的转折处也会出现。很多二次元风格的游戏改用屏幕空间的方法:沿着法线在屏幕上的投影方向,偏移固定的几个像素去读深度图,如果那里的深度比自己深很多,说明自己在轮廓上,就点亮。这样边缘光在屏幕上永远是固定的像素宽度,而且只出现在真正的轮廓处。

偏移 N 个像素后读深度:是背景(很远)→ 自己在轮廓上,点亮 内部的点:偏移后仍落在自己身上,深度差很小 → 不亮
图 14-6深度边缘光:沿法线的屏幕投影方向偏移若干像素,比较深度
Assets/Cookbook/Ch14/ToonRimSpec.shader(节选:depthrim)
// 屏幕空间深度边缘光:沿法线在屏幕上的投影方向偏移若干像素读深度,
// 如果那里的深度比自己“深很多”,说明自己处在轮廓边上
half DepthRim(ToonVaryings i, half3 N)
{
    float3 normalCS = TransformWorldToHClipDir(N);
    float2 dirPx = normalize(normalCS.xy * _ScreenParams.xy + 1e-6);
#if UNITY_UV_STARTS_AT_TOP
    dirPx.y = -dirPx.y;   // 这些平台的像素坐标 y 朝下,与裁剪空间 y 相反
#endif
    int2 p = int2(i.positionCS.xy + dirPx * _RimWidthPx);
    p = clamp(p, int2(0, 0), int2(_ScreenParams.xy) - 1);
    float sceneDepth = LinearEyeDepth(LoadSceneDepth(p), _ZBufferParams);
    float selfDepth = LinearEyeDepth(i.positionCS.z, _ZBufferParams);
    return step(_RimDepthThreshold, sceneDepth - selfDepth);
}
// 屏幕空间深度边缘光:沿法线在屏幕上的投影方向偏移若干像素读深度,
// 如果那里的深度比自己“深很多”,说明自己处在轮廓边上
float DepthRim(vec3 N)
{
    vec3 normalCS = mat3(unity_MatrixVP) * N;     // TransformWorldToHClipDir
    vec2 dirPx = normalize(normalCS.xy * _ScreenParams.xy + 1e-6);
    // WebGL 的像素坐标 y 朝上,与裁剪空间一致,不需要翻转(HLSL 版的 UNITY_UV_STARTS_AT_TOP 分支)
    ivec2 p = ivec2(gl_FragCoord.xy + dirPx * _RimWidthPx);
    p = clamp(p, ivec2(0), ivec2(_ScreenParams.xy) - 1);
    float sceneDepth = LinearEyeDepth(texelFetch(_CameraDepthTexture, p, 0).r, _ZBufferParams);   // LoadSceneDepth
    float selfDepth = LinearEyeDepth(gl_FragCoord.z, _ZBufferParams);
    return step(_RimDepthThreshold, sceneDepth - selfDepth);
}

这段代码有几个细节:

注意:深度图什么时候可用不透明物体着色时,_CameraDepthTexture 不一定已经有内容。URP 14 默认会尽量“在不透明物体画完之后拷贝深度”(Copy Depth Mode = After Opaques),这时在不透明 Shader 里读到的是上一帧或空的深度。要让本节的效果成立,需要在 Universal Renderer Data 上把 Depth Texture Mode 设为 Force Prepass(脚本里是 copyDepthMode = CopyDepthMode.ForcePrepass),这样 URP 会在不透明之前先用各物体的 DepthOnly Pass 画一遍深度——这也是为什么我们的 Shader 一定要带 DepthOnly Pass。代价是多一遍几何绘制。
Assets/Cookbook/Ch14/ToonRimSpec.shader(节选:frag)
half4 frag(ToonVaryings i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    half3 N = normalize(i.normalWS);
    half3 T = normalize(i.tangentWS.xyz);
    half3 B = cross(N, T) * i.tangentWS.w;
    half3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
    Light light = GetMainLight();
    half3 L = light.direction;
    half NoL = dot(N, L);
    half band = ToonBand(NoL * 0.5 + 0.5, _Threshold, _Softness);
    half3 color = lerp(_ShadeColor.rgb, _BaseColor.rgb, band) * light.color;
    color += SampleSH(half3(0, 0, 0)) * _BaseColor.rgb * 0.3;

    // ---------- 高光 ----------
    half3 H = normalize(L + V);
    half spec = 0;
#if defined(_SPEC_BLINN)
    spec = pow(saturate(dot(N, H)), 64.0);                                // 对照:普通 Blinn-Phong
#elif defined(_SPEC_TOON)
    spec = ToonBand(saturate(dot(N, H)), _SpecThreshold, 0.005);          // 色阶化:一个硬边色块
#elif defined(_SPEC_STYLIZED)
    spec = StylizedSpecular(N, T, B, H, _SpecShiftScale, _SpecSplit.xy, _SpecSquare, _SpecThreshold, 0.005);
#endif
    color += spec * band * _SpecColor.rgb * light.color;                  // 只在亮部出现

    // ---------- 边缘光 ----------
    half rim = 0;
#if defined(_RIM_FRESNEL)
    rim = FresnelRim(N, V, _RimWidth, 0.02);
#elif defined(_RIM_LIGHTMASKED)
    // 只保留朝光一侧的边缘:边缘光“像是被主光勾出来的”,背光侧不会到处发亮
    rim = FresnelRim(N, V, _RimWidth, 0.02) * ToonBand(NoL, 0.0, 0.1);
#elif defined(_RIM_DEPTH)
    rim = DepthRim(i, N) * ToonBand(NoL, -0.2, 0.1);
#endif
    color = lerp(color, _RimColor.rgb * light.color, rim);

    color = MixFog(color, i.fogFactor);
    return half4(color, 1);
}
vec4 frag()
{
    vec3 N = normalize(normalWS);
    vec3 T = normalize(tangentWS.xyz);
    vec3 B = cross(N, T) * tangentWS.w;
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    Light light = GetMainLight();
    vec3 L = light.direction;
    float NoL = dot(N, L);
    float band = ToonBand(NoL * 0.5 + 0.5, _Threshold, _Softness);
    vec3 color = mix(_ShadeColor.rgb, _BaseColor.rgb, band) * light.color;
    color += SampleSH(vec3(0.0)) * _BaseColor.rgb * 0.3;

    // ---------- 高光 ----------
    vec3 H = normalize(L + V);
    float spec = 0.0;
#if defined(_SPEC_BLINN)
    spec = pow(saturate(dot(N, H)), 64.0);                                // 对照:普通 Blinn-Phong
#elif defined(_SPEC_TOON)
    spec = ToonBand(saturate(dot(N, H)), _SpecThreshold, 0.005);          // 色阶化:一个硬边色块
#elif defined(_SPEC_STYLIZED)
    spec = StylizedSpecular(N, T, B, H, _SpecShiftScale, _SpecSplit.xy, _SpecSquare, _SpecThreshold, 0.005);
#endif
    color += spec * band * _SpecColor.rgb * light.color;                  // 只在亮部出现

    // ---------- 边缘光 ----------
    float rim = 0.0;
#if defined(_RIM_FRESNEL)
    rim = FresnelRim(N, V, _RimWidth, 0.02);
#elif defined(_RIM_LIGHTMASKED)
    // 只保留朝光一侧的边缘:边缘光“像是被主光勾出来的”,背光侧不会到处发亮
    rim = FresnelRim(N, V, _RimWidth, 0.02) * ToonBand(NoL, 0.0, 0.1);
#elif defined(_RIM_DEPTH)
    rim = DepthRim(N) * ToonBand(NoL, -0.2, 0.1);
#endif
    color = mix(color, _RimColor.rgb * light.color, rim);

    color = MixFog(color, fogFactor);
    return vec4(color, 1.0);
}
图 14-7主光从左后方照来。从左到右:无边缘光;纯菲涅尔,整圈轮廓都亮;只在受光侧的菲涅尔;屏幕空间深度边缘光,宽度固定、只出现在左侧轮廓,但在头与身体相接处出现了几处碎点

图中最右一格头与身体交界处的碎点,是深度边缘光的典型问题:头部下缘偏移后读到的是身体的深度,深度差超过了阈值。可以按物体的大小调大阈值、只在法线朝向侧面时启用(乘 1−|Nview.z|),或者对角色单独渲染一张深度来比较。