URP 渲染实战 / 第 14 章 卡通渲染 / 案例 14.1
案例 14.1色阶化:从 Lambert 到 Ramp入门
目标:把平滑的漫反射切成明暗两档(或多档),比较 step、smoothstep 和 Ramp 贴图三种做法。
输入:用半 Lambert 而不是 N·L
色阶的输入通常用半 Lambert(Half Lambert),而不是 。后者在整个背光面都等于 0,信息全丢了;半 Lambert 把 [−1, 1] 完整地映射到 [0, 1],背光面也有变化,可以在暗部再切出第二层阴影,或者交给 Ramp 贴图画出暗部的颜色过渡。它原本是 Valve 在《半条命 2》里为了让角色暗部不死黑而发明的,在卡通渲染里则主要被当作“查表坐标”使用。
三种量化方式
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); }
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_ 开头的文件已经这样设置。

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

提示
fwidth、ddx、ddy 在 2×2 像素块上用差分实现,只能在片元着色器里用,而且在动态分支(依赖于不同像素的 if)内部结果未定义。把它放在分支外面先算好再用,是一个好习惯。