Editor nur bei Änderungen neu zeichnen, im Play weiter durchgehend.
Stand: ein stehendes Bild kostet im Editor gemessen 69 % eines Kerns (Lüfter, Akku). Seit dem Low-Latency-Modus (desynchronized) verlängert ein Frame auf Abruf die Reaktionszeit nicht mehr.
Abzuklären: was alles ein Neuzeichnen auslösen muss (Eingabe, Hover, Laden einer Textur, Perlen/Live-Werte im Graph während Play).
Übernommen aus todo-notes/render-on-change.md beim Umzug auf das Issue-System (2026-09-12).
Angelegt: 2026-09-11 · bf41919 — Add todo notes for the next round of features, gaps and upkeep
Gemeldet von: Claude (Assistent)
Editor nur bei Änderungen neu zeichnen, im Play weiter durchgehend.
Stand: ein stehendes Bild kostet im Editor gemessen 69 % eines Kerns (Lüfter, Akku). Seit dem Low-Latency-Modus (`desynchronized`) verlängert ein Frame auf Abruf die Reaktionszeit nicht mehr.
Abzuklären: was alles ein Neuzeichnen auslösen muss (Eingabe, Hover, Laden einer Textur, Perlen/Live-Werte im Graph während Play).
---
*Übernommen aus `todo-notes/render-on-change.md` beim Umzug auf das Issue-System (2026-09-12).*
- **Angelegt:** 2026-09-11 · `bf41919` — Add todo notes for the next round of features, gaps and upkeep
- **Gemeldet von:** Claude (Assistent)
Anmerkung: ich habe mitbekommen, dass ein redraw beim shader gezielt ausgelöst wird, vorallem wenn Time variable vorhanden. Ich weißt nicht wie relevant diese info ist. wenn es nichts mit dem issue zu tun hat, dann meine anmerkung hier ignorieren.
Anmerkung: ich habe mitbekommen, dass ein redraw beim shader gezielt ausgelöst wird, vorallem wenn Time variable vorhanden. Ich weißt nicht wie relevant diese info ist. wenn es nichts mit dem issue zu tun hat, dann meine anmerkung hier ignorieren.
It pays off. Measured on the built stand, 20 s per variant, CPU of all eleven chromium processes read from /proc, real GPU (ANGLE (Intel, Vulkan 1.3.230, HD Graphics 630, Mesa)), order probed by repeating the first variant last:
CPU
frames drawn
kept awake (old behaviour)
75.5 % of a core
1201 (60 fps)
render on change, idle
8.8 %
0
awake again (order probe)
83.9 %
1199
So ~89 % off the idle cost, and the zero says nothing is drawn at all rather than drawn more cheaply. Splitting it further: the ticker itself costs ~3 points, drawing ~64, and ~9-12 % is browser floor that stays. The 69 % in the note is the same order of magnitude.
What it does.initRenderLoop() in src/ui/render.ts takes app.render off the ticker; the ticker keeps running, so the clock, a live world and the panels still get their step. requestRender() opens a 200 ms trailing window rather than scheduling exactly one frame.
The window is the point. Strict dirty tracking would have to be right at ~70 direct redraw() calls that never pass through invalidate(), ~70 position mutations and ~70 addChild/removeChild — that is the road to the bug you notice three days later. A trailing window also catches the repaint an event only starts: a microtask, a texture upload, a resolved font. The idle gain is the same, because after 200 ms it is quiet either way.
Woken at the places everything passes through: pointer/wheel/keyboard once centrally on window in initUI, Widget.invalidate()/resize(), Stack.relayout(), EditorState.emit(), and textureLibrary.onChange/fontLibrary.onChange. Editor.movesByItself() asks for every frame while Play or an editor run is on, a time shader is on screen, or the particle/animation preview is showing.
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.
Testing. No colour check can see a missing repaint: pixelAt extracts from the stage on demand (debugHook.ts) and is just as green on a screen frozen a minute ago — of 868 checks the 24 that read pixels are all blind here. Step 33 render-on-change watches the new __gm.renderStats() counter instead (7 checks). Two traps that cost me a run and are now in Development: let the last click's trailing window expire before measuring, or its legitimate frames look like a leak; and measure on a tab whose picture is actually still. Harness screenshots ask for a frame first via __gm.wake().
Ran locally: npm run check 1005 tests, the full e2e ALL PASSED - 875 checks, smokes 24/19/21. Full run rather than the affected module, because a change to the frame loop has no affected module.
One thing left open, not covered by any test here: whether a surface that is not redrawn for seconds stays stable on real hardware with our own desynchronized context and no preserveDrawingBuffer is up to the driver. Headless in the container does not answer that, and neither does the e2e run. ?vsync rules it out immediately if it ever shows.
Implemented in `bc57f61`, wiki in `5f71a42`.
**It pays off.** Measured on the built stand, 20 s per variant, CPU of all eleven chromium processes read from `/proc`, real GPU (`ANGLE (Intel, Vulkan 1.3.230, HD Graphics 630, Mesa)`), order probed by repeating the first variant last:
| | CPU | frames drawn |
|---|---|---|
| kept awake (old behaviour) | 75.5 % of a core | 1201 (60 fps) |
| render on change, idle | **8.8 %** | **0** |
| awake again (order probe) | 83.9 % | 1199 |
So ~89 % off the idle cost, and the zero says nothing is drawn at all rather than drawn more cheaply. Splitting it further: the ticker itself costs ~3 points, drawing ~64, and ~9-12 % is browser floor that stays. The 69 % in the note is the same order of magnitude.
**What it does.** `initRenderLoop()` in `src/ui/render.ts` takes `app.render` off the ticker; the ticker keeps running, so the clock, a live world and the panels still get their step. `requestRender()` opens a **200 ms trailing window** rather than scheduling exactly one frame.
The window is the point. Strict dirty tracking would have to be right at ~70 direct `redraw()` calls that never pass through `invalidate()`, ~70 position mutations and ~70 `addChild`/`removeChild` — that is the road to the bug you notice three days later. A trailing window also catches the repaint an event only *starts*: a microtask, a texture upload, a resolved font. The idle gain is the same, because after 200 ms it is quiet either way.
Woken at the places everything passes through: pointer/wheel/keyboard once centrally on `window` in `initUI`, `Widget.invalidate()`/`resize()`, `Stack.relayout()`, `EditorState.emit()`, and `textureLibrary.onChange`/`fontLibrary.onChange`. `Editor.movesByItself()` asks for every frame while Play or an editor run is on, a time shader is on screen, or the particle/animation preview is showing.
**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.
**Testing.** No colour check can see a missing repaint: `pixelAt` extracts from the stage on demand (`debugHook.ts`) and is just as green on a screen frozen a minute ago — of 868 checks the 24 that read pixels are all blind here. Step 33 `render-on-change` watches the new `__gm.renderStats()` counter instead (7 checks). Two traps that cost me a run and are now in `Development`: let the last click's trailing window expire before measuring, or its legitimate frames look like a leak; and measure on a tab whose picture is actually still. Harness screenshots ask for a frame first via `__gm.wake()`.
Ran locally: `npm run check` 1005 tests, the **full** e2e `ALL PASSED - 875 checks`, smokes 24/19/21. Full run rather than the affected module, because a change to the frame loop has no affected module.
**One thing left open, not covered by any test here:** whether a surface that is not redrawn for seconds stays stable on real hardware with our own `desynchronized` context and no `preserveDrawingBuffer` is up to the driver. Headless in the container does not answer that, and neither does the e2e run. `?vsync` rules it out immediately if it ever shows.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Editor nur bei Änderungen neu zeichnen, im Play weiter durchgehend.
Stand: ein stehendes Bild kostet im Editor gemessen 69 % eines Kerns (Lüfter, Akku). Seit dem Low-Latency-Modus (
desynchronized) verlängert ein Frame auf Abruf die Reaktionszeit nicht mehr.Abzuklären: was alles ein Neuzeichnen auslösen muss (Eingabe, Hover, Laden einer Textur, Perlen/Live-Werte im Graph während Play).
Übernommen aus
todo-notes/render-on-change.mdbeim Umzug auf das Issue-System (2026-09-12).bf41919— Add todo notes for the next round of features, gaps and upkeepAnmerkung: ich habe mitbekommen, dass ein redraw beim shader gezielt ausgelöst wird, vorallem wenn Time variable vorhanden. Ich weißt nicht wie relevant diese info ist. wenn es nichts mit dem issue zu tun hat, dann meine anmerkung hier ignorieren.
Implemented in
bc57f61, wiki in5f71a42.It pays off. Measured on the built stand, 20 s per variant, CPU of all eleven chromium processes read from
/proc, real GPU (ANGLE (Intel, Vulkan 1.3.230, HD Graphics 630, Mesa)), order probed by repeating the first variant last:So ~89 % off the idle cost, and the zero says nothing is drawn at all rather than drawn more cheaply. Splitting it further: the ticker itself costs ~3 points, drawing ~64, and ~9-12 % is browser floor that stays. The 69 % in the note is the same order of magnitude.
What it does.
initRenderLoop()insrc/ui/render.tstakesapp.renderoff the ticker; the ticker keeps running, so the clock, a live world and the panels still get their step.requestRender()opens a 200 ms trailing window rather than scheduling exactly one frame.The window is the point. Strict dirty tracking would have to be right at ~70 direct
redraw()calls that never pass throughinvalidate(), ~70 position mutations and ~70addChild/removeChild— that is the road to the bug you notice three days later. A trailing window also catches the repaint an event only starts: a microtask, a texture upload, a resolved font. The idle gain is the same, because after 200 ms it is quiet either way.Woken at the places everything passes through: pointer/wheel/keyboard once centrally on
windowininitUI,Widget.invalidate()/resize(),Stack.relayout(),EditorState.emit(), andtextureLibrary.onChange/fontLibrary.onChange.Editor.movesByItself()asks for every frame while Play or an editor run is on, a time shader is on screen, or the particle/animation preview is showing.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.
Testing. No colour check can see a missing repaint:
pixelAtextracts from the stage on demand (debugHook.ts) and is just as green on a screen frozen a minute ago — of 868 checks the 24 that read pixels are all blind here. Step 33render-on-changewatches the new__gm.renderStats()counter instead (7 checks). Two traps that cost me a run and are now inDevelopment: let the last click's trailing window expire before measuring, or its legitimate frames look like a leak; and measure on a tab whose picture is actually still. Harness screenshots ask for a frame first via__gm.wake().Ran locally:
npm run check1005 tests, the full e2eALL PASSED - 875 checks, smokes 24/19/21. Full run rather than the affected module, because a change to the frame loop has no affected module.One thing left open, not covered by any test here: whether a surface that is not redrawn for seconds stays stable on real hardware with our own
desynchronizedcontext and nopreserveDrawingBufferis up to the driver. Headless in the container does not answer that, and neither does the e2e run.?vsyncrules it out immediately if it ever shows.