acdream/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs
Erik 8a7a0837e1 feat(render): Vulkan campaign V11 step 2 — delete the OpenGL backend
Vulkan is the sole, user-signed-off backend (V10 landed) and step 1
already removed ImGui/Studio/DevTools. This step deletes the GL
rendering backend itself: every Gpu/Gl/** implementation, the Wb
ManagedGL*/GLHelpers/GLSLShader/GLStateScope/RenderStateCache/
BindlessSupport family, Shader/ShaderProgramConstruction/SamplerCache,
RenderBootstrap, and RenderFrameGlStateController.

GameWindow.cs's Run()/CreateGraphics()/CreateBackbufferReader()/
OnLoad() collapse to their Vulkan-only arm; GameWindowGraphics loses
its OpenGlGameWindowGraphics subclass. RuntimeOptions.RenderBackend and
RenderBackendKind (incl. the Gl member of GpuBackendKind) are gone —
there is nothing left to select between. The five world-draw dual-arm
renderers (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer,
ParticleRenderer, SkyRenderer) and the composition roots
(WorldRenderComposition, HostInputCameraComposition,
LivePresentationComposition, FrameRootComposition) collapse to their
RHI-only arm. GL-only diagnostic properties with a live external reader
(DynamicBufferCount and friends) simplify to a documented `=> 0`/no-op
rather than disappearing, since the reader is out of this commit's
scope.

A few GL-flavored mechanisms turned out to be backend-neutral once
isolated: GlConstructionCleanupLedger is renamed
ResourceConstructionCleanupLedger (exception-chain walking has nothing
to do with GL), and GlfwNativePlatformProbe moved out of the otherwise
GL-only GraphicalCapabilityRecord.cs into
GraphicalWindowBackendSelection.cs before the rest of that file was
deleted.

