From ffedd818035e860204c4506cf3d8282cf9ff21e5 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 29 Sep 2026 22:07:53 +0200 Subject: [PATCH] Read many pixels from one render in the e2e checks (#283) 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 --- src/editor/debug/renderHooks.ts | 13 +++--- src/ui/index.ts | 1 + src/ui/render.ts | 30 +++++++++++++- tests/ui/render.test.ts | 70 +++++++++++++++++++++++++++++++++ tools/e2e/04-assets.mjs | 20 ++++------ tools/e2e/05-drawing.mjs | 18 +++++---- tools/e2e/14-overflow.mjs | 7 ++-- tools/e2e/44-documents.mjs | 9 ++--- 8 files changed, 132 insertions(+), 36 deletions(-) diff --git a/src/editor/debug/renderHooks.ts b/src/editor/debug/renderHooks.ts index 1ff509fb..74b1492a 100644 --- a/src/editor/debug/renderHooks.ts +++ b/src/editor/debug/renderHooks.ts @@ -1,6 +1,6 @@ import { type FrameRect, isTextureFile, resolvePicture } from '../../core/model/texture'; import { effectiveScale, gameClock } from '../../core/time'; -import { afterFrames, isRendering, renderCount, requestRender, stagePixel } from '../../ui'; +import { afterFrames, isRendering, renderCount, requestRender, stagePixel, stagePixels } from '../../ui'; import { particleLibrary } from '../../viewport/ParticleLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { textureLibrary } from '../../viewport/TextureLibrary'; @@ -13,6 +13,8 @@ export function renderHooks({ app, state, editor }: HookContext) { const pixelAt = (x: number, y: number): string => stagePixel(app, x, y); return { pixelAt, + /** Many points from one render, each the colour `pixelAt` would give – for checks that sample hundreds. */ + pixels: (points: readonly { x: number; y: number }[]) => stagePixels(app, points), /** * A horizontal run of pixels in one read. A check that has to tell a repeated pattern from a * stretched one needs dozens of samples, and dozens of round trips would cost more than the @@ -20,11 +22,10 @@ export function renderHooks({ app, state, editor }: HookContext) { */ pixelRow: (x: number, y: number, count: number) => { const width = Math.max(1, Math.min(1024, Math.round(count))); - const row: string[] = []; - // One extract per pixel, not one strip: a wider frame comes back resampled, and a row that - // blends its neighbours cannot tell a hard border from a soft one – which is what it is for. - for (let i = 0; i < width; i++) row.push(pixelAt(x + i, y)); - return row; + return stagePixels( + app, + Array.from({ length: width }, (_, i) => ({ x: x + i, y })), + ); }, /** Every uploaded picture the project names and whether it is in memory, drawn anywhere or not. */ heldPictures: () => diff --git a/src/ui/index.ts b/src/ui/index.ts index 5236d926..830a93f1 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -13,6 +13,7 @@ export { requestRender, resetRenderLoop, stagePixel, + stagePixels, } from './render'; export { drawBand, diff --git a/src/ui/render.ts b/src/ui/render.ts index fb8c89a2..2f64fe43 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -132,6 +132,34 @@ export function resetRenderLoop(): void { /** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */ export function stagePixel(app: Application, x: number, y: number): string { const { pixels } = app.renderer.extract.pixels({ target: app.stage, frame: new Rectangle(x, y, 1, 1) }); + return colourAt(pixels, 0); +} + +/** + * The colours at many points from one render and one readback, in the order given – the same + * answers as `stagePixel` point by point. Each extract builds, fills and frees a render texture of + * its own and stalls until the GPU is done; a check reading hundreds of pixels paid that hundreds + * of times (25 s of one step behind WSL's d3d12 driver, where a single read costs ~7 ms). + * + * Only whole pixels at a resolution of 1 go through the shared frame. Anything else keeps the + * point-by-point read, whose sampling position this would otherwise have to reproduce. + */ +export function stagePixels(app: Application, points: readonly { x: number; y: number }[]): string[] { + const whole = points.every((p) => Number.isInteger(p.x) && Number.isInteger(p.y)); + if (points.length < 2 || !whole || app.renderer.resolution !== 1) + return points.map((p) => stagePixel(app, p.x, p.y)); + const left = Math.min(...points.map((p) => p.x)); + const top = Math.min(...points.map((p) => p.y)); + const width = Math.max(...points.map((p) => p.x)) - left + 1; + const height = Math.max(...points.map((p) => p.y)) - top + 1; + const { pixels } = app.renderer.extract.pixels({ + target: app.stage, + frame: new Rectangle(left, top, width, height), + }); + return points.map((p) => colourAt(pixels, ((p.y - top) * width + (p.x - left)) * 4)); +} + +function colourAt(pixels: ArrayLike, at: number): string { const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0'); - return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`; + return `#${hex(pixels[at])}${hex(pixels[at + 1])}${hex(pixels[at + 2])}`; } diff --git a/tests/ui/render.test.ts b/tests/ui/render.test.ts index e1196bb6..87d5040a 100644 --- a/tests/ui/render.test.ts +++ b/tests/ui/render.test.ts @@ -9,6 +9,8 @@ import { 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. */ @@ -208,3 +210,71 @@ describe('render loop', () => { } }); }); + +/** + * 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([]); + }); +}); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index b86cef06..9b730bde 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -338,13 +338,10 @@ export async function assets(t) { const MUTED = '#8a90a0'; const icons = await json(`(() => Object.fromEntries(__gm.assetGroups().map((g) => { const c = __gm.assetGroupIcon(g.type); - const background = __gm.pixelAt(c.x, c.y - 9); - let mask = ''; - for (let dy = -7; dy <= 7; dy++) - for (let dx = -7; dx <= 7; dx++) { - const p = __gm.pixelAt(c.x + dx, c.y + dy); - mask += p === '${MUTED}' ? '#' : p === background ? '.' : '+'; - } + const grid = []; + for (let dy = -7; dy <= 7; dy++) for (let dx = -7; dx <= 7; dx++) grid.push({ x: c.x + dx, y: c.y + dy }); + const [background, ...ink] = __gm.pixels([{ x: c.x, y: c.y - 9 }, ...grid]); + const mask = ink.map((p) => (p === '${MUTED}' ? '#' : p === background ? '.' : '+')).join(''); return [g.type, mask]; })))()`); const masks = Object.values(icons); @@ -430,11 +427,10 @@ export async function assets(t) { const iconAt = await json(`__gm.hierarchyIcon(${JSON.stringify(first)})`); const ink = await json(`(() => { const c = __gm.hierarchyIcon(${JSON.stringify(first)}); - const background = __gm.pixelAt(c.x - 12, c.y); - let n = 0; - for (let dy = -7; dy <= 7; dy++) - for (let dx = -7; dx <= 7; dx++) if (__gm.pixelAt(c.x + dx, c.y + dy) !== background) n++; - return n; + const grid = []; + for (let dy = -7; dy <= 7; dy++) for (let dx = -7; dx <= 7; dx++) grid.push({ x: c.x + dx, y: c.y + dy }); + const [background, ...ink] = __gm.pixels([{ x: c.x - 12, y: c.y }, ...grid]); + return ink.filter((p) => p !== background).length; })()`); const typeWords = (await json(`__gm.locateAll(${JSON.stringify(typeOfFirst)})`)).filter( (p) => Math.abs(p.y - rowAt.y) < 8, diff --git a/tools/e2e/05-drawing.mjs b/tools/e2e/05-drawing.mjs index 95894a95..6bd3b0dc 100644 --- a/tools/e2e/05-drawing.mjs +++ b/tools/e2e/05-drawing.mjs @@ -151,15 +151,17 @@ export async function drawing(t) { return ${JSON.stringify(ids)}.map((id) => { const icon = __gm.sceneIcon(id); const seen = { id, white: 0, outline: 0, old: [] }; + const grid = []; for (let dx = -12; dx <= 12; dx += 1.5) - for (let dy = -12; dy <= 12; dy += 1.5) { - const got = __gm.pixelAt(Math.floor(icon.screen.x + dx * zoom), Math.floor(icon.screen.y + dy * zoom)); - if (got === '#ffffff') seen.white++; - // The outline is thin, so at this zoom it is mostly an edge pixel blended into the - // background (#111216 and the like), rarely the exact #0b0c0f. - else if (near(got, '#0b0c0f', 20)) seen.outline++; - else if (old.includes(got)) seen.old.push(got); - } + for (let dy = -12; dy <= 12; dy += 1.5) + grid.push({ x: Math.floor(icon.screen.x + dx * zoom), y: Math.floor(icon.screen.y + dy * zoom) }); + for (const got of __gm.pixels(grid)) { + if (got === '#ffffff') seen.white++; + // The outline is thin, so at this zoom it is mostly an edge pixel blended into the + // background (#111216 and the like), rarely the exact #0b0c0f. + else if (near(got, '#0b0c0f', 20)) seen.outline++; + else if (old.includes(got)) seen.old.push(got); + } return seen; }); })()`); diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs index d090d13c..3bf2b667 100644 --- a/tools/e2e/14-overflow.mjs +++ b/tools/e2e/14-overflow.mjs @@ -76,10 +76,9 @@ export async function overflow(t) { // The fixed tabs carry an icon as well (#176); the Scene card shows it in front of its name. const sceneInk = await json(`(() => { const x0 = ${card.x} + 6, y = ${card.y + card.h / 2}; - let n = 0; - for (let dy = -7; dy <= 7; dy++) - for (let dx = 0; dx < 16; dx++) if (__gm.pixelAt(x0 + dx, y + dy) !== '${ACTIVE}') n++; - return n; + const grid = []; + for (let dy = -7; dy <= 7; dy++) for (let dx = 0; dx < 16; dx++) grid.push({ x: x0 + dx, y: y + dy }); + return __gm.pixels(grid).filter((p) => p !== '${ACTIVE}').length; })()`); const fixedIcons = styled.shown .filter((b) => ['scene', 'game'].includes(b.tab)) diff --git a/tools/e2e/44-documents.mjs b/tools/e2e/44-documents.mjs index d0dddfb2..26bd11a8 100644 --- a/tools/e2e/44-documents.mjs +++ b/tools/e2e/44-documents.mjs @@ -47,11 +47,10 @@ export async function documents(t) { // The icon's 16 px in front of the name, read against the tab's own background at its left edge. const iconInk = await json(`(() => { const x0 = ${placeA.x} + 6, y = ${placeA.y + placeA.h / 2}; - const background = __gm.pixelAt(${placeA.x} + 1, y); - let n = 0; - for (let dy = -7; dy <= 7; dy++) - for (let dx = 0; dx < 16; dx++) if (__gm.pixelAt(x0 + dx, y + dy) !== background) n++; - return n; + const grid = []; + for (let dy = -7; dy <= 7; dy++) for (let dx = 0; dx < 16; dx++) grid.push({ x: x0 + dx, y: y + dy }); + const [background, ...ink] = __gm.pixels([{ x: ${placeA.x} + 1, y }, ...grid]); + return ink.filter((p) => p !== background).length; })()`); check( 'a tab shows the icon of its kind, then the asset’s name – the kind no longer as a word',