feat(client): terreno 3D — copertura consapevole della quota e ricostruzione in background #273

Closed
panda wants to merge 51 commits from design/3d-terrain into main
Collaborator

Summary

Il terreno passa al 3D vero, e questa tornata chiude i due problemi emersi camminando sull'asterra: "ad esempio qui dovrebbe esserci della lava" — un lago di lava autorizzato che a schermo era nero — e "è qualcosa nel mesh perché camminando in pendenza lo sfondo cambia di continuo a seconda di dove ci si muove, anche se magari è monobioma come la pendice del vulcano".

Erano due sintomi di una causa sola, in due punti distinti.

Primo: la camera segue il giocatore alla sua posizione disegnata, che contiene già il sollevamento -z*ZScale. I due punti che riconvertivano quella posizione in celle di mappa usavano l'inverso piatto, quindi nominavano le celle a quota 0 che finiscono sugli stessi pixel — 86 celle di distanza a z 237. La finestra dei biomi veniva costruita attorno a un'altra zona della mappa: la lava non veniva mai campionata, e siccome l'errore è proporzionale a z, ogni passo in salita lo spostava.

Secondo: la mesh veniva disegnata su un rettangolo più largo di quello per cui esistevano dati di bioma e rilievo. Nel mezzo i sampler bloccavano al bordo — niente texture, ombreggiatura piatta — e quel bordo si spostava a ogni ricostruzione della finestra. Da qui TerrainWindow, che diventa l'unica autorità su quali celle vanno coperte: mesh e dati derivano da lì e non possono più divergere.

I margini di quota sono asimmetrici perché è la forma giusta, non un'ottimizzazione: con screenY = (x+y)*TileHeight/2 - z*ZScale, il terreno entra in vista da +x+y solo se sta sopra il piano della camera, e da -x-y solo se sta sotto.

Coprire tutto il disegnato costava però 300-500 ms di ricostruzione sul thread di render. Quel lavoro passa a un worker cancellabile con un loader generation-safe; il thread di render fa solo lo scambio delle texture. La misura chiave: adottare una finestra costa ora 1 ms.

Infine le statiche, cullate con padding dimensionato solo sullo sprite e senza termine di quota: dal fondovalle il vulcano risultava correttamente texturizzato e completamente calvo.

Screenshots / recording

Rotta cratere-valle, database fresco, cinque punti diagnostici. Nessuna fascia grigia e nessun bordo diagonale mobile in nessuno dei cinque.

punto tile quota vista
1 7511,4197 234 punto 1
2 7505,4191 - punto 2
3 7499,4185 - punto 3
4 7490,4176 - punto 4
5 7473,4163 159 punto 5

Comandi (docs/debug-harness.md), con il database azzerato prima di just dev:

login test
key Enter / type /tp 7511 4197 / key Enter
move northwest x6  ->  screenshot        (ripetuto fino al quinto punto)

How it was tested

  • 1032 test, tutti verdi; IsoMmo.Client.Core.Tests 292.
  • Un oracolo di proiezione indipendente (TerrainWindowOracleTests) decide la visibilità di una cella dall'equazione di proiezione, senza chiamare le funzioni sotto test: senza di lui un errore di segno nel margine renderebbe sbagliati insieme DrawnBounds e Required, e ogni test di contenimento passerebbe comunque. È il test che ha trovato il buco del mezzo-rombo.
  • Verifica per mutazione su ogni regola di concorrenza del loader: rotta la riga che la fa rispettare, un test nominato deve fallire. Idem per i checkpoint di cancellazione e per il pad geometrico.
  • Misure di frame sulla rotta a cinque punti, database fresco, senza screenshot dentro il campione (la codifica PNG sta sul thread di render e falsava il picco): p50 16,2-16,7 ms · p95 21,6-22,9 · p99 24,5-28,3 · max 29,9-37,9. Gate p99 <= 2x mediana: 28,3 <= 33,5. Build bloccanti durante il cammino: 0. snapshot 13-14 ms · build 400-529 ms · upload 1 ms.

