The demo below samples a 2D scalar field whose zero-level set forms a square — a shape with four perfectly sharp corners. Toggle between Dual Contouring and Marching Cubes to see how each algorithm extracts the boundary.
<!-- Controls and canvas for dual contouring vs marching cubes demo -->
<div class="controls">
<label class="toggle-label">
<span>Algorithm</span>
<span class="toggle-group" role="group" aria-label="Choose algorithm">
<button id="btn-dc" class="mode-btn active" type="button">Dual Contouring</button>
<button id="btn-mc" class="mode-btn" type="button">Marching Cubes</button>
</span>
</label>
<label class="toggle-label">
<span>Shape</span>
<span class="toggle-group" role="group" aria-label="Choose test shape">
<button id="btn-square" class="shape-btn active" type="button">Square</button>
<button id="btn-cross" class="shape-btn" type="button">Cross</button>
<button id="btn-diamond" class="shape-btn" type="button">Diamond</button>
</span>
</label>
</div>
<canvas id="canvas" width="380" height="280" aria-label="2D isosurface extraction comparison canvas"></canvas>
<div id="info" class="info">Select an algorithm above to begin.</div>
/* Styles for the dual contouring demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; background: transparent; padding: .5rem; }
.controls { display: flex; flex-wrap: wrap; gap: .7rem; margin-bottom: .6rem; }
.toggle-label { display: flex; align-items: center; gap: .5rem; font-size: .85rem; font-weight: 600; color: #444; }
.toggle-group { display: flex; gap: 2px; }
.mode-btn, .shape-btn {
font: 600 .78rem system-ui, sans-serif;
padding: .3rem .65rem;
border: 1px solid #1d3557;
background: #fff;
color: #1d3557;
border-radius: 6px;
cursor: pointer;
transition: background .12s, color .12s;
}
.mode-btn.active, .shape-btn.active {
background: #1d3557;
color: #fff;
}
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px; background: #f5f8fb; width: 100%; max-width: 380px; }
.info { font-size: .82rem; color: #555; margin-top: .45rem; min-height: 1.3em; line-height: 1.4; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Dual Contouring vs Marching Cubes — 2D interactive comparison */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const info = document.getElementById('info');
/* Grid dimensions and padding */
const COLS = 9, ROWS = 7;
const PAD = 28;
const cellW = (canvas.width - PAD * 2) / COLS;
const cellH = (canvas.height - PAD * 2) / ROWS;
/* Signed distance functions for test shapes (negative = inside) */
const SHAPES = {
square: (x, y) => Math.max(Math.abs(x - 0.5), Math.abs(y - 0.5)) - 0.28,
cross: (x, y) => Math.min(Math.max(Math.abs(x - 0.5) - 0.12, Math.abs(y - 0.5) - 0.28),
Math.max(Math.abs(x - 0.5) - 0.28, Math.abs(y - 0.5) - 0.12)),
diamond: (x, y) => Math.abs(x - 0.5) + Math.abs(y - 0.5) - 0.3,
};
let currentMode = 'dc'; // dc = Dual Contouring, mc = Marching Cubes
let currentShape = 'square';
/* Finite-difference gradient of the SDF, normalized to a unit normal */
function gradSDF(fn, x, y) {
const EPS = 1e-4;
const gx = (fn(x + EPS, y) - fn(x - EPS, y)) / (2 * EPS);
const gy = (fn(x, y + EPS) - fn(x, y - EPS)) / (2 * EPS);
const len = Math.sqrt(gx * gx + gy * gy) || 1;
return [gx / len, gy / len];
}
/* Find where the isosurface crosses an edge and return the Hermite data */
function edgeCrossing(fn, x0, y0, x1, y1) {
const v0 = fn(x0, y0), v1 = fn(x1, y1);
if ((v0 < 0) === (v1 < 0)) return null;
const t = v0 / (v0 - v1);
const cx = x0 + t * (x1 - x0);
const cy = y0 + t * (y1 - y0);
const [nx, ny] = gradSDF(fn, cx, cy);
return { px: cx, py: cy, nx, ny };
}
/* Solve the 2D quadric error function via normal equations */
function solveQEF(hermiteList) {
/* Build AtA and Atb from Hermite constraints, then solve the 2x2 system AtA x = Atb */
let a00 = 0, a01 = 0, a11 = 0, b0 = 0, b1 = 0;
let mx = 0, my = 0;
for (const { px, py, nx, ny } of hermiteList) {
a00 += nx * nx; a01 += nx * ny; a11 += ny * ny;
b0 += nx * (nx * px + ny * py);
b1 += ny * (nx * px + ny * py);
mx += px; my += py;
}
mx /= hermiteList.length; my /= hermiteList.length;
const det = a00 * a11 - a01 * a01;
if (Math.abs(det) < 1e-8) return [mx, my]; // Ill-conditioned system — fall back to centroid of crossing points
const vx = (a11 * b0 - a01 * b1) / det;
const vy = (a00 * b1 - a01 * b0) / det;
return [vx, vy];
}
/* Convert normalized field coordinates to canvas pixels */
function toCanvas(nx, ny) {
return [PAD + nx * COLS * cellW, PAD + ny * ROWS * cellH];
}
function draw() {
const fn = SHAPES[currentShape];
ctx.clearRect(0, 0, canvas.width, canvas.height);
/* Draw background grid */
ctx.strokeStyle = '#d0dae3';
ctx.lineWidth = 0.5;
for (let c = 0; c <= COLS; c++) {
const x = PAD + c * cellW;
ctx.beginPath(); ctx.moveTo(x, PAD); ctx.lineTo(x, PAD + ROWS * cellH); ctx.stroke();
}
for (let r = 0; r <= ROWS; r++) {
const y = PAD + r * cellH;
ctx.beginPath(); ctx.moveTo(PAD, y); ctx.lineTo(PAD + COLS * cellW, y); ctx.stroke();
}
/* Sample the SDF at every grid corner */
const val = [];
for (let r = 0; r <= ROWS; r++) {
val[r] = [];
for (let c = 0; c <= COLS; c++) {
val[r][c] = fn(c / COLS, r / ROWS);
}
}
/* Shade cells fully inside the shape */
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const inside = val[r][c] < 0 && val[r][c+1] < 0 && val[r+1][c] < 0 && val[r+1][c+1] < 0;
if (inside) {
const [cx, cy] = toCanvas(c / COLS, r / ROWS);
ctx.fillStyle = '#d0e8f7';
ctx.fillRect(cx, cy, cellW, cellH);
}
}
}
/* Branch on selected algorithm */
if (currentMode === 'dc') {
/* Dual Contouring — one QEF solve per cell */
const dcVertices = []; // Stores one vertex per intersected cell
for (let r = 0; r < ROWS; r++) {
dcVertices[r] = [];
for (let c = 0; c < COLS; c++) {
const hermite = [];
const x0 = c / COLS, y0 = r / ROWS;
const x1 = (c + 1) / COLS, y1 = (r + 1) / ROWS;
const cross = [
edgeCrossing(fn, x0, y0, x1, y0), // top edge
edgeCrossing(fn, x0, y1, x1, y1), // bottom edge
edgeCrossing(fn, x0, y0, x0, y1), // left edge
edgeCrossing(fn, x1, y0, x1, y1), // right edge
].filter(Boolean);
if (cross.length === 0) { dcVertices[r][c] = null; continue; }
hermite.push(...cross);
const [vx, vy] = solveQEF(hermite);
dcVertices[r][c] = [vx, vy];
}
}
/* Connect adjacent DC vertices across sign-changing edges */
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 2;
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const v = dcVertices[r][c];
if (!v) continue;
/* Connect to the cell on the right across their shared vertical edge */
if (c + 1 < COLS && dcVertices[r][c + 1]) {
const cross = edgeCrossing(fn, (c + 1) / COLS, r / ROWS, (c + 1) / COLS, (r + 1) / ROWS);
if (cross) {
const [ax, ay] = toCanvas(v[0], v[1]);
const [bx, by] = toCanvas(dcVertices[r][c + 1][0], dcVertices[r][c + 1][1]);
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
}
}
/* Connect to the cell below across their shared horizontal edge */
if (r + 1 < ROWS && dcVertices[r + 1][c]) {
const cross = edgeCrossing(fn, c / COLS, (r + 1) / ROWS, (c + 1) / COLS, (r + 1) / ROWS);
if (cross) {
const [ax, ay] = toCanvas(v[0], v[1]);
const [bx, by] = toCanvas(dcVertices[r + 1][c][0], dcVertices[r + 1][c][1]);
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
}
}
}
}
/* Draw DC vertex positions */
ctx.fillStyle = '#1d3557';
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const v = dcVertices[r][c];
if (!v) continue;
const [cx, cy] = toCanvas(v[0], v[1]);
ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill();
}
}
const dcCount = dcVertices.flat().filter(Boolean).length;
info.textContent = i18nFmt('Dual Contouring: %n% cell vertices placed using QEF (normals). Corners are sharp.', { n: dcCount });
} else {
/* Marching Cubes — connect edge crossings directly */
ctx.strokeStyle = '#e67700';
ctx.lineWidth = 2;
let segCount = 0;
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const x0 = c / COLS, y0 = r / ROWS;
const x1 = (c + 1) / COLS, y1 = (r + 1) / ROWS;
const crossings = [
edgeCrossing(fn, x0, y0, x1, y0), // top edge crossing
edgeCrossing(fn, x0, y1, x1, y1), // bottom edge crossing
edgeCrossing(fn, x0, y0, x0, y1), // left edge crossing
edgeCrossing(fn, x1, y0, x1, y1), // right edge crossing
].filter(Boolean);
/* Pair edge crossings and draw segments */
for (let i = 0; i < crossings.length - 1; i += 2) {
const [ax, ay] = toCanvas(crossings[i].px, crossings[i].py);
const [bx, by] = toCanvas(crossings[i + 1].px, crossings[i + 1].py);
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
segCount++;
}
}
}
info.textContent = i18nFmt('Marching Cubes: %n% edge segments drawn. Corners are rounded.', { n: segCount });
}
}
/* Wire algorithm and shape toggle buttons */
document.getElementById('btn-dc').addEventListener('click', () => {
currentMode = 'dc';
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
document.getElementById('btn-dc').classList.add('active');
draw();
});
document.getElementById('btn-mc').addEventListener('click', () => {
currentMode = 'mc';
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
document.getElementById('btn-mc').classList.add('active');
draw();
});
const shapeBtns = document.querySelectorAll('.shape-btn');
shapeBtns.forEach(btn => {
btn.addEventListener('click', () => {
shapeBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentShape = btn.id.replace('btn-', '');
draw();
});
});
draw();
<!-- Controls and canvas for dual contouring vs marching cubes demo -->
<div class="controls">
<label class="toggle-label">
<span>Algorithm</span>
<span class="toggle-group" role="group" aria-label="Choose algorithm">
<button id="btn-dc" class="mode-btn active" type="button">Dual Contouring</button>
<button id="btn-mc" class="mode-btn" type="button">Marching Cubes</button>
</span>
</label>
<label class="toggle-label">
<span>Shape</span>
<span class="toggle-group" role="group" aria-label="Choose test shape">
<button id="btn-square" class="shape-btn active" type="button">Square</button>
<button id="btn-cross" class="shape-btn" type="button">Cross</button>
<button id="btn-diamond" class="shape-btn" type="button">Diamond</button>
</span>
</label>
</div>
<canvas id="canvas" width="380" height="280" aria-label="2D isosurface extraction comparison canvas"></canvas>
<div id="info" class="info">Select an algorithm above to begin.</div>
/* Styles for the dual contouring demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; background: transparent; padding: .5rem; }
.controls { display: flex; flex-wrap: wrap; gap: .7rem; margin-bottom: .6rem; }
.toggle-label { display: flex; align-items: center; gap: .5rem; font-size: .85rem; font-weight: 600; color: #444; }
.toggle-group { display: flex; gap: 2px; }
.mode-btn, .shape-btn {
font: 600 .78rem system-ui, sans-serif;
padding: .3rem .65rem;
border: 1px solid #1d3557;
background: #fff;
color: #1d3557;
border-radius: 6px;
cursor: pointer;
transition: background .12s, color .12s;
}
.mode-btn.active, .shape-btn.active {
background: #1d3557;
color: #fff;
}
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px; background: #f5f8fb; width: 100%; max-width: 380px; }
.info { font-size: .82rem; color: #555; margin-top: .45rem; min-height: 1.3em; line-height: 1.4; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Dual Contouring vs Marching Cubes — 2D interactive comparison */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const info = document.getElementById('info');
/* Grid dimensions and padding */
const COLS = 9, ROWS = 7;
const PAD = 28;
const cellW = (canvas.width - PAD * 2) / COLS;
const cellH = (canvas.height - PAD * 2) / ROWS;
/* Signed distance functions for test shapes (negative = inside) */
const SHAPES = {
square: (x, y) => Math.max(Math.abs(x - 0.5), Math.abs(y - 0.5)) - 0.28,
cross: (x, y) => Math.min(Math.max(Math.abs(x - 0.5) - 0.12, Math.abs(y - 0.5) - 0.28),
Math.max(Math.abs(x - 0.5) - 0.28, Math.abs(y - 0.5) - 0.12)),
diamond: (x, y) => Math.abs(x - 0.5) + Math.abs(y - 0.5) - 0.3,
};
let currentMode = 'dc'; // dc = Dual Contouring, mc = Marching Cubes
let currentShape = 'square';
/* Finite-difference gradient of the SDF, normalized to a unit normal */
function gradSDF(fn, x, y) {
const EPS = 1e-4;
const gx = (fn(x + EPS, y) - fn(x - EPS, y)) / (2 * EPS);
const gy = (fn(x, y + EPS) - fn(x, y - EPS)) / (2 * EPS);
const len = Math.sqrt(gx * gx + gy * gy) || 1;
return [gx / len, gy / len];
}
/* Find where the isosurface crosses an edge and return the Hermite data */
function edgeCrossing(fn, x0, y0, x1, y1) {
const v0 = fn(x0, y0), v1 = fn(x1, y1);
if ((v0 < 0) === (v1 < 0)) return null;
const t = v0 / (v0 - v1);
const cx = x0 + t * (x1 - x0);
const cy = y0 + t * (y1 - y0);
const [nx, ny] = gradSDF(fn, cx, cy);
return { px: cx, py: cy, nx, ny };
}
/* Solve the 2D quadric error function via normal equations */
function solveQEF(hermiteList) {
/* Build AtA and Atb from Hermite constraints, then solve the 2x2 system AtA x = Atb */
let a00 = 0, a01 = 0, a11 = 0, b0 = 0, b1 = 0;
let mx = 0, my = 0;
for (const { px, py, nx, ny } of hermiteList) {
a00 += nx * nx; a01 += nx * ny; a11 += ny * ny;
b0 += nx * (nx * px + ny * py);
b1 += ny * (nx * px + ny * py);
mx += px; my += py;
}
mx /= hermiteList.length; my /= hermiteList.length;
const det = a00 * a11 - a01 * a01;
if (Math.abs(det) < 1e-8) return [mx, my]; // Ill-conditioned system — fall back to centroid of crossing points
const vx = (a11 * b0 - a01 * b1) / det;
const vy = (a00 * b1 - a01 * b0) / det;
return [vx, vy];
}
/* Convert normalized field coordinates to canvas pixels */
function toCanvas(nx, ny) {
return [PAD + nx * COLS * cellW, PAD + ny * ROWS * cellH];
}
function draw() {
const fn = SHAPES[currentShape];
ctx.clearRect(0, 0, canvas.width, canvas.height);
/* Draw background grid */
ctx.strokeStyle = '#d0dae3';
ctx.lineWidth = 0.5;
for (let c = 0; c <= COLS; c++) {
const x = PAD + c * cellW;
ctx.beginPath(); ctx.moveTo(x, PAD); ctx.lineTo(x, PAD + ROWS * cellH); ctx.stroke();
}
for (let r = 0; r <= ROWS; r++) {
const y = PAD + r * cellH;
ctx.beginPath(); ctx.moveTo(PAD, y); ctx.lineTo(PAD + COLS * cellW, y); ctx.stroke();
}
/* Sample the SDF at every grid corner */
const val = [];
for (let r = 0; r <= ROWS; r++) {
val[r] = [];
for (let c = 0; c <= COLS; c++) {
val[r][c] = fn(c / COLS, r / ROWS);
}
}
/* Shade cells fully inside the shape */
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const inside = val[r][c] < 0 && val[r][c+1] < 0 && val[r+1][c] < 0 && val[r+1][c+1] < 0;
if (inside) {
const [cx, cy] = toCanvas(c / COLS, r / ROWS);
ctx.fillStyle = '#d0e8f7';
ctx.fillRect(cx, cy, cellW, cellH);
}
}
}
/* Branch on selected algorithm */
if (currentMode === 'dc') {
/* Dual Contouring — one QEF solve per cell */
const dcVertices = []; // Stores one vertex per intersected cell
for (let r = 0; r < ROWS; r++) {
dcVertices[r] = [];
for (let c = 0; c < COLS; c++) {
const hermite = [];
const x0 = c / COLS, y0 = r / ROWS;
const x1 = (c + 1) / COLS, y1 = (r + 1) / ROWS;
const cross = [
edgeCrossing(fn, x0, y0, x1, y0), // top edge
edgeCrossing(fn, x0, y1, x1, y1), // bottom edge
edgeCrossing(fn, x0, y0, x0, y1), // left edge
edgeCrossing(fn, x1, y0, x1, y1), // right edge
].filter(Boolean);
if (cross.length === 0) { dcVertices[r][c] = null; continue; }
hermite.push(...cross);
const [vx, vy] = solveQEF(hermite);
dcVertices[r][c] = [vx, vy];
}
}
/* Connect adjacent DC vertices across sign-changing edges */
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 2;
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const v = dcVertices[r][c];
if (!v) continue;
/* Connect to the cell on the right across their shared vertical edge */
if (c + 1 < COLS && dcVertices[r][c + 1]) {
const cross = edgeCrossing(fn, (c + 1) / COLS, r / ROWS, (c + 1) / COLS, (r + 1) / ROWS);
if (cross) {
const [ax, ay] = toCanvas(v[0], v[1]);
const [bx, by] = toCanvas(dcVertices[r][c + 1][0], dcVertices[r][c + 1][1]);
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
}
}
/* Connect to the cell below across their shared horizontal edge */
if (r + 1 < ROWS && dcVertices[r + 1][c]) {
const cross = edgeCrossing(fn, c / COLS, (r + 1) / ROWS, (c + 1) / COLS, (r + 1) / ROWS);
if (cross) {
const [ax, ay] = toCanvas(v[0], v[1]);
const [bx, by] = toCanvas(dcVertices[r + 1][c][0], dcVertices[r + 1][c][1]);
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
}
}
}
}
/* Draw DC vertex positions */
ctx.fillStyle = '#1d3557';
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const v = dcVertices[r][c];
if (!v) continue;
const [cx, cy] = toCanvas(v[0], v[1]);
ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill();
}
}
const dcCount = dcVertices.flat().filter(Boolean).length;
info.textContent = i18nFmt('Dual Contouring: %n% cell vertices placed using QEF (normals). Corners are sharp.', { n: dcCount });
} else {
/* Marching Cubes — connect edge crossings directly */
ctx.strokeStyle = '#e67700';
ctx.lineWidth = 2;
let segCount = 0;
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const x0 = c / COLS, y0 = r / ROWS;
const x1 = (c + 1) / COLS, y1 = (r + 1) / ROWS;
const crossings = [
edgeCrossing(fn, x0, y0, x1, y0), // top edge crossing
edgeCrossing(fn, x0, y1, x1, y1), // bottom edge crossing
edgeCrossing(fn, x0, y0, x0, y1), // left edge crossing
edgeCrossing(fn, x1, y0, x1, y1), // right edge crossing
].filter(Boolean);
/* Pair edge crossings and draw segments */
for (let i = 0; i < crossings.length - 1; i += 2) {
const [ax, ay] = toCanvas(crossings[i].px, crossings[i].py);
const [bx, by] = toCanvas(crossings[i + 1].px, crossings[i + 1].py);
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
segCount++;
}
}
}
info.textContent = i18nFmt('Marching Cubes: %n% edge segments drawn. Corners are rounded.', { n: segCount });
}
}
/* Wire algorithm and shape toggle buttons */
document.getElementById('btn-dc').addEventListener('click', () => {
currentMode = 'dc';
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
document.getElementById('btn-dc').classList.add('active');
draw();
});
document.getElementById('btn-mc').addEventListener('click', () => {
currentMode = 'mc';
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
document.getElementById('btn-mc').classList.add('active');
draw();
});
const shapeBtns = document.querySelectorAll('.shape-btn');
shapeBtns.forEach(btn => {
btn.addEventListener('click', () => {
shapeBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentShape = btn.id.replace('btn-', '');
draw();
});
});
draw();
Notice that Marching Cubes cuts each grid edge at the crossing point and connects them, producing a rounded staircase. Dual Contouring solves for each cell's vertex using the surface normals, snapping it to the true corner. The same gradient data that lets it find sharp features also makes the algorithm more complex to implement — but the visual payoff is immediate.
Comments
Loading comments...