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
242
src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs
Normal file
242
src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// One standalone, resident bindless texture used by particle billboards.
|
||||
/// Unlike entity composites, the shader always samples layer zero.
|
||||
/// </summary>
|
||||
internal sealed class StandaloneBindlessTextureResource
|
||||
{
|
||||
public required uint SurfaceId { get; init; }
|
||||
public required uint Name { get; init; }
|
||||
public required ulong Handle { get; init; }
|
||||
public required long Bytes { get; init; }
|
||||
}
|
||||
|
||||
internal interface IStandaloneBindlessTextureBackend
|
||||
{
|
||||
void MakeNonResident(StandaloneBindlessTextureResource resource);
|
||||
void Delete(StandaloneBindlessTextureResource resource);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shares exact DAT-decoded particle textures between live emitter owners.
|
||||
/// The final owner moves a resource into a small LRU reuse pool; exceeding
|
||||
/// either cache bound logically evicts the oldest entry immediately and
|
||||
/// retires its resident handle/backing texture behind the frame-flight fence.
|
||||
/// Live resources are never evicted, so this policy cannot change visuals.
|
||||
/// Render-thread only.
|
||||
/// </summary>
|
||||
internal sealed class StandaloneBindlessTextureCache : IDisposable
|
||||
{
|
||||
internal const long DefaultUnownedBudgetBytes = 32L * 1024 * 1024;
|
||||
internal const int DefaultMaximumUnownedCount = 256;
|
||||
internal const int DefaultMaximumEvictionsPerFrame = 1;
|
||||
|
||||
private readonly IStandaloneBindlessTextureBackend _backend;
|
||||
private readonly GpuRetirementLedger _retirementLedger;
|
||||
private readonly OwnerScopedResourceRegistry<uint> _owners = new();
|
||||
private readonly BoundedUnownedResourceCache<uint> _unowned;
|
||||
private readonly Dictionary<uint, StandaloneBindlessTextureResource> _entries = new();
|
||||
private readonly HashSet<uint> _disposeResidencyReleased = [];
|
||||
private readonly HashSet<uint> _disposeDeleted = [];
|
||||
private bool _disposeRequested;
|
||||
private bool _disposing;
|
||||
private bool _disposed;
|
||||
|
||||
public StandaloneBindlessTextureCache(
|
||||
IStandaloneBindlessTextureBackend backend,
|
||||
IGpuResourceRetirementQueue retirementQueue,
|
||||
long unownedBudgetBytes = DefaultUnownedBudgetBytes,
|
||||
int maximumUnownedCount = DefaultMaximumUnownedCount)
|
||||
{
|
||||
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
|
||||
ArgumentNullException.ThrowIfNull(retirementQueue);
|
||||
_retirementLedger = new GpuRetirementLedger(retirementQueue);
|
||||
_unowned = new BoundedUnownedResourceCache<uint>(
|
||||
unownedBudgetBytes,
|
||||
maximumUnownedCount);
|
||||
}
|
||||
|
||||
internal int EntryCount => _entries.Count;
|
||||
internal int ActiveResourceCount => _owners.ResourceCount;
|
||||
internal int OwnerCount => _owners.OwnerCount;
|
||||
internal int UnownedEntryCount => _unowned.Count;
|
||||
internal long UnownedBytes => _unowned.ResidentBytes;
|
||||
internal int AwaitingRetirementPublicationCount =>
|
||||
_retirementLedger.AwaitingPublicationCount;
|
||||
|
||||
public bool TryAcquire(
|
||||
uint ownerId,
|
||||
uint surfaceId,
|
||||
out StandaloneBindlessTextureResource resource)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposeRequested, this);
|
||||
ValidateOwnerAndSurface(ownerId, surfaceId);
|
||||
if (!_entries.TryGetValue(surfaceId, out resource!))
|
||||
return false;
|
||||
|
||||
_owners.Acquire(ownerId, surfaceId);
|
||||
_unowned.MarkOwned(surfaceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void AddAndAcquire(
|
||||
uint ownerId,
|
||||
StandaloneBindlessTextureResource resource)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposeRequested, this);
|
||||
ArgumentNullException.ThrowIfNull(resource);
|
||||
ValidateOwnerAndSurface(ownerId, resource.SurfaceId);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Name);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Handle);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Bytes);
|
||||
|
||||
if (!_entries.TryAdd(resource.SurfaceId, resource))
|
||||
throw new InvalidOperationException(
|
||||
$"Standalone particle surface 0x{resource.SurfaceId:X8} is already cached.");
|
||||
|
||||
_owners.Acquire(ownerId, resource.SurfaceId);
|
||||
}
|
||||
|
||||
public void ReleaseOwner(uint ownerId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposeRequested, this);
|
||||
if (ownerId == 0)
|
||||
return;
|
||||
|
||||
IReadOnlyList<uint> newlyUnowned = _owners.ReleaseOwner(ownerId);
|
||||
for (int i = 0; i < newlyUnowned.Count; i++)
|
||||
{
|
||||
uint surfaceId = newlyUnowned[i];
|
||||
if (_entries.TryGetValue(surfaceId, out StandaloneBindlessTextureResource? resource))
|
||||
_unowned.MarkUnowned(surfaceId, resource.Bytes);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal void VisitEntries(Action<StandaloneBindlessTextureResource> visitor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(visitor);
|
||||
foreach (StandaloneBindlessTextureResource resource in _entries.Values)
|
||||
visitor(resource);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances logical eviction at a bounded rate. Owner release only marks
|
||||
/// resources reusable; this render-frame maintenance edge prevents a
|
||||
/// portal unload from submitting hundreds of bindless destruction calls
|
||||
/// to the same GPU fence serial.
|
||||
/// </summary>
|
||||
public void Tick(int maximumEvictions = DefaultMaximumEvictionsPerFrame)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposeRequested, this);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumEvictions);
|
||||
_retirementLedger.RetryPendingPublications();
|
||||
|
||||
for (int i = 0; i < maximumEvictions; i++)
|
||||
{
|
||||
if (!_unowned.TryTakeOldestOverBudget(out uint surfaceId))
|
||||
break;
|
||||
if (!_entries.Remove(surfaceId, out StandaloneBindlessTextureResource? resource))
|
||||
continue;
|
||||
|
||||
// The publication ledger owns this release from this point even
|
||||
// when queue admission or an immediate callback throws. Never
|
||||
// republish a texture after residency release has started.
|
||||
_retirementLedger.Retire(new RetryableGpuResourceRelease(
|
||||
() => _backend.MakeNonResident(resource),
|
||||
() => _backend.Delete(resource)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateOwnerAndSurface(uint ownerId, uint surfaceId)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfZero(ownerId);
|
||||
ArgumentOutOfRangeException.ThrowIfZero(surfaceId);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed || _disposing)
|
||||
return;
|
||||
_disposeRequested = true;
|
||||
_disposing = true;
|
||||
|
||||
try
|
||||
{
|
||||
// GameWindow drains the frame-flight queue before TextureCache
|
||||
// teardown. Release every handle before deleting any texture so the
|
||||
// ARB_bindless_texture lifetime ordering remains explicit.
|
||||
List<Exception>? failures = null;
|
||||
try
|
||||
{
|
||||
_retirementLedger.RetryPendingPublications();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= []).Add(error);
|
||||
}
|
||||
|
||||
foreach (StandaloneBindlessTextureResource resource in _entries.Values)
|
||||
{
|
||||
if (_disposeResidencyReleased.Contains(resource.SurfaceId))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
_backend.MakeNonResident(resource);
|
||||
_disposeResidencyReleased.Add(resource.SurfaceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
(failures ??= []).Add(ex);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (StandaloneBindlessTextureResource resource in _entries.Values)
|
||||
{
|
||||
if (!_disposeResidencyReleased.Contains(resource.SurfaceId)
|
||||
|| _disposeDeleted.Contains(resource.SurfaceId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_backend.Delete(resource);
|
||||
_disposeDeleted.Add(resource.SurfaceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
(failures ??= []).Add(ex);
|
||||
}
|
||||
}
|
||||
|
||||
if (_disposeDeleted.Count != 0)
|
||||
{
|
||||
foreach (uint surfaceId in _disposeDeleted)
|
||||
_entries.Remove(surfaceId);
|
||||
}
|
||||
|
||||
if (_entries.Count == 0
|
||||
&& _retirementLedger.AwaitingPublicationCount == 0)
|
||||
{
|
||||
_owners.Clear();
|
||||
_unowned.Clear();
|
||||
_disposeResidencyReleased.Clear();
|
||||
_disposeDeleted.Clear();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
if (failures is not null)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"One or more standalone particle textures failed to retire.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_disposing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue