Work out only the cells around a painted one on a tilemap (#348)

A painted cell worked out the tiles and blocking cells of the whole
map again, so painting got slower the bigger the map. One cell cost
16.8 ms on 60x60 and 54.6 ms on 120x120. Now it costs what is around
it: 3.5 ms and 4.0 ms, against 3.4 ms without painting.

- The brush, the paint command and its undo name the keys they
  changed. Each new version of the cells keeps them for the last 16
  versions (cellsChangedSince).
- The cell index, the tiles of a layer and the blocking cells patch
  only the changed cells and their eight neighbours. Everything else
  stays the same object as before.
- Both lists are in cell order now, so a patch is one walk that keeps
  every other entry.
- The rectangles are merged row by row. A patch joins again only the
  rows around it, one row further than the changed cells, and stacks
  the rest as they were.
- A writer that names no keys costs one walk over the cells. A
  replaced cell object (load, resize, edits at run time) is worked out
  in full as before.
- The tiles a map names come sorted by id. Two trigger tiles on one
  cell now keep that order, whichever way they were painted.
- resolveTilemap walked all tiles every frame, even without painting.
  It now does so only when the list changed.

Found on the way: deleting an outlined autotile can turn the one below
into a full box, which cuts a scaled box two rows further down. Merging
only the patched rows missed that. It is a test of its own now.

A brush stroke on the map doubled to 120x120 runs at 42 fps instead of
28, p95 of a frame 52 ms instead of 92 ms. What still grows with the
map is the mesh of a layer, which is built again on every paint.

Tests:
- tests/core/tilemap-patch.test.ts (new, 7): 400 random steps on two
  layers with every kind of tile, each compared with a fresh full run
- Unit, tilemap and viewport: 1284 green in 57 files
- e2e 39 + 40 (atx): ALL PASSED, 107 checks
- Gate (atx): 312 files, 4831 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-14 16:14:39 +02:00
1 parent 8a49392d7f
commit 88e1cf1c7d
7 files changed
+773 -112

No files matched your search

+313
View File
@@ -0,0 +1,313 @@
import { describe, expect, it } 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, TilemapLayer } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import type { PropertyValue } from '../../src/core/model/Properties';
import {
TILE_COLLISION_SCALE_KEY,
TILE_FRICTION_KEY,
TILE_KIND_KEY,
TILE_ONE_WAY_ANGLE_KEY,
TILE_ONE_WAY_KEY,
TILE_SOLID_KEY,
TILE_TRIGGER_KEY,
} from '../../src/core/model/tile';
import {
cellKey,
cellsAround,
layerTiles,
patchedInOrder,
TILEMAP_COLUMNS_KEY,
TILEMAP_ROWS_KEY,
TILEMAP_TILE_KEY,
} from '../../src/core/model/tilemap';
import {
oneWayAngles,
solidPolygons,
solidRects,
tileMaterials,
triggerCells,
triggerPolygons,
triggerRects,
} from '../../src/core/model/tilemapCollision';
import {
cellsChanged,
cellsChangedSince,
cellsTileIds,
cellsVersion,
} from '../../src/core/model/tilemapMemo';
import { makeSpriteScene } from '../helpers';
const COLUMNS = 12;
const ROWS = 9;
/**
* A 12 × 9 map of two layers and a tile of every kind that works out differently: plain and
* autotiles, with and without outlines, drawn in and out, one-way at two angles, of another
* surface, triggers, one that draws only – and an id no tile has.
*/
function patchMap() {
const f = makeSpriteScene('Thing');
const texture = new CreateAssetCommand('texture', 'Sheet');
f.stack.execute(texture);
const sheet = findAsset(f.project, texture.asset.id) as TextureAsset;
sheet.file = `${'a'.repeat(64)}.png`;
sheet.width = 128;
sheet.height = 128;
const frame = (x: number, y: number, w: number, h: number) => {
const command = new AddFrameCommand(texture.asset.id, { x, y, w, h });
f.stack.execute(command);
return command.frameId;
};
const single = frame(0, 0, 32, 32);
const outlined = frame(0, 32, 64, 96);
const bare = frame(64, 32, 64, 96);
f.stack.execute(
new SetFrameCollisionCommand(texture.asset.id, single, [
[
{ x: 0, y: 0 },
{ x: 32, y: 0 },
{ x: 0, y: 32 },
],
]),
);
f.stack.execute(
new SetFrameCollisionCommand(texture.asset.id, outlined, [
[
{ x: 0, y: 0 },
{ x: 64, y: 0 },
{ x: 0, y: 96 },
],
]),
);
const tile = (name: string, props: Record<string, PropertyValue>, picture?: string) => {
const command = new CreateAssetCommand('tile', name);
f.stack.execute(command);
const id = command.asset.id;
if (picture) f.stack.execute(new AddTilePictureCommand(id, picture));
f.stack.execute(new SetAssetPropertyCommand(id, TILE_KIND_KEY, 'none'));
for (const [key, value] of Object.entries(props))
f.stack.execute(new SetAssetPropertyCommand(id, key, value));
return id;
};
const solid = { [TILE_SOLID_KEY]: true };
const floor = { [TILE_KIND_KEY]: 'floor' };
const tiles = [
tile('Slope', solid, single),
tile('Lake', { ...solid, ...floor }, outlined),
tile('Grass', { ...solid, ...floor, [TILE_COLLISION_SCALE_KEY]: 0.5 }, bare),
tile('Wall', solid),
tile('Bush', { ...solid, [TILE_COLLISION_SCALE_KEY]: 0.5 }),
tile('Big', { ...solid, [TILE_COLLISION_SCALE_KEY]: 1.5 }),
tile('Ledge', { ...solid, [TILE_ONE_WAY_KEY]: true, [TILE_ONE_WAY_ANGLE_KEY]: 30 }),
tile('Steep', { ...solid, [TILE_ONE_WAY_KEY]: true, [TILE_ONE_WAY_ANGLE_KEY]: 60 }, bare),
tile('Ice', { ...solid, [TILE_FRICTION_KEY]: 0 }),
tile('Water', { [TILE_TRIGGER_KEY]: true, ...floor }, bare),
tile('Spikes', { [TILE_TRIGGER_KEY]: true }, single),
tile('Deco', floor, bare),
'no-such-tile',
];
const map = new CreateAssetCommand('tilemap', 'Ground');
f.stack.execute(map);
for (const [key, value] of [
[TILEMAP_COLUMNS_KEY, COLUMNS],
[TILEMAP_ROWS_KEY, ROWS],
[TILEMAP_TILE_KEY, 32],
] as const)
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, key, value));
const asset = findAsset(f.project, map.asset.id) as TilemapAsset;
asset.layers.push({ id: 'upper', name: 'Upper', cells: {} });
return { ...f, asset, tiles };
}
/** A small seeded generator, so a failing walk can be walked again. */
function random(seed: number): () => number {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Everything a map works out, as plain data: tiles per layer, solids per filter, triggers. */
function workedOut(m: ReturnType<typeof patchMap>, asset: TilemapAsset) {
const props = m.asset.props;
const solidsOf = (of: TilemapAsset) => ({
rects: solidRects(m.project, props, of),
polygons: solidPolygons(m.project, props, of),
oneWay: oneWayAngles(m.project, of).map((angle) => ({
angle,
rects: solidRects(m.project, props, of, angle),
polygons: solidPolygons(m.project, props, of, angle),
})),
materials: tileMaterials(m.project, of).map((material) => ({
material,
rects: solidRects(m.project, props, of, false, material),
polygons: solidPolygons(m.project, props, of, false, material),
})),
triggerRects: triggerRects(m.project, props, of),
triggerPolygons: triggerPolygons(m.project, props, of),
triggerCells: [...triggerCells(m.project, props, of)],
});
return {
tiles: asset.layers.map((layer) => layerTiles(m.project, props, layer)),
whole: solidsOf(asset),
perLayer: asset.layers.map((layer) => solidsOf({ ...asset, layers: [layer] })),
};
}
/** The same map on cells never seen before, so nothing of it is patched. */
function afresh(asset: TilemapAsset): TilemapAsset {
return { ...asset, layers: asset.layers.map((layer) => ({ ...layer, cells: { ...layer.cells } })) };
}
describe('a painted map works out only the cells around what changed (#348)', () => {
it('knows which cells changed since a version, and when it does not know', () => {
const cells: Record<string, string> = { '0,0': 'a', '1,0': 'b' };
const first = cellsVersion(cells);
expect(cellsChangedSince(cells, first)).toEqual([]);
cells['2,0'] = 'a';
cellsChanged(cells, ['2,0']);
const second = cellsVersion(cells);
delete cells['0,0'];
cells['1,0'] = 'c';
// Named and not, the same: one walk finds what the painter left out.
cellsChanged(cells);
expect([...cellsChangedSince(cells, first)!].sort()).toEqual(['0,0', '1,0', '2,0']);
expect([...cellsChangedSince(cells, second)!].sort()).toEqual(['0,0', '1,0']);
expect(cellsTileIds(cells)).toEqual(['a', 'c']);
expect(cellsChangedSince(cells, -1)).toBeNull();
expect(cellsChangedSince({ ...cells }, second)).toBeNull();
});
it('a key named but written back as it was is no change', () => {
const cells: Record<string, string> = { '0,0': 'a' };
const first = cellsVersion(cells);
cells['0,0'] = 'b';
cells['0,0'] = 'a';
cellsChanged(cells, ['0,0', '5,5']);
expect(cellsVersion(cells)).toBe(first);
});
it('the cells around a change stay in the grid', () => {
expect(cellsAround(['0,0'], 3, 2)).toEqual([0, 1, 3, 4]);
expect(cellsAround(['2,1', '1,1'], 3, 2)).toEqual([0, 1, 2, 3, 4, 5]);
expect(cellsAround(['9,9', 'nonsense'], 3, 2)).toEqual([]);
});
it('a patch replaces the entries of the cells it works out, keeps the rest and their order', () => {
const entry = (at: number, name: string) => ({ at, name });
const list = [entry(1, 'a'), entry(3, 'b'), entry(3, 'c'), entry(7, 'd')];
const made: Record<number, ReturnType<typeof entry>[]> = {
0: [entry(0, 'new')],
3: [],
5: [entry(5, 'x'), entry(5, 'y')],
};
const patched = patchedInOrder(
list,
(e) => e.at,
[0, 3, 5],
(at) => made[at]!,
);
expect(patched.map((e) => e.name)).toEqual(['new', 'a', 'x', 'y', 'd']);
expect(patched[1]).toBe(list[0]);
});
it('patched and worked out afresh agree after every step of a long walk of painting', () => {
const m = patchMap();
const next = random(348);
const pick = <T>(list: readonly T[]) => list[Math.floor(next() * list.length)]!;
const somewhere = () => ({
x: Math.floor(next() * (COLUMNS + 2)) - 1,
y: Math.floor(next() * (ROWS + 2)) - 1,
});
const layerOf = (): TilemapLayer => pick(m.asset.layers);
const counts = { paint: 0, undo: 0, named: 0, unnamed: 0 };
workedOut(m, m.asset);
for (let step = 0; step < 400; step++) {
const roll = next();
if (roll < 0.5) {
const layer = layerOf();
const edits = Array.from({ length: 1 + Math.floor(next() * 4) }, () => {
const at = somewhere();
return { ...at, tileId: next() < 0.15 ? '' : pick(m.tiles) };
});
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, edits));
counts.paint++;
} else if (roll < 0.65 && m.stack.canUndo) {
m.stack.undo();
counts.undo++;
} else {
// In place, as a stroke paints before it is a command: with its keys, or without.
const layer = layerOf();
const keys = Array.from({ length: 1 + Math.floor(next() * 3) }, () => {
const at = somewhere();
const key = cellKey(at.x, at.y);
const tileId = next() < 0.3 ? '' : pick(m.tiles);
if (tileId) layer.cells[key] = tileId;
else delete layer.cells[key];
return key;
});
const named = next() < 0.5;
cellsChanged(layer.cells, named ? keys : undefined);
counts[named ? 'named' : 'unnamed']++;
}
const patched = workedOut(m, m.asset);
const fresh = workedOut(m, afresh(m.asset));
expect(patched, `step ${step}`).toEqual(fresh);
}
// The walk went everywhere it should: enough of each step, and maps that block in every way.
expect(Math.min(...Object.values(counts))).toBeGreaterThan(20);
const end = workedOut(m, m.asset);
expect(end.whole.rects.length).toBeGreaterThan(5);
expect(end.whole.polygons.length).toBeGreaterThan(5);
expect(end.whole.oneWay).toHaveLength(2);
expect(end.whole.triggerCells.length).toBeGreaterThan(3);
});
it('a cell two rows off is cut again when the autotile between turns into a box', () => {
const m = patchMap();
const [, lake, , , bush, big] = m.tiles;
const layer = m.asset.layers[0]!;
// Found by a search: erasing (3,1) makes the lake at (3,2) block whole, so the big box at (3,3)
// – drawn out on every side without a blocking neighbour – no longer grows upwards.
const rows = [
[bush, lake, lake, lake],
[lake, '', lake, lake],
['', lake, lake, lake],
[lake, '', lake, big],
];
const edits = rows.flatMap((row, y) => row.map((tileId, x) => ({ x, y, tileId: tileId! })));
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, edits));
solidRects(m.project, m.asset.props, m.asset);
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 3, y: 1, tileId: '' }]));
expect(solidRects(m.project, m.asset.props, m.asset)).toEqual(
solidRects(m.project, m.asset.props, afresh(m.asset)),
);
});
it('a painted cell leaves what is drawn and what blocks far from it the very same objects', () => {
const m = patchMap();
const [slope, lake] = m.tiles;
const layer = m.asset.layers[0]!;
const all = Array.from({ length: COLUMNS * ROWS }, (_, i) => ({
x: i % COLUMNS,
y: Math.floor(i / COLUMNS),
tileId: i % COLUMNS < 6 ? lake! : slope!,
}));
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, all));
const tiles = layerTiles(m.project, m.asset.props, layer);
const polygons = solidPolygons(m.project, m.asset.props, m.asset);
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 2, y: 4, tileId: '' }]));
const after = layerTiles(m.project, m.asset.props, layer);
expect(after).not.toBe(tiles);
expect(after.find((t) => t.x === 10 && t.y === 0)).toBe(tiles.find((t) => t.x === 10 && t.y === 0));
const far = (list: readonly { x: number; y: number }[]) => list.filter((p) => p.y === 8 && p.x === 0);
expect(far(solidPolygons(m.project, m.asset.props, m.asset))).toEqual(far(polygons));
expect(far(solidPolygons(m.project, m.asset.props, m.asset))[0]).toBe(far(polygons)[0]);
});
});