Marching Squares
Contours from a sixteen-case lookup.
Intermediate / contours / implicit-surfaces
Marching squares turns an implicit field into line segments: classify each grid cell by which corners sit above the iso value, then place the crossing edges - with interpolation for smoothness and the standard resolution for the ambiguous saddle cases. The metaballs move; the contours follow.
Tagged: iso-contour, metaballs, interpolation, saddle case
Live simulation
The iso slider retightens the contour around the moving blobs. Keyboard: Space runs or pauses, . steps, r resets.
Speed
The iso slider retightens the contour around the moving blobs.
Mathematics
Marching squares turns a scalar field into an iso-contour: each grid cell is classified by a 4-bit code (which corners exceed the threshold), and a 16-case lookup returns the line segments crossing that cell. Linear interpolation positions each crossing along the edges for sub-cell smoothness.
cell code: bit_i = 1 when f(corner_i) > iso
edge crossing: t = (iso - f_a) / (f_b - f_a)
saddle cases (5, 10): resolved by the cell-center average
The two ambiguous cases (codes 5 and 10) are where topology is decided: the center average chooses whether opposite corners connect as one region or two - the standard resolution, and the only place the algorithm makes a judgment call. Everything else is lookup and interpolation.
How it works
Three metaballs move on seeded paths; the field is their inverse-square sum sampled on a 70x70 grid. Below-threshold cells are shaded faintly, contour segments are drawn in accent, and the segment counter lets you see the contour complexity respond to the iso slider.
Drag the iso value across a blob and watch the contour tighten around it - the implicit surface's shape was always in the field; marching squares just extracts it.
Implementation
src/engine/experiments/marching-squares.ts · 191 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | /** |
| 5 | * Marching squares over a field of moving metaballs: the classic 16-case |
| 6 | * table turns the implicit surface f(x, y) = threshold into line segments. |
| 7 | * The contour you see is computed in the tick, not drawn by hand. |
| 8 | */ |
| 9 | export class MarchingSquaresExperiment extends BaseExperiment { |
| 10 | readonly id = "marching-squares"; |
| 11 | |
| 12 | private blobs = [ |
| 13 | { cx: 0.3, cy: 0.3, vx: 34, vy: 26 }, |
| 14 | { cx: 0.7, cy: 0.6, vx: -28, vy: 31 }, |
| 15 | { cx: 0.45, cy: 0.75, vx: 22, vy: -33 }, |
| 16 | ]; |
| 17 | private threshold = 1; |
| 18 | private segments = 0; |
| 19 | private segmentList: number[] = []; |
| 20 | |
| 21 | protected params(): ParameterDef[] { |
| 22 | return [ |
| 23 | { key: "threshold", label: "Iso value", min: 0.5, max: 2.2, step: 0.05, defaultValue: 1 }, |
| 24 | { key: "speed", label: "Blob speed", min: 0, max: 3, step: 0.1, defaultValue: 1 }, |
| 25 | { key: "grid", label: "Sample grid", min: 40, max: 110, step: 5, defaultValue: 70 }, |
| 26 | ]; |
| 27 | } |
| 28 | |
| 29 | protected onReset(): void { |
| 30 | this.threshold = this.num("threshold"); |
| 31 | } |
| 32 | |
| 33 | protected onParameterChange(key: string): void { |
| 34 | if (key === "threshold") this.threshold = this.num("threshold"); |
| 35 | void key; |
| 36 | } |
| 37 | |
| 38 | private field(gx: number, gy: number, cols: number, rows: number): number { |
| 39 | let sum = 0; |
| 40 | for (const blob of this.blobs) { |
| 41 | const bx = blob.cx * cols; |
| 42 | const by = blob.cy * rows; |
| 43 | const d2 = ((gx - bx) / (cols / 3)) ** 2 + ((gy - by) / (rows / 3)) ** 2; |
| 44 | sum += 1 / (d2 + 0.05); |
| 45 | } |
| 46 | return sum; |
| 47 | } |
| 48 | |
| 49 | private computeSegments(): void { |
| 50 | const cols = this.num("grid"); |
| 51 | const rows = this.num("grid"); |
| 52 | const cw = this.width / cols; |
| 53 | const ch = this.height / rows; |
| 54 | this.segmentList = []; |
| 55 | this.segments = 0; |
| 56 | const interp = (f1: number, f2: number) => (this.threshold - f1) / (f2 - f1 || 1e-9); |
| 57 | for (let gy = 0; gy < rows - 1; gy++) { |
| 58 | for (let gx = 0; gx < cols - 1; gx++) { |
| 59 | const f00 = this.field(gx, gy, cols, rows); |
| 60 | const f10 = this.field(gx + 1, gy, cols, rows); |
| 61 | const f01 = this.field(gx, gy + 1, cols, rows); |
| 62 | const f11 = this.field(gx + 1, gy + 1, cols, rows); |
| 63 | let code = 0; |
| 64 | if (f00 > this.threshold) code |= 8; |
| 65 | if (f10 > this.threshold) code |= 4; |
| 66 | if (f11 > this.threshold) code |= 2; |
| 67 | if (f01 > this.threshold) code |= 1; |
| 68 | if (code === 0 || code === 15) continue; |
| 69 | const topX = (gx + interp(f00, f10)) * cw; |
| 70 | const bottomX = (gx + interp(f01, f11)) * cw; |
| 71 | const leftY = (gy + interp(f00, f01)) * ch; |
| 72 | const rightY = (gy + interp(f10, f11)) * ch; |
| 73 | const midX = (gx + 0.5) * cw; |
| 74 | const midY = (gy + 0.5) * ch; |
| 75 | const seg = (x1: number, y1: number, x2: number, y2: number) => { |
| 76 | this.segmentList.push(x1, y1, x2, y2); |
| 77 | this.segments += 1; |
| 78 | }; |
| 79 | const leftX = gx * cw; |
| 80 | switch (code) { |
| 81 | case 1: seg(leftX, leftY, bottomX, (gy + 1) * ch); break; |
| 82 | case 2: seg(bottomX, (gy + 1) * ch, (gx + 1) * cw, rightY); break; |
| 83 | case 3: seg(leftX, leftY, (gx + 1) * cw, rightY); break; |
| 84 | case 4: seg(topX, gy * ch, (gx + 1) * cw, rightY); break; |
| 85 | case 5: { |
| 86 | const centerHigh = (f00 + f10 + f01 + f11) / 4 > this.threshold; |
| 87 | if (centerHigh) { |
| 88 | seg(leftX, leftY, midX, midY); |
| 89 | seg(midX, midY, topX, gy * ch); |
| 90 | seg((gx + 1) * cw, rightY, midX, midY); |
| 91 | seg(midX, midY, bottomX, (gy + 1) * ch); |
| 92 | } else { |
| 93 | seg(leftX, leftY, midX, midY); |
| 94 | seg(midX, midY, bottomX, (gy + 1) * ch); |
| 95 | seg((gx + 1) * cw, rightY, midX, midY); |
| 96 | seg(midX, midY, topX, gy * ch); |
| 97 | } |
| 98 | break; |
| 99 | } |
| 100 | case 6: seg(topX, gy * ch, bottomX, (gy + 1) * ch); break; |
| 101 | case 7: seg(leftX, leftY, topX, gy * ch); break; |
| 102 | case 8: seg(leftX, leftY, topX, gy * ch); break; |
| 103 | case 9: seg(topX, gy * ch, bottomX, (gy + 1) * ch); break; |
| 104 | case 10: { |
| 105 | const centerHigh = (f00 + f10 + f01 + f11) / 4 > this.threshold; |
| 106 | if (centerHigh) { |
| 107 | seg(topX, gy * ch, midX, midY); |
| 108 | seg(midX, midY, (gx + 1) * cw, rightY); |
| 109 | seg(bottomX, (gy + 1) * ch, midX, midY); |
| 110 | seg(midX, midY, leftX, leftY); |
| 111 | } else { |
| 112 | seg(topX, gy * ch, midX, midY); |
| 113 | seg(midX, midY, leftX, leftY); |
| 114 | seg(bottomX, (gy + 1) * ch, midX, midY); |
| 115 | seg(midX, midY, (gx + 1) * cw, rightY); |
| 116 | } |
| 117 | break; |
| 118 | } |
| 119 | case 11: seg(topX, gy * ch, (gx + 1) * cw, rightY); break; |
| 120 | case 12: seg(leftX, leftY, (gx + 1) * cw, rightY); break; |
| 121 | case 13: seg(bottomX, (gy + 1) * ch, (gx + 1) * cw, rightY); break; |
| 122 | case 14: seg(leftX, leftY, bottomX, (gy + 1) * ch); break; |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | protected onUpdate(dt: number): void { |
| 129 | this.threshold = this.num("threshold"); |
| 130 | const speed = this.num("speed"); |
| 131 | for (const blob of this.blobs) { |
| 132 | blob.cx += (blob.vx * dt * speed) / 100; |
| 133 | blob.cy += (blob.vy * dt * speed) / 100; |
| 134 | if (blob.cx < 0.12 || blob.cx > 0.88) blob.vx *= -1; |
| 135 | if (blob.cy < 0.12 || blob.cy > 0.88) blob.vy *= -1; |
| 136 | blob.cx = Math.min(0.88, Math.max(0.12, blob.cx)); |
| 137 | blob.cy = Math.min(0.88, Math.max(0.12, blob.cy)); |
| 138 | } |
| 139 | this.computeSegments(); |
| 140 | } |
| 141 | |
| 142 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 143 | ctx.fillStyle = theme.bg; |
| 144 | ctx.fillRect(0, 0, this.width, this.height); |
| 145 | const cols = this.num("grid"); |
| 146 | const rows = this.num("grid"); |
| 147 | const cw = this.width / cols; |
| 148 | const ch = this.height / rows; |
| 149 | // Faint above-threshold field |
| 150 | ctx.fillStyle = theme.raised; |
| 151 | for (let gy = 0; gy < rows; gy++) { |
| 152 | for (let gx = 0; gx < cols; gx++) { |
| 153 | if (this.field(gx + 0.5, gy + 0.5, cols, rows) > this.threshold) { |
| 154 | ctx.fillRect(gx * cw, gy * ch, cw, ch); |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | // Contours from the cached segment list |
| 159 | ctx.strokeStyle = theme.accent; |
| 160 | ctx.lineWidth = 2; |
| 161 | ctx.lineCap = "round"; |
| 162 | ctx.beginPath(); |
| 163 | for (let i = 0; i < this.segmentList.length; i += 4) { |
| 164 | ctx.moveTo(this.segmentList[i], this.segmentList[i + 1]); |
| 165 | ctx.lineTo(this.segmentList[i + 2], this.segmentList[i + 3]); |
| 166 | } |
| 167 | ctx.stroke(); |
| 168 | ctx.lineWidth = 1; |
| 169 | ctx.font = "11px ui-monospace, monospace"; |
| 170 | ctx.fillStyle = theme.fgTertiary; |
| 171 | ctx.fillText(`${this.segments} contour segments`, 10, 16); |
| 172 | } |
| 173 | |
| 174 | getMetrics() { |
| 175 | return { |
| 176 | isoValue: this.threshold.toFixed(2), |
| 177 | segments: this.segments, |
| 178 | blobs: this.blobs.length, |
| 179 | grid: `${this.num("grid")}^2`, |
| 180 | }; |
| 181 | } |
| 182 | |
| 183 | describe(): string { |
| 184 | return `Marching squares extracting the iso-contour of three moving metaballs: ${this.segments} segments at iso value ${this.threshold.toFixed(2)}.`; |
| 185 | } |
| 186 | |
| 187 | entities(): number { |
| 188 | return this.num("grid") ** 2; |
| 189 | } |
| 190 | } |
| 191 | |
Performance
- Time per tick
- O(cells) field evaluation plus O(cells) classification
- Space
- None beyond the sampled field
- Notes
- The same 16-case table, run per cell, is the algorithm behind every iso-line in every medical imaging package.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.