Limiti noti, dichiarati

  • I test di cancellazione inchiodano che esiste un checkpoint dopo la materializzazione, non che ce n'è uno per ogni passaggio: la seconda è una proprietà di latenza e non ha un punto d'iniezione deterministico senza dipendere dal tempo.
  • Una finestra completata che non è più safe ma copre ancora viene scartata anziché adottata. Serve una deriva da teleport (~80 celle/s contro le ~9 di una corsa) e di norma produce comunque una richiesta asincrona, non uno stallo. Registrato come follow-up.
  • Il ciclo delle statiche passa da ~9,8k a ~75k celle per frame: il margine è corretto e restringerlo sarebbe un difetto. Il seguito è il min/max-Z per chunk, fuori ambito qui.
  • Il personaggio può finire dietro al terreno su un versante rivolto verso la camera: lo sprite è un cartellone il cui piede sta sul vertice della mesh e viene testato in profondità contro quella stessa superficie. Precede questo ramo (i file che governano la profondità sono invariati) e va risolto con un bias di profondità, in una modifica a sé.

Checklist

  • just lint passa (CSharpier + analyzer, zero warning)
  • just test è verde
  • L'intera soluzione compila (client e tool inclusi), anche in Release
  • Multi-piattaforma preservato: solo Task/CancellationToken e Texture2D.SetData, nulla di specifico per OS
  • Test aggiunti/aggiornati per questa modifica
  • Issue collegata: da associare se ne esiste una per il terreno 3D
