URP 渲染实战 / 第 14 章 卡通渲染 / 案例 14.3
案例 14.3边缘光:菲涅尔与屏幕空间深度进阶
目标:实现三种边缘光——纯菲涅尔、只在受光侧出现的菲涅尔、屏幕空间等宽的深度边缘光,并比较它们的特点。
菲涅尔边缘光
最简单的边缘光就是 :法线越垂直于视线,越靠近轮廓,值越大。对它做一次色阶,就得到一条贴着轮廓的亮线:
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·L 的色阶),像是主光从侧后方“勾”出来的轮廓。
屏幕空间深度边缘光
菲涅尔边缘光有两个缺点:宽度随着曲率变化(平面几乎没有边缘光,球体边缘很宽),而且在物体内部的转折处也会出现。很多二次元风格的游戏改用屏幕空间的方法:沿着法线在屏幕上的投影方向,偏移固定的几个像素去读深度图,如果那里的深度比自己深很多,说明自己在轮廓上,就点亮。这样边缘光在屏幕上永远是固定的像素宽度,而且只出现在真正的轮廓处。
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); }
这段代码有几个细节:
- 偏移方向用裁剪空间的法线
TransformWorldToHClipDir(N),并像描边那样乘屏幕尺寸后再归一化,保证横竖方向的像素宽度一致。 - 直接用
SV_Position.xy(像素坐标)加偏移,再用LoadSceneDepth按整数坐标读深度,避开了 UV 翻转的麻烦。只有一点要处理:在 D3D、Metal、Vulkan 这类“像素坐标 y 朝下”的平台(UNITY_UV_STARTS_AT_TOP为 1)上,裁剪空间的 y 与像素坐标的 y 方向相反,偏移的 y 要取反。 - 比较的是线性深度(
LinearEyeDepth),阈值才有“米”的意义。
注意:深度图什么时候可用不透明物体着色时,
_CameraDepthTexture 不一定已经有内容。URP 14 默认会尽量“在不透明物体画完之后拷贝深度”(Copy Depth Mode = After Opaques),这时在不透明 Shader 里读到的是上一帧或空的深度。要让本节的效果成立,需要在 Universal Renderer Data 上把 设为 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); }

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