Keep a tilemap with outlined solids at 60 fps in scene and play
A project with three tilemap layers, two of them with collision on water and hill tiles, ran at 33 to 52 fps. A CPU profile showed that almost all of the time went into the collision geometry of the map. It was rebuilt from scratch every frame although nothing had changed. The animated water makes the views redraw every frame, so the cost was never skipped. - New module tilemapMemo keeps what is derived from the cells: the tiles of a layer, the blocking cells, the rectangles and polygons. It is reused as long as the content stays the same. - A brush stroke paints into the existing cells object. The memo therefore compares the content cell by cell, not the identity. The comparison walks two arrays in insertion order. - Clipped outlines are kept per outline list. The model always replaces that list instead of changing it. - PhysicsSystem no longer rebuilds the shapes of a tilemap each tick while its cells, placement and settings are unchanged. Before, it also built a JSON key from all world shapes every tick. - The overlaid layers after Set tile in a run stay stable. Otherwise the memo would miss on every call after the first edit. The #184 work only helped Move and raycasts, not this path. Measured on stein2 (i5-7500T, HD 630), script time per frame: - Scene, nothing selected: 17.7 ms, 52 fps -> 5.8 ms, 60 fps - Tilemap selected: 26.2 ms, 38 fps -> 8.6 ms, 60 fps - Play: 29.9 ms, 33 fps -> 8.7 ms, 60 fps About 3 ms per frame remain. The content comparison costs roughly 60 us per 700-cell layer and runs about 40 times a frame. The mesh geometries in TilemapField are still rebuilt every frame. Tests: - ./check.sh: 162 files, 2377 tests, CHECK EXIT 0 (before 2370) - e2e 1-5 and 39-43: ALL PASSED, 289 checks - 7 new unit tests in tests/core/tilemap-memo.test.ts - 5 counter checks (content ignored, no memo, outline by kind only, physics always rebuilds, layer settings ignored) each fail their test. All originals are back, checked with cmp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
021a6dcb56
commit
03e7a78c3d
10 files changed
+491
-79
No files matched your search
@@ -39,7 +39,17 @@ import {
|
||||
tileKind,
|
||||
type TileKind,
|
||||
} from './tile';
|
||||
import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapSize, tilemapTileSize } from './tilemap';
|
||||
import {
|
||||
cellOrigin,
|
||||
inGrid,
|
||||
neighboursOf,
|
||||
parseCellKey,
|
||||
tilemapColumns,
|
||||
tilemapRows,
|
||||
tilemapSize,
|
||||
tilemapTileSize,
|
||||
} from './tilemap';
|
||||
import { cellsTileIds, cellsVersion, identityToken, KeyedMemo } from './tilemapMemo';
|
||||
import { type ColliderShape, oneWayAngle, type ShapeBounds } from '../properties/collider';
|
||||
import { colliderMaterial } from '../properties/rigidbody';
|
||||
|
||||
@@ -126,19 +136,34 @@ export function tileShape(project: Project, tile: TileAsset): TileShape | null {
|
||||
const frame = id ? findFrame(project, id)?.frame : undefined;
|
||||
if (!frame?.collision || frame.w <= 0 || frame.h <= 0) return null;
|
||||
const kind = tileKind(tile);
|
||||
const quad = kind === 'none' ? 0 : readTile(project, tile).quad;
|
||||
// Outlines are replaced, never edited in place, so the array stands for what is drawn.
|
||||
let known = shapes.get(frame.collision);
|
||||
if (!known) shapes.set(frame.collision, (known = new Map<string, TileShape | null>()));
|
||||
const key = `${kind}|${quad}|${frame.w}|${frame.h}`;
|
||||
if (!known.has(key)) known.set(key, workOutShape(frame.collision, frame.w, frame.h, kind, quad));
|
||||
return known.get(key)!;
|
||||
}
|
||||
|
||||
const shapes = new WeakMap<Vec2[][], Map<string, TileShape | null>>();
|
||||
|
||||
function workOutShape(
|
||||
collision: Vec2[][],
|
||||
w: number,
|
||||
h: number,
|
||||
kind: TileKind,
|
||||
quad: number,
|
||||
): TileShape | null {
|
||||
if (kind === 'none') {
|
||||
const across = frame.collision.map((o) => o.map((p) => ({ x: p.x / frame.w, y: p.y / frame.h })));
|
||||
const across = collision.map((o) => o.map((p) => ({ x: p.x / w, y: p.y / h })));
|
||||
const outlines = validOutlines(across);
|
||||
return outlines.length > 0 ? { kind: 'plain', outlines } : null;
|
||||
}
|
||||
const { quad } = readTile(project, tile);
|
||||
if (quad <= 0) return null;
|
||||
const outlines = validOutlines(
|
||||
frame.collision.map((o) => o.map((p) => ({ x: p.x / quad, y: p.y / quad }))),
|
||||
);
|
||||
const outlines = validOutlines(collision.map((o) => o.map((p) => ({ x: p.x / quad, y: p.y / quad }))));
|
||||
if (outlines.length === 0) return null;
|
||||
const columns = Math.round(frame.w / quad);
|
||||
const rows = Math.round(frame.h / quad);
|
||||
const columns = Math.round(w / quad);
|
||||
const rows = Math.round(h / quad);
|
||||
const quarters = new Map<string, QuarterBlock>();
|
||||
for (let qy = 0; qy < rows; qy++)
|
||||
for (let qx = 0; qx < columns; qx++) quarters.set(`${qx},${qy}`, quarterBlock(outlines, qx, qy));
|
||||
@@ -228,8 +253,8 @@ export function tileMaterials(
|
||||
const materials: TileMaterial[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const layer of asset.layers)
|
||||
for (const tileId of Object.values(layer.cells)) {
|
||||
if (!tileId || seen.has(tileId)) continue;
|
||||
for (const tileId of cellsTileIds(layer.cells)) {
|
||||
if (seen.has(tileId)) continue;
|
||||
seen.add(tileId);
|
||||
const tile = findAssetOf(project, tileId, 'tile');
|
||||
if (!tile || !isTileSolid(tile) || !passesFilter(tile, oneWay)) continue;
|
||||
@@ -244,8 +269,8 @@ export function oneWayAngles(project: Project, asset: TilemapAsset): number[] {
|
||||
const angles = new Set<number>();
|
||||
const seen = new Set<string>();
|
||||
for (const layer of asset.layers)
|
||||
for (const tileId of Object.values(layer.cells)) {
|
||||
if (!tileId || seen.has(tileId)) continue;
|
||||
for (const tileId of cellsTileIds(layer.cells)) {
|
||||
if (seen.has(tileId)) continue;
|
||||
seen.add(tileId);
|
||||
const tile = findAssetOf(project, tileId, 'tile');
|
||||
if (tile && isTileSolid(tile) && isTileOneWay(tile)) angles.add(tileOneWayAngle(tile));
|
||||
@@ -253,24 +278,52 @@ export function oneWayAngles(project: Project, asset: TilemapAsset): number[] {
|
||||
return [...angles].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
type BlockingTile = { scale: number; shape: TileShape | null } | null;
|
||||
|
||||
const blocking = new KeyedMemo<readonly CellBlock[]>();
|
||||
|
||||
/**
|
||||
* Worked out once per content: the key holds the grid, the filter, the version of every layer's
|
||||
* cells and how each tile painted there blocks, which is all the answer depends on.
|
||||
*/
|
||||
function blockingCells(
|
||||
project: Project,
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
oneWay: OneWayFilter,
|
||||
material?: TileMaterial,
|
||||
): readonly CellBlock[] {
|
||||
const tiles = new Map<string, BlockingTile>();
|
||||
for (const layer of asset.layers)
|
||||
for (const id of cellsTileIds(layer.cells)) {
|
||||
if (tiles.has(id)) continue;
|
||||
const tile = findAssetOf(project, id, 'tile');
|
||||
tiles.set(
|
||||
id,
|
||||
tile && isTileSolid(tile) && passesFilter(tile, oneWay, material)
|
||||
? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) }
|
||||
: null,
|
||||
);
|
||||
}
|
||||
const key = [
|
||||
tilemapColumns(props),
|
||||
tilemapRows(props),
|
||||
oneWay,
|
||||
material ? `${material.friction}/${material.bounciness}` : '',
|
||||
asset.layers.map((layer) => cellsVersion(layer.cells)).join(','),
|
||||
[...tiles]
|
||||
.map(([id, t]) => (t ? `${id}:${t.scale}:${t.shape ? identityToken(t.shape) : 'box'}` : id))
|
||||
.join(','),
|
||||
].join('|');
|
||||
return blocking.get(key, () => workOutBlockingCells(props, asset, tiles));
|
||||
}
|
||||
|
||||
function workOutBlockingCells(
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
tiles: Map<string, BlockingTile>,
|
||||
): CellBlock[] {
|
||||
const tiles = new Map<string, { scale: number; shape: TileShape | null } | null>();
|
||||
const tileOf = (id: string) => {
|
||||
if (tiles.has(id)) return tiles.get(id)!;
|
||||
const tile = findAssetOf(project, id, 'tile');
|
||||
const value =
|
||||
tile && isTileSolid(tile) && passesFilter(tile, oneWay, material)
|
||||
? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) }
|
||||
: null;
|
||||
tiles.set(id, value);
|
||||
return value;
|
||||
};
|
||||
const tileOf = (id: string) => tiles.get(id) ?? null;
|
||||
const found = new Map<string, CellBlock>();
|
||||
for (const layer of asset.layers) {
|
||||
for (const [key, tileId] of Object.entries(layer.cells)) {
|
||||
@@ -324,9 +377,11 @@ export function solidCells(
|
||||
oneWay: OneWayFilter = false,
|
||||
material?: TileMaterial,
|
||||
): Required<SolidCell>[] {
|
||||
return blockingCells(project, props, asset, oneWay, material)
|
||||
.filter((cell) => cell.box !== null)
|
||||
.map(({ x, y, box }) => ({ x, y, scale: box! }));
|
||||
return boxCells(blockingCells(project, props, asset, oneWay, material));
|
||||
}
|
||||
|
||||
function boxCells(cells: readonly CellBlock[]): Required<SolidCell>[] {
|
||||
return cells.filter((cell) => cell.box !== null).map(({ x, y, box }) => ({ x, y, scale: box! }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,15 +396,34 @@ export function solidPolygons(
|
||||
asset: TilemapAsset,
|
||||
oneWay: OneWayFilter = false,
|
||||
material?: TileMaterial,
|
||||
): CellPolygon[] {
|
||||
const polygons: CellPolygon[] = [];
|
||||
for (const cell of blockingCells(project, props, asset, oneWay, material)) {
|
||||
if (cell.box !== null) continue;
|
||||
for (const { outline, pieces } of cell.polygons) polygons.push({ x: cell.x, y: cell.y, outline, pieces });
|
||||
}
|
||||
return polygons.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
): readonly CellPolygon[] {
|
||||
return derived(polygonsOf, blockingCells(project, props, asset, oneWay, material), (cells) => {
|
||||
const polygons: CellPolygon[] = [];
|
||||
for (const cell of cells) {
|
||||
if (cell.box !== null) continue;
|
||||
for (const { outline, pieces } of cell.polygons)
|
||||
polygons.push({ x: cell.x, y: cell.y, outline, pieces });
|
||||
}
|
||||
return polygons.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The same cells hand out the same rectangles and polygons, the very same arrays – so whoever builds
|
||||
* on them can tell by identity that nothing changed (the physics does, every tick).
|
||||
*/
|
||||
function derived<T>(
|
||||
held: WeakMap<readonly CellBlock[], T>,
|
||||
cells: readonly CellBlock[],
|
||||
make: (cells: readonly CellBlock[]) => T,
|
||||
): T {
|
||||
if (!held.has(cells)) held.set(cells, make(cells));
|
||||
return held.get(cells)!;
|
||||
}
|
||||
|
||||
const polygonsOf = new WeakMap<readonly CellBlock[], readonly CellPolygon[]>();
|
||||
const rectsOf = new WeakMap<readonly CellBlock[], readonly CellRect[]>();
|
||||
|
||||
/** A rectangle by its four sides, which is what the merge compares. */
|
||||
interface Piece {
|
||||
x0: number;
|
||||
@@ -456,8 +530,10 @@ export function solidRects(
|
||||
asset: TilemapAsset,
|
||||
oneWay: OneWayFilter = false,
|
||||
material?: TileMaterial,
|
||||
): CellRect[] {
|
||||
return mergeCells(solidCells(project, props, asset, oneWay, material));
|
||||
): readonly CellRect[] {
|
||||
return derived(rectsOf, blockingCells(project, props, asset, oneWay, material), (cells) =>
|
||||
mergeCells(boxCells(cells)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user