Files
feuerwerk/src/core/model/liveTilemap.ts
T
adminandClaude Opus 5.5 03e7a78c3d
game-maker / gate (push) Successful in 3m1s
game-maker / suite (push) Failing after 8m33s
Keep a tilemap with outlined solids at 60 fps in scene and play
A project with three tilemap layers, two of them with collision on
water and hill tiles, ran at 33 to 52 fps. A CPU profile showed that
almost all of the time went into the collision geometry of the map.
It was rebuilt from scratch every frame although nothing had changed.
The animated water makes the views redraw every frame, so the cost
was never skipped.

- New module tilemapMemo keeps what is derived from the cells: the
  tiles of a layer, the blocking cells, the rectangles and polygons.
  It is reused as long as the content stays the same.
- A brush stroke paints into the existing cells object. The memo
  therefore compares the content cell by cell, not the identity.
  The comparison walks two arrays in insertion order.
- Clipped outlines are kept per outline list. The model always
  replaces that list instead of changing it.
- PhysicsSystem no longer rebuilds the shapes of a tilemap each tick
  while its cells, placement and settings are unchanged. Before, it
  also built a JSON key from all world shapes every tick.
- The overlaid layers after Set tile in a run stay stable. Otherwise
  the memo would miss on every call after the first edit.

The #184 work only helped Move and raycasts, not this path.

Measured on stein2 (i5-7500T, HD 630), script time per frame:

- Scene, nothing selected: 17.7 ms, 52 fps -> 5.8 ms, 60 fps
- Tilemap selected: 26.2 ms, 38 fps -> 8.6 ms, 60 fps
- Play: 29.9 ms, 33 fps -> 8.7 ms, 60 fps

About 3 ms per frame remain. The content comparison costs roughly
60 us per 700-cell layer and runs about 40 times a frame. The mesh
geometries in TilemapField are still rebuilt every frame.

Tests:
- ./check.sh: 162 files, 2377 tests, CHECK EXIT 0 (before 2370)
- e2e 1-5 and 39-43: ALL PASSED, 289 checks
- 7 new unit tests in tests/core/tilemap-memo.test.ts
- 5 counter checks (content ignored, no memo, outline by kind only,
  physics always rebuilds, layer settings ignored) each fail their
  test. All originals are back, checked with cmp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:23:18 +02:00

