perf(render): bound animation and alpha scratch residency

Complete Slice D3 by replacing the unbounded animation dictionary with a concurrent byte/count LRU and by putting the three retail alpha scratch owners behind one typed aggregate budget. Preserve immediate growth and draw order while reclaiming one-frame density spikes after sustained under-use. Close stale bounds-cache issue evidence without inventing a cache.
This commit is contained in:
Erik 2026-07-24 16:34:28 +02:00
parent 3e18fc2730
commit f2644d42c2
18 changed files with 857 additions and 34 deletions

View file

@ -360,7 +360,7 @@ ack-per-packet).
## #240 — RetailAnimationLoader caches every parsed Animation forever ## #240 — RetailAnimationLoader caches every parsed Animation forever
**Status:** OPEN **Status:** DONE — 2026-07-24, Modern Runtime Slice D3
**Severity:** MEDIUM **Severity:** MEDIUM
**Filed:** 2026-07-24 **Filed:** 2026-07-24
**Component:** content / animation **Component:** content / animation
@ -373,12 +373,14 @@ creature/object motion tables grows this monotonically — same class as the
audio caches bounded on 2026-07-24, and structurally incapable of the audio caches bounded on 2026-07-24, and structurally incapable of the
plateau behavior the audit measured for entities/particles. plateau behavior the audit measured for entities/particles.
**Root cause / status:** 2026-07-24 audit review finding. Bound with the **Fix:** `RetailAnimationLoader` now has one concurrent in-flight gate plus a
same LRU/byte-budget shape as `DatSoundCache` (2026-07-24) / 64 MiB / 512-entry LRU (startup-configurable through
`BoundedDatObjectCache`. Note sequencers retain references to loaded `ResidencyBudgetOptions`). Duplicate requests coalesce, unrelated reads remain
animations — eviction must not invalidate an Animation a live sequencer parallel, null and oversize results cannot grow the cache, and eviction only
still holds (they keep the object alive; the cache only drops its own drops the cache reference. Live sequencers therefore keep their immutable
reference). `Animation` object valid. Deterministic count, byte-pressure, oversize, LRU,
and concurrent-load tests cover the policy; the unified residency snapshot
reports decoded bytes, budget, and evictions.
**Files:** `src/AcDream.Content/Vfx/RetailAnimationLoader.cs:19-53`. **Files:** `src/AcDream.Content/Vfx/RetailAnimationLoader.cs:19-53`.
@ -435,7 +437,7 @@ the ordered array on the publication object.
## #243 — ObjectMeshManager._boundsCache grows unbounded ## #243 — ObjectMeshManager._boundsCache grows unbounded
**Status:** OPEN **Status:** DONE — 2026-07-24, stale audit finding closed by Slice D3
**Severity:** LOW **Severity:** LOW
**Filed:** 2026-07-24 **Filed:** 2026-07-24
**Component:** render **Component:** render
@ -446,8 +448,12 @@ but the one unbounded container in a class where every other cache is
deliberately budgeted, and another no-plateau curve for long-uptime deliberately budgeted, and another no-plateau curve for long-uptime
sessions. sessions.
**Root cause / status:** 2026-07-24 audit review finding. Bound with the **Resolution:** The named field and insertion site do not exist in the current
existing entry-count LRU shape; plan Slice D's accounting covers it. tree: a repository-wide search finds no `_boundsCache` and
`ObjectMeshManager` has no bounds memoization owner. The review appears to
have described an older or different tree. Slice D deliberately does not add
a cache merely to bound it; current mesh/prepared/staging/arena owners are all
reported through the residency ledger.
**Files:** `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs` (`_boundsCache`, **Files:** `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs` (`_boundsCache`,
insert site ~1757). insert site ~1757).

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 — D1 and D2 complete **Status:** active — D1 through D3 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
@ -227,6 +227,19 @@ residence.
- Bring deferred-alpha retained scratch behind a bounded capacity owner. - Bring deferred-alpha retained scratch behind a bounded capacity owner.
- Close stale #243 after proving no current bounds cache exists. - Close stale #243 after proving no current bounds cache exists.
**Complete 2026-07-24.** `RetailAnimationLoader` now uses a concurrent
in-flight load gate feeding a byte-and-count bounded LRU. Cache eviction drops
only the cache reference, so active sequencers retain valid immutable
animations; duplicate DID requests coalesce while unrelated reads remain
parallel. The shared retail alpha path now partitions its 16 MiB startup budget
between the queue, object dispatcher, and particle renderer. Each physical
owner grows immediately when a frame requires it, reports exact retained
backing capacity, and shrinks only after repeated severe under-use. This bounds
one-frame density spikes without changing draw order, particle content, or
visible quality. Issue #240 is closed. Issue #243 is closed as a stale audit
finding: no `_boundsCache`, bounds memo, or cited insertion site exists in the
current source, so no replacement cache was invented.
### D4 — Diagnostics and forced pressure ### D4 — Diagnostics and forced pressure
- Publish aggregate and per-domain budgets/occupancy/fragmentation through the - Publish aggregate and per-domain budgets/occupancy/fragmentation through the

View file

