fix(client): copertura del terreno consapevole della quota, ricostruita in background #274

Merged
panda merged 17 commits from fix/terrain-window-coverage into main 2026-08-06 12:56:00 +02:00
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

Nota sulla storia. Sostituisce #273, che era irrimediabilmente in conflitto: #272 aveva titolo docs(design) ma ha schiacciato in main anche tutto il codice 3D (IsoCamera, TerrainMesh3D, lo shader e il suo .xnb, GridRenderer, GroundBlendRenderer, WorldRenderer, Mobile), quindi il ramo design/3d-terrain ne portava una seconda copia con storia diversa. Qui restano solo i 17 commit che main non ha: la correzione della camera (FlatFocus/FocusZ, che #272 non conteneva), la spec, il piano e i 13 del lavoro. Replicati su main senza un solo conflitto; 1041 test verdi, 0 avvisi, csharpier pulito.

## 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 --- **Nota sulla storia.** Sostituisce #273, che era irrimediabilmente in conflitto: #272 aveva titolo `docs(design)` ma ha schiacciato in `main` anche tutto il codice 3D (`IsoCamera`, `TerrainMesh3D`, lo shader e il suo `.xnb`, `GridRenderer`, `GroundBlendRenderer`, `WorldRenderer`, `Mobile`), quindi il ramo `design/3d-terrain` ne portava una seconda copia con storia diversa. Qui restano solo i 17 commit che `main` non ha: la correzione della camera (`FlatFocus`/`FocusZ`, che #272 non conteneva), la spec, il piano e i 13 del lavoro. Replicati su `main` senza un solo conflitto; **1041 test verdi**, 0 avvisi, csharpier pulito.
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 14s
ci / Lint & Test (pull_request) Successful in 4m46s
8143a03a84
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.
panda merged commit 63978cdb55 into main 2026-08-06 12:56:00 +02:00
panda deleted branch fix/terrain-window-coverage 2026-08-06 12:56:01 +02:00
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!274
No description provided.