namespace AcDream.App.Rendering; /// /// One standalone, resident bindless texture used by particle billboards. /// Unlike entity composites, the shader always samples layer zero. /// 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); } /// /// 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. /// 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 _owners = new(); private readonly BoundedUnownedResourceCache _unowned; private readonly Dictionary _entries = new(); private readonly HashSet _disposeResidencyReleased = []; private readonly HashSet _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( 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 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 visitor) { ArgumentNullException.ThrowIfNull(visitor); foreach (StandaloneBindlessTextureResource resource in _entries.Values) visitor(resource); } /// /// 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. /// 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? 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; } } }