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 # 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` **Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md`
**Baseline:** Slice C closeout commit `a564c4b7` **Baseline:** Slice C closeout commit `a564c4b7`
**Behavior contract:** no visual-quality or retail-behavior change **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, - Test stale completions, duplicate acquire, owner reuse, asset-slot reuse,
cancellation, corrupt/failure states, and illegal transitions. 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 ### D2 — Existing-owner adapters
- Parameterize current mesh, staging, composite, and standalone budgets. - 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 - Cross-check manager GPU totals with `GpuMemoryTracker`, allowing explicitly
enumerated non-cache resources. 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 ### D3 — Missing policies
- Bound `RetailAnimationLoader` by count and estimated retained bytes. - 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 6. The architecture, roadmap, issues, inventory, and durable memory agree with
the implementation. the implementation.
7. Connected screenshots retain Slice C's visual output. 7. Connected screenshots retain Slice C's visual output.

View file

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

View file

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

View file

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

View file

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

View file

@ -384,6 +384,8 @@ internal sealed class CompositeTextureArrayCache : IDisposable
internal long UnownedBytes => _unowned.ResidentBytes; internal long UnownedBytes => _unowned.ResidentBytes;
internal int AtlasCount => _atlases.Count; internal int AtlasCount => _atlases.Count;
internal long AllocatedBytes => _allocatedBytes; internal long AllocatedBytes => _allocatedBytes;
internal long PhysicalBudgetBytes => _physicalBudgetBytes;
internal long UnownedBudgetBytes => _unowned.BudgetBytes;
internal int FrameUploadCount => _frameUploadCount; internal int FrameUploadCount => _frameUploadCount;
internal long FrameUploadBytes => _frameUploadBytes; internal long FrameUploadBytes => _frameUploadBytes;
internal bool CanStartUpload => internal bool CanStartUpload =>
@ -391,6 +393,49 @@ internal sealed class CompositeTextureArrayCache : IDisposable
&& _frameUploadCount < _maximumUploadsPerFrame && _frameUploadCount < _maximumUploadsPerFrame
&& (_frameUploadCount == 0 || _frameUploadBytes < _maximumUploadBytesPerFrame); && (_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) internal bool CanUpload(long bytes)
{ {
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytes); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytes);

View file