127 lines
5.5 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.
/**
* A placed tilemap as a running game sees it (#135). A behavior may read and change its cells, but
* the cells belong to the asset, and two placements of one map must not paint each other. So a run
* keeps what it changed per placement, as edits laid over the asset's cells, and never writes them
* back: after Stop the map is the one that was painted.
*/
import type { TilemapAsset, TilemapLayer } from './Asset';
import { apply, determinant, invert, type Mat } from './matrix';
import type { PropertyBag, Vec2 } from './Properties';
import { type Cell, cellAt, cellAtPoint, cellKey, cellOrigin, inGrid, tilemapTileSize } from './tilemap';
import { cellsVersion } from './tilemapMemo';
/**
* What a run changed on one placement: per layer id, the cells it set, `''` for one it emptied.
* Replaced rather than changed in place, so whoever worked something out from the old one can tell.
*/
export type TileEdits = Readonly<Record<string, Readonly<Record<string, string>>>>;
/** How a node's empty layer choice reads: the first layer of whatever map it is given. */
export const FIRST_TILE_LAYER = 'First layer';
/**
* The layer a node names. By name, not by index, since the index moves when the layers are
* reordered; no name is the first layer, which is all a map of one layer needs. Two layers of one
* name are the upper one in the list, the same one the panel shows first.
*/
export function layerNamed(asset: TilemapAsset, name: string): TilemapLayer | undefined {
return name === '' ? asset.layers[0] : asset.layers.find((layer) => layer.name === name);
}
/** The asset's layers with a run's edits laid over them – what is drawn and what blocks. */
export function liveLayers(asset: TilemapAsset, edits: TileEdits | undefined): TilemapLayer[] {
if (!edits) return asset.layers;
return asset.layers.map((layer) => {
const changed = edits[layer.id];
return changed ? editedLayer(layer, changed) : layer;
});
}
/**
* Laid over once per edits and layer content: the same object each frame is what lets the tiles and
* solids worked out from it be remembered (`tilemapMemo.ts`).
*/
function editedLayer(layer: TilemapLayer, changed: Readonly<Record<string, string>>): TilemapLayer {
let byLayer = laidOver.get(changed);
if (!byLayer) laidOver.set(changed, (byLayer = new WeakMap()));
const version = cellsVersion(layer.cells);
const held = byLayer.get(layer);
if (held?.version === version && sameBesidesCells(held.layer, layer)) return held.layer;
const cells: Record<string, string> = { ...layer.cells };
for (const [key, tileId] of Object.entries(changed)) {
if (tileId) cells[key] = tileId;
else delete cells[key];
}
const edited = { ...layer, cells };
byLayer.set(layer, { version, layer: edited });
return edited;
}
/** A layer's settings (name, z, hidden …) are changed in place too, so they are compared one by one. */
function sameBesidesCells(a: TilemapLayer, b: TilemapLayer): boolean {
const keys = Object.keys(b) as (keyof TilemapLayer)[];
return keys.length === Object.keys(a).length && keys.every((key) => key === 'cells' || a[key] === b[key]);
}
const laidOver = new WeakMap<object, WeakMap<TilemapLayer, { version: number; layer: TilemapLayer }>>();
/** The asset as a run sees it: the same one when nothing was changed. */
export function liveTilemap(asset: TilemapAsset, edits: TileEdits | undefined): TilemapAsset {
return edits ? { ...asset, layers: liveLayers(asset, edits) } : asset;
}
/**
* The tile in a cell right now, `''` for an empty one. Null when there is nothing to ask: no layer
* of that name, or a cell outside the grid.
*/
export function liveTileAt(
asset: TilemapAsset,
edits: TileEdits | undefined,
props: PropertyBag,
layerName: string,
cell: Cell,
): string | null {
const layer = layerNamed(asset, layerName);
if (!layer || !inGrid(props, cell.x, cell.y)) return null;
const edited = edits?.[layer.id]?.[cellKey(cell.x, cell.y)];
return edited ?? cellAt(layer, cell.x, cell.y);
}
/**
* The edits with one more cell set, `''` emptying it; null when the cell cannot be set (see
* `liveTileAt`). Setting what is already there gives the same edits back, so nothing is rebuilt.
*/
export function withTile(
asset: TilemapAsset,
edits: TileEdits | undefined,
props: PropertyBag,
layerName: string,
cell: Cell,
tileId: string,
): TileEdits | null {
const now = liveTileAt(asset, edits, props, layerName, cell);
if (now === null) return null;
if (now === tileId) return edits ?? {};
const layer = layerNamed(asset, layerName)!;
return { ...edits, [layer.id]: { ...edits?.[layer.id], [cellKey(cell.x, cell.y)]: tileId } };
}
/**
* The cell a world point falls into, and whether that cell is on the grid. The map may be moved,
* turned, scaled and mirrored, so the point is taken into the map's own frame first; a map scaled
* to nothing has no cells at all.
*/
export function worldToCell(props: PropertyBag, m: Mat, point: Vec2): Cell & { inside: boolean } {
if (Math.abs(determinant(m)) < 1e-12) return { x: 0, y: 0, inside: false };
const local = apply(invert(m), point);
const cell = cellAtPoint(props, local.x, local.y);
return { ...cell, inside: inGrid(props, cell.x, cell.y) };
}
/** The middle of a cell in world units – where something standing on that cell is put. */
export function cellToWorld(props: PropertyBag, m: Mat, cell: Cell): Vec2 {
const half = tilemapTileSize(props) / 2;
const corner = cellOrigin(props, cell.x, cell.y);
return apply(m, { x: corner.x + half, y: corner.y + half });
}