fix(rendering): bound portal resource lifetime

Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-18 21:35:16 +02:00
parent 3971997689
commit 749e8ceeb1
225 changed files with 29107 additions and 3914 deletions

View file

@ -22,9 +22,8 @@ public sealed class StreamingController
private readonly Action<uint, ulong> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LandblockBuild, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _removeTerrain;
private readonly Action<uint>? _demoteNearLayer;
private readonly Action? _clearPendingLoads;
private readonly LandblockRetirementCoordinator _retirements;
/// <summary>
/// #138: fired after a landblock's entity layer (re)loads — both the full
@ -42,6 +41,9 @@ public sealed class StreamingController
private readonly Action<EnvCellLandblockBuild>? _ensureEnvCellMeshes;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
private RadiiReconfiguration? _pendingRadiiReconfiguration;
private bool _advancingRadiiReconfiguration;
private (int NearRadius, int FarRadius)? _deferredRadiiRequest;
// Hard streaming boundaries advance this token. Worker completions carry
// the generation captured at enqueue time, so an old overlapping load or
// unload cannot mutate the replacement window after a portal recenter.
@ -63,23 +65,15 @@ public sealed class StreamingController
/// <summary>
/// Near-tier radius (LBs from observer that load full detail: terrain +
/// scenery + entities). Set at construction; readable thereafter.
/// scenery + entities). Runtime changes go through
/// <see cref="ReconfigureRadii"/>.
/// </summary>
/// <remarks>
/// Mutating after the first <see cref="Tick"/> has no effect — the
/// internal <see cref="StreamingRegion"/> snapshots both radii on its
/// constructor. Treat as init-only post-Tick.
/// </remarks>
public int NearRadius { get; }
public int NearRadius { get; private set; }
/// <summary>
/// Far-tier radius (LBs from observer that load terrain only). Set at
/// construction; readable thereafter.
/// Far-tier radius (LBs from observer that load terrain only).
/// </summary>
/// <remarks>
/// Mutating after the first <see cref="Tick"/> has no effect — see <see cref="NearRadius"/>.
/// </remarks>
public int FarRadius { get; }
public int FarRadius { get; private set; }
/// <summary>
/// Cap on completions drained per <see cref="Tick"/> call. The cap is
@ -194,22 +188,122 @@ public sealed class StreamingController
Action<uint>? demoteNearLayer = null,
Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null)
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_demoteNearLayer = demoteNearLayer;
_clearPendingLoads = clearPendingLoads;
_onLandblockLoaded = onLandblockLoaded;
_ensureEnvCellMeshes = ensureEnvCellMeshes;
_state = state;
_retirements = retirementCoordinator
?? LandblockRetirementCoordinator.CreateLegacy(
state,
removeTerrain,
demoteNearLayer);
NearRadius = nearRadius;
FarRadius = farRadius;
}
/// <summary>
/// Reconciles an active outdoor streaming window to new quality radii
/// without replacing the worker/controller or blindly bootstrapping data
/// already published in <see cref="GpuWorldState"/>. Pending jobs from the
/// old window are invalidated by generation; resident blocks are retained,
/// promoted, demoted, loaded, or unloaded from their actual current tier.
/// A sealed dungeon remains radius-zero until its normal exit edge while
/// remembering the new outdoor radii for that exit.
/// </summary>
public void ReconfigureRadii(int nearRadius, int farRadius)
{
if (nearRadius < 0)
throw new ArgumentOutOfRangeException(nameof(nearRadius));
if (farRadius < nearRadius)
throw new ArgumentOutOfRangeException(
nameof(farRadius),
"Far radius must be greater than or equal to near radius.");
// Queue callbacks are injected seams and can synchronously reenter the
// controller. Last-request-wins deferral keeps the active mutation
// cursor stable; the request is applied after the current transaction
// converges instead of recursively replaying its active step.
if (_advancingRadiiReconfiguration)
{
_deferredRadiiRequest = (nearRadius, farRadius);
return;
}
// A prior callback may have failed after this controller accepted the
// request. Resume that exact transaction before considering another
// target; replacing it would orphan already-admitted generation work.
if (_pendingRadiiReconfiguration is not null)
AdvanceRadiiReconfiguration();
// This explicit call is newer than any request deferred by a prior
// failed attempt and therefore supersedes it.
_deferredRadiiRequest = null;
if (nearRadius == NearRadius && farRadius == FarRadius)
return;
if (_collapsed || _region is null)
{
NearRadius = nearRadius;
FarRadius = farRadius;
return;
}
var rebuilt = new StreamingRegion(
_region.CenterX,
_region.CenterY,
nearRadius,
farRadius);
TwoTierDiff bootstrap = rebuilt.ComputeFirstTickDiff();
var desiredNear = new HashSet<uint>(bootstrap.ToLoadNear);
var desiredFar = new HashSet<uint>(bootstrap.ToLoadFar);
var mutations = new List<RadiiMutation>();
uint[] loaded = [.. _state.LoadedLandblockIds];
for (int i = 0; i < loaded.Length; i++)
{
uint id = loaded[i];
if (desiredNear.Contains(id))
{
if (!_state.IsNearTier(id))
mutations.Add(new RadiiMutation(
() => EnqueueLoad(id, LandblockStreamJobKind.PromoteToNear)));
}
else if (desiredFar.Contains(id))
{
if (_state.IsNearTier(id))
mutations.Add(new RadiiMutation(() => DemoteLandblock(id)));
}
else
{
mutations.Add(new RadiiMutation(() => EnqueueUnload(id)));
}
}
foreach (uint id in desiredNear)
if (!_state.IsLoaded(id))
mutations.Add(new RadiiMutation(
() => EnqueueLoad(id, LandblockStreamJobKind.LoadNear)));
foreach (uint id in desiredFar)
if (!_state.IsLoaded(id))
mutations.Add(new RadiiMutation(
() => EnqueueLoad(id, LandblockStreamJobKind.LoadFar)));
_pendingRadiiReconfiguration = new RadiiReconfiguration(
nearRadius,
farRadius,
rebuilt,
mutations);
AdvanceRadiiReconfiguration();
}
/// <summary>
/// Compatibility constructor for deterministic tests and callers that do
/// not own an asynchronous worker. Production must use the generation-aware
@ -227,7 +321,8 @@ public sealed class StreamingController
Action<uint>? demoteNearLayer = null,
Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null)
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null)
: this(
(id, kind, _) => enqueueLoad(id, kind),
(id, _) => enqueueUnload(id),
@ -240,7 +335,8 @@ public sealed class StreamingController
demoteNearLayer,
clearPendingLoads,
onLandblockLoaded,
ensureEnvCellMeshes)
ensureEnvCellMeshes,
retirementCoordinator)
{
}
@ -252,11 +348,7 @@ public sealed class StreamingController
private void DemoteLandblock(uint id)
{
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
// App-owned Near resources need the still-present static entity list
// for exact light/translucency owner cleanup. Retire them before
// GpuWorldState drops the static layer and its render pins.
_demoteNearLayer?.Invoke(canonical);
_state.RemoveEntitiesFromLandblock(canonical);
_retirements.BeginNearLayer(canonical);
}
private void AdvanceGeneration()
@ -280,6 +372,18 @@ public sealed class StreamingController
/// </summary>
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
{
if (_pendingRadiiReconfiguration is not null)
AdvanceRadiiReconfiguration();
else if (_deferredRadiiRequest is { } deferred)
{
_deferredRadiiRequest = null;
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
}
// Presentation-owner failures never roll back spatial state. Resume
// unfinished owner ledgers before accepting replacement publications.
_retirements.Advance();
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
if (_collapsed)
@ -313,6 +417,125 @@ public sealed class StreamingController
DrainAndApply();
}
private void AdvanceRadiiReconfiguration()
{
if (_advancingRadiiReconfiguration)
return;
RadiiReconfiguration pending = _pendingRadiiReconfiguration
?? throw new InvalidOperationException("No radii reconfiguration is pending.");
var failures = new List<Exception>();
_advancingRadiiReconfiguration = true;
try
{
if (!pending.GenerationAdvanced)
{
AdvanceGeneration();
pending.GenerationAdvanced = true;
}
if (!pending.PendingLoadsCleared)
{
try
{
_clearPendingLoads?.Invoke();
pending.PendingLoadsCleared = true;
}
catch (Exception error)
{
if (error is StreamingMutationException { MutationCommitted: true })
pending.PendingLoadsCleared = true;
failures.Add(error);
}
}
// Do not admit new-generation work until cancellation of the old
// inbox is durable. Otherwise a successful retry of ClearPendingLoads
// would also erase mutations already marked complete by this ledger.
for (int i = 0; pending.PendingLoadsCleared && i < pending.Mutations.Count; i++)
{
RadiiMutation mutation = pending.Mutations[i];
if (mutation.Completed)
continue;
try
{
mutation.Apply();
mutation.Completed = true;
}
catch (Exception error)
{
if (error is StreamingMutationException { MutationCommitted: true })
mutation.Completed = true;
failures.Add(error);
}
}
bool converged = pending.PendingLoadsCleared
&& pending.Mutations.All(static mutation => mutation.Completed);
if (converged)
{
// Publish the new logical window only after every queue/retirement
// admission is durable. A later Tick can now trust Resident as the
// complete desired set and will never strand a hole.
pending.Region.MarkResidentFromBootstrap();
_region = pending.Region;
NearRadius = pending.NearRadius;
FarRadius = pending.FarRadius;
_deferredApply.Clear();
_pendingRadiiReconfiguration = null;
}
}
finally
{
_advancingRadiiReconfiguration = false;
}
if (failures.Count != 0)
{
throw new AggregateException(
"Streaming quality reconfiguration did not complete cleanly.",
failures);
}
if (_pendingRadiiReconfiguration is null
&& _deferredRadiiRequest is { } deferred)
{
_deferredRadiiRequest = null;
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
}
}
private sealed class RadiiReconfiguration
{
public RadiiReconfiguration(
int nearRadius,
int farRadius,
StreamingRegion region,
List<RadiiMutation> mutations)
{
NearRadius = nearRadius;
FarRadius = farRadius;
Region = region;
Mutations = mutations;
}
public int NearRadius { get; }
public int FarRadius { get; }
public StreamingRegion Region { get; }
public List<RadiiMutation> Mutations { get; }
public bool GenerationAdvanced { get; set; }
public bool PendingLoadsCleared { get; set; }
}
private sealed class RadiiMutation
{
public RadiiMutation(Action apply) => Apply = apply;
public Action Apply { get; }
public bool Completed { get; set; }
}
/// <summary>
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
/// <see cref="Tick"/> has a chance to bootstrap the full 25×25 window. Called
@ -400,6 +623,7 @@ public sealed class StreamingController
AdvanceGeneration();
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
_region = null;
foreach (var id in _state.LoadedLandblockIds)
if (id != centerId) EnqueueUnload(id);
@ -495,16 +719,15 @@ public sealed class StreamingController
AdvanceGeneration();
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
// Commit the old region boundary before any presentation owner is
// advanced. New same-id publications are fenced by _retirements.
_region = null;
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
var ids = new List<uint>(_state.LoadedLandblockIds);
ForceReloadCount++; // [FRAME-DIAG] churn counter
LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume
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
_retirements.BeginFull(id);
}
/// <summary>
@ -541,7 +764,9 @@ public sealed class StreamingController
// for direct callers and future drain paths.
if (IsStaleGeneration(result))
continue;
if (result is LandblockStreamResult.Unloaded
if (IsPublicationBlockedByRetirement(result))
_deferredApply.Add(result);
else if (result is LandblockStreamResult.Unloaded
|| IsWithinPriorityRing(ResultLandblockId(result)))
ApplyResult(result); // free (unload) or behind-the-fade near ring
else
@ -553,9 +778,39 @@ public sealed class StreamingController
// 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);
int applied = 0;
int read = 0;
int write = 0;
try
{
while (applied < budget && read < _deferredApply.Count)
{
LandblockStreamResult result = _deferredApply[read];
if (IsPublicationBlockedByRetirement(result))
{
if (write != read)
_deferredApply[write] = result;
write++;
read++;
continue;
}
// Advance read only after the apply commits. If it throws, the
// finally block removes prior committed entries but retains this
// exact result and the untouched tail for retry.
ApplyResult(result);
read++;
applied++;
}
}
finally
{
// One linear tail shift replaces budget-many RemoveAt shifts. The
// retained blocked prefix and the untouched FIFO tail keep their
// original relative order.
if (read > write)
_deferredApply.RemoveRange(write, read - write);
}
}
/// <summary>
@ -683,8 +938,7 @@ public sealed class StreamingController
_onLandblockLoaded?.Invoke(promoted.LandblockId);
break;
case LandblockStreamResult.Unloaded unloaded:
_state.RemoveLandblock(unloaded.LandblockId);
_removeTerrain?.Invoke(unloaded.LandblockId);
_retirements.BeginFull(unloaded.LandblockId);
break;
case LandblockStreamResult.Failed failed:
Console.WriteLine(
@ -722,6 +976,10 @@ public sealed class StreamingController
result is not LandblockStreamResult.WorkerCrashed
&& result.Generation != _generation;
private bool IsPublicationBlockedByRetirement(LandblockStreamResult result) =>
result is LandblockStreamResult.Loaded or LandblockStreamResult.Promoted
&& _retirements.IsPending(result.LandblockId);
/// <summary>
/// Returns the landblock id associated with <paramref name="result"/>.
/// For <see cref="LandblockStreamResult.WorkerCrashed"/> this is 0 by