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',