@ -1,6 +1,7 @@
using AcDream.App.Audio; using AcDream.App.Audio;
using AcDream.App.Physics; using AcDream.App.Physics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.App.Spells; using AcDream.App.Spells;
using AcDream.Content; using AcDream.Content;
@ -43,6 +44,7 @@ internal sealed record ContentEffectsAudioResult(
internal sealed record ContentEffectsAudioDependencies( internal sealed record ContentEffectsAudioDependencies(
string DatDirectory, string DatDirectory,
string PreparedAssetPath, string PreparedAssetPath,
ResidencyBudgetOptions ResidencyBudgets,
PhysicsDataCache PhysicsDataCache, PhysicsDataCache PhysicsDataCache,
bool DumpMotionEnabled, bool DumpMotionEnabled,
Spellbook SpellBook, Spellbook SpellBook,
@ -84,7 +86,10 @@ internal interface IContentEffectsAudioCompositionFactory
MagicCatalog LoadMagicCatalog(IDatReaderWriter dats); MagicCatalog LoadMagicCatalog(IDatReaderWriter dats);
void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog); void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog);
int GetSpellCount(MagicCatalog catalog); int GetSpellCount(MagicCatalog catalog);
IAnimationLoader CreateAnimationLoader(IDatReaderWriter dats); IAnimationLoader CreateAnimationLoader(
IDatReaderWriter dats,
long maximumEstimatedBytes,
int maximumEntries);
LiveEntityCollisionBuilder CreateCollisionBuilder( LiveEntityCollisionBuilder CreateCollisionBuilder(
PhysicsDataCache physicsData, PhysicsDataCache physicsData,
IDatReaderWriter dats, IDatReaderWriter dats,
@ -137,8 +142,14 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
public int GetSpellCount(MagicCatalog catalog) => catalog.SpellTable.Count; public int GetSpellCount(MagicCatalog catalog) => catalog.SpellTable.Count;
public IAnimationLoader CreateAnimationLoader(IDatReaderWriter dats) => public IAnimationLoader CreateAnimationLoader(
new RetailAnimationLoader(dats); IDatReaderWriter dats,
long maximumEstimatedBytes,
int maximumEntries) =>
new RetailAnimationLoader(
dats,
maximumEstimatedBytes,
maximumEntries);
public LiveEntityCollisionBuilder CreateCollisionBuilder( public LiveEntityCollisionBuilder CreateCollisionBuilder(
PhysicsDataCache physicsData, PhysicsDataCache physicsData,
@ -302,7 +313,10 @@ internal sealed class ContentEffectsAudioCompositionPhase :
$"spells: loaded {_factory.GetSpellCount(magic)} entries from portal.dat"); $"spells: loaded {_factory.GetSpellCount(magic)} entries from portal.dat");
Fault(ContentEffectsAudioCompositionPoint.SpellMetadataInstalled); Fault(ContentEffectsAudioCompositionPoint.SpellMetadataInstalled);
IAnimationLoader animations = _factory.CreateAnimationLoader(dats); IAnimationLoader animations = _factory.CreateAnimationLoader(
dats,
_dependencies.ResidencyBudgets.AnimationBytes,
_dependencies.ResidencyBudgets.AnimationEntries);
_publication.PublishAnimationLoader(animations); _publication.PublishAnimationLoader(animations);
Fault(ContentEffectsAudioCompositionPoint.AnimationLoaderPublished); Fault(ContentEffectsAudioCompositionPoint.AnimationLoaderPublished);

View file

@ -5,6 +5,7 @@ using AcDream.App.Input;
using AcDream.App.Interaction; using AcDream.App.Interaction;
using AcDream.App.Physics; using AcDream.App.Physics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Selection; using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Sky; using AcDream.App.Rendering.Sky;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
@ -586,6 +587,9 @@ internal sealed class LivePresentationCompositionPhase
{ {
LivePresentationDependencies d = _dependencies; LivePresentationDependencies d = _dependencies;
WorldRenderFoundation foundation = world.Foundation; WorldRenderFoundation foundation = world.Foundation;
AlphaScratchBudgetProfile alphaScratchBudgets =
AlphaScratchBudgetProfile.Create(
d.Options.ResidencyBudgets.AlphaScratchBytes);
var selectionScene = new RetailSelectionScene( var selectionScene = new RetailSelectionScene(
new RetailSelectionGeometryCache(content.Dats, d.DatLock)); new RetailSelectionGeometryCache(content.Dats, d.DatLock));
@ -601,7 +605,8 @@ internal sealed class LivePresentationCompositionPhase
d.ClassificationCache, d.ClassificationCache,
d.TranslucencyFades, d.TranslucencyFades,
selectionScene, selectionScene,
d.RetailAlphaQueue), d.RetailAlphaQueue,
alphaScratchBudgets.DispatcherBytes),
static value => value.Dispose()); static value => value.Dispose());
var selectionQuery = new WorldSelectionQuery( var selectionQuery = new WorldSelectionQuery(
liveEntities, liveEntities,
@ -883,7 +888,8 @@ internal sealed class LivePresentationCompositionPhase
foundation.TextureCache, foundation.TextureCache,
content.Dats, content.Dats,
foundation.MeshAdapter, foundation.MeshAdapter,
d.RetailAlphaQueue), d.RetailAlphaQueue,
alphaScratchBudgets.ParticleBytes),
static value => value.Dispose()); static value => value.Dispose());
Fault(LivePresentationCompositionPoint.SkyAndParticlesCreated); Fault(LivePresentationCompositionPoint.SkyAndParticlesCreated);
@ -966,6 +972,19 @@ internal sealed class LivePresentationCompositionPhase
frameDiagnostics, frameDiagnostics,
bindings, bindings,
landblockLoaded); landblockLoaded);
foundation.Residency.RegisterDomainSource(
new DelegateResidencyDomainSource(
ResidencyDomain.AlphaScratch,
() => new ResidencyDomainSnapshot(
ResidencyDomain.AlphaScratch,
EntryCount: 3,
OwnerCount: 3,
Charges: new ResidencyCharges(
ScratchBytes: checked(
d.RetailAlphaQueue.RetainedScratchBytes
+ dispatcherLease.Resource.RetainedAlphaScratchBytes
+ particleLease.Resource.RetainedAlphaScratchBytes)),
BudgetBytes: alphaScratchBudgets.TotalBytes)));
_publication.PublishLivePresentation(result); _publication.PublishLivePresentation(result);
liveRuntimeLease.Transfer(); liveRuntimeLease.Transfer();

View file

