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

@ -18,26 +18,95 @@ namespace AcDream.Content.Vfx;
/// </remarks>
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 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)
: this(
dats,
DefaultMaximumEstimatedBytes,
DefaultMaximumEntries)
{
}
public RetailAnimationLoader(
IDatReaderWriter dats,
long maximumEstimatedBytes,
int maximumEntries)
{
ArgumentNullException.ThrowIfNull(dats);
ValidateBudgets(maximumEstimatedBytes, maximumEntries);
_database = dats.Portal.Db;
_readRaw = id => dats.Portal.TryGetFileBytes(id, out byte[]? bytes)
? bytes
: null;
_maximumEstimatedBytes = maximumEstimatedBytes;
_maximumEntries = maximumEntries;
}
public RetailAnimationLoader(IDatDatabase portal)
: this(
portal,
DefaultMaximumEstimatedBytes,
DefaultMaximumEntries)
{
}
public RetailAnimationLoader(
IDatDatabase portal,
long maximumEstimatedBytes,
int maximumEntries)
{
ArgumentNullException.ThrowIfNull(portal);
ValidateBudgets(maximumEstimatedBytes, maximumEntries);
_database = portal.Db;
_readRaw = id => portal.TryGetFileBytes(id, out byte[]? bytes)
? bytes
: 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)
@ -45,24 +114,156 @@ public sealed class RetailAnimationLoader : IAnimationLoader
if (!IsAnimationDid(id))
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,
key => new Lazy<Animation?>(
() => LoadUncached(key),
LazyThreadSafetyMode.ExecutionAndPublication)).Value;
candidate);
try
{
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);
if (bytes is null)
return null;
return new LoadResult(null, NegativeEntryEstimate);
Animation animation = Parse(bytes, _database);
if (animation.Id != id)
throw new InvalidDataException(
$"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(
@ -106,3 +307,10 @@ public sealed class RetailAnimationLoader : IAnimationLoader
// AC data IDs reserve only the high byte for DBObj type.
(did & 0xFF000000u) == 0x03000000u;
}
public readonly record struct AnimationCacheDiagnostics(
int Count,
long EstimatedBytes,
long BudgetBytes,
int EntryLimit,
CacheStats Stats);