diff --git a/src/editor/App.ts b/src/editor/App.ts index 4dcdb37f..48409332 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -2,7 +2,7 @@ import type { Application } from 'pixi.js'; import { HttpFontStore } from '../core/store/HttpFontStore'; import { HttpProjectStore } from '../core/store/HttpProjectStore'; import { HttpTextureStore } from '../core/store/HttpTextureStore'; -import type { VStack } from '../ui'; +import { requestRender, type VStack } from '../ui'; import { fontLibrary } from '../viewport/FontLibrary'; import { textureLibrary } from '../viewport/TextureLibrary'; import { installDebugHook } from './debugHook'; @@ -31,6 +31,10 @@ export async function startEditor(app: Application, root: VStack): Promise new HttpTextureStore(API_ENDPOINT), new HttpFontStore(API_ENDPOINT), ); + // An image or a font arrives long after the click that asked for it, and the widget waiting for + // it repaints without telling anyone: both libraries are a wake-up of their own. + textureLibrary.onChange(() => requestRender()); + fontLibrary.onChange(() => requestRender()); installDebugHook(app, state, editor); await editor.boot(); } diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 0d1d7ce8..a849bdaa 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -5,7 +5,19 @@ import type { ProjectStore } from '../core/store/ProjectStore'; import type { FontStore } from '../core/store/FontStore'; import type { TextureStore } from '../core/store/TextureStore'; import { gameClock } from '../core/time'; -import { clamp, HStack, Panel, Splitter, Theme, ui, VStack, type Widget } from '../ui'; +import { + clamp, + HStack, + isRendering, + Panel, + renderCount, + requestRender, + Splitter, + Theme, + ui, + VStack, + type Widget, +} from '../ui'; import { TabStrip, type TabPlace } from './TabStrip'; import { ConsolePanel } from './console/ConsolePanel'; import { GameViewport } from './viewport/GameViewport'; @@ -125,6 +137,7 @@ export class Editor implements PlayHost { private readonly tabs: TabStrip; private readonly panes: Record; private readonly toolbar: Toolbar; + /** Drawn-frame count at the last measurement, not a counter of its own. */ private fpsFrames = 0; private fpsElapsed = 0; private leftWidth = 250; @@ -331,14 +344,37 @@ export class Editor implements PlayHost { this.console.refresh(); if (this.tab === 'animation') this.animationEditor.tick(step); if (this.tab === 'particles') this.particlesEditor.tick(); + if (this.movesByItself()) requestRender(); } + /** + * What changes without anyone touching it, and therefore cannot be woken by an event: the + * running world, a Time shader on screen, the two previews that are moving pictures. + */ + private movesByItself(): boolean { + return ( + this.play.isPlaying || + this.editorRun.isRunning || + (this.viewport.visible && this.viewport.animates) || + (this.gameView.visible && this.gameView.animates) || + this.tab === 'particles' || + (this.tab === 'animation' && this.animationEditor.isPlaying) + ); + } + + /** + * Counts drawn frames over the time the renderer was awake. Idle seconds are not slow seconds: + * since the editor stopped drawing what nobody changed, counting them would drag the readout to + * zero while everything is fine. It answers "how smooth is it while it moves", and holds its + * last answer in between. + */ private measureFps(dt: number): void { - this.fpsFrames += 1; + if (!isRendering()) return; this.fpsElapsed += dt; if (this.fpsElapsed < 0.5) return; - this.fps = Math.round(this.fpsFrames / this.fpsElapsed); - this.fpsFrames = 0; + const drawn = renderCount(); + this.fps = Math.round((drawn - this.fpsFrames) / this.fpsElapsed); + this.fpsFrames = drawn; this.fpsElapsed = 0; this.toolbar.setFps(this.fps); } diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index 9ea6e1f4..24d30c0f 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -80,6 +80,11 @@ export class EditorRun { return findAsset(this.state.project, selection.assetId)?.type === 'variables'; } + /** Whether a world is up – what tells the editor its picture moves on its own. */ + get isRunning(): boolean { + return this.world !== null; + } + /** Facts for the check scripts: object ids of the behaviors currently running without Play. */ debugInfo(): { running: boolean; time: number; behaviors: string[]; keyboard: boolean } { return { diff --git a/src/editor/animation/AnimationEditor.ts b/src/editor/animation/AnimationEditor.ts index be4902cc..950d4aac 100644 --- a/src/editor/animation/AnimationEditor.ts +++ b/src/editor/animation/AnimationEditor.ts @@ -27,6 +27,11 @@ export class AnimationEditor extends AssetTabEditor editor.fps, + /** + * What the render loop did: frames actually drawn, and whether it is currently awake. The + * only way to see a missing repaint from a check script – `pixelAt` re-renders on demand and + * would answer correctly even if the screen had been frozen for a minute. + */ + renderStats: () => ({ drawn: renderCount(), awake: isRendering() }), + /** Draw again, whatever happens next – what a screenshot needs before it is taken. */ + wake: () => requestRender(), /** Visible nodes carrying a filter – one extra render pass each; since #77 a shader is none. */ filterCount: () => { let n = 0; diff --git a/src/editor/state.ts b/src/editor/state.ts index 49fb415a..700a76c4 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -13,6 +13,7 @@ import { type Project, } from '../core/model/Project'; import { deserializeProject, serializeProject } from '../core/store/serialize'; +import { requestRender } from '../ui'; import { consoleLog } from './console/log'; export type Selection = @@ -128,6 +129,9 @@ export class EditorState { } emit(event: EditorEvent): void { + // The one choke point every model change passes: what a listener repaints is drawn even when + // it repaints without going through `invalidate()`. + requestRender(); for (const listener of this.listeners.get(event) ?? []) listener(); } diff --git a/src/main.ts b/src/main.ts index 72aec8d5..a678e080 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,7 @@ import { Application, Container } from 'pixi.js'; import { captureConsole } from './editor/console/log'; import { startEditor } from './editor/App'; import { mountPlayground } from './playground'; -import { initUI, Theme, VStack } from './ui'; +import { initRenderLoop, initUI, Theme, VStack } from './ui'; // Before anything else: a renderer warns while it is being created, and those warnings are // exactly the ones worth having in the panel. @@ -45,6 +45,8 @@ async function main(): Promise { app.stage.eventMode = 'static'; app.stage.hitArea = app.screen; initUI(app, root, overlay); + // From here on the picture is drawn when something changes, not sixty times a second. + initRenderLoop(app); if (params.has('playground')) mountPlayground(app, root); else await startEditor(app, root); diff --git a/src/ui/UIContext.ts b/src/ui/UIContext.ts index 1aa54b00..7b7fe32a 100644 --- a/src/ui/UIContext.ts +++ b/src/ui/UIContext.ts @@ -1,6 +1,7 @@ import type { Application, Container } from 'pixi.js'; import { FocusManager } from './FocusManager'; import { KeyboardBridge } from './KeyboardBridge'; +import { requestRender } from './render'; /** Services shared by all widgets, created once by `initUI()`. */ export interface UIContext { @@ -22,6 +23,15 @@ export function initUI(app: Application, root: Container, overlay: Container): U // Clicking the canvas must not steal DOM focus from the keyboard bridge input. app.canvas.addEventListener('mousedown', (e) => e.preventDefault()); app.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + // Anything the user does may change the picture, and in far more places than the ones that + // remember to invalidate: one wake-up at the door covers all of them (see `render.ts`). + // Listened for on the window rather than on the stage: a release or a key that arrives while + // the pointer sits outside the canvas still ends a drag or an edit. + const wake = () => requestRender(); + for (const type of ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'wheel'] as const) + window.addEventListener(type, wake, { capture: true, passive: true }); + for (const type of ['keydown', 'keyup', 'focus', 'blur'] as const) + window.addEventListener(type, wake, true); return context; } diff --git a/src/ui/Widget.ts b/src/ui/Widget.ts index f8e7d051..4379d424 100644 --- a/src/ui/Widget.ts +++ b/src/ui/Widget.ts @@ -1,4 +1,5 @@ import { Container, Rectangle } from 'pixi.js'; +import { requestRender } from './render'; export interface Size { w: number; @@ -44,6 +45,7 @@ export abstract class Widget extends Container { this.hitArea = new Rectangle(0, 0, w, h); if (changed) this.layout(); this.redraw(); + requestRender(); } /** Position children. Called after a resize; must be cheap and idempotent. */ @@ -53,6 +55,7 @@ export abstract class Widget extends Container { /** Request a repaint at the end of the current frame (coalesced). */ invalidate(): void { + requestRender(); if (this.dirty) return; this.dirty = true; queueMicrotask(() => { diff --git a/src/ui/index.ts b/src/ui/index.ts index 66b1bd6f..46f1dfcb 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -2,6 +2,7 @@ export { Widget, Box, type Size } from './Widget'; export { HStack, VStack, Spacer, Stack, type StackOptions } from './layout/Stack'; export { Theme, textStyle, type TextStyleToken } from './Theme'; export { initUI, ui, type UIContext } from './UIContext'; +export { initRenderLoop, isRendering, renderCount, requestRender, resetRenderLoop } from './render'; export { drawBand, drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw'; export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math'; export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag'; diff --git a/src/ui/layout/Stack.ts b/src/ui/layout/Stack.ts index 333fa773..4aeafd01 100644 --- a/src/ui/layout/Stack.ts +++ b/src/ui/layout/Stack.ts @@ -1,3 +1,4 @@ +import { requestRender } from '../render'; import { type Size, Widget } from '../Widget'; export interface StackOptions { @@ -92,6 +93,8 @@ export abstract class Stack extends Widget { relayout(): void { this.layout(); this.redraw(); + // A stack that only moves children around resizes none of them, so this is its own wake-up. + requestRender(); } } diff --git a/src/ui/render.ts b/src/ui/render.ts new file mode 100644 index 00000000..4681c3ed --- /dev/null +++ b/src/ui/render.ts @@ -0,0 +1,63 @@ +import { type Application, UPDATE_PRIORITY } from 'pixi.js'; + +/** + * Drives the renderer from changes instead of from the clock. + * + * An idle editor at 60 fps costs ~75 % of a core, and ~64 of those points are the drawing alone – + * a picture nobody asked to change. The ticker keeps running (~3 points): the clock, a running + * world and the panels still get their frame, only `app.render()` is skipped while nothing asked + * for it. + * + * What wakes it is deliberately not a strict dirty flag. Every pointer and keyboard event, every + * `Widget.invalidate()`/`resize()`, every `EditorState.emit()` and every texture or font arriving + * opens a short trailing window rather than requesting exactly one frame. A repaint that lands a + * few milliseconds after its cause – a microtask, a texture upload, a font resolving – is drawn + * without anyone having had to declare it, which is the failure this design refuses to have: + * a missing repaint is invisible to the check scripts, because `pixelAt` re-renders on demand. + */ + +/** How long one wake-up keeps drawing. Long enough for the late repaint, short enough to idle. */ +const WAKE_MS = 200; + +let deadline = 0; +let rendered = 0; + +/** Draw, and keep drawing for a moment. Cheap enough to call from every event. */ +export function requestRender(): void { + deadline = performance.now() + WAKE_MS; +} + +/** Whether the renderer is currently awake. */ +export function isRendering(): boolean { + return performance.now() <= deadline; +} + +/** Frames actually drawn since the loop was installed – the fps readout and the checks read it. */ +export function renderCount(): number { + return rendered; +} + +/** + * Takes the renderer off the ticker and drives it from `requestRender()` instead. Added at the + * ticker's lowest priority, so everything that may request a frame has run for this tick already. + */ +export function initRenderLoop(app: Application): void { + // `TickerPlugin` registers `Application.prototype.render` bound to the app; same pair to remove. + app.ticker.remove(app.render, app); + app.ticker.add( + () => { + if (!isRendering()) return; + rendered += 1; + app.render(); + }, + undefined, + UPDATE_PRIORITY.LOW, + ); + requestRender(); +} + +/** Tests only: back to a cold loop. */ +export function resetRenderLoop(): void { + deadline = 0; + rendered = 0; +} diff --git a/tests/ui/render.test.ts b/tests/ui/render.test.ts new file mode 100644 index 00000000..c9935c2e --- /dev/null +++ b/tests/ui/render.test.ts @@ -0,0 +1,96 @@ +import type { Application } from 'pixi.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + initRenderLoop, + isRendering, + renderCount, + requestRender, + resetRenderLoop, +} from '../../src/ui/render'; + +/** A stand-in for the Pixi application: counts draws and remembers what was put on the ticker. */ +function fakeApp() { + const added: { fn: () => void; priority?: number }[] = []; + const removed: { fn: unknown; context: unknown }[] = []; + const app = { + drawn: 0, + render(): void { + app.drawn += 1; + }, + ticker: { + add(fn: () => void, _context?: unknown, priority?: number) { + added.push({ fn, priority }); + return this; + }, + remove(fn: unknown, context?: unknown) { + removed.push({ fn, context }); + return this; + }, + }, + }; + return { app, added, removed, tick: () => added.forEach((entry) => entry.fn()) }; +} + +describe('render loop', () => { + let now = 0; + + beforeEach(() => { + now = 1000; + vi.spyOn(performance, 'now').mockImplementation(() => now); + resetRenderLoop(); + }); + + afterEach(() => vi.restoreAllMocks()); + + it('is asleep until something asks for a frame', () => { + expect(isRendering()).toBe(false); + }); + + it('stays awake for a window after the request, not for a single frame', () => { + requestRender(); + expect(isRendering()).toBe(true); + now += 199; + expect(isRendering()).toBe(true); + now += 2; + expect(isRendering()).toBe(false); + }); + + it('a later request extends the window rather than queueing a second one', () => { + requestRender(); + now += 150; + requestRender(); + now += 150; + expect(isRendering()).toBe(true); + }); + + it('takes the renderer off the ticker and drives it itself', () => { + const { app, removed, tick } = fakeApp(); + initRenderLoop(app as unknown as Application); + expect(removed).toEqual([{ fn: app.render, context: app }]); + + // `initRenderLoop` opens the first window, so the first frames are drawn. + tick(); + tick(); + expect(app.drawn).toBe(2); + expect(renderCount()).toBe(2); + }); + + it('draws nothing while nothing changed, and again after a request', () => { + const { app, tick } = fakeApp(); + initRenderLoop(app as unknown as Application); + now += 500; + + for (let i = 0; i < 30; i++) tick(); + expect(app.drawn).toBe(0); + expect(renderCount()).toBe(0); + + requestRender(); + tick(); + expect(app.drawn).toBe(1); + + // And it falls asleep again by itself. + now += 500; + tick(); + expect(app.drawn).toBe(1); + }); +}); diff --git a/tools/e2e/33-render-on-change.mjs b/tools/e2e/33-render-on-change.mjs new file mode 100644 index 00000000..78607586 --- /dev/null +++ b/tools/e2e/33-render-on-change.mjs @@ -0,0 +1,102 @@ +import { openTab } from './lib/scaffold.mjs'; + +/** + * Render on change: the editor draws what changed, not sixty pictures a second. + * + * This is the one thing the rest of the suite cannot see. `pixelAt` re-renders the stage on + * demand, so every colour check would pass just as well on a screen that froze a minute ago – + * only the drawn-frame counter tells the difference between "drawn" and "merely correct". + * + * The step runs last and leaves the editor as it found it. + */ +export async function renderOnChange(t) { + const { check, sleep, evaluate, select, click, locate, play, stop, shot } = t; + + const stats = () => evaluate('__gm.renderStats()'); + /** Frames drawn over `ms` of doing nothing at all. */ + const drawnDuring = async (ms) => { + const before = await stats(); + await sleep(ms); + const after = await stats(); + return after.drawn - before.drawn; + }; + /** + * Waits for the trailing window of the last event to run out. Measuring without this counts the + * frames the previous click is still entitled to and calls them a leak. + */ + const settle = async (timeoutMs = 3000) => { + for (let waited = 0; waited < timeoutMs; waited += 100) { + if (!(await stats()).awake) return true; + await sleep(100); + } + return false; + }; + + // 33a. A tab whose picture stands still: the scene viewport may wear a Time shader, and the + // particles and animation previews are moving pictures by definition. + await openTab(t, 'graph'); + await select(null); + + const quiet = await settle(); + const idle = await drawnDuring(700); + check( + 'an editor nobody touches draws no frames at all', + quiet && idle === 0, + `settled=${quiet}, ${idle} frames drawn`, + ); + + // 33b. A model change that arrives without any input – no click, no key – still reaches the + // screen. This is the wake-up that a strict dirty flag on the widgets would have missed. + const beforeChange = (await stats()).drawn; + await evaluate(`__gm.state.setNotice('render check', 'info')`); + await sleep(150); + const afterChange = (await stats()).drawn; + check( + 'a change nobody clicked for is drawn anyway', + afterChange > beforeChange, + `${beforeChange} → ${afterChange}`, + ); + + // 33c. Input wakes it, and it goes back to sleep on its own afterwards. + await settle(); + const beforeClick = (await stats()).drawn; + const spot = await locate('Behavior'); + await click(spot.x, spot.y); + const afterClick = (await stats()).drawn; + check('a click draws again', afterClick > beforeClick, `${beforeClick} → ${afterClick}`); + + const settled = await settle(); + const backToSleep = await drawnDuring(700); + check( + 'and it stops on its own once the click is dealt with', + settled && backToSleep === 0, + `settled=${settled}, ${backToSleep} frames`, + ); + + // 33d. While the game runs, the frame cannot come from an event: the world moves by itself. + await play(); + const playing = await drawnDuring(600); + const fps = await evaluate('__gm.fps()'); + check( + 'a running game keeps getting frames without being touched', + playing > 10, + `${playing} frames in 600 ms`, + ); + check('and the fps readout keeps measuring them', typeof fps === 'number' && fps > 0, `fps=${fps}`); + await shot('01-playing'); + await stop(); + + // 33e. Stopping ends the reason to draw. Back off the scene tab first: a Time shader left on + // screen by an earlier step is a legitimate reason to keep drawing, and would mask this check. + await openTab(t, 'graph'); + const stopped = await settle(); + const afterStop = await drawnDuring(700); + check( + 'stopping puts it back to sleep', + stopped && afterStop === 0, + `settled=${stopped}, ${afterStop} frames`, + ); + + await evaluate(`__gm.state.setNotice('', 'info')`); + await sleep(200); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 37e5d87f..1bd7ba7f 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -35,6 +35,7 @@ import { transform } from './29-transform.mjs'; import { font } from './30-font.mjs'; import { particles } from './31-particles.mjs'; import { scope } from './32-scope.mjs'; +import { renderOnChange } from './33-render-on-change.mjs'; export const STEPS = [ hierarchy, @@ -69,4 +70,5 @@ export const STEPS = [ font, particles, scope, + renderOnChange, ]; diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index e5def6be..aea216ac 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -141,6 +141,10 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { return p; }; const shot = async (name) => { + // The editor only draws on change, so a screenshot asks for one frame first: without it the + // shot shows whatever the compositor still holds, which is right but needlessly fragile. + await evaluate('window.__gm?.wake()').catch(() => {}); + await sleep(50); const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }); writeFileSync(`/tmp/${shotPrefix}-${name}.png`, Buffer.from(data, 'base64')); };