@ -4,6 +4,8 @@ using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Residency;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Content; using AcDream.Content;
using AcDream.Content.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.Terrain; using AcDream.Core.Terrain;
using AcDream.UI.Abstractions.Settings; using AcDream.UI.Abstractions.Settings;
using DatReaderWriter; using DatReaderWriter;
@ -116,7 +118,8 @@ internal interface IWorldRenderCompositionFactory
ResidencyManager manager, ResidencyManager manager,
WbMeshAdapter meshes, WbMeshAdapter meshes,
TextureCache textures, TextureCache textures,
IPreparedAssetSource preparedAssets); IPreparedAssetSource preparedAssets,
IAnimationLoader animations);
SamplerCache CreateSamplerCache(GL gl); SamplerCache CreateSamplerCache(GL gl);
void Release(IDisposable resource); void Release(IDisposable resource);
} }
@ -295,7 +298,8 @@ internal sealed class RetailWorldRenderCompositionFactory
ResidencyManager manager, ResidencyManager manager,
WbMeshAdapter meshes, WbMeshAdapter meshes,
TextureCache textures, TextureCache textures,
IPreparedAssetSource preparedAssets) IPreparedAssetSource preparedAssets,
IAnimationLoader animations)
{ {
meshes.RegisterResidencySources(manager); meshes.RegisterResidencySources(manager);
textures.RegisterResidencySources(manager); textures.RegisterResidencySources(manager);
@ -308,6 +312,25 @@ internal sealed class RetailWorldRenderCompositionFactory
Charges: new ResidencyCharges( Charges: new ResidencyCharges(
MappedVirtualBytes: MappedVirtualBytes:
preparedAssets.MappedVirtualBytes)))); preparedAssets.MappedVirtualBytes))));
if (animations is RetailAnimationLoader retailAnimations)
{
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.Animations,
() =>
{
AnimationCacheDiagnostics diagnostics =
retailAnimations.Diagnostics;
return new ResidencyDomainSnapshot(
ResidencyDomain.Animations,
EntryCount: diagnostics.Count,
OwnerCount: 0,
Charges: new ResidencyCharges(
DecodedBytes:
diagnostics.EstimatedBytes),
BudgetBytes: diagnostics.BudgetBytes,
Evictions: diagnostics.Stats.Evictions);
}));
}
} }
public SamplerCache CreateSamplerCache(GL gl) => new(gl); public SamplerCache CreateSamplerCache(GL gl) => new(gl);
@ -493,7 +516,8 @@ internal sealed class WorldRenderCompositionPhase
residency, residency,
meshAdapter, meshAdapter,
textureCache, textureCache,
content.PreparedAssets); content.PreparedAssets,
content.AnimationLoader);
scope.Complete(); scope.Complete();
_dependencies.Log( _dependencies.Log(

View file

@ -281,7 +281,7 @@ public sealed class GameWindow :
private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader; private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader;
private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects; private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects;
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer; private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue = new(); private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue;
// Remote-entity motion inference: tracks when each remote entity last // Remote-entity motion inference: tracks when each remote entity last
// moved meaningfully. Used in TickAnimations to swap to Ready when // moved meaningfully. Used in TickAnimations to swap to Ready when
// position has stalled for >StopIdleMs — retail observer pattern per // position has stalled for >StopIdleMs — retail observer pattern per
@ -564,6 +564,11 @@ public sealed class GameWindow :
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
{ {
_options = options ?? throw new System.ArgumentNullException(nameof(options)); _options = options ?? throw new System.ArgumentNullException(nameof(options));
var alphaScratchBudgets =
AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create(
_options.ResidencyBudgets.AlphaScratchBytes);
_retailAlphaQueue = new AcDream.App.Rendering.RetailAlphaQueue(
alphaScratchBudgets.QueueBytes);
WorldTime = _worldEnvironment.WorldTime; WorldTime = _worldEnvironment.WorldTime;
Weather = _worldEnvironment.Weather; Weather = _worldEnvironment.Weather;
_retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot(); _retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot();
@ -1141,6 +1146,7 @@ public sealed class GameWindow :
new ContentEffectsAudioDependencies( new ContentEffectsAudioDependencies(
_datDir, _datDir,
_options.PreparedAssetPath, _options.PreparedAssetPath,
_options.ResidencyBudgets,
_physicsDataCache, _physicsDataCache,
_animationDiagnostics.DumpMotionEnabled, _animationDiagnostics.DumpMotionEnabled,
SpellBook, SpellBook,

View file

@ -1,7 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.Content; using AcDream.Content;
using AcDream.Core.Meshing; using AcDream.Core.Meshing;
@ -181,6 +183,13 @@ public sealed unsafe class ParticleRenderer : IDisposable
private DeferredParticleDraw[] _preparedAlpha = new DeferredParticleDraw[256]; private DeferredParticleDraw[] _preparedAlpha = new DeferredParticleDraw[256];
private uint[] _preparedInstanceOffsets = new uint[256]; private uint[] _preparedInstanceOffsets = new uint[256];
private int _preparedAlphaCount; private int _preparedAlphaCount;
private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy;
internal long AlphaScratchBudgetBytes => _alphaScratchPolicy.BudgetBytes;
internal long RetainedAlphaScratchBytes => checked(
(long)_deferredAlpha.Capacity * Unsafe.SizeOf<DeferredParticleDraw>()
+ (long)_preparedAlpha.Length * Unsafe.SizeOf<DeferredParticleDraw>()
+ (long)_preparedInstanceOffsets.Length * sizeof(uint));
private sealed class AlphaDrawSource(ParticleRenderer owner) : IRetailAlphaDrawSource private sealed class AlphaDrawSource(ParticleRenderer owner) : IRetailAlphaDrawSource
{ {
@ -201,7 +210,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
TextureCache? textures = null, TextureCache? textures = null,
IDatReaderWriter? dats = null, IDatReaderWriter? dats = null,
WbMeshAdapter? meshAdapter = null, WbMeshAdapter? meshAdapter = null,
RetailAlphaQueue? alphaQueue = null) RetailAlphaQueue? alphaQueue = null,
long? alphaScratchBudgetBytes = null)
{ {
_gl = gl ?? throw new ArgumentNullException(nameof(gl)); _gl = gl ?? throw new ArgumentNullException(nameof(gl));
_textures = textures; _textures = textures;
@ -210,6 +220,11 @@ public sealed unsafe class ParticleRenderer : IDisposable
_particles = particles ?? throw new ArgumentNullException(nameof(particles)); _particles = particles ?? throw new ArgumentNullException(nameof(particles));
_alphaQueue = alphaQueue; _alphaQueue = alphaQueue;
_alphaSource = new AlphaDrawSource(this); _alphaSource = new AlphaDrawSource(this);
long scratchBudget = alphaScratchBudgetBytes
?? AlphaScratchBudgetProfile.Create(
ResidencyBudgetOptions.Default.AlphaScratchBytes).ParticleBytes;
_alphaScratchPolicy =
new RetainedScratchCapacityPolicy(scratchBudget);
if (_meshAdapter is not null) if (_meshAdapter is not null)
{ {
_meshReferences = new ParticleMeshReferenceTracker( _meshReferences = new ParticleMeshReferenceTracker(
@ -732,8 +747,30 @@ public sealed unsafe class ParticleRenderer : IDisposable
private void ResetDeferredAlpha() private void ResetDeferredAlpha()
{ {
int observedCount = Math.Max(
_deferredAlpha.Count,
_preparedAlphaCount);
_deferredAlpha.Clear(); _deferredAlpha.Clear();
_preparedAlphaCount = 0; _preparedAlphaCount = 0;
int currentCapacity = Math.Max(
_deferredAlpha.Capacity,
Math.Max(
_preparedAlpha.Length,
_preparedInstanceOffsets.Length));
int bytesPerDraw = checked(
2 * Unsafe.SizeOf<DeferredParticleDraw>() + sizeof(uint));
int targetCapacity = _alphaScratchPolicy.ObserveAndSelectCapacity(
currentCapacity,
observedCount,
bytesPerDraw,
minimumCapacity: 256,
growthQuantum: 256);
if (targetCapacity >= currentCapacity)
return;
_deferredAlpha.Capacity = targetCapacity;
Array.Resize(ref _preparedAlpha, targetCapacity);
Array.Resize(ref _preparedInstanceOffsets, targetCapacity);
} }
private void PrepareBillboardPipeline(Matrix4x4 viewProjection) private void PrepareBillboardPipeline(Matrix4x4 viewProjection)

View file

@ -84,6 +84,7 @@ internal readonly record struct ResidencyCharges(
long LogicalBytes = 0, long LogicalBytes = 0,
long CpuPreparedBytes = 0, long CpuPreparedBytes = 0,
long DecodedBytes = 0, long DecodedBytes = 0,
long ScratchBytes = 0,
long PinnedBytes = 0, long PinnedBytes = 0,
long StagingBytes = 0, long StagingBytes = 0,
long GpuRequestedBytes = 0, long GpuRequestedBytes = 0,
@ -97,6 +98,7 @@ internal readonly record struct ResidencyCharges(
LogicalBytes LogicalBytes
+ CpuPreparedBytes + CpuPreparedBytes
+ DecodedBytes + DecodedBytes
+ ScratchBytes
+ StagingBytes); + StagingBytes);
public long PhysicalGpuBytes => checked( public long PhysicalGpuBytes => checked(
@ -111,6 +113,7 @@ internal readonly record struct ResidencyCharges(
ArgumentOutOfRangeException.ThrowIfNegative(LogicalBytes); ArgumentOutOfRangeException.ThrowIfNegative(LogicalBytes);
ArgumentOutOfRangeException.ThrowIfNegative(CpuPreparedBytes); ArgumentOutOfRangeException.ThrowIfNegative(CpuPreparedBytes);
ArgumentOutOfRangeException.ThrowIfNegative(DecodedBytes); ArgumentOutOfRangeException.ThrowIfNegative(DecodedBytes);
ArgumentOutOfRangeException.ThrowIfNegative(ScratchBytes);
ArgumentOutOfRangeException.ThrowIfNegative(PinnedBytes); ArgumentOutOfRangeException.ThrowIfNegative(PinnedBytes);
ArgumentOutOfRangeException.ThrowIfNegative(StagingBytes); ArgumentOutOfRangeException.ThrowIfNegative(StagingBytes);
ArgumentOutOfRangeException.ThrowIfNegative(GpuRequestedBytes); ArgumentOutOfRangeException.ThrowIfNegative(GpuRequestedBytes);
@ -126,6 +129,7 @@ internal readonly record struct ResidencyCharges(
checked(left.LogicalBytes + right.LogicalBytes), checked(left.LogicalBytes + right.LogicalBytes),
checked(left.CpuPreparedBytes + right.CpuPreparedBytes), checked(left.CpuPreparedBytes + right.CpuPreparedBytes),
checked(left.DecodedBytes + right.DecodedBytes), checked(left.DecodedBytes + right.DecodedBytes),
checked(left.ScratchBytes + right.ScratchBytes),
checked(left.PinnedBytes + right.PinnedBytes), checked(left.PinnedBytes + right.PinnedBytes),
checked(left.StagingBytes + right.StagingBytes), checked(left.StagingBytes + right.StagingBytes),
checked(left.GpuRequestedBytes + right.GpuRequestedBytes), checked(left.GpuRequestedBytes + right.GpuRequestedBytes),

View file

@ -0,0 +1,105 @@
namespace AcDream.App.Rendering.Residency;
/// <summary>
/// Splits the one deferred-alpha scratch ceiling between the three physical
/// owners. The final share receives the division remainder so the aggregate
/// remains exact for every valid startup budget.
/// </summary>
internal readonly record struct AlphaScratchBudgetProfile(
long QueueBytes,
long DispatcherBytes,
long ParticleBytes)
{
public long TotalBytes => checked(
QueueBytes + DispatcherBytes + ParticleBytes);
public static AlphaScratchBudgetProfile Create(long totalBytes)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(totalBytes);
long queue = totalBytes / 4;
long particles = totalBytes / 4;
long dispatcher = checked(totalBytes - queue - particles);
return new AlphaScratchBudgetProfile(queue, dispatcher, particles);
}
}
/// <summary>
/// Capacity retention policy for render-thread scratch storage. Growth is
/// always immediate at the physical owner. This policy only recommends a
/// smaller warmed capacity after several completed low-demand observations,
/// preventing one dense alpha frame from pinning its high-water arrays for the
/// rest of the process without introducing an elapsed-time workaround.
/// </summary>
internal sealed class RetainedScratchCapacityPolicy(
long budgetBytes,
int lowDemandSamplesBeforeShrink = 3)
{
private readonly long _budgetBytes =
budgetBytes > 0
? budgetBytes
: throw new ArgumentOutOfRangeException(nameof(budgetBytes));
private readonly int _lowDemandSamplesBeforeShrink =
lowDemandSamplesBeforeShrink > 0
? lowDemandSamplesBeforeShrink
: throw new ArgumentOutOfRangeException(
nameof(lowDemandSamplesBeforeShrink));
private int _lowDemandSamples;
public long BudgetBytes => _budgetBytes;
public int ObserveAndSelectCapacity(
int currentCapacity,
int requiredCapacity,
int bytesPerUnit,
int minimumCapacity,
int growthQuantum)
{
ArgumentOutOfRangeException.ThrowIfNegative(currentCapacity);
ArgumentOutOfRangeException.ThrowIfNegative(requiredCapacity);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytesPerUnit);
ArgumentOutOfRangeException.ThrowIfNegative(minimumCapacity);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(growthQuantum);
if (requiredCapacity > currentCapacity)
{
_lowDemandSamples = 0;
return RoundUp(requiredCapacity, growthQuantum);
}
int budgetCapacity = checked((int)Math.Min(
int.MaxValue,
Math.Max(minimumCapacity, _budgetBytes / bytesPerUnit)));
bool demandHasFallen =
currentCapacity > budgetCapacity
&& requiredCapacity <= budgetCapacity
&& (requiredCapacity == 0
|| (long)currentCapacity >= (long)requiredCapacity * 4);
if (!demandHasFallen)
{
_lowDemandSamples = 0;
return currentCapacity;
}
_lowDemandSamples++;
if (_lowDemandSamples < _lowDemandSamplesBeforeShrink)
return currentCapacity;
_lowDemandSamples = 0;
long warmedDemand = Math.Max(
minimumCapacity,
Math.Min(int.MaxValue, (long)requiredCapacity * 2));
int target = RoundUp(
checked((int)Math.Min(warmedDemand, budgetCapacity)),
growthQuantum);
target = Math.Max(requiredCapacity, Math.Min(target, budgetCapacity));
return Math.Min(currentCapacity, target);
}
private static int RoundUp(int value, int quantum)
{
if (value == 0)
return 0;
long rounded = ((long)value + quantum - 1) / quantum * quantum;
return checked((int)Math.Min(int.MaxValue, rounded));
}
}

View file

@ -2,7 +2,9 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using System.Runtime.ExceptionServices; using System.Runtime.ExceptionServices;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using AcDream.App.Rendering.Residency;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -68,15 +70,42 @@ internal interface IWorldSceneAlphaFrame
internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
{ {
private const int MinimumSubmissionCapacity = 256;
private const int SubmissionGrowthQuantum = 256;
private const int MinimumSourceCapacity = 4;
private readonly List<RetailAlphaSubmission> _submissions = new(256); private readonly List<RetailAlphaSubmission> _submissions = new(256);
private readonly List<IRetailAlphaDrawSource> _sources = new(4); private readonly List<IRetailAlphaDrawSource> _sources = new(4);
private int[] _tokenScratch = new int[256]; private int[] _tokenScratch = new int[256];
private int[] _sourceDrawOffsets = new int[4]; private int[] _sourceDrawOffsets = new int[4];
private RetailAlphaSubmission[] _sortScratch = new RetailAlphaSubmission[256]; private RetailAlphaSubmission[] _sortScratch = new RetailAlphaSubmission[256];
private readonly int[] _radixOffsets = new int[256]; private readonly int[] _radixOffsets = new int[256];
private readonly RetainedScratchCapacityPolicy _scratchPolicy;
private readonly long _scratchBudgetBytes;
internal RetailAlphaQueue(long? scratchBudgetBytes = null)
{
long budget = scratchBudgetBytes
?? AlphaScratchBudgetProfile.Create(
ResidencyBudgetOptions.Default.AlphaScratchBytes).QueueBytes;
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(budget);
_scratchBudgetBytes = budget;
_scratchPolicy = new RetainedScratchCapacityPolicy(Math.Max(
1,
budget - (long)_radixOffsets.Length * sizeof(int)));
}
public bool IsCollecting { get; private set; } public bool IsCollecting { get; private set; }
internal int PendingCount => _submissions.Count; internal int PendingCount => _submissions.Count;
internal long ScratchBudgetBytes => _scratchBudgetBytes;
internal long RetainedScratchBytes => checked(
(long)_submissions.Capacity
* Unsafe.SizeOf<RetailAlphaSubmission>()
+ (long)_sortScratch.Length
* Unsafe.SizeOf<RetailAlphaSubmission>()
+ (long)_tokenScratch.Length * sizeof(int)
+ (long)_sources.Capacity * IntPtr.Size
+ (long)_sourceDrawOffsets.Length * sizeof(int)
+ (long)_radixOffsets.Length * sizeof(int));
public void BeginFrame() public void BeginFrame()
{ {
@ -171,6 +200,8 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
} }
finally finally
{ {
int submissionCount = _submissions.Count;
int sourceCount = _sources.Count;
for (int i = 0; i < _sources.Count; i++) for (int i = 0; i < _sources.Count; i++)
{ {
try try
@ -184,6 +215,7 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
} }
_sources.Clear(); _sources.Clear();
_submissions.Clear(); _submissions.Clear();
ApplyScratchRetention(submissionCount, sourceCount);
} }
if (drawFailure is not null) if (drawFailure is not null)
@ -246,9 +278,12 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
} }
finally finally
{ {
int submissionCount = _submissions.Count;
int sourceCount = _sources.Count;
_sources.Clear(); _sources.Clear();
_submissions.Clear(); _submissions.Clear();
IsCollecting = false; IsCollecting = false;
ApplyScratchRetention(submissionCount, sourceCount);
} }
if (failures is { Count: > 0 }) if (failures is { Count: > 0 })
@ -339,4 +374,40 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
return i; return i;
throw new InvalidOperationException("Retail alpha source was not registered for this frame."); throw new InvalidOperationException("Retail alpha source was not registered for this frame.");
} }
private void ApplyScratchRetention(
int observedSubmissionCount,
int observedSourceCount)
{
int currentCapacity = Math.Max(
_submissions.Capacity,
Math.Max(_tokenScratch.Length, _sortScratch.Length));
int bytesPerSubmission =
checked(
2 * Unsafe.SizeOf<RetailAlphaSubmission>()
+ 2 * sizeof(int)
+ IntPtr.Size);
int targetCapacity = _scratchPolicy.ObserveAndSelectCapacity(
currentCapacity,
observedSubmissionCount,
bytesPerSubmission,
MinimumSubmissionCapacity,
SubmissionGrowthQuantum);
if (targetCapacity < currentCapacity)
{
_submissions.Capacity = targetCapacity;
Array.Resize(ref _tokenScratch, targetCapacity);
Array.Resize(ref _sortScratch, targetCapacity);
int sourceTarget = Math.Max(
MinimumSourceCapacity,
observedSourceCount == 0
? MinimumSourceCapacity
: checked(observedSourceCount * 2));
sourceTarget = Math.Min(sourceTarget, _sources.Capacity);
_sources.Capacity = sourceTarget;
if (_sourceDrawOffsets.Length > sourceTarget)
Array.Resize(ref _sourceDrawOffsets, sourceTarget);
}
}
} }

View file

@ -1,7 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using AcDream.App.Rendering.Residency;
using AcDream.Core.Lighting; using AcDream.Core.Lighting;
using AcDream.Core.Meshing; using AcDream.Core.Meshing;
using AcDream.Core.Rendering; using AcDream.Core.Rendering;
@ -91,6 +93,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private readonly IRetailSelectionLightingSource? _selectionLighting; private readonly IRetailSelectionLightingSource? _selectionLighting;
private readonly RetailAlphaQueue? _alphaQueue; private readonly RetailAlphaQueue? _alphaQueue;
private readonly AlphaDrawSource _alphaSource; private readonly AlphaDrawSource _alphaSource;
private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy;
private int _scratchPeakUnits;
private readonly BindlessSupport _bindless; private readonly BindlessSupport _bindless;
@ -596,7 +600,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
=> owner.DrawPreparedAlphaBatch(firstPreparedDraw, drawCount); => owner.DrawPreparedAlphaBatch(firstPreparedDraw, drawCount);
public void ResetAlphaSubmissions() public void ResetAlphaSubmissions()
=> owner._deferredAlpha.Clear(); => owner.ResetDeferredAlphaSubmissions();
} }
// Per-frame scratch — reused across frames to avoid per-frame allocation. // Per-frame scratch — reused across frames to avoid per-frame allocation.
@ -606,6 +610,25 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private readonly List<DeferredAlphaInstance> _deferredAlpha = new(128); private readonly List<DeferredAlphaInstance> _deferredAlpha = new(128);
private TranslucencyKind[] _deferredAlphaKinds = new TranslucencyKind[128]; private TranslucencyKind[] _deferredAlphaKinds = new TranslucencyKind[128];
private Matrix4x4 _deferredAlphaViewProjection; private Matrix4x4 _deferredAlphaViewProjection;
internal long AlphaScratchBudgetBytes => _alphaScratchPolicy.BudgetBytes;
internal long RetainedAlphaScratchBytes => checked(
(long)_instanceData.Length * sizeof(float)
+ (long)_clipSlotData.Length * sizeof(uint)
+ (long)_lightSetData.Length * sizeof(int)
+ (long)_indoorData.Length * sizeof(uint)
+ (long)_alphaData.Length * sizeof(float)
+ (long)_selectionLightingData.Length * Unsafe.SizeOf<Vector2>()
+ (long)_batchData.Length * Unsafe.SizeOf<BatchData>()
+ (long)_indirectCommands.Length
* Unsafe.SizeOf<DrawElementsIndirectCommand>()
+ (long)_drawCullModes.Length * Unsafe.SizeOf<CullMode>()
+ (long)_batchPublicScratch.Length
* Unsafe.SizeOf<BatchDataPublic>()
+ (long)_deferredAlphaKinds.Length
* Unsafe.SizeOf<TranslucencyKind>()
+ (long)_deferredAlpha.Capacity
* Unsafe.SizeOf<DeferredAlphaInstance>());
// A.5 T26 follow-up (Bug B): WalkEntities populates this scratch list // A.5 T26 follow-up (Bug B): WalkEntities populates this scratch list
// instead of allocating a fresh List<(WorldEntity, int)> per frame. At // instead of allocating a fresh List<(WorldEntity, int)> per frame. At
// ~10K entities × ~3 mesh refs = ~30K tuples × 16 bytes = ~480 KB / frame // ~10K entities × ~3 mesh refs = ~30K tuples × 16 bytes = ~480 KB / frame
@ -729,7 +752,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
EntityClassificationCache classificationCache, EntityClassificationCache classificationCache,
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades, AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades,
IRetailSelectionRenderSink? selectionSink = null, IRetailSelectionRenderSink? selectionSink = null,
RetailAlphaQueue? alphaQueue = null) RetailAlphaQueue? alphaQueue = null,
long? alphaScratchBudgetBytes = null)
{ {
ArgumentNullException.ThrowIfNull(gl); ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(shader); ArgumentNullException.ThrowIfNull(shader);
@ -750,6 +774,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_selectionLighting = selectionSink as IRetailSelectionLightingSource; _selectionLighting = selectionSink as IRetailSelectionLightingSource;
_alphaQueue = alphaQueue; _alphaQueue = alphaQueue;
_alphaSource = new AlphaDrawSource(this); _alphaSource = new AlphaDrawSource(this);
long scratchBudget = alphaScratchBudgetBytes
?? AlphaScratchBudgetProfile.Create(
ResidencyBudgetOptions.Default.AlphaScratchBytes).DispatcherBytes;
_alphaScratchPolicy =
new RetainedScratchCapacityPolicy(scratchBudget);
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless)); _bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
} }
@ -766,6 +795,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_groupFrame == long.MaxValue) if (_groupFrame == long.MaxValue)
throw new InvalidOperationException("Instance-group frame identity was exhausted."); throw new InvalidOperationException("Instance-group frame identity was exhausted.");
ApplyScratchRetention(_scratchPeakUnits);
_scratchPeakUnits = 0;
_groupFrame++; _groupFrame++;
PruneInstanceGroupsUnusedBeforeFrame( PruneInstanceGroupsUnusedBeforeFrame(
_groups, _groups,
@ -1965,6 +1996,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// fill via BuildIndirectArrays ────────────────────────────────── // fill via BuildIndirectArrays ──────────────────────────────────
int immediateTransparentCount = deferTransparent ? 0 : _translucentDraws.Count; int immediateTransparentCount = deferTransparent ? 0 : _translucentDraws.Count;
int totalDraws = _opaqueDraws.Count + immediateTransparentCount; int totalDraws = _opaqueDraws.Count + immediateTransparentCount;
TrackScratchDemand(Math.Max(totalInstances, totalDraws));
if (_batchData.Length < totalDraws) if (_batchData.Length < totalDraws)
_batchData = new BatchData[totalDraws + 64]; _batchData = new BatchData[totalDraws + 64];
if (_indirectCommands.Length < totalDraws) if (_indirectCommands.Length < totalDraws)
@ -2493,6 +2525,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private void EnsureDeferredAlphaCapacity(int count) private void EnsureDeferredAlphaCapacity(int count)
{ {
TrackScratchDemand(count);
int neededMatrixFloats = count * 16; int neededMatrixFloats = count * 16;
if (_instanceData.Length < neededMatrixFloats) if (_instanceData.Length < neededMatrixFloats)
_instanceData = new float[neededMatrixFloats + 256 * 16]; _instanceData = new float[neededMatrixFloats + 256 * 16];
@ -2516,6 +2549,63 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_deferredAlphaKinds = new TranslucencyKind[count + 64]; _deferredAlphaKinds = new TranslucencyKind[count + 64];
} }
private void ResetDeferredAlphaSubmissions()
{
_deferredAlpha.Clear();
}
private void TrackScratchDemand(int units)
{
if (units > _scratchPeakUnits)
_scratchPeakUnits = units;
}
private void ApplyScratchRetention(int observedUnits)
{
int currentCapacity = Math.Max(
_instanceData.Length / 16,
Math.Max(
_lightSetData.Length / LightManager.MaxLightsPerObject,
Math.Max(
_deferredAlpha.Capacity,
Math.Max(_batchData.Length, _indirectCommands.Length))));
int bytesPerUnit = checked(
16 * sizeof(float)
+ sizeof(uint)
+ LightManager.MaxLightsPerObject * sizeof(int)
+ sizeof(uint)
+ sizeof(float)
+ Unsafe.SizeOf<Vector2>()
+ Unsafe.SizeOf<BatchData>()
+ Unsafe.SizeOf<DrawElementsIndirectCommand>()
+ Unsafe.SizeOf<CullMode>()
+ Unsafe.SizeOf<BatchDataPublic>()
+ Unsafe.SizeOf<TranslucencyKind>()
+ Unsafe.SizeOf<DeferredAlphaInstance>());
int targetCapacity = _alphaScratchPolicy.ObserveAndSelectCapacity(
currentCapacity,
observedUnits,
bytesPerUnit,
minimumCapacity: 256,
growthQuantum: 256);
if (targetCapacity >= currentCapacity)
return;
_instanceData = new float[checked(targetCapacity * 16)];
_clipSlotData = new uint[targetCapacity];
_lightSetData = new int[
checked(targetCapacity * LightManager.MaxLightsPerObject)];
_indoorData = new uint[targetCapacity];
_alphaData = new float[targetCapacity];
_selectionLightingData = new Vector2[targetCapacity];
_batchData = new BatchData[targetCapacity];
_indirectCommands = new DrawElementsIndirectCommand[targetCapacity];
_drawCullModes = new CullMode[targetCapacity];
_batchPublicScratch = new BatchDataPublic[targetCapacity];
_deferredAlphaKinds = new TranslucencyKind[targetCapacity];
_deferredAlpha.Capacity = targetCapacity;
}
private void UploadDeferredAlphaBuffers(int count) private void UploadDeferredAlphaBuffers(int count)
{ {
fixed (float* p = _instanceData) fixed (float* p = _instanceData)

View file

@ -18,26 +18,95 @@ namespace AcDream.Content.Vfx;
/// </remarks> /// </remarks>
public sealed class RetailAnimationLoader : IAnimationLoader public sealed class RetailAnimationLoader : IAnimationLoader
{ {
public const long DefaultMaximumEstimatedBytes = 64L * 1024L * 1024L;
public const int DefaultMaximumEntries = 512;
private const long NegativeEntryEstimate = 128;
private readonly record struct LoadResult(
Animation? Animation,
long EstimatedBytes);
private sealed record CacheEntry(
uint Id,
Animation? Animation,
long EstimatedBytes);
private readonly Func<uint, byte[]?> _readRaw; private readonly Func<uint, byte[]?> _readRaw;
private readonly DatDatabase? _database; private readonly DatDatabase? _database;
private readonly ConcurrentDictionary<uint, Lazy<Animation?>> _cache = new(); private readonly ConcurrentDictionary<uint, Lazy<LoadResult>> _inflight = new();
private readonly object _gate = new();
private readonly Dictionary<uint, LinkedListNode<CacheEntry>> _entries = [];
private readonly LinkedList<CacheEntry> _leastRecentlyUsed = [];
private readonly long _maximumEstimatedBytes;
private readonly int _maximumEntries;
private long _estimatedBytes;
private long _hits;
private long _misses;
private long _evictions;
public RetailAnimationLoader(IDatReaderWriter dats) public RetailAnimationLoader(IDatReaderWriter dats)
: this(
dats,
DefaultMaximumEstimatedBytes,
DefaultMaximumEntries)
{
}
public RetailAnimationLoader(
IDatReaderWriter dats,
long maximumEstimatedBytes,
int maximumEntries)
{ {
ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(dats);
ValidateBudgets(maximumEstimatedBytes, maximumEntries);
_database = dats.Portal.Db; _database = dats.Portal.Db;
_readRaw = id => dats.Portal.TryGetFileBytes(id, out byte[]? bytes) _readRaw = id => dats.Portal.TryGetFileBytes(id, out byte[]? bytes)
? bytes ? bytes
: null; : null;
_maximumEstimatedBytes = maximumEstimatedBytes;
_maximumEntries = maximumEntries;
} }
public RetailAnimationLoader(IDatDatabase portal) public RetailAnimationLoader(IDatDatabase portal)
: this(
portal,
DefaultMaximumEstimatedBytes,
DefaultMaximumEntries)
{
}
public RetailAnimationLoader(
IDatDatabase portal,
long maximumEstimatedBytes,
int maximumEntries)
{ {
ArgumentNullException.ThrowIfNull(portal); ArgumentNullException.ThrowIfNull(portal);
ValidateBudgets(maximumEstimatedBytes, maximumEntries);
_database = portal.Db; _database = portal.Db;
_readRaw = id => portal.TryGetFileBytes(id, out byte[]? bytes) _readRaw = id => portal.TryGetFileBytes(id, out byte[]? bytes)
? bytes ? bytes
: null; : null;
_maximumEstimatedBytes = maximumEstimatedBytes;
_maximumEntries = maximumEntries;
}
public AnimationCacheDiagnostics Diagnostics
{
get
{
lock (_gate)
{
return new AnimationCacheDiagnostics(
_entries.Count,
_estimatedBytes,
_maximumEstimatedBytes,
_maximumEntries,
new CacheStats(
Interlocked.Read(ref _hits),
Interlocked.Read(ref _misses),
Interlocked.Read(ref _evictions)));
}
}
} }
public Animation? LoadAnimation(uint id) public Animation? LoadAnimation(uint id)
@ -45,24 +114,156 @@ public sealed class RetailAnimationLoader : IAnimationLoader
if (!IsAnimationDid(id)) if (!IsAnimationDid(id))
return null; return null;
return _cache.GetOrAdd( if (TryGet(id, out Animation? cached))
return cached;
Lazy<LoadResult> candidate = new(
() => LoadUncached(id),
LazyThreadSafetyMode.ExecutionAndPublication);
Lazy<LoadResult> shared = _inflight.GetOrAdd(
id, id,
key => new Lazy<Animation?>( candidate);
() => LoadUncached(key), try
LazyThreadSafetyMode.ExecutionAndPublication)).Value; {
LoadResult loaded = shared.Value;
return Admit(id, loaded);
}
finally
{
_inflight.TryRemove(
new KeyValuePair<uint, Lazy<LoadResult>>(id, shared));
}
} }
private Animation? LoadUncached(uint id) private bool TryGet(uint id, out Animation? animation)
{
lock (_gate)
{
if (!_entries.TryGetValue(
id,
out LinkedListNode<CacheEntry>? node))
{
Interlocked.Increment(ref _misses);
animation = null;
return false;
}
Interlocked.Increment(ref _hits);
Touch(node);
animation = node.Value.Animation;
return true;
}
}
private LoadResult LoadUncached(uint id)
{ {
byte[]? bytes = _readRaw(id); byte[]? bytes = _readRaw(id);
if (bytes is null) if (bytes is null)
return null; return new LoadResult(null, NegativeEntryEstimate);
Animation animation = Parse(bytes, _database); Animation animation = Parse(bytes, _database);
if (animation.Id != id) if (animation.Id != id)
throw new InvalidDataException( throw new InvalidDataException(
$"Animation entry 0x{id:X8} contained id 0x{animation.Id:X8}."); $"Animation entry 0x{id:X8} contained id 0x{animation.Id:X8}.");
return animation; return new LoadResult(
animation,
EstimateRetainedBytes(animation, bytes.LongLength));
}
private Animation? Admit(uint id, LoadResult loaded)
{
long estimatedBytes = Math.Max(1, loaded.EstimatedBytes);
lock (_gate)
{
if (_entries.TryGetValue(
id,
out LinkedListNode<CacheEntry>? existing))
{
Touch(existing);
return existing.Value.Animation;
}
if (estimatedBytes > _maximumEstimatedBytes)
return loaded.Animation;
while (_entries.Count >= _maximumEntries
|| _estimatedBytes
> _maximumEstimatedBytes - estimatedBytes)
{
EvictLeastRecentlyUsed();
}
var entry = new CacheEntry(
id,
loaded.Animation,
estimatedBytes);
LinkedListNode<CacheEntry> node =
_leastRecentlyUsed.AddLast(entry);
_entries.Add(id, node);
_estimatedBytes = checked(
_estimatedBytes + estimatedBytes);
return loaded.Animation;
}
}
private void Touch(LinkedListNode<CacheEntry> node)
{
if (ReferenceEquals(node, _leastRecentlyUsed.Last))
return;
_leastRecentlyUsed.Remove(node);
_leastRecentlyUsed.AddLast(node);
}
private void EvictLeastRecentlyUsed()
{
LinkedListNode<CacheEntry>? node = _leastRecentlyUsed.First;
if (node is null)
{
throw new InvalidOperationException(
"Animation cache accounting is inconsistent.");
}
_leastRecentlyUsed.RemoveFirst();
_entries.Remove(node.Value.Id);
_estimatedBytes = checked(
_estimatedBytes - node.Value.EstimatedBytes);
Interlocked.Increment(ref _evictions);
}
internal static long EstimateRetainedBytes(
Animation animation,
long rawBytes)
{
ArgumentNullException.ThrowIfNull(animation);
ArgumentOutOfRangeException.ThrowIfNegative(rawBytes);
// Generated DAT graph nodes do not expose managed-size metadata.
// Charge their known list/object shape conservatively; the independent
// entry ceiling remains the hard bound if a future hook subtype holds
// an unmeasured child graph.
long estimate = 512;
estimate = checked(
estimate + animation.PosFrames.Count * 64L);
for (int i = 0; i < animation.PartFrames.Count; i++)
{
AnimationFrame frame = animation.PartFrames[i];
estimate = checked(
estimate
+ 96L
+ frame.Frames.Count * 64L
+ frame.Hooks.Count * 192L);
}
return Math.Max(rawBytes, estimate);
}
private static void ValidateBudgets(
long maximumEstimatedBytes,
int maximumEntries)
{
ArgumentOutOfRangeException.ThrowIfLessThan(
maximumEstimatedBytes,
1);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumEntries, 1);
} }
public static Animation Parse( public static Animation Parse(
@ -106,3 +307,10 @@ public sealed class RetailAnimationLoader : IAnimationLoader
// AC data IDs reserve only the high byte for DBObj type. // AC data IDs reserve only the high byte for DBObj type.
(did & 0xFF000000u) == 0x03000000u; (did & 0xFF000000u) == 0x03000000u;
} }
public readonly record struct AnimationCacheDiagnostics(
int Count,
long EstimatedBytes,
long BudgetBytes,
int EntryLimit,
CacheStats Stats);

View file

@ -4,6 +4,7 @@ using AcDream.App.Audio;
using AcDream.App.Composition; using AcDream.App.Composition;
using AcDream.App.Physics; using AcDream.App.Physics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.App.Spells; using AcDream.App.Spells;
using AcDream.Content; using AcDream.Content;
@ -253,6 +254,7 @@ public sealed class ContentEffectsAudioCompositionTests
Dependencies = new ContentEffectsAudioDependencies( Dependencies = new ContentEffectsAudioDependencies(
"test-dat", "test-dat",
"test.pak", "test.pak",
ResidencyBudgetOptions.Default,
new PhysicsDataCache(), new PhysicsDataCache(),
false, false,
new Spellbook(), new Spellbook(),
@ -386,7 +388,19 @@ public sealed class ContentEffectsAudioCompositionTests
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => _magic; public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => _magic;
public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) { } public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) { }
public int GetSpellCount(MagicCatalog catalog) => 0; public int GetSpellCount(MagicCatalog catalog) => 0;
public IAnimationLoader CreateAnimationLoader(IDatReaderWriter dats) => _animations; public IAnimationLoader CreateAnimationLoader(
IDatReaderWriter dats,
long maximumEstimatedBytes,
int maximumEntries)
{
Assert.Equal(
ResidencyBudgetOptions.Default.AnimationBytes,
maximumEstimatedBytes);
Assert.Equal(
ResidencyBudgetOptions.Default.AnimationEntries,
maximumEntries);
return _animations;
}
public LiveEntityCollisionBuilder CreateCollisionBuilder( public LiveEntityCollisionBuilder CreateCollisionBuilder(
PhysicsDataCache physicsData, PhysicsDataCache physicsData,

View file

@ -6,6 +6,7 @@ using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Residency;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Content; using AcDream.Content;
using AcDream.Core.Physics;
using AcDream.Core.Terrain; using AcDream.Core.Terrain;
using AcDream.UI.Abstractions.Settings; using AcDream.UI.Abstractions.Settings;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
@ -306,7 +307,8 @@ public sealed class WorldRenderCompositionTests
ResidencyManager manager, ResidencyManager manager,
WbMeshAdapter meshes, WbMeshAdapter meshes,
TextureCache textures, TextureCache textures,
IPreparedAssetSource preparedAssets) IPreparedAssetSource preparedAssets,
IAnimationLoader animations)
{ {
RegisteredResidency = manager; RegisteredResidency = manager;
} }

View file

@ -7,6 +7,20 @@ public sealed class ResidencyManagerTests
private sealed class MeshAsset; private sealed class MeshAsset;
private sealed class TextureAsset; private sealed class TextureAsset;
[Fact]
public void ScratchBytesAreCommittedCpuResidence()
{
var charges = new ResidencyCharges(
LogicalBytes: 1,
CpuPreparedBytes: 2,
DecodedBytes: 3,
ScratchBytes: 4,
StagingBytes: 5);
Assert.Equal(15, charges.CommittedCpuBytes);
charges.Validate();
}
[Fact] [Fact]
public void HandlesAndOwnersReserveZeroAsInvalid() public void HandlesAndOwnersReserveZeroAsInvalid()
{ {

View file

@ -0,0 +1,86 @@
using AcDream.App.Rendering.Residency;
namespace AcDream.App.Tests.Rendering.Residency;
public sealed class RetainedScratchCapacityPolicyTests
{
[Fact]
public void AlphaBudgetProfilePartitionsTheAggregateExactly()
{
AlphaScratchBudgetProfile profile =
AlphaScratchBudgetProfile.Create(16 * 1024 * 1024);
Assert.Equal(4 * 1024 * 1024, profile.QueueBytes);
Assert.Equal(8 * 1024 * 1024, profile.DispatcherBytes);
Assert.Equal(4 * 1024 * 1024, profile.ParticleBytes);
Assert.Equal(16 * 1024 * 1024, profile.TotalBytes);
}
[Fact]
public void SpikeRetainsWarmCapacityUntilDemandFallsThenConverges()
{
var policy = new RetainedScratchCapacityPolicy(
budgetBytes: 1024,
lowDemandSamplesBeforeShrink: 3);
Assert.Equal(
4096,
policy.ObserveAndSelectCapacity(
currentCapacity: 4096,
requiredCapacity: 4096,
bytesPerUnit: 4,
minimumCapacity: 64,
growthQuantum: 64));
Assert.Equal(4096, ObserveLowDemand(policy));
Assert.Equal(4096, ObserveLowDemand(policy));
int converged = ObserveLowDemand(policy);
Assert.InRange(converged, 64, 256);
Assert.True((long)converged * 4 <= 1024);
}
[Fact]
public void SustainedLegitimateDemandIsNeverTrimmedBelowRequirement()
{
var policy = new RetainedScratchCapacityPolicy(
budgetBytes: 1024,
lowDemandSamplesBeforeShrink: 2);
for (int i = 0; i < 10; i++)
{
Assert.Equal(
4096,
policy.ObserveAndSelectCapacity(
currentCapacity: 4096,
requiredCapacity: 4096,
bytesPerUnit: 4,
minimumCapacity: 64,
growthQuantum: 64));
}
}
[Fact]
public void RequiredGrowthIsImmediateAndQuantumRounded()
{
var policy = new RetainedScratchCapacityPolicy(1024);
Assert.Equal(
320,
policy.ObserveAndSelectCapacity(
currentCapacity: 64,
requiredCapacity: 257,
bytesPerUnit: 4,
minimumCapacity: 64,
growthQuantum: 64));
}
private static int ObserveLowDemand(
RetainedScratchCapacityPolicy policy) =>
policy.ObserveAndSelectCapacity(
currentCapacity: 4096,
requiredCapacity: 32,
bytesPerUnit: 4,
minimumCapacity: 64,
growthQuantum: 64);
}

View file

@ -157,6 +157,31 @@ public sealed class RetailAlphaQueueTests
Assert.Equal(expected, source.PreparedTokens); Assert.Equal(expected, source.PreparedTokens);
} }
[Fact]
public void RetainedScratchConvergesAfterAOneScopeSpike()
{
const int budgetBytes = 128 * 1024;
var log = new List<string>();
var source = new RecordingSource("alpha", log);
var queue = new RetailAlphaQueue(budgetBytes);
queue.BeginFrame();
for (int i = 0; i < 8_192; i++)
queue.Submit(source, i, i);
queue.EndFrame();
Assert.True(queue.RetainedScratchBytes > budgetBytes);
for (int i = 0; i < 3; i++)
{
queue.BeginFrame();
queue.EndFrame();
}
Assert.True(queue.RetainedScratchBytes <= budgetBytes);
Assert.False(queue.IsCollecting);
Assert.Equal(0, queue.PendingCount);
}
[Fact] [Fact]
public void Flush_EqualDistanceOrderStartsFreshInEachFrameScope() public void Flush_EqualDistanceOrderStartsFreshInEachFrameScope()
{ {

View file

@ -202,6 +202,81 @@ public sealed class RetailDatLoaderTests
Assert.Throws<InvalidDataException>(() => animations.LoadAnimation(wrongAnimationKey)); Assert.Throws<InvalidDataException>(() => animations.LoadAnimation(wrongAnimationKey));
} }
[Fact]
public void AnimationCache_CountPressureEvictsLruWithoutInvalidatingLiveReferences()
{
const uint firstDid = 0x03010020u;
const uint secondDid = 0x03010021u;
const uint thirdDid = 0x03010022u;
var portal = new RawDatabase();
portal.Add(firstDid, AnimationBytes(firstDid));
portal.Add(secondDid, AnimationBytes(secondDid));
portal.Add(thirdDid, AnimationBytes(thirdDid));
var loader = new RetailAnimationLoader(
portal,
maximumEstimatedBytes: long.MaxValue,
maximumEntries: 2);
DatAnimation first = Assert.IsType<DatAnimation>(
loader.LoadAnimation(firstDid));
_ = Assert.IsType<DatAnimation>(loader.LoadAnimation(secondDid));
_ = Assert.IsType<DatAnimation>(loader.LoadAnimation(thirdDid));
AnimationCacheDiagnostics pressured = loader.Diagnostics;
Assert.Equal(2, pressured.Count);
Assert.Equal(1, pressured.Stats.Evictions);
Assert.Equal(firstDid, first.Id);
DatAnimation reloaded = Assert.IsType<DatAnimation>(
loader.LoadAnimation(firstDid));
Assert.NotSame(first, reloaded);
Assert.Equal(4, portal.TotalReads);
Assert.Equal(2, loader.Diagnostics.Stats.Evictions);
}
[Fact]
public void AnimationCache_OversizeEntryIsServedButNeverRetained()
{
const uint animationDid = 0x03010023u;
var portal = new RawDatabase();
portal.Add(animationDid, AnimationBytes(animationDid));
var loader = new RetailAnimationLoader(
portal,
maximumEstimatedBytes: 1,
maximumEntries: 2);
Assert.NotNull(loader.LoadAnimation(animationDid));
Assert.NotNull(loader.LoadAnimation(animationDid));
Assert.Equal(0, loader.Diagnostics.Count);
Assert.Equal(0, loader.Diagnostics.EstimatedBytes);
Assert.Equal(2, portal.TotalReads);
}
[Fact]
public async Task AnimationCache_CoalescesSameDidAndAllowsUnrelatedReadsInParallel()
{
const uint firstDid = 0x03010024u;
const uint secondDid = 0x03010025u;
var portal = new RawDatabase { ReadDelayMilliseconds = 40 };
portal.Add(firstDid, AnimationBytes(firstDid));
portal.Add(secondDid, AnimationBytes(secondDid));
var loader = new RetailAnimationLoader(portal);
DatAnimation?[] firstPair = await Task.WhenAll(
Task.Run(() => loader.LoadAnimation(firstDid)),
Task.Run(() => loader.LoadAnimation(firstDid)));
Assert.Same(firstPair[0], firstPair[1]);
Assert.Equal(1, portal.TotalReads);
await Task.WhenAll(
Task.Run(() => loader.LoadAnimation(secondDid)),
Task.Run(() => loader.LoadAnimation(0x03010026u)));
Assert.True(portal.MaxConcurrentReads >= 2);
Assert.Equal(3, portal.TotalReads);
}
[Fact] [Fact]
public async Task PhysicsScriptLoader_AllowsConcurrentFirstReads() public async Task PhysicsScriptLoader_AllowsConcurrentFirstReads()
{ {
@ -224,6 +299,16 @@ public sealed class RetailDatLoaderTests
Assert.Equal(2, portal.TotalReads); Assert.Equal(2, portal.TotalReads);
} }
private static byte[] AnimationBytes(uint id)
{
byte[] bytes =
ProjectileVfxDatFixtures.OrdinaryAnimation.ToArray();
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
bytes,
id);
return bytes;
}
[Fact] [Fact]
public async Task PhysicsScriptLoader_CoalescesConcurrentReadsForTheSameDid() public async Task PhysicsScriptLoader_CoalescesConcurrentReadsForTheSameDid()
{ {