From df6dbe97c5c15c9a599c908206a5ce5e56c6e8cd Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 14 Oct 2026 18:41:41 +0200 Subject: [PATCH] Build only the meshes of the tiles a painted cell changes (#424) 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 --- src/core/model/tilemap.ts | 106 +++++++++++++++++++++++++++++-- src/viewport/drawOrder.ts | 15 ++++- src/viewport/tilemapMesh.ts | 12 +++- tests/core/tilemap-patch.test.ts | 74 +++++++++++++++++++++ 4 files changed, 198 insertions(+), 9 deletions(-) diff --git a/src/core/model/tilemap.ts b/src/core/model/tilemap.ts index 25095240..f8738ee5 100644 --- a/src/core/model/tilemap.ts +++ b/src/core/model/tilemap.ts @@ -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 - * 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( list: readonly T[], indexOf: (entry: T) => number, indices: readonly number[], - make: (index: number) => readonly T[], + make: (index: number, before: readonly T[]) => readonly T[], ): T[] { const out: T[] = []; let i = 0; for (const index of indices) { while (i < list.length && indexOf(list[i]!) < index) out.push(list[i++]!); + const start = 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++]!); return out; @@ -317,23 +319,115 @@ function workOutLayerTiles( 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( props: PropertyBag, layer: TilemapLayer, kinds: Map, tiles: readonly TileDraw[], changed: readonly string[], -): TileDraw[] { +): readonly TileDraw[] { const cells = layerCells(props, layer); - return patchedInOrder( + const removed: TileDraw[] = []; + const added: TileDraw[] = []; + const patched = patchedInOrder( tiles, (tile) => tile.y * cells.columns + tile.x, cellsAround(changed, cells.columns, cells.rows), - (index) => { + (index, [held]) => { 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] : []; }, ); + 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(); + +/** 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( + groups: ReadonlyMap, + patch: TilesPatch, + keyOf: (tile: TileDraw) => K, + order: (a: [K, readonly TileDraw[]], b: [K, readonly TileDraw[]]) => number = ([, a], [, b]) => + byCell(a[0]!, b[0]!), +): Map { + const touched = new Map(); + 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; } /** diff --git a/src/viewport/drawOrder.ts b/src/viewport/drawOrder.ts index 749c2389..6bb0534f 100644 --- a/src/viewport/drawOrder.ts +++ b/src/viewport/drawOrder.ts @@ -1,6 +1,6 @@ import { isSymbolType } from '../core/model/AssetTypes'; 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 { SORT_WITH_PARENT_KEY } from '../core/properties/PropertySchema'; import type { DrawItem, TilemapDraw, TilemapLayerDraw } from './items'; @@ -36,11 +36,24 @@ const rowsOf = new WeakMap `${id}:${tile.sortOffset}`).join(','); const held = rowsOf.get(layer.tiles); 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(); for (const tile of layer.tiles) { const row = sortRowOf(draw, tile); diff --git a/src/viewport/tilemapMesh.ts b/src/viewport/tilemapMesh.ts index 56847636..ad369340 100644 --- a/src/viewport/tilemapMesh.ts +++ b/src/viewport/tilemapMesh.ts @@ -5,7 +5,7 @@ * checked without a browser. */ 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. */ export interface TileSheet { @@ -95,11 +95,19 @@ const grouped = new WeakMap { const held = grouped.get(tiles); 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(); for (const drawn of tiles) { const group = groups.get(drawn.tileId); diff --git a/tests/core/tilemap-patch.test.ts b/tests/core/tilemap-patch.test.ts index 75de4128..ede66716 100644 --- a/tests/core/tilemap-patch.test.ts +++ b/tests/core/tilemap-patch.test.ts @@ -23,6 +23,7 @@ import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY, + type TileDraw, } from '../../src/core/model/tilemap'; import { oneWayAngles, @@ -39,6 +40,9 @@ import { cellsTileIds, cellsVersion, } 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'; const COLUMNS = 12; @@ -160,6 +164,32 @@ function workedOut(m: ReturnType, 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, layer: TilemapLayer, last: Set) { + 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(groups) }; +} + /** 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 } })) }; @@ -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 counts = { paint: 0, undo: 0, named: 0, unnamed: 0 }; 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++) { const roll = next(); 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 fresh = workedOut(m, afresh(m.asset)); 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. expect(Math.min(...Object.values(counts))).toBeGreaterThan(20); + expect(reused).toBeGreaterThan(1000); const end = workedOut(m, m.asset); expect(end.whole.rects.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))[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])]); + }); });