Keep the frame rate up while painting a big tilemap (#347)

Painting on the 60x60 map of a project with fifty objects dropped the
editor to 20 to 39 fps. A profile of a brush stroke showed four causes,
none of them the brush itself.

- Each frame of a stroke previews the model. The asset and hierarchy
  panels then built every row anew, so every label became a new text
  and a new upload to the GPU. One run spent 46 % of the main thread
  in those uploads. ListView.setItems now keeps a row whose item draws
  the same (sameListItem) and builds only the rows that changed.
- The brush drew the whole scene on every pointer event, on top of the
  preview. Now the preview draws what the stroke changed, once a frame.
  The rectangle tool asks for one drawing a frame for its box.
- An animated tile made the view draw in the tick and the preview drew
  it again in the same frame. animate now asks just before the frame
  is drawn and stays out when the view was drawn since the tick began.
- layoutSymbols ran on every drawing and costs up to the cube of the
  symbols. SymbolLayoutMemo keeps the spots while no anchor moves.
- A painted cell made the map read every "x,y" key again, and once
  more for each of the eight neighbours of every cell. layerCells reads
  a layer once per content into an index by place. layerTiles and the
  blocking cells take their neighbours from there (neighboursIn).
  parseCellKey reads a key without split, with the same results.

The test hook sceneRedraws counts how often the scene view was drawn.

Not done: a painted cell still works out the tiles and blocking cells
of the whole map again, so that part grows with the map.

Tests:
- Brush stroke on a copy of the project (stein2, Chrome): 20 to 39 fps
  before, 47.5 after, 55 to 57 without painting. Labels drawn again
  during the stroke 2012 before, none after. Scene drawings per frame
  1.32 before, 1 after.
- One painted cell, headless: 28 to 16 ms on 60x60, 109 to 49 ms on
  the same map doubled to 120x120.
- frame-preview: 2 new, red with the old pointer redraw.
- tilemap: 3 new (key reading, index, refresh), list-ping 1 new,
  symbol-layout 1 new. 5 single reversals turned each red.
- e2e 39,40 (melektaus-atx): ALL PASSED, 107 checks (1 new, a fast
  stroke: 6 drawings in 6 frames, no label drawn again). With the row
  reuse and the pointer redraw reverted it fails: 24 drawings in
  3 frames, and the panels draw their labels again.
- Gate (melektaus-atx): CHECK EXIT 0, 260 files, 4038 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 16:54:59 +02:00
1 parent 101b12504e
commit 4c7861b5b8
13 files changed
+397 -44

No files matched your search

+61
View File
@@ -30,8 +30,11 @@ import {
cellsInRect,
floodFill,
inGrid,
layerCells,
layerTiles,
neighboursIn,
neighboursOf,
parseCellKey,
TILEMAP_COLUMNS_KEY,
TILEMAP_ROWS_KEY,
TILEMAP_TILE_KEY,
@@ -39,6 +42,7 @@ import {
} from '../../src/core/model/tilemap';
import { DIMMED_LAYER_ALPHA, layerAlpha } from '../../src/viewport/tilemap';
import { cellsPainted } from '../../src/core/model/usages';
import { cellsChanged } from '../../src/core/model/tilemapMemo';
import { groupByTile, tileMesh } from '../../src/viewport/tilemapMesh';
import { makeProject, roundTrip } from '../helpers';
@@ -332,6 +336,63 @@ describe('what a painted cell shows', () => {
});
});
describe('the cells of a layer, read once per content (#347)', () => {
it('reads a key the way it always was read', () => {
const before = (key: string) => {
const [x, y] = key.split(',').map(Number);
return Number.isInteger(x) && Number.isInteger(y) ? { x, y } : null;
};
for (const key of ['3,4', '-1,12', '0,0', '1,2,3', ',5', '5,', '1.5,2', 'a,1', '1', '', ' 2,3'])
expect(parseCellKey(key)).toEqual(before(key));
});
it('holds the cells inside the grid in reading order, and knows the same neighbours', () => {
const f = makeTilemap();
const sand = new CreateAssetCommand('tile', 'Sand');
f.stack.execute(sand);
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 5));
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 4));
f.paint([
{ x: 4, y: 3 },
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 2 },
{ x: 3, y: 3 },
]);
f.paint([{ x: 2, y: 1 }], sand.asset.id);
const asset = f.assetOf();
const layer = asset.layers[0]!;
// Outside the grid: kept in the file, but neither drawn nor anyone's neighbour.
layer.cells['5,0'] = f.tileId;
layer.cells['-1,1'] = f.tileId;
cellsChanged(layer.cells);
const cells = layerCells(asset.props, layer);
expect(cells.list.map((c) => `${c.x},${c.y}`)).toEqual(['0,0', '1,0', '1,1', '2,1', '2,2', '3,3', '4,3']);
expect(cells.list.find((c) => c.x === 2 && c.y === 1)?.tileId).toBe(sand.asset.id);
for (let y = -1; y <= 4; y++)
for (let x = -1; x <= 5; x++)
for (const tileId of [f.tileId, sand.asset.id])
expect(neighboursIn(cells, x, y, tileId)).toEqual(neighboursOf(asset.props, layer, x, y, tileId));
});
it('reads the cells again once they are said to have changed, or the grid is resized', () => {
const f = makeTilemap();
f.paint([{ x: 1, y: 1 }]);
const asset = f.assetOf();
const layer = asset.layers[0]!;
const first = layerCells(asset.props, layer);
expect(layerCells(asset.props, layer)).toBe(first);
layer.cells['2,1'] = f.tileId;
cellsChanged(layer.cells);
const painted = layerCells(asset.props, layer);
expect(painted.list).toHaveLength(2);
expect(neighboursIn(painted, 1, 1, f.tileId).e).toBe(true);
const narrow = layerCells({ ...asset.props, [TILEMAP_COLUMNS_KEY]: 2 }, layer);
expect(narrow.list.map((c) => `${c.x},${c.y}`)).toEqual(['1,1']);
});
});
describe('the layers of a tilemap', () => {
it('adds, renames, reorders, hides and removes, each with an undo', () => {
const f = makeTilemap();
+80 -1
View File
@@ -11,6 +11,8 @@ import type {
GraphAsset,
PaletteAsset,
PixelArtAsset,
SceneAsset,
TilemapAsset,
VectorArtAsset,
VectorGradient,
} from '../../src/core/model/Asset';
@@ -26,7 +28,14 @@ import { buildShapeSection, currentGradientBar } from '../../src/editor/inspecto
import { PixelArtCanvas } from '../../src/editor/pixelart/PixelArtCanvas';
import { PixelArtSide } from '../../src/editor/pixelart/PixelArtSide';
import { PixelThumbs } from '../../src/editor/pixelart/PixelThumbs';
import { EditorState } from '../../src/editor/state';
import { EditorState, objectSelection } from '../../src/editor/state';
import { chooseTool, pickTile } from '../../src/editor/panels/TilemapPanel';
import type { SceneViewHost } from '../../src/editor/viewport/sceneViewHost';
import { TilemapPainter } from '../../src/editor/viewport/tilemapPainting';
import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands';
import { IDENTITY } from '../../src/core/model/matrix';
import { cellOrigin, tilemapTileSize } from '../../src/core/model/tilemap';
import type { DrawItem } from '../../src/viewport/items';
import { VStack } from '../../src/ui';
import { initRenderLoop, resetRenderLoop } from '../../src/ui/render';
import { PaintCache } from '../../src/viewport/vectorArtPicture';
@@ -341,6 +350,76 @@ describe('the side panel of the pixel art tab (#274)', () => {
});
});
describe('painting a placed tilemap (#347)', () => {
beforeEach(() => resetRenderLoop());
function rig() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const scene = buildAsset('scene', 'Scene') as SceneAsset;
const map = buildAsset('tilemap', 'Ground') as TilemapAsset;
const tile = buildAsset('tile', 'Grass');
state.project.assets.push(scene, map, tile);
state.project.activeSceneId = scene.id;
const placed = new PlaceAssetCommand(scene.id, map.id, { x: 0, y: 0 });
state.execute(placed);
state.select(objectSelection(scene.id, [placed.objectId]));
pickTile(state, tile.id);
const item = { id: placed.objectId, type: 'tilemap', m: IDENTITY, props: map.props } as DrawItem;
const redraw = vi.fn();
const host: SceneViewHost = {
state,
holder: () => scene,
items: () => [item],
worldAt: (global) => global,
local: (global) => global,
view: () => ({ x: 0, y: 0, scale: 1, rect: { x: 0, y: 0, w: 0, h: 0 } }),
scale: () => 1,
redraw,
};
const painter = new TilemapPainter(host);
const size = tilemapTileSize(map.props);
const centre = (x: number) => {
const at = cellOrigin(map.props, x, 0);
return { x: at.x + size / 2, y: at.y + size / 2 };
};
const cells = () => Object.keys(map.layers[0]!.cells).sort();
const press = () => painter.press(painter.targetOf(placed.objectId)!, centre(0));
return { state, painter, redraw, centre, cells, press, preview: vi.spyOn(state, 'preview') };
}
it('writes every cell the brush crosses at once and draws the scene once a frame', () => {
const tick = frames();
const { painter, redraw, centre, cells, press, preview } = rig();
expect(press()).toBe(true);
redraw.mockClear();
for (let x = 1; x <= 4; x++) painter.move(centre(x));
expect(cells()).toEqual(['0,0', '1,0', '2,0', '3,0', '4,0']);
expect(redraw).not.toHaveBeenCalled();
tick();
expect(preview).toHaveBeenCalledTimes(1);
expect(redraw).not.toHaveBeenCalled();
});
it('draws the box a rectangle drag would fill once a frame, and nothing after the release', () => {
const tick = frames();
const { state, painter, redraw, centre, cells, press } = rig();
chooseTool(state, 'rect');
press();
redraw.mockClear();
for (let x = 1; x <= 4; x++) painter.move(centre(x));
expect(redraw).not.toHaveBeenCalled();
tick();
expect(redraw).toHaveBeenCalledTimes(1);
painter.move(centre(5));
painter.release();
redraw.mockClear();
tick();
expect(redraw).not.toHaveBeenCalled();
expect(cells()).toHaveLength(6);
});
});
describe('the place fields of the curve tab (#274)', () => {
beforeAll(() => {
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
+25 -1
View File
@@ -4,7 +4,7 @@
*/
import { describe, expect, it } from 'vitest';
import { EditorState } from '../../src/editor/state';
import { PING_MS, pingAlpha } from '../../src/ui/widgets/ListView';
import { type ListItem, PING_MS, pingAlpha, sameListItem } from '../../src/ui/widgets/ListView';
describe('pingAlpha', () => {
it('holds full for the first fifth, then fades to nothing at PING_MS', () => {
@@ -18,6 +18,30 @@ describe('pingAlpha', () => {
});
});
describe('sameListItem (#347)', () => {
it('keeps a row only while everything it draws is the same', () => {
const row: ListItem = { id: 'a', label: 'Crate', hint: '2', depth: 1, icon: '<svg/>', marks: ['<m/>'] };
expect(sameListItem(row, { ...row, marks: ['<m/>'] })).toBe(true);
expect(
sameListItem({ id: 'a', label: 'Crate' }, { id: 'a', label: 'Crate', depth: 0, overridden: false }),
).toBe(true);
const changes: Partial<ListItem>[] = [
{ id: 'b' },
{ label: 'Box' },
{ hint: '3' },
{ depth: 2 },
{ expanded: true },
{ overridden: true },
{ icon: '<svg></svg>' },
{ iconAtEnd: true },
{ heading: true },
{ marks: [] },
{ marks: ['<n/>'] },
];
for (const change of changes) expect(sameListItem(row, { ...row, ...change })).toBe(false);
});
});
describe('EditorState.pingAsset', () => {
it('names the asset and tells the listeners, but leaves the selection alone', () => {
const state = new EditorState();
+31 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { layoutSymbols, ringRadius } from '../../src/viewport/symbolLayout';
import { layoutSymbols, ringRadius, SymbolLayoutMemo } from '../../src/viewport/symbolLayout';
import { haloSvg } from '../../src/ui/svg';
const SPACING = 28;
@@ -89,6 +89,36 @@ describe('layoutSymbols', () => {
});
});
describe('SymbolLayoutMemo (#347)', () => {
it('answers with the same spots while the anchors stay put, and works them out again once one moves', () => {
const memo = new SymbolLayoutMemo();
const anchors = () => [
{ id: 'a', x: 0, y: 0 },
{ id: 'b', x: 0, y: 0 },
];
const first = memo.layout(anchors(), SPACING);
expect(memo.layout(anchors(), SPACING)).toBe(first);
const moved = memo.layout(
[
{ id: 'a', x: 0, y: 0 },
{ id: 'b', x: 100, y: 0 },
],
SPACING,
);
expect(moved).not.toBe(first);
expect(moved.get('b')).toEqual({ x: 100, y: 0, angle: null });
const wider = memo.layout(
[
{ id: 'a', x: 0, y: 0 },
{ id: 'b', x: 100, y: 0 },
],
2 * SPACING,
);
expect(wider).not.toBe(moved);
expect([...memo.layout(anchors(), SPACING)]).toEqual([...layoutSymbols(anchors(), SPACING)]);
});
});
describe('haloSvg', () => {
it('widens every shape by its outline in currentColor, keeping hollow shapes hollow', () => {
const svg = [