fix(streaming): reload terrain on an outdoor teleport (sky-arcs)

Terrain vertices are baked into the GPU relative to the render origin (_liveCenter),
which only moves on a teleport. A FAR jump unloads/reloads everything → fine. But a
NEARBY outdoor jump (e.g. (170,168)->(169,180), 12 landblocks) leaves the old and new
streaming windows overlapping, so StreamingRegion.RecenterTo KEEPS the ~330 overlapping
blocks (correct — they're in the new window) — yet they still hold vertices baked at the
OLD origin. The instant _liveCenter moves they render shifted by the jump distance: a
band of terrain hanging in the sky ("terrain in the sky" arcs). Confirmed by probe: every
stale slot was offset by EXACTLY deltaLB*192 ((-1,12)*192 = (-192,2304)), 330 of them
persisting after the hop.

Fix: StreamingController.ForceReloadWindow() — on an OUTDOOR teleport, SYNCHRONOUSLY drop
every resident landblock (render slot + physics + state) so none survives the frame stale,
then null the region so NormalTick re-bootstraps the whole window fresh at the new origin
(the near ring is priority-applied behind the fade; the rest streams). Called from
OnLivePositionUpdated's outdoor recenter branch; sealed dungeons keep PreCollapseToDungeon.

Verified live: the exact nearby hop that produced 330 stale slots now produces 0 across
the whole session, and the horizon is clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-23 00:18:27 +02:00
parent 15e320490d
commit 9945d46280
2 changed files with 31 additions and 0 deletions

View file

@ -5470,6 +5470,12 @@ public sealed class GameWindow : IDisposable
// player position can't drift the observer off the dungeon and re-expand.
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
_streamingController.PreCollapseToDungeon(lbX, lbY);
else
// Outdoor teleport: the render origin (_liveCenter) just moved. Drop ALL
// resident terrain so overlapping blocks on a NEARBY jump re-bake at the
// new origin instead of rendering shifted — the confirmed "terrain in the
// sky" arcs (stale slots offset by exactly deltaLB×192). 2026-06-22.
_streamingController?.ForceReloadWindow();
}
else
{

View file

@ -344,6 +344,31 @@ public sealed class StreamingController
_region = rebuilt;
}
/// <summary>
/// 2026-06-22: an OUTDOOR teleport moved the render origin (<c>_liveCenter</c>). Every
/// resident terrain block was baked relative to the OLD origin, so any block that survives
/// an INCREMENTAL recenter — which happens on a NEARBY jump where the old and new windows
/// overlap, e.g. (170,168)→(169,180) — renders shifted by the jump distance: the confirmed
/// "terrain in the sky" arcs (the stale slots were all offset by exactly deltaLB×192).
/// SYNCHRONOUSLY drop every resident landblock (render slot + physics + state) so none
/// survives into the next frame stale, and no async unload can race a re-bake, then null
/// the region so the next <see cref="NormalTick"/> re-bootstraps the WHOLE window fresh at
/// the new origin (the near ring is priority-applied behind the fade; the rest streams in).
/// A sealed-dungeon destination uses <see cref="PreCollapseToDungeon"/> instead.
/// </summary>
public void ForceReloadWindow()
{
_collapsed = false;
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
var ids = new List<uint>(_state.LoadedLandblockIds);
foreach (var id in ids)
{
_state.RemoveLandblock(id);
_removeTerrain?.Invoke(id); // frees the render slot + physics + cell registries
}
_region = null; // NormalTick re-creates + bootstraps the full window next frame
}
/// <summary>
/// Apply streamed completions for this frame. LOADS (terrain mesh GPU uploads) are the
/// expensive part, so they are metered at <see cref="MaxCompletionsPerFrame"/> to avoid a