fix(render): make reveal warmup mutation-resumable

Retain composite candidate progress across live membership generations and run a stable follow-up pass after churn, preventing ACE object-stream updates from resetting portal readiness forever.

Co-authored-by: Erik Nilsson <erikn@users.noreply.github.com>
This commit is contained in:
Erik 2026-07-24 19:54:40 +02:00
parent a77ba06722
commit e991eeca34
2 changed files with 63 additions and 12 deletions

View file

@ -114,6 +114,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
internal int LastCompositeWarmupPendingCount { get; private set; }
internal const int MaximumCompositeWarmupEntitiesPerFrame = 128;
private readonly Queue<WorldEntity> _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
// restarting at index zero on every generation edge.
private readonly HashSet<WorldEntity> _compositeWarmupTracked = [];
private IReadOnlyList<WorldEntity>? _compositeWarmupSource;
private ulong _compositeWarmupSourceGeneration;
private uint _compositeWarmupDestinationCell;
@ -133,6 +137,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
CompositeTexturesReady = false;
LastCompositeWarmupPendingCount = 1;
_compositeWarmupQueue.Clear();
_compositeWarmupTracked.Clear();
_compositeWarmupSource = null;
_compositeWarmupSourceGeneration = 0;
_compositeWarmupDestinationCell = 0;
@ -159,9 +164,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_compositeWarmupSource,
_compositeWarmupDestinationCell,
_compositeWarmupRadius,
_compositeWarmupSourceGeneration,
entities,
entityGeneration,
destinationCell,
radius))
{
@ -171,19 +174,38 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
destinationCell,
radius);
}
else if (ShouldBeginCompositeWarmupRescan(
_compositeWarmupScanComplete,
_compositeWarmupSourceGeneration,
entityGeneration))
{
BeginCompositeWarmupRescan(entities.Count, entityGeneration);
}
if (CompositeTexturesReady)
return;
_compositeWarmupScanIndex =
Math.Min(_compositeWarmupScanIndex, entities.Count);
int scanEnd = Math.Min(
entities.Count,
_compositeWarmupScanIndex + MaximumCompositeWarmupEntitiesPerFrame);
for (; _compositeWarmupScanIndex < scanEnd; _compositeWarmupScanIndex++)
{
WorldEntity entity = entities[_compositeWarmupScanIndex];
if (IsCompositeWarmupCandidate(entity, destinationCell, radius))
if (IsCompositeWarmupCandidate(entity, destinationCell, radius)
&& _compositeWarmupTracked.Add(entity))
{
_compositeWarmupQueue.Enqueue(entity);
}
}
_compositeWarmupScanComplete = _compositeWarmupScanIndex == entities.Count;
if (ShouldBeginCompositeWarmupRescan(
_compositeWarmupScanComplete,
_compositeWarmupSourceGeneration,
entityGeneration))
{
BeginCompositeWarmupRescan(entities.Count, entityGeneration);
}
int candidatesThisPass = Math.Min(
_compositeWarmupQueue.Count,
@ -208,16 +230,19 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
IReadOnlyList<WorldEntity>? currentSource,
uint currentDestinationCell,
int currentRadius,
ulong currentGeneration,
IReadOnlyList<WorldEntity> nextSource,
ulong nextGeneration,
uint nextDestinationCell,
int nextRadius) =>
!ReferenceEquals(currentSource, nextSource)
|| currentGeneration != nextGeneration
|| currentDestinationCell != nextDestinationCell
|| currentRadius != nextRadius;
internal static bool ShouldBeginCompositeWarmupRescan(
bool scanComplete,
ulong scanGeneration,
ulong entityGeneration) =>
scanComplete && scanGeneration != entityGeneration;
private void RebuildCompositeWarmupQueue(
IReadOnlyList<WorldEntity> entities,
ulong entityGeneration,
@ -225,6 +250,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
int radius)
{
_compositeWarmupQueue.Clear();
_compositeWarmupTracked.Clear();
_compositeWarmupSource = entities;
_compositeWarmupSourceGeneration = entityGeneration;
_compositeWarmupDestinationCell = destinationCell;
@ -235,6 +261,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
CompositeTexturesReady = _compositeWarmupScanComplete;
}
private void BeginCompositeWarmupRescan(
int entityCount,
ulong entityGeneration)
{
_compositeWarmupSourceGeneration = entityGeneration;
_compositeWarmupScanIndex = 0;
_compositeWarmupScanComplete = entityCount == 0;
CompositeTexturesReady =
_compositeWarmupScanComplete && _compositeWarmupQueue.Count == 0;
}
internal static bool IsCompositeWarmupCandidate(
WorldEntity entity,
uint destinationCell,

View file

@ -19,29 +19,43 @@ public sealed class WbDrawDispatcherCompositeWarmupTests
emptyReadySnapshot,
Destination,
currentRadius: 0,
currentGeneration: 0,
publishedDestinationSnapshot,
nextGeneration: 1,
Destination,
nextRadius: 0));
}
[Fact]
public void MutatedStableEntityViewRequiresRebuildWhenGenerationChanges()
public void MutatedStableEntityViewRetainsScopeWhenGenerationChanges()
{
IReadOnlyList<WorldEntity> stableView = new List<WorldEntity>();
Assert.True(WbDrawDispatcher.RequiresCompositeWarmupRebuild(
Assert.False(WbDrawDispatcher.RequiresCompositeWarmupRebuild(
stableView,
Destination,
currentRadius: 0,
currentGeneration: 41,
stableView,
nextGeneration: 42,
Destination,
nextRadius: 0));
}
[Fact]
public void MembershipMutationDoesNotRestartAnIncompletePass()
{
Assert.False(WbDrawDispatcher.ShouldBeginCompositeWarmupRescan(
scanComplete: false,
scanGeneration: 41,
entityGeneration: 42));
}
[Fact]
public void MembershipMutationSchedulesFollowUpAfterPassCompletes()
{
Assert.True(WbDrawDispatcher.ShouldBeginCompositeWarmupRescan(
scanComplete: true,
scanGeneration: 41,
entityGeneration: 42));
}
[Fact]
public void PaletteEntityInsideDestinationRadiusIsCandidate()
{