The frame went 5.86ms to 3.17ms -- a 170fps ceiling to 315 -- and `loop`/`recur`
is gone from src/ entirely.
Two real wins, both from measuring rather than guessing:
- `->rgba` was 3.16ms of that frame and was scene-independent: a `nth` into a
vector of vectors is four protocol dispatches per pixel, 64,000 pixels a frame.
The palette is now flattened once and cached by identity of the source vector
-- palettes are values, so identity is exactly the right test and there is no
invalidation to get wrong. At zoom 1 on a little-endian machine the inner loop
is one 32-bit write per pixel through a Uint32Array view of the same buffer:
0.11ms, 28x. Every other case walks bytes off the same flat palette.
raster-test pins both against a naive per-pixel reference at three zooms,
because a fast path that is subtly wrong about colour would look like a palette
bug rather than like an optimisation.
- The per-frame z sort was re-deriving a constant. Draw order is a function of
the z paths, which change when the scene changes and never because the playhead
moved, so `draw-rank` computes it once and a frame sorts small integers. Every
op drops its `:i` and `:z-path` fields as a result.
The loop pass, and an honest note on it: it came out NET POSITIVE on lines, which
is the wrong direction for a cleanup. geom is -3 (transduce for the accumulators,
`(-> (iterate refine ref) (nth iters))` for Procrustes, which is what the
algorithm says rather than a counter that happens to stop), channel -3,
fill-poly!'s copy loop 7 lines to 1. Against that, eval-into went from one
four-deep pyramid with seven positional parameters to `place` / `emit` / a fold
over a ctx map -- less nesting, more lines, and a different change from "fix the
loops" that should not have been bundled with it.
Two idioms were reverted for being worse here than what they replaced, both the
same mistake -- reaching for a form that allocates inside a hot loop:
- `partition 2` over an `array-seq` per scanline is some five thousand throwaway
objects a frame and took draw from 0.88ms to 1.48ms. Now a pairwise `dotimes`
over the array.
- `z-lex` via `(map compare a b)` allocated three lazy seqs per call, ~700 calls
a frame. Made moot by `draw-rank`.
And one DRY move reverted for coupling things that only coincide: a `geom-path`
table had `node/valid-paths` and `scene/emit` deriving from one source, which
ties what a kind may CARRY to what the renderer READS off it. Those are the same
today and are not the same question, and the table put a spec change in charge of
what gets drawn, across a namespace boundary. `emit`'s three branches are three
different marks and stay three branches.
Kept, because it is one operation with two callers rather than two concerns that
rhyme: `lineage`, which `depth` and `z-path` were both walking separately. Its
cycle check is now a length bound -- a chain that does not repeat cannot be
longer than the node count -- instead of a `seen` set.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDfHGdV39zu6rvgbBTfDaT