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>
192 lines
7.6 KiB
TypeScript
192 lines
7.6 KiB
TypeScript
/**
|
||
* The drawn side of a placed tilemap (#128): one mesh per tile and layer, so a whole map is a
|
||
* handful of draw calls instead of one sprite per cell. The geometry itself is `tilemapMesh.ts`;
|
||
* what happens here is Pixi – which texture a mesh wears, which animation step it shows, and
|
||
* throwing away the meshes of a layer that is gone.
|
||
*/
|
||
import { Container, Mesh, MeshGeometry, type Texture } from 'pixi.js';
|
||
import { pictureIndexAt, type TilePicture } from '../core/model/tile';
|
||
import { cellOrigin, type TileDraw, tilemapTileSize } from '../core/model/tilemap';
|
||
import { layerRows } from './drawOrder';
|
||
import type { DrawItem, TilemapTile } from './items';
|
||
import { placeNode } from './symbols';
|
||
import type { TextureLibrary } from './TextureLibrary';
|
||
import { groupByTile, type TileSheet, tileMesh } from './tilemapMesh';
|
||
|
||
/** The layer painted on and how the others are shown next to it (#138); null shows every layer. */
|
||
export interface LayerFocus {
|
||
layerId: string;
|
||
mode: 'all' | 'dim' | 'solo';
|
||
}
|
||
|
||
/** How far the others fade in `dim`: still readable as a map, plainly not what the brush reaches. */
|
||
export const DIMMED_LAYER_ALPHA = 0.3;
|
||
|
||
/** How opaque one layer is drawn; 0 means it is not drawn at all. */
|
||
export function layerAlpha(layerId: string, focus: LayerFocus | null): number {
|
||
if (!focus || focus.mode === 'all' || layerId === focus.layerId) return 1;
|
||
return focus.mode === 'dim' ? DIMMED_LAYER_ALPHA : 0;
|
||
}
|
||
|
||
/**
|
||
* One container per layer rather than one per map: each layer has a `z` of its own (#174) and is
|
||
* sorted in among the other objects on its own – where is the viewport's call, not this one's.
|
||
* A layer sorted by Y has one container per row instead (#228), keyed like its draw slot.
|
||
*/
|
||
export class TilemapField {
|
||
private readonly layers = new Map<string, Container>();
|
||
private readonly meshes = new Map<string, Mesh>();
|
||
private shown = true;
|
||
/** True after a sync that drew a tile with more than one picture. */
|
||
animated = false;
|
||
/** How many meshes the last sync drew – one draw call each (test hook). */
|
||
drawCalls = 0;
|
||
|
||
get visible(): boolean {
|
||
return this.shown;
|
||
}
|
||
|
||
set visible(shown: boolean) {
|
||
this.shown = shown;
|
||
for (const layer of this.layers.values()) layer.visible = shown;
|
||
}
|
||
|
||
/** The container a layer, or one row of it, draws into; null for one the last sync did not draw. */
|
||
layerContainer(layerId: string, row: number | null = null): Container | null {
|
||
return this.layers.get(rowKey(layerId, row)) ?? null;
|
||
}
|
||
|
||
/**
|
||
* `now` is the global clock, so every tilemap showing the same tile is on the same step –
|
||
* animated water does not run out of phase between two maps.
|
||
*/
|
||
sync(item: DrawItem, textures: TextureLibrary, now: number, focus: LayerFocus | null = null): void {
|
||
const draw = item.tilemap;
|
||
this.animated = false;
|
||
const tile = tilemapTileSize(item.props);
|
||
const origin = cellOrigin(item.props, 0, 0);
|
||
const drawn: { layer: Container; mesh: Mesh }[] = [];
|
||
const alive = new Set<string>();
|
||
const aliveLayers = new Set<string>();
|
||
for (const layer of draw?.layers ?? []) {
|
||
const parts: [number | null, readonly TileDraw[]][] =
|
||
draw && layer.sortByY ? [...layerRows(draw, layer)] : [[null, layer.tiles]];
|
||
const alpha = layerAlpha(layer.id, focus);
|
||
for (const [row, cells] of parts) {
|
||
const part = rowKey(layer.id, row);
|
||
aliveLayers.add(part);
|
||
// Each part sits under a node of its own carrying the map's transform, so that is applied
|
||
// once per part rather than baked into every vertex.
|
||
const container = this.layerFor(item.id, part);
|
||
placeNode(container, item.m);
|
||
if (alpha === 0) continue;
|
||
for (const [tileId, tiles] of groupByTile(cells)) {
|
||
const source = draw?.tiles.get(tileId);
|
||
const picture = source && pictureNow(source, now);
|
||
const texture = picture ? textures.get(picture.ref) : null;
|
||
if (!source || !picture || !texture) continue;
|
||
if (source.info.pictures.length > 1) this.animated = true;
|
||
const key = `${part}|${tileId}`;
|
||
alive.add(key);
|
||
const { positions, uvs, indices } = tileMesh(
|
||
tiles,
|
||
tile,
|
||
origin,
|
||
sheetOf(source, picture, texture),
|
||
);
|
||
const mesh = this.meshFor(key, new MeshGeometry({ positions, uvs, indices }), texture);
|
||
// Per mesh rather than per layer: the meshes of one layer never overlap, so fading each
|
||
// one looks the same as fading the layer as a whole.
|
||
mesh.alpha = alpha;
|
||
drawn.push({ layer: container, mesh });
|
||
}
|
||
}
|
||
}
|
||
for (const [key, mesh] of this.meshes) {
|
||
if (alive.has(key)) continue;
|
||
mesh.destroy();
|
||
this.meshes.delete(key);
|
||
}
|
||
for (const [id, layer] of this.layers) {
|
||
if (aliveLayers.has(id)) continue;
|
||
layer.destroy();
|
||
this.layers.delete(id);
|
||
}
|
||
// Re-attached rather than kept: a mesh whose layer showed nothing last time has to come back.
|
||
for (const layer of this.layers.values()) layer.removeChildren();
|
||
for (const { layer, mesh } of drawn) layer.addChild(mesh);
|
||
this.drawCalls = drawn.length;
|
||
}
|
||
|
||
private layerFor(objectId: string, part: string): Container {
|
||
let layer = this.layers.get(part);
|
||
if (!layer) {
|
||
layer = new Container();
|
||
layer.eventMode = 'none';
|
||
// The draw order reports it by this, next to the object's own id.
|
||
layer.label = `${objectId}#${part}`;
|
||
layer.visible = this.shown;
|
||
this.layers.set(part, layer);
|
||
}
|
||
return layer;
|
||
}
|
||
|
||
private meshFor(key: string, geometry: MeshGeometry, texture: Texture): Mesh {
|
||
const held = this.meshes.get(key);
|
||
if (held) {
|
||
held.geometry.destroy();
|
||
held.geometry = geometry;
|
||
held.texture = texture;
|
||
return held;
|
||
}
|
||
const mesh = new Mesh({ geometry, texture });
|
||
mesh.eventMode = 'none';
|
||
this.meshes.set(key, mesh);
|
||
return mesh;
|
||
}
|
||
|
||
destroy(): void {
|
||
for (const layer of this.layers.values()) layer.destroy({ children: true });
|
||
this.layers.clear();
|
||
this.meshes.clear();
|
||
}
|
||
}
|
||
|
||
/** A whole layer is keyed by its id, as before; a row of a layer sorted by Y by id and row. */
|
||
function rowKey(layerId: string, row: number | null): string {
|
||
return row === null ? layerId : `${layerId}#${row}`;
|
||
}
|
||
|
||
/**
|
||
* The block this tile shows right now. A quarter is cut from it by uvs rather than by the
|
||
* texture library, so what is wanted here is the picture of the step and nothing finer.
|
||
*/
|
||
function pictureNow(source: TilemapTile, now: number): TilePicture | undefined {
|
||
const { pictures, problem } = source.info;
|
||
if (problem) return undefined;
|
||
return pictures[pictureIndexAt(pictures.length, source.playback, source.fps, now)];
|
||
}
|
||
|
||
/**
|
||
* The library hands out the block as a texture of its own, so the quadrants count from its corner
|
||
* whether the picture was a whole sheet or one frame cut from one.
|
||
*/
|
||
function sheetOf(source: TilemapTile, picture: TilePicture, texture: Texture): TileSheet {
|
||
return {
|
||
quad: source.info.quad,
|
||
block: { x: 0, y: 0, w: texture.width, h: texture.height },
|
||
textureWidth: texture.width,
|
||
textureHeight: texture.height,
|
||
smooth: picture.ref.smooth,
|
||
};
|
||
}
|
||
|
||
/** Where the centre of one cell of a placed tilemap lands in world units – what a pixel check aims at. */
|
||
export function cellCentre(item: DrawItem, x: number, y: number): { x: number; y: number } {
|
||
const tile = tilemapTileSize(item.props);
|
||
const origin = cellOrigin(item.props, x, y);
|
||
const local = { x: origin.x + tile / 2, y: origin.y + tile / 2 };
|
||
const m = item.m;
|
||
return { x: m.tx + local.x * m.a + local.y * m.c, y: m.ty + local.x * m.b + local.y * m.d };
|
||
}
|