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:
parent
3e18fc2730
commit
f2644d42c2
18 changed files with 857 additions and 34 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue