TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.
Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.
Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):
- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
The cache assumes it is the sole writer of GL program/blend/depth/cull
state, which was true while it had zero real consumers, but every
still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
mutates that same GL state directly and never informs the cache. Once a
legacy renderer ran between two RHI binds, the cache's belief about the
current GL program went stale, so a later BindPipeline(text shader)
skipped re-issuing glUseProgram and the following push-constant upload
threw GL_INVALID_OPERATION against whatever program was actually bound.
Reset() at the frame boundary is the same defensive move BeginPass
already makes after a forced clear (see its comment); it costs one
redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
computed from GpuPipelineDescription.SampleCount at BindPipeline time -
mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
toggle.
Collateral, scoped to keep the port real rather than a stub:
- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
slot (the device's default white texture), so the old sentinel would
have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
public AcDream.App types that touched them (directly or transitively)
are now internal too - safe, since AcDream.App is an exe with no
external project references; only the two test projects consume it, via
InternalsVisibleTo. A handful of unrelated types the sweep caught
(ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
were reverted back to public where making them internal would have
either cascaded into unrelated files or broken xUnit's public-member
discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
produce (V4g's scope) into the device's texture table for
UiViewport.TextureHandle, via a temporary
GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
conformance tests keyed to TextRenderer's old multi-resource
construction shape (Shader + per-flight FrameBufferSet array + white
texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
- that shape is gone, replaced by one IGpuPipeline created through
IGpuDevice. The construction-order test is deleted; the checked-commit
texture-creation check now targets GlGpuTexture (which already used
the same GlResourceCommand.CreateName primitive before this slice).
Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
skipped (was 3,843/3 entering this slice - net 3 fewer tests:
TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
method). Full solution: 8,908 passed / 5 skipped across all nine test
projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
vs this commit): differing fraction 0.318% (1,791/563,200 compared
pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
than waved through: a diff heatmap plus 4x crops at the differing
clusters show zero differences anywhere in the retained UI, terrain,
scenery, or static meshes - every differing pixel sits on continuously-
animated ambient content (flying-insect sprites over the swamp, foliage
sparkle/dew glints) whose exact phase depends on elapsed wall-clock
time, the same category the gate's own sky-masking rationale already
documents and the campaign doc's coverage table explicitly excludes
("Not covered - particles"). Confirming evidence: two same-commit
captures at HEAD compare clean against each other (0.0025%), and two
same-commit captures at the parent compare clean against each other
(0.0044%) - only base-vs-head is consistently elevated, which is what
frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
ring resets, the render-state reset above) would produce against a
fixed wall-clock capture deadline, not a rendering defect. Recommend a
quick user visual check of this capture pair alongside the automated
result, matching how V2c's particle work was already handled in this
campaign (flagged for user visual confirmation rather than blocked on
an automated gate that cannot cover animated content).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
989 lines
37 KiB
C#
989 lines
37 KiB
C#
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>
|
|
internal 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 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,
|
|
}
|
|
|
|
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 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 = 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);
|
|
}
|