using AcDream.App.Rendering.Gpu; using AcDream.Core.Textures; using AcDream.Core.World; namespace AcDream.App.Rendering; /// /// Location of one decoded entity-material composite: the device texture-table /// slot of the array holding it, plus the layer within that array. The modern /// mesh shader consumes this exact pair. /// /// Campaign V slice V4t replaced the raw 64-bit /// ARB_bindless_texture handle with , and /// that makes the DEFAULT value load-bearing. The old type could say "not /// resolved" with handle 0, because no texture ever has handle 0. A slot index /// has no such spare value — default(GpuTextureSlot) is real slot 0 — so /// a positional record would have turned every budget-rejected or /// still-uploading return into a silent read of whichever texture registered /// first. The slot is therefore stored one-based, which makes default /// exactly . Same discipline, same reason, as /// UiTextureTableHandle on the UI path. /// /// Internal rather than public because is /// part of the internal RHI contract. Nothing outside this assembly and its /// InternalsVisibleTo test assemblies ever named this type. /// internal readonly struct BindlessTextureLocation : IEquatable { private readonly uint _slotPlusOne; public BindlessTextureLocation(GpuTextureSlot slot, uint layer) { _slotPlusOne = slot.IsAssigned ? slot.Index + 1 : 0; Layer = layer; } /// The "no composite yet" value. Identical to default. public static BindlessTextureLocation Unresolved => default; /// Layer within the array. Meaningless unless . public uint Layer { get; } public bool IsResolved => _slotPlusOne != 0; public GpuTextureSlot Slot => _slotPlusOne == 0 ? GpuTextureSlot.Unassigned : new GpuTextureSlot(_slotPlusOne - 1); public bool Equals(BindlessTextureLocation other) => _slotPlusOne == other._slotPlusOne && Layer == other.Layer; public override bool Equals(object? obj) => obj is BindlessTextureLocation other && Equals(other); public override int GetHashCode() => HashCode.Combine(_slotPlusOne, Layer); public static bool operator ==(BindlessTextureLocation left, BindlessTextureLocation right) => left.Equals(right); public static bool operator !=(BindlessTextureLocation left, BindlessTextureLocation right) => !left.Equals(right); public override string ToString() => IsResolved ? $"{Slot}/layer{Layer}" : "unresolved"; } 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 { /// The GL texture name, or 0 on the backend-neutral arm. public required uint Name { get; init; } /// The resident bindless handle, or 0 on the backend-neutral arm. public required ulong Handle { get; init; } /// /// Campaign V slice V6i-2: the RHI image, on the arm that owns one. Null on /// GL, where and are the identity. /// The cache above touches neither — it only ever hands a resource back to /// the backend that made it. /// public Gpu.IGpuTexture? Image { get; init; } /// /// Campaign V slice V4t: this array's entry in the device texture table. /// The backend that made resident also interned it, so /// the pair is created and retired together and the cache above never has /// to know a backend exists. /// public required GpuTextureSlot Slot { 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); } /// /// Campaign V slice V6i-2: the backend-neutral composite array backend. /// /// Plan §5.5.12 item 1 noted that /// "is already a seam and takes an RHI backend directly" — this is that arm. It /// is deliberately the smallest of the three texture paths: one mip level, one /// sampler, RGBA8 only, no residency negotiation. The composited 32×32 item art /// and per-entity material surfaces it holds are exactly the textures retail /// releases the moment the surface is built, so a mip chain would be paid for /// nothing. /// /// Clamped, matching what the GL backend's texture parameters say for /// the modes that matter. The GL backend sets Repeat, but a composite /// array's neighbouring layers are unrelated surfaces; the wrap mode only /// affects UVs outside [0,1], which the composite path does not generate. The /// slice that draws these on Vulkan is the one that can see a difference, and /// it inherits a named decision rather than an accident. /// internal sealed class RhiCompositeTextureArrayBackend : ICompositeTextureArrayBackend { private readonly Gpu.IGpuDevice _device; private readonly Gpu.IGpuSampler _sampler; internal RhiCompositeTextureArrayBackend(Gpu.IGpuDevice device) { _device = device ?? throw new ArgumentNullException(nameof(device)); _sampler = device.CreateSampler(Gpu.GpuSamplerDescription.WorldClamp with { MipFilter = Gpu.GpuMipFilter.None, }); } /// /// The GL backend reads GL_MAX_ARRAY_TEXTURE_LAYERS. The pinned /// has no array-layer field and §3.3 is /// frozen, so this reports Vulkan's guaranteed maxImageArrayLayers /// minimum of 256. That is not a limitation in practice: /// caps every /// array at 64, so the true device limit is never the binding constraint. /// public int MaximumArrayLayers => 256; public CompositeTextureArrayResource Create(int width, int height, int capacity) { Gpu.IGpuTexture? image = null; try { image = _device.CreateTexture(new Gpu.GpuTextureDescription( $"composite-array-{width}x{height}x{capacity}", Gpu.GpuTextureKind.Texture2DArray, Gpu.GpuTextureFormat.Rgba8Unorm, width, height, capacity, MipLevelCount: 1)); Gpu.GpuTextureSlot slot = _device.RegisterTexture(image, _sampler); return new CompositeTextureArrayResource { Name = 0, Handle = 0, Image = image, Slot = slot, Width = width, Height = height, Capacity = capacity, Bytes = checked((long)width * height * 4L * capacity), }; } catch { image?.Dispose(); throw; } } public void Upload(CompositeTextureArrayResource resource, int layer, byte[] rgba) => RequireImage(resource).Upload(0, layer, rgba); /// /// On GL this makes a bindless handle non-resident after retiring its table /// entry. There is no residency on the RHI arm, so retiring the entry is the /// whole of it — and it is a slot the device defers behind its own /// retirement queue, exactly as the GL arm's release does. /// public void MakeNonResident(CompositeTextureArrayResource resource) { ArgumentNullException.ThrowIfNull(resource); if (resource.Slot.IsAssigned) _device.ReleaseTextureSlot(resource.Slot); } public void Delete(CompositeTextureArrayResource resource) => RequireImage(resource).Dispose(); private static Gpu.IGpuTexture RequireImage(CompositeTextureArrayResource resource) { ArgumentNullException.ThrowIfNull(resource); return resource.Image ?? throw new InvalidOperationException( "This composite resource was created by the GL backend and has no RHI image."); } } /// /// 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 int DestinationRevealMaximumUploadsPerFrame = 64; 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 bool _destinationRevealUploadPriority; 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, } 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 long PhysicalBudgetBytes => _physicalBudgetBytes; internal long UnownedBudgetBytes => _unowned.BudgetBytes; internal int FrameUploadCount => _frameUploadCount; internal long FrameUploadBytes => _frameUploadBytes; internal bool CanStartUpload => !_uploadBudgetBlocked && _frameUploadCount < CurrentMaximumUploadsPerFrame && (_frameUploadCount == 0 || _frameUploadBytes < _maximumUploadBytesPerFrame); private int CurrentMaximumUploadsPerFrame => _destinationRevealUploadPriority ? Math.Max( _maximumUploadsPerFrame, DestinationRevealMaximumUploadsPerFrame) : _maximumUploadsPerFrame; internal Residency.ResidencyDomainSnapshot CaptureResidency() { long retiringBytes = 0; long usedBytes = 0; long availableBytes = 0; for (int i = 0; i < _atlases.Count; i++) { Atlas atlas = _atlases[i]; if (atlas.ReleaseRequested || atlas.ReleaseStage != AtlasReleaseStage.Resident) { retiringBytes = checked( retiringBytes + atlas.Resource.Bytes); usedBytes = checked( usedBytes + atlas.Resource.Bytes); continue; } long layerBytes = atlas.Resource.Bytes / atlas.Slots.Capacity; usedBytes = checked( usedBytes + layerBytes * checked( atlas.EntryCount + atlas.PendingRetirements)); availableBytes = checked( availableBytes + layerBytes * atlas.AvailableLayers); } return new Residency.ResidencyDomainSnapshot( Residency.ResidencyDomain.CompositeTextures, EntryCount: _entries.Count, OwnerCount: _owners.OwnerCount, Charges: new Residency.ResidencyCharges( GpuRequestedBytes: _pendingAtlasAllocationBytes, GpuResidentBytes: checked( _allocatedBytes - retiringBytes), RetiringBytes: retiringBytes), BudgetBytes: _physicalBudgetBytes, CapacityBytes: _allocatedBytes, UsedBytes: usedBytes, LargestFreeBytes: availableBytes); } internal bool CanUpload(long bytes) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytes); if (_frameUploadCount >= CurrentMaximumUploadsPerFrame) 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(bool destinationRevealUploadPriority = false) { ThrowIfUnavailable(); _retirementLedger.RetryPendingPublications(); _destinationRevealUploadPriority = destinationRevealUploadPriority; _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 = BindlessTextureLocation.Unresolved; return false; } _owners.Acquire(ownerLocalId, key); _unowned.MarkOwned(key); entry.Atlas.LastUseSequence = ++_useSequence; location = new BindlessTextureLocation( entry.Atlas.Resource.Slot, 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 = BindlessTextureLocation.Unresolved; 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 = BindlessTextureLocation.Unresolved; 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.Slot, 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); }