A still editor has nothing to draw, so the frame loop wakes on change (#44)
game-maker / suite (push) Successful in 10m46s
game-maker / suite (push) Successful in 10m46s
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.
This commit is contained in:
1 parent
2637feb715
commit
bc57f61c91
16 files changed
+355
-7
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user