On the WSL runner every __gm.pixelAt costs about 7 ms, three times as
much as on stein2. Each call renders the whole stage into a fresh
render texture and stalls until the GPU hands one pixel back. pixelRow
and several checks read hundreds of pixels that way. Module 05 spent
36.8 of its 40 s on these reads, module 04 11.3 of 20 s.
- stagePixels in src/ui/render.ts renders once around all points and
reads them in one go. Only whole pixels at resolution 1 take that
path. Anything else stays point by point.
- The hook __gm.pixels(points) exposes it, and pixelRow uses it.
- The loops in e2e 04, 05, 14 and 44 read their grids in one call.
- The old comment in pixelRow said a wider frame comes back resampled.
That is not true. The colours match exactly, so the comment is gone.
Measured on the RTX (d3d12) and on stein2 (Intel, Vulkan): diff 0 at
10 680 points on each GPU. One screen row takes 4 to 18 ms instead of
2.4 to 9.9 s. The six affected modules need 32 s instead of 139 s
under WSL, faster than on stein2.
The rest of the gap is in Chromium's GPU process. perf puts three
quarters of its time in the drivers (NVIDIA D3D12, Mesa d3d12, dxg).
Four browser and Mesa settings were measured, none helped.
Tests:
- ./check.sh on melektaus-atx: 195 files, 2827 tests, CHECK EXIT 0 (+4)
- e2e 1-16,30,37,44,58 under WSL: ALL PASSED, 587 checks
- counter-check: a wrong index in stagePixels fails the unit test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dragging a point only redrew once the hand stopped moving, which the
user reported after stage 3 and which measures as a real backlog, not a
feeling: every pointer move wrote the model and fired state.preview(),
and a project event rebuilds the panels, the run in the editor and the
picture the drawing is rasterised into. A mouse reports about 125 times
a second while the screen takes 60, so the queue grew instead of the
drawing moving.
- Measured with a throwaway e2e probe sending 120 moves at the 8 ms of
a real mouse, against a growing scene. Per move event: 10 -> 7 ms
with an empty scene, 19 -> 4 ms at 40 objects, 36 -> 3 ms at 120.
The 120 moves took 4173 ms before and 960 ms after, which is the
time they were supposed to take.
- New onFrame(key, fn) in src/ui/render.ts collects such work per key
and runs it in the ticker before app.render(), because afterwards it
would be one frame late in the picture. The rule lives in ui/ rather
than in the canvas so a second caller gets it for free.
- VectorArtCanvas only queues in pointermove now, and cancels its slot
on pointer up, where the command writes the final state anyway.
During the gesture the drag's own copy of the rings is the truth and
the model is never read back, so nothing is lost by the delay.
The same cost sits in the scene viewport, the texture tab and the graph,
which all call preview() from their move handler. They are untouched:
that is outside the reported bug.
Tests: check.sh 127 files, 1883 tests, exit 0 (was 1878). Full e2e
suite locally instead of CI only because the change sits in the render
loop: ALL PASSED - 1172 checks, the sum unchanged - a timing check in
the browser would flutter under CI load. Five new unit tests in
tests/ui/render.test.ts. Two counter-checks hold: running the work
after the frame fails one unit test, and taking the coalescing out of
the canvas puts the measurement back at 36 ms and 4153 ms.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.
The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.
Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.
564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.
Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
The renderer is taken off the ticker; the ticker itself keeps running, so
the clock, a live world and the panels still get their step. Drawing now
happens inside a 200 ms trailing window that every pointer, wheel and key
event, every Widget.invalidate/resize, every Stack.relayout, every
EditorState.emit and both library onChange hooks reopen. The window rather
than a single frame is what makes the ~70 direct redraw() calls that never
pass through invalidate() safe, along with repaints an event only starts:
a microtask, a texture upload, a resolved font.
Editor.movesByItself() keeps asking for every frame while Play or an editor
run is on, a time shader is on screen, or the particle or animation preview
is showing.
The FPS counter now counts drawn frames over the time the renderer was
awake. Idle seconds are not slow seconds, so it answers "how smooth while
it moves" and holds its last answer instead of falling to 0 at rest.
Measured on the built stand, 20 s per variant, CPU of all chromium
processes from /proc: kept awake 75.5 % of a core at 60 fps, render on
change 8.8 % with 0 frames drawn, awake again 83.9 % as an order probe.
No colour check can see a missing repaint: pixelAt extracts from the stage
on demand and is just as green on a screen frozen a minute ago. Step 33
watches the new __gm.renderStats() counter instead.