Files
feuerwerk/tests/viewport/sort-by-y.test.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

360 lines
15 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.
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddFrameCommand, SetFrameSortLineCommand } from '../../src/core/commands/frameCommands';
import { MoveSceneObjectCommand } from '../../src/core/commands/sceneObjectCommands';
import {
AddTilemapLayerCommand,
PaintTilesCommand,
RenameTilemapLayerCommand,
SetTilemapLayerSortByYCommand,
} from '../../src/core/commands/tilemapCommands';
import type { TextureAsset, TileAsset, TilemapAsset } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { frameSortLine } from '../../src/core/model/texture';
import { TILE_SORT_OFFSET_KEY, tileSortOffset } from '../../src/core/model/tile';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { type DrawSlot, drawSlots, layerRows } from '../../src/viewport/drawOrder';
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
import { stackAt } from '../../src/viewport/pick';
import { groupByTile } from '../../src/viewport/tilemapMesh';
import { sortLineAt, sortLineY } from '../../src/editor/texture/sheetView';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
/**
* A 20 × 15 map of 48-unit cells at the origin, so row `r` ends at `-360 + (r + 1) * 48`: row 5 at
* -72, row 8 at 72. "Ground" is a plain layer; "Trees" holds a trunk at (2,5), its crown at (2,4)
* and a bush at (7,8). The sprite "Box" is 100 tall and stands with its bottom at `feet`.
*/
function makeScene(feet: number, crownOffset = 1) {
const f = makeSpriteScene('Box');
const map = new CreateAssetCommand('tilemap', 'Forest');
f.stack.execute(map);
const mapId = map.asset.id;
const assetOf = () => findAsset(f.project, mapId) as TilemapAsset;
const ground = assetOf().layers[0]!.id;
f.stack.execute(new RenameTilemapLayerCommand(mapId, ground, 'Ground'));
const treesAdd = new AddTilemapLayerCommand(mapId);
f.stack.execute(treesAdd);
const trees = treesAdd.layerId;
f.stack.execute(new RenameTilemapLayerCommand(mapId, trees, 'Trees'));
const tile = (name: string, props = {}) => {
const created = new CreateAssetCommand('tile', name, props);
f.stack.execute(created);
return created.asset.id;
};
const trunk = tile('Trunk');
const crown = tile('Crown', { [TILE_SORT_OFFSET_KEY]: crownOffset });
f.stack.execute(new PaintTilesCommand(mapId, ground, [{ x: 0, y: 0, tileId: trunk }]));
f.stack.execute(
new PaintTilesCommand(mapId, trees, [
{ x: 2, y: 5, tileId: trunk },
{ x: 2, y: 4, tileId: crown },
{ x: 7, y: 8, tileId: trunk },
]),
);
const mapObject = place(f, mapId, { x: 0, y: 0, z: 0 });
const box = place(f, f.spriteId, { x: 0, y: feet - 50, z: 0 });
const names = new Map([
[mapObject, 'map'],
[box, 'box'],
]);
const layerNames = new Map([
[ground, 'Ground'],
[trees, 'Trees'],
]);
const sortTrees = () => f.stack.execute(new SetTilemapLayerSortByYCommand(mapId, trees, true));
return { f, mapId, mapObject, box, ground, trees, crown, assetOf, names, layerNames, sortTrees };
}
type Scene = ReturnType<typeof makeScene>;
function readable(s: Scene, slots: DrawSlot[]): string[] {
return slots.map((slot) => {
if (!slot.layerId) return s.names.get(slot.id) ?? slot.id;
const name = s.layerNames.get(slot.layerId)!;
return slot.row === null ? name : `${name} ${slot.row}`;
});
}
function sceneOrder(s: Scene): string[] {
return readable(s, drawSlots(collectHolderItems(s.f.project, s.f.sceneOf(), 0).items));
}
describe('a tilemap layer sorted by Y (#228)', () => {
it('is off by default, and the map then draws as one piece per layer as before', () => {
const s = makeScene(-50);
expect(s.assetOf().layers.map((l) => l.sortByY)).toEqual([undefined, undefined]);
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Trees', 'box']);
});
it('sets, undoes and redoes, and stores off as nothing', () => {
const s = makeScene(-50);
const trees = () => s.assetOf().layers[1]!;
s.sortTrees();
expect(trees().sortByY).toBe(true);
s.f.stack.execute(new SetTilemapLayerSortByYCommand(s.mapId, s.trees, false));
expect('sortByY' in trees()).toBe(false);
s.f.stack.undo();
expect(trees().sortByY).toBe(true);
s.f.stack.undo();
expect('sortByY' in trees()).toBe(false);
s.f.stack.redo();
expect(trees().sortByY).toBe(true);
});
it('survives a round trip, and loading keeps only a literal true', () => {
const s = makeScene(-50);
s.sortTrees();
const back = findAsset(roundTrip(s.f.project), s.mapId) as TilemapAsset;
expect(back.layers.map((l) => l.sortByY)).toEqual([undefined, true]);
const saved = serializeProject(s.f.project) as {
assets: { id: string; layers?: Record<string, unknown>[] }[];
};
const layers = saved.assets.find((a) => a.id === s.mapId)!.layers!;
for (const [raw, kept] of [
[true, true],
['true', undefined],
[1, undefined],
[false, undefined],
] as const) {
layers[1]!.sortByY = raw;
const loaded = findAsset(deserializeProject(saved), s.mapId) as TilemapAsset;
expect(loaded.layers[1]!.sortByY, `stored ${String(raw)}`).toBe(kept);
}
});
it('puts a figure in front of a row whose bottom is above its feet, behind one below', () => {
const between = makeScene(-50);
between.sortTrees();
expect(sceneOrder(between)).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']);
const behind = makeScene(-100);
behind.sortTrees();
expect(sceneOrder(behind)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
const front = makeScene(100);
front.sortTrees();
expect(sceneOrder(front)).toEqual(['map', 'Ground', 'Trees 5', 'Trees 8', 'box']);
});
it('sorts a crown with the trunk its sort offset points to, and on its own row without one', () => {
const offset = makeScene(-80);
offset.sortTrees();
// Feet at -80: below the crown's own row (ends at -120), above the trunk's (ends at -72).
expect(sceneOrder(offset)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
const plain = makeScene(-80, 0);
plain.sortTrees();
expect(sceneOrder(plain)).toEqual(['map', 'Ground', 'Trees 4', 'box', 'Trees 5', 'Trees 8']);
});
it('hands out the very same rows while cells and offsets stay, and new ones when either changes', () => {
const s = makeScene(-80);
s.sortTrees();
const rows = () => {
const draw = collectHolderItems(s.f.project, s.f.sceneOf(), 0).items.find(
(item) => item.id === s.mapObject,
)!.tilemap!;
return layerRows(
draw,
draw.layers.find((layer) => layer.id === s.trees)!,
);
};
const first = rows();
expect(rows()).toBe(first);
expect([...first.keys()]).toEqual([5, 8]);
expect(groupByTile(first.get(5)!)).toBe(groupByTile(rows().get(5)!));
s.f.stack.execute(new SetAssetPropertyCommand(s.crown, TILE_SORT_OFFSET_KEY, 0));
const moved = rows();
expect(moved).not.toBe(first);
expect([...moved.keys()]).toEqual([4, 5, 8]);
s.f.stack.execute(new PaintTilesCommand(s.mapId, s.trees, [{ x: 3, y: 9, tileId: s.crown }]));
expect(rows()).not.toBe(moved);
expect([...rows().keys()]).toEqual([4, 5, 8, 9]);
});
it('sorts objects on that z among each other by their feet, too', () => {
const s = makeScene(100);
s.sortTrees();
const far = place(s.f, s.f.spriteId, { x: 0, y: -150, z: 0 });
s.names.set(far, 'far');
// `far` comes after `box` in the hierarchy but stands further up, so it is drawn first.
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'far', 'Trees 5', 'Trees 8', 'box']);
});
it('leaves everything on another z as it was', () => {
const s = makeScene(-50);
s.sortTrees();
s.f.stack.execute(new MoveSceneObjectCommand(s.f.sceneId, s.box, { x: 0, y: -100, z: 3 }));
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Trees 5', 'Trees 8', 'box']);
});
it('holds in the running game, and follows a figure that walks past a row', () => {
const s = makeScene(-100);
s.sortTrees();
const { world } = startWorld(s.f);
const gameOrder = () => readable(s, drawSlots(collectWorldItems(world).items));
expect(gameOrder()).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
world.entities.find((e) => e.objectId === s.box)!.y = 0;
expect(gameOrder()).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']);
});
it('picks the figure over a row behind it and the row over a figure behind it', () => {
const cellHit = (s: Scene) => ({
id: s.mapObject,
layers: [s.ground, s.trees],
rows: new Map([[s.trees, 5]]),
});
const pick = (s: Scene) =>
stackAt(collectHolderItems(s.f.project, s.f.sceneOf(), 0).items, [
{ id: s.box, layers: null },
cellHit(s),
]).map((id) => s.names.get(id));
const inFront = makeScene(-50);
inFront.sortTrees();
expect(pick(inFront)).toEqual(['box', 'map']);
const behind = makeScene(-100);
behind.sortTrees();
expect(pick(behind)).toEqual(['map', 'box']);
});
});
/**
* The scene above with "Box" wearing a frame of a 50 px tall picture, so one image pixel is two
* units of its 100-unit box. "Stand" has the sort line under test, "Plain" has none.
*/
function withFrames(feet: number, line: number) {
const s = makeScene(feet);
const texture = new CreateAssetCommand('texture', 'Hero');
s.f.stack.execute(texture);
const sheet = findAsset(s.f.project, texture.asset.id) as TextureAsset;
sheet.file = `${'a'.repeat(64)}.png`;
sheet.width = 64;
sheet.height = 50;
const stand = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 50 }, 'Stand');
const plain = new AddFrameCommand(texture.asset.id, { x: 32, y: 0, w: 32, h: 50 }, 'Plain');
s.f.stack.execute(stand);
s.f.stack.execute(plain);
s.f.stack.execute(new SetFrameSortLineCommand(texture.asset.id, stand.frameId, line));
const wear = (frameId: string) =>
s.f.stack.execute(new SetAssetPropertyCommand(s.f.spriteId, 'texture', frameId));
wear(stand.frameId);
const frameOf = (id: string) => sheet.frames.find((fr) => fr.id === id)!;
return { ...s, textureId: texture.asset.id, stand: stand.frameId, plain: plain.frameId, wear, frameOf };
}
describe('the sort line of a texture frame (#228)', () => {
it('lifts where a sprite wearing the frame stands by its share of the box', () => {
// Feet at -50 are below row 5 (ends at -72); 15 px of 50 lift them 30 units, to -80, above it.
const lifted = withFrames(-50, 15);
lifted.sortTrees();
expect(sceneOrder(lifted)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
const low = withFrames(-50, 5);
low.sortTrees();
expect(sceneOrder(low)).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']);
});
it('belongs to the frame: another frame of the same picture stands on its bottom edge', () => {
const s = withFrames(-50, 15);
s.sortTrees();
s.wear(s.plain);
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']);
s.wear(s.stand);
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']);
});
it('holds in the running game', () => {
const s = withFrames(-50, 15);
s.sortTrees();
const { world } = startWorld(s.f);
expect(readable(s, drawSlots(collectWorldItems(world).items))).toEqual([
'map',
'Ground',
'box',
'Trees 5',
'Trees 8',
]);
});
it('sets, undoes and redoes, and stores 0 as nothing', () => {
const s = withFrames(0, 12);
const stand = () => s.frameOf(s.stand);
expect(stand().sortLine).toBe(12);
s.f.stack.execute(new SetFrameSortLineCommand(s.textureId, s.stand, 0));
expect('sortLine' in stand()).toBe(false);
s.f.stack.undo();
expect(stand().sortLine).toBe(12);
s.f.stack.redo();
expect('sortLine' in stand()).toBe(false);
expect('sortLine' in s.frameOf(s.plain)).toBe(false);
const fresh = withFrames(0, 0);
fresh.f.stack.execute(new SetFrameSortLineCommand(fresh.textureId, fresh.stand, 7));
fresh.f.stack.undo();
expect('sortLine' in fresh.frameOf(fresh.stand)).toBe(false);
});
it('is held to whole pixels inside the frame, also when loaded', () => {
const s = withFrames(0, 0);
for (const [raw, kept] of [
[2.6, 3],
[-4, 0],
[80, 50],
] as const) {
s.f.stack.execute(new SetFrameSortLineCommand(s.textureId, s.stand, raw));
expect(frameSortLine(s.frameOf(s.stand)), String(raw)).toBe(kept);
}
const saved = serializeProject(s.f.project) as {
assets: { id: string; frames?: Record<string, unknown>[] }[];
};
const frames = saved.assets.find((a) => a.id === s.textureId)!.frames!;
frames[0]!.sortLine = 70;
frames[1]!.sortLine = 'x';
const loaded = findAsset(deserializeProject(saved), s.textureId) as TextureAsset;
expect(loaded.frames.map((fr) => fr.sortLine)).toEqual([50, undefined]);
const back = findAsset(roundTrip(s.f.project), s.textureId) as TextureAsset;
expect(back.frames.map((fr) => fr.sortLine)).toEqual([50, undefined]);
});
it('is grabbed by its knob left of the frame, or on the line inside it', () => {
const rect = { x: 10, y: 20, w: 30, h: 40 };
expect(sortLineY(rect, 0)).toBe(60);
expect(sortLineY(rect, 6)).toBe(54);
expect(sortLineAt(rect, 6, { x: 4, y: 54 }, 1, 1, 8)).toBe('knob');
expect(sortLineAt(rect, 6, { x: 25, y: 54.5 }, 1, 1, 8)).toBe('line');
expect(sortLineAt(rect, 6, { x: 25, y: 57 }, 1, 1, 8)).toBeNull();
expect(sortLineAt(rect, 6, { x: 0, y: 54 }, 1, 1, 8)).toBeNull();
expect(sortLineAt(rect, 6, { x: 45, y: 54 }, 1, 1, 8)).toBeNull();
});
it('takes the middle of its knob even where the corner handle reaches over it', () => {
// The editor's sizes at zoom 1: handles grab 8 px around the corner, the knob is drawn from
// 4 to 12 px left of the frame, so its middle sits exactly 8 px out – on the handle's edge.
const rect = { x: 100, y: 20, w: 24, h: 24 };
expect(sortLineAt(rect, 0, { x: 92, y: 44 }, 8, 4, 14)).toBe('knob');
expect(sortLineAt(rect, 0, { x: 97, y: 44 }, 8, 4, 14)).toBeNull();
});
});
describe('the sort offset of a tile (#228)', () => {
it('is 0 by default and held to whole rows between -16 and 16', () => {
const s = makeScene(0);
const crown = () => findAsset(s.f.project, s.crown) as TileAsset;
expect(tileSortOffset({ ...crown(), props: {} })).toBe(0);
for (const [raw, kept] of [
[1, 1],
[2.4, 2],
[-3, -3],
[40, 16],
[-40, -16],
['x', 0],
] as const)
expect(tileSortOffset({ ...crown(), props: { [TILE_SORT_OFFSET_KEY]: raw } }), String(raw)).toBe(kept);
});
});