Keep the texture tab smooth with many collision outlines (#220)
game-maker / gate (push) Successful in 3m0s
game-maker / suite (push) Failing after 8m37s

With a large sheet the texture tab stalled on every mouse move. A
profile over a pan with 1728 frames showed two causes. Half of the time
went into convexPieces, which split every outline into convex pieces
only to pick its colour. And every pointer move during a pan rebuilt
and re-tessellated the whole overlay geometry.

- TextureCanvas draws on two layers. `sheet` holds every frame except
  the selected one and is rebuilt only when the zoom, the selection or
  one of those frames changes. `overlay` holds the selected frame, its
  handles and the rubber band of a new frame.
- Both layers are drawn without the pan. A pan only moves the
  container and places the name labels again.
- SheetCanvas has a new hook `panned()`. By default it calls `redraw()`
  as before, so Pixel Art and Vector Art are unchanged.
- The validity of an outline is remembered per outline and comes from
  polygonProblem, with the same result. This works because the model
  replaces an outline list on every change and never edits it in
  place, the same as frameOutline.ts already relies on.
- The name plates stay below the outlines and the texts on top. Checked
  on a screenshot: the corner handle next to a label sits on top.
- New debug hook `__gm.texture.drawInfo()` with the rebuild count, the
  outline count and the screen positions of outlines and names.

Measured with a throwaway probe, 1728 frames, 120 moves, main thread
time per move, before and after in the same browser:

- Middle-click pan: 76.9 ms -> 4.7 ms (4.4 ms without frames)
- Dragging a frame: 51.7 ms -> 10.1 ms
- Wheel zoom: 52.2 ms -> 30.9 ms
- Selecting another frame: 67.6 ms -> 41.0 ms

Findings:

- Wheel zoom and selection still rebuild `sheet` once on purpose. That
  stays a single hitch per notch or click on very large sheets.

Tests:

- ./check.sh: 148 files, 2214 tests, CHECK EXIT 0.
- e2e 1-16,37,39-43 on the final build: ALL PASSED, 641 checks.
- Three new checks in 42c-ii. A corner drag does not rebuild the other
  frames. An undo on an unselected frame reaches the picture (1 -> 2
  outlines). A pan moves outlines and names by exactly 40/25 px without
  a rebuild.
- Counter-checks: a missing outline in the cache key and a pan that
  does not move each fail at their check. The selected frame in the
  key fails alone with 9 rebuilds during an 8-step drag.
- Check total 1333 -> 1336 (confirmed by CI at ceaa44e, task 259).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 15:57:13 +02:00
1 parent ceaa44e5b8
commit fd6af84474
5 files changed
+179 -46

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1333 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1336 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+2
View File
@@ -15,6 +15,8 @@ export function documentHooks({ state, editor }: HookContext) {
rect: () => textureEditor.canvasRect(), rect: () => textureEditor.canvasRect(),
/** Whether a press edits the selected frame's collision outline (#141). */ /** Whether a press edits the selected frame's collision outline (#141). */
collisionMode: () => textureEditor.canvas.collisionMode, collisionMode: () => textureEditor.canvas.collisionMode,
/** Rebuilds of the unselected frames' marks, their outlines, and where marks and names sit (#220). */
drawInfo: () => textureEditor.canvas.drawInfo,
/** The open "Generate collision…" dialog (#161), or null. */ /** The open "Generate collision…" dialog (#161), or null. */
generateDialog: () => textureEditor.generateDialog.current()?.info ?? null, generateDialog: () => textureEditor.generateDialog.current()?.info ?? null,
}, },
+5
View File
@@ -117,6 +117,11 @@ export abstract class SheetCanvas<A> extends Widget {
return; return;
} }
this.panTo(panning.panX + (local.x - panning.pointer.x), panning.panY + (local.y - panning.pointer.y)); this.panTo(panning.panX + (local.x - panning.pointer.x), panning.panY + (local.y - panning.pointer.y));
this.panned();
}
/** The view moved without zooming; a tab that can shift what it drew needs no full redraw. */
protected panned(): void {
this.redraw(); this.redraw();
} }
+121 -45
View File
@@ -5,7 +5,7 @@ import {
SetFrameRectCommand, SetFrameRectCommand,
SetFrameSliceCommand, SetFrameSliceCommand,
} from '../../core/commands/frameCommands'; } from '../../core/commands/frameCommands';
import { convexPieces } from '../../core/model/polygon'; import { polygonProblem } from '../../core/model/polygon';
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset'; import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
import { textOf, type Vec2 } from '../../core/model/Properties'; import { textOf, type Vec2 } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project'; import { findAsset } from '../../core/model/Project';
@@ -54,6 +54,7 @@ import {
imageToLocal, imageToLocal,
localToImage, localToImage,
rectBetween, rectBetween,
type SheetView,
} from './sheetView'; } from './sheetView';
/** Side of a resize handle in screen pixels – constant, so handles stay grabbable at any zoom. */ /** Side of a resize handle in screen pixels – constant, so handles stay grabbable at any zoom. */
@@ -108,9 +109,20 @@ interface NewFrame {
*/ */
export class TextureCanvas extends SheetCanvas<TextureAsset> { export class TextureCanvas extends SheetCanvas<TextureAsset> {
private readonly image = new Sprite(); private readonly image = new Sprite();
/**
* Outlines and handles, drawn with the pan left out so that a pan only moves them (#220). `sheet`
* holds every frame but the selected one and is only redrawn when one of those changes; `overlay`
* holds what a gesture changes.
*/
private readonly marks = new Container();
private readonly sheet = new Graphics();
private readonly overlay = new Graphics(); private readonly overlay = new Graphics();
private sheetKey: unknown[] = [];
private sheetBuilds = 0;
private sheetOutlines = 0;
private readonly labels = new Map<string, Text>(); private readonly labels = new Map<string, Text>();
private readonly labelLayer = new Container(); private readonly labelLayer = new Container();
private readonly plates = new Graphics();
private dragging: FrameDrag | null = null; private dragging: FrameDrag | null = null;
private spanning: NewFrame | null = null; private spanning: NewFrame | null = null;
private corner: CornerDrag | null = null; private corner: CornerDrag | null = null;
@@ -124,7 +136,9 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
constructor(private readonly state: EditorState) { constructor(private readonly state: EditorState) {
super(); super();
this.perFrame = new FramePreview(state, 'texture-drag'); this.perFrame = new FramePreview(state, 'texture-drag');
this.content.addChild(this.image, this.overlay, this.labelLayer); this.marks.addChild(this.sheet, this.overlay);
// Plates under the outlines, as before the split: a corner handle must not sink under a name.
this.content.addChild(this.image, this.plates, this.marks, this.labelLayer);
state.on('project', () => this.redraw()); state.on('project', () => this.redraw());
state.on('selection', () => this.redraw()); state.on('selection', () => this.redraw());
@@ -160,6 +174,19 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
return this.collision; return this.collision;
} }
/** How the marks were drawn: the check scripts prove with it that a pan or a drag rebuilds nothing. */
get drawInfo() {
const origin = this.marks.getGlobalPosition();
return {
sheetBuilds: this.sheetBuilds,
sheetOutlines: this.sheetOutlines,
origin: { x: origin.x, y: origin.y },
labels: [...this.labels.values()]
.filter((label) => label.visible)
.map((label) => ({ text: label.text, ...label.getGlobalPosition() })),
};
}
private asset(): TextureAsset | null { private asset(): TextureAsset | null {
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined; const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
return asset && asset.type === 'texture' ? asset : null; return asset && asset.type === 'texture' ? asset : null;
@@ -557,56 +584,69 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
drawChecker(this.checker, origin.x, origin.y, w, h, this.w, this.h); drawChecker(this.checker, origin.x, origin.y, w, h, this.w, this.h);
} }
/** A pan leaves every outline where it is on the sheet: move them, and place the names anew. */
protected override panned(): void {
const asset = this.asset();
this.image.position.set(this.view.panX, this.view.panY);
this.drawChecker(asset, this.image.visible);
this.marks.position.set(this.view.panX, this.view.panY);
this.syncLabels(asset?.frames ?? [], this.selectedFrameId());
}
private drawFrames(asset: TextureAsset | null): void { private drawFrames(asset: TextureAsset | null): void {
const view: SheetView = { panX: 0, panY: 0, zoom: this.view.zoom };
this.marks.position.set(this.view.panX, this.view.panY);
const frames = asset?.frames ?? [];
const selectedId = this.selectedFrameId();
const key = sheetKey(frames, selectedId, view.zoom);
if (!sameKey(key, this.sheetKey)) {
this.sheetKey = key;
this.sheet.clear();
const others = frames.filter((f) => f.id !== selectedId);
for (const frame of others) this.drawFrame(this.sheet, view, frame, false);
for (const frame of others) this.drawOutlines(this.sheet, view, frame, false);
this.sheetBuilds += 1;
this.sheetOutlines = others.reduce((n, f) => n + (f.collision?.length ?? 0), 0);
}
this.overlay.clear(); this.overlay.clear();
const alive = new Set<string>(); const selected = frames.find((f) => f.id === selectedId);
if (asset) { if (selected) {
const selectedId = this.selectedFrameId(); this.drawFrame(this.overlay, view, selected, true);
for (const frame of asset.frames) { this.drawOutlines(this.overlay, view, selected, this.collision);
alive.add(frame.id); if (!this.collision) {
this.drawFrame(frame, frame.id === selectedId); this.drawBorders(view, selected);
} this.drawHandles(view, selected);
for (const frame of asset.frames) this.drawOutlines(frame, this.collision && frame.id === selectedId);
const selected = this.frame(selectedId);
if (selected && !this.collision) {
this.drawBorders(selected);
this.drawHandles(selected);
} }
} }
if (this.spanning?.active) { if (this.spanning?.active) {
const rect = rectBetween(this.spanning.start, this.spanning.current); const rect = rectBetween(this.spanning.start, this.spanning.current);
const a = imageToLocal(this.view, rect.x, rect.y); const a = imageToLocal(view, rect.x, rect.y);
this.overlay this.overlay
.rect(a.x, a.y, rect.w * this.view.zoom, rect.h * this.view.zoom) .rect(a.x, a.y, rect.w * view.zoom, rect.h * view.zoom)
.fill({ color: Theme.colors.marquee, alpha: 0.15 }) .fill({ color: Theme.colors.marquee, alpha: 0.15 })
.stroke({ width: 1, color: Theme.colors.marquee }); .stroke({ width: 1, color: Theme.colors.marquee });
} }
for (const [id, label] of this.labels) { this.syncLabels(frames, selectedId);
if (alive.has(id)) continue;
label.destroy();
this.labels.delete(id);
}
} }
private drawFrame(frame: TextureFrame, selected: boolean): void { private drawFrame(g: Graphics, view: SheetView, frame: TextureFrame, selected: boolean): void {
const a = imageToLocal(this.view, frame.x, frame.y); const a = imageToLocal(view, frame.x, frame.y);
const w = frame.w * this.view.zoom; const w = frame.w * view.zoom;
const h = frame.h * this.view.zoom; const h = frame.h * view.zoom;
const color = selected ? Theme.colors.accent : Theme.colors.collider; const color = selected ? Theme.colors.accent : Theme.colors.collider;
this.overlay.rect(a.x + 0.5, a.y + 0.5, w - 1, h - 1).stroke({ width: selected ? 2 : 1, color }); g.rect(a.x + 0.5, a.y + 0.5, w - 1, h - 1).stroke({ width: selected ? 2 : 1, color });
this.syncLabel(frame, a, w, h, selected);
} }
/** /**
* The four nine-slice lines of the selected frame (#41). Drawn even when they all sit on its * The four nine-slice lines of the selected frame (#41). Drawn even when they all sit on its
* edges: a frame nobody sliced still has them, and a line one cannot see is one nobody drags. * edges: a frame nobody sliced still has them, and a line one cannot see is one nobody drags.
*/ */
private drawBorders(frame: TextureFrame): void { private drawBorders(view: SheetView, frame: TextureFrame): void {
const border = frameBorders(frame); const border = frameBorders(frame);
for (const edge of BORDER_EDGES) { for (const edge of BORDER_EDGES) {
const line = borderLine(frame, border, edge); const line = borderLine(frame, border, edge);
const a = imageToLocal(this.view, line.x1, line.y1); const a = imageToLocal(view, line.x1, line.y1);
const b = imageToLocal(this.view, line.x2, line.y2); const b = imageToLocal(view, line.x2, line.y2);
dashedLine(this.overlay, a.x, a.y, b.x, b.y, 4, 3); dashedLine(this.overlay, a.x, a.y, b.x, b.y, 4, 3);
} }
this.overlay.stroke({ width: 1, color: Theme.colors.marquee }); this.overlay.stroke({ width: 1, color: Theme.colors.marquee });
@@ -617,35 +657,30 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
* the edited frame the active outline is drawn heavier and every corner of every outline gets a * the edited frame the active outline is drawn heavier and every corner of every outline gets a
* handle. An outline that is no polygon is drawn in red. * handle. An outline that is no polygon is drawn in red.
*/ */
private drawOutlines(frame: TextureFrame, edited: boolean): void { private drawOutlines(g: Graphics, view: SheetView, frame: TextureFrame, edited: boolean): void {
const outlines = frameCollision(frame); const outlines = frame.collision ?? [];
const active = edited ? this.activeOutline(frame, outlines) : -1; const active = edited ? this.activeOutline(frame, outlines) : -1;
for (const [index, points] of outlines.entries()) { for (const [index, points] of outlines.entries()) {
const screen = points.map((p) => imageToLocal(this.view, frame.x + p.x, frame.y + p.y)); const screen = points.map((p) => imageToLocal(view, frame.x + p.x, frame.y + p.y));
const color = convexPieces(points).length > 0 ? Theme.colors.collider : Theme.colors.danger; const color = isPolygon(points) ? Theme.colors.collider : Theme.colors.danger;
const width = index === active ? 2 : 1; const width = index === active ? 2 : 1;
if (screen.length >= 3) if (screen.length >= 3) g.poly(screen, true).fill({ color, alpha: 0.15 }).stroke({ width, color });
this.overlay.poly(screen, true).fill({ color, alpha: 0.15 }).stroke({ width, color });
else if (screen.length === 2) else if (screen.length === 2)
this.overlay g.moveTo(screen[0]!.x, screen[0]!.y).lineTo(screen[1]!.x, screen[1]!.y).stroke({ width: 2, color });
.moveTo(screen[0]!.x, screen[0]!.y)
.lineTo(screen[1]!.x, screen[1]!.y)
.stroke({ width: 2, color });
if (!edited) continue; if (!edited) continue;
const half = HANDLE_SIZE / 2; const half = HANDLE_SIZE / 2;
for (const c of screen) for (const c of screen)
this.overlay g.rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE)
.rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE)
.fill(index === active ? Theme.colors.accentText : Theme.colors.viewport) .fill(index === active ? Theme.colors.accentText : Theme.colors.viewport)
.stroke({ width: 1, color }); .stroke({ width: 1, color });
} }
} }
private drawHandles(frame: TextureFrame): void { private drawHandles(view: SheetView, frame: TextureFrame): void {
const half = HANDLE_SIZE / 2; const half = HANDLE_SIZE / 2;
for (const handle of frameHandles()) { for (const handle of frameHandles()) {
const p = handlePoint(frame, handle); const p = handlePoint(frame, handle);
const local = imageToLocal(this.view, p.x, p.y); const local = imageToLocal(view, p.x, p.y);
this.overlay this.overlay
.rect(local.x - half, local.y - half, HANDLE_SIZE, HANDLE_SIZE) .rect(local.x - half, local.y - half, HANDLE_SIZE, HANDLE_SIZE)
.fill(Theme.colors.accentText) .fill(Theme.colors.accentText)
@@ -653,6 +688,22 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
} }
} }
/** Names stay in screen space and only on frames in view, so a pan places them anew. */
private syncLabels(frames: readonly TextureFrame[], selectedId: string): void {
this.plates.clear();
const alive = new Set<string>();
for (const frame of frames) {
alive.add(frame.id);
const at = imageToLocal(this.view, frame.x, frame.y);
this.syncLabel(frame, at, frame.w * this.view.zoom, frame.h * this.view.zoom, frame.id === selectedId);
}
for (const [id, label] of this.labels) {
if (alive.has(id)) continue;
label.destroy();
this.labels.delete(id);
}
}
private syncLabel(frame: TextureFrame, at: Vec2, w: number, h: number, selected: boolean): void { private syncLabel(frame: TextureFrame, at: Vec2, w: number, h: number, selected: boolean): void {
let label = this.labels.get(frame.id); let label = this.labels.get(frame.id);
// A name that does not fit inside its frame would be read as belonging to the neighbour. // A name that does not fit inside its frame would be read as belonging to the neighbour.
@@ -673,7 +724,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
label.position.set(x + LABEL_PAD, y + LABEL_PAD); label.position.set(x + LABEL_PAD, y + LABEL_PAD);
// A dark plate, not just a text colour: the name is drawn on the user's picture, where no // A dark plate, not just a text colour: the name is drawn on the user's picture, where no
// fixed colour is legible against every background. // fixed colour is legible against every background.
this.overlay this.plates
.roundRect(x, y, label.width + LABEL_PAD * 2, label.height + LABEL_PAD * 2, 2) .roundRect(x, y, label.width + LABEL_PAD * 2, label.height + LABEL_PAD * 2, 2)
.fill({ color: 0x000000, alpha: selected ? 0.75 : 0.55 }); .fill({ color: 0x000000, alpha: selected ? 0.75 : 0.55 });
} }
@@ -698,6 +749,31 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
} }
} }
/**
* What the unselected frames are drawn from. The model replaces a frame's outline list when it
* changes rather than editing it, so the list itself stands for its corners.
*/
function sheetKey(frames: readonly TextureFrame[], selectedId: string, zoom: number): unknown[] {
const key: unknown[] = [zoom, selectedId];
for (const f of frames) if (f.id !== selectedId) key.push(f, f.x, f.y, f.w, f.h, f.collision);
return key;
}
function sameKey(a: readonly unknown[], b: readonly unknown[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
/**
* Whether an outline is a polygon, remembered per outline for the same reason. Working it out on
* every redraw was half of what a sheet with many outlines cost per pan (#220).
*/
const polygonOutlines = new WeakMap<readonly Vec2[], boolean>();
function isPolygon(points: readonly Vec2[]): boolean {
let valid = polygonOutlines.get(points);
if (valid === undefined) polygonOutlines.set(points, (valid = polygonProblem(points) === ''));
return valid;
}
/** A corner of an outline: on whole pixels, and inside its frame. */ /** A corner of an outline: on whole pixels, and inside its frame. */
function inFrame(frame: TextureFrame, p: Vec2): Vec2 { function inFrame(frame: TextureFrame, p: Vec2): Vec2 {
return { return {
+50
View File
@@ -298,6 +298,56 @@ export async function shapes(t) {
)) === true, )) === true,
JSON.stringify({ two, left }), JSON.stringify({ two, left }),
); );
// 42c-ii. A sheet full of outlines stays smooth (#220): dragging a corner redraws only the
// edited frame, a pan moves the drawn outlines instead of drawing them anew, and a
// change to an unselected frame still reaches the picture.
const drawInfo = () => json('__gm.texture.drawInfo()');
const beforeDrag = await drawInfo();
const corner = await json('__gm.texture.pointAt(32, 0)');
const aside = await json('__gm.texture.pointAt(24, 0)');
await drag(corner.x, corner.y, aside.x, aside.y, 8);
const afterDrag = await drawInfo();
await undo();
check(
'dragging a corner does not redraw the frames around it',
afterDrag.sheetBuilds === beforeDrag.sheetBuilds,
JSON.stringify({ before: beforeDrag.sheetBuilds, after: afterDrag.sheetBuilds }),
);
await select({ kind: 'asset', assetId: texture?.id });
await settle();
const unselected = await drawInfo();
await undo();
await settle();
const restored = await drawInfo();
await t.redo();
await settle();
check(
'undo on an unselected frame redraws its outlines',
unselected.sheetOutlines === 1 && restored.sheetOutlines === 2 && (await drawInfo()).sheetOutlines === 1,
JSON.stringify({ unselected: unselected.sheetOutlines, restored: restored.sheetOutlines }),
);
const area = await json('__gm.texture.rect()');
const beforePan = await drawInfo();
const [px, py] = [Math.round(area.x + area.w / 2), Math.round(area.y + area.h / 2)];
await drag(px, py, px + 40, py + 25, { steps: 8, button: 'middle' });
const afterPan = await drawInfo();
const origin = await json('__gm.texture.pointAt(0, 0)');
const label = afterPan.labels.find((l) => l.text === 'Wedge');
check(
'a pan moves the drawn outlines and names along without drawing them anew',
afterPan.sheetBuilds === beforePan.sheetBuilds &&
Math.abs(afterPan.origin.x - beforePan.origin.x - 40) < 0.01 &&
Math.abs(afterPan.origin.y - beforePan.origin.y - 25) < 0.01 &&
Math.abs(afterPan.origin.x - origin.x) < 0.01 &&
Math.abs(afterPan.origin.y - origin.y) < 0.01 &&
Boolean(label) &&
Math.abs(label.x - origin.x - 5) < 0.01 &&
Math.abs(label.y - origin.y - 5) < 0.01,
JSON.stringify({ beforePan, afterPan, origin }),
);
await select({ kind: 'frame', assetId: texture?.id, frameId: 'sh-frame' });
await settle();
await clickText('Collision'); await clickText('Collision');
await showTab('scene'); await showTab('scene');
await settle(); await settle();