diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 1eca6cd5..b6d50e74 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -114,7 +114,13 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable public DrawStats LastDrawStats { get; private set; } public bool CompositeTexturesReady { get; private set; } = true; internal int LastCompositeWarmupPendingCount { get; private set; } - internal const int MaximumCompositeWarmupEntitiesPerFrame = 128; + // Candidate discovery only reads already-published entity facts. Keep it + // independently bounded from the materially more expensive mesh/texture + // preparation below so a large retained Far-tier world cannot consume the + // entire portal reveal window merely proving that most entities are + // outside the destination neighborhood. + internal const int MaximumCompositeWarmupScanEntitiesPerFrame = 4096; + internal const int MaximumCompositeWarmupPrepareEntitiesPerFrame = 128; private readonly Queue _compositeWarmupQueue = new(); // Membership may change while ACE is streaming the destination object set. // Retain exact candidate progress and schedule a follow-up pass instead of @@ -188,9 +194,9 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _compositeWarmupScanIndex = Math.Min(_compositeWarmupScanIndex, entities.Count); - int scanEnd = Math.Min( - entities.Count, - _compositeWarmupScanIndex + MaximumCompositeWarmupEntitiesPerFrame); + int scanEnd = CompositeWarmupScanEnd( + _compositeWarmupScanIndex, + entities.Count); for (; _compositeWarmupScanIndex < scanEnd; _compositeWarmupScanIndex++) { WorldEntity entity = entities[_compositeWarmupScanIndex]; @@ -211,7 +217,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable int candidatesThisPass = Math.Min( _compositeWarmupQueue.Count, - MaximumCompositeWarmupEntitiesPerFrame); + MaximumCompositeWarmupPrepareEntitiesPerFrame); for (int i = 0; i < candidatesThisPass; i++) { WorldEntity entity = _compositeWarmupQueue.Dequeue(); @@ -245,6 +251,17 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable ulong entityGeneration) => scanComplete && scanGeneration != entityGeneration; + internal static int CompositeWarmupScanEnd( + int scanIndex, + int entityCount) + { + ArgumentOutOfRangeException.ThrowIfNegative(scanIndex); + ArgumentOutOfRangeException.ThrowIfNegative(entityCount); + return Math.Min( + entityCount, + scanIndex + MaximumCompositeWarmupScanEntitiesPerFrame); + } + private void RebuildCompositeWarmupQueue( IReadOnlyList entities, ulong entityGeneration, diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherCompositeWarmupTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherCompositeWarmupTests.cs index b698e96d..ebd097d0 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherCompositeWarmupTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherCompositeWarmupTests.cs @@ -56,6 +56,28 @@ public sealed class WbDrawDispatcherCompositeWarmupTests entityGeneration: 42)); } + [Fact] + public void LargeRetainedWorldDiscoveryConvergesIndependentlyOfUploadBudget() + { + const int entityCount = 21_025; + int scanIndex = 0; + int frameCount = 0; + + while (scanIndex < entityCount) + { + scanIndex = WbDrawDispatcher.CompositeWarmupScanEnd( + scanIndex, + entityCount); + frameCount++; + } + + Assert.Equal(6, frameCount); + Assert.Equal(entityCount, scanIndex); + Assert.True( + WbDrawDispatcher.MaximumCompositeWarmupScanEntitiesPerFrame + > WbDrawDispatcher.MaximumCompositeWarmupPrepareEntitiesPerFrame); + } + [Fact] public void PaletteEntityInsideDestinationRadiusIsCandidate() {