acdream/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs
Erik 749e8ceeb1 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>
2026-07-18 21:35:16 +02:00

242 lines
8.8 KiB
C#

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;
}
}
}