URP 渲染实战 / 第 4 章 Shader 里的数学工具箱与程序化图案 / 案例 4.9
案例 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 动效里大受欢迎的原因。
