GameTech/URP 渲染实战

案例 4.9SDF 图标:定位图钉,带描边、发光与投影进阶

目标:只用圆、圆角矩形和布尔运算拼出一个“定位图钉”图标,再用距离场一次性做出描边、外发光、内部明暗和软投影。

Assets/Cookbook/Ch04/C04_09_SDFIcon.shader(节选:pin)
// 图钉 = 圆(头) ∪ 旋转 45° 的方块(尖) − 小圆(孔)
// 方块的两条下边恰好与圆相切:尖端到圆心距离 = R·√2
float PinSDF(float2 p)
{
    const float R = 0.30;
    float2 headCenter = float2(0.0, 0.15);
    float head = SdCircle(p - headCenter, R);
    float halfDiag = R * 0.70710678;                         // 方块半对角线 = R/√2
    float2 tipCenter = headCenter - float2(0.0, halfDiag);    // 方块中心在圆心正下方
    float2 tp = Rotate2D(p - tipCenter, radians(45.0));
    float tip = SdRoundBox(tp, halfDiag * 0.70710678, 0.012); // 半边长 = 半对角线/√2,小圆角让尖端不扎手
    float body = OpUnion(head, tip);   // 两者相切,直接取 min 就是光滑的;这里用平滑并集反而会鼓出一个包
    float hole = SdCircle(p - headCenter, 0.12);
    return OpSubtract(body, hole);
}
// 图钉 = 圆(头) ∪ 旋转 45° 的方块(尖) − 小圆(孔)
// 方块的两条下边恰好与圆相切:尖端到圆心距离 = R·√2
float PinSDF(vec2 p)
{
    const float R = 0.30;
    vec2 headCenter = vec2(0.0, 0.15);
    float head = SdCircle(p - headCenter, R);
    float halfDiag = R * 0.70710678;                         // 方块半对角线 = R/√2
    vec2 tipCenter = headCenter - vec2(0.0, halfDiag);       // 方块中心在圆心正下方
    vec2 tp = Rotate2D(p - tipCenter, radians(45.0));
    float tip = SdRoundBox(tp, vec2(halfDiag * 0.70710678), 0.012); // 半边长 = 半对角线/√2,小圆角让尖端不扎手
    float body = OpUnion(head, tip);   // 两者相切,直接取 min 就是光滑的;这里用平滑并集反而会鼓出一个包
    float hole = SdCircle(p - headCenter, 0.12);
    return OpSubtract(body, hole);
}

图钉 = 圆(头)∪ 旋转 45° 的方块(尖)− 小圆(孔)。关键是让方块的两条下边与圆相切,轮廓才是一条光滑的曲线:设圆半径 R、圆心为 C,要让从尖端出发、与竖直方向成 45° 的两条直线与圆相切,尖端到圆心的距离必须是 R·√2;方块中心放在圆心正下方 R/√2 处、半对角线 R/√2,它的上角恰好在圆心,两个侧角恰好落在圆上的切点。几何关系对了,直接用 min 并集就是光滑的。

一个真实的坑第一版代码用了平滑并集 OpSmoothUnion(head, tip, 0.06),想把相切处“磨圆”。结果图标的两侧各鼓出一个小包:两个形状在切点附近的距离几乎相等,平滑并集恰好在这里向外膨胀了约 k/4。改回 min 后轮廓立刻光滑了。平滑并集适合融合有夹角的形状,对本来就相切的形状只会帮倒忙。
Assets/Cookbook/Ch04/C04_09_SDFIcon.shader(节选:field)
// 把距离场画出来:外部暖色、内部冷色,等高线间隔 0.05,零等值线为白
half3 VisualizeField(float d)
{
    half3 col = (d > 0.0) ? half3(0.90, 0.55, 0.30) : half3(0.25, 0.55, 0.85);
    col *= 1.0 - exp(-5.0 * abs(d));                   // 离边界越远越亮
    col *= 0.75 + 0.25 * cos(TWO_PI * d / 0.05);       // 等高线
    return lerp(col, half3(1, 1, 1), 1.0 - smoothstep(0.0, 0.012, abs(d)));
}
// 把距离场画出来:外部暖色、内部冷色,等高线间隔 0.05,零等值线为白
vec3 VisualizeField(float d)
{
    vec3 col = (d > 0.0) ? vec3(0.90, 0.55, 0.30) : vec3(0.25, 0.55, 0.85);
    col *= 1.0 - exp(-5.0 * abs(d));                   // 离边界越远越亮
    col *= 0.75 + 0.25 * cos(TWO_PI * d / 0.05);       // 等高线
    return mix(col, vec3(1.0), 1.0 - smoothstep(0.0, 0.012, abs(d)));
}

调试 SDF 最好的办法是把它画出来:外部暖色、内部冷色,颜色随 |d| 增大,每隔固定距离画一圈等高线,零等值线用白色。看等高线是否均匀,就知道这个距离场是否“健康”。图 4-16 第一格里,图钉内部的等高线在方块和圆的交界处出现了一个折角,这就是布尔运算在内部只给出“下界”的直观体现。

