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 |
||
|---|---|---|
| .. | ||
| public | ||
| src/arthur | ||
| test | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| shadow-cljs.edn | ||
frontend
The ClojureScript half. See docs/port-plan.md for what is being built and in
what order; this file is only how to run it.
Once
mise install # from the REPO ROOT: java 21+, node 20, clojure, python
cd frontend && npm install
java must be 21+. On an older JDK shadow-cljs fails with "CompilerOptions has
been compiled by a more recent version of the Java Runtime", which reads like a
shadow-cljs bug and is not one. mise install is what prevents it.
The tests
cd frontend && npm test
That is three things in order: regenerate the JS oracle's answers, compile the
:test build, run it under node.
node test/parity/oracle.mjs # drives js/ and writes test/parity/oracle.json
shadow-cljs compile test
node out/node-tests.js
Run them separately if a compile error is in the way. The oracle JSON is generated, not committed.
Run them through mise, or make sure mise's node is first on PATH. The
oracle imports js/*.js directly, and those are ES modules in a directory with
no package.json, so node needs the module detection that became default in
20.19. On an older node 20 — an nvm install shadowing the pinned one is the easy
way to get there — every import fails with "Named export not found ... is a
CommonJS module", which reads like the oracle being broken and is not.
The app
cd frontend && npx shadow-cljs watch app
Then open http://localhost:8778/index.html — with the /index.html, not
bare /. This shadow-cljs does no directory-index resolution, so / is a 404
whatever the roots are.
The page is the hand-written scene from step 2, scrubbed by hand. There is
deliberately no clock: the audio clock, the rAF loop, the ::resolver
subscription and the transport are step 3, and the question this step answers is
whether the data model evaluates correctly, not whether it evaluates at 30fps.
A scrubber answers the first and nothing else, which is what makes a failure
here unambiguous.
The scene itself is src/arthur/demo/scene.edn, and it is not a face. It is the
smallest scene that exercises every mechanism the model claims to have —
inherited exposure, a sparse held [:xform :pos], composition through a group,
rotation about an anchor, a stencil chain, a keyed [:vis], a :span, and
fractional z among siblings — chosen so that each one is visible when it breaks.
Port 8778 is deliberately not 8777. python3 serve.py from the repo root still
runs the old JS tool on 8777, and the two are meant to run side by side — that is
the whole reason js/ is still in the tree.
From step 9 Django serves the page and :dev-http goes away.
The oracle
js/ is the numeric oracle, not dead weight. test/parity/ runs both
implementations on the same synthetic track and diffs them: fit-similarity and
procrustes-mean agree to 1e-9, the raster pixel-for-pixel.
Both sides get the identical track because js/synth.js reads Math.random at
call time, so oracle.mjs stubs it to a constant and the CLJS side passes
:rand-fn (constantly 0.5). js/ itself is never modified.
test/parity/ and arthur.parity-test get deleted in one commit at step 5.
A parity test pins behaviour while code moves; keeping it afterwards would bake
the prototype's mistakes into the rewrite and make them permanent.
Layout
src/arthur/domain/ pure. No re-frame, no DOM, no flow/.
src/arthur/demo.cljs the hand-written scene, read from demo/scene.edn
src/arthur/ui/canvas.cljs the one imperative sink — the only DOM canvas call
test/arthur/synth.cljs the synthetic track — test infrastructure, not src
test/parity/ the JS oracle harness. Deletable at step 5.
public/index.html dev host page. Django replaces it at step 9.
Two evaluators, on purpose
domain/scene has both eval-frame and resolver, and they are not
alternatives:
(eval-frame scene f store)is the specification. Allocating, order-free, obviously correct. Tests and one-off renders use it.(resolver scene store)->(fn [f] ops)is what playback uses. It caches the topological order and the z paths, holds a cursor per channel and reuses one point buffer per node, so a frame allocates the op maps and nothing else.
Both run the same walk, parameterised by how a channel is read and where its
points are written — two independent implementations of frame evaluation would
drift, and the drift would look like a rendering bug rather than like two
functions disagreeing. What differs between them is exactly the part that can be
wrong, and scene-test asserts they agree frame for frame in forward, backward
and random order.
Because the resolver reuses its buffers, ops must be rasterised before the next frame is asked for. That is the contract the rAF loop wants anyway: it reads, blits, and dispatches nothing.