Tilemap: beim Malen nur die Meshes der geänderten Tiles neu bauen #424

Closed
opened 2026-10-14 16:14:54 +02:00 by meleki · 1 comment
Owner

Warum

Nach #348 rechnet eine gemalte Zelle Zellindex, Tile-Liste und Kollision nur noch in ihrer Umgebung neu. Was jetzt noch mit der Map-Größe wächst, ist das Mesh: Im CPU-Profil beim Malen auf 120x120 (Kopie von Garden of Sacrifices) ist TilemapField.sync mit rund 10 % des Main-Threads der größte Posten, der noch an der Map hängt.

Gemessen bei #348 (Pinselstrich, stein2):

Map vorher nach #348
60x60 42,3 fps 48,8 fps
120x120 27,9 fps, p95 92 ms 41,9 fps, p95 52 ms

Ohne Malen liegt die Szene bei 55–57 fps. Die Lücke auf 120x120 ist also noch deutlich.

Was heute passiert

  • Eine gemalte Zelle gibt der Ebene eine neue layer.tiles-Liste (seit #348 stückweise geflickt, die einzelnen TileDraw-Einträge bleiben dieselben Objekte).
  • groupByTile (src/viewport/tilemapMesh.ts) hängt am Listen-Objekt. Eine neue Liste wird komplett neu gruppiert, jede Gruppe ist ein neues Array.
  • TilemapField.meshFor (src/viewport/tilemap.ts) vergleicht tiles per Identität. Damit wird jedes Mesh der Ebene neu gebaut (tileMesh + neue MeshGeometry), nicht nur das des gemalten Tiles.
  • Bei einer Ebene mit Y-Sortierung (layerRows) gilt das je Zeile. Ob unberührte Zeilen ihre Liste behalten, ist zu prüfen.

Idee

  1. Gruppen stückweise: groupByTile übernimmt bei einer geflickten Liste die Gruppen der Vorgänger-Liste und baut nur die Gruppen der Tiles neu, deren Zellen sich geändert haben (die geänderten Schlüssel gibt es seit #348 über cellsChangedSince). Unberührte Gruppen bleiben dasselbe Array, ihr Mesh wird nicht angefasst. Das allein sollte den Großteil bringen.
  2. Geometrie stückweise (nur falls 1 nicht reicht): Im Mesh des gemalten Tiles nur die Quads der geänderten Zellen schreiben, statt positions/uvs/indices neu zu erzeugen. Das heißt: feste Slots je Zelle oder Lücken als degenerierte Quads, Puffer mit Reserve, buffer.update() statt neuer MeshGeometry. Das ist der eigentliche Umbau an der Pixi-Geometrie.

Abnahme

  • Pinselstrich auf 120x120 kommt nahe an die fps von 60x60 heran (gleiche Sonde wie #347/#348).
  • TilemapField.builds steigt pro gemalter Zelle um höchstens die Zahl der betroffenen Tiles, nicht um alle Tiles der Ebene.
  • Vergleichstest: stückweise gebaute Meshes ergeben dieselben Vertices wie ein frischer Bau (wie tilemap-patch.test.ts bei #348).
  • e2e 39 + 40 grün.

Bezug

  • #347, #348 (Vorarbeit), #284 (Mesh nur bei Änderung neu bauen)
## Warum Nach #348 rechnet eine gemalte Zelle Zellindex, Tile-Liste und Kollision nur noch in ihrer Umgebung neu. Was jetzt noch mit der Map-Größe wächst, ist das **Mesh**: Im CPU-Profil beim Malen auf 120x120 (Kopie von *Garden of Sacrifices*) ist `TilemapField.sync` mit rund **10 % des Main-Threads** der größte Posten, der noch an der Map hängt. Gemessen bei #348 (Pinselstrich, stein2): | Map | vorher | nach #348 | |---|---|---| | 60x60 | 42,3 fps | 48,8 fps | | 120x120 | 27,9 fps, p95 92 ms | 41,9 fps, p95 52 ms | Ohne Malen liegt die Szene bei 55–57 fps. Die Lücke auf 120x120 ist also noch deutlich. ## Was heute passiert - Eine gemalte Zelle gibt der Ebene eine neue `layer.tiles`-Liste (seit #348 stückweise geflickt, die einzelnen `TileDraw`-Einträge bleiben dieselben Objekte). - `groupByTile` (`src/viewport/tilemapMesh.ts`) hängt am Listen-Objekt. Eine neue Liste wird komplett neu gruppiert, **jede** Gruppe ist ein neues Array. - `TilemapField.meshFor` (`src/viewport/tilemap.ts`) vergleicht `tiles` per Identität. Damit wird **jedes Mesh der Ebene** neu gebaut (`tileMesh` + neue `MeshGeometry`), nicht nur das des gemalten Tiles. - Bei einer Ebene mit Y-Sortierung (`layerRows`) gilt das je Zeile. Ob unberührte Zeilen ihre Liste behalten, ist zu prüfen. ## Idee 1. **Gruppen stückweise:** `groupByTile` übernimmt bei einer geflickten Liste die Gruppen der Vorgänger-Liste und baut nur die Gruppen der Tiles neu, deren Zellen sich geändert haben (die geänderten Schlüssel gibt es seit #348 über `cellsChangedSince`). Unberührte Gruppen bleiben dasselbe Array, ihr Mesh wird nicht angefasst. Das allein sollte den Großteil bringen. 2. **Geometrie stückweise (nur falls 1 nicht reicht):** Im Mesh des gemalten Tiles nur die Quads der geänderten Zellen schreiben, statt `positions`/`uvs`/`indices` neu zu erzeugen. Das heißt: feste Slots je Zelle oder Lücken als degenerierte Quads, Puffer mit Reserve, `buffer.update()` statt neuer `MeshGeometry`. Das ist der eigentliche Umbau an der Pixi-Geometrie. ## Abnahme - Pinselstrich auf 120x120 kommt nahe an die fps von 60x60 heran (gleiche Sonde wie #347/#348). - `TilemapField.builds` steigt pro gemalter Zelle um höchstens die Zahl der betroffenen Tiles, nicht um alle Tiles der Ebene. - Vergleichstest: stückweise gebaute Meshes ergeben dieselben Vertices wie ein frischer Bau (wie `tilemap-patch.test.ts` bei #348). - e2e 39 + 40 grün. ## Bezug - #347, #348 (Vorarbeit), #284 (Mesh nur bei Änderung neu bauen)
meleki added the high-priority label 2026-10-14 16:14:54 +02:00
admin added this to the Development project 2026-10-14 16:34:02 +02:00
admin moved this to Next in Development on 2026-10-14 16:34:03 +02:00
admin moved this to Done in Development on 2026-10-14 18:37:30 +02:00
Author
Owner

Erledigt mit df6dbe9 (Wiki e33da89, Tilemaps). Stufe 1 hat gereicht, Stufe 2 braucht es nicht.

Umsetzung

  • Eine geflickte Tile-Liste merkt sich ihre Herkunft (tilesPatch): aus welcher Liste sie entstanden ist und welche Einträge weg- bzw. dazugekommen sind. Eine Nachbarzelle, die gleich aussieht, behält ihren Eintrag. Ändert sich am Bild nichts, kommt dieselbe Liste zurück.
  • groupByTile und layerRows flicken die Gruppen der vorigen Liste (patchedGroups). Eine unberührte Gruppe bleibt dasselbe Array, meshFor baut ihr Mesh also nicht neu. Bei Ebenen, die nach Y sortiert sind, gilt das für jede Zeile und die Gruppen darin.
  • Ein Tile, das auf der Ebene noch nicht vorkommt, rechnet die Ebene weiter ganz neu (wie seit #348).

Gemessen

Gleiche Sonde wie bei #348 (Pinselstrich auf einer Kopie von Garden of Sacrifices, stein2):

60x60 vorher 60x60 jetzt 120x120 vorher 120x120 jetzt
Neu gebaute Meshes pro Frame 21,9 0,94 40,7 1,22
fps beim Malen 49,4 51,1 42,0 45,8
p95 eines Frames 44,7 ms 40,2 ms 53,3 ms 44,9 ms
  • Malen kostet auf beiden Größen jetzt gleich viel, etwa 10 fps unter derselben Ansicht ohne Malen (60,9 bzw. 55,3 fps). Vorher waren es auf 120x120 13,4 fps.
  • Der Rest-Abstand zwischen den Größen ist das Zeichnen der größeren Map, er ist auch ohne Malen da. Das Abnahmekriterium „120x120 nahe an 60x60“ ist damit beim Malen erfüllt, absolut liegen 120x120 noch 5 fps darunter.
  • Im CPU-Profil auf 120x120 taucht die Tilemap-Geometrie nicht mehr auf. Oben steht jetzt die Auflösung der Szene (expand/resolveObjects, collectHolderItems).

Tests

  • Gate auf melektaus-atx grün: 312 Dateien, 4832 Tests.
  • Der Zufallsweg aus #348 vergleicht nach jedem der 400 Schritte auch Gruppen und Zeilen (samt Sort-Offsets) mit einer frischen Gruppierung und zählt, dass Gruppen wirklich übernommen werden. Dazu ein fester Test: Eine gemalte Zelle lässt die Gruppe eines anderen Tiles und alle anderen Zeilen unverändert.
  • Gegenproben, alle drei rot: entfernte Einträge bleiben in der Gruppe, Zeilen nach der ersten Zelle statt nach der Zeilennummer sortiert, groupByTile übergeht den Patch.
  • e2e 39 + 40 auf melektaus-atx: ALL PASSED, 107 Checks.
  • Den CI-Lauf zu df6dbe9 habe ich nicht abgewartet.
Erledigt mit `df6dbe9` (Wiki `e33da89`, *Tilemaps*). Stufe 1 hat gereicht, Stufe 2 braucht es nicht. ## Umsetzung - **Eine geflickte Tile-Liste merkt sich ihre Herkunft** (`tilesPatch`): aus welcher Liste sie entstanden ist und welche Einträge weg- bzw. dazugekommen sind. Eine Nachbarzelle, die gleich aussieht, behält ihren Eintrag. Ändert sich am Bild nichts, kommt dieselbe Liste zurück. - **`groupByTile` und `layerRows` flicken die Gruppen der vorigen Liste** (`patchedGroups`). Eine unberührte Gruppe bleibt dasselbe Array, `meshFor` baut ihr Mesh also nicht neu. Bei Ebenen, die nach Y sortiert sind, gilt das für jede Zeile und die Gruppen darin. - Ein Tile, das auf der Ebene noch nicht vorkommt, rechnet die Ebene weiter ganz neu (wie seit #348). ## Gemessen Gleiche Sonde wie bei #348 (Pinselstrich auf einer Kopie von *Garden of Sacrifices*, stein2): | | 60x60 vorher | 60x60 jetzt | 120x120 vorher | 120x120 jetzt | |---|---|---|---|---| | Neu gebaute Meshes pro Frame | 21,9 | 0,94 | 40,7 | 1,22 | | fps beim Malen | 49,4 | 51,1 | 42,0 | 45,8 | | p95 eines Frames | 44,7 ms | 40,2 ms | 53,3 ms | 44,9 ms | - Malen kostet auf beiden Größen jetzt gleich viel, etwa 10 fps unter derselben Ansicht ohne Malen (60,9 bzw. 55,3 fps). Vorher waren es auf 120x120 13,4 fps. - Der Rest-Abstand zwischen den Größen ist das Zeichnen der größeren Map, er ist auch ohne Malen da. Das Abnahmekriterium „120x120 nahe an 60x60“ ist damit beim Malen erfüllt, absolut liegen 120x120 noch 5 fps darunter. - Im CPU-Profil auf 120x120 taucht die Tilemap-Geometrie nicht mehr auf. Oben steht jetzt die Auflösung der Szene (`expand`/`resolveObjects`, `collectHolderItems`). ## Tests - Gate auf `melektaus-atx` grün: 312 Dateien, 4832 Tests. - Der Zufallsweg aus #348 vergleicht nach jedem der 400 Schritte auch Gruppen und Zeilen (samt Sort-Offsets) mit einer frischen Gruppierung und zählt, dass Gruppen wirklich übernommen werden. Dazu ein fester Test: Eine gemalte Zelle lässt die Gruppe eines anderen Tiles und alle anderen Zeilen unverändert. - Gegenproben, alle drei rot: entfernte Einträge bleiben in der Gruppe, Zeilen nach der ersten Zelle statt nach der Zeilennummer sortiert, `groupByTile` übergeht den Patch. - e2e 39 + 40 auf `melektaus-atx`: ALL PASSED, 107 Checks. - Den CI-Lauf zu `df6dbe9` habe ich nicht abgewartet.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: meleki/feuerwerk#424