game-maker / suite (push) Successful in 6m48s
The suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.
The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.
Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.
564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.
Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
142 lines
3.7 KiB
TypeScript
142 lines
3.7 KiB
TypeScript
import type { Application } from 'pixi.js';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
afterFrames,
|
|
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);
|
|
});
|
|
|
|
it('afterFrames resolves only once that many frames were really drawn', async () => {
|
|
const { app, tick } = fakeApp();
|
|
initRenderLoop(app as unknown as Application);
|
|
now += 500;
|
|
|
|
let done = false;
|
|
const waited = afterFrames(2).then(() => (done = true));
|
|
|
|
tick();
|
|
await Promise.resolve();
|
|
expect(done).toBe(false);
|
|
tick();
|
|
await waited;
|
|
expect(done).toBe(true);
|
|
expect(app.drawn).toBe(2);
|
|
});
|
|
|
|
it('afterFrames wakes the loop itself, so an idle editor still answers', async () => {
|
|
const { app, tick } = fakeApp();
|
|
initRenderLoop(app as unknown as Application);
|
|
now += 500;
|
|
for (let i = 0; i < 5; i++) tick();
|
|
expect(app.drawn).toBe(0);
|
|
|
|
const waited = afterFrames(1);
|
|
tick();
|
|
await waited;
|
|
expect(app.drawn).toBe(1);
|
|
});
|
|
|
|
it('afterFrames gives up rather than hanging when no frame ever comes', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const { app } = fakeApp();
|
|
initRenderLoop(app as unknown as Application);
|
|
const waited = afterFrames(1, 50);
|
|
vi.advanceTimersByTime(60);
|
|
await waited;
|
|
expect(app.drawn).toBe(0);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|