fix(world+streaming): trees-in-sky Z, O(1) anim, teleport near-ring + immediate unloads
Three apparatus-confirmed fixes from the world-load/FPS deep-dive (all live-verified).
1. trees-in-sky — scenery ground-Z now samples THIS landblock's OWN heightmap
(TerrainSurface.SampleZFromHeightmap, lock-step with the physics terrain) instead
of the global PhysicsEngine.SampleTerrainZ query. At build time the landblock isn't
registered in physics yet, so that query could only return null OR a STALE
neighbour's height — the previous location's terrain, still registered after a
teleport recenter — planting scenery at the old altitude (+250..500m, confirmed via
the [scenery-z-stale] probe). Own-heightmap is correct in every case; the query is
removed. (GameWindow.BuildSceneryEntitiesForStreaming)
2. FPS per-hop — TickAnimations recovered each animated entity's server guid via an
O(N) ReferenceEquals reverse scan over ALL _entitiesByServerGuid (which never
evicts, so N climbs every teleport — the drops-with-each-hop sink). Replaced with
ae.Entity.ServerGuid: O(1), exact-equivalent (the dict key IS entity.ServerGuid).
(GameWindow.TickAnimations)
3. teleport arrival + bulk floating terrain — two streaming fixes:
- Near-ring eager-apply: a teleport applies the destination's 3x3 surroundings
(StreamingController.PriorityRadius) and holds the fade until they're resident
(PhysicsEngine.IsNeighborhoodTerrainResident), so the player arrives in a loaded,
collidable world instead of one landblock in the void.
- Immediate unloads: DrainAndApply no longer throttles UNLOADS at the per-frame
load budget — they're cheap (free GPU buffers, no upload). A teleport produced
~600 unloads draining at 4/frame, leaving the previous region resident for
seconds (floating terrain) and accumulating across rapid hops (951 resident vs a
625 window). Only GPU-upload LOADS are metered now. Cut out-of-window resident
650 -> 63 and resident 951 -> 688 (live-verified via [resid-audit]).
Includes gated-off diagnostic probes (ACDREAM_PROBE_SCENERY_FRAME / _BLDG_REACH /
_STREAM_RESID) used to root-cause the above — zero-cost when unset, same pattern as
the committed tp-probe.
The pre-existing teleport-induced "terrain arcs in the sky" (present in the dd2eb8b
baseline too, with NONE of this work) are a SEPARATE bug — investigated next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8fbde99441
commit
15e320490d
6 changed files with 382 additions and 61 deletions
|
|
@ -107,6 +107,21 @@ public sealed class StreamingController
|
|||
/// </summary>
|
||||
public uint PriorityLandblockId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 2026-06-22: the radius (in landblocks, Chebyshev) around
|
||||
/// <see cref="PriorityLandblockId"/> that <see cref="DrainAndApply"/> eager-applies
|
||||
/// ahead of the per-frame budget. 0 (default) = only the single center landblock — the
|
||||
/// original priority behaviour. A teleport sets this to the near ring so the player's
|
||||
/// IMMEDIATE SURROUNDINGS (terrain + collision + scenery) are resident on arrival, not
|
||||
/// just the one landblock they stand on. Without it, only the destination landblock
|
||||
/// applies immediately and everything around it drains at <see cref="MaxCompletionsPerFrame"/>,
|
||||
/// so the player arrives to a near-empty world (the "Fort Tethana only one landblock
|
||||
/// loaded" symptom) and their cell-walk can't root into neighbour cells yet (the
|
||||
/// transient walk-through-walls). The far ring still drains at the budget. The eager
|
||||
/// apply runs during the teleport hold (behind the fade), so the GPU spike is hidden.
|
||||
/// </summary>
|
||||
public int PriorityRadius { get; set; }
|
||||
|
||||
// Completions that were drained past a priority item get buffered here
|
||||
// so they still apply over subsequent frames without loss.
|
||||
private readonly List<LandblockStreamResult> _deferredApply = new();
|
||||
|
|
@ -293,6 +308,17 @@ public sealed class StreamingController
|
|||
return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="id"/> is the priority center or within
|
||||
/// <see cref="PriorityRadius"/> landblocks of it (Chebyshev) — i.e. inside the teleport
|
||||
/// near ring that <see cref="DrainAndApply"/> eager-applies. With the default radius 0
|
||||
/// this reduces to an exact match on <see cref="PriorityLandblockId"/> (the original
|
||||
/// single-landblock priority behaviour).
|
||||
/// </summary>
|
||||
private bool IsWithinPriorityRing(uint id)
|
||||
=> PriorityLandblockId != 0u
|
||||
&& ChebyshevLandblocks(id, PriorityLandblockId) <= PriorityRadius;
|
||||
|
||||
/// <summary>
|
||||
/// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full
|
||||
/// two-tier window at the new center and unload anything resident from the
|
||||
|
|
@ -319,62 +345,49 @@ public sealed class StreamingController
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drain up to N completions per frame so a big diff doesn't spike GPU
|
||||
/// upload time. Remaining completions wait for the next frame.
|
||||
///
|
||||
/// <para>
|
||||
/// When <see cref="PriorityLandblockId"/> is set (non-zero), the priority
|
||||
/// landblock is applied immediately even if it sits past position N in the
|
||||
/// outbox. Non-priority completions drained past it are buffered in
|
||||
/// <see cref="_deferredApply"/> and applied over subsequent frames
|
||||
/// (no loss, no GPU spike).
|
||||
/// </para>
|
||||
/// 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
|
||||
/// GPU-upload spike; the overflow buffers in <see cref="_deferredApply"/> and drains over
|
||||
/// subsequent frames. UNLOADS are cheap (they free GPU buffers — no upload) and are applied
|
||||
/// IMMEDIATELY, never throttled: a teleport produces a whole window of unloads (~600), and
|
||||
/// metering them at the load rate left the previous location's terrain resident for seconds
|
||||
/// (rendering at its old world position as "floating terrain at the horizon"), and rapid
|
||||
/// hops accumulated them faster than they cleared — a runaway resident count (951 observed
|
||||
/// vs a 625 window) that also dragged FPS. Loads inside the teleport near ring
|
||||
/// (<see cref="PriorityRadius"/>, applied behind the fade) likewise bypass the budget so the
|
||||
/// player materialises in a loaded world.
|
||||
/// </summary>
|
||||
private void DrainAndApply()
|
||||
{
|
||||
// --- Step 1: priority hunt FIRST and UNCONDITIONALLY (when a destination is set).
|
||||
// The teleport destination must apply the frame the worker finishes building it,
|
||||
// ahead of any deferred/outbox backlog — otherwise a big dungeon-exit expand
|
||||
// (hundreds of completions) buries it and the arrival times out into the skybox.
|
||||
// CRITICAL: this must NOT be gated behind the per-frame budget. The prior version
|
||||
// ran the deferred drain first and returned when the budget hit 0, so once
|
||||
// _deferredApply held >= MaxCompletionsPerFrame items the hunt (and the outbox
|
||||
// drain) were skipped entirely — the destination never priority-applied. The hunt
|
||||
// drains the outbox in bounded chunks; the priority is applied on match, every
|
||||
// non-priority item is buffered in _deferredApply (no loss).
|
||||
if (PriorityLandblockId != 0u)
|
||||
// --- Step 1: drain the outbox in bounded chunks. Apply unloads + priority near-ring
|
||||
// loads immediately; defer every other (budget-metered) load. Draining the whole
|
||||
// outbox each frame (bounded by MaxDrainIterations) is what lets unloads flush
|
||||
// promptly regardless of the load backlog — the throttle is on GPU UPLOADS, not on
|
||||
// freeing them. The drain cap must NOT be gated behind the per-frame load budget
|
||||
// (the prior version returned once the budget hit 0, stranding the outbox).
|
||||
const int MaxDrainIterations = 64; // cap at 64 * MaxCompletionsPerFrame drained/frame
|
||||
int iter = 0;
|
||||
while (iter++ < MaxDrainIterations)
|
||||
{
|
||||
const int MaxHuntIterations = 64; // cap at 64 * MaxCompletionsPerFrame drained/frame
|
||||
int hunted = 0;
|
||||
while (hunted < MaxHuntIterations)
|
||||
var chunk = _drainCompletions(MaxCompletionsPerFrame);
|
||||
if (chunk.Count == 0) break;
|
||||
foreach (var result in chunk)
|
||||
{
|
||||
var chunk = _drainCompletions(MaxCompletionsPerFrame);
|
||||
if (chunk.Count == 0) break;
|
||||
foreach (var result in chunk)
|
||||
{
|
||||
hunted++;
|
||||
if (ResultLandblockId(result) == PriorityLandblockId)
|
||||
ApplyResult(result); // applied immediately, ahead of the budget
|
||||
else
|
||||
_deferredApply.Add(result);
|
||||
}
|
||||
if (result is LandblockStreamResult.Unloaded
|
||||
|| IsWithinPriorityRing(ResultLandblockId(result)))
|
||||
ApplyResult(result); // free (unload) or behind-the-fade near ring
|
||||
else
|
||||
_deferredApply.Add(result); // a GPU-upload load — meter it in step 2
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 2: apply up to MaxCompletionsPerFrame this frame — the deferred backlog
|
||||
// first (FIFO), then fresh outbox completions. Caps GPU upload per frame so a big
|
||||
// diff doesn't spike. While a priority is set, Step 1 has already moved the outbox
|
||||
// into _deferredApply, so this drains that backlog at the budget rate.
|
||||
// --- Step 2: apply the deferred LOAD backlog at the per-frame budget (FIFO, so
|
||||
// earlier-queued landblocks win). Caps GPU upload per frame so a big diff doesn't
|
||||
// spike. _deferredApply now only ever holds loads — unloads were applied in step 1.
|
||||
int budget = MaxCompletionsPerFrame;
|
||||
int i = 0;
|
||||
while (i < budget && i < _deferredApply.Count) { ApplyResult(_deferredApply[i]); i++; }
|
||||
if (i > 0) _deferredApply.RemoveRange(0, i);
|
||||
budget -= i;
|
||||
if (budget <= 0) return;
|
||||
|
||||
var drained = _drainCompletions(budget);
|
||||
foreach (var result in drained)
|
||||
ApplyResult(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue