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

@ -5,13 +5,21 @@ using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// The exact owner is still inside a reference transition, so its requested
/// logical removal has been queued and must be retried by the live-entity
/// teardown owner after the transition unwinds.
/// </summary>
public sealed class EntityPresentationRemovalDeferredException(uint serverGuid)
: InvalidOperationException(
$"Live entity 0x{serverGuid:X8} presentation removal is deferred until its active reference transition completes.");
/// <summary>
/// Routes server-spawned (<c>CreateObject</c>) entities through the
/// per-instance rendering path. Server entities always carry per-instance
/// customizations (palette overrides, texture changes, part swaps) that
/// don't fit WB's atlas key, so they bypass the atlas and use the existing
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// path which already hash-keys overrides for caching.
/// customizations (palette overrides, texture changes, part swaps). Shared
/// surfaces use the WB atlas while per-instance texture composites are owned
/// by the live entity and released with it.
///
/// <para>
/// Companion to <see cref="LandblockSpawnAdapter"/>: that adapter handles
@ -21,17 +29,6 @@ namespace AcDream.App.Rendering.Wb;
/// </para>
///
/// <para>
/// <b>Per-entity texture decode</b>: when <c>entity.PaletteOverride</c> is
/// non-null, the adapter calls
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// once per surface id that is known at spawn time (those on
/// <see cref="MeshRef.SurfaceOverrides"/>). Surfaces whose ids are only
/// discoverable by opening the GfxObj dat are decoded lazily by the draw
/// dispatcher (Task 22) on first use — that matches the existing
/// <c>StaticMeshRenderer</c> behavior.
/// </para>
///
/// <para>
/// <b>Sequencer factory</b>: the adapter is constructed with a
/// <c>Func&lt;WorldEntity, AnimationSequencer&gt;</c> factory so tests can
/// inject a stub without needing a live DatCollection or MotionTable.
@ -47,24 +44,67 @@ namespace AcDream.App.Rendering.Wb;
/// </summary>
public sealed class EntitySpawnAdapter
{
private readonly ITextureCachePerInstance _textureCache;
private readonly IEntityTextureLifetime _textureLifetime;
private readonly Func<WorldEntity, AnimationSequencer> _sequencerFactory;
private readonly IWbMeshAdapter? _meshAdapter;
// Per-server-guid state. Written on OnCreate, released on OnRemove.
// One logical owner per server GUID. Animated state survives projection
// suspension, while the resident bit controls the shorter GPU-presentation
// lifetime. The exact WorldEntity reference makes delayed visibility edges
// from a displaced GUID generation harmless.
// Single-threaded: called only from the render thread (same as GpuWorldState).
private readonly Dictionary<uint, AnimatedEntityState> _stateByGuid = new();
private readonly Dictionary<uint, Owner> _ownersByGuid = new();
// Per-server-guid set of GfxObj ids registered with the mesh adapter,
// so OnRemove can decrement each. Per-instance entities don't go through
// LandblockSpawnAdapter, so without this their meshes would never load
// (WB doesn't know they exist).
private readonly Dictionary<uint, HashSet<ulong>> _meshIdsByGuid = new();
private sealed class Owner(
WorldEntity entity,
AnimatedEntityState state,
HashSet<ulong> meshIds)
{
public WorldEntity Entity { get; } = entity;
public AnimatedEntityState State { get; } = state;
public HashSet<ulong> MeshIds { get; set; } = meshIds;
public HashSet<ulong> MeshReferencesHeld { get; } = new();
public bool IsPresentationResident { get; set; }
public bool TextureReleaseRequired { get; set; }
public PresentationTransition Transition { get; set; }
public bool RemovalPending { get; set; }
/// <param name="textureCache">
/// Per-instance texture decode path. In production this is the
/// <see cref="TextureCache"/> instance (which implements
/// <see cref="ITextureCachePerInstance"/>); in tests it is a capturing mock.
public bool HasPresentationResources
{
get
{
if (TextureReleaseRequired)
return true;
return MeshReferencesHeld.Count != 0;
}
}
public bool IsFullyResident
{
get
{
if (!IsPresentationResident || !TextureReleaseRequired)
return false;
return MeshReferencesHeld.SetEquals(MeshIds);
}
}
public bool IsFullySuspended =>
!IsPresentationResident && !HasPresentationResources;
}
private enum PresentationTransition
{
None,
Resuming,
Suspending,
ChangingAppearance,
}
/// <param name="textureLifetime">
/// Per-entity texture lifetime owner. Production uses
/// <see cref="TextureCache"/>; tests use a recording implementation.
/// </param>
/// <param name="sequencerFactory">
/// Factory that builds an <see cref="AnimationSequencer"/> for a given
@ -74,20 +114,19 @@ public sealed class EntitySpawnAdapter
/// returns a stub sequencer.
/// </param>
/// <param name="meshAdapter">
/// Optional WB mesh adapter. When non-null, <see cref="OnCreate"/>
/// registers each unique <c>MeshRef.GfxObjId</c> with the adapter so WB
/// background-loads the mesh data; <see cref="OnRemove"/> decrements the
/// matching ref counts. When null, the adapter only tracks per-instance
/// state without driving WB lifecycle (test mode + flag-off mode).
/// Optional WB mesh adapter. When non-null, presentation residency
/// registers each unique <c>MeshRef.GfxObjId</c> so WB background-loads
/// the mesh data. Projection suspension or logical removal balances those
/// references. When null, the adapter only tracks per-instance state.
/// </param>
public EntitySpawnAdapter(
ITextureCachePerInstance textureCache,
IEntityTextureLifetime textureLifetime,
Func<WorldEntity, AnimationSequencer> sequencerFactory,
IWbMeshAdapter? meshAdapter = null)
{
ArgumentNullException.ThrowIfNull(textureCache);
ArgumentNullException.ThrowIfNull(textureLifetime);
ArgumentNullException.ThrowIfNull(sequencerFactory);
_textureCache = textureCache;
_textureLifetime = textureLifetime;
_sequencerFactory = sequencerFactory;
_meshAdapter = meshAdapter;
}
@ -105,29 +144,6 @@ public sealed class EntitySpawnAdapter
// are handled by LandblockSpawnAdapter, not here.
if (entity.ServerGuid == 0) return null;
// Pre-warm the per-instance texture cache for surfaces whose ids are
// already known at spawn time (those appearing as keys in
// MeshRef.SurfaceOverrides). GfxObj sub-mesh surface ids that aren't
// covered by SurfaceOverrides are decoded lazily by the draw
// dispatcher on first use — consistent with StaticMeshRenderer.
if (entity.PaletteOverride is { } paletteOverride)
{
foreach (var meshRef in entity.MeshRefs)
{
if (meshRef.SurfaceOverrides is null) continue;
// SurfaceOverrides maps surfaceId → origTextureOverride (may be 0
// meaning "no texture swap, just the palette override applies").
foreach (var (surfaceId, origTexOverride) in meshRef.SurfaceOverrides)
{
_textureCache.GetOrUploadWithPaletteOverride(
surfaceId,
origTexOverride == 0 ? null : origTexOverride,
paletteOverride);
}
}
}
// A.5 T18: populate cached AABB so WalkEntities reads from the cache
// rather than recomputing Position±5 per frame. Called here because
// all entity-state initialization (position, rotation) is complete
@ -147,41 +163,228 @@ public sealed class EntitySpawnAdapter
foreach (var po in entity.PartOverrides)
state.SetPartOverride(po.PartIndex, po.GfxObjId);
_stateByGuid[entity.ServerGuid] = state;
HashSet<ulong> meshIds = _meshAdapter is null
? []
: CollectMeshIds(entity.MeshRefs, entity.PartOverrides);
// Register each unique GfxObj id with WB so the meshes background-load.
// Snapshot each unique GfxObj id for the shorter presentation lifetime.
// Includes both the entity's natural MeshRefs AND any server-sent
// PartOverride GfxObjs (weapons, clothing, helmets) — those replace the
// Setup default and need their own mesh data uploaded.
if (_meshAdapter is not null)
// Construct the replacement completely before displacing a live owner.
// Sequencer/appearance construction is allowed to fail; in that case
// the prior GUID incarnation and its presentation references remain
// valid. Retirement is also completed before replacement publication:
// a failed texture or mesh release therefore leaves the prior owner in
// the dictionary, with its per-resource progress available to retry.
var replacementOwner = new Owner(entity, state, meshIds);
if (_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? displacedOwner))
{
var unique = new HashSet<ulong>();
foreach (var meshRef in entity.MeshRefs)
unique.Add((ulong)meshRef.GfxObjId);
foreach (var po in entity.PartOverrides)
unique.Add((ulong)po.GfxObjId);
if (displacedOwner.RemovalPending)
{
throw new EntityPresentationRemovalDeferredException(entity.ServerGuid);
}
_meshIdsByGuid[entity.ServerGuid] = unique;
foreach (var id in unique) _meshAdapter.IncrementRefCount(id);
if (displacedOwner.Transition != PresentationTransition.None)
{
throw new InvalidOperationException(
$"Live entity 0x{entity.ServerGuid:X8} replacement was requested while its presentation transition was already in progress.");
}
if (displacedOwner.HasPresentationResources)
{
if (!SuspendPresentation(displacedOwner))
{
throw new InvalidOperationException(
$"Live entity 0x{entity.ServerGuid:X8} replacement was requested while its presentation teardown was already in progress.");
}
}
_ownersByGuid[entity.ServerGuid] = replacementOwner;
}
else
{
_ownersByGuid.Add(entity.ServerGuid, replacementOwner);
}
return state;
}
/// <summary>
/// Changes only the shorter presentation/GPU lifetime of an already-created
/// live entity. A visible projection acquires WB mesh references; suspension
/// releases those references and every owner-scoped composite texture. The
/// animated state remains registered and no create-time scripts are replayed.
/// Duplicate edges and edges for an older incarnation of a reused server GUID
/// are ignored. A failed release keeps the published resident state and the
/// exact unfinished resources so the same edge can be retried safely.
/// </summary>
/// <returns><c>true</c> when this call applied a residency edge.</returns>
public bool SetPresentationResident(WorldEntity entity, bool resident)
{
ArgumentNullException.ThrowIfNull(entity);
if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner)
|| !ReferenceEquals(owner.Entity, entity)
|| owner.RemovalPending
|| (resident ? owner.IsFullyResident : owner.IsFullySuspended))
{
return false;
}
return resident
? ResumePresentation(owner)
: SuspendPresentation(owner);
}
/// <summary>
/// Reconciles the mesh-reference set for an in-place retail
/// <c>SmartBox::UpdateVisualDesc</c> mutation. Added meshes are acquired
/// before <paramref name="publishAppearance"/> makes the new appearance
/// visible. Only then is the exact new mesh set published and superseded
/// references released. A failed release remains represented by
/// <see cref="Owner.MeshReferencesHeld"/> and is retried by the next
/// residency edge or logical teardown.
/// <paramref name="afterPublication"/> runs after that commit point but
/// before retirement, so dependent pose/cache publication cannot leave the
/// new entity appearance paired with rolled-back mesh ownership.
/// </summary>
/// <remarks>
/// The exact <see cref="WorldEntity"/> reference is the incarnation token.
/// A delayed appearance callback from a GUID-reused owner is ignored. The
/// method is render-thread only; a re-entrant request is rejected before
/// its publication callback can mutate the active owner.
/// </remarks>
/// <returns>
/// <c>true</c> when the matching owner's appearance was published;
/// otherwise <c>false</c> for a stale, removing, or re-entrant owner.
/// </returns>
public bool OnAppearanceChanged(
WorldEntity entity,
IReadOnlyList<MeshRef> meshRefs,
IReadOnlyList<PartOverride> partOverrides,
Action publishAppearance,
Action? afterPublication = null)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(meshRefs);
ArgumentNullException.ThrowIfNull(partOverrides);
ArgumentNullException.ThrowIfNull(publishAppearance);
if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner)
|| !ReferenceEquals(owner.Entity, entity)
|| owner.RemovalPending
|| owner.Transition != PresentationTransition.None)
{
return false;
}
HashSet<ulong> nextMeshIds = _meshAdapter is null
? []
: CollectMeshIds(meshRefs, partOverrides);
owner.Transition = PresentationTransition.ChangingAppearance;
var acquiredThisTransition = new List<ulong>();
bool meshSetPublished = false;
try
{
if (owner.IsPresentationResident && _meshAdapter is not null)
AcquireMissingMeshReferences(owner, nextMeshIds, acquiredThisTransition);
publishAppearance();
// Publication is the commit point: every desired resident mesh is
// owned before this assignment. Superseded references may remain
// temporarily held if their release reports a retryable failure,
// but they are no longer part of the published appearance set.
owner.MeshIds = nextMeshIds;
meshSetPublished = true;
afterPublication?.Invoke();
List<Exception>? releaseFailures = owner.IsPresentationResident
? ReleaseMeshReferencesOutside(owner, nextMeshIds)
: ReleaseAllMeshReferences(owner);
if (releaseFailures is not null)
{
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} appearance mesh retirement failed.",
releaseFailures);
}
return true;
}
catch (Exception acquireOrPublicationFailure) when (!meshSetPublished)
{
// Acquisition failed before publication. Preserve the prior exact
// mesh set and undo only references acquired by this transition.
List<Exception>? rollbackFailures = RollBackAcquiredMeshReferences(
owner,
acquiredThisTransition);
if (rollbackFailures is null)
throw;
rollbackFailures.Insert(0, acquireOrPublicationFailure);
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} appearance publication and mesh rollback failed.",
rollbackFailures);
}
finally
{
owner.Transition = PresentationTransition.None;
}
}
/// <summary>
/// Release the per-entity state for <paramref name="serverGuid"/>. Called
/// on <c>RemoveObject</c>. Unknown guids (never spawned, or already
/// removed) are silently ignored.
/// removed) are silently ignored. Cleanup failure leaves the owner registered
/// and propagates the exception; a later call resumes its unfinished releases.
/// </summary>
public void OnRemove(uint serverGuid)
{
_stateByGuid.Remove(serverGuid);
=> _ = TryRemove(serverGuid, expectedEntity: null);
if (_meshAdapter is not null && _meshIdsByGuid.TryGetValue(serverGuid, out var ids))
private bool TryRemove(uint serverGuid, WorldEntity? expectedEntity)
{
if (!_ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
|| (expectedEntity is not null && !ReferenceEquals(owner.Entity, expectedEntity)))
{
foreach (var id in ids) _meshAdapter.DecrementRefCount(id);
_meshIdsByGuid.Remove(serverGuid);
return false;
}
owner.RemovalPending = true;
if (owner.Transition != PresentationTransition.None)
throw new EntityPresentationRemovalDeferredException(serverGuid);
// A resident owner still holds both mesh and potentially-lazy texture
// resources. A suspended owner released them at the visibility edge,
// so logical removal must not decrement or release a second time. Do
// not remove the dictionary entry until cleanup succeeds: otherwise a
// release exception would orphan its remaining references and make a
// later OnRemove retry impossible.
if (owner.HasPresentationResources && !SuspendPresentation(owner))
return false;
if (_ownersByGuid.TryGetValue(serverGuid, out Owner? current)
&& ReferenceEquals(current, owner))
{
_ownersByGuid.Remove(serverGuid);
return true;
}
return false;
}
/// <summary>
/// Releases a logical owner only when <paramref name="entity"/> is the exact
/// incarnation currently registered for its server GUID. This is the live
/// runtime teardown entry point: a delayed callback from an older generation
/// cannot remove a replacement that reused the same GUID.
/// </summary>
/// <returns><c>true</c> when the matching owner was removed.</returns>
public bool OnRemove(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
return TryRemove(entity.ServerGuid, entity);
}
/// <summary>
@ -190,5 +393,209 @@ public sealed class EntitySpawnAdapter
/// been removed.
/// </summary>
public AnimatedEntityState? GetState(uint serverGuid)
=> _stateByGuid.TryGetValue(serverGuid, out var s) ? s : null;
=> _ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
? owner.State
: null;
private bool ResumePresentation(Owner owner)
{
if (owner.Transition != PresentationTransition.None)
return false;
owner.Transition = PresentationTransition.Resuming;
var acquiredThisTransition = new List<ulong>();
bool desiredMeshSetAcquired = false;
try
{
if (_meshAdapter is not null)
AcquireMissingMeshReferences(owner, owner.MeshIds, acquiredThisTransition);
desiredMeshSetAcquired = true;
owner.TextureReleaseRequired = true;
owner.IsPresentationResident = true;
List<Exception>? releaseFailures = ReleaseMeshReferencesOutside(
owner,
owner.MeshIds);
if (releaseFailures is not null)
{
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation mesh reconciliation failed.",
releaseFailures);
}
return true;
}
catch (Exception acquireFailure) when (!desiredMeshSetAcquired)
{
List<Exception>? rollbackFailures = RollBackAcquiredMeshReferences(
owner,
acquiredThisTransition);
if (rollbackFailures is null)
throw;
rollbackFailures.Insert(0, acquireFailure);
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation resume and rollback failed.",
rollbackFailures);
}
finally
{
owner.Transition = PresentationTransition.None;
}
}
private bool SuspendPresentation(Owner owner)
{
if (owner.Transition != PresentationTransition.None)
return false;
// IsPresentationResident deliberately remains true until every release
// succeeds. Transition prevents a re-entrant duplicate edge from
// releasing the same resource twice, while the completion markers keep
// partial progress retryable after an exception.
owner.Transition = PresentationTransition.Suspending;
List<Exception>? failures = null;
if (owner.TextureReleaseRequired)
{
try
{
_textureLifetime.ReleaseOwner(owner.Entity.Id);
owner.TextureReleaseRequired = false;
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
}
List<Exception>? meshFailures = ReleaseAllMeshReferences(owner);
if (meshFailures is not null)
(failures ??= new List<Exception>()).AddRange(meshFailures);
if (failures is not null)
{
owner.Transition = PresentationTransition.None;
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation suspension failed.",
failures);
}
owner.IsPresentationResident = false;
owner.Transition = PresentationTransition.None;
return true;
}
private static HashSet<ulong> CollectMeshIds(
IReadOnlyList<MeshRef> meshRefs,
IReadOnlyList<PartOverride> partOverrides)
{
var unique = new HashSet<ulong>();
for (int i = 0; i < meshRefs.Count; i++)
unique.Add(meshRefs[i].GfxObjId);
for (int i = 0; i < partOverrides.Count; i++)
unique.Add(partOverrides[i].GfxObjId);
return unique;
}
private void AcquireMissingMeshReferences(
Owner owner,
HashSet<ulong> desiredMeshIds,
List<ulong> acquiredThisTransition)
{
if (_meshAdapter is null)
return;
foreach (ulong meshId in desiredMeshIds)
{
if (owner.MeshReferencesHeld.Contains(meshId))
continue;
try
{
_meshAdapter.IncrementRefCount(meshId);
owner.MeshReferencesHeld.Add(meshId);
acquiredThisTransition.Add(meshId);
}
catch (MeshReferenceMutationException error)
{
if (error.MutationCommitted)
{
owner.MeshReferencesHeld.Add(meshId);
acquiredThisTransition.Add(meshId);
}
throw;
}
}
}
private List<Exception>? RollBackAcquiredMeshReferences(
Owner owner,
List<ulong> acquiredThisTransition)
{
if (_meshAdapter is null)
return null;
List<Exception>? failures = null;
for (int i = acquiredThisTransition.Count - 1; i >= 0; i--)
{
ulong meshId = acquiredThisTransition[i];
if (!owner.MeshReferencesHeld.Contains(meshId))
continue;
try
{
_meshAdapter.DecrementRefCount(meshId);
owner.MeshReferencesHeld.Remove(meshId);
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
owner.MeshReferencesHeld.Remove(meshId);
(failures ??= new List<Exception>()).Add(error);
}
}
return failures;
}
private List<Exception>? ReleaseMeshReferencesOutside(
Owner owner,
HashSet<ulong> desiredMeshIds)
{
if (_meshAdapter is null || owner.MeshReferencesHeld.Count == 0)
return null;
List<Exception>? failures = null;
ulong[] heldSnapshot = [.. owner.MeshReferencesHeld];
foreach (ulong meshId in heldSnapshot)
{
if (desiredMeshIds.Contains(meshId))
continue;
try
{
_meshAdapter.DecrementRefCount(meshId);
owner.MeshReferencesHeld.Remove(meshId);
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
owner.MeshReferencesHeld.Remove(meshId);
(failures ??= new List<Exception>()).Add(error);
}
}
return failures;
}
private List<Exception>? ReleaseAllMeshReferences(Owner owner)
{
if (_meshAdapter is null || owner.MeshReferencesHeld.Count == 0)
return null;
return ReleaseMeshReferencesOutside(owner, []);
}
}