Build only the meshes of the tiles a painted cell changes (#424)
feuerwerk / gate (push) Successful in 4m34s
feuerwerk / suite (push) Failing after 18m22s

Since #348 a painted cell worked out only the cells around it, but the
layer's tiles still came as a new list. groupByTile grouped that list
afresh, so meshFor saw no group again and every mesh on the layer was
built anew. On 120x120 that was the last cost that grew with the map.

- A patched list of tiles remembers the list it came from and which
  entries went and came (tilesPatch). A cell that is drawn the same
  keeps its entry, and a patch that changes nothing drawn hands back
  the very same list.
- groupByTile and layerRows patch the groups of the list before
  (patchedGroups). An untouched group stays the same array, so its
  mesh is not built again. On a layer sorted by Y this holds for each
  row and for the groups in it.
- A tile new to the layer still works the whole layer out again, as
  since #348.
- Stage 2 of the issue (patching quads inside a mesh) is not needed.
  The tilemap geometry no longer shows in the CPU profile.

Measured with the #348 probe, a brush stroke on a copy of Garden of
Sacrifices on stein2:

- Meshes built per frame: 21.9 to 0.94 on 60x60, 40.7 to 1.22 on
  120x120.
- fps while painting: 49.4 to 51.1 on 60x60, 42.0 to 45.8 on 120x120.
- Painting now costs both sizes about 10 fps. The gap left between the
  sizes is drawing the larger map, which shows without painting too.

Tests:
- Gate on melektaus-atx green, 312 files, 4832 tests.
- The random walk from #348 also compares groups and rows with a fresh
  grouping after each of its 400 steps, and counts reused groups.
- A fixed test paints a cell and keeps every other group and row.
- Three counter-checks turn red: entries not removed from a group,
  rows sorted by their first cell, groupByTile ignoring the patch.
- e2e 39 + 40 on melektaus-atx: ALL PASSED, 107 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-14 18:41:41 +02:00
1 parent 88e1cf1c7d
commit df6dbe97c5
4 files changed
+198 -9

No files matched your search

+100 -6
View File
@@ -223,20 +223,22 @@ export function cellsAround(keys: readonly string[], columns: number, rows: numb
/** /**
* A list in cell order with the entries at `indices` (ascending) worked out again: `make` gives a * A list in cell order with the entries at `indices` (ascending) worked out again: `make` gives a
* cell's new entries, none or several. A new list – the old one may still be held by identity. * cell's new entries, none or several, from the ones it had. A new list – the old one may still be
* held by identity.
*/ */
export function patchedInOrder<T>( export function patchedInOrder<T>(
list: readonly T[], list: readonly T[],
indexOf: (entry: T) => number, indexOf: (entry: T) => number,
indices: readonly number[], indices: readonly number[],
make: (index: number) => readonly T[], make: (index: number, before: readonly T[]) => readonly T[],
): T[] { ): T[] {
const out: T[] = []; const out: T[] = [];
let i = 0; let i = 0;
for (const index of indices) { for (const index of indices) {
while (i < list.length && indexOf(list[i]!) < index) out.push(list[i++]!); while (i < list.length && indexOf(list[i]!) < index) out.push(list[i++]!);
const start = i;
while (i < list.length && indexOf(list[i]!) === index) i++; while (i < list.length && indexOf(list[i]!) === index) i++;
for (const entry of make(index)) out.push(entry); for (const entry of make(index, list.slice(start, i))) out.push(entry);
} }
while (i < list.length) out.push(list[i++]!); while (i < list.length) out.push(list[i++]!);
return out; return out;
@@ -317,23 +319,115 @@ function workOutLayerTiles(
return tiles; return tiles;
} }
/**
* A cell around the painted ones that still looks the same keeps its entry, so only what is drawn
* otherwise counts as changed (#424). Nothing drawn otherwise hands back the very same list.
*/
function patchedLayerTiles( function patchedLayerTiles(
props: PropertyBag, props: PropertyBag,
layer: TilemapLayer, layer: TilemapLayer,
kinds: Map<string, TileKind>, kinds: Map<string, TileKind>,
tiles: readonly TileDraw[], tiles: readonly TileDraw[],
changed: readonly string[], changed: readonly string[],
): TileDraw[] { ): readonly TileDraw[] {
const cells = layerCells(props, layer); const cells = layerCells(props, layer);
return patchedInOrder( const removed: TileDraw[] = [];
const added: TileDraw[] = [];
const patched = patchedInOrder(
tiles, tiles,
(tile) => tile.y * cells.columns + tile.x, (tile) => tile.y * cells.columns + tile.x,
cellsAround(changed, cells.columns, cells.rows), cellsAround(changed, cells.columns, cells.rows),
(index) => { (index, [held]) => {
const drawn = tileDrawAt(cells, kinds, index); const drawn = tileDrawAt(cells, kinds, index);
if (held && drawn && sameTileDraw(held, drawn)) return [held];
if (held) removed.push(held);
if (drawn) added.push(drawn);
return drawn ? [drawn] : []; return drawn ? [drawn] : [];
}, },
); );
if (removed.length === 0 && added.length === 0) return tiles;
noteTilesPatch(patched, { from: tiles, removed, added });
return patched;
}
function sameTileDraw(a: TileDraw, b: TileDraw): boolean {
return (
a.x === b.x &&
a.y === b.y &&
a.tileId === b.tileId &&
a.quadrants.every((q, i) => q.x === b.quadrants[i]!.x && q.y === b.quadrants[i]!.y)
);
}
/** How a list of tiles came out of the one before it (#424): the entries gone and the ones new. */
export interface TilesPatch {
from: readonly TileDraw[];
removed: readonly TileDraw[];
added: readonly TileDraw[];
}
const patches = new WeakMap<readonly TileDraw[], TilesPatch>();
/** Null for a list worked out whole, or one whose patch was passed on to a newer list. */
export function tilesPatch(tiles: readonly TileDraw[]): TilesPatch | null {
return patches.get(tiles) ?? null;
}
/** One step only: a chain of patched lists would otherwise keep every list before alive. */
function noteTilesPatch(tiles: readonly TileDraw[], patch: TilesPatch): void {
patches.delete(patch.from);
patches.set(tiles, patch);
}
/**
* `groups` of the list `patch` came from, grouped for the list it made: a group no changed entry
* falls into stays the very same array, the others are patched in cell order and note their own
* patch. In the order `order` gives, by default the one a fresh grouping has – by first cell.
*/
export function patchedGroups<K>(
groups: ReadonlyMap<K, readonly TileDraw[]>,
patch: TilesPatch,
keyOf: (tile: TileDraw) => K,
order: (a: [K, readonly TileDraw[]], b: [K, readonly TileDraw[]]) => number = ([, a], [, b]) =>
byCell(a[0]!, b[0]!),
): Map<K, readonly TileDraw[]> {
const touched = new Map<K, { removed: TileDraw[]; added: TileDraw[] }>();
const changeOf = (key: K) => {
let change = touched.get(key);
if (!change) touched.set(key, (change = { removed: [], added: [] }));
return change;
};
for (const tile of patch.removed) changeOf(keyOf(tile)).removed.push(tile);
for (const tile of patch.added) changeOf(keyOf(tile)).added.push(tile);
const out = new Map(groups);
for (const [key, { removed, added }] of touched) {
const from = groups.get(key) ?? [];
const gone = new Set(removed);
const merged = mergedInCellOrder(
from.filter((tile) => !gone.has(tile)),
[...added].sort(byCell),
);
if (merged.length === 0) out.delete(key);
else {
out.set(key, merged);
noteTilesPatch(merged, { from, removed, added });
}
}
return new Map([...out].sort(order));
}
function byCell(a: TileDraw, b: TileDraw): number {
return a.y - b.y || a.x - b.x;
}
function mergedInCellOrder(a: readonly TileDraw[], b: readonly TileDraw[]): TileDraw[] {
const out: TileDraw[] = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) out.push(byCell(a[i]!, b[j]!) <= 0 ? a[i++]! : b[j++]!);
while (i < a.length) out.push(a[i++]!);
while (j < b.length) out.push(b[j++]!);
return out;
} }
/** /**
+14 -1
View File
@@ -1,6 +1,6 @@
import { isSymbolType } from '../core/model/AssetTypes'; import { isSymbolType } from '../core/model/AssetTypes';
import { sortByZ } from '../core/model/Properties'; import { sortByZ } from '../core/model/Properties';
import { cellOrigin, type TileDraw, tilemapTileSize } from '../core/model/tilemap'; import { cellOrigin, patchedGroups, type TileDraw, tilemapTileSize, tilesPatch } from '../core/model/tilemap';
import { footprintExtent } from '../core/properties/bounds'; import { footprintExtent } from '../core/properties/bounds';
import { SORT_WITH_PARENT_KEY } from '../core/properties/PropertySchema'; import { SORT_WITH_PARENT_KEY } from '../core/properties/PropertySchema';
import type { DrawItem, TilemapDraw, TilemapLayerDraw } from './items'; import type { DrawItem, TilemapDraw, TilemapLayerDraw } from './items';
@@ -36,11 +36,24 @@ const rowsOf = new WeakMap<readonly TileDraw[], { offsets: string; rows: LayerRo
/** /**
* The cells of a layer sorted by Y, grouped by the row they sort by, top row first. The same cells * The cells of a layer sorted by Y, grouped by the row they sort by, top row first. The same cells
* and offsets hand out the very same rows, so a mesh built from one can tell it need not be rebuilt. * and offsets hand out the very same rows, so a mesh built from one can tell it need not be rebuilt.
* Cells patched in from the list before touch only their rows, the others stay the same (#424).
*/ */
export function layerRows(draw: TilemapDraw, layer: TilemapLayerDraw): LayerRows { export function layerRows(draw: TilemapDraw, layer: TilemapLayerDraw): LayerRows {
const offsets = [...draw.tiles].map(([id, tile]) => `${id}:${tile.sortOffset}`).join(','); const offsets = [...draw.tiles].map(([id, tile]) => `${id}:${tile.sortOffset}`).join(',');
const held = rowsOf.get(layer.tiles); const held = rowsOf.get(layer.tiles);
if (held?.offsets === offsets) return held.rows; if (held?.offsets === offsets) return held.rows;
const patch = tilesPatch(layer.tiles);
const before = patch && rowsOf.get(patch.from);
if (patch && before?.offsets === offsets) {
const rows = patchedGroups(
before.rows,
patch,
(tile) => sortRowOf(draw, tile),
([a], [b]) => a - b,
);
rowsOf.set(layer.tiles, { offsets, rows });
return rows;
}
const grouped = new Map<number, TileDraw[]>(); const grouped = new Map<number, TileDraw[]>();
for (const tile of layer.tiles) { for (const tile of layer.tiles) {
const row = sortRowOf(draw, tile); const row = sortRowOf(draw, tile);
+10 -2
View File
@@ -5,7 +5,7 @@
* checked without a browser. * checked without a browser.
*/ */
import type { FrameRect } from '../core/model/texture'; import type { FrameRect } from '../core/model/texture';
import type { TileDraw } from '../core/model/tilemap'; import { patchedGroups, type TileDraw, tilesPatch } from '../core/model/tilemap';
/** Where a tile's quarters are cut from, in the pixels of the texture the mesh wears. */ /** Where a tile's quarters are cut from, in the pixels of the texture the mesh wears. */
export interface TileSheet { export interface TileSheet {
@@ -95,11 +95,19 @@ const grouped = new WeakMap<readonly TileDraw[], ReadonlyMap<string, readonly Ti
/** /**
* The cells of one layer grouped by tile – one group is one mesh, because it is one texture. A list * The cells of one layer grouped by tile – one group is one mesh, because it is one texture. A list
* of tiles is never edited, so the same list hands out the very same groups: that is how a mesh * of tiles is never edited, so the same list hands out the very same groups: that is how a mesh
* tells its cells are the ones it was built from (see `TilemapField.sync`). * tells its cells are the ones it was built from (see `TilemapField.sync`). A list patched from one
* grouped before keeps the groups no painted cell falls into, so only their meshes are built (#424).
*/ */
export function groupByTile(tiles: readonly TileDraw[]): ReadonlyMap<string, readonly TileDraw[]> { export function groupByTile(tiles: readonly TileDraw[]): ReadonlyMap<string, readonly TileDraw[]> {
const held = grouped.get(tiles); const held = grouped.get(tiles);
if (held) return held; if (held) return held;
const patch = tilesPatch(tiles);
const before = patch && grouped.get(patch.from);
if (before) {
const groups = patchedGroups(before, patch, (tile) => tile.tileId);
grouped.set(tiles, groups);
return groups;
}
const groups = new Map<string, TileDraw[]>(); const groups = new Map<string, TileDraw[]>();
for (const drawn of tiles) { for (const drawn of tiles) {
const group = groups.get(drawn.tileId); const group = groups.get(drawn.tileId);
+74
View File
@@ -23,6 +23,7 @@ import {
TILEMAP_COLUMNS_KEY, TILEMAP_COLUMNS_KEY,
TILEMAP_ROWS_KEY, TILEMAP_ROWS_KEY,
TILEMAP_TILE_KEY, TILEMAP_TILE_KEY,
type TileDraw,
} from '../../src/core/model/tilemap'; } from '../../src/core/model/tilemap';
import { import {
oneWayAngles, oneWayAngles,
@@ -39,6 +40,9 @@ import {
cellsTileIds, cellsTileIds,
cellsVersion, cellsVersion,
} from '../../src/core/model/tilemapMemo'; } from '../../src/core/model/tilemapMemo';
import { layerRows } from '../../src/viewport/drawOrder';
import type { TilemapDraw, TilemapLayerDraw } from '../../src/viewport/items';
import { groupByTile } from '../../src/viewport/tilemapMesh';
import { makeSpriteScene } from '../helpers'; import { makeSpriteScene } from '../helpers';
const COLUMNS = 12; const COLUMNS = 12;
@@ -160,6 +164,32 @@ function workedOut(m: ReturnType<typeof patchMap>, asset: TilemapAsset) {
}; };
} }
/**
* A layer's tiles as the meshes take them (#424): grouped by tile, and by sort row and tile with
* offsets that move some tiles a row up or down. Each next to the same grouping of a copy of the
* list, which nothing was patched from; `reused` counts the groups handed on from the last call.
*/
function grouping(m: ReturnType<typeof patchMap>, layer: TilemapLayer, last: Set<readonly unknown[]>) {
const draw = {
tiles: new Map(m.tiles.map((id, i) => [id, { sortOffset: (i % 4) - 1 }])),
} as unknown as TilemapDraw;
const tiles = layerTiles(m.project, m.asset.props, layer);
const of = (list: readonly TileDraw[]) => {
const rows = layerRows(draw, { tiles: list } as TilemapLayerDraw);
return {
byTile: [...groupByTile(list)],
byRow: [...rows].map(([row, cells]) => [row, cells, [...groupByTile(cells)]] as const),
};
};
const patched = of(tiles);
const groups = [
...patched.byTile.map(([, group]) => group),
...patched.byRow.flatMap(([, cells, byTile]) => [cells, ...byTile.map(([, group]) => group)]),
];
const reused = groups.filter((group) => last.has(group)).length;
return { patched, fresh: of([...tiles]), reused, groups: new Set<readonly unknown[]>(groups) };
}
/** The same map on cells never seen before, so nothing of it is patched. */ /** The same map on cells never seen before, so nothing of it is patched. */
function afresh(asset: TilemapAsset): TilemapAsset { function afresh(asset: TilemapAsset): TilemapAsset {
return { ...asset, layers: asset.layers.map((layer) => ({ ...layer, cells: { ...layer.cells } })) }; return { ...asset, layers: asset.layers.map((layer) => ({ ...layer, cells: { ...layer.cells } })) };
@@ -228,6 +258,8 @@ describe('a painted map works out only the cells around what changed (#348)', ()
const layerOf = (): TilemapLayer => pick(m.asset.layers); const layerOf = (): TilemapLayer => pick(m.asset.layers);
const counts = { paint: 0, undo: 0, named: 0, unnamed: 0 }; const counts = { paint: 0, undo: 0, named: 0, unnamed: 0 };
workedOut(m, m.asset); workedOut(m, m.asset);
const lastGroups = m.asset.layers.map((layer) => grouping(m, layer, new Set()).groups);
let reused = 0;
for (let step = 0; step < 400; step++) { for (let step = 0; step < 400; step++) {
const roll = next(); const roll = next();
if (roll < 0.5) { if (roll < 0.5) {
@@ -259,9 +291,16 @@ describe('a painted map works out only the cells around what changed (#348)', ()
const patched = workedOut(m, m.asset); const patched = workedOut(m, m.asset);
const fresh = workedOut(m, afresh(m.asset)); const fresh = workedOut(m, afresh(m.asset));
expect(patched, `step ${step}`).toEqual(fresh); expect(patched, `step ${step}`).toEqual(fresh);
m.asset.layers.forEach((layer, i) => {
const groups = grouping(m, layer, lastGroups[i]!);
expect(groups.patched, `step ${step}, layer ${i}`).toEqual(groups.fresh);
reused += groups.reused;
lastGroups[i] = groups.groups;
});
} }
// The walk went everywhere it should: enough of each step, and maps that block in every way. // 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); expect(Math.min(...Object.values(counts))).toBeGreaterThan(20);
expect(reused).toBeGreaterThan(1000);
const end = workedOut(m, m.asset); const end = workedOut(m, m.asset);
expect(end.whole.rects.length).toBeGreaterThan(5); expect(end.whole.rects.length).toBeGreaterThan(5);
expect(end.whole.polygons.length).toBeGreaterThan(5); expect(end.whole.polygons.length).toBeGreaterThan(5);
@@ -310,4 +349,39 @@ describe('a painted map works out only the cells around what changed (#348)', ()
expect(far(solidPolygons(m.project, m.asset.props, m.asset))).toEqual(far(polygons)); 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]); expect(far(solidPolygons(m.project, m.asset.props, m.asset))[0]).toBe(far(polygons)[0]);
}); });
it('a painted cell regroups only the tiles and rows it touches, so only their meshes are built (#424)', () => {
const m = patchMap();
const [slope, lake, , wall] = 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!,
}));
// A wall in a corner already: a tile new to the layer would work all of it out again (#348).
all[all.length - 1]!.tileId = wall!;
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, all));
const draw = { tiles: new Map(m.tiles.map((id) => [id, { sortOffset: 0 }])) } as unknown as TilemapDraw;
const tiles = layerTiles(m.project, m.asset.props, layer);
const groups = groupByTile(tiles);
const rows = layerRows(draw, { tiles } as TilemapLayerDraw);
// A wall on the slopes, away from the lake: the lake looks the same, so its group stays.
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 10, y: 4, tileId: wall! }]));
const after = layerTiles(m.project, m.asset.props, layer);
const regrouped = groupByTile(after);
expect(regrouped.get(lake!)).toBe(groups.get(lake!));
expect(regrouped.get(slope!)).not.toBe(groups.get(slope!));
expect(regrouped.get(wall!)!.map((t) => [t.x, t.y])).toEqual([
[10, 4],
[11, 8],
]);
const rowsAfter = layerRows(draw, { tiles: after } as TilemapLayerDraw);
expect([...rowsAfter.keys()].filter((row) => rowsAfter.get(row) !== rows.get(row))).toEqual([4]);
// And back to a slope, a patch on a patch: still the same lake, and the same as grouped afresh.
m.stack.execute(new PaintTilesCommand(m.asset.id, layer.id, [{ x: 10, y: 4, tileId: slope! }]));
const back = layerTiles(m.project, m.asset.props, layer);
expect(groupByTile(back).get(lake!)).toBe(groups.get(lake!));
expect([...groupByTile(back)]).toEqual([...groupByTile([...back])]);
});
}); });