A hexagonal lattice where every cell's tone is a sample of a noise field, drawn as hatching.
Axial coordinates
Hexagons do not tile a square grid, so the cells are generated in axial
coordinates — two axes at sixty degrees — and converted to the plane by a matrix that
also carries the lattice’s rotation:
float qx = EdgeSize * (1.5f * cosRot - sqr3/2f * sinRot);
float qy = EdgeSize * (1.5f * sinRot + sqr3/2f * cosRot);
float rx = EdgeSize * (0f * cosRot - sqr3 * sinRot);
float ry = EdgeSize * (0f * sinRot + sqr3 * cosRot);
A cell at axial (q, r) sits at q·(qx, qy) + r·(rx, ry). The rotation is drawn once
per drawing, uniformly from a sixth of a turn — the lattice’s own symmetry — so the
grain of the honeycomb differs between renderings without the lattice ever being
irregular.
EnumerateRings walks outward: centre first, then ring one, ring two, and so on. That
order is the drawing order and therefore the progression.
The inner rings. Every cell here is in its final position and carries its final tone.
Tone is sampled, not assigned
float per = (perlin.Get(cx + 100f, cy + 100f, PerlinZoom) - minP) / perlinReach;
int baleIdx = Math.Clamp((int)MathF.Round((PaletteSize - 1) * per), 0, PaletteSize - 1);
Each cell samples Perlin noise at its own centre and maps the result into one of
twenty tone steps. Because the noise is smooth, neighbouring cells sample nearly the
same value and end up in the same or adjacent steps — so the field has regions, and
none of them was drawn.
The normalisation is worth noticing. The code makes a full pass over every cell first
to find the minimum and maximum noise values, then rescales so the darkest cell is 0
and the brightest is 1. Whatever the noise happens to do, the palette is always fully
used.
Fifty-five units out. Regions of tone are already legible.
Tone is hatching, not fill
A cell is not filled. It is hatched: the tone index selects a bale size — a number
of hatch lines — and the cell is drawn with that many parallel strokes at
HatchAngle, clipped to the hexagon:
float intensity = 1f - MathF.Pow(1f - 1f / PaletteSize, n);
The intensity is not n / PaletteSize. It is one minus the probability that none of
n independent events at rate 1/PaletteSize occurred — a saturating curve. Adding
the first hatch line changes the tone a lot; adding the twentieth changes it barely at
all, which is how ink behaves on paper and not how a linear ramp behaves.
A cell whose bale size comes out zero is skipped entirely, leaving the background
showing — so the lightest tone is an absence rather than a faint mark.
Eighty-five units.
A hundred and eighteen units — past the sides of the clip, short of its corners. The
lattice rotation is visible in the grain of the hatching, which
follows the canvas rather than the cell — so hatch lines run continuously across cell
boundaries where two neighbours share a tone.
The whole clipped field. Cells whose bounding box falls outside ±100 units are
skipped, so the field is generated as a hexagon and shown as a square — which is why
the ring structure that drives the progression is invisible in the finished piece.
The generation goes well past the clip: at forty-five rings of four-unit hexagons the
outermost cells sit around 270 units from the centre, nearly twice as far as the
frame’s own corner. Roughly three quarters of the cells are computed and thrown away,
and the stages above are stated in units rather than in cell counts for exactly that
reason.
Five fields
| Rendering | Rings | Edge | Hatch angle | Hatch spacing | Ink |
|---|
| Cyan Hexgrid | 45 | 4 | 57° | 0.15 | #00FFFF on black |
| Amber Forge | 22 | 7 | 0° horiz | 0.50 | #E07820 on near-black |
| Cobalt Grain | 60 | 2 | 57° | 0.09 | #3B82F6 on near-black |
| Gold Lattice | 40 | 5 | 45° diag | 0.16 | #F5C518 on near-black |
| Rose Sweep | 42 | 4 | 90° vert | 0.16 | #E8365D on near-black |
Ring count and edge size trade off to fill the same clip: fewer, larger cells or more,
smaller ones. Hatch angle and spacing vary independently: a 0.50-unit spacing at
edge 7 places lines far enough apart that individual strokes are visible inside each
hexagon; a 0.09-unit spacing at edge 2 merges them into a continuous tone, making
the hexagonal grid invisible. Cyan Hexgrid uses the default 57° hatch angle; Amber
Forge is horizontal, Gold Lattice is 45°, and Rose Sweep is vertical. Rose Sweep also
reduces the palette to fourteen tones — with a wide noise field, this gives broad zones
of similar brightness rather than a smooth gradient.
The museum plate
A finished work is more than its crop. The five fields above fill the canvas
edge to edge — the square frame is a window on a lattice that keeps going past
it, and a review of the finished renders found no undersizing, no off-centre
field, and no unused canvas to reclaim, so the full-bleed crop stays. What the
finished piece lacked was a boundary that belongs to the piece rather than to
the file format. The bundle’s gallery therefore carries a sixth plate, the
museum presentation: the lead field mounted behind a mat in a dark moulding,
hanging on a warm wall with its shadow.
The framing is a presentation layer, not a variant. The library’s
MuseumFrame draws the wrapped piece itself into the opening, scaled to fit,
and decides what surrounds it: the generative design never knows about the
frame, and the frame never redraws the lattice. Because the frame wraps a
finished piece rather than the drawing code, the collection gets one museum
plate, not a framed copy of every field.
Determinism
Two generators, both seeded from the preset: a Random for the single lattice
rotation, and the project’s Perlin noise seeded at Seed + 1 for the tone field. The
noise is sampled by position rather than by sequence, so cells could be visited in any
order and still get the same tone. The Random is System.Random, which the project’s
determinism policy flags as not guaranteed stable across .NET versions.