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

@ -281,7 +281,7 @@ public sealed class GameWindow :
private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader;
private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects;
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
// moved meaningfully. Used in TickAnimations to swap to Ready when
// position has stalled for >StopIdleMs — retail observer pattern per
@ -564,6 +564,11 @@ public sealed class GameWindow :
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
{
_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;
Weather = _worldEnvironment.Weather;
_retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot();
@ -1141,6 +1146,7 @@ public sealed class GameWindow :
new ContentEffectsAudioDependencies(
_datDir,
_options.PreparedAssetPath,
_options.ResidencyBudgets,
_physicsDataCache,
_animationDiagnostics.DumpMotionEnabled,
SpellBook,

View file

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Wb;
using AcDream.Content;
using AcDream.Core.Meshing;
@ -181,6 +183,13 @@ public sealed unsafe class ParticleRenderer : IDisposable
private DeferredParticleDraw[] _preparedAlpha = new DeferredParticleDraw[256];
private uint[] _preparedInstanceOffsets = new uint[256];
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
{
@ -201,7 +210,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
TextureCache? textures = null,
IDatReaderWriter? dats = null,
WbMeshAdapter? meshAdapter = null,
RetailAlphaQueue? alphaQueue = null)
RetailAlphaQueue? alphaQueue = null,
long? alphaScratchBudgetBytes = null)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_textures = textures;
@ -210,6 +220,11 @@ public sealed unsafe class ParticleRenderer : IDisposable
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
_alphaQueue = alphaQueue;
_alphaSource = new AlphaDrawSource(this);
long scratchBudget = alphaScratchBudgetBytes
?? AlphaScratchBudgetProfile.Create(
ResidencyBudgetOptions.Default.AlphaScratchBytes).ParticleBytes;
_alphaScratchPolicy =
new RetainedScratchCapacityPolicy(scratchBudget);
if (_meshAdapter is not null)
{
_meshReferences = new ParticleMeshReferenceTracker(
@ -732,8 +747,30 @@ public sealed unsafe class ParticleRenderer : IDisposable
private void ResetDeferredAlpha()
{
int observedCount = Math.Max(
_deferredAlpha.Count,
_preparedAlphaCount);
_deferredAlpha.Clear();
_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)

View file

@ -84,6 +84,7 @@ internal readonly record struct ResidencyCharges(
long LogicalBytes = 0,
long CpuPreparedBytes = 0,
long DecodedBytes = 0,
long ScratchBytes = 0,
long PinnedBytes = 0,
long StagingBytes = 0,
long GpuRequestedBytes = 0,
@ -97,6 +98,7 @@ internal readonly record struct ResidencyCharges(
LogicalBytes
+ CpuPreparedBytes
+ DecodedBytes
+ ScratchBytes
+ StagingBytes);
public long PhysicalGpuBytes => checked(
@ -111,6 +113,7 @@ internal readonly record struct ResidencyCharges(
ArgumentOutOfRangeException.ThrowIfNegative(LogicalBytes);
ArgumentOutOfRangeException.ThrowIfNegative(CpuPreparedBytes);
ArgumentOutOfRangeException.ThrowIfNegative(DecodedBytes);
ArgumentOutOfRangeException.ThrowIfNegative(ScratchBytes);
ArgumentOutOfRangeException.ThrowIfNegative(PinnedBytes);
ArgumentOutOfRangeException.ThrowIfNegative(StagingBytes);
ArgumentOutOfRangeException.ThrowIfNegative(GpuRequestedBytes);
@ -126,6 +129,7 @@ internal readonly record struct ResidencyCharges(
checked(left.LogicalBytes + right.LogicalBytes),
checked(left.CpuPreparedBytes + right.CpuPreparedBytes),
checked(left.DecodedBytes + right.DecodedBytes),
checked(left.ScratchBytes + right.ScratchBytes),
checked(left.PinnedBytes + right.PinnedBytes),
checked(left.StagingBytes + right.StagingBytes),
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.Numerics;
using System.Runtime.ExceptionServices;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Residency;
namespace AcDream.App.Rendering;
@ -68,15 +70,42 @@ internal interface 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<IRetailAlphaDrawSource> _sources = new(4);
private int[] _tokenScratch = new int[256];
private int[] _sourceDrawOffsets = new int[4];
private RetailAlphaSubmission[] _sortScratch = new RetailAlphaSubmission[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; }
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()
{
@ -171,6 +200,8 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
}
finally
{
int submissionCount = _submissions.Count;
int sourceCount = _sources.Count;
for (int i = 0; i < _sources.Count; i++)
{
try
@ -184,6 +215,7 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
}
_sources.Clear();
_submissions.Clear();
ApplyScratchRetention(submissionCount, sourceCount);
}
if (drawFailure is not null)
@ -246,9 +278,12 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
}
finally
{
int submissionCount = _submissions.Count;
int sourceCount = _sources.Count;
_sources.Clear();
_submissions.Clear();
IsCollecting = false;
ApplyScratchRetention(submissionCount, sourceCount);
}
if (failures is { Count: > 0 })
@ -339,4 +374,40 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
return i;
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.Collections.Generic;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Residency;
using AcDream.Core.Lighting;
using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
@ -91,6 +93,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private readonly IRetailSelectionLightingSource? _selectionLighting;
private readonly RetailAlphaQueue? _alphaQueue;
private readonly AlphaDrawSource _alphaSource;
private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy;
private int _scratchPeakUnits;
private readonly BindlessSupport _bindless;
@ -596,7 +600,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
=> owner.DrawPreparedAlphaBatch(firstPreparedDraw, drawCount);
public void ResetAlphaSubmissions()
=> owner._deferredAlpha.Clear();
=> owner.ResetDeferredAlphaSubmissions();
}
// 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 TranslucencyKind[] _deferredAlphaKinds = new TranslucencyKind[128];
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
// instead of allocating a fresh List<(WorldEntity, int)> per frame. At
// ~10K entities × ~3 mesh refs = ~30K tuples × 16 bytes = ~480 KB / frame
@ -729,7 +752,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
EntityClassificationCache classificationCache,
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades,
IRetailSelectionRenderSink? selectionSink = null,
RetailAlphaQueue? alphaQueue = null)
RetailAlphaQueue? alphaQueue = null,
long? alphaScratchBudgetBytes = null)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(shader);
@ -750,6 +774,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_selectionLighting = selectionSink as IRetailSelectionLightingSource;
_alphaQueue = alphaQueue;
_alphaSource = new AlphaDrawSource(this);
long scratchBudget = alphaScratchBudgetBytes
?? AlphaScratchBudgetProfile.Create(
ResidencyBudgetOptions.Default.AlphaScratchBytes).DispatcherBytes;
_alphaScratchPolicy =
new RetainedScratchCapacityPolicy(scratchBudget);
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
}
@ -766,6 +795,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_groupFrame == long.MaxValue)
throw new InvalidOperationException("Instance-group frame identity was exhausted.");
ApplyScratchRetention(_scratchPeakUnits);
_scratchPeakUnits = 0;
_groupFrame++;
PruneInstanceGroupsUnusedBeforeFrame(
_groups,
@ -1965,6 +1996,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// fill via BuildIndirectArrays ──────────────────────────────────
int immediateTransparentCount = deferTransparent ? 0 : _translucentDraws.Count;
int totalDraws = _opaqueDraws.Count + immediateTransparentCount;
TrackScratchDemand(Math.Max(totalInstances, totalDraws));
if (_batchData.Length < totalDraws)
_batchData = new BatchData[totalDraws + 64];
if (_indirectCommands.Length < totalDraws)
@ -2493,6 +2525,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private void EnsureDeferredAlphaCapacity(int count)
{
TrackScratchDemand(count);
int neededMatrixFloats = count * 16;
if (_instanceData.Length < neededMatrixFloats)
_instanceData = new float[neededMatrixFloats + 256 * 16];
@ -2516,6 +2549,63 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_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)
{
fixed (float* p = _instanceData)