A still editor has nothing to draw, so the frame loop wakes on change (#44)
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:
admin committed 2026-09-14 21:41:50 +02:00
1 parent 2637feb715
commit bc57f61c91
16 files changed
+355 -7

No files matched your search

+5 -1
View File
@@ -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<void>
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();
}
+40 -4
View File
@@ -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<CentreTab>;
private readonly panes: Record<CentreTab, Widget>;
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);
}
+5
View File
@@ -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 {
+5
View File
@@ -27,6 +27,11 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, AnimationCan
this.canvas.tick(dt);
}
/** Whether the preview is running – a moving picture needs a frame without an event. */
get isPlaying(): boolean {
return this.canvas.playing;
}
protected override selectedId(): string {
return this.state.selectedAnimationId ?? '';
}
+9 -1
View File
@@ -4,7 +4,7 @@ import { resolveObjects } from '../core/model/instance';
import { findObjectHolder } from '../core/model/Project';
import type { FrameRect } from '../core/model/texture';
import { effectiveScale, gameClock } from '../core/time';
import { activeDrag, Button, ui, Widget } from '../ui';
import { activeDrag, Button, isRendering, renderCount, requestRender, ui, Widget } from '../ui';
import { particleLibrary } from '../viewport/ParticleLibrary';
import { shaderLibrary } from '../viewport/ShaderLibrary';
import { textureLibrary } from '../viewport/TextureLibrary';
@@ -450,6 +450,14 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
};
},
fps: () => 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;
+4
View File
@@ -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();
}