master
AdityaAngajala Publish 0.3.0 6d39e2e 2mo ago

Reveal build, the board growing itself in

A copy of the occupancy-grid engine that grows the board instead of showing it finished. Nothing in the original files changed:

classic reveal
engine circuit-background.js circuit-background-reveal.js
page index-classic.html index-reveal.html
URL (port 8930) / /reveal

What happens

Copper grows out across the background as one front: each route draws itself out of an end via, with a white-hot head riding the growing tip and pads landing as the tip reaches them. When the last route lands, the hot layer cross-fades into the real SVG artwork.

The look: heat, not paint

A single bright colour everywhere reads as a neon sheet, not as something being made. Two rules fix that:

  • Copper cools. The tip is near-white, the colour sits just behind it, and everything already laid fades exponentially (revealCool) toward a dim settled trace. At any instant the screen shows a bright rim, a warm wake, and a quiet interior, depth instead of one flat brightness. The default is 25 ms, the bottom of the knob and the shortest the engine can honour (the tail's tightening floors at 24), which leaves each route a wake about a tip-length long rather than a board that stays warm. Even there the fade only removes 48.7% per frame at 60fps, still short of the >50% a multiplicative fade needs to reach zero on its own, so the generation buffers below are doing real work at every setting.
  • Colour comes in regions. Hue is sampled from a smooth screen-space noise field (revealZone px) across the engine's own teal → blue → violet ramp, so the board grows in broad zones of colour rather than confetti or one tone.

The settled copper keeps a trace of its zone's hue (revealSettle), so the closing cross-fade into the uniform near-black artwork is a settling, not a cut.

Timing, the same motion language as the live signals

The reveal and the idle signal animation are one system, so the reveal borrows the signal system's timing rules rather than inventing its own:

  • Constant speed, linear. A tip travels at revealSpeed units/sec, the same units and the same default (420) as signalSpeed. It is never eased and never scaled by a route's length, so every tip on screen moves at one pace and a long bus takes longer than a short comb tap.
  • The same stroke stack: a ~2.4-wide core over a 7-wide glow, like a signal's head and halo.
  • The same arrival blip. A pad grows into place and rings out, the ring runs from the pad radius out to ~8 over revealBlip, matching the ring a signal leaves when it lands on a via.
  • The same palette (revealPalette is the signal palette verbatim).

Nothing appears or vanishes in one frame:

moment before now
board at the start cut to hidden fades out over 260 ms under the growth
a route's head popped in, vanished on landing fades in (130 ms), fades out after landing (260 ms)
a pad stamped at full size scales up and rings out before being stamped into the settled layer
the via hole drilled when the pop ended annular from the first frame
signals after the reveal a dozen appeared mid-flight at once the fountain fills from empty at its own cadence
resize mid-reveal restarted the whole growth settles, but only on a REAL size change; the observer also fires when nothing moved, which was ending the reveal ~200 ms in
the handoff to the static board cross-faded, dipped and shifted hue invisible: the board comes up under an opaque canvas, then only the glow fades

The layer that was missing. The single loudest "traces pop in fully formed" symptom had nothing to do with the schedule: the final composite drew only the settled and tip layers, so the two heat generations were sized, cleared, faded, rotated and stroked into every frame, and never reached the screen. A route's copper lives ONLY in the heat buffer until it lands, so every route was invisible while it grew and appeared whole the instant it was stamped into the settled layer. All that was visible mid-growth was the tip spark, and there was no glow at all. Measured by running the engine against a recording canvas: before, 2 of 4 layers were drawn into but never composited; after, all 4 composite and 3.5M px of stroke per reveal reaches the screen through the heat buffer.

The release queue was also sorted by metric, not by delay, while the release loop walks it with a single cursor. Jitter (±37 ms) is ~80x wider than the gap between neighbouring start times (0.91 ms median), which left 84% of the queue out of order and head-of-line blocked routes up to 71 ms behind the cursor before letting them out in one burst, the opposite of what the jitter is for. It is now sorted by delay.

A route starts growing when it is first drawn, not at the wall-clock moment it was scheduled (startedAt, capped at REVEAL_CATCHUP ms of catch-up). Deriving growth from the schedule means any slow frame, a GC pause, a busy tab, a machine waking up, releases every route whose start time has passed already fully grown: they pop into existence instead of animating. Measured before the fix, with ~700 ms hitches in the frame stream: 941 of 2070 routes were born complete, the median route born 106 px long against a typical length of ~78 px. After: zero, and never more than 16 px. Pads are timed off the same start, so a late route does not sprinkle its pads ahead of its own tip, and the closing tail waits for the growth to finish rather than for the clock.

Start times also carry ±35 ms of scatter (REVEAL_JITTER). Without it the first wave of short routes all landed on the same frame and their pads popped together , a visible batch. Measured frame-to-frame ink change went from 7.8% of the board to 3.0%, and the only frames above 2.5% are now the mid-point of the closing cross-fade, where a fade is supposed to be steep.

The glow, and why it now reaches exactly zero

The glow accumulates in a buffer that is faded a little every frame, one fill, whatever is warm. That is what keeps it O(1). But a multiplicative fade never reaches zero: at 8 bits a pixel stops moving as soon as alpha x decay rounds back to itself, so a multiply only lands on 0 if it removes more than half per frame. Measured at a 150 ms heat fade (5.19%/frame): alpha ran 255 → 151 → 31 → 9, and stayed 9 forever, 3.5% of #00e6dc sitting on the copper permanently, turning rgb(21, 27, 37) into rgb(20, 34, 43). That residue is why the growing board read lighter and bluer than the finished one. Canvas has no subtractive alpha operation, so no choice of fade curve fixes it.

The fix is a multiply by zero, applied where it costs nothing: the buffer is kept in two generations. New glow always goes into the current one. The other keeps decaying but is also composited through a falling ramp, and once that ramp reaches zero its buffer is cleared outright. Every pixel therefore ends at exactly zero, the clear happens at a moment when it changes nothing on screen, and the per-frame cost is still two fills and two composites regardless of how much of the board is glowing.

(An earlier attempt derived the whole glow from the routes every frame, in floating point. It was exact, but its cost scales with how much copper is warm, at a long heat fade that is the whole board, and it showed.)

Verified live at a 150 ms fade, sampling every fully-opaque pixel of the growth layer: the dominant colour is rgb(21, 27, 37) at t = 1.5 s, 3 s and 5 s, the artwork's own copper, exactly. Frame pacing with it: 4.2 ms median, 9.2 ms p95.

Matching the static board exactly

Measured against the SVG board it hands over to (same seed, same density, ink weighted by coverage):

how the copper is laid coverage vs SVG ink colour
SVG 0.14129 , rgb(19.9, 25.8, 35.4)
one stroke per route, butt caps + miter joins 0.14125 −0.03% rgb(19.9, 25.8, 35.4)
per-frame pieces (what it used to do) 0.14261 +0.93% rgb(19.8, 25.7, 35.4)

The colour was never the problem, it is identical either way. What differed was weight: stroking a route in per-frame pieces blends each piece's antialiased edge over the last one, leaving the canvas board ~1% denser than the SVG. So a route in progress is now redrawn whole on its own layer each frame, and stamped into the persistent layer exactly once when it lands, with the artwork's own caps and joins. Cost of redrawing the in-flight routes: 0.28 ms/frame (only ~4% of the board is growing at any instant; stroking the entire board once costs 7 ms).

That layer sits UNDER the glow. Copper painted over its own glow reads as a dark line with a coloured fringe instead of hot metal.

The tail is two phases, and that ordering matters. First the remaining glow is cooled to nothing, the time constant tightens over handoff (clamp(revealCool x 1.6, 120, 1100)) ms so the glow leaves as glow, at its own rate. That floor was 260 ms, which at a short heat fade left the finished board sitting untouched for ~150 ms with nothing left to cool; at the 25 ms default the whole reveal is now 4.3 s end to end instead of 5.3 s, with the growth itself unchanged. Only then does the canvas dissolve (REVEAL_HANDOFF, 260 ms), by which point it holds nothing but copper identical to the SVG already at full opacity beneath it. Fading the canvas while it was still glowing yanked the warm rim away instead of letting it cool, which reads as a colour change, most obviously with heat fade at minimum, where the fade and the decay ran at the same short duration. Measured at the dissolve: peak glow above the board colour drops 187 → 16 (−97%) before anything fades.

The handoff deserves its own note. Cross-fading the growth layer out while the SVG faded in looks obvious for two reasons: alpha compositing is not additive, so two complementary alphas over identical copper dip to ~75% opacity halfway through; and the settled copper carried a little of its zone's hue, so the whole board changed colour at the same moment. Now the settled layer is stroked in the artwork's exact colour (revealSettle: 1), the real SVG snaps to full opacity underneath the still-opaque canvas the instant growth ends, and the canvas fades off the top of it, so all that disappears is the glow. The growth layers also match devicePixelRatio (capped at 2), or a soft board would hand over to a crisp one.

A trap worth remembering: Chrome restores form-control values a beat after a reload and fires change on each one. The tuning sliders' change handler regenerates the board, so every reload silently kicked off two or three extra reveals a few seconds in, which looked exactly like the animation randomly restarting. autocomplete="off" on the form fixes it.

Why it is a canvas, not an animated SVG

The board is ONE tile in <defs>, instanced ~25× by <use>. That instancing is what makes the background cheap, and it is also why the obvious implementations do not work:

  • Web Animations on the referenced content paint nothing. Chrome runs them on the original element, getComputedStyle shows the dash offset advancing perfectly, but <use> instances mirror DOM and attribute state only, not the animation's effect. The board looked frozen while the DOM said it was growing. (Do not "verify" an animation by reading the DOM. Look at pixels.)
  • Writing attributes every frame does propagate, but costs ~260 ms/frame. Every mutation on referenced content resyncs all 25 instances.

So the reveal renders on a <canvas> above the SVG, and the SVG is hidden until the handoff. Two layers: settled (persistent, each route stamped once, when it lands) and work (cleared every frame, growing copper, its glow, the heads and the pads). Measured: 8.4 ms median frame, 13 ms p95 on a 3000×2000 viewport.

What keeps it cheap:

  • Landed copper is free. A route is stroked into the persistent layer exactly once; after that it costs nothing to keep on screen. Only what is still moving or still warm is redrawn.
  • Screen-space scheduling. Every (route × screen tile) is its own run with its own start time, so the growth is one front across the whole background rather than the same blob repeated in each tile. ~11k runs sort once at start; per-frame work tracks only what is moving.

Start times are spread by ink, not by run count: a third of the records are pad-only formations with no line to draw, and pacing by count gave the reveal a two-second opening where only vias sprinkled in, which read as a field of dots rather than anything growing.

Styles (REVEAL_PRESETS)

  • Instant, off; the board is there.
  • Etch, build order: pads, then the nets between them, then filler, then tap combs. The router's own passes, replayed everywhere at once.
  • Flood, outward from the cursor (or centre), nudged by how many hops of routing separate each trace from its tile's seed, so the front follows copper.
  • Sweep, a shallow diagonal scan across the screen.
  • Bloom, rings expanding from the cursor.
  • Scatter, no order; the whole board stipples in.

Config

reveal, revealDuration (whole board), revealSpeed (units/sec a tip travels), revealHead, revealBlip, revealSpark, revealPalette, revealTip, revealZone, revealSwell, revealBloom, revealCool (heat time constant), revealSettle. All six styles and seven knobs are on the page; Replay ⟳ regrows the board without re-routing it (circuit.replayReveal(style)).

Signals wait for the reveal to finish before spawning, a pulse running down a trace that is not there yet reads as a glitch. prefers-reduced-motion skips the reveal entirely, and exporting mid-reveal snaps to the finished board first, so downloads are never half-grown.

Verifying changes

Headless Chrome in this container never produces a screenshot, --screenshot hangs even on a data: URL, with or without virtual time, so the offline check node checks/frames.mjs: it runs the REAL engine against a recording canvas on a synthetic rAF clock and reports which layers reach the visible context and how much stroke goes with them. That is what caught the missing heat composite; reading the draw code had not. node checks/timing.mjs does the same for the schedule (route durations, queue ordering, release bursts) without any DOM at all. COOL=150 node checks/frames.mjs exercises the generation rotation, which never fires at the default 1200 ms cool within one reveal.

The pup window is usually occluded, so Chrome paint-throttles it to ~1 Hz and screenshots lag by seconds, several rounds of "it still looks wrong" were stale frames. To capture an exact instant, freeze the page (cancel its timers and rAF) and take the screenshot after a few seconds, or have the page rasterize itself: clone the SVG, serialize, draw it into a canvas, and read the data URL back through browser_eval.