diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs
index af169341..38f8bb02 100644
--- a/src/AcDream.App/Composition/LivePresentationComposition.cs
+++ b/src/AcDream.App/Composition/LivePresentationComposition.cs
@@ -375,7 +375,9 @@ internal sealed class LivePresentationCompositionPhase
liveEntities,
static runtime => runtime.Clear());
LiveRenderProjectionJournal? liveRenderProjections =
- renderSceneShadow?.BindLiveRuntime(liveEntities);
+ renderSceneShadow?.BindLiveRuntime(
+ liveEntities,
+ new GpuWorldRenderTraversalOrderSource(worldState));
Fault(LivePresentationCompositionPoint.CanonicalRuntimeCreated);
bindings.Adopt(
diff --git a/src/AcDream.App/Rendering/Scene/GpuWorldRenderTraversalOrderSource.cs b/src/AcDream.App/Rendering/Scene/GpuWorldRenderTraversalOrderSource.cs
new file mode 100644
index 00000000..dab38fb9
--- /dev/null
+++ b/src/AcDream.App/Rendering/Scene/GpuWorldRenderTraversalOrderSource.cs
@@ -0,0 +1,21 @@
+using AcDream.App.Streaming;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering.Scene;
+
+///
+/// Narrow derived adapter over 's canonical spatial
+/// traversal. It exposes no bucket mutation or gameplay lookup to the render
+/// scene.
+///
+internal sealed class GpuWorldRenderTraversalOrderSource(
+ GpuWorldState world) : IRenderTraversalOrderSource
+{
+ private readonly GpuWorldState _world =
+ world ?? throw new ArgumentNullException(nameof(world));
+
+ public bool TryGetTraversalSortKey(
+ WorldEntity entity,
+ out RenderSortKey sortKey) =>
+ _world.TryGetRenderTraversalSortKey(entity, out sortKey);
+}
diff --git a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs
index cbdb2639..7d89d859 100644
--- a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs
+++ b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs
@@ -24,16 +24,20 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
private readonly LiveEntityRuntime _runtime;
private readonly RenderProjectionJournal _journal;
+ private readonly IRenderTraversalOrderSource _traversalOrder;
private readonly Dictionary _byLocalId = [];
private readonly List _activeRootScratch = [];
private readonly List _activeScratch = [];
public LiveRenderProjectionJournal(
LiveEntityRuntime runtime,
- RenderProjectionJournal journal)
+ RenderProjectionJournal journal,
+ IRenderTraversalOrderSource traversalOrder)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_journal = journal ?? throw new ArgumentNullException(nameof(journal));
+ _traversalOrder = traversalOrder
+ ?? throw new ArgumentNullException(nameof(traversalOrder));
}
public int ProjectionCount => _byLocalId.Count;
@@ -257,7 +261,8 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
: fullCellId == 0
? 0
: Canonicalize(fullCellId);
- return RenderProjectionRecordFactory.ProjectEntity(
+ RenderProjectionRecord projected =
+ RenderProjectionRecordFactory.ProjectEntity(
ProjectionId(entity.Id),
record.ProjectionKind is LiveEntityProjectionKind.Attached
? RenderProjectionClass.EquippedChild
@@ -268,6 +273,21 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
fullCellId,
entity,
spatiallyVisible);
+ if (_traversalOrder.TryGetTraversalSortKey(
+ entity,
+ out RenderSortKey sortKey))
+ {
+ return projected with { SortKey = sortKey };
+ }
+
+ // Spatial withdrawal is not logical destruction. Retain the last
+ // resident order while the projection is inactive so a portal/rebucket
+ // edge does not manufacture an unrelated ordering mutation.
+ return _byLocalId.TryGetValue(
+ entity.Id,
+ out TrackedProjection? retained)
+ ? projected with { SortKey = retained.Projection.SortKey }
+ : projected;
}
private void Remove(TrackedProjection tracked)
diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs
index a0f96fa8..abd4ce67 100644
--- a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs
+++ b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs
@@ -178,6 +178,35 @@ internal readonly record struct RenderDegradeState(byte Level, uint Revision);
internal readonly record struct RenderSortKey(ulong Value);
+///
+/// Read-only seam over the spatial owner's exact resident traversal. The
+/// render scene consumes this derived order only; it does not acquire spatial
+/// or gameplay ownership.
+///
+internal interface IRenderTraversalOrderSource
+{
+ bool TryGetTraversalSortKey(
+ WorldEntity entity,
+ out RenderSortKey sortKey);
+}
+
+internal static class RenderTraversalSortKey
+{
+ public static RenderSortKey Compose(
+ uint landblockOrder,
+ int entityIndex)
+ {
+ if (landblockOrder == 0)
+ throw new ArgumentOutOfRangeException(nameof(landblockOrder));
+ if (entityIndex < 0)
+ throw new ArgumentOutOfRangeException(nameof(entityIndex));
+
+ return new RenderSortKey(
+ ((ulong)landblockOrder << 32)
+ | checked((uint)entityIndex));
+ }
+}
+
///
/// Immutable source facts required to compare the derived presentation scene
/// with the accepted current path. These are diagnostics/render inputs only;
diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs
index c91af846..ee06e47f 100644
--- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs
+++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs
@@ -254,8 +254,6 @@ internal sealed class RenderScenePViewFrameProductController :
}
}
- _expected.Sort(CandidateKeyComparer.Instance);
- _actual.Sort(CandidateKeyComparer.Instance);
RenderSceneHash128 expectedDigest = BuildDigest(_expected);
RenderSceneHash128 actualDigest = BuildDigest(_actual);
string? mismatch = CompareKeys();
@@ -641,8 +639,6 @@ internal sealed class RenderScenePViewFrameProductController :
}
}
- _packedExpected.Sort(PackedInputKeyComparer.Instance);
- _packedActual.Sort(PackedInputKeyComparer.Instance);
RenderSceneHash128 expectedDigest =
BuildPackedInputDigest(_packedExpected);
RenderSceneHash128 actualDigest =
@@ -873,44 +869,6 @@ internal sealed class RenderScenePViewFrameProductController :
}
}
- private sealed class CandidateKeyComparer : IComparer
- {
- public static CandidateKeyComparer Instance { get; } = new();
-
- private CandidateKeyComparer()
- {
- }
-
- public int Compare(CandidateKey left, CandidateKey right)
- {
- int value = left.Route.CompareTo(right.Route);
- if (value != 0) return value;
- value = left.RouteIndex.CompareTo(right.RouteIndex);
- if (value != 0) return value;
- value = left.CellId.CompareTo(right.CellId);
- if (value != 0) return value;
- return left.ProjectionId.CompareTo(right.ProjectionId);
- }
- }
-
- private sealed class PackedInputKeyComparer :
- IComparer
- {
- public static PackedInputKeyComparer Instance { get; } = new();
-
- private PackedInputKeyComparer()
- {
- }
-
- public int Compare(PackedInputKey left, PackedInputKey right)
- {
- int value = left.DrawSequence.CompareTo(right.DrawSequence);
- if (value != 0) return value;
- value = left.ProjectionId.CompareTo(right.ProjectionId);
- if (value != 0) return value;
- return left.MeshRefIndex.CompareTo(right.MeshRefIndex);
- }
- }
}
///
diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs b/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs
index 11a8b71f..23cef3c7 100644
--- a/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs
+++ b/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs
@@ -92,17 +92,22 @@ internal sealed class RenderSceneShadowRuntime : IDisposable
internal RenderSceneQuery Query => _scene.OpenQuery();
public LiveRenderProjectionJournal BindLiveRuntime(
- LiveEntityRuntime runtime)
+ LiveEntityRuntime runtime,
+ IRenderTraversalOrderSource traversalOrder)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(runtime);
+ ArgumentNullException.ThrowIfNull(traversalOrder);
if (_live is not null)
{
throw new InvalidOperationException(
"The shadow render scene is already bound to a live runtime.");
}
- _live = new LiveRenderProjectionJournal(runtime, _journal);
+ _live = new LiveRenderProjectionJournal(
+ runtime,
+ _journal,
+ traversalOrder);
return _live;
}
diff --git a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs
index b1512e4c..e6140060 100644
--- a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs
+++ b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs
@@ -63,12 +63,24 @@ internal sealed class StaticRenderProjectionJournal :
if (!publication.RequiresActivation)
return;
+ if (publication.RenderTraversalOrder == 0)
+ {
+ throw new InvalidOperationException(
+ $"Landblock 0x{landblockId:X8} has no render traversal order.");
+ }
_candidates.Clear();
for (int i = 0; i < publication.Landblock.Entities.Count; i++)
{
WorldEntity entity = publication.Landblock.Entities[i];
if (entity.ServerGuid == 0)
- _candidates.Add(ProjectStaticEntity(landblockId, entity));
+ {
+ _candidates.Add(ProjectStaticEntity(
+ landblockId,
+ entity,
+ RenderTraversalSortKey.Compose(
+ publication.RenderTraversalOrder,
+ i)));
+ }
}
if (build.EnvCells is { } envCells)
@@ -237,6 +249,7 @@ internal sealed class StaticRenderProjectionJournal :
{
PreviousTransform = new PreviousRenderTransform(
tracked.Record.Transform.LocalToWorld),
+ SortKey = tracked.Record.SortKey,
};
if (tracked.Record.ProjectionClass
!= RenderProjectionClass.ActiveAnimatedStatic)
@@ -290,7 +303,8 @@ internal sealed class StaticRenderProjectionJournal :
private RenderProjectionRecord ProjectStaticEntity(
uint landblockId,
- WorldEntity entity)
+ WorldEntity entity,
+ RenderSortKey sortKey)
{
RenderProjectionId id = StaticEntityId(landblockId, entity.Id);
uint fullCellId = entity.ParentCellId ?? landblockId;
@@ -303,7 +317,10 @@ internal sealed class StaticRenderProjectionJournal :
landblockId,
fullCellId,
entity,
- spatiallyVisible: true);
+ spatiallyVisible: true) with
+ {
+ SortKey = sortKey,
+ };
}
private static RenderProjectionRecord ProjectEnvCellShell(
diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs
index 556157be..28cbab3b 100644
--- a/src/AcDream.App/Streaming/GpuWorldState.cs
+++ b/src/AcDream.App/Streaming/GpuWorldState.cs
@@ -1,6 +1,8 @@
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Numerics;
+using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.World;
@@ -18,7 +20,8 @@ internal sealed record GpuLandblockSpatialPublication(
LoadedLandblock Landblock,
IReadOnlyList AdditionalRenderIds,
IReadOnlyList StaticEntities,
- bool RequiresActivation = true);
+ bool RequiresActivation = true,
+ uint RenderTraversalOrder = 0);
///
/// Render-thread-owned registry of currently-loaded landblocks and their
@@ -83,6 +86,14 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
}
private readonly Dictionary _loaded = new();
+ // The old renderer consumes landblocks in Dictionary entry-slot order.
+ // Make that formerly implicit ordering explicit so both the accepted path
+ // and the retained render scene share one stable source. Slots are reused
+ // LIFO, matching Dictionary's remove/add behavior, while enumeration skips
+ // retired holes.
+ private readonly List _renderTraversalLandblockSlots = [];
+ private readonly Stack _freeRenderTraversalLandblockSlots = [];
+ private readonly Dictionary _renderTraversalSlotByLandblock = [];
private readonly Dictionary _tierByLandblock = new();
private readonly Dictionary _aabbs = new();
private readonly Dictionary _animatedIndexByLandblock = new();
@@ -252,32 +263,127 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
if (!_availability.IsWorldAvailable)
yield break;
- foreach (var kvp in _loaded)
+ for (int slot = 0; slot < _renderTraversalLandblockSlots.Count; slot++)
{
+ uint landblockId = _renderTraversalLandblockSlots[slot];
+ if (landblockId == 0)
+ continue;
+ LoadedLandblock landblock = _loaded[landblockId];
IReadOnlyDictionary? byId = null;
if (includeAnimatedIndex)
{
- if (!_animatedIndexByLandblock.TryGetValue(kvp.Key, out var cached)
- || !ReferenceEquals(cached.Source, kvp.Value))
+ if (!_animatedIndexByLandblock.TryGetValue(landblockId, out var cached)
+ || !ReferenceEquals(cached.Source, landblock))
{
- var rebuilt = new Dictionary(kvp.Value.Entities.Count);
- foreach (var entity in kvp.Value.Entities)
+ var rebuilt = new Dictionary(landblock.Entities.Count);
+ foreach (var entity in landblock.Entities)
rebuilt[entity.Id] = entity;
- cached = new AnimatedEntityIndex(kvp.Value, rebuilt);
- _animatedIndexByLandblock[kvp.Key] = cached;
+ cached = new AnimatedEntityIndex(landblock, rebuilt);
+ _animatedIndexByLandblock[landblockId] = cached;
}
byId = cached.EntitiesById;
}
- if (_aabbs.TryGetValue(kvp.Key, out var aabb))
- yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId);
+ if (_aabbs.TryGetValue(landblockId, out var aabb))
+ yield return (landblockId, aabb.Min, aabb.Max, landblock.Entities, byId);
else
- yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId);
+ yield return (landblockId, Vector3.Zero, Vector3.Zero, landblock.Entities, byId);
}
}
+ internal bool TryGetRenderTraversalSortKey(
+ WorldEntity entity,
+ out RenderSortKey sortKey)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ if (_projectionLocations.TryGetValue(
+ entity,
+ out ProjectionLocation location)
+ && location.IsLoaded
+ && _renderTraversalSlotByLandblock.TryGetValue(
+ location.LandblockId,
+ out int landblockSlot))
+ {
+ sortKey = RenderTraversalSortKey.Compose(
+ checked((uint)landblockSlot + 1),
+ location.BucketIndex);
+ return true;
+ }
+
+ sortKey = default;
+ return false;
+ }
+
+ private uint GetRenderTraversalOrder(uint landblockId)
+ {
+ if (!_renderTraversalSlotByLandblock.TryGetValue(
+ landblockId,
+ out int slot))
+ {
+ throw new InvalidOperationException(
+ $"Loaded landblock 0x{landblockId:X8} has no render traversal slot.");
+ }
+
+ return checked((uint)slot + 1);
+ }
+
+ private void AddLoadedLandblock(
+ uint landblockId,
+ LoadedLandblock landblock)
+ {
+ if (!_loaded.TryAdd(landblockId, landblock))
+ {
+ throw new InvalidOperationException(
+ $"Landblock 0x{landblockId:X8} is already loaded.");
+ }
+
+ int slot;
+ if (_freeRenderTraversalLandblockSlots.TryPop(out int freeSlot))
+ {
+ slot = freeSlot;
+ if (_renderTraversalLandblockSlots[slot] != 0)
+ {
+ throw new InvalidOperationException(
+ $"Render traversal slot {slot} was not free.");
+ }
+ _renderTraversalLandblockSlots[slot] = landblockId;
+ }
+ else
+ {
+ slot = _renderTraversalLandblockSlots.Count;
+ _renderTraversalLandblockSlots.Add(landblockId);
+ }
+
+ if (!_renderTraversalSlotByLandblock.TryAdd(landblockId, slot))
+ {
+ throw new InvalidOperationException(
+ $"Landblock 0x{landblockId:X8} already owns a render traversal slot.");
+ }
+ }
+
+ private bool RemoveLoadedLandblock(
+ uint landblockId,
+ [NotNullWhen(true)]
+ out LoadedLandblock? landblock)
+ {
+ if (!_loaded.Remove(landblockId, out landblock))
+ return false;
+ if (!_renderTraversalSlotByLandblock.Remove(
+ landblockId,
+ out int slot)
+ || _renderTraversalLandblockSlots[slot] != landblockId)
+ {
+ throw new InvalidOperationException(
+ $"Loaded landblock 0x{landblockId:X8} has inconsistent render traversal state.");
+ }
+
+ _renderTraversalLandblockSlots[slot] = 0;
+ _freeRenderTraversalLandblockSlots.Push(slot);
+ return true;
+ }
+
///
/// Total live entities currently parked in the pending bucket waiting
/// for their landblock to arrive. Useful diagnostic for verifying the
@@ -689,7 +795,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
retained,
Array.Empty(),
Array.Empty(),
- RequiresActivation: false);
+ RequiresActivation: false,
+ RenderTraversalOrder:
+ GetRenderTraversalOrder(retained.LandblockId));
}
// Reconcile the only fallible logical-owner edge before consuming any
@@ -727,18 +835,19 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
tier = LandblockStreamTier.Near;
}
- if (_loaded.Remove(landblock.LandblockId, out displaced))
+ if (RemoveLoadedLandblock(landblock.LandblockId, out displaced))
{
RemoveLoadedLandblockFromFlatView(displaced);
_animatedIndexByLandblock.Remove(landblock.LandblockId);
}
- _loaded[landblock.LandblockId] = landblock;
+ AddLoadedLandblock(landblock.LandblockId, landblock);
AddLoadedLandblockToFlatView(landblock);
_tierByLandblock[landblock.LandblockId] = tier;
return CreateSpatialPublication(
_loaded[landblock.LandblockId],
- (IEnumerable?)mergedRenderIds ?? Array.Empty());
+ (IEnumerable?)mergedRenderIds ?? Array.Empty(),
+ GetRenderTraversalOrder(landblock.LandblockId));
}
///
@@ -804,7 +913,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
private static GpuLandblockSpatialPublication CreateSpatialPublication(
LoadedLandblock landblock,
- IEnumerable additionalRenderIds)
+ IEnumerable additionalRenderIds,
+ uint renderTraversalOrder)
{
ulong[] renderIds = additionalRenderIds as ulong[]
?? additionalRenderIds.ToArray();
@@ -815,7 +925,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
landblock.LandblockId,
landblock,
renderIds,
- staticEntities);
+ staticEntities,
+ RenderTraversalOrder: renderTraversalOrder);
}
///
@@ -1027,7 +1138,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_animatedIndexByLandblock.Remove(canonical);
_tierByLandblock.Remove(canonical);
- if (_loaded.Remove(canonical, out LoadedLandblock? removed))
+ if (RemoveLoadedLandblock(canonical, out LoadedLandblock? removed))
RemoveLoadedLandblockFromFlatView(removed);
for (int i = 0; i < retainedLive.Count; i++)
@@ -1453,7 +1564,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
canonical,
_loaded[canonical],
effectiveRenderIds,
- staticEntities);
+ staticEntities,
+ RenderTraversalOrder: GetRenderTraversalOrder(canonical));
}
private void DrainVisibilityTransitions()
diff --git a/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs b/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs
index 6a7389f4..c87a37a7 100644
--- a/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs
@@ -55,10 +55,13 @@ public sealed class LiveRenderProjectionJournalTests
Load(harness.State, LandblockId);
Assert.True(record.IsSpatiallyVisible);
- Assert.Equal(2, harness.Journal.Count);
+ Assert.Equal(3, harness.Journal.Count);
+ Assert.Equal(
+ RenderProjectionDeltaKind.UpdateTransform,
+ harness.Journal.Pending[1].Kind);
Assert.Equal(
RenderProjectionDeltaKind.UpdateFlags,
- harness.Journal.Pending[1].Kind);
+ harness.Journal.Pending[2].Kind);
const uint secondLandblock = 0x1235FFFFu;
const uint secondCell = 0x12350100u;
@@ -66,13 +69,16 @@ public sealed class LiveRenderProjectionJournalTests
Assert.True(harness.Runtime.RebucketLiveEntity(Guid, secondCell));
harness.Projections.SynchronizeActiveSources();
- Assert.Equal(3, harness.Journal.Count);
+ Assert.Equal(5, harness.Journal.Count);
+ Assert.Equal(
+ RenderProjectionDeltaKind.UpdateTransform,
+ harness.Journal.Pending[3].Kind);
Assert.Equal(
RenderProjectionDeltaKind.Rebucket,
- harness.Journal.Pending[2].Kind);
+ harness.Journal.Pending[4].Kind);
Assert.Equal(
incarnation,
- harness.Journal.Pending[2].Record.OwnerIncarnation);
+ harness.Journal.Pending[4].Record.OwnerIncarnation);
Assert.Equal(1, harness.Projections.ProjectionCount);
}
@@ -357,7 +363,10 @@ public sealed class LiveRenderProjectionJournalTests
var runtime = new LiveEntityRuntime(state, resources);
var journal = new RenderProjectionJournal(
RenderSceneGeneration.FromRaw(1));
- var projections = new LiveRenderProjectionJournal(runtime, journal);
+ var projections = new LiveRenderProjectionJournal(
+ runtime,
+ journal,
+ new GpuWorldRenderTraversalOrderSource(state));
sink = projections;
var scene = new ArchRenderScene(RenderSceneGeneration.FromRaw(1));
return new Harness(state, runtime, journal, projections, scene);
diff --git a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs
index 10b2b482..6bb4d3ff 100644
--- a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs
@@ -262,6 +262,79 @@ public sealed class RenderScenePViewFrameProductTests
Assert.Equal(2, logs.Count);
}
+ [Fact]
+ public void Controller_RejectsEqualMembershipInDifferentTraversalOrder()
+ {
+ WorldEntity first =
+ Entity(1_000_001, 0x8000_0001, parentCell: null);
+ WorldEntity second =
+ Entity(1_000_002, 0x8000_0002, parentCell: null);
+ var oracle = new CurrentRenderSceneOracle();
+ var partition = new InteriorEntityPartition.Result();
+ InteriorEntityPartition.Partition(
+ partition,
+ [],
+ [Entry([first, second])],
+ oracle);
+ oracle.BeginPViewFrame();
+ oracle.ObservePViewBucket(
+ CurrentRenderPViewRoute.DynamicLast,
+ 0,
+ 0,
+ [first, second]);
+ oracle.CompletePViewFrame();
+ oracle.BeginDispatcherFrame();
+ oracle.ObserveDispatcherDraw(
+ WbDrawDispatcher.EntitySet.All,
+ entitiesWalked: 2,
+ [
+ (first, 0, Landblock),
+ (second, 0, Landblock),
+ ]);
+
+ using var shadow = new RenderSceneShadowRuntime(Generation);
+ RenderProjectionRecord firstRecord = Project(first) with
+ {
+ SortKey = new RenderSortKey(2),
+ };
+ RenderProjectionRecord secondRecord = Project(second) with
+ {
+ SortKey = new RenderSortKey(1),
+ };
+ shadow.Journal.Register(in firstRecord);
+ shadow.Journal.Register(in secondRecord);
+ shadow.DrainUpdateBoundary();
+ var controller = new RenderScenePViewFrameProductController(
+ shadow,
+ oracle);
+ PortalVisibilityFrame portal = Portal(Cell);
+ ClipFrameAssembly clip = FullScreenClip(Cell);
+ ViewconeCuller viewcone =
+ ViewconeCuller.Build(clip, Matrix4x4.Identity);
+
+ controller.BuildAndCompare(
+ portal,
+ clip,
+ viewcone,
+ [],
+ [],
+ EmptyCellSource.Instance,
+ [],
+ Landblock,
+ rootIsOutdoor: true);
+
+ RenderFrameProductComparisonSnapshot snapshot =
+ controller.Snapshot;
+ Assert.Equal(1, snapshot.MismatchCount);
+ Assert.Contains(
+ $"ProjectionId = {LiveRenderProjectionJournal.ProjectionId(first.Id)}",
+ snapshot.FirstMismatch);
+ Assert.Contains(
+ $"ProjectionId = {LiveRenderProjectionJournal.ProjectionId(second.Id)}",
+ snapshot.FirstMismatch);
+ Assert.Equal(1, snapshot.PackedInputMismatchCount);
+ }
+
private static RenderProjectionRecord Project(WorldEntity entity)
{
bool dynamic = entity.ServerGuid != 0;
diff --git a/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs b/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs
index 622ece6c..1ddbbda7 100644
--- a/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/StaticRenderProjectionJournalTests.cs
@@ -137,6 +137,74 @@ public sealed class StaticRenderProjectionJournalTests
StaticRenderProjectionJournal.StaticEntityId(otherLandblock, 42));
}
+ [Fact]
+ public void Reconcile_PreservesResidentTraversalOrderInSortKeys()
+ {
+ const uint secondLandblock = 0x5678FFFFu;
+ var journal = new RenderProjectionJournal(Generation(10));
+ var statics = new StaticRenderProjectionJournal(journal);
+ WorldEntity first = Entity(99);
+ WorldEntity liveGap = Entity(77, serverGuid: 0x80000077u);
+ WorldEntity second = Entity(1);
+ LandblockBuild firstBuild = Build(
+ LandblockId,
+ [first, liveGap, second],
+ includeShell: false);
+ LandblockBuild secondBuild = Build(
+ secondLandblock,
+ [Entity(500)],
+ includeShell: false);
+
+ statics.Reconcile(firstBuild, Publication(firstBuild));
+ statics.Reconcile(
+ secondBuild,
+ Publication(secondBuild, renderTraversalOrder: 2));
+
+ Assert.True(statics.TryGet(
+ StaticRenderProjectionJournal.StaticEntityId(
+ LandblockId,
+ first.Id),
+ out RenderProjectionRecord firstRecord));
+ Assert.True(statics.TryGet(
+ StaticRenderProjectionJournal.StaticEntityId(
+ LandblockId,
+ second.Id),
+ out RenderProjectionRecord secondRecord));
+ Assert.True(statics.TryGet(
+ StaticRenderProjectionJournal.StaticEntityId(
+ secondLandblock,
+ 500),
+ out RenderProjectionRecord laterLandblockRecord));
+ Assert.True(firstRecord.SortKey.Value < secondRecord.SortKey.Value);
+ Assert.True(
+ secondRecord.SortKey.Value
+ < laterLandblockRecord.SortKey.Value);
+ Assert.Equal(
+ 2u,
+ unchecked((uint)secondRecord.SortKey.Value));
+
+ LandblockBuild reordered = Build(
+ LandblockId,
+ [second, first],
+ includeShell: false);
+ statics.Reconcile(reordered, Publication(reordered));
+
+ Assert.True(statics.TryGet(
+ StaticRenderProjectionJournal.StaticEntityId(
+ LandblockId,
+ first.Id),
+ out firstRecord));
+ Assert.True(statics.TryGet(
+ StaticRenderProjectionJournal.StaticEntityId(
+ LandblockId,
+ second.Id),
+ out secondRecord));
+ Assert.True(secondRecord.SortKey.Value < firstRecord.SortKey.Value);
+ Assert.Equal(
+ 2uL,
+ laterLandblockRecord.SortKey.Value >> 32);
+ }
+
[Fact]
public void StaleFarPublication_DoesNotMutateProjectionJournal()
{
@@ -307,14 +375,16 @@ public sealed class StaticRenderProjectionJournalTests
private static GpuLandblockSpatialPublication Publication(
LandblockBuild build,
- bool requiresActivation = true) =>
+ bool requiresActivation = true,
+ uint renderTraversalOrder = 1) =>
new(
build.LandblockId,
build.Landblock,
build.EnvCells?.Shells.Select(shell => shell.GeometryId).ToArray()
?? [],
build.Landblock.Entities.Where(entity => entity.ServerGuid == 0).ToArray(),
- requiresActivation);
+ requiresActivation,
+ renderTraversalOrder);
private static LandblockBuild Build(
uint landblockId,
diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateRenderTraversalTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateRenderTraversalTests.cs
new file mode 100644
index 00000000..24aec76d
--- /dev/null
+++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateRenderTraversalTests.cs
@@ -0,0 +1,107 @@
+using System.Numerics;
+using AcDream.App.Rendering.Scene;
+using AcDream.App.Streaming;
+using AcDream.Core.Physics;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.App.Tests.Streaming;
+
+public sealed class GpuWorldStateRenderTraversalTests
+{
+ private const uint FirstLandblock = 0x1234FFFFu;
+ private const uint SecondLandblock = 0x1235FFFFu;
+ private const uint ReusedLandblock = 0x1236FFFFu;
+
+ [Fact]
+ public void TraversalKeys_MirrorResidentLandblockAndMutableBucketOrder()
+ {
+ var state = new GpuWorldState();
+ WorldEntity first = Entity(1, 0x80000001u);
+ WorldEntity moved = Entity(2, 0x80000002u);
+ WorldEntity secondLandblockEntity = Entity(3, 0x80000003u);
+ state.AddLandblock(Landblock(FirstLandblock, first, moved));
+ state.AddLandblock(Landblock(
+ SecondLandblock,
+ secondLandblockEntity));
+
+ AssertSort(state, first, landblockOrder: 1, entityIndex: 0);
+ AssertSort(state, moved, landblockOrder: 1, entityIndex: 1);
+ AssertSort(
+ state,
+ secondLandblockEntity,
+ landblockOrder: 2,
+ entityIndex: 0);
+
+ state.RemoveLiveEntityProjection(first);
+
+ AssertSort(state, moved, landblockOrder: 1, entityIndex: 0);
+
+ WorldEntity replacement = Entity(4, 0x80000004u);
+ state.AddLandblock(Landblock(SecondLandblock, replacement));
+
+ Assert.False(state.TryGetRenderTraversalSortKey(
+ secondLandblockEntity,
+ out _));
+ AssertSort(state, replacement, landblockOrder: 2, entityIndex: 0);
+
+ state.RemoveLandblock(FirstLandblock);
+ WorldEntity reused = Entity(5, 0x80000005u);
+ state.AddLandblock(Landblock(ReusedLandblock, reused));
+
+ AssertSort(state, reused, landblockOrder: 1, entityIndex: 0);
+ Assert.Equal(
+ [ReusedLandblock, SecondLandblock],
+ state.LandblockEntriesWithoutAnimatedIndex
+ .Select(static entry => entry.LandblockId)
+ .ToArray());
+ }
+
+ [Fact]
+ public void TraversalKey_IsUnavailableWhileProjectionIsPending()
+ {
+ var state = new GpuWorldState();
+ WorldEntity pending = Entity(1, 0x80000001u);
+
+ state.PlaceLiveEntityProjection(FirstLandblock, pending);
+
+ Assert.False(state.TryGetRenderTraversalSortKey(pending, out _));
+ }
+
+ private static void AssertSort(
+ GpuWorldState state,
+ WorldEntity entity,
+ uint landblockOrder,
+ int entityIndex)
+ {
+ Assert.True(state.TryGetRenderTraversalSortKey(
+ entity,
+ out RenderSortKey actual));
+ Assert.Equal(
+ RenderTraversalSortKey.Compose(landblockOrder, entityIndex),
+ actual);
+ }
+
+ private static LoadedLandblock Landblock(
+ uint landblockId,
+ params WorldEntity[] entities) =>
+ new(
+ landblockId,
+ new LandBlock(),
+ entities,
+ PhysicsDatBundle.Empty);
+
+ private static WorldEntity Entity(uint id, uint serverGuid) =>
+ new()
+ {
+ Id = id,
+ ServerGuid = serverGuid,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs =
+ [
+ new MeshRef(0x01000001u, Matrix4x4.Identity),
+ ],
+ };
+}