Halve the frame time of a playing scene with a tilemap (#284)
The performance panel showed Physics and Editor as the large parts of a frame in a scene of 222 entities. Nearly all of it came from a tilemap that only stands there. - Whoever writes cells in place now says so with cellsChanged. Only then does cellsVersion compare the content. The comparison ran about 60 times per frame and was the largest single item at 14 percent. - The four writers call it: the painter during and at the end of a stroke, PaintTilesCommand on execute and on undo. A new writer that forgets it stays invisible. - The game view is drawn once per frame in play. With an animated tile in view it was drawn twice, once by the run and once by animate(). animate() now gets the redraw count from the start of the tick. - TilemapField builds the geometry of a mesh only when its source changes: the cells, the tile size, the origin or the image. For that groupByTile and layerRows return the same objects for the same list. - PhysicsSystem skips a tilemap whose source and switch are unchanged. The switch is kept because a map that starts switched off would otherwise have dead colliders once it is switched on. - World.syncToModel looks entities and assets up in a map per pass instead of a linear search per object. Measured on stein2 with a copy of the reference project, ms per frame: Physics 2.64 to 0.97, Drawing 6.89 to 2.56, Editor 1.95 to 1.31. Left open: syncToModel still mirrors every entity every frame. That is its own issue. Tests: - Gate: 198 files, 2887 tests (2883 + 4), exit 0 - e2e 1-17,28,33,39-43,59: 810 checks passed, 40 has +3 - Smokes: 24 / 19 / 21, exit 0 - Three unit tests wrote cells directly and now call cellsChanged - Eleven counter-probes fail. Two did not at first: the stroke needed a check of its own, the physics test has to start with the map switched off. That last one was repeated on stein2 only. - e2e total 1613 is computed, not measured in a full run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6491888f56
commit
cfd288c4b7
15 files changed
+303
-43
No files matched your search
@@ -245,9 +245,18 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
|
||||
return this.animated;
|
||||
}
|
||||
|
||||
/** Called every tick: redraws while the last redraw found a Time shader on screen. */
|
||||
animate(): void {
|
||||
if (this.visible && this.animated) this.redraw();
|
||||
/** How often the view was drawn so far; `animate` is told the count its tick began with. */
|
||||
get redrawCount(): number {
|
||||
return this.redraws;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called every tick: redraws while the last redraw found a Time shader on screen. A view drawn
|
||||
* since the tick began, at `drawnBefore`, already shows this frame – a run draws its views
|
||||
* itself, and drawing them again cost a second collect of the whole world each frame (#284).
|
||||
*/
|
||||
animate(drawnBefore: number): void {
|
||||
if (this.visible && this.animated && this.redraws === drawnBefore) this.redraw();
|
||||
}
|
||||
|
||||
layerOf(objectId: string): 'objects' | 'overlay' | null {
|
||||
@@ -529,13 +538,17 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
|
||||
return { x: origin.x + local.x, y: origin.y + local.y };
|
||||
}
|
||||
|
||||
/** What a placed tilemap drew last redraw: how many meshes, and how many cells in them (test hook). */
|
||||
tilemapInfo(objectId: string): { drawCalls: number; tiles: number; layers: number } | null {
|
||||
/**
|
||||
* What a placed tilemap drew last redraw: how many meshes, and how many cells in them; `builds`
|
||||
* counts the geometries built since the map came on screen (test hook).
|
||||
*/
|
||||
tilemapInfo(objectId: string): { drawCalls: number; builds: number; tiles: number; layers: number } | null {
|
||||
const field = this.tilemaps.get(objectId);
|
||||
const item = this.lastItems.find((i) => i.id === objectId);
|
||||
if (!field?.visible || !item?.tilemap) return null;
|
||||
return {
|
||||
drawCalls: field.drawCalls,
|
||||
builds: field.builds,
|
||||
tiles: item.tilemap.layers.reduce((sum, layer) => sum + layer.tiles.length, 0),
|
||||
layers: item.tilemap.layers.length,
|
||||
};
|
||||
|
||||
@@ -28,16 +28,29 @@ export function sortRowOf(draw: TilemapDraw, tile: TileDraw): number {
|
||||
return tile.y + (draw.tiles.get(tile.tileId)?.sortOffset ?? 0);
|
||||
}
|
||||
|
||||
/** The cells of a layer sorted by Y, grouped by the row they sort by, top row first. */
|
||||
export function layerRows(draw: TilemapDraw, layer: TilemapLayerDraw): Map<number, TileDraw[]> {
|
||||
const rows = new Map<number, TileDraw[]>();
|
||||
type LayerRows = ReadonlyMap<number, readonly TileDraw[]>;
|
||||
|
||||
/** Per list of tiles, which is the same one for as long as the layer's cells are (`layerTiles`). */
|
||||
const rowsOf = new WeakMap<readonly TileDraw[], { offsets: string; rows: LayerRows }>();
|
||||
|
||||
/**
|
||||
* The cells of a layer sorted by Y, grouped by the row they sort by, top row first. The same cells
|
||||
* and offsets hand out the very same rows, so a mesh built from one can tell it need not be rebuilt.
|
||||
*/
|
||||
export function layerRows(draw: TilemapDraw, layer: TilemapLayerDraw): LayerRows {
|
||||
const offsets = [...draw.tiles].map(([id, tile]) => `${id}:${tile.sortOffset}`).join(',');
|
||||
const held = rowsOf.get(layer.tiles);
|
||||
if (held?.offsets === offsets) return held.rows;
|
||||
const grouped = new Map<number, TileDraw[]>();
|
||||
for (const tile of layer.tiles) {
|
||||
const row = sortRowOf(draw, tile);
|
||||
const held = rows.get(row);
|
||||
if (held) held.push(tile);
|
||||
else rows.set(row, [tile]);
|
||||
const cells = grouped.get(row);
|
||||
if (cells) cells.push(tile);
|
||||
else grouped.set(row, [tile]);
|
||||
}
|
||||
return new Map([...rows].sort(([a], [b]) => a - b));
|
||||
const rows = new Map([...grouped].sort(([a], [b]) => a - b));
|
||||
rowsOf.set(layer.tiles, { offsets, rows });
|
||||
return rows;
|
||||
}
|
||||
|
||||
interface Candidate extends DrawSlot {
|
||||
|
||||
+45
-10
@@ -36,6 +36,10 @@ export function layerAlpha(layerId: string, focus: LayerFocus | null): number {
|
||||
export class TilemapField {
|
||||
private readonly layers = new Map<string, Container>();
|
||||
private readonly meshes = new Map<string, Mesh>();
|
||||
/** What each mesh's geometry was built from. */
|
||||
private readonly built = new Map<string, MeshSource>();
|
||||
/** How many geometries were built so far (test hook). */
|
||||
builds = 0;
|
||||
private shown = true;
|
||||
/** True after a sync that drew a tile with more than one picture. */
|
||||
animated = false;
|
||||
@@ -88,13 +92,8 @@ export class TilemapField {
|
||||
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);
|
||||
const sheet = sheetOf(source, picture, texture);
|
||||
const mesh = this.meshFor(key, { tiles, tile, origin, texture, sheet });
|
||||
// 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;
|
||||
@@ -106,6 +105,7 @@ export class TilemapField {
|
||||
if (alive.has(key)) continue;
|
||||
mesh.destroy();
|
||||
this.meshes.delete(key);
|
||||
this.built.delete(key);
|
||||
}
|
||||
for (const [id, layer] of this.layers) {
|
||||
if (aliveLayers.has(id)) continue;
|
||||
@@ -131,15 +131,24 @@ export class TilemapField {
|
||||
return layer;
|
||||
}
|
||||
|
||||
private meshFor(key: string, geometry: MeshGeometry, texture: Texture): Mesh {
|
||||
/**
|
||||
* The mesh of one tile on one part, with its geometry built only when what it is built from
|
||||
* changed: a map that stands still was building every mesh anew each frame (#284).
|
||||
*/
|
||||
private meshFor(key: string, from: MeshSource): Mesh {
|
||||
const held = this.meshes.get(key);
|
||||
if (held && sameSource(this.built.get(key), from)) return held;
|
||||
const { positions, uvs, indices } = tileMesh(from.tiles, from.tile, from.origin, from.sheet);
|
||||
const geometry = new MeshGeometry({ positions, uvs, indices });
|
||||
this.built.set(key, from);
|
||||
this.builds++;
|
||||
if (held) {
|
||||
held.geometry.destroy();
|
||||
held.geometry = geometry;
|
||||
held.texture = texture;
|
||||
held.texture = from.texture;
|
||||
return held;
|
||||
}
|
||||
const mesh = new Mesh({ geometry, texture });
|
||||
const mesh = new Mesh({ geometry, texture: from.texture });
|
||||
mesh.eventMode = 'none';
|
||||
this.meshes.set(key, mesh);
|
||||
return mesh;
|
||||
@@ -149,9 +158,35 @@ export class TilemapField {
|
||||
for (const layer of this.layers.values()) layer.destroy({ children: true });
|
||||
this.layers.clear();
|
||||
this.meshes.clear();
|
||||
this.built.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the geometry of one mesh follows from. */
|
||||
interface MeshSource {
|
||||
tiles: readonly TileDraw[];
|
||||
tile: number;
|
||||
origin: { x: number; y: number };
|
||||
texture: Texture;
|
||||
sheet: TileSheet;
|
||||
}
|
||||
|
||||
/** The cells by identity (see `groupByTile`), the rest by value. */
|
||||
function sameSource(built: MeshSource | undefined, from: MeshSource): boolean {
|
||||
return (
|
||||
built !== undefined &&
|
||||
built.tiles === from.tiles &&
|
||||
built.tile === from.tile &&
|
||||
built.origin.x === from.origin.x &&
|
||||
built.origin.y === from.origin.y &&
|
||||
built.texture === from.texture &&
|
||||
built.sheet.quad === from.sheet.quad &&
|
||||
built.sheet.textureWidth === from.sheet.textureWidth &&
|
||||
built.sheet.textureHeight === from.sheet.textureHeight &&
|
||||
built.sheet.smooth === from.sheet.smooth
|
||||
);
|
||||
}
|
||||
|
||||
/** 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}`;
|
||||
|
||||
@@ -90,13 +90,22 @@ export function tileMesh(
|
||||
return { positions, uvs, indices };
|
||||
}
|
||||
|
||||
/** The cells of one layer grouped by tile – one group is one mesh, because it is one texture. */
|
||||
export function groupByTile(tiles: readonly TileDraw[]): Map<string, TileDraw[]> {
|
||||
const grouped = new WeakMap<readonly TileDraw[], ReadonlyMap<string, readonly TileDraw[]>>();
|
||||
|
||||
/**
|
||||
* The cells of one layer grouped by tile – one group is one mesh, because it is one texture. A list
|
||||
* of tiles is never edited, so the same list hands out the very same groups: that is how a mesh
|
||||
* tells its cells are the ones it was built from (see `TilemapField.sync`).
|
||||
*/
|
||||
export function groupByTile(tiles: readonly TileDraw[]): ReadonlyMap<string, readonly TileDraw[]> {
|
||||
const held = grouped.get(tiles);
|
||||
if (held) return held;
|
||||
const groups = new Map<string, TileDraw[]>();
|
||||
for (const drawn of tiles) {
|
||||
const group = groups.get(drawn.tileId);
|
||||
if (group) group.push(drawn);
|
||||
else groups.set(drawn.tileId, [drawn]);
|
||||
}
|
||||
grouped.set(tiles, groups);
|
||||
return groups;
|
||||
}
|
||||
Reference in new issue
Block a user