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 <noreply@anthropic.com>
This commit is contained in:
1 parent
88e1cf1c7d
commit
df6dbe97c5
4 files changed
+198
-9
No files matched your search
@@ -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<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. */
|
||||
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])]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user