Below, two circular blobs are rendered by evaluating their SDFs on a canvas pixel by pixel. Drag each blob with your mouse and watch them smoothly melt into one another when they get close — that is the smooth-union operator at work.
<!-- SDF smooth-union demo: two distance-field blobs that merge when close -->
<canvas id="sdf-canvas" width="480" height="300" aria-label="Interactive signed distance field canvas — drag the blobs"></canvas>
<div class="controls">
<label>Smoothness (k): <input id="k-slider" type="range" min="0" max="120" value="60" /></label>
<span id="k-val">k = 60</span>
</div>
<div class="info" id="info">Drag a blob to move it. Bring the two close to watch them merge.</div>
<div class="btns">
<button id="reset-btn" type="button">Reset</button>
</div>
/* Layout: canvas + controls strip + info line */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f7fa; color: #222; }
canvas { display: block; border-radius: 10px; cursor: grab; width: 100%; max-width: 480px; touch-action: none; }
canvas:active { cursor: grabbing; }
.controls { display: flex; align-items: center; gap: 1rem; margin: .6rem 0 .3rem; flex-wrap: wrap; }
label { font-size: .9rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 120px; }
#k-val { font-size: .85rem; color: #555; }
.info { font-size: .88rem; color: #444; min-height: 1.3em; margin-bottom: .4rem; }
.btns { display: flex; gap: .5rem; }
button { font: 600 14px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
// SDF smooth-union demo. Each pixel evaluates two circle SDFs and their smooth union.
// sdfCircle(px,py,cx,cy,r) = distance from (px,py) to circle of radius r centred at (cx,cy).
// smin: Quilez smooth-min blends the two SDFs within distance k of their shared boundary.
const canvas = document.getElementById('sdf-canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Two blobs: position (x,y) + radius r.
let blobs = [
{ x: W * 0.35, y: H * 0.5, r: 70 },
{ x: W * 0.65, y: H * 0.5, r: 55 }
];
// Read smoothness k from the slider.
function getK() { return parseFloat(document.getElementById('k-slider').value); }
// Circle SDF: Euclidean distance minus radius.
function sdfCircle(px, py, cx, cy, r) {
const dx = px - cx, dy = py - cy;
return Math.sqrt(dx * dx + dy * dy) - r;
}
// Smooth-min (Quilez polynomial): blends two SDFs when |a-b| < k.
function smin(a, b, k) {
if (k <= 0) return Math.min(a, b);
const h = Math.max(k - Math.abs(a - b), 0) / k;
return Math.min(a, b) - h * h * k / 4;
}
// Pixel-by-pixel SDF evaluation and colouring.
function render() {
const k = getK();
const imgData = ctx.createImageData(W, H);
const data = imgData.data;
for (let py = 0; py < H; py++) {
for (let px = 0; px < W; px++) {
const f0 = sdfCircle(px, py, blobs[0].x, blobs[0].y, blobs[0].r);
const f1 = sdfCircle(px, py, blobs[1].x, blobs[1].y, blobs[1].r);
const f = smin(f0, f1, k);
let r, g, b;
if (f < 0) {
// Inside the merged shape: blue gradient.
const t = Math.max(0, 1 + f / 60);
r = Math.round(29 + t * 30);
g = Math.round(100 + t * 40);
b = Math.round(200 - t * 20);
} else if (f < 2) {
// Thin white edge at the zero level set.
r = 255; g = 255; b = 255;
} else {
// Outside: light distance-field gradient (lighter = farther).
const t = Math.min(1, f / 90);
r = Math.round(220 - t * 50);
g = Math.round(230 - t * 40);
b = Math.round(245 - t * 30);
}
const i = (py * W + px) * 4;
data[i] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
// Draw white dot at each blob centre for drag affordance.
blobs.forEach((b, idx) => {
ctx.beginPath();
ctx.arc(b.x, b.y, 5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.fill();
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 1.5;
ctx.stroke();
});
}
// Mouse/touch drag: pick nearest blob centre within 30 px.
let dragging = null;
function nearestBlob(x, y) {
let best = null, bestD = 30;
blobs.forEach((b, i) => {
const d = Math.sqrt((x - b.x) ** 2 + (y - b.y) ** 2);
if (d < bestD) { bestD = d; best = i; }
});
return best;
}
function canvasXY(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = W / rect.width, scaleY = H / rect.height;
const src = e.touches ? e.touches[0] : e;
return [(src.clientX - rect.left) * scaleX, (src.clientY - rect.top) * scaleY];
}
canvas.addEventListener('mousedown', e => {
const [x, y] = canvasXY(e);
const idx = nearestBlob(x, y) ?? (blobs.findIndex((b, i) => {
const f = sdfCircle(x, y, b.x, b.y, b.r); return f < 0;
}));
if (idx !== null && idx !== -1) dragging = idx;
});
canvas.addEventListener('mousemove', e => {
if (dragging === null) return;
const [x, y] = canvasXY(e);
blobs[dragging].x = x; blobs[dragging].y = y;
document.getElementById('info').textContent = `f₁=${Math.round(sdfCircle(x,y,blobs[0].x,blobs[0].y,blobs[0].r))}, f₂=${Math.round(sdfCircle(x,y,blobs[1].x,blobs[1].y,blobs[1].r))}`;
render();
});
canvas.addEventListener('mouseup', () => { dragging = null; document.getElementById('info').textContent = 'Drag a blob to move it. Bring the two close to watch them merge.'; });
canvas.addEventListener('mouseleave', () => { dragging = null; });
canvas.addEventListener('touchstart', e => { e.preventDefault(); const [x,y]=canvasXY(e); const idx=nearestBlob(x,y); if(idx!==null) dragging=idx; }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); if(dragging===null)return; const [x,y]=canvasXY(e); blobs[dragging].x=x; blobs[dragging].y=y; render(); }, { passive: false });
canvas.addEventListener('touchend', () => { dragging=null; });
document.getElementById('k-slider').addEventListener('input', () => {
document.getElementById('k-val').textContent = 'k = ' + getK();
render();
});
document.getElementById('reset-btn').addEventListener('click', () => {
blobs = [{ x: W * 0.35, y: H * 0.5, r: 70 }, { x: W * 0.65, y: H * 0.5, r: 55 }];
document.getElementById('k-slider').value = 60;
document.getElementById('k-val').textContent = 'k = 60';
document.getElementById('info').textContent = 'Drag a blob to move it. Bring the two close to watch them merge.';
render();
});
render();
<!-- SDF smooth-union demo: two distance-field blobs that merge when close -->
<canvas id="sdf-canvas" width="480" height="300" aria-label="Interactive signed distance field canvas — drag the blobs"></canvas>
<div class="controls">
<label>Smoothness (k): <input id="k-slider" type="range" min="0" max="120" value="60" /></label>
<span id="k-val">k = 60</span>
</div>
<div class="info" id="info">Drag a blob to move it. Bring the two close to watch them merge.</div>
<div class="btns">
<button id="reset-btn" type="button">Reset</button>
</div>
/* Layout: canvas + controls strip + info line */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f7fa; color: #222; }
canvas { display: block; border-radius: 10px; cursor: grab; width: 100%; max-width: 480px; touch-action: none; }
canvas:active { cursor: grabbing; }
.controls { display: flex; align-items: center; gap: 1rem; margin: .6rem 0 .3rem; flex-wrap: wrap; }
label { font-size: .9rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 120px; }
#k-val { font-size: .85rem; color: #555; }
.info { font-size: .88rem; color: #444; min-height: 1.3em; margin-bottom: .4rem; }
.btns { display: flex; gap: .5rem; }
button { font: 600 14px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
// SDF smooth-union demo. Each pixel evaluates two circle SDFs and their smooth union.
// sdfCircle(px,py,cx,cy,r) = distance from (px,py) to circle of radius r centred at (cx,cy).
// smin: Quilez smooth-min blends the two SDFs within distance k of their shared boundary.
const canvas = document.getElementById('sdf-canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Two blobs: position (x,y) + radius r.
let blobs = [
{ x: W * 0.35, y: H * 0.5, r: 70 },
{ x: W * 0.65, y: H * 0.5, r: 55 }
];
// Read smoothness k from the slider.
function getK() { return parseFloat(document.getElementById('k-slider').value); }
// Circle SDF: Euclidean distance minus radius.
function sdfCircle(px, py, cx, cy, r) {
const dx = px - cx, dy = py - cy;
return Math.sqrt(dx * dx + dy * dy) - r;
}
// Smooth-min (Quilez polynomial): blends two SDFs when |a-b| < k.
function smin(a, b, k) {
if (k <= 0) return Math.min(a, b);
const h = Math.max(k - Math.abs(a - b), 0) / k;
return Math.min(a, b) - h * h * k / 4;
}
// Pixel-by-pixel SDF evaluation and colouring.
function render() {
const k = getK();
const imgData = ctx.createImageData(W, H);
const data = imgData.data;
for (let py = 0; py < H; py++) {
for (let px = 0; px < W; px++) {
const f0 = sdfCircle(px, py, blobs[0].x, blobs[0].y, blobs[0].r);
const f1 = sdfCircle(px, py, blobs[1].x, blobs[1].y, blobs[1].r);
const f = smin(f0, f1, k);
let r, g, b;
if (f < 0) {
// Inside the merged shape: blue gradient.
const t = Math.max(0, 1 + f / 60);
r = Math.round(29 + t * 30);
g = Math.round(100 + t * 40);
b = Math.round(200 - t * 20);
} else if (f < 2) {
// Thin white edge at the zero level set.
r = 255; g = 255; b = 255;
} else {
// Outside: light distance-field gradient (lighter = farther).
const t = Math.min(1, f / 90);
r = Math.round(220 - t * 50);
g = Math.round(230 - t * 40);
b = Math.round(245 - t * 30);
}
const i = (py * W + px) * 4;
data[i] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
// Draw white dot at each blob centre for drag affordance.
blobs.forEach((b, idx) => {
ctx.beginPath();
ctx.arc(b.x, b.y, 5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.fill();
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 1.5;
ctx.stroke();
});
}
// Mouse/touch drag: pick nearest blob centre within 30 px.
let dragging = null;
function nearestBlob(x, y) {
let best = null, bestD = 30;
blobs.forEach((b, i) => {
const d = Math.sqrt((x - b.x) ** 2 + (y - b.y) ** 2);
if (d < bestD) { bestD = d; best = i; }
});
return best;
}
function canvasXY(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = W / rect.width, scaleY = H / rect.height;
const src = e.touches ? e.touches[0] : e;
return [(src.clientX - rect.left) * scaleX, (src.clientY - rect.top) * scaleY];
}
canvas.addEventListener('mousedown', e => {
const [x, y] = canvasXY(e);
const idx = nearestBlob(x, y) ?? (blobs.findIndex((b, i) => {
const f = sdfCircle(x, y, b.x, b.y, b.r); return f < 0;
}));
if (idx !== null && idx !== -1) dragging = idx;
});
canvas.addEventListener('mousemove', e => {
if (dragging === null) return;
const [x, y] = canvasXY(e);
blobs[dragging].x = x; blobs[dragging].y = y;
document.getElementById('info').textContent = `f₁=${Math.round(sdfCircle(x,y,blobs[0].x,blobs[0].y,blobs[0].r))}, f₂=${Math.round(sdfCircle(x,y,blobs[1].x,blobs[1].y,blobs[1].r))}`;
render();
});
canvas.addEventListener('mouseup', () => { dragging = null; document.getElementById('info').textContent = 'Drag a blob to move it. Bring the two close to watch them merge.'; });
canvas.addEventListener('mouseleave', () => { dragging = null; });
canvas.addEventListener('touchstart', e => { e.preventDefault(); const [x,y]=canvasXY(e); const idx=nearestBlob(x,y); if(idx!==null) dragging=idx; }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); if(dragging===null)return; const [x,y]=canvasXY(e); blobs[dragging].x=x; blobs[dragging].y=y; render(); }, { passive: false });
canvas.addEventListener('touchend', () => { dragging=null; });
document.getElementById('k-slider').addEventListener('input', () => {
document.getElementById('k-val').textContent = 'k = ' + getK();
render();
});
document.getElementById('reset-btn').addEventListener('click', () => {
blobs = [{ x: W * 0.35, y: H * 0.5, r: 70 }, { x: W * 0.65, y: H * 0.5, r: 55 }];
document.getElementById('k-slider').value = 60;
document.getElementById('k-val').textContent = 'k = 60';
document.getElementById('info').textContent = 'Drag a blob to move it. Bring the two close to watch them merge.';
render();
});
render();
Comments
Loading comments...