Folge-Issue aus #347 (dort im Abschnitt Was bleibt im Abschluss-Kommentar).
Problem
Mit #347 läuft das Malen auf Garden of Sacrifices (60x60, 3 Ebenen) wieder flüssig. Eine gemalte Zelle rechnet aber weiterhin die ganze Map neu, nicht nur die Stelle, die sich geändert hat. Die Kosten wachsen also mit der Map-Größe, und bei deutlich größeren Maps ruckelt das Malen wieder.
Gemessen bei #347 (headless, eine gemalte Zelle, nach dem Fix):
60x60: 16 ms
dieselbe Map auf 120x120 verdoppelt: 49 ms
Bei 60 fps hat ein Frame 16,7 ms. Ab etwa 120x120 kostet eine einzelne Zelle also schon drei Frames.
Was pro gemalter Zelle neu gerechnet wird
Alle drei sind per Inhalt gemerkt (cellsVersion im Schlüssel). Jede Änderung macht den Merker also für die ganze Ebene bzw. Map ungültig:
layerCells (src/core/model/tilemap.ts): der Zellindex der Ebene, aus allen x,y-Schlüsseln neu gebaut.
layerTiles → workOutLayerTiles (src/core/model/tilemap.ts): Autotile-Viertel für jede Zelle der Ebene.
blockingCells → workOutBlockingCells (src/core/model/tilemapCollision.ts): die Kollisionsform über alle Ebenen. Im Profil von #347 war das nach dem Fix der größte Posten (rund 5,6 ms pro Zelle bei 60x60).
Dazu kommt alles, was danach aus diesen Listen gebaut wird (Mesh in src/viewport/tilemapMesh.ts, Collider). Wie viel davon auf die ganze Map geht, ist noch nicht gemessen.
Idee
Nur die geänderten Zellen und ihre 8 Nachbarn neu rechnen. Mehr kann sich beim Autotiling und bei der Kollisionsform nicht ändern.
Der Merker hält das letzte Ergebnis und den Stand, aus dem es kam. Kommt ein neuer Stand, wird der Unterschied der Zellen bestimmt und nur dieser Bereich (plus Rand von 1) neu gerechnet.
Ändert sich etwas anderes als Zellen (Gridgröße, Tile-Art, Kollisionsform eines Tiles, Filter), wird wie bisher alles neu gerechnet.
Das Ergebnis muss bytegleich mit dem vollen Neurechnen sein. Gut testbar: zufällige Mal-Folgen, nach jedem Schritt inkrementell gegen voll vergleichen.
Offen
Ob der Unterschied über die Zellen bestimmt wird (Vergleich alter/neuer Stand) oder ob der Pinsel die geänderten Zellen mitliefert. Letzteres ist schneller, gilt aber nur für Änderungen aus dem Editor, nicht für Undo, Laden oder Änderungen zur Laufzeit (liveTilemap).
Ob Mesh und Collider ebenfalls stückweise aktualisiert werden müssen, oder ob das Neurechnen der Listen schon reicht. Das zeigt erst ein Profil auf einer großen Map.
Fertig, wenn
Eine gemalte Zelle auf 120x120 kostet ungefähr so viel wie auf 60x60 (Messung wie bei #347).
Inkrementell und voll gerechnet liefern dasselbe (Unit-Test mit Gegenprobe).
Folge-Issue aus #347 (dort im Abschnitt *Was bleibt* im Abschluss-Kommentar).
## Problem
Mit #347 läuft das Malen auf *Garden of Sacrifices* (60x60, 3 Ebenen) wieder flüssig. Eine gemalte Zelle rechnet aber weiterhin die **ganze** Map neu, nicht nur die Stelle, die sich geändert hat. Die Kosten wachsen also mit der Map-Größe, und bei deutlich größeren Maps ruckelt das Malen wieder.
Gemessen bei #347 (headless, eine gemalte Zelle, nach dem Fix):
- 60x60: 16 ms
- dieselbe Map auf 120x120 verdoppelt: 49 ms
Bei 60 fps hat ein Frame 16,7 ms. Ab etwa 120x120 kostet eine einzelne Zelle also schon drei Frames.
## Was pro gemalter Zelle neu gerechnet wird
Alle drei sind per Inhalt gemerkt (`cellsVersion` im Schlüssel). Jede Änderung macht den Merker also für die ganze Ebene bzw. Map ungültig:
- `layerCells` (`src/core/model/tilemap.ts`): der Zellindex der Ebene, aus allen `x,y`-Schlüsseln neu gebaut.
- `layerTiles` → `workOutLayerTiles` (`src/core/model/tilemap.ts`): Autotile-Viertel für **jede** Zelle der Ebene.
- `blockingCells` → `workOutBlockingCells` (`src/core/model/tilemapCollision.ts`): die Kollisionsform über **alle** Ebenen. Im Profil von #347 war das nach dem Fix der größte Posten (rund 5,6 ms pro Zelle bei 60x60).
- Dazu kommt alles, was danach aus diesen Listen gebaut wird (Mesh in `src/viewport/tilemapMesh.ts`, Collider). Wie viel davon auf die ganze Map geht, ist noch nicht gemessen.
## Idee
Nur die geänderten Zellen und ihre 8 Nachbarn neu rechnen. Mehr kann sich beim Autotiling und bei der Kollisionsform nicht ändern.
- Der Merker hält das letzte Ergebnis und den Stand, aus dem es kam. Kommt ein neuer Stand, wird der Unterschied der Zellen bestimmt und nur dieser Bereich (plus Rand von 1) neu gerechnet.
- Ändert sich etwas anderes als Zellen (Gridgröße, Tile-Art, Kollisionsform eines Tiles, Filter), wird wie bisher alles neu gerechnet.
- Das Ergebnis muss bytegleich mit dem vollen Neurechnen sein. Gut testbar: zufällige Mal-Folgen, nach jedem Schritt inkrementell gegen voll vergleichen.
## Offen
- Ob der Unterschied über die Zellen bestimmt wird (Vergleich alter/neuer Stand) oder ob der Pinsel die geänderten Zellen mitliefert. Letzteres ist schneller, gilt aber nur für Änderungen aus dem Editor, nicht für Undo, Laden oder Änderungen zur Laufzeit (`liveTilemap`).
- Ob Mesh und Collider ebenfalls stückweise aktualisiert werden müssen, oder ob das Neurechnen der Listen schon reicht. Das zeigt erst ein Profil auf einer großen Map.
## Fertig, wenn
- Eine gemalte Zelle auf 120x120 kostet ungefähr so viel wie auf 60x60 (Messung wie bei #347).
- Inkrementell und voll gerechnet liefern dasselbe (Unit-Test mit Gegenprobe).
Pinsel, Paint-Command und Undo melden die geänderten Zell-Schlüssel. Jede Version der Zellen merkt sie sich (cellsChangedSince, die letzten 16 Versionen).
Zellindex, Tile-Liste der Ebene und Kollisionszellen flicken nur die geänderten Zellen und ihre 8 Nachbarn. Alles andere bleibt dasselbe Objekt.
Die Kollisionsrechtecke werden zeilenweise verschmolzen. Nach dem Malen laufen nur die Zeilen rund um die Änderung neu, eine Zeile weiter als die geänderten Zellen.
Ohne gemeldete Schlüssel kostet es einen Vergleich über alle Zellen. Ein ersetztes Zellobjekt (Laden, Resize, Laufzeit-Edits) wird wie bisher ganz gerechnet.
Kleine Verhaltensänderung: Zwei Trigger-Tiles in einer Zelle stehen jetzt nach Tile-Id sortiert, nicht in Malreihenfolge.
Nebenbei: resolveTilemap lief jeden Frame über alle Tiles, jetzt nur noch bei geänderter Liste.
Gemessen
Eine gemalte Zelle (headless, stein2):
Map
vorher
jetzt
60x60
16,8 ms
3,5 ms
120x120
54,6 ms
4,0 ms
ohne Malen
3,4 ms
Pinselstrich im Browser (Kopie von Garden of Sacrifices):
Map
vorher
jetzt
60x60
42,3 fps
48,8 fps
120x120
27,9 fps, p95 92 ms
41,9 fps, p95 52 ms
Das Kriterium „120x120 kostet ungefähr so viel wie 60x60“ ist für die Rechnung erfüllt.
Tests
tests/core/tilemap-patch.test.ts (neu, 7): 400 zufällige Schritte auf 2 Ebenen mit allen Tile-Arten, nach jedem Schritt Vergleich mit einer frisch gerechneten Kopie. Dazu ein fester Test für den Fund unten.
Gegenproben: Nachbarn weglassen, gelöschte Zellen übersehen und nur die eigene Zeile neu verschmelzen machen den Test jeweils rot.
Fund: Ein gelöschtes Autotile mit Umriss kann das Autotile darunter zur vollen Box machen, und das ändert den Zuschnitt einer skalierten Box zwei Zeilen tiefer. Deshalb wird eine Zeile weiter verschmolzen.
Was bleibt
Was noch mit der Map wächst, ist das Mesh: Pro gemalter Zelle wird jedes Mesh der Ebene neu gebaut (rund 10 % des Main-Threads auf 120x120). Das ist jetzt #424.
Erledigt mit 88e1cf1c, Wiki *Tilemaps* mit ae9afca.
## Was sich ändert
- Pinsel, Paint-Command und Undo melden die geänderten Zell-Schlüssel. Jede Version der Zellen merkt sie sich (`cellsChangedSince`, die letzten 16 Versionen).
- Zellindex, Tile-Liste der Ebene und Kollisionszellen flicken nur die geänderten Zellen und ihre 8 Nachbarn. Alles andere bleibt dasselbe Objekt.
- Die Kollisionsrechtecke werden zeilenweise verschmolzen. Nach dem Malen laufen nur die Zeilen rund um die Änderung neu, eine Zeile weiter als die geänderten Zellen.
- Ohne gemeldete Schlüssel kostet es einen Vergleich über alle Zellen. Ein ersetztes Zellobjekt (Laden, Resize, Laufzeit-Edits) wird wie bisher ganz gerechnet.
- Kleine Verhaltensänderung: Zwei Trigger-Tiles in einer Zelle stehen jetzt nach Tile-Id sortiert, nicht in Malreihenfolge.
- Nebenbei: `resolveTilemap` lief jeden Frame über alle Tiles, jetzt nur noch bei geänderter Liste.
## Gemessen
Eine gemalte Zelle (headless, stein2):
| Map | vorher | jetzt |
|---|---|---|
| 60x60 | 16,8 ms | 3,5 ms |
| 120x120 | 54,6 ms | 4,0 ms |
| ohne Malen | | 3,4 ms |
Pinselstrich im Browser (Kopie von *Garden of Sacrifices*):
| Map | vorher | jetzt |
|---|---|---|
| 60x60 | 42,3 fps | 48,8 fps |
| 120x120 | 27,9 fps, p95 92 ms | 41,9 fps, p95 52 ms |
Das Kriterium „120x120 kostet ungefähr so viel wie 60x60“ ist für die Rechnung erfüllt.
## Tests
- `tests/core/tilemap-patch.test.ts` (neu, 7): 400 zufällige Schritte auf 2 Ebenen mit allen Tile-Arten, nach jedem Schritt Vergleich mit einer frisch gerechneten Kopie. Dazu ein fester Test für den Fund unten.
- Unit Tilemap + Viewport: 1284 grün. e2e 39 + 40 (atx): 107 Checks grün. Gate (atx): 312 Dateien, 4831 Tests grün.
- Gegenproben: Nachbarn weglassen, gelöschte Zellen übersehen und nur die eigene Zeile neu verschmelzen machen den Test jeweils rot.
- **Fund:** Ein gelöschtes Autotile mit Umriss kann das Autotile darunter zur vollen Box machen, und das ändert den Zuschnitt einer skalierten Box zwei Zeilen tiefer. Deshalb wird eine Zeile weiter verschmolzen.
## Was bleibt
Was noch mit der Map wächst, ist das Mesh: Pro gemalter Zelle wird jedes Mesh der Ebene neu gebaut (rund 10 % des Main-Threads auf 120x120). Das ist jetzt #424.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Folge-Issue aus #347 (dort im Abschnitt Was bleibt im Abschluss-Kommentar).
Problem
Mit #347 läuft das Malen auf Garden of Sacrifices (60x60, 3 Ebenen) wieder flüssig. Eine gemalte Zelle rechnet aber weiterhin die ganze Map neu, nicht nur die Stelle, die sich geändert hat. Die Kosten wachsen also mit der Map-Größe, und bei deutlich größeren Maps ruckelt das Malen wieder.
Gemessen bei #347 (headless, eine gemalte Zelle, nach dem Fix):
Bei 60 fps hat ein Frame 16,7 ms. Ab etwa 120x120 kostet eine einzelne Zelle also schon drei Frames.
Was pro gemalter Zelle neu gerechnet wird
Alle drei sind per Inhalt gemerkt (
cellsVersionim Schlüssel). Jede Änderung macht den Merker also für die ganze Ebene bzw. Map ungültig:layerCells(src/core/model/tilemap.ts): der Zellindex der Ebene, aus allenx,y-Schlüsseln neu gebaut.layerTiles→workOutLayerTiles(src/core/model/tilemap.ts): Autotile-Viertel für jede Zelle der Ebene.blockingCells→workOutBlockingCells(src/core/model/tilemapCollision.ts): die Kollisionsform über alle Ebenen. Im Profil von #347 war das nach dem Fix der größte Posten (rund 5,6 ms pro Zelle bei 60x60).src/viewport/tilemapMesh.ts, Collider). Wie viel davon auf die ganze Map geht, ist noch nicht gemessen.Idee
Nur die geänderten Zellen und ihre 8 Nachbarn neu rechnen. Mehr kann sich beim Autotiling und bei der Kollisionsform nicht ändern.
Offen
liveTilemap).Fertig, wenn
Erledigt mit
88e1cf1c, Wiki Tilemaps mit ae9afca.Was sich ändert
cellsChangedSince, die letzten 16 Versionen).resolveTilemaplief jeden Frame über alle Tiles, jetzt nur noch bei geänderter Liste.Gemessen
Eine gemalte Zelle (headless, stein2):
Pinselstrich im Browser (Kopie von Garden of Sacrifices):
Das Kriterium „120x120 kostet ungefähr so viel wie 60x60“ ist für die Rechnung erfüllt.
Tests
tests/core/tilemap-patch.test.ts(neu, 7): 400 zufällige Schritte auf 2 Ebenen mit allen Tile-Arten, nach jedem Schritt Vergleich mit einer frisch gerechneten Kopie. Dazu ein fester Test für den Fund unten.Was bleibt
Was noch mit der Map wächst, ist das Mesh: Pro gemalter Zelle wird jedes Mesh der Ebene neu gebaut (rund 10 % des Main-Threads auf 120x120). Das ist jetzt #424.