diff --git a/Architecture.md b/Architecture.md index 1549069..eaadcc4 100644 --- a/Architecture.md +++ b/Architecture.md @@ -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 diff --git a/Development.md b/Development.md index 88cf56a..8cbf9a5 100644 --- a/Development.md +++ b/Development.md @@ -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