The render loop is a thing you can get wrong invisibly, so it gets written down (#44)

admin committed 2026-09-14 21:41:54 +02:00
1 parent e0eb5c5089
commit 5f71a420bc
2 files changed
+38 -3

No files matched your search

+26 -2
@@ -90,8 +90,32 @@ api/ PHP backend: index.php (routing), lib/{Store,TextureStore
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/, assets/ (the walk demo's sheet)
```
The toolbar's right edge shows the frame rate (frames counted over a 0.5 s window, updated
twice a second) in edit and play mode alike; `__gm.fps()` exposes the same number to tests.
**The picture is drawn when something changes, not sixty times a second** (`src/ui/render.ts`).
An idle editor on the ticker costs ~75 % of a core, and ~64 of those points are the drawing of a
picture nobody changed. `initRenderLoop()` takes `app.render` off the ticker; the ticker itself
keeps running (~3 points), so the clock, a running world and the panels still get their frame —
only the drawing is skipped. Idle then costs ~10 % of a core, which is the browser's own floor.
What wakes it is deliberately **not** a strict dirty flag. `requestRender()` opens a short
trailing window (200 ms) rather than asking for exactly one frame, and it is called from the few
places everything passes through: every pointer, wheel and keyboard event (once, on the window,
in `initUI`), `Widget.invalidate()` and `Widget.resize()`, `Stack.relayout()`, `EditorState.emit()`,
and a texture or font arriving. A repaint that lands a few milliseconds after its cause — a
microtask, a texture upload, a font resolving, one of the 70-odd direct `redraw()` calls that
never went through `invalidate()` — is drawn without anyone having had to declare it. What moves
by itself cannot be woken by an event, so `Editor.tick` asks for the next frame for as long as
play or an editor run is on, a Time shader is on screen, or the particles or animation preview is
showing (`movesByItself()`).
The reason for the trailing window rather than the exact frame: **a missing repaint is invisible
to the checks.** `__gm.pixelAt` re-renders the stage on demand, so every colour check would pass
just as well on a screen that froze a minute ago. The drawn-frame counter is the only witness —
`__gm.renderStats()` gives it, and step 33 is the check that the editor really does go quiet and
really does wake up again.
The toolbar's right edge shows the frame rate in edit and play mode alike; `__gm.fps()` exposes
the same number to tests. It counts **drawn** frames over the time the renderer was **awake**:
idle seconds are not slow seconds, so the readout answers "how smooth is it while it moves" and
holds its last answer while nothing needs drawing, instead of falling to zero when all is well.
Rules that keep it organised: `core/` imports nothing from other layers; `ui/` never sees
assets; position exists only on scene objects; overrides are merged in exactly one place
+12 -1
@@ -64,7 +64,7 @@ The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, asse
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable,
variable nodes, property nodes, animations, state machines, console, shaders, transforms, fonts,
particles, shared scope) that build on each other's
particles, shared scope, render on change) that build on each other's
scene state and share the CDP driver in `tools/lib/harness.mjs` — `press`, `undo`, `play`,
`object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it
exercises.
@@ -80,6 +80,17 @@ nothing. A step picks a type with `pickAssetType(t, 'Font')` from the same file,
first. The fourteenth type (Variables) pushed `Font` under that edge and took the font step down,
because only the particles step had carried the workaround of its own.
**What no colour check can see: whether anything was drawn at all.** `__gm.pixelAt` re-renders the
stage on demand, so it answers correctly on a screen that has been frozen for a minute — which is
exactly the failure mode of drawing on change instead of every frame (see *Architecture*). The
drawn-frame counter in `__gm.renderStats()` is the witness, and the render-on-change step is the
only place the suite looks at it. Two rules there, both learned the hard way: measure idleness only
after waiting for the trailing window of the last event to run out (otherwise the frames the
previous click is still entitled to look like a leak), and take a step that does its measuring on a
tab whose picture stands still — the scene viewport may be wearing a Time shader, and the particles
and animation previews are moving pictures by definition. Screenshots ask for a frame first
(`__gm.wake()` in `harness.mjs`) rather than trusting what the compositor still holds.
**A check that demands an exact number must be handed the number, not something it can be derived
from.** The particle preview used to hold second 0.5 by computing the clock reading that *would* mean
0.5 since the field started; the round trip through floating point misses by the last bit about one