Files
feuerwerk/src/editor/debug/sceneHooks.ts
T
adminandClaude Opus 5.5 4c7861b5b8 Keep the frame rate up while painting a big tilemap (#347)
Painting on the 60x60 map of a project with fifty objects dropped the
editor to 20 to 39 fps. A profile of a brush stroke showed four causes,
none of them the brush itself.

- Each frame of a stroke previews the model. The asset and hierarchy
  panels then built every row anew, so every label became a new text
  and a new upload to the GPU. One run spent 46 % of the main thread
  in those uploads. ListView.setItems now keeps a row whose item draws
  the same (sameListItem) and builds only the rows that changed.
- The brush drew the whole scene on every pointer event, on top of the
  preview. Now the preview draws what the stroke changed, once a frame.
  The rectangle tool asks for one drawing a frame for its box.
- An animated tile made the view draw in the tick and the preview drew
  it again in the same frame. animate now asks just before the frame
  is drawn and stays out when the view was drawn since the tick began.
- layoutSymbols ran on every drawing and costs up to the cube of the
  symbols. SymbolLayoutMemo keeps the spots while no anchor moves.
- A painted cell made the map read every "x,y" key again, and once
  more for each of the eight neighbours of every cell. layerCells reads
  a layer once per content into an index by place. layerTiles and the
  blocking cells take their neighbours from there (neighboursIn).
  parseCellKey reads a key without split, with the same results.

The test hook sceneRedraws counts how often the scene view was drawn.

Not done: a painted cell still works out the tiles and blocking cells
of the whole map again, so that part grows with the map.

Tests:
- Brush stroke on a copy of the project (stein2, Chrome): 20 to 39 fps
  before, 47.5 after, 55 to 57 without painting. Labels drawn again
  during the stroke 2012 before, none after. Scene drawings per frame
  1.32 before, 1 after.
- One painted cell, headless: 28 to 16 ms on 60x60, 109 to 49 ms on
  the same map doubled to 120x120.
- frame-preview: 2 new, red with the old pointer redraw.
- tilemap: 3 new (key reading, index, refresh), list-ping 1 new,
  symbol-layout 1 new. 5 single reversals turned each red.
- e2e 39,40 (melektaus-atx): ALL PASSED, 107 checks (1 new, a fast
  stroke: 6 drawings in 6 frames, no label drawn again). With the row
  reuse and the pointer redraw reverted it fails: 24 drawings in
  3 frames, and the panels draw their labels again.
- Gate (melektaus-atx): CHECK EXIT 0, 260 files, 4038 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:54:59 +02:00

296 lines
15 KiB
TypeScript
Raw 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 { findAssetOf, findObjectHolder } from '../../core/model/Project';
import { resolveObjects } from '../../core/model/instance';
import { findLayer, layerZ } from '../../core/model/tilemap';
import { currentVisibilityPopup } from '../panels/SceneVisibilityPopup';
import { currentTilemapWidgets, paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
import type { TilemapTool } from '../state';
import { rectOf } from '../textProbe';
import { centreOf, type HookContext, shownOnStage } from './hookHelpers';
/** The Scene and Game tabs, the tilemap painted in the scene view, and the Container tab. */
export function sceneHooks({ state, editor }: HookContext) {
const { viewport, gameView, gamePane, scenePane, containerEditor } = editor;
/**
* A holder's objects the way the editor sees them: the content of placed containers is resolved
* from the template, so it is nowhere in `holder.objects` (see `model/instance.ts`).
*/
const objectsOf = (holderId: string) => {
const holder = findObjectHolder(state.project, holderId);
return holder ? resolveObjects(state.project, holder) : [];
};
return {
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
viewportCenter: () => centreOf(viewport),
/** Pans the Scene tab to a world point – a step's own lane, away from what earlier steps left. */
lookAt: (x: number, y: number) => viewport.lookAt(x, y),
/** How often the Scene tab was drawn so far – a gesture should cost one per frame (#347). */
sceneRedraws: () => viewport.redrawCount,
/** Its drawn box – a step that wants an empty spot must stay inside what the console left over. */
viewportRect: () => {
const p = viewport.getGlobalPosition();
return { x: p.x, y: p.y, w: viewport.w, h: viewport.h };
},
/**
* A point in the viewport that no object covers – steps click there to deselect. A fixed
* offset from the centre lands on an object as soon as the viewport changes size.
*/
emptySpot: () => {
const p = viewport.getGlobalPosition();
const scene = state.activeScene;
const taken = scene
? objectsOf(scene.id)
.map((object) => viewport.objectRect(object.id))
.filter((rect): rect is NonNullable<typeof rect> => rect !== null)
: [];
const clear = (x: number, y: number) =>
!taken.some((r) => x >= r.x - 12 && x <= r.x + r.w + 12 && y >= r.y - 12 && y <= r.y + r.h + 12);
for (let y = p.y + viewport.h - 24; y > p.y + 24; y -= 24) {
for (let x = p.x + 24; x < p.x + viewport.w - 24; x += 24) if (clear(x, y)) return { x, y };
}
return null;
},
/** Where to click Fit or the eye in the scene tab's header. */
sceneHeaderAt: (name: 'fit' | 'visibility') =>
centreOf(name === 'fit' ? scenePane.fitButton : scenePane.visibilityButton),
viewportInfo: () => viewport.debugInfo(),
/** The scene view's centre and zoom, and whether a zoom transition is under way (#309). */
sceneView: () => viewport.viewState(),
/** The world point under a screen point, as the scene view shows it right now. */
sceneWorldAt: (x: number, y: number) => viewport.worldAt(x, y),
/** A full redraw of the Scene tab – what a pan, which only moves the camera, is compared with. */
viewportRedraw: () => viewport.redraw(),
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */
dropPreview: () => viewport.dropInfo(),
gameInfo: () => gameView.debugInfo(),
/** Selection outlines in the Scene tab: a switched-off object is invisible but still framed. */
gizmos: () => viewport.gizmoInfo(),
/** The corner handles of the polygon collider being edited, in global pixels (#151). */
polygonCorners: () => viewport.cornerInfo(),
/** The lines of the joints in the Scene tab, in world units, and the anchor handle in global pixels (#154). */
joints: () => viewport.jointInfo(),
/** The size gizmo on the selected sprite or collider, in global pixels (#223). */
sizeGizmo: () => viewport.sizeGizmoInfo(),
/** The rays the editor run cast in the last moment, as the Scene tab draws them, in world units (#153). */
rays: () => viewport.rayInfo(),
layerOf: (id: string) => viewport.layerOf(id),
/** The icon a particle source wears in the Scene tab, with its name and its owner link. */
sceneIcon: (id: string) => viewport.iconInfo(id),
/** The names shown beside the symbols, each with the plate under it, in world units and on screen. */
sceneLabels: () => viewport.labelInfo(),
/** A collider's outline in the Scene tab: hidden by the eye in its header, it is neither seen nor hit. */
sceneShape: (id: string) => viewport.shapeInfo(id),
/** The switches behind the eye while it stands open: per type and part, on, enabled, where to click. */
visibilityPopup: () =>
currentVisibilityPopup()
?.info()
.map(({ widget, ...entry }) => ({ ...entry, ...centreOf(widget) })) ?? null,
drawOrder: () => viewport.drawOrder(),
objectScreen: (id: string) => viewport.objectScreen(id),
objectRect: (id: string) => viewport.objectRect(id),
/** What a click at a screen point picks from in the Scene tab, front to back (#203). */
sceneStack: (x: number, y: number) => viewport.stackInfo(x, y),
/**
* The eyedropper of an object's place (#276): whether it picks, what it fills, its candidate and
* the object outlined as that in the view, the bar over the view with its two buttons, the tab
* showing – and how the last one ended.
*/
objectPick: () => {
const pick = state.objectPick;
const strip = [scenePane.pickStrip, containerEditor.pickStrip].find((s) => shownOnStage(s));
return {
picking: pick !== null,
name: pick?.name ?? null,
candidate: pick?.candidate ?? null,
outline: viewport.pickOutline() ?? containerEditor.canvas.pickOutline(),
strip: strip
? { text: strip.shownText, take: centreOf(strip.takeButton), cancel: centreOf(strip.cancelButton) }
: null,
tab: editor.tab,
ended: state.pickEnd,
};
},
/** The same for the Game tab – through the camera, so what the player sees. */
gameScreen: (id: string) => gameView.objectScreen(id),
gameRect: (id: string) => gameView.objectRect(id),
gameDrawnRect: (id: string) => gameView.drawnRect(id),
gameCenter: () => centreOf(gameView),
/** What the main camera draws in the Game tab, back to front (#302). */
gameDrawOrder: () => gameView.drawOrder(),
/** The overlay cameras laid over it (#302): what each draws and its picture in global pixels. */
gameOverlays: () => {
const origin = gameView.getGlobalPosition();
return gameView.overlayInfo().map(({ rect, ...overlay }) => ({
...overlay,
rect: { ...rect, x: origin.x + rect.x, y: origin.y + rect.y },
}));
},
/** The game view's scale mode and what it came to, the view's box and the picker in its header (#225). */
gameScale: () => {
const origin = gameView.getGlobalPosition();
return {
mode: gameView.scaleMode,
...gameView.scaleInfo(),
view: { x: origin.x, y: origin.y, w: gameView.w, h: gameView.h },
label: gamePane.scalePicker.text,
picker: centreOf(gamePane.scalePicker),
};
},
/**
* Fullscreen of the game view (#267): whether the view is moved to the root, whether the browser
* is in fullscreen, the header button, and the corner button while it shows.
*/
gameFullscreen: () => ({
on: gameView.isFullscreen,
document: document.fullscreenElement !== null,
headerButton: shownOnStage(gamePane.fullscreenButton) ? centreOf(gamePane.fullscreenButton) : null,
corner: shownOnStage(gameView.fullscreenButton) ? centreOf(gameView.fullscreenButton) : null,
}),
/**
* The performance overlay (#275): the header switch, whether the overlay is on screen, where,
* and the lines it shows. The numbers behind them are in `playInfo().performance`.
*/
gamePerformance: () => {
const overlay = gameView.performanceOverlay;
const origin = overlay.getGlobalPosition();
const button = gamePane.performanceButton;
return {
button: { ...centreOf(button), lit: button.toggled },
shown: shownOnStage(overlay),
box: { x: origin.x, y: origin.y, w: overlay.width, h: overlay.height },
view: rectOf(gameView),
lines: overlay.lines,
};
},
/** Which typeface a text ended up drawn in, and how wide it made it. */
textInfo: (id: string) => viewport.textInfo(id),
gameTextInfo: (id: string) => gameView.textInfo(id),
tilemap: {
/** What the scene view painted a map with, and on which layer (#128). */
paint: () => ({ ...state.tilemapPaint }),
/** The cells of one layer as the model holds them: `"x,y"` to tile id. */
cells: (assetId: string, layerId: string) => {
const asset = findAssetOf(state.project, assetId, 'tilemap');
const layer = asset && findLayer(asset, layerId);
return layer ? { ...layer.cells } : null;
},
layers: (assetId: string) =>
findAssetOf(state.project, assetId, 'tilemap')?.layers.map((layer) => ({
id: layer.id,
name: layer.name,
hidden: layer.hidden === true,
z: layerZ(layer),
sortByY: layer.sortByY === true,
cells: Object.keys(layer.cells).length,
})) ?? null,
/** What the scene view drew: meshes (one draw call each), cells and visible layers. */
info: (objectId: string) => viewport.tilemapInfo(objectId),
gameInfo: (objectId: string) => gameView.tilemapInfo(objectId),
/** Where the scene view outlines the map as blocking: the merged rectangles, in cells (#132). */
solids: (objectId: string) => viewport.solidInfo(objectId),
/**
* The painting panel beside the scene view (#142): whether it is on screen, its box, the
* scene view's box next to it and its splitter – null boxes while it is hidden.
*/
panel: () => {
// The editor hides the panel itself on every other tab, so its own flag is the whole answer.
const shown = editor.tilemapPanel.visible;
return {
shown,
title: editor.tilemapPanel.title,
panel: shown ? rectOf(editor.tilemapPanel) : null,
splitter: shown ? centreOf(editor.tilemapSplitter) : null,
viewport: rectOf(viewport),
};
},
/** Where the widgets of the painting panel sit: their texts are on screen elsewhere too. */
toolAt: (tool: string) => {
const button = currentTilemapWidgets()?.tools.get(tool as TilemapTool);
return button ? centreOf(button) : null;
},
tileButtonAt: (assetId: string) =>
currentTilemapWidgets()?.palette?.swatchGlobal(assetId)?.centre ?? null,
/** A tile's box in the palette: its centre, where its picture sits and the palette's columns (#139). */
tileSwatch: (assetId: string) => {
const palette = currentTilemapWidgets()?.palette;
const swatch = palette?.swatchGlobal(assetId);
return palette && swatch ? { ...swatch, columns: palette.columns } : null;
},
/** The plus in the layer list's header. */
addLayerAt: () => currentTilemapWidgets()?.layers.addGlobal() ?? null,
/**
* Where the painted layer's name stands in its row – a double-click there edits it (#138) –
* with the name the row shows and the field it is being edited in, while it is.
*/
layerNameAt: () => {
const widgets = currentTilemapWidgets();
const target = paintedTilemap(state);
if (!widgets || !target) return null;
const layerId = paintedLayerId(state, target.asset);
const at = widgets.layers.nameGlobal(layerId);
const editing = widgets.layers.renameField;
return at
? {
...at,
value: findLayer(target.asset, layerId)?.name ?? '',
editing: editing ? { ...centreOf(editing), value: editing.value } : null,
}
: null;
},
/** The Z field in the painted layer's row, with what it holds (#174). */
layerZAt: () => {
const widgets = currentTilemapWidgets();
const target = paintedTilemap(state);
const field = widgets && target && widgets.layers.valueField(paintedLayerId(state, target.asset));
return field ? { ...centreOf(field), value: field.value } : null;
},
/** The Sort by Y box of the painted layer (#228), with whether it is ticked. */
sortByYAt: () => {
const box = currentTilemapWidgets()?.sortByY;
return box ? { ...centreOf(box), checked: box.checked } : null;
},
/** The duplicate mark on the painted layer's row. */
duplicateLayerAt: () => {
const widgets = currentTilemapWidgets();
const target = paintedTilemap(state);
return widgets && target ? widgets.layers.duplicateGlobal(paintedLayerId(state, target.asset)) : null;
},
focusAt: () => {
const button = currentTilemapWidgets()?.focus;
return button ? { ...centreOf(button), label: button.text } : null;
},
/** One row of the layer list, top-down as drawn: the layer, where to press it, and its eye. */
layerRowAt: (index: number) => {
const list = currentTilemapWidgets()?.layers;
const id = list?.shownIds[index];
// On the name: the middle of the row may be the Z field, which a press would scrub.
const name = id ? list.nameGlobal(id) : null;
if (!list || !id || !name) return null;
// `y` is the row's own line, so a check can tell whether it is inside the panel.
return { id, y: name.y, name, visible: list.eyeGlobal(id) };
},
/** The cross on the painted layer's row; `enabled` is false where the row has none (the last layer). */
removeLayerAt: () => {
const widgets = currentTilemapWidgets();
const target = paintedTilemap(state);
if (!widgets || !target) return null;
const layerId = paintedLayerId(state, target.asset);
const at = widgets.layers.removeGlobal(layerId);
const row = widgets.layers.rowGlobal(layerId);
return at ? { ...at, enabled: true } : row ? { x: row.x, y: row.y, enabled: false } : null;
},
/** Where a cell sits on screen – for a click that paints it or a pixel that proves it. */
cellAt: (objectId: string, x: number, y: number) => viewport.cellScreen(objectId, x, y),
gameCellAt: (objectId: string, x: number, y: number) => gameView.cellScreen(objectId, x, y),
},
container: {
current: () => containerEditor.currentAssetId,
center: () => centreOf(containerEditor),
rect: () => containerEditor.canvasRect(),
/** Where a template object is drawn, so a check can click or sample it. */
objectScreen: (id: string) => containerEditor.objectScreen(id),
objectRect: (id: string) => containerEditor.objectRect(id),
dropPreview: () => containerEditor.dropInfo(),
},
};
}