Keep a tilemap with outlined solids at 60 fps in scene and play
game-maker / gate (push) Successful in 3m1s
game-maker / suite (push) Failing after 8m33s

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:
adminandClaude Opus 5.5 committed 2026-09-25 12:23:18 +02:00
1 parent 021a6dcb56
commit 03e7a78c3d
10 files changed
+491 -79

No files matched your search

+48 -21
View File
@@ -83,6 +83,8 @@ interface Body {
solids: SolidShape[];
/** What the colliders were built from; they are built again when it changes (a scaled object). */
shapeKey: string;
/** For a tilemap, what its shapes were last worked out from (see `build`). */
source: string;
settingsKey: string;
/** Whether the engine may turn it – what `writeBack` hands on as the object's rotation. */
turns: boolean;
@@ -425,6 +427,7 @@ export class PhysicsSystem implements System, CollisionSource {
sources: [],
solids: [],
shapeKey: '',
source: '',
settingsKey: '',
turns: false,
ownMass: 0,
@@ -516,6 +519,36 @@ export class PhysicsSystem implements System, CollisionSource {
/** The body's colliders, built again whenever what they were built from changed. */
private build(body: Body, want: Wanted, key: Entity, world: World): void {
// A map's shapes follow from its cells, placement and settings alone. While none of those
// changed, its thousands of pieces are not worked out again only to find the same key.
const source = body.kind === 'tilemap' ? this.tilemapSource(key, world) : '';
const local = source !== '' && source === body.source ? [] : this.rebuild(body, want, key, world);
body.source = source;
// Shapes are refreshed every tick even when the colliders stay: the overlap test reads them. A
// tilemap's many shapes all stand for the one map, so its colliders keep the shape they were built from.
const solidOfEntity = new Map(local.map((solid) => [solid.entity, solid]));
body.colliders.forEach((collider, i) => {
const entity = body.sources[i]!;
const { shape, facing } = body.kind === 'tilemap' ? body.solids[i]! : solidOfEntity.get(entity)!;
this.parts.set(collider.handle, { entity, shape, facing, owner: body });
const on = isHandleEnabled(entity);
if (collider.isEnabled() === on) return;
collider.setEnabled(on);
// A resting body sleeps, and losing what held it up does not wake it by itself.
body.body.wakeUp();
});
if (body.kind !== 'rigid') return;
// Without a collider the engine would weigh it nothing, and a thing of no weight never falls.
const weighed = body.sources.some((entity) => isSolid(entity) && isHandleEnabled(entity));
const own = weighed ? 0 : want.settings?.mass || 1;
if (own !== body.ownMass) {
body.ownMass = own;
body.body.setAdditionalMass(own, true);
}
}
/** Works out the body's shapes and builds its colliders anew when they differ from the last ones. */
private rebuild(body: Body, want: Wanted, key: Entity, world: World) {
const shapes = this.shapesWanted(want, key, world);
const origin = body.placed;
const local = shapes.map((solid) => ({ ...solid, pieces: piecesInBody(origin, solid.shape) }));
@@ -557,27 +590,21 @@ export class PhysicsSystem implements System, CollisionSource {
this.parts.set(collider.handle, { entity, shape, facing, owner: body });
}
}
// Shapes are refreshed every tick even when the colliders stay: the overlap test reads them. A
// tilemap's many shapes all stand for the one map, so its colliders keep the shape they were built from.
const solidOfEntity = new Map(local.map((solid) => [solid.entity, solid]));
body.colliders.forEach((collider, i) => {
const entity = body.sources[i]!;
const { shape, facing } = body.kind === 'tilemap' ? body.solids[i]! : solidOfEntity.get(entity)!;
this.parts.set(collider.handle, { entity, shape, facing, owner: body });
const on = isHandleEnabled(entity);
if (collider.isEnabled() === on) return;
collider.setEnabled(on);
// A resting body sleeps, and losing what held it up does not wake it by itself.
body.body.wakeUp();
});
if (body.kind !== 'rigid') return;
// Without a collider the engine would weigh it nothing, and a thing of no weight never falls.
const weighed = body.sources.some((entity) => isSolid(entity) && isHandleEnabled(entity));
const own = weighed ? 0 : want.settings?.mass || 1;
if (own !== body.ownMass) {
body.ownMass = own;
body.body.setAdditionalMass(own, true);
}
return local;
}
private tilemapSource(map: Entity, world: World): string {
const m = worldMatrixOf(map);
return [
this.solids.blocksToken(world, map),
m.a,
m.b,
m.c,
m.d,
m.tx,
m.ty,
JSON.stringify(map.props),
].join('|');
}
private shapesWanted(want: Wanted, key: Entity, world: World): (SolidShape & { entity: Entity })[] {
+17 -2
View File
@@ -4,6 +4,7 @@ import type { Vec2 } from '../../core/model/Properties';
import { dot } from '../../core/util/vec';
import { findAssetOf } from '../../core/model/Project';
import { liveTilemap, type TileEdits } from '../../core/model/liveTilemap';
import { identityToken } from '../../core/model/tilemapMemo';
import {
type CellPolygon,
cellPolygonShape,
@@ -227,6 +228,20 @@ export class Solids {
return held.blocks;
}
/**
* Stands for the map's blocking cells: the same as long as they are, since the same cells hand out
* the very same rectangles and polygons (`tilemapCollision.ts`).
*/
blocksToken(world: World, tilemap: Entity): string {
return this.blocksOf(world, tilemap)
.map(({ angle, material, rects, polygons }) =>
[angle, material.friction, material.bounciness, identityToken(rects), identityToken(polygons)].join(
':',
),
)
.join(',');
}
/**
* The shapes a body occupies in the world: one for a collider; for a tilemap one box per merged
* rectangle and one polygon per outlined cell (#141), the one-way cells blocking from the map's
@@ -573,8 +588,8 @@ function unitOf(v: Vec2): Vec2 {
type TilemapBlocks = {
angle: number | null;
material: TileMaterial;
rects: CellRect[];
polygons: CellPolygon[];
rects: readonly CellRect[];
polygons: readonly CellPolygon[];
}[];
export function shapeOf(collider: Entity): ColliderShape {