## Summary Il terreno passa al 3D vero, e questa tornata chiude i due problemi emersi camminando sull'asterra: **"ad esempio qui dovrebbe esserci della lava"** — un lago di lava autorizzato che a schermo era nero — e **"è qualcosa nel mesh perché camminando in pendenza lo sfondo cambia di continuo a seconda di dove ci si muove, anche se magari è monobioma come la pendice del vulcano"**. Erano due sintomi di una causa sola, in due punti distinti. **Primo**: la camera segue il giocatore alla sua posizione *disegnata*, che contiene già il sollevamento `-z*ZScale`. I due punti che riconvertivano quella posizione in celle di mappa usavano l'inverso piatto, quindi nominavano le celle a quota 0 che finiscono sugli stessi pixel — 86 celle di distanza a `z 237`. La finestra dei biomi veniva costruita attorno a un'altra zona della mappa: la lava non veniva mai campionata, e siccome l'errore è proporzionale a `z`, ogni passo in salita lo spostava. **Secondo**: la mesh veniva disegnata su un rettangolo più largo di quello per cui esistevano dati di bioma e rilievo. Nel mezzo i sampler bloccavano al bordo — niente texture, ombreggiatura piatta — e quel bordo si spostava a ogni ricostruzione della finestra. Da qui `TerrainWindow`, che diventa **l'unica autorità** su quali celle vanno coperte: mesh e dati derivano da lì e non possono più divergere. I margini di quota sono **asimmetrici** perché è la forma giusta, non un'ottimizzazione: con `screenY = (x+y)*TileHeight/2 - z*ZScale`, il terreno entra in vista da `+x+y` solo se sta *sopra* il piano della camera, e da `-x-y` solo se sta *sotto*. Coprire tutto il disegnato costava però 300-500 ms di ricostruzione sul thread di render. Quel lavoro passa a un worker cancellabile con un loader generation-safe; il thread di render fa solo lo scambio delle texture. **La misura chiave: adottare una finestra costa ora 1 ms.** Infine le statiche, cullate con padding dimensionato solo sullo sprite e senza termine di quota: dal fondovalle il vulcano risultava correttamente texturizzato e completamente calvo. ## Screenshots / recording Rotta cratere-valle, database fresco, cinque punti diagnostici. Nessuna fascia grigia e nessun bordo diagonale mobile in nessuno dei cinque. | punto | tile | quota | vista | | --- | --- | --- | --- | | 1 | 7511,4197 | 234 | ![punto 1](https://git.homelab.devncode.it/attachments/87bb73e7-b798-4f4c-88b0-a5d695ea70f5) | | 2 | 7505,4191 | - | ![punto 2](https://git.homelab.devncode.it/attachments/edb6ee78-5f5c-42c2-a050-8e774713bf96) | | 3 | 7499,4185 | - | ![punto 3](https://git.homelab.devncode.it/attachments/530eb327-8750-4c5e-be83-66c0c77a4dbb) | | 4 | 7490,4176 | - | ![punto 4](https://git.homelab.devncode.it/attachments/4bb4a568-e265-4020-a1e6-458cf092307e) | | 5 | 7473,4163 | 159 | ![punto 5](https://git.homelab.devncode.it/attachments/d7391ea1-2ce5-4d7f-93ac-900c8864f961) | Comandi (`docs/debug-harness.md`), con il database azzerato prima di `just dev`: ``` login test key Enter / type /tp 7511 4197 / key Enter move northwest x6 -> screenshot (ripetuto fino al quinto punto) ``` ## How it was tested - **1032 test**, tutti verdi; `IsoMmo.Client.Core.Tests` 292. - Un **oracolo di proiezione indipendente** (`TerrainWindowOracleTests`) decide la visibilità di una cella dall'equazione di proiezione, senza chiamare le funzioni sotto test: senza di lui un errore di segno nel margine renderebbe sbagliati *insieme* `DrawnBounds` e `Required`, e ogni test di contenimento passerebbe comunque. È il test che ha trovato il buco del mezzo-rombo. - **Verifica per mutazione** su ogni regola di concorrenza del loader: rotta la riga che la fa rispettare, un test nominato deve fallire. Idem per i checkpoint di cancellazione e per il pad geometrico. - **Misure di frame** sulla rotta a cinque punti, database fresco, senza screenshot dentro il campione (la codifica PNG sta sul thread di render e falsava il picco): p50 16,2-16,7 ms · p95 21,6-22,9 · p99 24,5-28,3 · max 29,9-37,9. Gate `p99 <= 2x mediana`: **28,3 <= 33,5**. Build bloccanti durante il cammino: **0**. snapshot 13-14 ms · build 400-529 ms · **upload 1 ms**. ### Limiti noti, dichiarati - I test di cancellazione inchiodano che *esiste* un checkpoint dopo la materializzazione, non che ce n'è uno per ogni passaggio: la seconda è una proprietà di latenza e non ha un punto d'iniezione deterministico senza dipendere dal tempo. - Una finestra completata che non è più *safe* ma **copre ancora** viene scartata anziché adottata. Serve una deriva da teleport (~80 celle/s contro le ~9 di una corsa) e di norma produce comunque una richiesta asincrona, non uno stallo. Registrato come follow-up. - Il ciclo delle statiche passa da ~9,8k a ~75k celle per frame: il margine è corretto e restringerlo sarebbe un difetto. Il seguito è il min/max-Z per chunk, fuori ambito qui. - Il personaggio può finire dietro al terreno su un versante rivolto verso la camera: lo sprite è un cartellone il cui piede sta sul vertice della mesh e viene testato in profondità contro quella stessa superficie. Precede questo ramo (i file che governano la profondità sono invariati) e va risolto con un bias di profondità, in una modifica a sé. ## Checklist - [x] `just lint` passa (CSharpier + analyzer, zero warning) - [x] `just test` è verde - [x] L'intera soluzione compila (client e tool inclusi), anche in Release - [x] Multi-piattaforma preservato: solo `Task`/`CancellationToken` e `Texture2D.SetData`, nulla di specifico per OS - [x] Test aggiunti/aggiornati per questa modifica - [ ] Issue collegata: da associare se ne esiste una per il terreno 3D
docs(design): 3D terrain with a fixed isometric camera — real Z instead of simulated, 2D sprites preserved
All checks were successful
ci / Lua content lint (pull_request) Successful in 13s
ci / Lint & Test (pull_request) Successful in 4m54s
dbfbd4d65d
docs(plan): 3D terrain de-risk spike — shared camera + mesh maths, real map, Z-scale gate
All checks were successful
ci / Lua content lint (pull_request) Successful in 11s
ci / Lint & Test (pull_request) Successful in 5m41s
171001d2f5
spike(render): 3D terrain on the real map — ortho iso camera, real normals, depth + billboard
Some checks failed
ci / Lua content lint (pull_request) Successful in 10s
ci / Lint & Test (pull_request) Failing after 1m17s
d7f2c90790
docs(design): 3D terrain gate PASSED — real verticality confirmed, owner picks Z scale x16
Some checks failed
ci / Lua content lint (pull_request) Successful in 9s
ci / Lint & Test (pull_request) Failing after 1m9s
d1800b0c43
spike(render): 3D terrain textured with the real biome art from the asset pack
Some checks failed
ci / Lua content lint (pull_request) Successful in 12s
ci / Lint & Test (pull_request) Failing after 1m11s
04ef006d19
docs(design): enumerate the existing visual refinements as a Phase 1 carry-over checklist
Some checks failed
ci / Lua content lint (pull_request) Successful in 13s
ci / Lint & Test (pull_request) Failing after 1m16s
9bcbb5ead5
feat(render): 3D terrain in the client — unified projection at Z scale 16, real-normal lighting, height-aware picking
Some checks failed
ci / Lua content lint (pull_request) Successful in 12s
ci / Lint & Test (pull_request) Failing after 1m12s
450f08818d
fix(render): ray-march tile picking + movement test suite over procedural terrain with peaks
Some checks failed
ci / Lua content lint (pull_request) Successful in 10s
ci / Lint & Test (pull_request) Failing after 1m14s
51c79fee37
docs(design): real Z exposes a walkability gap — steepness/gradient, not just per-step height
Some checks failed
ci / Lua content lint (pull_request) Successful in 12s
ci / Lint & Test (pull_request) Failing after 1m16s
b0c5e9be00
feat(movement): sustained-climb budget — a route's steepness is limited, not just one step
Some checks failed
ci / Lua content lint (pull_request) Successful in 13s
ci / Lint & Test (pull_request) Failing after 1m7s
dcbc8c3758
docs(design): record the entity-occlusion approach and its shared-depth-mapping pitfall
Some checks failed
ci / Lua content lint (pull_request) Successful in 19s
ci / Lint & Test (pull_request) Failing after 1m29s
bae2f5f869
feat(render): entities test the terrain depth buffer — a cliff now hides what is behind it
Some checks failed
ci / Lua content lint (pull_request) Successful in 10s
ci / Lint & Test (pull_request) Failing after 1m5s
dd290df964
fix(roads): grade roads to the sustainable gradient, not the single-step limit — they were unwalkable by construction
All checks were successful
ci / Lua content lint (pull_request) Successful in 10s
ci / Lint & Test (pull_request) Successful in 5m9s
8be3067764
feat(editor): DEBUG automation channel — script the real editing tools reproducibly; rebuild the test mountain and its spiral road
All checks were successful
ci / Lua content lint (pull_request) Successful in 17s
ci / Lint & Test (pull_request) Successful in 4m50s
f19032b58c
feat(render): mobiles join the shared depth buffer — a character behind a mountain is hidden by it
All checks were successful
ci / Lua content lint (pull_request) Successful in 9s
ci / Lint & Test (pull_request) Successful in 4m43s
e19df07370
fix(editor): densify the road stroke — a fast drag painted disconnected pits instead of a road
All checks were successful
ci / Lua content lint (pull_request) Successful in 10s
ci / Lint & Test (pull_request) Successful in 4m44s
a2d99472ec
docs(design): landform editor — parametric terrain tools, rotatable/switchable views, editable traces
Some checks failed
ci / Lua content lint (pull_request) Successful in 13s
ci / Lint & Test (pull_request) Has been cancelled
74d1575e28
docs(plan): landform editor — picking-first, then views, landforms, traces, naturalisation
All checks were successful
ci / Lua content lint (pull_request) Successful in 31s
ci / Lint & Test (pull_request) Successful in 5m21s
650a69e9e5
feat(editor): MeshPicker — ray-cast picking correct at any camera rotation
All checks were successful
ci / Lua content lint (pull_request) Successful in 18s
ci / Lint & Test (pull_request) Successful in 6m33s
d7d33b9a31
feat(editor): switchable top-down / orbiting 3D view, with the height window read in bulk
All checks were successful
ci / Lua content lint (pull_request) Successful in 11s
ci / Lint & Test (pull_request) Successful in 4m46s
8c5379763a
perf(editor): pick by testing only the cells the view ray crosses — exact, and bounded by the ray not the viewport
All checks were successful
ci / Lua content lint (pull_request) Successful in 12s
ci / Lint & Test (pull_request) Successful in 4m55s
ba1c1418e0
perf(editor): keep the terrain mesh on the GPU and stop rebuilding it while orbiting
All checks were successful
ci / Lua content lint (pull_request) Successful in 12s
ci / Lint & Test (pull_request) Successful in 5m21s
5756508f9f
feat(editor): landform tool — place a shaped hill or basin, with its climbability reported
All checks were successful
ci / Lua content lint (pull_request) Successful in 11s
ci / Lint & Test (pull_request) Successful in 4m55s
47e739914c
feat(editor): roads become re-bakeable Traces, shared by the GUI and the automation channel
All checks were successful
ci / Lua content lint (pull_request) Successful in 10s
ci / Lint & Test (pull_request) Successful in 4m51s
699cb57f97
feat(editor): warn on exit with unsaved chunks instead of discarding them
All checks were successful
ci / Lua content lint (pull_request) Successful in 10s
ci / Lint & Test (pull_request) Successful in 6m32s
1e09538dc9
fix(editor): landform is one placement per click, with a live preview of footprint and profile
Some checks failed
ci / Lua content lint (pull_request) Successful in 20s
ci / Lint & Test (pull_request) Has been cancelled
8f36192daa
feat(editor): --tool flag for scripted capture; ASCII-safe UI strings (ImGui has no em-dash glyph)
All checks were successful
ci / Lua content lint (pull_request) Successful in 14s
ci / Lint & Test (pull_request) Successful in 5m7s
761dda86ce
The camera follows the player at their DRAWN position, which carries -z*ZScale. Both
places that invert it back to map cells used the flat inverse, so on high ground they
named the Z-0 cells projecting to the same pixels - 86 cells per axis away at z 237,
ZScale 16. The ground blend window was therefore built around another region entirely
(volcano lava never sampled, the summit rendered as clamped edge weights) and statics
were culled around it too; every step that changed z moved the error, which is why the
ground kept shifting while walking a slope.

