From 3e18fc2730c15d58b7ce194215d50f11eaf31293 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Jul 2026 16:20:48 +0200 Subject: [PATCH] feat(render): unify physical residency accounting --- ...odern-runtime-slice-d-unified-residency.md | 19 ++- .../Composition/FrameRootComposition.cs | 3 +- .../Composition/WorldRenderComposition.cs | 62 +++++-- .../WorldLifecycleAutomationController.cs | 4 + .../WorldLifecycleResourceSnapshotSource.cs | 16 +- .../Rendering/CompositeTextureArrayCache.cs | 45 +++++ src/AcDream.App/Rendering/GameWindow.cs | 1 + .../DelegateResidencyDomainSource.cs | 22 +++ .../Rendering/Residency/ResidencyManager.cs | 46 ++++- .../StandaloneBindlessTextureCache.cs | 15 +- src/AcDream.App/Rendering/TextureCache.cs | 44 ++++- .../Rendering/Wb/GlobalMeshBuffer.cs | 13 ++ .../Rendering/Wb/ManagedGLTextureArray.cs | 9 + .../Rendering/Wb/MeshUploadCaches.cs | 9 + .../Rendering/Wb/ObjectMeshManager.cs | 161 +++++++++++++++++- .../Rendering/Wb/TextureAtlasManager.cs | 2 + .../Rendering/Wb/TextureAtlasSlotAllocator.cs | 1 + src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 40 ++++- src/AcDream.Content/IPreparedAssetSource.cs | 8 + src/AcDream.Content/Pak/PakReader.cs | 1 + .../WorldRenderCompositionTests.cs | 47 ++++- ...WorldLifecycleAutomationControllerTests.cs | 4 +- .../CompositeTextureArrayCachePolicyTests.cs | 34 ++++ .../Residency/ResidencyManagerTests.cs | 54 ++++++ .../StandaloneBindlessTextureCacheTests.cs | 30 ++++ 25 files changed, 642 insertions(+), 48 deletions(-) create mode 100644 src/AcDream.App/Rendering/Residency/DelegateResidencyDomainSource.cs diff --git a/docs/plans/2026-07-24-modern-runtime-slice-d-unified-residency.md b/docs/plans/2026-07-24-modern-runtime-slice-d-unified-residency.md index d4029ea3..4bc4af67 100644 --- a/docs/plans/2026-07-24-modern-runtime-slice-d-unified-residency.md +++ b/docs/plans/2026-07-24-modern-runtime-slice-d-unified-residency.md @@ -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. - diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 369ce330..66b8db5e 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -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, diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs index 9000e9ba..f57798d7 100644 --- a/src/AcDream.App/Composition/WorldRenderComposition.cs +++ b/src/AcDream.App/Composition/WorldRenderComposition.cs @@ -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 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.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) { diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs index b1777747..51dea8db 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs @@ -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); diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs index 010c80cd..0f753bf7 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs @@ -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); diff --git a/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs b/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs index 47fd21ce..86ef9286 100644 --- a/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs +++ b/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs @@ -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); diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8a8bba8a..c9cd54b8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1221,6 +1221,7 @@ public sealed class GameWindow : _worldEnvironment, _renderResourceLifetime, _gpuFrameFlights!, + _options.ResidencyBudgets, initialCenterLandblockId, Console.WriteLine), this).Compose(platformResult, contentEffectsAudio, settingsDevTools); diff --git a/src/AcDream.App/Rendering/Residency/DelegateResidencyDomainSource.cs b/src/AcDream.App/Rendering/Residency/DelegateResidencyDomainSource.cs new file mode 100644 index 00000000..8e839e2f --- /dev/null +++ b/src/AcDream.App/Rendering/Residency/DelegateResidencyDomainSource.cs @@ -0,0 +1,22 @@ +namespace AcDream.App.Rendering.Residency; + +internal sealed class DelegateResidencyDomainSource( + ResidencyDomain domain, + Func capture) : IResidencyDomainSource +{ + private readonly Func _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; + } +} diff --git a/src/AcDream.App/Rendering/Residency/ResidencyManager.cs b/src/AcDream.App/Rendering/Residency/ResidencyManager.cs index 3e357360..08c43a63 100644 --- a/src/AcDream.App/Rendering/Residency/ResidencyManager.cs +++ b/src/AcDream.App/Rendering/Residency/ResidencyManager.cs @@ -47,6 +47,11 @@ internal sealed class ResidencyManager private readonly List _observationScratch = []; private readonly List _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 sourced = stackalloc bool[ + Enum.GetValues().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); } diff --git a/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs b/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs index 27c31034..5952f590 100644 --- a/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs +++ b/src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs @@ -39,6 +39,8 @@ internal sealed class StandaloneBindlessTextureCache : IDisposable private readonly Dictionary _entries = new(); private readonly HashSet _disposeResidencyReleased = []; private readonly HashSet _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) { diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index 8e6eae6f..f8bf9452 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -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); + } + /// /// Get or upload the GL texture handle for a Surface id. Returns a /// 1x1 magenta fallback if the Surface or its RenderSurface chain is diff --git a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs index 6ebce5c0..ba074462 100644 --- a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs +++ b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs @@ -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) { diff --git a/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs b/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs index f745959e..1115cede 100644 --- a/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs +++ b/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs @@ -523,6 +523,15 @@ namespace AcDream.App.Rendering.Wb { } } + /// + /// 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. + /// + 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); diff --git a/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs b/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs index 8bbcd01b..43bb303d 100644 --- a/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs +++ b/src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs @@ -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)); + /// /// 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, diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index aec922b1..c639becd 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -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 _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 _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 _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; /// #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 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 logger) + ILogger 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 /// 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> atlasFamilies) + { + ArgumentNullException.ThrowIfNull(atlasFamilies); + long bytes = 0; + foreach (IReadOnlyCollection family in atlasFamilies) + { + foreach (TextureAtlasManager atlas in family) + bytes = checked(bytes + atlas.AllocatedBytes); + } + return bytes; + } + + internal static long CalculateAtlasBytes( + IEnumerable 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); + } + /// /// Cancel preparation tasks for IDs that are no longer needed. /// @@ -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 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; diff --git a/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs b/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs index 22b16666..ee9ed87f 100644 --- a/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs +++ b/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs @@ -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; diff --git a/src/AcDream.App/Rendering/Wb/TextureAtlasSlotAllocator.cs b/src/AcDream.App/Rendering/Wb/TextureAtlasSlotAllocator.cs index 7564ed58..987a6e47 100644 --- a/src/AcDream.App/Rendering/Wb/TextureAtlasSlotAllocator.cs +++ b/src/AcDream.App/Rendering/Wb/TextureAtlasSlotAllocator.cs @@ -19,6 +19,7 @@ internal sealed class TextureAtlasSlotAllocator } public int AvailableCount => _returned.Count + (_rented.Length - _next); + public int Capacity => _rented.Length; public int Rent() { diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index 36adc599..b05d7a8d 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -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 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 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()); + new ConsoleErrorLogger(), + 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)); + } + /// /// Minimal Console-backed logger that fires only on /// and above. Format: diff --git a/src/AcDream.Content/IPreparedAssetSource.cs b/src/AcDream.Content/IPreparedAssetSource.cs index 0b6c599b..46662c0b 100644 --- a/src/AcDream.Content/IPreparedAssetSource.cs +++ b/src/AcDream.Content/IPreparedAssetSource.cs @@ -119,6 +119,12 @@ public interface IPreparedAssetSource : IDisposable PreparedAssetSourceStats Stats { get; } CacheStats DecodedTextureCacheStats { get; } + + /// + /// Immutable package address-space mapped by this source. This is virtual + /// address space, not committed managed or process-working-set bytes. + /// + 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) diff --git a/src/AcDream.Content/Pak/PakReader.cs b/src/AcDream.Content/Pak/PakReader.cs index 03accd0b..ef636757 100644 --- a/src/AcDream.Content/Pak/PakReader.cs +++ b/src/AcDream.Content/Pak/PakReader.cs @@ -46,6 +46,7 @@ public sealed class PakReader : IDisposable { private readonly ConcurrentDictionary _loggedCorruption = new(); public PakHeader Header { get; } + public long FileLength => _fileLength; public PakReader(string path) { _fileLength = new FileInfo(path).Length; diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs index 64213137..aee6b6e4 100644 --- a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs @@ -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(); public int AnisotropicLevel { get; private set; } public List 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("WB mesh adapter"); + IGpuResourceRetirementQueue retirement, + ResidencyBudgetOptions budgets) + { + MeshBudgets = budgets; + return Resource("WB mesh adapter"); + } public TextureCache CreateTextureCache( GL gl, IDatReaderWriter dats, BindlessSupport bindless, - IGpuResourceRetirementQueue retirement) => - Resource("texture cache"); + IGpuResourceRetirementQueue retirement, + ResidencyBudgetOptions budgets) + { + TextureBudgets = budgets; + return Resource("texture cache"); + } + + public void RegisterResidencySources( + ResidencyManager manager, + WbMeshAdapter meshes, + TextureCache textures, + IPreparedAssetSource preparedAssets) + { + RegisteredResidency = manager; + } public SamplerCache CreateSamplerCache(GL gl) => Resource("sampler cache"); diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index 229be3d7..bb55bf1d 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -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() { diff --git a/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs b/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs index fe69eb5a..b963fa55 100644 --- a/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CompositeTextureArrayCachePolicyTests.cs @@ -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)] diff --git a/tests/AcDream.App.Tests/Rendering/Residency/ResidencyManagerTests.cs b/tests/AcDream.App.Tests/Rendering/Residency/ResidencyManagerTests.cs index df388955..77fe3c03 100644 --- a/tests/AcDream.App.Tests/Rendering/Residency/ResidencyManagerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Residency/ResidencyManagerTests.cs @@ -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(() => + manager.RegisterDomainSource( + new DelegateResidencyDomainSource( + ResidencyDomain.ObjectMeshes, + () => default))); + } + private static AssetHandle Resident( ResidencyManager manager, ulong id, diff --git a/tests/AcDream.App.Tests/Rendering/StandaloneBindlessTextureCacheTests.cs b/tests/AcDream.App.Tests/Rendering/StandaloneBindlessTextureCacheTests.cs index 11a217c3..08033fd7 100644 --- a/tests/AcDream.App.Tests/Rendering/StandaloneBindlessTextureCacheTests.cs +++ b/tests/AcDream.App.Tests/Rendering/StandaloneBindlessTextureCacheTests.cs @@ -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() {