Assets/Cookbook/Ch04/C04_09_SDFIcon.shader(节选:booleans)
half3 Booleans(float2 uv)
{
    // 2×2 网格,每格一种布尔运算
    float2 cellId = floor(uv * 2.0);
    float2 p = (frac(uv * 2.0) - 0.5) * 2.0;           // 每格内 [-1,1]
    float a = SdCircle(p - float2(-0.2, 0.05), 0.45);
    float b = SdBox(Rotate2D(p - float2(0.28, -0.08), 0.4), float2(0.32, 0.32));
    int op = (int)(cellId.x + (1.0 - cellId.y) * 2.0); // 左上 0 右上 1 左下 2 右下 3
    float d = (op == 0) ? OpUnion(a, b)
            : (op == 1) ? OpSubtract(a, b)
            : (op == 2) ? OpIntersect(a, b)
            :             OpSmoothUnion(a, b, 0.25);
    half3 col = _BgColor.rgb;
    // 淡淡画出两个原始形状的轮廓
    col = lerp(col, half3(0.45, 0.5, 0.56), 1.0 - smoothstep(0.0, fwidth(a) * 1.5, abs(a)));
    col = lerp(col, half3(0.45, 0.5, 0.56), 1.0 - smoothstep(0.0, fwidth(b) * 1.5, abs(b)));
    col = lerp(col, _FillColor.rgb, FillAA(d) * 0.9);
    // 格子分隔线
    float2 g = abs(frac(uv * 2.0 + 0.5) - 0.5) / fwidth(uv * 2.0);
    return lerp(col, half3(0.9, 0.92, 0.95), 1.0 - saturate(min(g.x, g.y) - 0.5));
}
vec3 Booleans(vec2 uv)
{
    // 2×2 网格,每格一种布尔运算
    vec2 cellId = floor(uv * 2.0);
    vec2 p = (fract(uv * 2.0) - 0.5) * 2.0;            // 每格内 [-1,1]
    float a = SdCircle(p - vec2(-0.2, 0.05), 0.45);
    float b = SdBox(Rotate2D(p - vec2(0.28, -0.08), 0.4), vec2(0.32, 0.32));
    int op = int(cellId.x + (1.0 - cellId.y) * 2.0);   // 左上 0 右上 1 左下 2 右下 3
    float d = (op == 0) ? OpUnion(a, b)
            : (op == 1) ? OpSubtract(a, b)
            : (op == 2) ? OpIntersect(a, b)
            :             OpSmoothUnion(a, b, 0.25);
    vec3 col = _BgColor.rgb;
    // 淡淡画出两个原始形状的轮廓
    col = mix(col, vec3(0.45, 0.5, 0.56), 1.0 - smoothstep(0.0, fwidth(a) * 1.5, abs(a)));
    col = mix(col, vec3(0.45, 0.5, 0.56), 1.0 - smoothstep(0.0, fwidth(b) * 1.5, abs(b)));
    col = mix(col, _FillColor.rgb, FillAA(d) * 0.9);
    // 格子分隔线
    vec2 g = abs(fract(uv * 2.0 + 0.5) - 0.5) / fwidth(uv * 2.0);
    return mix(col, vec3(0.9, 0.92, 0.95), 1.0 - saturate(min(g.x, g.y) - 0.5));
}
Assets/Cookbook/Ch04/C04_09_SDFIcon.shader(节选:styled)
half3 col = _BgColor.rgb;
// 1) 投影:在偏移后的位置再求一次距离,用宽 smoothstep 得到软边
float ds = PinSDF(p - _ShadowOffset.xy);
col *= 1.0 - 0.6 * (1.0 - smoothstep(-_ShadowSoftness, _ShadowSoftness, ds));
// 2) 外发光:只在形状外面,按距离指数衰减
col += _GlowColor.rgb * exp(-max(d, 0.0) * _GlowFalloff) * 0.35;
// 3) 填充:内部按距离做一点明暗,越靠近边越暗
half3 fill = _FillColor.rgb * lerp(0.75, 1.15, saturate(-d * 8.0));
col = lerp(col, fill, FillAA(d));
// 4) 描边:|d| - 半宽 是“沿边界的一条带子”的 SDF
float stroke = abs(d) - _StrokeWidth * 0.5;
col = lerp(col, _StrokeColor.rgb, FillAA(stroke));
vec3 col = _BgColor.rgb;
// 1) 投影:在偏移后的位置再求一次距离,用宽 smoothstep 得到软边
float ds = PinSDF(p - _ShadowOffset.xy);
col *= 1.0 - 0.6 * (1.0 - smoothstep(-_ShadowSoftness, _ShadowSoftness, ds));
// 2) 外发光:只在形状外面,按距离指数衰减
col += _GlowColor.rgb * exp(-max(d, 0.0) * _GlowFalloff) * 0.35;
// 3) 填充:内部按距离做一点明暗,越靠近边越暗
vec3 fill = _FillColor.rgb * mix(0.75, 1.15, saturate(-d * 8.0));
col = mix(col, fill, FillAA(d));
// 4) 描边:|d| - 半宽 是“沿边界的一条带子”的 SDF
float stroke = abs(d) - _StrokeWidth * 0.5;
col = mix(col, _StrokeColor.rgb, FillAA(stroke));

最终效果按“从下往上”的顺序叠加四层,每一层都只是对 d 做一次简单变换:投影是对偏移后的点重新求距离,再用一个很宽的 smoothstep 得到软边;外发光只作用在 d > 0 的区域;填充色按 −d 做由边缘向内的明暗;描边是 abs(d) − 半宽。改一个参数就能调整一层,不需要重新出图——这就是 SDF 图标在 UI 动效里大受欢迎的原因。

图 4-16左一:图钉的距离场可视化(白线为边界,等高线间隔 0.05);左二:圆与旋转方块的四种布尔运算——并集、差集(圆挖去方块)、交集、平滑并集(k=0.25);右二:纯色填充;右一:投影 + 外发光 + 内部明暗 + 白色描边