IsoCamera.FlatFocus undoes the lift; Camera2D carries the focus height so both callers
invert the point the camera actually looks at.
fix(client): scope the height margins and pin the cancellation checkpoints
All checks were successful
ci / Lua content lint (pull_request) Successful in 11s
ci / Lint & Test (pull_request) Successful in 5m20s
8cfdad70ff
Fixes six review findings on the terrain-window change plus a stale doc
passage: DrawGround no longer inherits statics-only height margins it
cannot use, the relief read-margin comment now shows the composed
(displayZ + relief) 24-cell reach instead of the outer 15 and corrects
the WideSigma ceiling, cancellation is pinned with tests that cancel on
materialisation's last read (verified by mutation), dead BlockingBuilds
telemetry is removed, a redundant Cancel() before BuildNow is dropped,
_buildMs is no longer static, and architecture.md now describes the
cancellable background rebuild instead of a synchronous one.
Author
Collaborator

Sostituita da #274: questo ramo portava una seconda copia del codice 3D che #272 aveva già schiacciato in main, quindi il conflitto non era risolvibile in modo pulito. #274 contiene lo stesso lavoro replicato su main senza conflitti.

Sostituita da #274: questo ramo portava una seconda copia del codice 3D che #272 aveva già schiacciato in main, quindi il conflitto non era risolvibile in modo pulito. #274 contiene lo stesso lavoro replicato su main senza conflitti.
panda closed this pull request 2026-08-06 12:50:37 +02:00
All checks were successful
ci / Lua content lint (pull_request) Successful in 11s
ci / Lint & Test (pull_request) Successful in 5m20s
Required
Details

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo!273
No description provided.