Files
feuerwerk/src/viewport/tilemapMesh.ts
T
adminandClaude Opus 5.5 cfd288c4b7 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>
2026-10-01 09:52:57 +02:00

112 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The geometry a painted layer becomes: one mesh per tile instead of one sprite per cell, so a
* map of a thousand cells costs as many draw calls as it has tiles on that layer (#128). Kept a
* plain function of numbers – no Pixi, no project – so the quarter that lands in a corner can be
* checked without a browser.
*/
import type { FrameRect } from '../core/model/texture';
import type { TileDraw } from '../core/model/tilemap';
/** Where a tile's quarters are cut from, in the pixels of the texture the mesh wears. */
export interface TileSheet {
/** Side of one quarter tile in image pixels. */
quad: number;
/** The block inside that texture; the quadrant positions count from its top-left corner. */
block: FrameRect;
textureWidth: number;
textureHeight: number;
/**
* Whether the texture is sampled smoothly. It is, the uvs are pulled half a texel inwards: a
* linear sample right on the border of a quarter otherwise reaches into the one next to it and
* draws a seam along every tile.
*/
smooth: boolean;
}
export interface MeshArrays {
positions: Float32Array;
uvs: Float32Array;
indices: Uint32Array;
}
/** The corners of one quad, in reading order – the order `tileQuadrants` uses for the four quarters. */
const CORNER_OFFSETS = [
[0, 0],
[1, 0],
[0, 1],
[1, 1],
] as const;
/**
* `origin` is where cell (0, 0) starts in the tilemap's own frame, `tile` the side of a drawn cell
* in world units. Every cell contributes four quads – its four quarters – so a tile whose corners
* come from four corners of the block is one thing to draw, not four.
*/
export function tileMesh(
tiles: readonly TileDraw[],
tile: number,
origin: { x: number; y: number },
sheet: TileSheet,
): MeshArrays {
const positions = new Float32Array(tiles.length * 4 * 4 * 2);
const uvs = new Float32Array(positions.length);
const indices = new Uint32Array(tiles.length * 4 * 6);
const half = tile / 2;
// Linear sampling needs half a texel so the blend never reaches the neighbour. Nearest needs only
// a sliver: at odd zooms a pixel centre on the quad edge can round onto the neighbour's first
// texel and draw a line. 1/10 texel keeps every picked texel the same.
const inset = sheet.smooth ? 0.5 : 0.1;
const insetX = inset / sheet.textureWidth;
const insetY = inset / sheet.textureHeight;
let v = 0;
let i = 0;
for (const drawn of tiles) {
const left = origin.x + drawn.x * tile;
const top = origin.y + drawn.y * tile;
drawn.quadrants.forEach((position, corner) => {
const x = left + (corner % 2) * half;
const y = top + Math.floor(corner / 2) * half;
const u0 = (sheet.block.x + position.x * sheet.quad) / sheet.textureWidth + insetX;
const v0 = (sheet.block.y + position.y * sheet.quad) / sheet.textureHeight + insetY;
const u1 = (sheet.block.x + (position.x + 1) * sheet.quad) / sheet.textureWidth - insetX;
const v1 = (sheet.block.y + (position.y + 1) * sheet.quad) / sheet.textureHeight - insetY;
const base = v / 2;
for (const [dx, dy] of CORNER_OFFSETS) {
positions[v] = x + dx * half;
uvs[v] = dx ? u1 : u0;
v++;
positions[v] = y + dy * half;
uvs[v] = dy ? v1 : v0;
v++;
}
indices[i++] = base;
indices[i++] = base + 1;
indices[i++] = base + 2;
indices[i++] = base + 2;
indices[i++] = base + 1;
indices[i++] = base + 3;
});
}
return { positions, uvs, indices };
}
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;
}