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
@@ -0,0 +1,180 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } 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 } from '../../src/core/model/Asset';
|
||||
import { liveLayers } from '../../src/core/model/liveTilemap';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
|
||||
import {
|
||||
layerTiles,
|
||||
TILEMAP_COLUMNS_KEY,
|
||||
TILEMAP_ROWS_KEY,
|
||||
TILEMAP_TILE_KEY,
|
||||
} from '../../src/core/model/tilemap';
|
||||
import { solidPolygons, solidRects } from '../../src/core/model/tilemapCollision';
|
||||
import { cellsVersion, KeyedMemo } from '../../src/core/model/tilemapMemo';
|
||||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||||
import { Solids } from '../../src/runtime/systems/solids';
|
||||
import { makeSpriteScene, place, startWorld } from '../helpers';
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadPhysics();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const TRIANGLE = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 32, y: 0 },
|
||||
{ x: 0, y: 32 },
|
||||
];
|
||||
|
||||
/**
|
||||
* A 4 × 4 map of 32 px cells with two tiles: `box` blocks as a box, `cut` with a triangle drawn on
|
||||
* its picture. Both lie on the one layer, `box` along the top row and `cut` at (1,2).
|
||||
*/
|
||||
function map() {
|
||||
const f = makeSpriteScene('Thing');
|
||||
const texture = new CreateAssetCommand('texture', 'Sheet');
|
||||
const box = new CreateAssetCommand('tile', 'Box');
|
||||
const cut = new CreateAssetCommand('tile', 'Cut');
|
||||
const tilemap = new CreateAssetCommand('tilemap', 'Ground');
|
||||
for (const command of [texture, box, cut, tilemap]) f.stack.execute(command);
|
||||
const sheet = findAsset(f.project, texture.asset.id) as TextureAsset;
|
||||
sheet.file = `${'a'.repeat(64)}.png`;
|
||||
sheet.width = 32;
|
||||
sheet.height = 32;
|
||||
const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 32 });
|
||||
f.stack.execute(frame);
|
||||
f.stack.execute(new AddTilePictureCommand(cut.asset.id, frame.frameId));
|
||||
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, [TRIANGLE]));
|
||||
for (const tile of [box, cut]) {
|
||||
f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true));
|
||||
f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_KIND_KEY, 'none'));
|
||||
}
|
||||
for (const [key, value] of [
|
||||
[TILEMAP_COLUMNS_KEY, 4],
|
||||
[TILEMAP_ROWS_KEY, 4],
|
||||
[TILEMAP_TILE_KEY, 32],
|
||||
] as const)
|
||||
f.stack.execute(new SetAssetPropertyCommand(tilemap.asset.id, key, value));
|
||||
const mapId = place(f, tilemap.asset.id, { x: 0, y: 0 });
|
||||
const asset = findAsset(f.project, tilemap.asset.id) as TilemapAsset;
|
||||
const layer = asset.layers[0]!;
|
||||
const top = [0, 1, 2, 3].map((x) => ({ x, y: 0, tileId: box.asset.id }));
|
||||
f.stack.execute(new PaintTilesCommand(asset.id, layer.id, [...top, { x: 1, y: 2, tileId: cut.asset.id }]));
|
||||
return {
|
||||
...f,
|
||||
mapId,
|
||||
asset,
|
||||
layer,
|
||||
boxId: box.asset.id,
|
||||
cutId: cut.asset.id,
|
||||
textureId: texture.asset.id,
|
||||
frameId: frame.frameId,
|
||||
props: asset.props,
|
||||
};
|
||||
}
|
||||
|
||||
describe('what a tilemap works out is remembered while its content stays', () => {
|
||||
it('a version stays while the cells hold the same, and changes when one is painted in place', () => {
|
||||
const cells: Record<string, string> = { '0,0': 'a', '1,0': 'b' };
|
||||
const first = cellsVersion(cells);
|
||||
expect(cellsVersion(cells)).toBe(first);
|
||||
cells['2,0'] = 'a';
|
||||
const added = cellsVersion(cells);
|
||||
expect(added).not.toBe(first);
|
||||
cells['2,0'] = 'b';
|
||||
const changed = cellsVersion(cells);
|
||||
expect(changed).not.toBe(added);
|
||||
delete cells['0,0'];
|
||||
expect(cellsVersion(cells)).not.toBe(changed);
|
||||
});
|
||||
|
||||
it('the cache hands out what it made for a key, and forgets the oldest once full', () => {
|
||||
const memo = new KeyedMemo<number[]>(2);
|
||||
const make = vi.fn(() => [1]);
|
||||
const a = memo.get('a', make);
|
||||
expect(memo.get('a', make)).toBe(a);
|
||||
expect(make).toHaveBeenCalledTimes(1);
|
||||
memo.get('b', make);
|
||||
memo.get('c', make);
|
||||
expect(memo.get('a', make)).not.toBe(a);
|
||||
expect(make).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('asking again for an unchanged map hands out the very same tiles and solids', () => {
|
||||
const m = map();
|
||||
expect(layerTiles(m.project, m.props, m.layer)).toBe(layerTiles(m.project, m.props, m.layer));
|
||||
expect(solidRects(m.project, m.props, m.asset)).toBe(solidRects(m.project, m.props, m.asset));
|
||||
expect(solidPolygons(m.project, m.props, m.asset)).toBe(solidPolygons(m.project, m.props, m.asset));
|
||||
});
|
||||
|
||||
it('a cell painted in place, as a stroke does before it is a command, is seen at once', () => {
|
||||
const m = map();
|
||||
const rects = solidRects(m.project, m.props, m.asset);
|
||||
expect(rects).toEqual([{ x: 0, y: 0, w: 4, h: 1 }]);
|
||||
m.layer.cells['0,3'] = m.boxId;
|
||||
expect(solidRects(m.project, m.props, m.asset)).toEqual([
|
||||
{ x: 0, y: 0, w: 4, h: 1 },
|
||||
{ x: 0, y: 3, w: 1, h: 1 },
|
||||
]);
|
||||
expect(layerTiles(m.project, m.props, m.layer).map((t) => `${t.x},${t.y}`)).toContain('0,3');
|
||||
delete m.layer.cells['1,2'];
|
||||
expect(solidPolygons(m.project, m.props, m.asset)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a tile no longer solid, a new outline and a new kind are each seen at once', () => {
|
||||
const m = map();
|
||||
const polygons = solidPolygons(m.project, m.props, m.asset);
|
||||
expect(polygons).toHaveLength(1);
|
||||
const other = [
|
||||
{ x: 32, y: 0 },
|
||||
{ x: 32, y: 32 },
|
||||
{ x: 0, y: 32 },
|
||||
];
|
||||
m.stack.execute(new SetFrameCollisionCommand(m.textureId, m.frameId, [other]));
|
||||
const turned = solidPolygons(m.project, m.props, m.asset);
|
||||
expect(turned).not.toBe(polygons);
|
||||
expect(turned[0]!.outline).not.toEqual(polygons[0]!.outline);
|
||||
m.stack.execute(new SetAssetPropertyCommand(m.boxId, TILE_SOLID_KEY, false));
|
||||
expect(solidRects(m.project, m.props, m.asset)).toEqual([]);
|
||||
const before = layerTiles(m.project, m.props, m.layer).find((t) => t.tileId === m.boxId)!;
|
||||
m.stack.execute(new SetAssetPropertyCommand(m.boxId, TILE_KIND_KEY, 'floor'));
|
||||
const after = layerTiles(m.project, m.props, m.layer).find((t) => t.tileId === m.boxId)!;
|
||||
expect(after.quadrants).not.toEqual(before.quadrants);
|
||||
});
|
||||
|
||||
it('a run lays the same edits over a layer once, and again when the layer was painted meanwhile', () => {
|
||||
const m = map();
|
||||
const edits = { [m.layer.id]: { '3,3': m.boxId } };
|
||||
const first = liveLayers(m.asset, edits)[0]!;
|
||||
expect(liveLayers(m.asset, edits)[0]).toBe(first);
|
||||
m.layer.cells['2,3'] = m.boxId;
|
||||
const painted = liveLayers(m.asset, edits)[0]!;
|
||||
expect(painted).not.toBe(first);
|
||||
expect(painted.cells['2,3']).toBe(m.boxId);
|
||||
expect(painted.cells['3,3']).toBe(m.boxId);
|
||||
m.layer.z = 3;
|
||||
expect(liveLayers(m.asset, edits)[0]!.z).toBe(3);
|
||||
});
|
||||
|
||||
it('the physics works a still map out once, and again once it is moved', () => {
|
||||
const m = map();
|
||||
const shapesOf = vi.spyOn(Solids.prototype, 'solidShapesOf');
|
||||
const s = startWorld(m, { physics: true, collisions: true });
|
||||
const mapWorkedOut = () =>
|
||||
shapesOf.mock.calls.filter(([, body, within]) => body.objectId === m.mapId && !within).length;
|
||||
for (let i = 0; i < 5; i++) s.frame();
|
||||
expect(mapWorkedOut()).toBe(1);
|
||||
s.world.findByObjectId(m.mapId)!.x += 10;
|
||||
s.frame();
|
||||
expect(mapWorkedOut()).toBe(2);
|
||||
s.frame();
|
||||
expect(mapWorkedOut()).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user