Files
feuerwerk/tests/core/tilemap-memo.test.ts
T
adminandClaude Opus 5.5 cfd288c4b7 Halve the frame time of a playing scene with a tilemap (#284)
The performance panel showed Physics and Editor as the large parts of
a frame in a scene of 222 entities. Nearly all of it came from a
tilemap that only stands there.

- Whoever writes cells in place now says so with cellsChanged. Only
  then does cellsVersion compare the content. The comparison ran about
  60 times per frame and was the largest single item at 14 percent.
- The four writers call it: the painter during and at the end of a
  stroke, PaintTilesCommand on execute and on undo. A new writer that
  forgets it stays invisible.
- The game view is drawn once per frame in play. With an animated tile
  in view it was drawn twice, once by the run and once by animate().
  animate() now gets the redraw count from the start of the tick.
- TilemapField builds the geometry of a mesh only when its source
  changes: the cells, the tile size, the origin or the image. For that
  groupByTile and layerRows return the same objects for the same list.
- PhysicsSystem skips a tilemap whose source and switch are unchanged.
  The switch is kept because a map that starts switched off would
  otherwise have dead colliders once it is switched on.
- World.syncToModel looks entities and assets up in a map per pass
  instead of a linear search per object.

Measured on stein2 with a copy of the reference project, ms per frame:
Physics 2.64 to 0.97, Drawing 6.89 to 2.56, Editor 1.95 to 1.31.

Left open: syncToModel still mirrors every entity every frame. That
is its own issue.

Tests:
- Gate: 198 files, 2887 tests (2883 + 4), exit 0
- e2e 1-17,28,33,39-43,59: 810 checks passed, 40 has +3
- Smokes: 24 / 19 / 21, exit 0
- Three unit tests wrote cells directly and now call cellsChanged
- Eleven counter-probes fail. Two did not at first: the stroke needed
  a check of its own, the physics test has to start with the map
  switched off. That last one was repeated on stein2 only.
- e2e total 1613 is computed, not measured in a full run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:52:57 +02:00

216 lines
8.9 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 { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands';
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
import type { TextureAsset, TilemapAsset } from '../../src/core/model/Asset';
import { liveLayers } from '../../src/core/model/liveTilemap';
import { findAsset } from '../../src/core/model/Project';
import { TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
import {
layerTiles,
TILEMAP_COLUMNS_KEY,
TILEMAP_ROWS_KEY,
TILEMAP_TILE_KEY,
} from '../../src/core/model/tilemap';
import { solidPolygons, solidRects } from '../../src/core/model/tilemapCollision';
import { cellsChanged, cellsVersion, KeyedMemo } from '../../src/core/model/tilemapMemo';
import { loadPhysics } from '../../src/runtime/physics/engine';
import { Solids } from '../../src/runtime/systems/solids';
import { makeSpriteScene, place, startWorld } from '../helpers';
beforeAll(async () => {
await loadPhysics();
});
afterEach(() => {
vi.restoreAllMocks();
});
const TRIANGLE = [
{ x: 0, y: 0 },
{ x: 32, y: 0 },
{ x: 0, y: 32 },
];
/**
* A 4 × 4 map of 32 px cells with two tiles: `box` blocks as a box, `cut` with a triangle drawn on
* its picture. Both lie on the one layer, `box` along the top row and `cut` at (1,2).
*/
function map() {
const f = makeSpriteScene('Thing');
const texture = new CreateAssetCommand('texture', 'Sheet');
const box = new CreateAssetCommand('tile', 'Box');
const cut = new CreateAssetCommand('tile', 'Cut');
const tilemap = new CreateAssetCommand('tilemap', 'Ground');
for (const command of [texture, box, cut, tilemap]) f.stack.execute(command);
const sheet = findAsset(f.project, texture.asset.id) as TextureAsset;
sheet.file = `${'a'.repeat(64)}.png`;
sheet.width = 32;
sheet.height = 32;
const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 32 });
f.stack.execute(frame);
f.stack.execute(new AddTilePictureCommand(cut.asset.id, frame.frameId));
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, [TRIANGLE]));
for (const tile of [box, cut]) {
f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true));
f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_KIND_KEY, 'none'));
}
for (const [key, value] of [
[TILEMAP_COLUMNS_KEY, 4],
[TILEMAP_ROWS_KEY, 4],
[TILEMAP_TILE_KEY, 32],
] as const)
f.stack.execute(new SetAssetPropertyCommand(tilemap.asset.id, key, value));
const mapId = place(f, tilemap.asset.id, { x: 0, y: 0 });
const asset = findAsset(f.project, tilemap.asset.id) as TilemapAsset;
const layer = asset.layers[0]!;
const top = [0, 1, 2, 3].map((x) => ({ x, y: 0, tileId: box.asset.id }));
f.stack.execute(new PaintTilesCommand(asset.id, layer.id, [...top, { x: 1, y: 2, tileId: cut.asset.id }]));
return {
...f,
mapId,
asset,
layer,
boxId: box.asset.id,
cutId: cut.asset.id,
textureId: texture.asset.id,
frameId: frame.frameId,
props: asset.props,
};
}
describe('what a tilemap works out is remembered while its content stays', () => {
it('a version stays while the cells hold the same, and changes when one is painted in place', () => {
const cells: Record<string, string> = { '0,0': 'a', '1,0': 'b' };
const paint = (key: string, tileId: string) => {
if (tileId) cells[key] = tileId;
else delete cells[key];
cellsChanged(cells);
};
const first = cellsVersion(cells);
expect(cellsVersion(cells)).toBe(first);
paint('2,0', 'a');
const added = cellsVersion(cells);
expect(added).not.toBe(first);
paint('2,0', 'b');
const changed = cellsVersion(cells);
expect(changed).not.toBe(added);
paint('0,0', '');
expect(cellsVersion(cells)).not.toBe(changed);
});
it('cells are compared only when their painter says so, and keep their version when nothing differs', () => {
const cells: Record<string, string> = { '0,0': 'a', '1,0': 'b' };
const first = cellsVersion(cells);
// A stroke that ends where it began: written to, but the same content as before.
cells['2,0'] = 'a';
delete cells['2,0'];
cellsChanged(cells);
expect(cellsVersion(cells)).toBe(first);
// Not walked again on every question: a write nobody reported goes unseen until one is.
cells['2,0'] = 'a';
expect(cellsVersion(cells)).toBe(first);
cellsChanged(cells);
expect(cellsVersion(cells)).not.toBe(first);
});
it('a paint command and its undo are each seen at once', () => {
const m = map();
expect(solidRects(m.project, m.props, m.asset)).toEqual([{ x: 0, y: 0, w: 4, h: 1 }]);
m.stack.execute(new PaintTilesCommand(m.asset.id, m.layer.id, [{ x: 0, y: 3, tileId: m.boxId }]));
expect(solidRects(m.project, m.props, m.asset)).toEqual([
{ x: 0, y: 0, w: 4, h: 1 },
{ x: 0, y: 3, w: 1, h: 1 },
]);
m.stack.undo();
expect(solidRects(m.project, m.props, m.asset)).toEqual([{ x: 0, y: 0, w: 4, h: 1 }]);
});
it('the cache hands out what it made for a key, and forgets the oldest once full', () => {
const memo = new KeyedMemo<number[]>(2);
const make = vi.fn(() => [1]);
const a = memo.get('a', make);
expect(memo.get('a', make)).toBe(a);
expect(make).toHaveBeenCalledTimes(1);
memo.get('b', make);
memo.get('c', make);
expect(memo.get('a', make)).not.toBe(a);
expect(make).toHaveBeenCalledTimes(4);
});
it('asking again for an unchanged map hands out the very same tiles and solids', () => {
const m = map();
expect(layerTiles(m.project, m.props, m.layer)).toBe(layerTiles(m.project, m.props, m.layer));
expect(solidRects(m.project, m.props, m.asset)).toBe(solidRects(m.project, m.props, m.asset));
expect(solidPolygons(m.project, m.props, m.asset)).toBe(solidPolygons(m.project, m.props, m.asset));
});
it('a cell painted in place, as a stroke does before it is a command, is seen at once', () => {
const m = map();
const rects = solidRects(m.project, m.props, m.asset);
expect(rects).toEqual([{ x: 0, y: 0, w: 4, h: 1 }]);
m.layer.cells['0,3'] = m.boxId;
cellsChanged(m.layer.cells);
expect(solidRects(m.project, m.props, m.asset)).toEqual([
{ x: 0, y: 0, w: 4, h: 1 },
{ x: 0, y: 3, w: 1, h: 1 },
]);
expect(layerTiles(m.project, m.props, m.layer).map((t) => `${t.x},${t.y}`)).toContain('0,3');
delete m.layer.cells['1,2'];
cellsChanged(m.layer.cells);
expect(solidPolygons(m.project, m.props, m.asset)).toEqual([]);
});
it('a tile no longer solid, a new outline and a new kind are each seen at once', () => {
const m = map();
const polygons = solidPolygons(m.project, m.props, m.asset);
expect(polygons).toHaveLength(1);
const other = [
{ x: 32, y: 0 },
{ x: 32, y: 32 },
{ x: 0, y: 32 },
];
m.stack.execute(new SetFrameCollisionCommand(m.textureId, m.frameId, [other]));
const turned = solidPolygons(m.project, m.props, m.asset);
expect(turned).not.toBe(polygons);
expect(turned[0]!.outline).not.toEqual(polygons[0]!.outline);
m.stack.execute(new SetAssetPropertyCommand(m.boxId, TILE_SOLID_KEY, false));
expect(solidRects(m.project, m.props, m.asset)).toEqual([]);
const before = layerTiles(m.project, m.props, m.layer).find((t) => t.tileId === m.boxId)!;
m.stack.execute(new SetAssetPropertyCommand(m.boxId, TILE_KIND_KEY, 'floor'));
const after = layerTiles(m.project, m.props, m.layer).find((t) => t.tileId === m.boxId)!;
expect(after.quadrants).not.toEqual(before.quadrants);
});
it('a run lays the same edits over a layer once, and again when the layer was painted meanwhile', () => {
const m = map();
const edits = { [m.layer.id]: { '3,3': m.boxId } };
const first = liveLayers(m.asset, edits)[0]!;
expect(liveLayers(m.asset, edits)[0]).toBe(first);
m.layer.cells['2,3'] = m.boxId;
cellsChanged(m.layer.cells);
const painted = liveLayers(m.asset, edits)[0]!;
expect(painted).not.toBe(first);
expect(painted.cells['2,3']).toBe(m.boxId);
expect(painted.cells['3,3']).toBe(m.boxId);
m.layer.z = 3;
expect(liveLayers(m.asset, edits)[0]!.z).toBe(3);
});
it('the physics works a still map out once, and again once it is moved', () => {
const m = map();
const shapesOf = vi.spyOn(Solids.prototype, 'solidShapesOf');
const s = startWorld(m, { physics: true, collisions: true });
const mapWorkedOut = () =>
shapesOf.mock.calls.filter(([, body, within]) => body.objectId === m.mapId && !within).length;
for (let i = 0; i < 5; i++) s.frame();
expect(mapWorkedOut()).toBe(1);
s.world.findByObjectId(m.mapId)!.x += 10;
s.frame();
expect(mapWorkedOut()).toBe(2);
s.frame();
expect(mapWorkedOut()).toBe(2);
});
});