using AcDream.Core.Textures; using AcDream.Core.World; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; /// /// Location of one decoded entity-material composite in a resident bindless /// texture array. The modern mesh shader consumes this exact pair. /// public readonly record struct BindlessTextureLocation(ulong Handle, uint Layer); internal enum CompositeTextureKind : byte { OriginalTextureOverride, PaletteComposite, } /// /// Structural palette identity. The precomputed hash accelerates dictionary /// bucketing, but equality still compares every server-supplied range so a /// hash collision can never share the wrong material pixels. /// internal readonly struct PaletteCompositeIdentity : IEquatable { private readonly IReadOnlyList? _ranges; public PaletteCompositeIdentity(PaletteOverride palette, ulong hash) { ArgumentNullException.ThrowIfNull(palette); BasePaletteId = palette.BasePaletteId; Hash = hash; _ranges = palette.SubPalettes; } public uint BasePaletteId { get; } public ulong Hash { get; } public int RangeCount => _ranges?.Count ?? 0; public bool Equals(PaletteCompositeIdentity other) { if (Hash != other.Hash || BasePaletteId != other.BasePaletteId || RangeCount != other.RangeCount) { return false; } for (int i = 0; i < RangeCount; i++) if (_ranges![i] != other._ranges![i]) return false; return true; } public override bool Equals(object? obj) => obj is PaletteCompositeIdentity other && Equals(other); public override int GetHashCode() => HashCode.Combine(BasePaletteId, Hash, RangeCount); public static bool operator ==(PaletteCompositeIdentity left, PaletteCompositeIdentity right) => left.Equals(right); public static bool operator !=(PaletteCompositeIdentity left, PaletteCompositeIdentity right) => !left.Equals(right); } internal readonly record struct CompositeTextureKey( CompositeTextureKind Kind, uint SurfaceId, uint OrigTextureOverride, PaletteCompositeIdentity Palette); internal sealed class CompositeTextureArrayResource { public required uint Name { get; init; } public required ulong Handle { get; init; } public required int Width { get; init; } public required int Height { get; init; } public required int Capacity { get; init; } public required long Bytes { get; init; } } internal interface ICompositeTextureArrayBackend { int MaximumArrayLayers { get; } CompositeTextureArrayResource Create(int width, int height, int capacity); void Upload(CompositeTextureArrayResource resource, int layer, byte[] rgba); void MakeNonResident(CompositeTextureArrayResource resource); void Delete(CompositeTextureArrayResource resource); } /// /// Narrow GL backend for composite arrays. Unlike ManagedGLTextureArray it /// deliberately has one mip level, no PBO, and one resident handle: those are /// the semantics of the standalone composite textures this pool replaces. /// internal sealed unsafe class GlCompositeTextureArrayBackend : ICompositeTextureArrayBackend { private readonly GL _gl; private readonly Wb.BindlessSupport _bindless; public GlCompositeTextureArrayBackend(GL gl, Wb.BindlessSupport bindless) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); _bindless = bindless ?? throw new ArgumentNullException(nameof(bindless)); _gl.GetInteger(GetPName.MaxArrayTextureLayers, out int maximumLayers); MaximumArrayLayers = Math.Max(1, maximumLayers); } public int MaximumArrayLayers { get; } public CompositeTextureArrayResource Create(int width, int height, int capacity) { uint name = _gl.GenTexture(); if (name == 0) throw new InvalidOperationException("OpenGL did not create a composite texture array."); bool resident = false; ulong handle = 0; long bytes = 0; bool tracked = false; try { // Composite creation/upload runs in the render thread's pre-draw // preparation phase. Normalize that phase to texture unit zero // instead of synchronously reading driver binding state. _gl.ActiveTexture(TextureUnit.Texture0); Wb.RenderStateCache.CurrentAtlas = 0; _gl.BindTexture(TextureTarget.Texture2DArray, name); _gl.TexStorage3D( TextureTarget.Texture2DArray, levels: 1, SizedInternalFormat.Rgba8, checked((uint)width), checked((uint)height), checked((uint)capacity)); _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureBaseLevel, 0); _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMaxLevel, 0); _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); handle = _bindless.GetResidentHandle(name); resident = true; Wb.GLHelpers.ThrowOnResourceError( _gl, $"creating composite texture array {width}x{height}x{capacity}"); bytes = checked((long)width * height * 4L * capacity); Wb.GpuMemoryTracker.TrackResourceAllocation(Wb.GpuResourceType.Texture); Wb.GpuMemoryTracker.TrackAllocation(bytes, Wb.GpuResourceType.Texture); tracked = true; return new CompositeTextureArrayResource { Name = name, Handle = handle, Width = width, Height = height, Capacity = capacity, Bytes = bytes, }; } catch (Exception creationFailure) { List? cleanupFailures = null; void Attempt(Action cleanup) { try { cleanup(); } catch (Exception ex) { (cleanupFailures ??= []).Add(ex); } } bool residencyReleased = !resident; if (resident) { Attempt(() => { _bindless.MakeNonResident(handle); Wb.GLHelpers.ThrowOnResourceError(_gl, "rolling back composite texture residency"); residencyReleased = true; }); } if (residencyReleased) { Attempt(() => { _gl.DeleteTexture(name); Wb.GLHelpers.ThrowOnResourceError(_gl, "rolling back composite texture array"); if (tracked) { Wb.GpuMemoryTracker.TrackDeallocation(bytes, Wb.GpuResourceType.Texture); Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture); } }); } if (cleanupFailures is not null) { cleanupFailures.Insert(0, creationFailure); throw new AggregateException( "Composite texture-array construction and rollback both failed.", cleanupFailures); } throw; } finally { _gl.BindTexture(TextureTarget.Texture2DArray, 0); _gl.ActiveTexture(TextureUnit.Texture0); } } public void Upload(CompositeTextureArrayResource resource, int layer, byte[] rgba) { _gl.ActiveTexture(TextureUnit.Texture0); Wb.RenderStateCache.CurrentAtlas = 0; try { _gl.BindBuffer(BufferTargetARB.PixelUnpackBuffer, 0); _gl.BindTexture(TextureTarget.Texture2DArray, resource.Name); fixed (byte* pixels = rgba) { _gl.TexSubImage3D( TextureTarget.Texture2DArray, level: 0, xoffset: 0, yoffset: 0, zoffset: layer, checked((uint)resource.Width), checked((uint)resource.Height), depth: 1, PixelFormat.Rgba, PixelType.UnsignedByte, pixels); } Wb.GLHelpers.ThrowOnResourceError( _gl, $"uploading composite texture layer {layer} ({resource.Width}x{resource.Height})"); } finally { _gl.BindTexture(TextureTarget.Texture2DArray, 0); _gl.BindBuffer(BufferTargetARB.PixelUnpackBuffer, 0); _gl.ActiveTexture(TextureUnit.Texture0); } } public void MakeNonResident(CompositeTextureArrayResource resource) { Wb.GLHelpers.ThrowOnResourceError( _gl, $"releasing composite texture handle {resource.Handle} (precondition)"); _bindless.MakeNonResident(resource.Handle); Wb.GLHelpers.ThrowOnResourceError(_gl, $"releasing composite texture handle {resource.Handle}"); } public void Delete(CompositeTextureArrayResource resource) { Wb.GLHelpers.ThrowOnResourceError( _gl, $"deleting composite texture array {resource.Name} (precondition)"); _gl.DeleteTexture(resource.Name); Wb.GLHelpers.ThrowOnResourceError(_gl, $"deleting composite texture array {resource.Name}"); Wb.GpuMemoryTracker.TrackDeallocation(resource.Bytes, Wb.GpuResourceType.Texture); Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture); } } /// /// Pools per-entity material composites into dimension-compatible texture /// arrays. Retail releases the owning CSurface reference immediately. This /// modern adaptation preserves that logical boundary while retaining recent /// same-pixel layers under a bounded LRU; layer reuse waits for a GPU fence. /// internal sealed class CompositeTextureArrayCache : IDisposable { internal const long DefaultUnownedBudgetBytes = 64L * 1024 * 1024; internal const long DefaultPhysicalBudgetBytes = 128L * 1024 * 1024; internal const long TargetArrayBytes = 4L * 1024 * 1024; internal const int MaximumLayersPerArray = 64; internal const int DefaultMaximumUploadsPerFrame = 16; internal const long DefaultMaximumUploadBytesPerFrame = 8L * 1024 * 1024; internal const int MaximumLogicalEvictionsPerFrame = 16; internal const int MaximumAtlasCreationsPerFrame = 1; private readonly ICompositeTextureArrayBackend _backend; private readonly GpuRetirementLedger _retirementLedger; private readonly OwnerScopedResourceRegistry _owners = new(); private readonly BoundedUnownedResourceCache _unowned; private readonly long _physicalBudgetBytes; private readonly int _maximumArrayLayers; private readonly int _maximumUploadsPerFrame; private readonly long _maximumUploadBytesPerFrame; private readonly Dictionary _entries = new(); private readonly Dictionary<(int Width, int Height), List> _atlasesBySize = new(); private readonly List _atlases = new(); private long _allocatedBytes; private long _useSequence; private int _frameUploadCount; private long _frameUploadBytes; private int _frameAtlasCreationCount; private bool _uploadBudgetBlocked; private int _pendingAtlasWidth; private int _pendingAtlasHeight; private long _pendingAtlasAllocationBytes; private readonly List _evictionScratch = new(MaximumLogicalEvictionsPerFrame); private bool _disposeRequested; private bool _disposed; private sealed class Entry { public required Atlas Atlas { get; init; } public required int Layer { get; init; } public required long Bytes { get; init; } } private sealed class Atlas { public required CompositeTextureArrayResource Resource { get; init; } public required Wb.TextureAtlasSlotAllocator Slots { get; init; } public int EntryCount { get; set; } public int PendingRetirements { get; set; } public long LastUseSequence { get; set; } public bool ReleaseRequested { get; set; } public AtlasReleaseStage ReleaseStage { get; set; } public int AvailableLayers => Slots.AvailableCount; public bool IsGpuSafeEmpty => EntryCount == 0 && PendingRetirements == 0; public bool IsReusable => !ReleaseRequested && ReleaseStage == AtlasReleaseStage.Resident; public bool Deleted => ReleaseStage >= AtlasReleaseStage.Deleted; } private enum AtlasReleaseStage : byte { Resident, NonResident, Deleted, Accounted, } public CompositeTextureArrayCache( GL gl, Wb.BindlessSupport bindless, IGpuResourceRetirementQueue retirementQueue, long unownedBudgetBytes = DefaultUnownedBudgetBytes, long physicalBudgetBytes = DefaultPhysicalBudgetBytes, int maximumUploadsPerFrame = DefaultMaximumUploadsPerFrame, long maximumUploadBytesPerFrame = DefaultMaximumUploadBytesPerFrame) : this( new GlCompositeTextureArrayBackend(gl, bindless), retirementQueue, unownedBudgetBytes, physicalBudgetBytes, maximumUploadsPerFrame, maximumUploadBytesPerFrame) { } internal CompositeTextureArrayCache( ICompositeTextureArrayBackend backend, IGpuResourceRetirementQueue retirementQueue, long unownedBudgetBytes = DefaultUnownedBudgetBytes, long physicalBudgetBytes = DefaultPhysicalBudgetBytes, int maximumUploadsPerFrame = DefaultMaximumUploadsPerFrame, long maximumUploadBytesPerFrame = DefaultMaximumUploadBytesPerFrame) { _backend = backend ?? throw new ArgumentNullException(nameof(backend)); ArgumentNullException.ThrowIfNull(retirementQueue); _retirementLedger = new GpuRetirementLedger(retirementQueue); ArgumentOutOfRangeException.ThrowIfNegative(physicalBudgetBytes); ArgumentOutOfRangeException.ThrowIfLessThan(maximumUploadsPerFrame, 1); ArgumentOutOfRangeException.ThrowIfLessThan(maximumUploadBytesPerFrame, 1); _unowned = new BoundedUnownedResourceCache(unownedBudgetBytes); _physicalBudgetBytes = physicalBudgetBytes; _maximumUploadsPerFrame = maximumUploadsPerFrame; _maximumUploadBytesPerFrame = maximumUploadBytesPerFrame; _maximumArrayLayers = Math.Max( 1, Math.Min(backend.MaximumArrayLayers, MaximumLayersPerArray)); } internal int ActiveResourceCount => _owners.ResourceCount; internal int OwnerCount => _owners.OwnerCount; internal int CachedEntryCount => _entries.Count; internal int UnownedEntryCount => _unowned.Count; internal long UnownedBytes => _unowned.ResidentBytes; internal int AtlasCount => _atlases.Count; internal long AllocatedBytes => _allocatedBytes; internal int FrameUploadCount => _frameUploadCount; internal long FrameUploadBytes => _frameUploadBytes; internal bool CanStartUpload => !_uploadBudgetBlocked && _frameUploadCount < _maximumUploadsPerFrame && (_frameUploadCount == 0 || _frameUploadBytes < _maximumUploadBytesPerFrame); internal bool CanUpload(long bytes) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytes); if (_frameUploadCount >= _maximumUploadsPerFrame) return false; // Always allow one item so a texture larger than the normal frame // budget cannot permanently stall portal readiness. Every later item // must fit completely inside the advertised byte budget. return _frameUploadCount == 0 || bytes <= _maximumUploadBytesPerFrame - _frameUploadBytes; } internal bool CanPrepareUpload(int width, int height) { ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); long layerBytes = checked((long)width * height * 4L); if (!CanUpload(layerBytes)) { _uploadBudgetBlocked = true; return false; } if (_atlasesBySize.TryGetValue((width, height), out List? compatible)) { for (int i = 0; i < compatible.Count; i++) if (compatible[i].IsReusable && compatible[i].AvailableLayers != 0) return true; } int capacity = CalculateLayerCapacity(width, height, _maximumArrayLayers); long requestedBytes = checked(layerBytes * capacity); if (_frameAtlasCreationCount < MaximumAtlasCreationsPerFrame && CanAllocateAtlas(requestedBytes)) return true; SetPendingAllocation(width, height, requestedBytes); _uploadBudgetBlocked = true; return false; } public void BeginFrame() { ThrowIfUnavailable(); _retirementLedger.RetryPendingPublications(); _frameUploadCount = 0; _frameUploadBytes = 0; _frameAtlasCreationCount = 0; _uploadBudgetBlocked = false; } public bool TryAcquire( uint ownerLocalId, CompositeTextureKey key, out BindlessTextureLocation location) { ThrowIfUnavailable(); if (!_entries.TryGetValue(key, out Entry? entry)) { location = default; return false; } _owners.Acquire(ownerLocalId, key); _unowned.MarkOwned(key); entry.Atlas.LastUseSequence = ++_useSequence; location = new BindlessTextureLocation( entry.Atlas.Resource.Handle, checked((uint)entry.Layer)); return true; } public bool TryAddAndAcquire( uint ownerLocalId, CompositeTextureKey key, DecodedTexture decoded, out BindlessTextureLocation location) { ThrowIfUnavailable(); if (TryAcquire(ownerLocalId, key, out BindlessTextureLocation existing)) { location = existing; return true; } ValidateDecodedTexture(decoded); long bytes = checked((long)decoded.Width * decoded.Height * 4L); if (!CanUpload(bytes)) { // Dimensions are only known after DAT decode. Once one candidate // does not fit, stop all later decodes this frame rather than // repeatedly allocating RGBA buffers that cannot be uploaded. _uploadBudgetBlocked = true; location = default; return false; } if (!TryFindOrCreateAtlas(decoded.Width, decoded.Height, out Atlas atlas)) { // As with a byte-budget rejection, stop subsequent DAT decodes in // this frame. Tick advances compatible reclamation before the next // frame retries the same logical composite. _uploadBudgetBlocked = true; location = default; return false; } int layer = atlas.Slots.Rent(); try { _backend.Upload(atlas.Resource, layer, decoded.Rgba8); } catch (Exception uploadFailure) { atlas.Slots.Return(layer); if (atlas.IsGpuSafeEmpty) { try { DeleteAtlas(atlas); } catch (Exception releaseFailure) { throw new AggregateException( "Composite upload and empty-atlas rollback both failed.", uploadFailure, releaseFailure); } } throw; } var entry = new Entry { Atlas = atlas, Layer = layer, Bytes = bytes }; _entries.Add(key, entry); atlas.EntryCount++; atlas.LastUseSequence = ++_useSequence; _owners.Acquire(ownerLocalId, key); _frameUploadCount++; _frameUploadBytes = checked(_frameUploadBytes + bytes); location = new BindlessTextureLocation(atlas.Resource.Handle, checked((uint)layer)); return true; } public void ReleaseOwner(uint ownerLocalId) { ThrowIfUnavailable(); IReadOnlyList unowned = _owners.ReleaseOwner(ownerLocalId); for (int i = 0; i < unowned.Count; i++) { CompositeTextureKey key = unowned[i]; if (_entries.TryGetValue(key, out Entry? entry)) _unowned.MarkUnowned(key, entry.Bytes); } } /// /// Logical layer eviction is a bounded CPU-only batch; its fenced callback /// merely returns integer slots. At most one already-empty GL array becomes /// non-resident and is deleted per frame, so a portal unload cannot become /// one large driver destruction batch. /// public void Tick() { ThrowIfUnavailable(); _retirementLedger.RetryPendingPublications(); bool allocationPressure = HasPendingAllocationPressure(); bool physicalOverBudget = _allocatedBytes > _physicalBudgetBytes; bool needsPhysicalRelief = allocationPressure || physicalOverBudget; // Finish an already-started release before selecting another array. // This keeps driver-visible destruction bounded to one array per tick // even when a prior non-resident/delete stage had to be retried. bool servicedPendingRelease = CompleteOnePendingAtlasRelease(); // A fence may have made an array safe since the prior frame. Free one // first; this is the only driver-visible destruction operation here. if (needsPhysicalRelief && !servicedPendingRelease) DeleteOneGpuSafeEmptyAtlas( _pendingAtlasAllocationBytes == 0 ? null : (_pendingAtlasWidth, _pendingAtlasHeight)); allocationPressure = HasPendingAllocationPressure(); physicalOverBudget = _allocatedBytes > _physicalBudgetBytes; needsPhysicalRelief = allocationPressure || physicalOverBudget; int evicted = 0; if (needsPhysicalRelief && _pendingAtlasAllocationBytes != 0) { evicted += EvictCompatibleUnowned( _pendingAtlasWidth, _pendingAtlasHeight, MaximumLogicalEvictionsPerFrame); } while (evicted < MaximumLogicalEvictionsPerFrame) { bool take = needsPhysicalRelief ? _unowned.TryTakeOldest(out CompositeTextureKey key) : _unowned.TryTakeOldestOverBudget(out key); if (!take) break; EvictEntry(key); evicted++; } // Allocation pressure is a one-frame demand signal. The requesting // entity will set it again later this frame if it is still relevant; // stale portal destinations must not keep evicting unrelated storage. _pendingAtlasWidth = 0; _pendingAtlasHeight = 0; _pendingAtlasAllocationBytes = 0; } internal void VisitEntries(Action visitor) { ArgumentNullException.ThrowIfNull(visitor); foreach ((CompositeTextureKey key, Entry entry) in _entries) visitor(key.SurfaceId, entry.Atlas.Resource.Width, entry.Atlas.Resource.Height); } internal static int CalculateLayerCapacity(int width, int height, int driverMaximumLayers) { ArgumentOutOfRangeException.ThrowIfLessThan(width, 1); ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); ArgumentOutOfRangeException.ThrowIfLessThan(driverMaximumLayers, 1); long layerBytes = checked((long)width * height * 4L); long targetLayers = Math.Max(1L, TargetArrayBytes / layerBytes); return checked((int)Math.Min( targetLayers, Math.Min(driverMaximumLayers, MaximumLayersPerArray))); } private bool TryFindOrCreateAtlas(int width, int height, out Atlas atlas) { var size = (width, height); if (_atlasesBySize.TryGetValue(size, out List? compatible)) { for (int i = 0; i < compatible.Count; i++) { Atlas candidate = compatible[i]; if (candidate.IsReusable && candidate.AvailableLayers != 0) { ClearPendingAllocation(width, height); atlas = candidate; return true; } } } int capacity = CalculateLayerCapacity(width, height, _maximumArrayLayers); long requestedBytes = checked((long)width * height * 4L * capacity); if (_frameAtlasCreationCount >= MaximumAtlasCreationsPerFrame || !CanAllocateAtlas(requestedBytes)) { SetPendingAllocation(width, height, requestedBytes); atlas = null!; return false; } CompositeTextureArrayResource resource = _backend.Create(width, height, capacity); atlas = new Atlas { Resource = resource, Slots = new Wb.TextureAtlasSlotAllocator(capacity), }; if (compatible is null) { compatible = new List(); _atlasesBySize.Add(size, compatible); } compatible.Add(atlas); _atlases.Add(atlas); _allocatedBytes = checked(_allocatedBytes + resource.Bytes); _frameAtlasCreationCount++; ClearPendingAllocation(width, height); return true; } private bool CanAllocateAtlas(long requestedBytes) { if (FitsWithinPhysicalBudget(requestedBytes)) return true; // The budget bounds reusable/cache storage, not required live scene // content. Wait while stale or retiring storage can make room; if the // entire resident set is live, permit one new atlas this frame so an // unusually large destination cannot deadlock portal readiness. return !HasReclaimableStorage(); } private bool FitsWithinPhysicalBudget(long requestedBytes) { if (requestedBytes > _physicalBudgetBytes) return _allocatedBytes == 0; return _allocatedBytes <= _physicalBudgetBytes - requestedBytes; } private bool HasPendingAllocationPressure() => _pendingAtlasAllocationBytes != 0 && !FitsWithinPhysicalBudget(_pendingAtlasAllocationBytes); private bool HasReclaimableStorage() { if (_unowned.Count != 0) return true; for (int i = 0; i < _atlases.Count; i++) { Atlas candidate = _atlases[i]; if (!candidate.Deleted && (candidate.ReleaseRequested || candidate.PendingRetirements != 0 || candidate.IsGpuSafeEmpty)) { return true; } } return false; } private void SetPendingAllocation(int width, int height, long bytes) { _pendingAtlasWidth = width; _pendingAtlasHeight = height; _pendingAtlasAllocationBytes = bytes; } private void ClearPendingAllocation(int width, int height) { if (_pendingAtlasWidth != width || _pendingAtlasHeight != height) return; _pendingAtlasWidth = 0; _pendingAtlasHeight = 0; _pendingAtlasAllocationBytes = 0; } private int EvictCompatibleUnowned(int width, int height, int maximum) { _evictionScratch.Clear(); foreach ((CompositeTextureKey key, Entry entry) in _entries) { if (_evictionScratch.Count == maximum) break; if (entry.Atlas.Resource.Width == width && entry.Atlas.Resource.Height == height && _unowned.Contains(key)) { _evictionScratch.Add(key); } } int evicted = 0; for (int i = 0; i < _evictionScratch.Count; i++) { CompositeTextureKey key = _evictionScratch[i]; if (!_unowned.TryTake(key)) continue; EvictEntry(key); evicted++; } return evicted; } private void EvictEntry(CompositeTextureKey key) { if (!_entries.Remove(key, out Entry? entry)) return; Atlas atlas = entry.Atlas; atlas.EntryCount--; atlas.PendingRetirements++; int layer = entry.Layer; _retirementLedger.Retire(new RetryableGpuResourceRelease( () => atlas.Slots.Return(layer), () => atlas.PendingRetirements--)); } private bool CompleteOnePendingAtlasRelease() { for (int i = 0; i < _atlases.Count; i++) { Atlas atlas = _atlases[i]; if (!atlas.ReleaseRequested) continue; DeleteAtlas(atlas); return true; } return false; } private void DeleteOneGpuSafeEmptyAtlas((int Width, int Height)? preserveSize = null) { Atlas? oldest = null; for (int i = 0; i < _atlases.Count; i++) { Atlas candidate = _atlases[i]; if (preserveSize is { } preserve && candidate.Resource.Width == preserve.Width && candidate.Resource.Height == preserve.Height) { continue; } if (candidate.IsReusable && candidate.IsGpuSafeEmpty && (oldest is null || candidate.LastUseSequence < oldest.LastUseSequence)) { oldest = candidate; } } if (oldest is not null) DeleteAtlas(oldest); } private void DeleteAtlas(Atlas atlas, bool requireGpuSafeEmpty = true) { if (atlas.ReleaseStage == AtlasReleaseStage.Accounted) return; if (requireGpuSafeEmpty && !atlas.IsGpuSafeEmpty) throw new InvalidOperationException("Cannot delete a composite array while a layer is live or retiring."); atlas.ReleaseRequested = true; if (atlas.ReleaseStage == AtlasReleaseStage.Resident) { try { _backend.MakeNonResident(atlas.Resource); atlas.ReleaseStage = AtlasReleaseStage.NonResident; RemoveFromReusableAtlasIndex(atlas); } catch (GpuResourceMutationException error) when (error.MutationCommitted) { atlas.ReleaseStage = AtlasReleaseStage.NonResident; RemoveFromReusableAtlasIndex(atlas); throw; } } if (atlas.ReleaseStage == AtlasReleaseStage.NonResident) { try { _backend.Delete(atlas.Resource); atlas.ReleaseStage = AtlasReleaseStage.Deleted; } catch (GpuResourceMutationException error) when (error.MutationCommitted) { atlas.ReleaseStage = AtlasReleaseStage.Deleted; throw; } } if (atlas.ReleaseStage == AtlasReleaseStage.Deleted) { _allocatedBytes = checked(_allocatedBytes - atlas.Resource.Bytes); _atlases.Remove(atlas); atlas.ReleaseStage = AtlasReleaseStage.Accounted; } } private void RevokeAtlasResidencyForDispose(Atlas atlas) { atlas.ReleaseRequested = true; if (atlas.ReleaseStage != AtlasReleaseStage.Resident) return; try { _backend.MakeNonResident(atlas.Resource); atlas.ReleaseStage = AtlasReleaseStage.NonResident; RemoveFromReusableAtlasIndex(atlas); } catch (GpuResourceMutationException error) when (error.MutationCommitted) { atlas.ReleaseStage = AtlasReleaseStage.NonResident; RemoveFromReusableAtlasIndex(atlas); throw; } } private void RemoveFromReusableAtlasIndex(Atlas atlas) { var size = (atlas.Resource.Width, atlas.Resource.Height); if (!_atlasesBySize.TryGetValue(size, out List? compatible)) return; compatible.Remove(atlas); if (compatible.Count == 0) _atlasesBySize.Remove(size); } private static void ValidateDecodedTexture(DecodedTexture decoded) { ArgumentOutOfRangeException.ThrowIfLessThan(decoded.Width, 1); ArgumentOutOfRangeException.ThrowIfLessThan(decoded.Height, 1); long expected = checked((long)decoded.Width * decoded.Height * 4L); if (decoded.Rgba8.LongLength != expected) throw new ArgumentException( $"Decoded RGBA texture has {decoded.Rgba8.LongLength} bytes; expected {expected}.", nameof(decoded)); } public void Dispose() { if (_disposed) return; _disposeRequested = true; _retirementLedger.RetryPendingPublications(); // GameWindow drains frame-flight fences before TextureCache teardown. // Release every handle first, then delete any backing array. A failed // stage leaves its exact atlas/stage reachable for a later Dispose. List? failures = null; for (int i = 0; i < _atlases.Count; i++) { Atlas atlas = _atlases[i]; try { RevokeAtlasResidencyForDispose(atlas); } catch (Exception ex) { (failures ??= []).Add(ex); } } if (failures is not null) throw new AggregateException("One or more composite-array residency releases failed.", failures); // DeleteAtlas removes completed entries, so walk a stable snapshot. Atlas[] atlases = _atlases.ToArray(); for (int i = 0; i < atlases.Length; i++) { try { DeleteAtlas(atlases[i], requireGpuSafeEmpty: false); } catch (Exception ex) { (failures ??= []).Add(ex); } } if (failures is not null) throw new AggregateException("One or more composite-array deletions failed.", failures); _entries.Clear(); _owners.Clear(); _unowned.Clear(); _atlasesBySize.Clear(); _atlases.Clear(); _allocatedBytes = 0; _pendingAtlasAllocationBytes = 0; _disposed = true; } private void ThrowIfUnavailable() => ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this); }