fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
933
src/AcDream.App/Rendering/CompositeTextureArrayCache.cs
Normal file
933
src/AcDream.App/Rendering/CompositeTextureArrayCache.cs
Normal file
|
|
@ -0,0 +1,933 @@
|
|||
using AcDream.Core.Textures;
|
||||
using AcDream.Core.World;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Location of one decoded entity-material composite in a resident bindless
|
||||
/// texture array. The modern mesh shader consumes this exact pair.
|
||||
/// </summary>
|
||||
public readonly record struct BindlessTextureLocation(ulong Handle, uint Layer);
|
||||
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Exception>? 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 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 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,
|
||||
}
|
||||
|
||||
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<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 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<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()
|
||||
{
|
||||
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<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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue