On the WSL runner every __gm.pixelAt costs about 7 ms, three times as much as on stein2. Each call renders the whole stage into a fresh render texture and stalls until the GPU hands one pixel back. pixelRow and several checks read hundreds of pixels that way. Module 05 spent 36.8 of its 40 s on these reads, module 04 11.3 of 20 s. - stagePixels in src/ui/render.ts renders once around all points and reads them in one go. Only whole pixels at resolution 1 take that path. Anything else stays point by point. - The hook __gm.pixels(points) exposes it, and pixelRow uses it. - The loops in e2e 04, 05, 14 and 44 read their grids in one call. - The old comment in pixelRow said a wider frame comes back resampled. That is not true. The colours match exactly, so the comment is gone. Measured on the RTX (d3d12) and on stein2 (Intel, Vulkan): diff 0 at 10 680 points on each GPU. One screen row takes 4 to 18 ms instead of 2.4 to 9.9 s. The six affected modules need 32 s instead of 139 s under WSL, faster than on stein2. The rest of the gap is in Chromium's GPU process. perf puts three quarters of its time in the drivers (NVIDIA D3D12, Mesa d3d12, dxg). Four browser and Mesa settings were measured, none helped. Tests: - ./check.sh on melektaus-atx: 195 files, 2827 tests, CHECK EXIT 0 (+4) - e2e 1-16,30,37,44,58 under WSL: ALL PASSED, 587 checks - counter-check: a wrong index in stagePixels fails the unit test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
281 lines
8.0 KiB
TypeScript
281 lines
8.0 KiB
TypeScript
import type { Application } from 'pixi.js';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import {
|
||
afterFrames,
|
||
cancelFrameWork,
|
||
initRenderLoop,
|
||
isRendering,
|
||
onFrame,
|
||
renderCount,
|
||
requestRender,
|
||
resetRenderLoop,
|
||
stagePixel,
|
||
stagePixels,
|
||
} 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('runs work put off to a frame once, however often it was asked for', () => {
|
||
const { app, tick } = fakeApp();
|
||
initRenderLoop(app as unknown as Application);
|
||
now += 500;
|
||
|
||
let ran = 0;
|
||
for (let i = 0; i < 20; i++) onFrame('drag', () => (ran += 1));
|
||
expect(ran).toBe(0);
|
||
tick();
|
||
expect(ran).toBe(1);
|
||
|
||
// And it is gone afterwards: the next frame has nothing left to do.
|
||
tick();
|
||
expect(ran).toBe(1);
|
||
});
|
||
|
||
it('keeps the work of two keys apart, and the last one wins per key', () => {
|
||
const { app, tick } = fakeApp();
|
||
initRenderLoop(app as unknown as Application);
|
||
now += 500;
|
||
|
||
const ran: string[] = [];
|
||
onFrame('a', () => ran.push('a1'));
|
||
onFrame('a', () => ran.push('a2'));
|
||
onFrame('b', () => ran.push('b'));
|
||
tick();
|
||
expect(ran).toEqual(['a2', 'b']);
|
||
});
|
||
|
||
it('runs the work before the frame it is meant to be in', () => {
|
||
const { app, tick } = fakeApp();
|
||
initRenderLoop(app as unknown as Application);
|
||
now += 500;
|
||
|
||
let drawnWhenRun = -1;
|
||
onFrame('drag', () => (drawnWhenRun = app.drawn));
|
||
tick();
|
||
expect(drawnWhenRun).toBe(0);
|
||
expect(app.drawn).toBe(1);
|
||
});
|
||
|
||
it('wakes the loop itself, so work asked for by an idle editor is not lost', () => {
|
||
const { app, tick } = fakeApp();
|
||
initRenderLoop(app as unknown as Application);
|
||
now += 500;
|
||
tick();
|
||
expect(app.drawn).toBe(0);
|
||
|
||
let ran = 0;
|
||
onFrame('drag', () => (ran += 1));
|
||
tick();
|
||
expect(ran).toBe(1);
|
||
});
|
||
|
||
it('cancelled work never runs – a gesture that ended writes nothing more', () => {
|
||
const { app, tick } = fakeApp();
|
||
initRenderLoop(app as unknown as Application);
|
||
now += 500;
|
||
|
||
let ran = 0;
|
||
onFrame('drag', () => (ran += 1));
|
||
onFrame('other', () => (ran += 10));
|
||
cancelFrameWork('drag');
|
||
tick();
|
||
expect(ran).toBe(10);
|
||
});
|
||
|
||
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();
|
||
}
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A stage whose every pixel has a colour of its own (red = x, green = y, blue = x + y), read the
|
||
* way Pixi's extract hands it back: row by row across the frame, four bytes a pixel.
|
||
*/
|
||
function fakeStage(resolution = 1) {
|
||
const frames: { x: number; y: number; width: number; height: number }[] = [];
|
||
const app = {
|
||
stage: {},
|
||
renderer: {
|
||
resolution,
|
||
extract: {
|
||
pixels({ frame }: { frame: { x: number; y: number; width: number; height: number } }) {
|
||
frames.push({ x: frame.x, y: frame.y, width: frame.width, height: frame.height });
|
||
const pixels = new Uint8ClampedArray(frame.width * frame.height * 4);
|
||
for (let row = 0; row < frame.height; row++)
|
||
for (let col = 0; col < frame.width; col++) {
|
||
const x = Math.floor(frame.x) + col;
|
||
const y = Math.floor(frame.y) + row;
|
||
pixels.set([x, y, x + y, 255], (row * frame.width + col) * 4);
|
||
}
|
||
return { pixels, width: frame.width, height: frame.height };
|
||
},
|
||
},
|
||
},
|
||
};
|
||
return { app: app as unknown as Application, frames };
|
||
}
|
||
|
||
describe('stage pixels', () => {
|
||
const scattered = [
|
||
{ x: 40, y: 7 },
|
||
{ x: 3, y: 90 },
|
||
{ x: 40, y: 7 },
|
||
{ x: 120, y: 33 },
|
||
{ x: 3, y: 7 },
|
||
];
|
||
|
||
it('reads many points from one extract, each the colour stagePixel gives it', () => {
|
||
const one = fakeStage();
|
||
const alone = scattered.map((p) => stagePixel(one.app, p.x, p.y));
|
||
const batch = fakeStage();
|
||
expect(stagePixels(batch.app, scattered)).toEqual(alone);
|
||
expect(alone[1]).toBe('#035a5d');
|
||
expect(batch.frames).toEqual([{ x: 3, y: 7, width: 118, height: 84 }]);
|
||
});
|
||
|
||
it('keeps the point-by-point read for fractions, which would sample elsewhere in a shared frame', () => {
|
||
const { app, frames } = fakeStage();
|
||
stagePixels(app, [
|
||
{ x: 10, y: 10 },
|
||
{ x: 12.5, y: 10 },
|
||
]);
|
||
expect(frames.length).toBe(2);
|
||
});
|
||
|
||
it('keeps it at a resolution other than 1, where one pixel is a block of values', () => {
|
||
const { app, frames } = fakeStage(2);
|
||
stagePixels(app, scattered);
|
||
expect(frames.length).toBe(scattered.length);
|
||
});
|
||
|
||
it('answers a single point and no point at all', () => {
|
||
const { app } = fakeStage();
|
||
expect(stagePixels(app, [{ x: 5, y: 6 }])).toEqual(['#05060b']);
|
||
expect(stagePixels(app, [])).toEqual([]);
|
||
});
|
||
});
|