URP 渲染实战 / 第 14 章 卡通渲染 / 案例 14.8
案例 14.8程序生成脸部阈值图与 SDF 面部阴影高阶
目标:在 C# 里程序生成一套“二值图 → SDF → 阈值图”的完整流程(用一条带鼻子凸起的解析曲线代替美术手绘),在 Shader 里按光照角度查表,对比法线色阶与 SDF 阴影。
第一步,生成二值图。真实项目里这是美术画的;这里用一条解析曲线描述“光从右侧、角度为 φ 时,第 y 行的明暗交界线在哪”:基础形状是 (正面光时交界线在最左边,90° 时在正中,背光时在最右边),再在鼻子的高度加一个凸起、在眼窝和下巴处加一点起伏。凸起量乘了 sin φ 且系数小于 1,保证交界线随 φ 单调移动:
Assets/Cookbook/Ch14/Editor/FaceSdfGenerator.cs(节选:mask)
// 光从画面右侧、与脸正前方成 phi(弧度,0~π)时,第 y 行的明暗交界线在哪(X ∈ [-1, 1]) static float Boundary(float phi, float y) { // 鼻子:交界线在鼻子高度处向暗侧“凸出”一块(鼻子挡光,留下鼻影) float nose = Mathf.Exp(-Mathf.Pow((y - 0.47f) / 0.07f, 2)); // 眼窝与下巴:上下两端各稍微内收,交界线不再是一条竖直线 float shape = 0.18f * Mathf.Exp(-Mathf.Pow((y - 0.6f) / 0.12f, 2)) - 0.25f * Mathf.Pow(Mathf.Max(0, 0.35f - y) / 0.35f, 2); // 把起伏乘上 sin(phi):正面光和背光时没有影子形状可言;系数 < 1 保证随 phi 单调 float p = phi + (0.55f * nose + shape) * Mathf.Sin(phi); return -Mathf.Cos(Mathf.Clamp(p, 0, Mathf.PI)); } // 第 k 张二值图:1 = 亮,0 = 暗 public static bool[] Mask(int size, float phi) { var m = new bool[size * size]; for (int y = 0; y < size; y++) { float b = Boundary(phi, (y + 0.5f) / size); for (int x = 0; x < size; x++) m[y * size + x] = ((x + 0.5f) / size * 2f - 1f) > b; } return m; }
第二步,逐张计算 SDF。这里用最直白的方法:先找出所有边界像素,再对每个像素求到它们的最近距离。256×256 的图、每张几百个边界像素,C# 里不到一秒就算完;图更大时应改用 8SSEDT 或 GPU 上的 Jump Flooding 算法。全亮(0°)和全暗(180°)两张图没有边界,把交界线视为在图像的左右边缘:
Assets/Cookbook/Ch14/Editor/FaceSdfGenerator.cs(节选:sdf)
// 有符号距离场:亮区为正、暗区为负,单位是像素。 // 这里用最直白的做法:先找出所有边界像素,再对每个像素求到它们的最近距离。 // 256² 的图足够快;更大的图请用 8SSEDT 或 Jump Flooding。 public static float[] SignedDistance(bool[] mask, int size) { var edge = new List<Vector2>(); for (int y = 0; y < size; y++) for (int x = 0; x < size; x++) { bool v = mask[y * size + x]; bool diff = (x + 1 < size && mask[y * size + x + 1] != v) || (y + 1 < size && mask[(y + 1) * size + x] != v); if (diff) edge.Add(new Vector2(x + 0.5f, y + 0.5f)); } var d = new float[size * size]; if (edge.Count == 0) { // 全亮(phi = 0)或全暗(phi = π)的图没有边界:把交界线视为在图的左缘 / 右缘 for (int y = 0; y < size; y++) for (int x = 0; x < size; x++) d[y * size + x] = mask[y * size + x] ? x + 0.5f : -(size - x - 0.5f); return d; } for (int y = 0; y < size; y++) for (int x = 0; x < size; x++) { float best = float.MaxValue; foreach (var e in edge) { float dx = e.x - x, dy = e.y - y; float dd = dx * dx + dy * dy; if (dd < best) best = dd; } float dist = Mathf.Sqrt(best); d[y * size + x] = mask[y * size + x] ? dist : -dist; } return d; }
第三步,合成阈值图:
Assets/Cookbook/Ch14/Editor/FaceSdfGenerator.cs(节选:blend)
// 合成阈值图:对每个像素,找到它“从亮变暗”的那两张相邻图 k、k+1, // 用两张图的 SDF 线性插值求出过零点的位置 t,阈值 = (k + t) / (N - 1) public static Texture2D Generate(int size, int count, out Texture2D maskViz, out Texture2D sdfViz, int vizIndex = 3) { var masks = new bool[count][]; var sdfs = new float[count][]; for (int k = 0; k < count; k++) { float phi = Mathf.PI * k / (count - 1); masks[k] = Mask(size, phi); sdfs[k] = SignedDistance(masks[k], size); } var outPx = new Color[size * size]; for (int i = 0; i < size * size; i++) { float th = 0; for (int k = 0; k < count - 1; k++) { float a = sdfs[k][i], b = sdfs[k + 1][i]; if (a >= 0 && b < 0) { float t = a / (a - b); th = (k + t) / (count - 1); break; } if (a >= 0 && b >= 0) th = (k + 1f) / (count - 1); // 这一段一直是亮的 } outPx[i] = new Color(th, th, th, 1); } maskViz = ToTex(size, i => masks[vizIndex][i] ? 1f : 0f); sdfViz = ToTex(size, i => Mathf.Clamp01(0.5f + sdfs[vizIndex][i] / (size * 0.5f))); var tex = new Texture2D(size, size, TextureFormat.RGBAHalf, false, true) { name = "FaceThreshold", wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear }; tex.SetPixels(outPx); tex.Apply(); return tex; }

