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:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
|
|
@ -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<WorldEntity, AnimationSequencer></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, []);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue