fix(streaming): prepare quiesced destination entities

Separate loaded spatial residency from the world presentation gate so destination live objects acquire mesh owners behind portal space while drawing and simulation stay quiesced. Prevent unowned CPU-cache hits from recreating stale GPU staging work.

Co-authored-by: Erik Nilsson <erikn@users.noreply.github.com>
This commit is contained in:
Erik 2026-07-24 20:21:57 +02:00
parent e991eeca34
commit 91e82c3c68
6 changed files with 142 additions and 23 deletions

View file

@ -11,6 +11,7 @@ public sealed class MeshUploadCachesTests
{
var staging = new MeshUploadStagingQueue();
var cache = new CpuMeshUploadCache(capacity: 2);
var ownership = Owned(0x01000010ul);
byte[] textureBytes = [1, 2, 3, 4];
var data = new ObjectMeshData { ObjectId = 0x01000010ul, UploadAttempts = 2 };
data.TextureBatches[(1, 1, TextureFormat.RGBA8)] =
@ -25,11 +26,13 @@ public sealed class MeshUploadCachesTests
staging.Complete(initial); // GPU upload completed, then was later evicted.
data.UploadAttempts = 3;
Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out var cached, out _));
Assert.True(cache.TryGetAndStageOwned(
data.ObjectId, staging, ownership, out var cached, out _));
Assert.Same(data, cached);
Assert.Equal(0, data.UploadAttempts);
Assert.Equal(textureBytes, cached!.TextureBatches.Values.Single().Single().TextureData);
Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out _, out _));
Assert.True(cache.TryGetAndStageOwned(
data.ObjectId, staging, ownership, out _, out _));
Assert.True(staging.TryDequeue(out var restaged));
Assert.Same(data, restaged.Data);
Assert.False(staging.TryDequeue(out _));
@ -89,19 +92,22 @@ public sealed class MeshUploadCachesTests
{
var staging = new MeshUploadStagingQueue(maximumCount: 8, maximumBytes: 64);
var cache = new CpuMeshUploadCache(capacity: 2);
var ownership = Owned(2);
ObjectMeshData queued = DataWithTexture(1, 40);
ObjectMeshData cached = DataWithTexture(2, 40);
cache.Store(cached);
Assert.True(staging.Stage(queued));
Assert.True(cache.TryGetAndStage(2, staging, out ObjectMeshData? found, out MeshStageResult result));
Assert.True(cache.TryGetAndStageOwned(
2, staging, ownership, out ObjectMeshData? found, out MeshStageResult result));
Assert.Same(cached, found);
Assert.Equal(MeshStageResult.HighWater, result);
Assert.Equal(1, staging.Count);
Assert.True(staging.TryDequeue(out MeshUploadQueueItem queuedItem));
staging.Complete(queuedItem);
Assert.True(cache.TryGetAndStage(2, staging, out _, out result));
Assert.True(cache.TryGetAndStageOwned(
2, staging, ownership, out _, out result));
Assert.Equal(MeshStageResult.Staged, result);
Assert.True(staging.TryDequeue(out MeshUploadQueueItem staged));
Assert.Same(cached, staged.Data);
@ -142,23 +148,60 @@ public sealed class MeshUploadCachesTests
Assert.Equal(0, staging.Count);
ownership.Acquire(id);
Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? rearmed, out _));
Assert.True(cache.TryGetAndStageOwned(
id, staging, ownership, out ObjectMeshData? rearmed, out _));
Assert.Same(data, rearmed);
Assert.Equal(0, staging.DiscardUnownedPrefix(ownership, maximum: 1));
Assert.True(staging.TryPeek(out MeshUploadQueueItem queued));
Assert.Same(data, queued.Data);
}
[Fact]
public void CpuCacheHitWithoutOwnerDoesNotRecreateStaleUploadWork()
{
const ulong id = 0x01000010;
var staging = new MeshUploadStagingQueue();
var cache = new CpuMeshUploadCache(capacity: 2);
var ownership = new MeshOwnershipCounter();
ObjectMeshData data = DataWithTexture(id, 16);
cache.Store(data);
Assert.True(cache.TryGetAndStageOwned(
id,
staging,
ownership,
out ObjectMeshData? cached,
out MeshStageResult result));
Assert.Same(data, cached);
Assert.Equal(MeshStageResult.NotOwned, result);
Assert.Equal(0, staging.Count);
Assert.Equal(0, staging.ClaimCount);
ownership.Acquire(id);
Assert.True(cache.TryGetAndStageOwned(
id,
staging,
ownership,
out _,
out result));
Assert.Equal(MeshStageResult.Staged, result);
Assert.Equal(1, staging.Count);
}
[Fact]
public void CpuCacheUsesByteBudgetInAdditionToEntryCount()
{
var staging = new MeshUploadStagingQueue();
var cache = new CpuMeshUploadCache(capacity: 10, byteCapacity: 64);
var ownership = Owned(1, 2);
cache.Store(DataWithTexture(1, 40));
cache.Store(DataWithTexture(2, 40));
Assert.False(cache.TryGetAndStage(1, staging, out _, out _));
Assert.True(cache.TryGetAndStage(2, staging, out _, out _));
Assert.False(cache.TryGetAndStageOwned(
1, staging, ownership, out _, out _));
Assert.True(cache.TryGetAndStageOwned(
2, staging, ownership, out _, out _));
Assert.Equal(1, cache.Count);
Assert.Equal(40, cache.ResidentBytes);
}
@ -168,6 +211,7 @@ public sealed class MeshUploadCachesTests
{
var staging = new MeshUploadStagingQueue(maximumBytes: 256);
var cache = new CpuMeshUploadCache(capacity: 2, byteCapacity: 64);
var ownership = Owned(1, 2);
ObjectMeshData retained = DataWithTexture(1, 40);
ObjectMeshData oversized = DataWithTexture(2, 80);
@ -176,8 +220,10 @@ public sealed class MeshUploadCachesTests
Assert.Equal(1, cache.Count);
Assert.Equal(40, cache.ResidentBytes);
Assert.False(cache.TryGetAndStage(2, staging, out _, out _));
Assert.True(cache.TryGetAndStage(1, staging, out ObjectMeshData? found, out _));
Assert.False(cache.TryGetAndStageOwned(
2, staging, ownership, out _, out _));
Assert.True(cache.TryGetAndStageOwned(
1, staging, ownership, out ObjectMeshData? found, out _));
Assert.Same(retained, found);
}
@ -186,11 +232,13 @@ public sealed class MeshUploadCachesTests
{
var staging = new MeshUploadStagingQueue(maximumBytes: 256);
var cache = new CpuMeshUploadCache(capacity: 2, byteCapacity: 64);
var ownership = Owned(1);
ObjectMeshData retained = DataWithTexture(1, 40);
Assert.True(cache.Store(retained));
Assert.False(cache.Store(DataWithTexture(1, 80)));
Assert.True(cache.TryGetAndStage(1, staging, out ObjectMeshData? found, out _));
Assert.True(cache.TryGetAndStageOwned(
1, staging, ownership, out ObjectMeshData? found, out _));
Assert.Same(retained, found);
Assert.Equal(40, cache.ResidentBytes);
}
@ -211,7 +259,8 @@ public sealed class MeshUploadCachesTests
// The new owner cache-hits while the dequeued generation still owns
// staging's id claim, so its direct Stage attempt cannot enqueue yet.
ownership.Acquire(id);
Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? cached, out _));
Assert.True(cache.TryGetAndStageOwned(
id, staging, ownership, out ObjectMeshData? cached, out _));
Assert.Same(data, cached);
Assert.Equal(0, staging.Count);
@ -237,7 +286,8 @@ public sealed class MeshUploadCachesTests
Assert.Equal(0, staging.Count);
ownership.Acquire(id);
Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? cached, out _));
Assert.True(cache.TryGetAndStageOwned(
id, staging, ownership, out ObjectMeshData? cached, out _));
Assert.Same(data, cached);
Assert.True(staging.TryDequeue(out MeshUploadQueueItem handedOff));
Assert.Same(data, handedOff.Data);
@ -305,10 +355,13 @@ public sealed class MeshUploadCachesTests
// is auditable from an artifact alone.
var staging = new MeshUploadStagingQueue();
var cache = new CpuMeshUploadCache(capacity: 1);
var ownership = Owned(1);
Assert.False(cache.TryGetAndStage(1, staging, out _, out _)); // miss
Assert.False(cache.TryGetAndStageOwned(
1, staging, ownership, out _, out _)); // miss
cache.Store(DataWithTexture(1, 4));
Assert.True(cache.TryGetAndStage(1, staging, out _, out _)); // hit
Assert.True(cache.TryGetAndStageOwned(
1, staging, ownership, out _, out _)); // hit
cache.Store(DataWithTexture(2, 4)); // capacity=1 evicts id 1
CacheStats stats = cache.Stats;
@ -317,6 +370,14 @@ public sealed class MeshUploadCachesTests
Assert.Equal(1, stats.Evictions);
}
private static MeshOwnershipCounter Owned(params ulong[] ids)
{
var ownership = new MeshOwnershipCounter();
foreach (ulong id in ids)
ownership.Acquire(id);
return ownership;
}
private static ObjectMeshData DataWithTexture(ulong id, int bytes)
{
var data = new ObjectMeshData { ObjectId = id };

View file

@ -435,6 +435,43 @@ public sealed class GpuWorldStateVisibilityTests
Assert.Equal(0, state.PendingVisibilityTransitionCount);
}
[Fact]
public void QuiescedDestinationProjection_AcquiresSpatialPresentationBeforeWorldReveal()
{
const uint landblock = 0x0101FFFFu;
const uint cell = 0x01010001u;
const uint guid = 0x70000021u;
var availability = new WorldGenerationAvailabilityState();
availability.Begin(7);
var state = new GpuWorldState(availability: availability);
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
var runtime = new LiveEntityRuntime(
state,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
WorldSession.EntitySpawn spawn = Spawn(guid, cell);
runtime.RegisterLiveEntity(spawn);
var edges = new List<bool>();
runtime.ProjectionVisibilityChanged += (_, visible) => edges.Add(visible);
runtime.MaterializeLiveEntity(
guid,
cell,
id => Entity(id, guid));
Assert.False(state.IsLiveEntityVisible(guid));
Assert.True(state.IsLiveEntityProjectionResident(guid));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
Assert.True(record.IsSpatiallyVisible);
Assert.Equal([true], edges);
Assert.True(availability.End(7));
Assert.True(state.IsLiveEntityVisible(guid));
Assert.True(record.IsSpatiallyVisible);
}
[Fact]
public void CopyLiveEntitiesNearLandblock_UsesBoundedNeighborhoodAndSkipsStatics()
{