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
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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'));
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user