feat(render): unify physical residency accounting

This commit is contained in:
Erik 2026-07-24 16:20:48 +02:00
parent 1866ea0c6d
commit 3e18fc2730
25 changed files with 642 additions and 48 deletions

View file

@ -1,6 +1,6 @@
# Modern Runtime Slice D — Typed Asset Handles and Unified Residency
**Status:** active
**Status:** active — D1 and D2 complete
**Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md`
**Baseline:** Slice C closeout commit `a564c4b7`
**Behavior contract:** no visual-quality or retail-behavior change
@ -196,6 +196,10 @@ defaults below their current values.
- Test stale completions, duplicate acquire, owner reuse, asset-slot reuse,
cancellation, corrupt/failure states, and illegal transitions.
**Complete 2026-07-24.** The typed ledger, generation checks, bounded
worker-observation journal, startup budget profile, and transition conformance
tests landed without moving physical GL ownership into the policy layer.
### D2 — Existing-owner adapters
- Parameterize current mesh, staging, composite, and standalone budgets.
@ -205,6 +209,18 @@ defaults below their current values.
- Cross-check manager GPU totals with `GpuMemoryTracker`, allowing explicitly
enumerated non-cache resources.
**Complete 2026-07-24.** Production composition passes one immutable budget
profile into the existing mesh and texture owners and registers exact snapshot
sources for object geometry/atlases, prepared meshes, staging, the global mesh
arena, composite arrays, standalone particle arrays, and the prepared package
mapping. Removed object atlases and standalone textures retain retiring-byte
ownership until their accepted frame-fence release actually completes.
Lifecycle artifacts now publish both cache-attributed GPU bytes and the signed
`GpuTrackerMinusResidencyBytes` remainder; that remainder deliberately covers
enumerated non-cache allocations such as terrain, shaders, UI textures,
framebuffers, and dynamic draw buffers rather than being mislabeled as cache
residence.
### D3 — Missing policies
- Bound `RetailAnimationLoader` by count and estimated retained bytes.
@ -242,4 +258,3 @@ Slice D closes only when:
6. The architecture, roadmap, issues, inventory, and durable memory agree with
the implementation.
7. Connected screenshots retain Slice C's visual output.

View file

@ -408,7 +408,8 @@ internal sealed class FrameRootCompositionPhase
foundation.TextureCache,
live.DrawDispatcher,
d.FrameProfiler,
content.Dats);
content.Dats,
foundation.Residency);
lifecycleAutomation =
new WorldLifecycleAutomationController(
() => session.WorldReveal.Snapshot,

View file

@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Residency;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Core.Terrain;
@ -37,7 +38,8 @@ internal sealed record WorldRenderFoundation(
Shader MeshShader,
WbMeshAdapter MeshAdapter,
TextureCache TextureCache,
SamplerCache Samplers);
SamplerCache Samplers,
ResidencyManager Residency);
internal sealed record WorldRenderResult(
WorldTerrainBuildContext TerrainBuild,
@ -47,6 +49,7 @@ internal sealed record WorldRenderDependencies(
WorldEnvironmentController Environment,
IGameRenderResourceLifetime RenderResources,
IGpuResourceRetirementQueue ResourceRetirement,
ResidencyBudgetOptions ResidencyBudgets,
uint InitialCenterLandblockId,
Action<string> Log);
@ -101,12 +104,19 @@ internal interface IWorldRenderCompositionFactory
GL gl,
IDatReaderWriter dats,
IPreparedAssetSource preparedAssets,
IGpuResourceRetirementQueue retirement);
IGpuResourceRetirementQueue retirement,
ResidencyBudgetOptions budgets);
TextureCache CreateTextureCache(
GL gl,
IDatReaderWriter dats,
BindlessSupport bindless,
IGpuResourceRetirementQueue retirement);
IGpuResourceRetirementQueue retirement,
ResidencyBudgetOptions budgets);
void RegisterResidencySources(
ResidencyManager manager,
WbMeshAdapter meshes,
TextureCache textures,
IPreparedAssetSource preparedAssets);
SamplerCache CreateSamplerCache(GL gl);
void Release(IDisposable resource);
}
@ -263,20 +273,42 @@ internal sealed class RetailWorldRenderCompositionFactory
GL gl,
IDatReaderWriter dats,
IPreparedAssetSource preparedAssets,
IGpuResourceRetirementQueue retirement) =>
IGpuResourceRetirementQueue retirement,
ResidencyBudgetOptions budgets) =>
new(
gl,
dats,
preparedAssets,
NullLogger<WbMeshAdapter>.Instance,
retirement);
retirement,
budgets);
public TextureCache CreateTextureCache(
GL gl,
IDatReaderWriter dats,
BindlessSupport bindless,
IGpuResourceRetirementQueue retirement) =>
new(gl, dats, bindless, retirement);
IGpuResourceRetirementQueue retirement,
ResidencyBudgetOptions budgets) =>
new(gl, dats, bindless, retirement, budgets);
public void RegisterResidencySources(
ResidencyManager manager,
WbMeshAdapter meshes,
TextureCache textures,
IPreparedAssetSource preparedAssets)
{
meshes.RegisterResidencySources(manager);
textures.RegisterResidencySources(manager);
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.PreparedPackage,
() => new ResidencyDomainSnapshot(
ResidencyDomain.PreparedPackage,
EntryCount: 1,
OwnerCount: 1,
Charges: new ResidencyCharges(
MappedVirtualBytes:
preparedAssets.MappedVirtualBytes))));
}
public SamplerCache CreateSamplerCache(GL gl) => new(gl);
@ -349,6 +381,8 @@ internal sealed class WorldRenderCompositionPhase
try
{
GL gl = platform.Graphics;
var residency = new ResidencyManager(
_dependencies.ResidencyBudgets);
_factory.InitializeGlState(gl);
Fault(WorldRenderCompositionPoint.GlStateInitialized);
@ -434,7 +468,8 @@ internal sealed class WorldRenderCompositionPhase
gl,
content.Dats,
content.PreparedAssets,
_dependencies.ResourceRetirement),
_dependencies.ResourceRetirement,
residency.Budgets),
_publication.PublishWbMeshAdapter,
WorldRenderCompositionPoint.MeshAdapterPublished);
TextureCache textureCache = AcquireAndPublish(
@ -444,7 +479,8 @@ internal sealed class WorldRenderCompositionPhase
gl,
content.Dats,
bindless,
_dependencies.ResourceRetirement),
_dependencies.ResourceRetirement,
residency.Budgets),
_publication.PublishTextureCache,
WorldRenderCompositionPoint.TextureCachePublished);
SamplerCache samplers = AcquireAndPublish(
@ -453,6 +489,11 @@ internal sealed class WorldRenderCompositionPhase
() => _factory.CreateSamplerCache(gl),
_publication.PublishSamplerCache,
WorldRenderCompositionPoint.SamplerCachePublished);
_factory.RegisterResidencySources(
residency,
meshAdapter,
textureCache,
content.PreparedAssets);
scope.Complete();
_dependencies.Log(
@ -473,7 +514,8 @@ internal sealed class WorldRenderCompositionPhase
meshShader,
meshAdapter,
textureCache,
samplers));
samplers,
residency));
}
catch (Exception failure)
{

View file

@ -1,5 +1,6 @@
using System.Text.Json;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Residency;
using AcDream.App.Streaming;
using AcDream.App.UI.Testing;
@ -53,6 +54,8 @@ internal sealed record WorldLifecycleResourceSnapshot(
int StagedMeshUploads,
long StagedMeshBytes,
long TrackedGpuBytes,
long ResidencyGpuBytes,
long GpuTrackerMinusResidencyBytes,
int TrackedGpuBuffers,
int TrackedGpuTextures,
int OwnedCompositeTextures,
@ -74,6 +77,7 @@ internal sealed record WorldLifecycleResourceSnapshot(
long DatObjectCacheHits,
long DatObjectCacheMisses,
long DatObjectCacheEvictions,
ResidencySnapshot Residency,
double Fps,
double FrameMilliseconds,
string? LastFrameProfile);

View file

@ -1,6 +1,7 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Residency;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Content;
@ -36,6 +37,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
private readonly WbDrawDispatcher _dispatcher;
private readonly FrameProfiler _frameProfiler;
private readonly IDatReaderWriter _dats;
private readonly ResidencyManager _residency;
public WorldLifecycleResourceSnapshotSource(
GpuWorldState world,
@ -52,7 +54,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
TextureCache textures,
WbDrawDispatcher dispatcher,
FrameProfiler frameProfiler,
IDatReaderWriter dats)
IDatReaderWriter dats,
ResidencyManager residency)
{
_world = world ?? throw new ArgumentNullException(nameof(world));
_animations = animations
@ -77,6 +80,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource
_frameProfiler = frameProfiler
?? throw new ArgumentNullException(nameof(frameProfiler));
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_residency = residency
?? throw new ArgumentNullException(nameof(residency));
}
public WorldLifecycleResourceSnapshot Capture(RenderFrameOutcome outcome)
@ -105,6 +110,9 @@ internal sealed class WorldLifecycleResourceSnapshotSource
: default;
CacheStats cpuMeshCacheStats = meshManager.CpuMeshCacheStats;
CacheStats decodedTextureCacheStats = meshManager.DecodedTextureCacheStats;
ResidencySnapshot residency = _residency.CaptureSnapshot();
long trackedGpuBytes = GpuMemoryTracker.AllocatedBytes;
long residencyGpuBytes = residency.TotalCharges.PhysicalGpuBytes;
return new WorldLifecycleResourceSnapshot(
LoadedLandblocks: _world.LoadedLandblockIds.Count,
@ -129,7 +137,10 @@ internal sealed class WorldLifecycleResourceSnapshotSource
MeshEstimatedBytes: mesh.EstimatedBytes,
StagedMeshUploads: _meshes.StagedUploadBacklog,
StagedMeshBytes: _meshes.StagedUploadBytes,
TrackedGpuBytes: GpuMemoryTracker.AllocatedBytes,
TrackedGpuBytes: trackedGpuBytes,
ResidencyGpuBytes: residencyGpuBytes,
GpuTrackerMinusResidencyBytes: checked(
trackedGpuBytes - residencyGpuBytes),
TrackedGpuBuffers: GpuMemoryTracker.BufferCount,
TrackedGpuTextures: GpuMemoryTracker.TextureCount,
OwnedCompositeTextures: _textures.OwnedBindlessTextureCount,
@ -152,6 +163,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
DatObjectCacheHits: datObjectCacheStats.Hits,
DatObjectCacheMisses: datObjectCacheStats.Misses,
DatObjectCacheEvictions: datObjectCacheStats.Evictions,
Residency: residency,
Fps: render.Fps,
FrameMilliseconds: render.FrameMilliseconds,
LastFrameProfile: _frameProfiler.LastReport);

View file

@ -384,6 +384,8 @@ internal sealed class CompositeTextureArrayCache : IDisposable
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 =>
@ -391,6 +393,49 @@ internal sealed class CompositeTextureArrayCache : IDisposable
&& _frameUploadCount < _maximumUploadsPerFrame
&& (_frameUploadCount == 0 || _frameUploadBytes < _maximumUploadBytesPerFrame);
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);

View file

@ -1221,6 +1221,7 @@ public sealed class GameWindow :
_worldEnvironment,
_renderResourceLifetime,
_gpuFrameFlights!,
_options.ResidencyBudgets,
initialCenterLandblockId,
Console.WriteLine),
this).Compose(platformResult, contentEffectsAudio, settingsDevTools);

View file

@ -0,0 +1,22 @@
namespace AcDream.App.Rendering.Residency;
internal sealed class DelegateResidencyDomainSource(
ResidencyDomain domain,
Func<ResidencyDomainSnapshot> capture) : IResidencyDomainSource
{
private readonly Func<ResidencyDomainSnapshot> _capture =
capture ?? throw new ArgumentNullException(nameof(capture));
public ResidencyDomain Domain { get; } = domain;
public ResidencyDomainSnapshot CaptureResidency()
{
ResidencyDomainSnapshot snapshot = _capture();
if (snapshot.Domain != Domain)
{
throw new InvalidOperationException(
$"Residency source {Domain} returned {snapshot.Domain}.");
}
return snapshot;
}
}

View file

@ -47,6 +47,11 @@ internal sealed class ResidencyManager
private readonly List<ResidencyObservation> _observationScratch = [];
private readonly List<ResidencyDomainSnapshot> _snapshotScratch = [];
public ResidencyManager(ResidencyBudgetOptions? budgets = null) =>
Budgets = budgets ?? ResidencyBudgetOptions.Default;
public ResidencyBudgetOptions Budgets { get; }
public int ActiveAssetCount => _assetByKey.Count;
public int ActiveOwnerCount => _owners.Count(owner => owner is not null);
@ -355,19 +360,46 @@ internal sealed class ResidencyManager
public ResidencySnapshot CaptureSnapshot()
{
_snapshotScratch.Clear();
for (int i = 0; i < _domainSources.Count; i++)
_snapshotScratch.Add(_domainSources[i].CaptureResidency());
Span<bool> sourced = stackalloc bool[
Enum.GetValues<ResidencyDomain>().Length];
ResidencyCharges totals = default;
for (int i = 0; i < _domainSources.Count; i++)
{
ResidencyDomainSnapshot snapshot =
_domainSources[i].CaptureResidency();
_snapshotScratch.Add(snapshot);
sourced[(int)snapshot.Domain] = true;
totals += snapshot.Charges;
}
ResidencyCharges[] logicalCharges =
new ResidencyCharges[sourced.Length];
int[] logicalEntries = new int[sourced.Length];
int[] logicalOwners = new int[sourced.Length];
for (uint index = 1; index < _assets.Count; index++)
{
AssetSlot? slot = _assets[checked((int)index)];
if (slot is not null)
totals += slot.Charges;
if (slot is null || sourced[(int)slot.Key.Domain])
continue;
int domain = (int)slot.Key.Domain;
logicalCharges[domain] += slot.Charges;
logicalEntries[domain]++;
logicalOwners[domain] = checked(
logicalOwners[domain] + slot.Owners.Count);
}
for (int i = 0; i < _snapshotScratch.Count; i++)
totals += _snapshotScratch[i].Charges;
for (int domain = 0; domain < logicalEntries.Length; domain++)
{
if (logicalEntries[domain] == 0)
continue;
ResidencyCharges charges = logicalCharges[domain];
_snapshotScratch.Add(new ResidencyDomainSnapshot(
(ResidencyDomain)domain,
logicalEntries[domain],
logicalOwners[domain],
charges));
totals += charges;
}
return new ResidencySnapshot(_snapshotScratch.ToArray(), totals);
}

View file

@ -39,6 +39,8 @@ internal sealed class StandaloneBindlessTextureCache : IDisposable
private readonly Dictionary<uint, StandaloneBindlessTextureResource> _entries = new();
private readonly HashSet<uint> _disposeResidencyReleased = [];
private readonly HashSet<uint> _disposeDeleted = [];
private long _allocatedBytes;
private long _retiringBytes;
private bool _disposeRequested;
private bool _disposing;
private bool _disposed;
@ -62,6 +64,9 @@ internal sealed class StandaloneBindlessTextureCache : IDisposable
internal int OwnerCount => _owners.OwnerCount;
internal int UnownedEntryCount => _unowned.Count;
internal long UnownedBytes => _unowned.ResidentBytes;
internal long AllocatedBytes => _allocatedBytes;
internal long RetiringBytes => _retiringBytes;
internal long BudgetBytes => _unowned.BudgetBytes;
internal int AwaitingRetirementPublicationCount =>
_retirementLedger.AwaitingPublicationCount;
@ -96,6 +101,7 @@ internal sealed class StandaloneBindlessTextureCache : IDisposable
$"Standalone particle surface 0x{resource.SurfaceId:X8} is already cached.");
_owners.Acquire(ownerId, resource.SurfaceId);
_allocatedBytes = checked(_allocatedBytes + resource.Bytes);
}
public void ReleaseOwner(uint ownerId)
@ -143,9 +149,14 @@ internal sealed class StandaloneBindlessTextureCache : IDisposable
// The publication ledger owns this release from this point even
// when queue admission or an immediate callback throws. Never
// republish a texture after residency release has started.
_retiringBytes = checked(_retiringBytes + resource.Bytes);
_retirementLedger.Retire(new RetryableGpuResourceRelease(
() => _backend.MakeNonResident(resource),
() => _backend.Delete(resource)));
() => _backend.Delete(resource),
() => _retiringBytes = checked(
_retiringBytes - resource.Bytes),
() => _allocatedBytes = checked(
_allocatedBytes - resource.Bytes)));
}
}
@ -204,6 +215,8 @@ internal sealed class StandaloneBindlessTextureCache : IDisposable
{
_backend.Delete(resource);
_disposeDeleted.Add(resource.SurfaceId);
_allocatedBytes = checked(
_allocatedBytes - resource.Bytes);
}
catch (Exception ex)
{

View file

@ -8,6 +8,7 @@ using Silk.NET.OpenGL;
using System.Linq;
using PixelFormatId = DatReaderWriter.Enums.PixelFormat;
using SurfaceType = DatReaderWriter.Enums.SurfaceType;
using AcDream.App.Rendering.Residency;
namespace AcDream.App.Rendering;
@ -87,8 +88,10 @@ public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable
GL gl,
IDatReaderWriter dats,
Wb.BindlessSupport? bindless,
IGpuResourceRetirementQueue retirementQueue)
IGpuResourceRetirementQueue retirementQueue,
ResidencyBudgetOptions? budgets = null)
{
budgets ??= ResidencyBudgetOptions.Default;
_gl = gl;
_dats = dats;
_bindless = bindless;
@ -103,11 +106,15 @@ public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable
composite = new CompositeTextureArrayCache(
gl,
bindless,
retirementQueue);
retirementQueue,
budgets.CompositeUnownedBytes,
budgets.CompositePhysicalBytes);
resources.Add("composite texture cache", composite.Dispose);
particles = new StandaloneBindlessTextureCache(
new ParticleTextureBackend(this),
retirementQueue);
retirementQueue,
budgets.StandaloneUnownedBytes,
budgets.StandaloneUnownedEntries);
resources.Add("particle texture cache", particles.Dispose);
resources.TransferAll();
}
@ -123,6 +130,37 @@ public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable
}
}
internal void RegisterResidencySources(ResidencyManager manager)
{
ArgumentNullException.ThrowIfNull(manager);
if (_compositeTextures is not null)
{
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.CompositeTextures,
_compositeTextures.CaptureResidency));
}
if (_particleTextures is not null)
{
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.StandaloneTextures,
CaptureStandaloneResidency));
}
}
private ResidencyDomainSnapshot CaptureStandaloneResidency()
{
StandaloneBindlessTextureCache textures = EnsureParticleTexturesAvailable();
return new ResidencyDomainSnapshot(
ResidencyDomain.StandaloneTextures,
EntryCount: textures.EntryCount,
OwnerCount: textures.OwnerCount,
Charges: new ResidencyCharges(
GpuResidentBytes: checked(
textures.AllocatedBytes - textures.RetiringBytes),
RetiringBytes: textures.RetiringBytes),
BudgetBytes: textures.BudgetBytes);
}
/// <summary>
/// Get or upload the GL texture handle for a Surface id. Returns a
/// 1x1 magenta fallback if the Surface or its RenderSurface chain is

View file

@ -137,6 +137,19 @@ public sealed class GlobalMeshBuffer : IDisposable
|| _retiredCapacityBytes != 0;
internal int VertexHighWaterMark => _vertices.HighWaterMark;
internal int IndexHighWaterMark => _indices.HighWaterMark;
internal long UsedBytes => checked(
(long)_vertices.Used * VertexPositionNormalTexture.Size
+ (long)_indices.Used * sizeof(ushort));
internal long LargestFreeBytes => checked(
(long)_vertices.LargestFreeRange * VertexPositionNormalTexture.Size
+ (long)_indices.LargestFreeRange * sizeof(ushort));
internal long PendingRangeRetirementBytes => checked(
(long)_vertices.PendingReleaseLength * VertexPositionNormalTexture.Size
+ (long)_indices.PendingReleaseLength * sizeof(ushort));
internal long RequestedMigrationBytes => _migration?.NewCapacityBytes ?? 0;
internal long RetiredBackingBytes => _retiredCapacityBytes;
internal long ResidentCapacityBytes => checked(
CapacityBytes - PendingRangeRetirementBytes);
internal GlobalMeshUploadPlan PlanUpload(int vertexCount, int indexCount)
{

View file

@ -523,6 +523,15 @@ namespace AcDream.App.Rendering.Wb {
}
}
/// <summary>
/// True only after every retained bindless-handle, GL-name, and memory
/// accounting release stage has completed. Logical disposal can become
/// durable earlier while the frame fence still owns the physical array.
/// </summary>
internal bool IsPhysicalRetirementComplete =>
Volatile.Read(ref _disposeQueued) != 0
&& Volatile.Read(ref _disposeRelease) is null;
public void Unbind() {
GL.BindTexture(GLEnum.Texture2DArray, 0);
GLHelpers.CheckErrors(GL);

View file

@ -133,6 +133,8 @@ internal sealed class MeshUploadStagingQueue
public int ClaimCount { get { lock (_gate) return _generationById.Count; } }
public long QueuedBytes { get { lock (_gate) return _queuedBytes; } }
public long ClaimedBytes { get { lock (_gate) return _claimedBytes; } }
public int MaximumCount => _maximumCount;
public long MaximumBytes => _maximumBytes;
public bool IsAtHighWater
{
get
@ -249,6 +251,11 @@ internal sealed class MeshOwnershipCounter
public bool IsOwned(ulong id) => Count(id) > 0;
public int OwnedIdCount => _counts.Count(pair => pair.Value > 0);
public int TotalReferenceCount => _counts.Sum(
pair => Math.Max(0, pair.Value));
/// <summary>
/// Reports whether freshly uploaded data has a live owner. This is a query,
/// not an acquire: upload existence and logical ownership are independent.
@ -297,6 +304,8 @@ internal sealed class CpuMeshUploadCache
internal int Count { get { lock (_data) return _data.Count; } }
internal long ResidentBytes { get { lock (_data) return _residentBytes; } }
internal int Capacity => _capacity;
internal long ByteCapacity => _byteCapacity;
public bool TryGetAndStage(
ulong id,

View file

@ -16,6 +16,7 @@ using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using AcDream.Content;
using AcDream.App.Rendering.Residency;
using AcDream.Core.Rendering.Wb;
using PixelFormat = Silk.NET.OpenGL.PixelFormat;
using BoundingBox = Chorizite.Core.Lib.BoundingBox;
@ -143,8 +144,8 @@ namespace AcDream.App.Rendering.Wb
// LRU Cache for Unused objects
private readonly LinkedList<ulong> _lruList = new();
private readonly long _maxGpuMemory = 1024 * 1024 * 1024; // 1GB
private readonly int _maxCachedObjects = 50; // Max number of cached objects (count-based limit)
private readonly long _maxGpuMemory;
private readonly int _maxCachedObjects;
private long _currentNonArenaGpuMemory;
// Shared atlases grouped by (Width, Height, Format)
@ -158,12 +159,15 @@ namespace AcDream.App.Rendering.Wb
private const int RetainedEmptyAtlasCountLimit = 32;
private readonly AcDream.App.Rendering.BoundedUnownedResourceCache<TextureAtlasManager>
_safeEmptyAtlases = new(RetainedEmptyAtlasBudgetBytes, RetainedEmptyAtlasCountLimit);
// Arrays removed from the reusable atlas families remain physical GPU
// allocations until their frame-fenced release completes. Retaining
// the owners here makes that overlap both retryable and observable.
private readonly List<TextureAtlasManager> _retiringAtlases = [];
// CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT)
private readonly int _maxCpuCacheSize = 100;
private readonly CpuMeshUploadCache _cpuMeshCache;
private readonly MeshUploadStagingQueue _stagedMeshData = new();
private readonly MeshUploadStagingQueue _stagedMeshData;
private volatile bool _arenaBackpressured;
/// <summary>#125: how many times a failed GL upload is re-staged before
@ -264,8 +268,13 @@ namespace AcDream.App.Rendering.Wb
int atlasArrays = 0;
foreach (List<TextureAtlasManager> atlases in _globalAtlases.Values)
atlasArrays += atlases.Count;
long atlasBytes = CalculateAtlasBytes(_globalAtlases.Values);
long retiringAtlasBytes = CalculateAtlasBytes(_retiringAtlases);
long physicalBytes = CalculateTrackedGpuBytes(
_currentNonArenaGpuMemory,
checked(
_currentNonArenaGpuMemory
+ atlasBytes
+ retiringAtlasBytes),
GlobalBuffer?.PhysicalCapacityBytes ?? 0);
return (_renderData.Count, atlasArrays, _lruList.Count, physicalBytes);
}
@ -400,15 +409,24 @@ namespace AcDream.App.Rendering.Wb
public ObjectMeshManager(
OpenGLGraphicsDevice graphicsDevice,
IPreparedAssetSource preparedAssets,
ILogger<ObjectMeshManager> logger)
ILogger<ObjectMeshManager> logger,
ResidencyBudgetOptions? budgets = null)
{
budgets ??= ResidencyBudgetOptions.Default;
_graphicsDevice = graphicsDevice
?? throw new ArgumentNullException(nameof(graphicsDevice));
_preparedAssets = preparedAssets
?? throw new ArgumentNullException(nameof(preparedAssets));
_logger = logger
?? throw new ArgumentNullException(nameof(logger));
_cpuMeshCache = new CpuMeshUploadCache(_maxCpuCacheSize);
_maxGpuMemory = budgets.ObjectMeshGpuBytes;
_maxCachedObjects = budgets.ObjectMeshUnownedEntries;
_cpuMeshCache = new CpuMeshUploadCache(
budgets.PreparedMeshCpuEntries,
budgets.PreparedMeshCpuBytes);
_stagedMeshData = new MeshUploadStagingQueue(
budgets.MeshStagingEntries,
budgets.MeshStagingBytes);
_useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless;
if (_useModernRendering)
{
@ -506,6 +524,7 @@ namespace AcDream.App.Rendering.Wb
/// </summary>
internal bool EvictOneEmptyAtlas()
{
RetryRetiringAtlasDisposals();
if (!_safeEmptyAtlases.TryTakeOldestOverBudget(out TextureAtlasManager victim))
return false;
@ -517,10 +536,28 @@ namespace AcDream.App.Rendering.Wb
_globalAtlases.Remove(key);
}
_dirtyAtlases.Remove(victim);
_retiringAtlases.Add(victim);
victim.Dispose();
RemoveCompletedAtlasRetirements();
return true;
}
private void RetryRetiringAtlasDisposals()
{
for (int i = 0; i < _retiringAtlases.Count; i++)
_retiringAtlases[i].Dispose();
RemoveCompletedAtlasRetirements();
}
private void RemoveCompletedAtlasRetirements()
{
for (int i = _retiringAtlases.Count - 1; i >= 0; i--)
{
if (_retiringAtlases[i].IsPhysicalRetirementComplete)
_retiringAtlases.RemoveAt(i);
}
}
private void OnAtlasGpuSafeEmpty(TextureAtlasManager atlas)
{
if (IsDisposed || !atlas.IsGpuSafeEmpty || _safeEmptyAtlases.Contains(atlas))
@ -637,6 +674,7 @@ namespace AcDream.App.Rendering.Wb
{
ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1);
RetryRetiringAtlasDisposals();
RetryPendingAtlasRetirements();
// Rollbacks own unpublished resources and therefore have no LRU
// node to wake them. Advance a bounded snapshot every render tick
@ -649,6 +687,9 @@ namespace AcDream.App.Rendering.Wb
lock (_lruList)
candidateBudget = _lruList.Count;
int attemptedCandidates = 0;
long trackedAtlasBytes = checked(
CalculateAtlasBytes(_globalAtlases.Values)
+ CalculateAtlasBytes(_retiringAtlases));
while (reclaimedCount < maximumCount
&& attemptedCandidates < candidateBudget)
@ -657,9 +698,12 @@ namespace AcDream.App.Rendering.Wb
lock (_lruList)
{
long physicalArenaBytes = GlobalBuffer?.PhysicalCapacityBytes ?? 0;
long nonArenaAndAtlasBytes = checked(
_currentNonArenaGpuMemory
+ trackedAtlasBytes);
if (!forceArenaReclamation
&& IsWithinGpuCacheBudget(
_currentNonArenaGpuMemory,
nonArenaAndAtlasBytes,
physicalArenaBytes,
_maxGpuMemory)
&& _lruList.Count <= _maxCachedObjects)
@ -1207,6 +1251,105 @@ namespace AcDream.App.Rendering.Wb
return _preparedAssets.Read(request, ct).Data;
}
internal ResidencyDomainSnapshot CaptureObjectMeshResidency()
{
GlobalMeshBuffer? arena = GlobalBuffer;
long nonArenaRetiring = 0;
foreach (ObjectReleaseTicket ticket in _objectReleases.Values)
{
nonArenaRetiring = checked(
nonArenaRetiring
+ Math.Max(0, ticket.Data.NonArenaGpuBytes));
}
nonArenaRetiring = Math.Min(
nonArenaRetiring,
_currentNonArenaGpuMemory);
long arenaResident = arena?.ResidentCapacityBytes ?? 0;
long arenaRequested = arena?.RequestedMigrationBytes ?? 0;
long arenaRetiring = checked(
(arena?.PendingRangeRetirementBytes ?? 0)
+ (arena?.RetiredBackingBytes ?? 0));
long atlasResident = CalculateAtlasBytes(_globalAtlases.Values);
long atlasRetiring = CalculateAtlasBytes(_retiringAtlases);
return new ResidencyDomainSnapshot(
ResidencyDomain.ObjectMeshes,
EntryCount: checked(
_renderData.Count
+ _globalAtlases.Values.Sum(static atlases => atlases.Count)
+ _retiringAtlases.Count),
OwnerCount: _ownership.TotalReferenceCount,
Charges: new ResidencyCharges(
GpuRequestedBytes: arenaRequested,
GpuResidentBytes: checked(
_currentNonArenaGpuMemory
- nonArenaRetiring
+ arenaResident
+ atlasResident),
RetiringBytes: checked(
nonArenaRetiring
+ arenaRetiring
+ atlasRetiring)),
BudgetBytes: _maxGpuMemory);
}
internal static long CalculateAtlasBytes(
IEnumerable<IReadOnlyCollection<TextureAtlasManager>> atlasFamilies)
{
ArgumentNullException.ThrowIfNull(atlasFamilies);
long bytes = 0;
foreach (IReadOnlyCollection<TextureAtlasManager> family in atlasFamilies)
{
foreach (TextureAtlasManager atlas in family)
bytes = checked(bytes + atlas.AllocatedBytes);
}
return bytes;
}
internal static long CalculateAtlasBytes(
IEnumerable<TextureAtlasManager> atlases)
{
ArgumentNullException.ThrowIfNull(atlases);
long bytes = 0;
foreach (TextureAtlasManager atlas in atlases)
bytes = checked(bytes + atlas.AllocatedBytes);
return bytes;
}
internal ResidencyDomainSnapshot CapturePreparedMeshResidency() =>
new(
ResidencyDomain.PreparedMeshCpu,
EntryCount: _cpuMeshCache.Count,
OwnerCount: 0,
Charges: new ResidencyCharges(
CpuPreparedBytes: _cpuMeshCache.ResidentBytes),
BudgetBytes: _cpuMeshCache.ByteCapacity);
internal ResidencyDomainSnapshot CaptureStagingResidency() =>
new(
ResidencyDomain.MeshStaging,
EntryCount: _stagedMeshData.ClaimCount,
OwnerCount: 0,
Charges: new ResidencyCharges(
StagingBytes: _stagedMeshData.ClaimedBytes),
BudgetBytes: _stagedMeshData.MaximumBytes);
internal ResidencyDomainSnapshot CaptureGlobalArenaResidency()
{
GlobalMeshBuffer? arena = GlobalBuffer;
return new ResidencyDomainSnapshot(
ResidencyDomain.GlobalMeshArena,
EntryCount: arena is null ? 0 : 2,
OwnerCount: 0,
// Physical arena bytes are already charged once by the
// ObjectMeshes aggregate. This row exposes allocator facts.
Charges: ResidencyCharges.Zero,
BudgetBytes: GlobalMeshBuffer.MaximumPhysicalArenaBytes,
CapacityBytes: arena?.CapacityBytes ?? 0,
UsedBytes: arena?.UsedBytes ?? 0,
LargestFreeBytes: arena?.LargestFreeBytes ?? 0);
}
/// <summary>
/// Cancel preparation tasks for IDs that are no longer needed.
/// </summary>
@ -2545,6 +2688,7 @@ namespace AcDream.App.Rendering.Wb
// Every layer release has now committed logically. Publish any
// callback which previously failed before queue acceptance.
Capture(ref failures, RetryPendingAtlasRetirements);
Capture(ref failures, RetryRetiringAtlasDisposals);
bool atlasFailure = false;
foreach (List<TextureAtlasManager> atlasList in _globalAtlases.Values)
@ -2577,6 +2721,7 @@ namespace AcDream.App.Rendering.Wb
_uploadRollbacks.Clear();
_uploadRollbackQueue.Clear();
_globalAtlases.Clear();
_retiringAtlases.Clear();
_dirtyAtlases.Clear();
_safeEmptyAtlases.Clear();
_currentNonArenaGpuMemory = 0;

View file

@ -69,6 +69,8 @@ namespace AcDream.App.Rendering.Wb {
return TextureArray.TotalSizeInBytes;
}
}
internal bool IsPhysicalRetirementComplete =>
TextureArray.IsPhysicalRetirementComplete;
internal long LastUseSequence { get; set; }
internal int Width => _textureWidth;
internal int Height => _textureHeight;

View file

@ -19,6 +19,7 @@ internal sealed class TextureAtlasSlotAllocator
}
public int AvailableCount => _returned.Count + (_rented.Length - _next);
public int Capacity => _rented.Length;
public int Rent()
{

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using AcDream.Content;
using AcDream.App.Rendering.Residency;
using AcDream.Core.Rendering;
using DatReaderWriter;
using Microsoft.Extensions.Logging;
@ -108,7 +109,8 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
preparedAssets: null,
logger,
AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance,
ownsPreparedAssets: true)
ownsPreparedAssets: true,
ResidencyBudgetOptions.Default)
{
}
@ -123,21 +125,24 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
preparedAssets: null,
logger,
resourceRetirement,
ownsPreparedAssets: true);
ownsPreparedAssets: true,
ResidencyBudgetOptions.Default);
internal WbMeshAdapter(
GL gl,
IDatReaderWriter dats,
IPreparedAssetSource preparedAssets,
ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement)
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
ResidencyBudgetOptions? budgets = null)
: this(
gl,
dats,
preparedAssets,
logger,
resourceRetirement,
ownsPreparedAssets: false)
ownsPreparedAssets: false,
budgets ?? ResidencyBudgetOptions.Default)
{
}
@ -147,11 +152,13 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
IPreparedAssetSource? preparedAssets,
ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
bool ownsPreparedAssets)
bool ownsPreparedAssets,
ResidencyBudgetOptions budgets)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(budgets);
_resourceRetirement = resourceRetirement;
var resources = new AcDream.App.Rendering.ResourceCleanupGroup();
@ -192,7 +199,8 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
meshManager = new ObjectMeshManager(
graphicsDevice,
resolvedPreparedAssets,
new ConsoleErrorLogger<ObjectMeshManager>());
new ConsoleErrorLogger<ObjectMeshManager>(),
budgets);
resources.Add("WB object mesh manager", meshManager.Dispose);
resources.TransferAll();
}
@ -210,6 +218,26 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
: null;
}
internal void RegisterResidencySources(ResidencyManager manager)
{
ArgumentNullException.ThrowIfNull(manager);
ObjectMeshManager meshManager = _meshManager
?? throw new InvalidOperationException(
"An initialized mesh adapter is required for residency diagnostics.");
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.ObjectMeshes,
meshManager.CaptureObjectMeshResidency));
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.PreparedMeshCpu,
meshManager.CapturePreparedMeshResidency));
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.MeshStaging,
meshManager.CaptureStagingResidency));
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.GlobalMeshArena,
meshManager.CaptureGlobalArenaResidency));
}
/// <summary>
/// Minimal Console-backed logger that fires only on
/// <see cref="LogLevel.Error"/> and above. Format:

View file

@ -119,6 +119,12 @@ public interface IPreparedAssetSource : IDisposable
PreparedAssetSourceStats Stats { get; }
CacheStats DecodedTextureCacheStats { get; }
/// <summary>
/// Immutable package address-space mapped by this source. This is virtual
/// address space, not committed managed or process-working-set bytes.
/// </summary>
long MappedVirtualBytes => 0;
}
internal static class PreparedAssetRequestContract
@ -211,6 +217,8 @@ public sealed class PakPreparedAssetSource : IPreparedAssetSource
public CacheStats DecodedTextureCacheStats => default;
public long MappedVirtualBytes => _reader.FileLength;
public PreparedAssetPresence Probe(
PakAssetType type,
uint sourceFileId)

View file

@ -46,6 +46,7 @@ public sealed class PakReader : IDisposable {
private readonly ConcurrentDictionary<int, bool> _loggedCorruption = new();
public PakHeader Header { get; }
public long FileLength => _fileLength;
public PakReader(string path) {
_fileLength = new FileInfo(path).Length;

View file

@ -3,6 +3,7 @@ using System.Runtime.CompilerServices;
using AcDream.App.Composition;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Residency;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Core.Terrain;
@ -19,7 +20,13 @@ public sealed class WorldRenderCompositionTests
[Fact]
public void SuccessPublishesExactModernFoundationInFrozenOrder()
{
var fixture = new Fixture();
ResidencyBudgetOptions budgets =
ResidencyBudgetOptions.Default with
{
ObjectMeshGpuBytes = 321,
CompositePhysicalBytes = 654,
};
var fixture = new Fixture(budgets: budgets);
WorldRenderResult result = fixture.Compose();
@ -30,6 +37,10 @@ public sealed class WorldRenderCompositionTests
Assert.Same(fixture.Lifetime.Atlas, result.Foundation.TerrainAtlas);
Assert.Equal(QualitySettings.From(QualityPreset.High).AnisotropicLevel,
fixture.Factory.AnisotropicLevel);
Assert.Same(budgets, result.Foundation.Residency.Budgets);
Assert.Same(budgets, fixture.Factory.MeshBudgets);
Assert.Same(budgets, fixture.Factory.TextureBudgets);
Assert.Same(result.Foundation.Residency, fixture.Factory.RegisteredResidency);
Assert.Equal(1, fixture.Lifetime.AcquireCalls);
Assert.Empty(fixture.Factory.Releases);
}
@ -134,13 +145,16 @@ public sealed class WorldRenderCompositionTests
private sealed class Fixture
{
private readonly WorldRenderCompositionPoint? _failurePoint;
private readonly ResidencyBudgetOptions _budgets;
public Fixture(
bool hasFont = true,
WorldRenderCompositionPoint? failurePoint = null,
string? publicationFailure = null)
string? publicationFailure = null,
ResidencyBudgetOptions? budgets = null)
{
_failurePoint = failurePoint;
_budgets = budgets ?? ResidencyBudgetOptions.Default;
Factory = new Factory(hasFont);
Publication = new Publication(publicationFailure);
Lifetime = new RenderLifetime(Factory.Atlas);
@ -161,6 +175,7 @@ public sealed class WorldRenderCompositionTests
new WorldEnvironmentController(),
Lifetime,
ImmediateGpuResourceRetirementQueue.Instance,
_budgets,
0xA9B4FFFFu,
_ => { }),
Publication,
@ -199,6 +214,9 @@ public sealed class WorldRenderCompositionTests
public TerrainAtlas Atlas { get; } = Stub<TerrainAtlas>();
public int AnisotropicLevel { get; private set; }
public List<string> Releases { get; } = [];
public ResidencyBudgetOptions? MeshBudgets { get; private set; }
public ResidencyBudgetOptions? TextureBudgets { get; private set; }
public ResidencyManager? RegisteredResidency { get; private set; }
public void InitializeGlState(GL gl) { }
@ -266,15 +284,32 @@ public sealed class WorldRenderCompositionTests
GL gl,
IDatReaderWriter dats,
IPreparedAssetSource preparedAssets,
IGpuResourceRetirementQueue retirement) =>
Resource<WbMeshAdapter>("WB mesh adapter");
IGpuResourceRetirementQueue retirement,
ResidencyBudgetOptions budgets)
{
MeshBudgets = budgets;
return Resource<WbMeshAdapter>("WB mesh adapter");
}
public TextureCache CreateTextureCache(
GL gl,
IDatReaderWriter dats,
BindlessSupport bindless,
IGpuResourceRetirementQueue retirement) =>
Resource<TextureCache>("texture cache");
IGpuResourceRetirementQueue retirement,
ResidencyBudgetOptions budgets)
{
TextureBudgets = budgets;
return Resource<TextureCache>("texture cache");
}
public void RegisterResidencySources(
ResidencyManager manager,
WbMeshAdapter meshes,
TextureCache textures,
IPreparedAssetSource preparedAssets)
{
RegisteredResidency = manager;
}
public SamplerCache CreateSamplerCache(GL gl) =>
Resource<SamplerCache>("sampler cache");

View file

@ -278,9 +278,9 @@ public sealed class WorldLifecycleAutomationControllerTests
private static WorldLifecycleResourceSnapshot EmptyResources() => new(
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0L, 0, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L,
0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L,
0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L,
0d, 0d, null);
default, 0d, 0d, null);
private static string NewDirectory()
{

View file

@ -6,6 +6,40 @@ namespace AcDream.App.Tests.Rendering;
public sealed class CompositeTextureArrayCachePolicyTests
{
[Fact]
public void ResidencySnapshotSeparatesResidentRequestedAndRetiringStorage()
{
var backend = new FakeBackend(maximumLayers: 1);
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 0,
physicalBudgetBytes: 0);
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(
1,
Key(1),
Texture(4, 4),
out _));
var resident = cache.CaptureResidency();
Assert.Equal(64, resident.Charges.GpuResidentBytes);
Assert.Equal(0, resident.Charges.RetiringBytes);
Assert.Equal(64, resident.CapacityBytes);
Assert.Equal(64, resident.UsedBytes);
cache.ReleaseOwner(1);
cache.Tick();
retirements.DrainAll();
cache.Tick();
var retired = cache.CaptureResidency();
Assert.Equal(0, retired.Charges.GpuResidentBytes);
Assert.Equal(0, retired.Charges.RetiringBytes);
Assert.Equal(0, retired.CapacityBytes);
}
[Theory]
[InlineData(16, 16, 64)]
[InlineData(128, 128, 64)]

View file

@ -343,6 +343,60 @@ public sealed class ResidencyManagerTests
Assert.Equal(1, journal.Count);
}
[Fact]
public void DomainSourcesAggregateWithoutCountingMappedAddressSpaceAsRam()
{
var manager = new ResidencyManager();
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.PreparedPackage,
() => new ResidencyDomainSnapshot(
ResidencyDomain.PreparedPackage,
EntryCount: 1,
OwnerCount: 1,
Charges: new ResidencyCharges(
LogicalBytes: 64,
PinnedBytes: 32,
MappedVirtualBytes: 30_000))));
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.StandaloneTextures,
() => new ResidencyDomainSnapshot(
ResidencyDomain.StandaloneTextures,
EntryCount: 2,
OwnerCount: 1,
Charges: new ResidencyCharges(
GpuResidentBytes: 128),
BudgetBytes: 256,
CapacityBytes: 128,
UsedBytes: 96,
LargestFreeBytes: 16)));
ResidencySnapshot snapshot = manager.CaptureSnapshot();
Assert.Equal(64, snapshot.TotalCharges.CommittedCpuBytes);
Assert.Equal(30_000, snapshot.TotalCharges.MappedVirtualBytes);
Assert.Equal(128, snapshot.TotalCharges.PhysicalGpuBytes);
Assert.Equal(
16,
snapshot.Get(ResidencyDomain.StandaloneTextures)
.FragmentedFreeBytes);
}
[Fact]
public void OnlyOneCanonicalSourceMayOwnADomain()
{
var manager = new ResidencyManager();
var first = new DelegateResidencyDomainSource(
ResidencyDomain.ObjectMeshes,
() => default);
manager.RegisterDomainSource(first);
Assert.Throws<InvalidOperationException>(() =>
manager.RegisterDomainSource(
new DelegateResidencyDomainSource(
ResidencyDomain.ObjectMeshes,
() => default)));
}
private static AssetHandle<MeshAsset> Resident(
ResidencyManager manager,
ulong id,

View file

@ -52,6 +52,8 @@ public sealed class StandaloneBindlessTextureCacheTests
cache.Tick();
Assert.Equal(2, cache.EntryCount);
Assert.Equal(12, cache.AllocatedBytes);
Assert.Equal(4, cache.RetiringBytes);
Assert.Equal(2, cache.UnownedEntryCount);
Assert.Single(retirements.Actions);
Assert.Empty(backend.Events);
@ -60,6 +62,8 @@ public sealed class StandaloneBindlessTextureCacheTests
retirements.Drain();
Assert.Equal(["nonresident:1", "delete:1"], backend.Events);
Assert.Equal(8, cache.AllocatedBytes);
Assert.Equal(0, cache.RetiringBytes);
}
[Fact]
@ -196,6 +200,32 @@ public sealed class StandaloneBindlessTextureCacheTests
backend.Events);
}
[Fact]
public void DisposePreservesAcceptedFenceRetirementAccountingUntilCompletion()
{
var backend = new FakeBackend();
var retirements = new DeferredRetirementQueue();
var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 1_000,
maximumUnownedCount: 1);
AddAndRelease(cache, ownerId: 10, Resource(1, bytes: 4));
AddAndRelease(cache, ownerId: 20, Resource(2, bytes: 4));
cache.Tick();
Assert.Equal(8, cache.AllocatedBytes);
Assert.Equal(4, cache.RetiringBytes);
cache.Dispose();
Assert.Equal(4, cache.AllocatedBytes);
Assert.Equal(4, cache.RetiringBytes);
retirements.Drain();
Assert.Equal(0, cache.AllocatedBytes);
Assert.Equal(0, cache.RetiringBytes);
}
[Fact]
public void FailedResidencyReleaseNeverDeletesThatBackingTexture()
{