Skip to content

Drop three.js, and give the SID surfaces a C64 identity - #215

Merged
eetu merged 6 commits into
mainfrom
c64-sid-surfaces
Aug 9, 2026
Merged

Drop three.js, and give the SID surfaces a C64 identity#215
eetu merged 6 commits into
mainfrom
c64-sid-surfaces

Conversation

@eetu

@eetu eetu commented Aug 9, 2026

Copy link
Copy Markdown
Owner

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

tubes ported to hand-rolled WebGL2 (gl3.ts + nixie-geometry.ts +
nixie-gl.ts), the dancer baked, the paint viz dropped — and with them the
three dependency and the shared stage.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 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, because 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 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 continuation
runs would have fallen back mid-row and broken the monospace grid.

The voices tab 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.vu the 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.sidRegs and its whole
worker-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 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 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-departures never
registered a player host (unhandled rejection on every visual run), the
browser and visual vitest projects shared one browser-instance object so a
bare vitest run died at startup, and PatternViewScroll had drifted off the
shared --tracker-font chain.

Verification

just lint, just build, prettier, svelte-check on both frontends, 219
unit + 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.

eetu added 6 commits August 9, 2026 09:50
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.
@eetu
eetu enabled auto-merge (squash) August 9, 2026 10:38
@eetu
eetu merged commit 0fa8896 into main Aug 9, 2026
8 checks passed
@eetu
eetu deleted the c64-sid-surfaces branch August 9, 2026 10:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant