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

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