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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 22:07:53 +02:00
1 parent 857e23c69b
commit ffedd81803
8 files changed
+132 -36

No files matched your search

+8 -12
View File
@@ -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,
+10 -8
View File
@@ -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;
});
})()`);
+3 -4
View File
@@ -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))
+4 -5
View File
@@ -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',