Smooth SDF Blending
by GameDev.net · GLSL ES 3.00 (WebGL2) · 25 Aug 2026
Run the shader to adjust these controls.
What it demonstrates
A plain union leaves a crease where two shapes meet, because a minimum switches abruptly from one field to the other. A smooth minimum interpolates between them over a chosen radius instead, producing the fillet you see where a real object joins another. It is the single operator that makes distance field modelling look organic rather than assembled.
Shader inputs
void mainImage(out vec4 fragColor, in vec2 fragCoord)
Called once per pixel. Write the colour to fragColor.
-
iResolutionvec3 - Viewport size in pixels (z is the pixel aspect ratio).
-
iTimefloat - Seconds since the shader started.
-
iTimeDeltafloat - Seconds since the previous frame.
-
iFrameRatefloat - Frames per second, smoothed.
-
iFrameint - Frames rendered since the start.
-
iMousevec4 - Mouse position: xy while held, zw of the last click.
-
iDatevec4 - Year, month, day, and seconds within the day.
-
iChannel0sampler2D - Texture bound to channel 0.
-
iChannel1sampler2D - Texture bound to channel 1.
-
iChannel2sampler2D - Texture bound to channel 2.
-
iChannel3sampler2D - Texture bound to channel 3.
-
iChannelResolutionvec3[4] - Pixel size of each bound channel texture.
-
iChannelTimefloat[4] - Playback time of each channel, in seconds.
-
iSampleRatefloat - Audio sample rate, always 44100.
Main Image6
uniform float uBlend; // @param 0.0..0.30 = 0.12 "Blend radius"
uniform float uSeparation; // @param 0.0..0.45 = 0.13 "Separation"
uniform float uSize; // @param 0.08..0.28 = 0.16 "Shape size"
float sdCircle(vec2 p, float radius) {
return length(p) - radius;
}
float sdBox(vec2 p, vec2 halfSize) {
vec2 d = abs(p) - halfSize;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
float sminPoly(float a, float b, float k) {
k = max(k, 0.0001);
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
vec3 shadeField(float d) {
vec3 color = (d < 0.0) ? vec3(0.18, 0.42, 0.72) : vec3(0.96, 0.74, 0.34);
color *= 1.0 - exp(-7.0 * abs(d));
color *= 0.88 + 0.12 * cos(140.0 * d);
return mix(color, vec3(1.0), 1.0 - smoothstep(0.0, 0.006, abs(d)));
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 p = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
float left = sdCircle(p + vec2(uSeparation, -0.06), uSize);
float right = sdCircle(p - vec2(uSeparation, 0.06), uSize);
float bar = sdBox(p + vec2(0.0, 0.20), vec2(0.42, 0.05));
float blob = sminPoly(left, right, uBlend);
float d = sminPoly(blob, bar, uBlend);
fragColor = vec4(shadeField(d), 1.0);
}
Learn from this shader
How it works
The polynomial smooth minimum builds a blend factor from the signed difference between the two distances, scaled by the blend radius and clamped. Mixing the fields with that factor removes the hard switch, but alone it leaves the surface slightly outside where it belongs. The final term subtracts a quadratic correction, largest halfway between the shapes and zero at both ends, which pulls the seam back into a circular fillet. The radius is clamped away from zero because the factor divides by it. Two circles blend into a blob, and the blob blends into a bar, so the operator composes with its own output.
Try changing
Set Blend radius to zero for the sharp union this replaces, then raise it slowly and watch the fillets grow at both seams. Push Separation until the circles no longer touch: the blend still bulges them toward each other, which is the operator's tell and the reason large radii look like melting. Shrink Shape size and the same radius becomes visually huge.
Using it in a game
Smooth minimum is how metaballs, slime, blobby creatures, welded terrain and liquid surfaces are built without geometry. The result is not a true distance field any more, since the fillet region reports slightly less than the real distance, so raymarching it needs a conservative step size.
Explore the techniques
Continue with curated explanations and progressively related examples.
Discussion