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