Noisy Boundaries
A region map drawn from a lattice has straight boundaries, and straight boundaries read as a diagram rather than as terrain. There are two ways to break them, and they have almost nothing in common. One subdivides the geometry: the shared edge between two regions is replaced by a recursive midpoint displacement, confined to the quadrilateral formed by the two region centres and the two shared corners. The otherperturbs the classification: no edge is ever constructed, each pixel asks which cell owns it, and noise is added to the answer. Both arrive at the same organic coastline. Only one of them hands you a curve at the end.
A — noisy edges: subdividing the geometry
Every boundary between two regions has four points attached to it: the two region centresR1, R2 and the two shared corners C1, C2. Those four points bound a quad, and the boundary is any path from C1 to C2 that stays inside it. Displace the middle, split the quad in two, recurse. The two endpoints never move, so the curve stays welded to its neighbours.
B — noisy hex rendering: perturbing the field
No geometry at all. For each pixel, find the triangle of the hex-centre lattice it falls in, take its barycentric coordinates against those three centres, add noise to each of the three, and the largest one wins the pixel. Each hex carries its own noise offset, so neighbouring triangles agree about how much a given hex is currently claiming, and the boundary comes out continuous without ever having been drawn.
The comparison
Part A costs geometry and gives you a path. One boundary is an array of points, built once, cheap to keep, and it is a real object: you can stroke it, dash it, march an animation along it, hit-test it, export it as SVG, hand it to a physics engine as a shoreline. The price is bookkeeping — a dual mesh, stable region ids, a canonical order for every edge, and a shared cache, because the moment two regions generate the same boundary independently and disagree by a hair, the map cracks.Part B costs pixels and gives you pixels. There is no mesh, no edge list, no invariant to protect: the boundary cannot crack because it was never assembled, and effects that are hard on a path — blending across the border, per-hex growth, changing everything at 60 fps — are free. But you cannot stroke it, you cannot animate along it, and asking "where exactly is the coastline" means re-running the classifier and marching squares over the answer. Reach for A when the boundary is a thing the rest of the program needs to talk about. Reach for B when it is only ever something to look at.
What it actually does
- Every boundary has four points attached to it: the two region centres and the two shared corners. Those four bound a quad, and the boundary is any path between the corners that stays inside it.
- A jittered triangular lattice with the triangle centroids as corners gives a valid planar dual by construction, with six corners per region, each corner shared by three regions, and each edge bounded by exactly two shared corners. A centroid can never leave its triangle, so a region polygon can never fold, which is what makes a Voronoi library unnecessary here.
- The two corners never move under subdivision, which is what welds a region’s outline to its neighbour’s.
- The field half constructs no boundary at all. Each pixel takes its barycentric coordinates against the three nearest hex centres, noise is added to each, and the largest wins.
- At amplitude zero the classifier reproduces the exact hexagons, which is what makes the true-edge overlay worth having: it shows how far the noisy boundary strayed from the edge it replaced.
- Wavelength near the cell size gives a ragged coast; wavelength well above it drags whole cells sideways.
What the rebuild taught
- Keying the seed on the sorted pair of region ids is the half of the rule everyone quotes, and it is not the half that bites. The two regions walk their corner rings in opposite rotational senses, so one presents the quad as (R1, C1, R2, C2) and the other as the mirrored (R1, C2, R2, C1). Same seed, same stream, mirrored geometry, and every edge cracks.
- The fix is a second canonicalisation the sorted-ids rule does not imply: sort the corners by key as well, generate the curve once, cache it under the edge id, and let whichever region traverses it backwards read that same array in reverse rather than regenerate it.
- The recursion must consume random values in a traversal order independent of the order points come out. Drawing the value at the top of the subdivision and pushing the midpoint between the two recursive calls keeps both halves deterministic.
- Prove the invariant rather than asserting it. A seam-check mode with flat unstroked fills makes a crack a hairline of paper white, and a disagreement counter turns "looks fine" into a number that stayed zero across a hundred-odd parameter combinations.
- Adjacent canvas fills always leave an antialiasing hairline even when the two paths agree exactly, so the seam check needs a three-colouring of the lattice to stay meaningful: a blended edge is antialiasing, only paper white is a real crack. Stroking the seams in the fill colour would have hidden up to a pixel of genuine gap and made the check worthless.
- The noise hash was the entire cost of the field half. Reusing the identity hash inside the value noise, where it runs four times per sample and three times per pixel, cost 162 ms a redraw; a dedicated single-round lattice hash took it to 56. Same function, wildly different budget, depending on how often it is called.
- The two roads are not interchangeable, and the cost readout is the argument. Subdivision costs bookkeeping and gives you a path you can stroke, dash, animate along and hit-test. Classification costs pixels and gives you pixels: effects that are hard on a path become free, and asking where the coastline actually is means running the classifier again.
- Blur is available on one road and not the other. Softening an argmax into a blend costs nothing; softening a polyline means offsetting it.
Rebuild
Both techniques on one page because the comparison is the reason to have either. The region map is a jittered triangular lattice with a centroid dual rather than a Voronoi diagram, so the page stays one file with no dependencies.