Tilemap: beim Malen nur die geänderten Zellen neu rechnen #348

Closed
opened 2026-10-07 16:57:35 +02:00 by meleki · 1 comment
Owner

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).
admin added this to the Development project 2026-10-07 17:05:25 +02:00
admin moved this to Next in Development on 2026-10-12 15:23:38 +02:00
admin added the low-priority label 2026-10-12 15:23:59 +02:00
Author
Owner

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.

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.
admin moved this to Done in Development on 2026-10-14 16:33:54 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: meleki/feuerwerk#348