GameTech/URP 渲染实战

案例 14.8程序生成脸部阈值图与 SDF 面部阴影高阶

目标:在 C# 里程序生成一套“二值图 → SDF → 阈值图”的完整流程(用一条带鼻子凸起的解析曲线代替美术手绘),在 Shader 里按光照角度查表,对比法线色阶与 SDF 阴影。

第一步,生成二值图。真实项目里这是美术画的;这里用一条解析曲线描述“光从右侧、角度为 φ 时,第 y 行的明暗交界线在哪”:基础形状是 x=−cosφ(正面光时交界线在最左边,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;
}
图 14-15左:φ = 67.5° 时的二值图(白 = 亮,鼻子处的暗区凸起就是鼻影);中:这张图的有符号距离场(灰 = 边界,越白越在亮区深处);右:9 张图合成的阈值图,越亮的像素越晚进入阴影

运行时的着色很短。先把光方向投影到头部的水平面上,求出与脸正前方的夹角 φ;阈值图按“光从右边来”制作,光从左边来时把 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 朝右”的约定(或者用脚本把头部的朝向向量传给材质),否则阴影会和头的朝向错位。

图 14-16同一个带鼻子、眼窝起伏的头部模型,光从右前方 60° 照来。左:按法线做色阶,鼻子和脸颊的起伏让阴影边缘碎成锯齿状;右:SDF 阈值图,阴影边缘干净,在鼻子处形成设计好的三角形鼻影
图 14-17光从正面向右侧、再向背后旋转:φ = 20°、60°、100°、140°。阴影边界随角度平滑推进,形状始终保持设计好的样子
常见坑① 阈值图必须是线性(非 sRGB)、未压缩或用高质量的单通道压缩格式(BC4/EAC R11),否则阈值被 sRGB 解码或压缩误差扭曲,阴影边界会抖动。② 光源在头顶正上方时,水平投影后的方向是零向量,φ 没有意义;代码里加了一个极小的偏移防止 NaN,项目里通常在这种情况下按“正面光”处理。③ SDF 阴影只解决明暗交界的形状,脸部依然需要接收别的物体投下的阴影(例如帽檐),那部分仍用普通的阴影图,与本节的结果取较暗者。