运行时的着色很短。先把光方向投影到头部的水平面上,求出与脸正前方的夹角 φ;阈值图按“光从右边来”制作,光从左边来时把 U 镜像;最后用 smoothstep 比较 φ 和阈值:
Assets/Cookbook/Ch14/ToonFace.shader(节选:frag)
half4 frag(ToonVaryings i) : SV_Target { UNITY_SETUP_INSTANCE_ID(i); Light light = GetMainLight(); float2 faceUV = i.positionOS.xy + 0.5; // 正面投影:物体空间 xy → 0~1 half lit; #if defined(_FACESHADOW_NORMAL) // 对照组:按法线做色阶,鼻子、眼窝的起伏会让阴影边缘碎成一块一块 half3 N = normalize(i.normalWS); lit = ToonBand(dot(N, light.direction) * 0.5 + 0.5, 0.5, 0.01); #else // 1) 头部的朝向(物体空间 -Z 是脸的正前方,+X 是画面右侧) float3 F = normalize(TransformObjectToWorldDir(float3(0, 0, -1))); float3 R = normalize(TransformObjectToWorldDir(float3(1, 0, 0))); float3 U = normalize(TransformObjectToWorldDir(float3(0, 1, 0))); // 2) 光方向投影到头部的水平面,只关心“从哪一侧、转了多少度” float3 Lh = light.direction - U * dot(light.direction, U); Lh = normalize(Lh + 1e-5); float phi = acos(clamp(dot(F, Lh), -1.0, 1.0)) / PI; // 0 = 正面光,1 = 正背光 // 3) 阈值图是按“光从右边来”画的,光从左边来就把 U 镜像 float2 uv = faceUV; if (dot(R, Lh) < 0) uv.x = 1.0 - uv.x; half threshold = SAMPLE_TEXTURE2D(_FaceSdf, sampler_FaceSdf, uv).r; // 4) 当前角度小于该像素的阈值 → 亮 lit = smoothstep(phi - _SdfSoftness, phi + _SdfSoftness, threshold); #endif half3 color = lerp(_ShadeColor.rgb, _BaseColor.rgb, lit) * light.color; color += SampleSH(half3(0, 0, 0)) * _BaseColor.rgb * 0.25; if (_DrawFeatures > 0.5) color = DrawFeatures(color, faceUV, step(i.positionOS.z, 0.0)); return half4(MixFog(color, i.fogFactor), 1); }
vec4 frag() { Light light = GetMainLight(); vec2 faceUV = objectPositionOS.xy + 0.5; // 正面投影:物体空间 xy → 0~1 float lit; #if defined(_FACESHADOW_NORMAL) // 对照组:按法线做色阶,鼻子、眼窝的起伏会让阴影边缘碎成一块一块 vec3 N = normalize(normalWS); lit = ToonBand(dot(N, light.direction) * 0.5 + 0.5, 0.5, 0.01); #else // 1) 头部的朝向(物体空间 -Z 是脸的正前方,+X 是画面右侧) vec3 F = normalize(TransformObjectToWorldDir(vec3(0, 0, -1))); vec3 R = normalize(TransformObjectToWorldDir(vec3(1, 0, 0))); vec3 U = normalize(TransformObjectToWorldDir(vec3(0, 1, 0))); // 2) 光方向投影到头部的水平面,只关心“从哪一侧、转了多少度” vec3 Lh = light.direction - U * dot(light.direction, U); Lh = normalize(Lh + 1e-5); float phi = acos(clamp(dot(F, Lh), -1.0, 1.0)) / PI; // 0 = 正面光,1 = 正背光 // 3) 阈值图是按“光从右边来”画的,光从左边来就把 U 镜像 vec2 uv = faceUV; if (dot(R, Lh) < 0.0) uv.x = 1.0 - uv.x; float threshold = texture(_FaceSdf, uv).r; // 4) 当前角度小于该像素的阈值 → 亮 lit = smoothstep(phi - _SdfSoftness, phi + _SdfSoftness, threshold); #endif vec3 color = mix(_ShadeColor.rgb, _BaseColor.rgb, lit) * light.color; color += SampleSH(vec3(0.0)) * _BaseColor.rgb * 0.25; if (_DrawFeatures > 0.5) color = DrawFeatures(color, faceUV, step(objectPositionOS.z, 0.0)); return vec4(MixFog(color, fogFactor), 1.0); }
脸的 UV 直接用物体空间坐标做正面投影(本例的头是半径 0.5 的球,positionOS.xy + 0.5 正好落在 0~1),这样阈值图不受模型 UV 展开方式的影响。真实项目里脸部通常有专门的一套 UV,阈值图按那套 UV 绘制。头部的“正前方”和“右方”由物体的变换矩阵给出,所以头骨骼必须保持“局部 −Z 朝前、+X 朝右”的约定(或者用脚本把头部的朝向向量传给材质),否则阴影会和头的朝向错位。


常见坑① 阈值图必须是线性(非 sRGB)、未压缩或用高质量的单通道压缩格式(BC4/EAC R11),否则阈值被 sRGB 解码或压缩误差扭曲,阴影边界会抖动。② 光源在头顶正上方时,水平投影后的方向是零向量,φ 没有意义;代码里加了一个极小的偏移防止 NaN,项目里通常在这种情况下按“正面光”处理。③ SDF 阴影只解决明暗交界的形状,脸部依然需要接收别的物体投下的阴影(例如帽檐),那部分仍用普通的阴影图,与本节的结果取较暗者。