A middle-mouse pan used to run the full redraw on every frame. It collected every object from the project, cleared and redrew every shape, re-sorted all children and made Pixi re-upload every buffer, although only the camera had moved. The user saw the frame rate drop while panning. - WorldViewport gets moveCamera(). When zoom and view size are unchanged it only moves the world and redraws the overlays drawn in screen pixels. collectFrame, the shape redraw and the re-sort drop out of a pan frame. - Every full redraw remembers the box each object is culled by. A pan that moves an object across the edge of the view still takes the full redraw, so objects appear and vanish as before. - Grid, selection outlines, rays and the container origin mark move into a shared drawOnScreen hook. It runs after every full redraw and after every pan. The parent links stay in afterItems because they are drawn in world units. - Wheel zoom keeps the full redraw, because line widths follow the zoom. A cache for collectFrame was left out: it would save about 1 ms and risk stale data. - debugInfo() reports redraws and cameraMoves, and __gm gets a hook that forces a full redraw, so a check can compare against it. Findings: - Chrome already coalesces mouse moves to one per frame (500 moves gave 183 redraws in 189 frames). A throttle was tried and removed again, because its counter-check did not fail. - A check in module 03 compared the whole gameInfo() object and broke on the new counters. It now compares camera, objects and rendering. - The stall without ?vsync most likely comes from the desynchronized context in src/main.ts. It cannot be reproduced headless. Tests: - Measured on Jump and Run, main-thread time per frame, 3 runs of 240 moves: pan 5.7-7.4 ms before, 3.8-4.8 ms after. Plain mouse moves stay at 2.1-2.4 ms. 208 of 240 moves took the fast path. - ./check.sh: 141 files, 2080 tests, exit 0. - GM_STEPS=1-16,29,42,43: 535 checks, 1 failed (the module 03 test above). After the fix GM_STEPS=1-5: all passed, 130 checks. - 3 new e2e checks in 5d: 12 pan steps give 12 camera moves and 0 full redraws, pixel rows after the pan match a fresh redraw, a box panned out of view and back is drawn again. - Counter-checks: overlays not redrawn on pan gives "3 of 3 rows differ". Edge test disabled keeps the box visible with 0 redraws. - Check total 1285 (1282 confirmed by CI task 229, plus 3). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
6.9 KiB
JavaScript
158 lines
6.9 KiB
JavaScript
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}`);
|
||
|
||
// 5c. The symbols are SVG files (#175): one of each in a lane of its own, read where each has a
|
||
// solid colour of the theme. Offsets are in world units from the symbol's centre.
|
||
const PLACED = { 's-beh': 'mover', 's-box': 's-box-a', 's-rb': 's-rb-a', 's-cam': 'cam', 's-pt': 's-pt-a' };
|
||
const SPOTS = [
|
||
['s-beh', 8, 0, '#b066ff'],
|
||
['s-beh', -6, -3, '#ffffff'],
|
||
['s-box', -4, -4, '#3fb8c9'],
|
||
['s-box', 4, 4, '#3fb8c9'],
|
||
['s-rb', 0, 3, '#4fd18b'],
|
||
['s-rb', 0, -4, '#4fd18b'],
|
||
['s-cam', 0, 0, '#8a90a0'],
|
||
['s-cam', 17, 0, '#8a90a0'],
|
||
['s-pt', 12.728, 12.728, '#c26a1f'],
|
||
['s-pt', -12.728, -12.728, '#c26a1f'],
|
||
];
|
||
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 wrong = await json(`(() => {
|
||
const zoom = __gm.objectRect('s-beh').w / 28;
|
||
return ${JSON.stringify(SPOTS)}.flatMap(([id, dx, dy, want]) => {
|
||
const r = __gm.objectRect(id);
|
||
const got = __gm.pixelAt(Math.floor(r.x + dx * zoom), Math.floor(r.y + dy * zoom));
|
||
return got === want ? [] : [id + ' ' + dx + ',' + dy + ': ' + got + ' != ' + want];
|
||
});
|
||
})()`);
|
||
check(
|
||
'every symbol draws its SVG in the theme colour',
|
||
wrong.length === 0,
|
||
wrong.join('; ') || `${SPOTS.length} spots`,
|
||
);
|
||
await shot('15-symbols');
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => !${JSON.stringify(ids)}.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)`);
|
||
}
|