Test files with no surviving subject are deleted outright
(GraphicalCapabilityRequirementsTests, ShaderProgramConstructionTests,
PortalDepthShaderParityTests, TextureCacheBindlessTests,
TextRendererFailureSafetyTests, ClipFrameUploadTests, every
Gpu/Gl/*Tests, GlTextureOwnershipTests, RenderFrameGlStateControllerTests);
others get their dead GL-only members trimmed while their live
assertions stay (ClipFrameLayoutTests' MeshClipSsboBinding check now
reads GpuBindingModel.StorageClipRegions, the same binding index under
its new backend-neutral name; GpuResourceRetirementTransactionTests
drops its OpenGLGraphicsDevice-subclassing test double and the two GL
queue tests it existed for). EnvCellRendererTests' construction helper
now builds a real ObjectMeshManager via VulkanMeshPipelineDevice
instead of passing null through a null-forgiving operator, since the
RHI constructor never tolerated a null mesh manager and the old GL
constructor (which did) is gone.

Deferred to the next two steps, deliberately not touched here: the
Silk.NET.OpenGL/.Extensions.ARB package references, IMeshPipelineDevice.Gl
(WbMeshAdapter's GL? threading stays in place), Chorizite.Core's stale
csproj comment (the package itself is still load-bearing —
TextureFormat and friends are used well beyond the deleted
ManagedGLUniformBuffer), and the CI/gate scripts.

Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors.
Tests: full-solution `dotnet test` green across every project
(App.Tests 3937/3940 + 3 skips, Core.Tests 3296/3298 + 2 skips, all
others 100%); the 2 App.Tests names that flake under full-suite
parallel execution (#250-family, documented pre-existing) pass in
isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:19:53 +02:00

970 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using AcDream.App.Rendering.Gpu;
using AcDream.Core.Textures;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// 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.
///
/// <para>Campaign V slice V4t replaced the raw 64-bit
/// <c>ARB_bindless_texture</c> handle with <see cref="GpuTextureSlot"/>, 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 — <c>default(GpuTextureSlot)</c> 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 <c>default</c>
/// exactly <see cref="Unresolved"/>. Same discipline, same reason, as
/// <c>UiTextureTableHandle</c> on the UI path.</para>
///
/// <para>Internal rather than public because <see cref="GpuTextureSlot"/> is
/// part of the internal RHI contract. Nothing outside this assembly and its
/// InternalsVisibleTo test assemblies ever named this type.</para>
/// </summary>
internal readonly struct BindlessTextureLocation : IEquatable<BindlessTextureLocation>
{
private readonly uint _slotPlusOne;
public BindlessTextureLocation(GpuTextureSlot slot, uint layer)
{
_slotPlusOne = slot.IsAssigned ? slot.Index + 1 : 0;
Layer = layer;
}
/// <summary>The "no composite yet" value. Identical to <c>default</c>.</summary>
public static BindlessTextureLocation Unresolved => default;
/// <summary>Layer within the array. Meaningless unless <see cref="IsResolved"/>.</summary>
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,
}
/// <summary>
/// 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.
/// </summary>
internal readonly struct PaletteCompositeIdentity : IEquatable<PaletteCompositeIdentity>
{
private readonly IReadOnlyList<PaletteOverride.SubPaletteRange>? _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
{
/// <summary>The GL texture name, or 0 on the backend-neutral arm.</summary>
public required uint Name { get; init; }
/// <summary>The resident bindless handle, or 0 on the backend-neutral arm.</summary>
public required ulong Handle { get; init; }
/// <summary>
/// Campaign V slice V6i-2: the RHI image, on the arm that owns one. Null on
/// GL, where <see cref="Name"/> and <see cref="Handle"/> are the identity.
/// The cache above touches neither — it only ever hands a resource back to
/// the backend that made it.
/// </summary>
public Gpu.IGpuTexture? Image { get; init; }
/// <summary>
/// Campaign V slice V4t: this array's entry in the device texture table.
/// The backend that made <see cref="Handle"/> resident also interned it, so
/// the pair is created and retired together and the cache above never has
/// to know a backend exists.
/// </summary>
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);
}
/// <summary>
/// Campaign V slice V6i-2: the backend-neutral composite array backend.
///
/// <para>Plan §5.5.12 item 1 noted that <see cref="ICompositeTextureArrayBackend"/>
/// "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.</para>
///
/// <para><b>Clamped, matching what the GL backend's texture parameters say for
/// the modes that matter.</b> The GL backend sets <c>Repeat</c>, 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.</para>
/// </summary>
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,
});
}
/// <summary>
/// The GL backend reads <c>GL_MAX_ARRAY_TEXTURE_LAYERS</c>. The pinned
/// <see cref="Gpu.GpuCapabilityRecord"/> has no array-layer field and §3.3 is
/// frozen, so this reports Vulkan's guaranteed <c>maxImageArrayLayers</c>
/// minimum of 256. That is not a limitation in practice:
/// <see cref="CompositeTextureArrayCache.MaximumLayersPerArray"/> caps every
/// array at 64, so the true device limit is never the binding constraint.
/// </summary>
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);
/// <summary>
/// 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.
/// </summary>
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.");
}
}
/// <summary>
/// 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.
/// </summary>
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<CompositeTextureKey> _owners = new();
private readonly BoundedUnownedResourceCache<CompositeTextureKey> _unowned;
private readonly long _physicalBudgetBytes;
private readonly int _maximumArrayLayers;
private readonly int _maximumUploadsPerFrame;
private readonly long _maximumUploadBytesPerFrame;
private readonly Dictionary<CompositeTextureKey, Entry> _entries = new();
private readonly Dictionary<(int Width, int Height), List<Atlas>> _atlasesBySize = new();
private readonly List<Atlas> _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<CompositeTextureKey> _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<CompositeTextureKey>(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<Atlas>? 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<CompositeTextureKey> 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);
}
}
/// <summary>
/// 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.
/// </summary>
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<uint, int, int> 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<Atlas>? 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<Atlas>();
_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<Atlas>? 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<Exception>? 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);
}