@ -1221,6 +1221,7 @@ public sealed class GameWindow :
_worldEnvironment, _worldEnvironment,
_renderResourceLifetime, _renderResourceLifetime,
_gpuFrameFlights!, _gpuFrameFlights!,
_options.ResidencyBudgets,
initialCenterLandblockId, initialCenterLandblockId,
Console.WriteLine), Console.WriteLine),
this).Compose(platformResult, contentEffectsAudio, settingsDevTools); 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<ResidencyObservation> _observationScratch = [];
private readonly List<ResidencyDomainSnapshot> _snapshotScratch = []; 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 ActiveAssetCount => _assetByKey.Count;
public int ActiveOwnerCount => _owners.Count(owner => owner is not null); public int ActiveOwnerCount => _owners.Count(owner => owner is not null);
@ -355,19 +360,46 @@ internal sealed class ResidencyManager
public ResidencySnapshot CaptureSnapshot() public ResidencySnapshot CaptureSnapshot()
{ {
_snapshotScratch.Clear(); _snapshotScratch.Clear();
for (int i = 0; i < _domainSources.Count; i++) Span<bool> sourced = stackalloc bool[
_snapshotScratch.Add(_domainSources[i].CaptureResidency()); Enum.GetValues<ResidencyDomain>().Length];
ResidencyCharges totals = default; 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++) for (uint index = 1; index < _assets.Count; index++)
{ {
AssetSlot? slot = _assets[checked((int)index)]; AssetSlot? slot = _assets[checked((int)index)];
if (slot is not null) if (slot is null || sourced[(int)slot.Key.Domain])
totals += slot.Charges; 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++) for (int domain = 0; domain < logicalEntries.Length; domain++)
totals += _snapshotScratch[i].Charges; {
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); 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 Dictionary<uint, StandaloneBindlessTextureResource> _entries = new();
private readonly HashSet<uint> _disposeResidencyReleased = []; private readonly HashSet<uint> _disposeResidencyReleased = [];
private readonly HashSet<uint> _disposeDeleted = []; private readonly HashSet<uint> _disposeDeleted = [];
private long _allocatedBytes;
private long _retiringBytes;
private bool _disposeRequested; private bool _disposeRequested;
private bool _disposing; private bool _disposing;
private bool _disposed; private bool _disposed;
@ -62,6 +64,9 @@ internal sealed class StandaloneBindlessTextureCache : IDisposable
internal int OwnerCount => _owners.OwnerCount; internal int OwnerCount => _owners.OwnerCount;
internal int UnownedEntryCount => _unowned.Count; internal int UnownedEntryCount => _unowned.Count;
internal long UnownedBytes => _unowned.ResidentBytes; internal long UnownedBytes => _unowned.ResidentBytes;
internal long AllocatedBytes => _allocatedBytes;
internal long RetiringBytes => _retiringBytes;
internal long BudgetBytes => _unowned.BudgetBytes;
internal int AwaitingRetirementPublicationCount => internal int AwaitingRetirementPublicationCount =>
_retirementLedger.AwaitingPublicationCount; _retirementLedger.AwaitingPublicationCount;
@ -96,6 +101,7 @@ internal sealed class StandaloneBindlessTextureCache : IDisposable
$"Standalone particle surface 0x{resource.SurfaceId:X8} is already cached."); $"Standalone particle surface 0x{resource.SurfaceId:X8} is already cached.");
_owners.Acquire(ownerId, resource.SurfaceId); _owners.Acquire(ownerId, resource.SurfaceId);
_allocatedBytes = checked(_allocatedBytes + resource.Bytes);
} }
public void ReleaseOwner(uint ownerId) public void ReleaseOwner(uint ownerId)
@ -143,9 +149,14 @@ internal sealed class StandaloneBindlessTextureCache : IDisposable
// The publication ledger owns this release from this point even // The publication ledger owns this release from this point even
// when queue admission or an immediate callback throws. Never // when queue admission or an immediate callback throws. Never
// republish a texture after residency release has started. // republish a texture after residency release has started.
_retiringBytes = checked(_retiringBytes + resource.Bytes);
_retirementLedger.Retire(new RetryableGpuResourceRelease( _retirementLedger.Retire(new RetryableGpuResourceRelease(
() => _backend.MakeNonResident(resource), () => _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); _backend.Delete(resource);
_disposeDeleted.Add(resource.SurfaceId); _disposeDeleted.Add(resource.SurfaceId);
_allocatedBytes = checked(
_allocatedBytes - resource.Bytes);
} }
catch (Exception ex) catch (Exception ex)
{ {

View file

@ -8,6 +8,7 @@ using Silk.NET.OpenGL;
using System.Linq; using System.Linq;
using PixelFormatId = DatReaderWriter.Enums.PixelFormat; using PixelFormatId = DatReaderWriter.Enums.PixelFormat;
using SurfaceType = DatReaderWriter.Enums.SurfaceType; using SurfaceType = DatReaderWriter.Enums.SurfaceType;
using AcDream.App.Rendering.Residency;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -87,8 +88,10 @@ public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable
GL gl, GL gl,
IDatReaderWriter dats, IDatReaderWriter dats,
Wb.BindlessSupport? bindless, Wb.BindlessSupport? bindless,
IGpuResourceRetirementQueue retirementQueue) IGpuResourceRetirementQueue retirementQueue,
ResidencyBudgetOptions? budgets = null)
{ {
budgets ??= ResidencyBudgetOptions.Default;
_gl = gl; _gl = gl;
_dats = dats; _dats = dats;
_bindless = bindless; _bindless = bindless;
@ -103,11 +106,15 @@ public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable
composite = new CompositeTextureArrayCache( composite = new CompositeTextureArrayCache(
gl, gl,
bindless, bindless,
retirementQueue); retirementQueue,
budgets.CompositeUnownedBytes,
budgets.CompositePhysicalBytes);
resources.Add("composite texture cache", composite.Dispose); resources.Add("composite texture cache", composite.Dispose);
particles = new StandaloneBindlessTextureCache( particles = new StandaloneBindlessTextureCache(
new ParticleTextureBackend(this), new ParticleTextureBackend(this),
retirementQueue); retirementQueue,
budgets.StandaloneUnownedBytes,
budgets.StandaloneUnownedEntries);
resources.Add("particle texture cache", particles.Dispose); resources.Add("particle texture cache", particles.Dispose);
resources.TransferAll(); 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> /// <summary>
/// Get or upload the GL texture handle for a Surface id. Returns a /// Get or upload the GL texture handle for a Surface id. Returns a
/// 1x1 magenta fallback if the Surface or its RenderSurface chain is /// 1x1 magenta fallback if the Surface or its RenderSurface chain is

View file

@ -137,6 +137,19 @@ public sealed class GlobalMeshBuffer : IDisposable
|| _retiredCapacityBytes != 0; || _retiredCapacityBytes != 0;
internal int VertexHighWaterMark => _vertices.HighWaterMark; internal int VertexHighWaterMark => _vertices.HighWaterMark;
internal int IndexHighWaterMark => _indices.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) 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() { public void Unbind() {
GL.BindTexture(GLEnum.Texture2DArray, 0); GL.BindTexture(GLEnum.Texture2DArray, 0);
GLHelpers.CheckErrors(GL); GLHelpers.CheckErrors(GL);

View file

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

View file

@ -16,6 +16,7 @@ using System.Runtime.InteropServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AcDream.Content; using AcDream.Content;
using AcDream.App.Rendering.Residency;
using AcDream.Core.Rendering.Wb; using AcDream.Core.Rendering.Wb;
using PixelFormat = Silk.NET.OpenGL.PixelFormat; using PixelFormat = Silk.NET.OpenGL.PixelFormat;
using BoundingBox = Chorizite.Core.Lib.BoundingBox; using BoundingBox = Chorizite.Core.Lib.BoundingBox;
@ -143,8 +144,8 @@ namespace AcDream.App.Rendering.Wb
// LRU Cache for Unused objects // LRU Cache for Unused objects
private readonly LinkedList<ulong> _lruList = new(); private readonly LinkedList<ulong> _lruList = new();
private readonly long _maxGpuMemory = 1024 * 1024 * 1024; // 1GB private readonly long _maxGpuMemory;
private readonly int _maxCachedObjects = 50; // Max number of cached objects (count-based limit) private readonly int _maxCachedObjects;
private long _currentNonArenaGpuMemory; private long _currentNonArenaGpuMemory;
// Shared atlases grouped by (Width, Height, Format) // Shared atlases grouped by (Width, Height, Format)
@ -158,12 +159,15 @@ namespace AcDream.App.Rendering.Wb
private const int RetainedEmptyAtlasCountLimit = 32; private const int RetainedEmptyAtlasCountLimit = 32;
private readonly AcDream.App.Rendering.BoundedUnownedResourceCache<TextureAtlasManager> private readonly AcDream.App.Rendering.BoundedUnownedResourceCache<TextureAtlasManager>
_safeEmptyAtlases = new(RetainedEmptyAtlasBudgetBytes, RetainedEmptyAtlasCountLimit); _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) // 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 CpuMeshUploadCache _cpuMeshCache;
private readonly MeshUploadStagingQueue _stagedMeshData = new(); private readonly MeshUploadStagingQueue _stagedMeshData;
private volatile bool _arenaBackpressured; private volatile bool _arenaBackpressured;
/// <summary>#125: how many times a failed GL upload is re-staged before /// <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; int atlasArrays = 0;
foreach (List<TextureAtlasManager> atlases in _globalAtlases.Values) foreach (List<TextureAtlasManager> atlases in _globalAtlases.Values)
atlasArrays += atlases.Count; atlasArrays += atlases.Count;
long atlasBytes = CalculateAtlasBytes(_globalAtlases.Values);
long retiringAtlasBytes = CalculateAtlasBytes(_retiringAtlases);
long physicalBytes = CalculateTrackedGpuBytes( long physicalBytes = CalculateTrackedGpuBytes(
_currentNonArenaGpuMemory, checked(
_currentNonArenaGpuMemory
+ atlasBytes
+ retiringAtlasBytes),
GlobalBuffer?.PhysicalCapacityBytes ?? 0); GlobalBuffer?.PhysicalCapacityBytes ?? 0);
return (_renderData.Count, atlasArrays, _lruList.Count, physicalBytes); return (_renderData.Count, atlasArrays, _lruList.Count, physicalBytes);
} }
@ -400,15 +409,24 @@ namespace AcDream.App.Rendering.Wb
public ObjectMeshManager( public ObjectMeshManager(
OpenGLGraphicsDevice graphicsDevice, OpenGLGraphicsDevice graphicsDevice,
IPreparedAssetSource preparedAssets, IPreparedAssetSource preparedAssets,
ILogger<ObjectMeshManager> logger) ILogger<ObjectMeshManager> logger,
ResidencyBudgetOptions? budgets = null)
{ {
budgets ??= ResidencyBudgetOptions.Default;
_graphicsDevice = graphicsDevice _graphicsDevice = graphicsDevice
?? throw new ArgumentNullException(nameof(graphicsDevice)); ?? throw new ArgumentNullException(nameof(graphicsDevice));
_preparedAssets = preparedAssets _preparedAssets = preparedAssets
?? throw new ArgumentNullException(nameof(preparedAssets)); ?? throw new ArgumentNullException(nameof(preparedAssets));
_logger = logger _logger = logger
?? throw new ArgumentNullException(nameof(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; _useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless;
if (_useModernRendering) if (_useModernRendering)
{ {
@ -506,6 +524,7 @@ namespace AcDream.App.Rendering.Wb
/// </summary> /// </summary>
internal bool EvictOneEmptyAtlas() internal bool EvictOneEmptyAtlas()
{ {
RetryRetiringAtlasDisposals();
if (!_safeEmptyAtlases.TryTakeOldestOverBudget(out TextureAtlasManager victim)) if (!_safeEmptyAtlases.TryTakeOldestOverBudget(out TextureAtlasManager victim))
return false; return false;
@ -517,10 +536,28 @@ namespace AcDream.App.Rendering.Wb
_globalAtlases.Remove(key); _globalAtlases.Remove(key);
} }
_dirtyAtlases.Remove(victim); _dirtyAtlases.Remove(victim);
_retiringAtlases.Add(victim);
victim.Dispose(); victim.Dispose();
RemoveCompletedAtlasRetirements();
return true; 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) private void OnAtlasGpuSafeEmpty(TextureAtlasManager atlas)
{ {
if (IsDisposed || !atlas.IsGpuSafeEmpty || _safeEmptyAtlases.Contains(atlas)) if (IsDisposed || !atlas.IsGpuSafeEmpty || _safeEmptyAtlases.Contains(atlas))
@ -637,6 +674,7 @@ namespace AcDream.App.Rendering.Wb
{ {
ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1); ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1); ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1);
RetryRetiringAtlasDisposals();
RetryPendingAtlasRetirements(); RetryPendingAtlasRetirements();
// Rollbacks own unpublished resources and therefore have no LRU // Rollbacks own unpublished resources and therefore have no LRU
// node to wake them. Advance a bounded snapshot every render tick // node to wake them. Advance a bounded snapshot every render tick
@ -649,6 +687,9 @@ namespace AcDream.App.Rendering.Wb
lock (_lruList) lock (_lruList)
candidateBudget = _lruList.Count; candidateBudget = _lruList.Count;
int attemptedCandidates = 0; int attemptedCandidates = 0;
long trackedAtlasBytes = checked(
CalculateAtlasBytes(_globalAtlases.Values)
+ CalculateAtlasBytes(_retiringAtlases));
while (reclaimedCount < maximumCount while (reclaimedCount < maximumCount
&& attemptedCandidates < candidateBudget) && attemptedCandidates < candidateBudget)
@ -657,9 +698,12 @@ namespace AcDream.App.Rendering.Wb
lock (_lruList) lock (_lruList)
{ {
long physicalArenaBytes = GlobalBuffer?.PhysicalCapacityBytes ?? 0; long physicalArenaBytes = GlobalBuffer?.PhysicalCapacityBytes ?? 0;
long nonArenaAndAtlasBytes = checked(
_currentNonArenaGpuMemory
+ trackedAtlasBytes);
if (!forceArenaReclamation if (!forceArenaReclamation
&& IsWithinGpuCacheBudget( && IsWithinGpuCacheBudget(
_currentNonArenaGpuMemory, nonArenaAndAtlasBytes,
physicalArenaBytes, physicalArenaBytes,
_maxGpuMemory) _maxGpuMemory)
&& _lruList.Count <= _maxCachedObjects) && _lruList.Count <= _maxCachedObjects)
@ -1207,6 +1251,105 @@ namespace AcDream.App.Rendering.Wb
return _preparedAssets.Read(request, ct).Data; 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> /// <summary>
/// Cancel preparation tasks for IDs that are no longer needed. /// Cancel preparation tasks for IDs that are no longer needed.
/// </summary> /// </summary>
@ -2545,6 +2688,7 @@ namespace AcDream.App.Rendering.Wb
// Every layer release has now committed logically. Publish any // Every layer release has now committed logically. Publish any
// callback which previously failed before queue acceptance. // callback which previously failed before queue acceptance.
Capture(ref failures, RetryPendingAtlasRetirements); Capture(ref failures, RetryPendingAtlasRetirements);
Capture(ref failures, RetryRetiringAtlasDisposals);
bool atlasFailure = false; bool atlasFailure = false;
foreach (List<TextureAtlasManager> atlasList in _globalAtlases.Values) foreach (List<TextureAtlasManager> atlasList in _globalAtlases.Values)
@ -2577,6 +2721,7 @@ namespace AcDream.App.Rendering.Wb
_uploadRollbacks.Clear(); _uploadRollbacks.Clear();
_uploadRollbackQueue.Clear(); _uploadRollbackQueue.Clear();
_globalAtlases.Clear(); _globalAtlases.Clear();
_retiringAtlases.Clear();
_dirtyAtlases.Clear(); _dirtyAtlases.Clear();
_safeEmptyAtlases.Clear(); _safeEmptyAtlases.Clear();
_currentNonArenaGpuMemory = 0; _currentNonArenaGpuMemory = 0;

View file

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

View file

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

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using AcDream.Content; using AcDream.Content;
using AcDream.App.Rendering.Residency;
using AcDream.Core.Rendering; using AcDream.Core.Rendering;
using DatReaderWriter; using DatReaderWriter;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -108,7 +109,8 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
preparedAssets: null, preparedAssets: null,
logger, logger,
AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance,
ownsPreparedAssets: true) ownsPreparedAssets: true,
ResidencyBudgetOptions.Default)
{ {
} }
@ -123,21 +125,24 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
preparedAssets: null, preparedAssets: null,
logger, logger,
resourceRetirement, resourceRetirement,
ownsPreparedAssets: true); ownsPreparedAssets: true,
ResidencyBudgetOptions.Default);
internal WbMeshAdapter( internal WbMeshAdapter(
GL gl, GL gl,
IDatReaderWriter dats, IDatReaderWriter dats,
IPreparedAssetSource preparedAssets, IPreparedAssetSource preparedAssets,
ILogger<WbMeshAdapter> logger, ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement) AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
ResidencyBudgetOptions? budgets = null)
: this( : this(
gl, gl,
dats, dats,
preparedAssets, preparedAssets,
logger, logger,
resourceRetirement, resourceRetirement,
ownsPreparedAssets: false) ownsPreparedAssets: false,
budgets ?? ResidencyBudgetOptions.Default)
{ {
} }
@ -147,11 +152,13 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
IPreparedAssetSource? preparedAssets, IPreparedAssetSource? preparedAssets,
ILogger<WbMeshAdapter> logger, ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement, AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
bool ownsPreparedAssets) bool ownsPreparedAssets,
ResidencyBudgetOptions budgets)
{ {
ArgumentNullException.ThrowIfNull(gl); ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(budgets);
_resourceRetirement = resourceRetirement; _resourceRetirement = resourceRetirement;
var resources = new AcDream.App.Rendering.ResourceCleanupGroup(); var resources = new AcDream.App.Rendering.ResourceCleanupGroup();
@ -192,7 +199,8 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
meshManager = new ObjectMeshManager( meshManager = new ObjectMeshManager(
graphicsDevice, graphicsDevice,
resolvedPreparedAssets, resolvedPreparedAssets,
new ConsoleErrorLogger<ObjectMeshManager>()); new ConsoleErrorLogger<ObjectMeshManager>(),
budgets);
resources.Add("WB object mesh manager", meshManager.Dispose); resources.Add("WB object mesh manager", meshManager.Dispose);
resources.TransferAll(); resources.TransferAll();
} }
@ -210,6 +218,26 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
: null; : 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> /// <summary>
/// Minimal Console-backed logger that fires only on /// Minimal Console-backed logger that fires only on
/// <see cref="LogLevel.Error"/> and above. Format: /// <see cref="LogLevel.Error"/> and above. Format:

View file

@ -119,6 +119,12 @@ public interface IPreparedAssetSource : IDisposable
PreparedAssetSourceStats Stats { get; } PreparedAssetSourceStats Stats { get; }
CacheStats DecodedTextureCacheStats { 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 internal static class PreparedAssetRequestContract
@ -211,6 +217,8 @@ public sealed class PakPreparedAssetSource : IPreparedAssetSource
public CacheStats DecodedTextureCacheStats => default; public CacheStats DecodedTextureCacheStats => default;
public long MappedVirtualBytes => _reader.FileLength;
public PreparedAssetPresence Probe( public PreparedAssetPresence Probe(
PakAssetType type, PakAssetType type,
uint sourceFileId) uint sourceFileId)

View file

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

View file

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

View file

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

View file

@ -6,6 +6,40 @@ namespace AcDream.App.Tests.Rendering;
public sealed class CompositeTextureArrayCachePolicyTests 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] [Theory]
[InlineData(16, 16, 64)] [InlineData(16, 16, 64)]
[InlineData(128, 128, 64)] [InlineData(128, 128, 64)]

View file

@ -343,6 +343,60 @@ public sealed class ResidencyManagerTests
Assert.Equal(1, journal.Count); 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( private static AssetHandle<MeshAsset> Resident(
ResidencyManager manager, ResidencyManager manager,
ulong id, ulong id,

View file

@ -52,6 +52,8 @@ public sealed class StandaloneBindlessTextureCacheTests
cache.Tick(); cache.Tick();
Assert.Equal(2, cache.EntryCount); Assert.Equal(2, cache.EntryCount);
Assert.Equal(12, cache.AllocatedBytes);
Assert.Equal(4, cache.RetiringBytes);
Assert.Equal(2, cache.UnownedEntryCount); Assert.Equal(2, cache.UnownedEntryCount);
Assert.Single(retirements.Actions); Assert.Single(retirements.Actions);
Assert.Empty(backend.Events); Assert.Empty(backend.Events);
@ -60,6 +62,8 @@ public sealed class StandaloneBindlessTextureCacheTests
retirements.Drain(); retirements.Drain();
Assert.Equal(["nonresident:1", "delete:1"], backend.Events); Assert.Equal(["nonresident:1", "delete:1"], backend.Events);
Assert.Equal(8, cache.AllocatedBytes);
Assert.Equal(0, cache.RetiringBytes);
} }
[Fact] [Fact]
@ -196,6 +200,32 @@ public sealed class StandaloneBindlessTextureCacheTests
backend.Events); 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] [Fact]
public void FailedResidencyReleaseNeverDeletesThatBackingTexture() public void FailedResidencyReleaseNeverDeletesThatBackingTexture()
{ {