Drop three.js, and give the SID surfaces a C64 identity - #215
Merged
Conversation
Selecting the tubes viz pulled a 725KB lazy chunk, and parsing it stalled the
app hard enough to be heard in the audio. The scene did not need most of what
was in that chunk: @glowbox/nixie ships 17KB of pure data with no three
reference at all, so every line of 3D here was ours already.
What the tubes are FOR is the stack - from an angle you look past the lit
numeral at the nine unlit ones behind it, which is what a real nixie tube does
and what no flat seven-segment fake can. Geometry and depth spacing are
therefore exact, and the glass is the part that got simplified: a
fresnel-weighted shell instead of screen-space refraction through a PMREM room
environment, which is what SVGLoader, TubeGeometry, LatheGeometry,
MeshPhysicalMaterial, UnrealBloomPass and OrbitControls were being carried for.
Three new modules replace them:
nixie-geometry.ts pure: an M/L/C/Z path parser (the whole grammar the
glyphs use, asserted in a test), parallel-transported tube
extrusion, lathe/cylinder/box, the hex grille. No GPU, so
it is unit-tested rather than eyeballed - 15 new tests.
gl3.ts the small slice of an engine this needs: context,
programs, indexed meshes, MSAA + resolve targets, a
fullscreen triangle, 4x4 matrices.
nixie-gl.ts the scene: one forward pass, a three-level
threshold-and-blur bloom, ACES, a damped orbit camera.
Two things changed on purpose while porting. The camera sways instead of the
row: turning the set about its own centre swung its near end towards the lens,
where perspective magnified it out of frame, so the fit had to be loosened
until there was dead space at every other phase. And the fit now runs over the
corners of the content box at the extremes of the sway rather than the head-on
width, so nothing clips at any angle.
Small enough now to build synchronously, so the lazy import and its
requestIdleCallback warm-up (prefetch.ts) go with it.
The dancer used three for an asset pipeline rather than for rendering: a GLTFLoader, an AnimationMixer, and a SkeletonUtils clone per echo so each trailing copy could hold its own pose. None of that is needed, because of two rules the viz already had: NO SHADING. Flat unlit colour - silhouettes with a tint. STEPPED, NOT SMOOTH. The figure snaps between poses like traced film. A stepped, unlit silhouette is a fixed set of vertex positions, so build-dancer.py now bakes the deformed mesh once per output frame instead of exporting a rigged glb. Each dance becomes its own file of quantised poses; the runtime uploads one buffer and changes frame with an attribute offset, and an echo is the same buffer read at a different offset. No loader, no skinning, no animation system. Two conversions live in the bake now rather than at runtime: Blender's Z-up to WebGL's Y-up (skip it and the figure dances lying on its back), and cancelling the clip's horizontal root travel, which the scene used to do off the hips bone every frame. The echo lag became a count of baked poses. It was 1/55s, which was fine against a continuously-sampled mixer but rounds every copy onto the same 10fps pose - three identical silhouettes drawn on top of each other, and no trail. The paint viz is deleted rather than ported (underwhelming, and the only other three consumer), which is what lets the dependency go: 725KB + 302KB of chunks out of the build, and the largest chunk drops to 339KB. The dances are the one thing that got bigger - 1.0MB fetched on mount against the old 1.1MB glb, with the other two fetched only if a track selects them.
Two things the port got wrong, both reported by eye and confirmed by measuring the rendered pixels. TUBES WENT YELLOW. The scene renders into an 8-bit target, so a glow brighter than white loses whichever channel saturates first - on a saturated accent that is red, and the bass pulse was pushing the emissive past 1.0 on every beat. The brightest wire pixel measured (211,159,13): green/red 0.75, against the accent's 0.58. Normalising by the peak channel instead of clamping per channel keeps the ratio, and the pulse still reads through the bloom, whose strength already rises with it. The same fix in the composite replaces the per-channel tone map, which was pulling the same trick a second time. Measured after: (206,121,10), 0.59. Also killed the cool rim light on anything emissive - a blue-white fill on top of a saturated glow is the other way to make orange yellow. THE TRAIL WAS TOO FAR BACK. The echoes lag by whole baked poses, and at a 10fps bake that is 100ms each: three separate dancers rather than one in motion. The bake goes to 20fps so a pose is 50ms, and the leading figure now snaps on its own coarser grid (STEP_FPS) so the traced-film step survives the finer poses. Vertex budget drops 1200 -> 900 triangles to pay for it; the silhouette is unchanged at the size it is drawn, and the first dance grows 1.04 -> 1.19 MB.
The echo trail was inherited from the Amiga demo, where drawing the figure several times was the only motion blur available. It has never looked like the blur it stands in for: however tight the lag, discrete copies read as several dancers, and the gaps between them are exactly what a long-persistence tube would have filled in. So the trail is now persistence. The figure is drawn into a buffer that is dimmed, smeared and cooled towards the palette's far colour every frame before the next pose lands on it, which compounds the smear along whatever path the figure took. That is a wake rather than a stack of stencils, and the fade is time-based, so it is the same length whatever the frame rate. The buffer is half resolution, because a blur has no use for the pixels, and the crisp figure is drawn again over the composited trail — soft is right for the wake and wrong for the dancer. Premultiplied alpha throughout: the figure goes in opaque and the decay scales colour and alpha together, so they stay in step and the wake has no dark fringe. `echoes`/`echoLag` give way to one `persistence` in seconds. The fullscreen vertex shader both scenes' post passes were each declaring moves to gl3.
…nt chain Three unrelated papercuts found while working nearby. board-departures never registered a player host, so playInOrder threw out of host() inside resetForTrack before the load could even fail — an unhandled rejection on every visual run. queue-refs already installs a host and swallows the expected engine failure; this test was the outlier. The browser and visual projects shared one BROWSER constant. Vitest names each browser instance in place, so the second project collided with the name the first had been given and a bare `vitest run` died at startup before loading a test. A factory gives each its own. PatternViewScroll hardcoded var(--font-mono-retro) where the shared TrackGrid uses the --tracker-font chain. Same grid, two font rules, and a bare var() falls back to serif rather than monospace. No behaviour change today; latent drift closed.
The SID pattern view rendered in Amiga TopazPlus, the same face as the MOD grid. That is a mismatch of machine: the rows it shows are a 6510 writing SID registers fifty times a second, so it should read like a C64. The trace now renders in C64 Pro Mono, the C64 character ROM. Its cell is square — a full-em advance where TopazPlus is half — so the view runs at 16px or 8px and picks between them per pane, using the large face only where the column shows the same fields it would at the small one. A plain width breakpoint would *drop* a column of data to buy bigger type when you widened the pane. That also needed a maxCell on TrackGrid: the module grid caps columns at 160px, tuned for a half-em advance, which would have frozen this grid at note-plus-waveform however wide the pane got. Glyphs are constrained to that ROM, and it was read out of the font rather than off a PETSCII chart — the diagonals are the opposite way round to the usual tables, and U+00B7 is not in it at all, so the continuation runs are periods now. A test fails if a waveform glyph ever leaves the repertoire. The voices tab is gone, folded into the trace. It showed note, waveform, ADSR and level per voice; all four are already columns here, and its level meter was the same playback.vu the frame draws as bars. What it uniquely had — the live pitch, the routing flags, the filter and master volume — now sits above and around the grid, decoded from the playhead frame of the same trace ring. So the header and the rows cannot disagree, and playback.sidRegs and its whole worker-to-store path is deleted. That also removes the reason its gate hold existed (registers sampled at ~43Hz against a 50Hz raster); the hold survives as frame-counted smoothing, for a voice that retriggers every frame. And a C64 demo visualiser, which the font makes practical: the ROM is mapped into the Private Use Area by screen code, so the model is real screen RAM and colour RAM rather than a grid of strings with a lookup table in front of it. It boots, loads a tape, then runs seven parts on rotation — title card, plasma, starfield, twister, oscilloscope, vector balls, fire — with a DYCP scroller under all of them and a border that catches the beat. The parts are pure functions in c64-parts.ts and the running order is c64-demo.ts, so the interesting half tests without a canvas. Cells blit from a pre-rendered glyph atlas; a full-screen part fills all thousand of them every frame. Several of the tunings here came from looking at the thing rather than from the tests: the fire body, the scope window, the scroller slope, and the twister, which had to become a cylinder because every point on one row of a flat strip shares a surface normal and can therefore only be one flat colour.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two features, developed in parallel in the same working tree and stacked here
because they both rewire the visualiser registries. The three.js removal is the
bottom four commits; the C64 work is the top two and sits on top of it.
Dropping three.js
tubesported to hand-rolled WebGL2 (gl3.ts+nixie-geometry.ts+nixie-gl.ts), the dancer baked, the paint viz dropped — and with them thethreedependency and the sharedstage.ts.A C64 identity for the SID surfaces
The SID pattern view rendered in Amiga TopazPlus, the same face as the MOD grid.
That is a mismatch of machine: the rows it shows are a 6510 writing SID
registers fifty times a second.
The trace now renders in C64 Pro Mono, the C64 character ROM (style64.org,
vendored under its own licence — see
THIRD_PARTY_NOTICES.md). Its cell issquare, a full-em advance where TopazPlus is half, so the view runs at 16px or
8px and picks between them per pane — using the large face only where the column
shows the same fields it would at the small one, because a plain width
breakpoint would drop a column of data to buy bigger type when you widened the
pane. That also needed a
maxCellonTrackGrid: the module grid caps columnsat 160px, tuned for a half-em advance, which would otherwise have frozen this
grid at note-plus-waveform however wide the pane got.
The glyph repertoire was read out of the font rather than off a PETSCII chart.
Two things that mattered: the diagonals are the opposite way round to the usual
published tables, and
·U+00B7 is not in the ROM at all — the continuationruns would have fallen back mid-row and broken the monospace grid.
The
voicestab is gone, folded into the trace. It showed note, waveform,ADSR and level per voice; all four are already columns there, and its level
meter was the same
playback.vuthe frame draws as bars. What it uniquely had —live pitch, routing flags, the chip's filter and volume — now sits above and
around the grid, decoded from the playhead frame of the same trace ring, so
header and rows cannot disagree.
playback.sidRegsand its wholeworker-to-store path is deleted.
And a C64 demo visualiser, which the font makes practical: the ROM is mapped
into the Private Use Area by screen code, so the model is real screen RAM and
colour RAM. It boots, loads a tape, then runs seven parts on rotation — title
card, plasma, starfield, twister, oscilloscope, vector balls, fire — with a DYCP
scroller under all of them and a border that catches the beat. Parts are pure
functions (
c64-parts.ts), the running order isc64-demo.ts, so theinteresting half tests without a canvas. Cells blit from a pre-rendered glyph
atlas; a full-screen part fills all thousand of them every frame.
Several tunings came from looking at the thing rather than from tests: the fire
body (shading every cell by its own heat draws scanlines, not flames), the scope
window (2048 samples into 40 columns is aliasing), the scroller slope, and the
twister — which had to become a cylinder, because every point on one row of a
flat strip shares a surface normal and can therefore only be one flat colour.
Also in here
A commit of three unrelated papercuts found nearby:
board-departuresneverregistered a player host (unhandled rejection on every visual run), the
browserandvisualvitest projects shared one browser-instance object so abare
vitest rundied at startup, andPatternViewScrollhad drifted off theshared
--tracker-fontchain.Verification
just lint,just build, prettier,svelte-checkon both frontends, 219unit + browser tests, 69 visual tests, and the 12 SID e2e tests in Chromium and
WebKit. Every demo part and the trace at three widths were screenshotted in the
running app.