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