Files
adminandClaude Opus 5.5 ffedd81803 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>
2026-09-29 22:07:53 +02:00

275 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
/** Draw order: symbols always above sprites, `z` above hierarchy order. */
export async function drawing(t) {
const { check, json, shot, click, drag, locate, typeText, press, undo, mutate, select, object } = t;
const orderOfCrates = () => json(`__gm.drawOrder().filter(id => id === 'o1' || id === 'o2')`);
// 5a. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate
// right on top of its icon → the icon must still be drawn above the sprite.
await mutate(`
const objs = __gm.state.activeScene.objects;
const cam = objs.splice(objs.findIndex(o => o.id === 'o3'), 1)[0];
objs.unshift(cam);
const crate = objs.find(o => o.id === 'o1');
crate.position.x = 0; crate.position.y = 0;
__gm.state.select(null)`);
const layers = await json(`[__gm.layerOf('o3'), __gm.layerOf('o1'), __gm.layerOf('o2')]`);
check(
'camera symbol is drawn in the overlay layer above sprites',
JSON.stringify(layers) === '["overlay","objects","objects"]',
JSON.stringify(layers),
);
await shot('13-symbol-on-top');
// 5b. Z-order: put both crates on the origin. With equal z the hierarchy order wins
// (o1 before o2 → o2 on top); raising o1's z draws it above o2; undo restores it.
await mutate(
`const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2'); o2.position.x = 0; o2.position.y = 0`,
);
check(
'equal z keeps hierarchy order',
JSON.stringify(await orderOfCrates()) === '["o1","o2"]',
JSON.stringify(await orderOfCrates()),
);
await select({ kind: 'object', holderId: 'scene1', objectId: 'o1' });
const zLabel = await locate('Z');
await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected
await typeText('5');
await press('Enter');
check(
'Z field sets position.z',
(await object('o1')).position.z === 5,
`z=${(await object('o1')).position.z}`,
);
check(
'higher z is drawn on top',
JSON.stringify(await orderOfCrates()) === '["o2","o1"]',
JSON.stringify(await orderOfCrates()),
);
await shot('14-z-order');
await undo();
check('undo restores z', (await object('o1')).position.z === 0, `z=${(await object('o1')).position.z}`);
// 5b-ii. Sort with parent (#232): o2 hangs under o1 at z -1, and a third crate stands before o1
// in the hierarchy. The fixture's crate is stored without the switch, so o2 first drops
// behind everything on 0; switched on in the inspector it is drawn right behind o1. Undo,
// and the scene is put back for the blocks below.
await mutate(`
const objs = __gm.state.activeScene.objects;
const o2 = objs.find(o => o.id === 'o2');
o2.parentId = 'o1'; o2.position.z = -1;
objs.splice(objs.findIndex(o => o.id === 'o1'), 0, { id: 'o-sort', assetId: 'crate', name: '', position: { x: 0, y: 0, z: 0 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} });`);
const sortOrder = () => json(`__gm.drawOrder().filter(id => ['o-sort', 'o1', 'o2'].includes(id))`);
const orderOff = await sortOrder();
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2' });
const sortRow = await json("__gm.inspectorField('Sort with parent')");
if (sortRow) await click(sortRow.x, sortRow.y);
const sortOverride = (await object('o2')).overrides.sortWithParent;
const orderOn = await sortOrder();
await undo();
const orderUndone = await sortOrder();
check(
'Sort with parent draws a child below 0 right behind its parent, and undo takes it back',
JSON.stringify(orderOff) === '["o2","o-sort","o1"]' &&
sortOverride === true &&
JSON.stringify(orderOn) === '["o-sort","o2","o1"]' &&
JSON.stringify(orderUndone) === JSON.stringify(orderOff),
JSON.stringify({ sortRow, orderOff, sortOverride, orderOn, orderUndone }),
);
await shot('14b-sort-with-parent');
await mutate(`
const objs = __gm.state.activeScene.objects;
const o2 = objs.find(o => o.id === 'o2');
o2.parentId = ''; o2.position.z = 0;
objs.splice(objs.findIndex(o => o.id === 'o-sort'), 1);
__gm.state.select(null)`);
// 5b-iii. A container sorted with its parent (#243): a container under o1 holds a crate on -1 in
// its template, a third crate stands before o1. Off, as a new container starts, the crate
// inside drops behind everything on 0; the container's own switch draws it right behind o1.
const INSIDE = 'o-cont~sort-in';
await mutate(`
const p = __gm.state.project;
const objs = __gm.state.activeScene.objects;
const make = (id, assetId, parentId, z) => ({ id, assetId, name: '', position: { x: 0, y: 0, z }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId, instanceEdits: {} });
p.assets.push({ id: 'sort-box', type: 'container', name: 'sort-box', props: { enabled: true }, objects: [make('sort-in', 'crate', '', -1)] });
objs.splice(objs.findIndex(o => o.id === 'o1'), 0, make('o-sort', 'crate', '', 0));
objs.push(make('o-cont', 'sort-box', 'o1', 0));`);
const boxOrder = () =>
json(`__gm.drawOrder().filter(id => ['o-sort', 'o1', ${JSON.stringify(INSIDE)}].includes(id))`);
const boxOff = await boxOrder();
await select({ kind: 'object', holderId: 'scene1', objectId: 'o-cont' });
const boxRow = await json("__gm.inspectorField('Sort with parent')");
if (boxRow) await click(boxRow.x, boxRow.y);
const boxOverride = (await object('o-cont')).overrides.sortWithParent;
const boxOn = await boxOrder();
await undo();
const boxUndone = await boxOrder();
check(
'Sort with parent on a container draws its content right behind the parent of the container, and undo takes it back',
JSON.stringify(boxOff) === `["${INSIDE}","o-sort","o1"]` &&
boxOverride === true &&
JSON.stringify(boxOn) === `["o-sort","${INSIDE}","o1"]` &&
JSON.stringify(boxUndone) === JSON.stringify(boxOff),
JSON.stringify({ boxRow, boxOff, boxOverride, boxOn, boxUndone }),
);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter(o => o.id !== 'o-sort' && o.id !== 'o-cont');
p.assets = p.assets.filter(a => a.id !== 'sort-box');
__gm.state.select(null)`);
// 5c. The symbols are the asset panel's icons, white in a dark outline and without a plate
// behind them: one of each in a lane of its own, the pixels read on a grid over each icon
// (±12 world units round its centre). Each must show white and the outline, and none of the
// colours the symbols had before.
const PLACED = { 's-beh': 'mover', 's-box': 's-box-a', 's-rb': 's-rb-a', 's-cam': 'cam', 's-pt': 's-pt-a' };
const ids = Object.keys(PLACED);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
p.assets.push(
{ id: 's-box-a', type: 'container', name: 's-box-a', props: { enabled: true }, objects: [] },
{ id: 's-rb-a', type: 'rigidbody', name: 's-rb-a', props: { bodyType: 'static' } },
{ id: 's-pt-a', type: 'particles', name: 's-pt-a', props: {}, graph: { nodes: [], edges: [] } },
);
Object.entries(${JSON.stringify(PLACED)}).forEach(([id, assetId], i) => {
const o = make(id, assetId, '', ${LANES.symbols}, id);
o.position.x = (i - 2) * 120;
scene.objects.push(o);
});
__gm.state.select(null);
__gm.lookAt(0, ${LANES.symbols});`);
const looks = await json(`(() => {
const zoom = __gm.objectRect('s-beh').w / 28;
const old = ['#b066ff', '#3fb8c9', '#4fd18b', '#c26a1f'];
const rgb = (c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
const near = (a, b, max) => Math.hypot(...rgb(a).map((v, i) => v - rgb(b)[i])) < max;
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)
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;
});
})()`);
const wrong = looks.filter((s) => s.white < 4 || s.outline < 4 || s.old.length > 0);
check(
'every symbol draws its icon white in a dark outline, in none of the old colours',
wrong.length === 0,
JSON.stringify(wrong.length ? wrong : looks.map((s) => `${s.id} ${s.white}/${s.outline}`)),
);
await shot('15-symbols');
// 5c-ii. Eight symbols on one spot stand on a ring, their names beside them on dark plates. Names
// that would cover each other go a row up or down instead – none overlaps another or an
// icon – and every name stays on its icon's side of the ring.
const crowd = Array.from({ length: 8 }, (_, k) => `s-crowd-${k}`);
await mutate(`
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
const assets = ${JSON.stringify(Object.values(PLACED))};
${JSON.stringify(crowd)}.forEach((id, k) => {
scene.objects.push(make(id, assets[k % assets.length], '', ${LANES.symbols}, 'crowded name ' + k));
});
__gm.lookAt(0, ${LANES.symbols});`);
const plates = await json(`(() => {
const crowd = ${JSON.stringify(crowd)};
const icons = crowd.map((id) => __gm.sceneIcon(id));
const labels = __gm.sceneLabels().filter((l) => crowd.includes(l.id));
return { icons: icons.map((i) => ({ x: i.x, y: i.y, size: i.size })), labels };
})()`);
const cross = (a, b) =>
a.x < b.x + b.w - 1e-6 && b.x < a.x + a.w - 1e-6 && a.y < b.y + b.h - 1e-6 && b.y < a.y + a.h - 1e-6;
const iconBoxes = plates.icons.map((i) => ({
x: i.x - i.size / 2,
y: i.y - i.size / 2,
w: i.size,
h: i.size,
}));
const clashes = [];
plates.labels.forEach((a, i) => {
plates.labels.slice(i + 1).forEach((b) => cross(a, b) && clashes.push(`${a.id}/${b.id}`));
iconBoxes.forEach((box, k) => cross(a, box) && clashes.push(`${a.id}/icon ${k}`));
});
const sides = plates.labels.filter((l) => {
const icon = plates.icons[crowd.indexOf(l.id)];
return l.left ? l.x + l.w > icon.x : l.x < icon.x;
});
check(
'names crowded on one spot stand on plates in rows of their own, on their icons’ side',
plates.labels.length === 8 && clashes.length === 0 && sides.length === 0,
`${plates.labels.length} plates, clashes ${JSON.stringify(clashes)}, wrong side ${JSON.stringify(sides.map((l) => l.id))}`,
);
await shot('15a-crowded-names');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => ![...${JSON.stringify(ids)}, ...${JSON.stringify(crowd)}].includes(o.id));
p.assets = p.assets.filter((a) => !['s-box-a', 's-rb-a', 's-pt-a'].includes(a.id));
__gm.lookAt(0, 0);`);
// 5d. A pan only moves the camera: no collect and no redraw of every shape per move. What it
// leaves on screen – the crate, its outline, the grid – must be what a full redraw draws, and
// an object panned back into view must be drawn again.
await select({ kind: 'object', holderId: 'scene1', objectId: 'o1' });
const vr = await json('__gm.viewportRect()');
const cx = Math.round(vr.x + vr.w / 2);
const cy = Math.round(vr.y + vr.h / 2);
const info = () => json('__gm.viewportInfo()');
const rows = () =>
json(`(() => {
const r = __gm.objectRect('o1');
const x = Math.round(r.x - r.w / 2 - 20);
return [r.y, r.y - r.h / 2 - 2, r.y + r.h / 2 + 2].map((y) => __gm.pixelRow(x, Math.round(y), Math.round(r.w + 40)).join());
})()`);
const sameAsRedraw = async () => {
const panned = await rows();
await json('(__gm.viewportRedraw(), true)');
const fresh = await rows();
return panned.filter((row, i) => row !== fresh[i]).length;
};
const before = await info();
await drag(cx, cy, cx + 36, cy + 24, { steps: 12, button: 'middle' });
const after = await info();
const moves = after.cameraMoves - before.cameraMoves;
const redraws = after.redraws - before.redraws;
check(
'a pan moves the camera without redrawing the scene',
moves >= 8 && redraws <= 4,
`${moves} camera moves, ${redraws} redraws in 12 steps`,
);
const offAfterPan = await sameAsRedraw();
check('a panned view draws what a full redraw draws', offAfterPan === 0, `${offAfterPan} of 3 rows differ`);
// Far enough that the crate's right edge passes the view's left one.
const crate = await json(`__gm.objectRect('o1')`);
const away = Math.round(crate.x + crate.w / 2 - vr.x + 20);
await drag(cx, cy, cx - away, cy, { steps: 12, button: 'middle' });
const gone = !(await json('__gm.drawOrder()')).includes('o1');
const back0 = await info();
// Pressed inside the view again: a press out over the panel would pan nothing.
await drag(cx, cy, cx + away, cy, { steps: 12, button: 'middle' });
const back = await info();
const shown = (await json('__gm.drawOrder()')).includes('o1');
const offAfterReturn = await sameAsRedraw();
check(
'an object panned out of view and back is drawn again',
gone && shown && back.redraws > back0.redraws && offAfterReturn === 0,
`gone=${gone} shown=${shown} redraws=${back.redraws - back0.redraws} rowsOff=${offAfterReturn}`,
);
await shot('15b-panned');
await mutate(`__gm.state.select(null); __gm.lookAt(0, 0)`);
}