fix(rendering): preserve exact scene traversal order

This commit is contained in:
Erik 2026-07-25 02:03:58 +02:00
parent 06e7754619
commit e0f36caa70
12 changed files with 500 additions and 77 deletions

View file

@ -375,7 +375,9 @@ internal sealed class LivePresentationCompositionPhase
liveEntities, liveEntities,
static runtime => runtime.Clear()); static runtime => runtime.Clear());
LiveRenderProjectionJournal? liveRenderProjections = LiveRenderProjectionJournal? liveRenderProjections =
renderSceneShadow?.BindLiveRuntime(liveEntities); renderSceneShadow?.BindLiveRuntime(
liveEntities,
new GpuWorldRenderTraversalOrderSource(worldState));
Fault(LivePresentationCompositionPoint.CanonicalRuntimeCreated); Fault(LivePresentationCompositionPoint.CanonicalRuntimeCreated);
bindings.Adopt( bindings.Adopt(

View file

@ -0,0 +1,21 @@
using AcDream.App.Streaming;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Scene;
/// <summary>
/// Narrow derived adapter over <see cref="GpuWorldState"/>'s canonical spatial
/// traversal. It exposes no bucket mutation or gameplay lookup to the render
/// scene.
/// </summary>
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);
}

View file

@ -24,16 +24,20 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
private readonly LiveEntityRuntime _runtime; private readonly LiveEntityRuntime _runtime;
private readonly RenderProjectionJournal _journal; private readonly RenderProjectionJournal _journal;
private readonly IRenderTraversalOrderSource _traversalOrder;
private readonly Dictionary<uint, TrackedProjection> _byLocalId = []; private readonly Dictionary<uint, TrackedProjection> _byLocalId = [];
private readonly List<LiveEntityRecord> _activeRootScratch = []; private readonly List<LiveEntityRecord> _activeRootScratch = [];
private readonly List<TrackedProjection> _activeScratch = []; private readonly List<TrackedProjection> _activeScratch = [];
public LiveRenderProjectionJournal( public LiveRenderProjectionJournal(
LiveEntityRuntime runtime, LiveEntityRuntime runtime,
RenderProjectionJournal journal) RenderProjectionJournal journal,
IRenderTraversalOrderSource traversalOrder)
{ {
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_journal = journal ?? throw new ArgumentNullException(nameof(journal)); _journal = journal ?? throw new ArgumentNullException(nameof(journal));
_traversalOrder = traversalOrder
?? throw new ArgumentNullException(nameof(traversalOrder));
} }
public int ProjectionCount => _byLocalId.Count; public int ProjectionCount => _byLocalId.Count;
@ -257,7 +261,8 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
: fullCellId == 0 : fullCellId == 0
? 0 ? 0
: Canonicalize(fullCellId); : Canonicalize(fullCellId);
return RenderProjectionRecordFactory.ProjectEntity( RenderProjectionRecord projected =
RenderProjectionRecordFactory.ProjectEntity(
ProjectionId(entity.Id), ProjectionId(entity.Id),
record.ProjectionKind is LiveEntityProjectionKind.Attached record.ProjectionKind is LiveEntityProjectionKind.Attached
? RenderProjectionClass.EquippedChild ? RenderProjectionClass.EquippedChild
@ -268,6 +273,21 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
fullCellId, fullCellId,
entity, entity,
spatiallyVisible); 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) private void Remove(TrackedProjection tracked)

View file

@ -178,6 +178,35 @@ internal readonly record struct RenderDegradeState(byte Level, uint Revision);
internal readonly record struct RenderSortKey(ulong Value); internal readonly record struct RenderSortKey(ulong Value);
/// <summary>
/// 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.
/// </summary>
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));
}
}
/// <summary> /// <summary>
/// Immutable source facts required to compare the derived presentation scene /// Immutable source facts required to compare the derived presentation scene
/// with the accepted current path. These are diagnostics/render inputs only; /// with the accepted current path. These are diagnostics/render inputs only;

View file

@ -254,8 +254,6 @@ internal sealed class RenderScenePViewFrameProductController :
} }
} }
_expected.Sort(CandidateKeyComparer.Instance);
_actual.Sort(CandidateKeyComparer.Instance);
RenderSceneHash128 expectedDigest = BuildDigest(_expected); RenderSceneHash128 expectedDigest = BuildDigest(_expected);
RenderSceneHash128 actualDigest = BuildDigest(_actual); RenderSceneHash128 actualDigest = BuildDigest(_actual);
string? mismatch = CompareKeys(); string? mismatch = CompareKeys();
@ -641,8 +639,6 @@ internal sealed class RenderScenePViewFrameProductController :
} }
} }
_packedExpected.Sort(PackedInputKeyComparer.Instance);
_packedActual.Sort(PackedInputKeyComparer.Instance);
RenderSceneHash128 expectedDigest = RenderSceneHash128 expectedDigest =
BuildPackedInputDigest(_packedExpected); BuildPackedInputDigest(_packedExpected);
RenderSceneHash128 actualDigest = RenderSceneHash128 actualDigest =
@ -873,44 +869,6 @@ internal sealed class RenderScenePViewFrameProductController :
} }
} }
private sealed class CandidateKeyComparer : IComparer<CandidateKey>
{
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<PackedInputKey>
{
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);
}
}
} }
/// <summary> /// <summary>

View file

@ -92,17 +92,22 @@ internal sealed class RenderSceneShadowRuntime : IDisposable
internal RenderSceneQuery Query => _scene.OpenQuery(); internal RenderSceneQuery Query => _scene.OpenQuery();
public LiveRenderProjectionJournal BindLiveRuntime( public LiveRenderProjectionJournal BindLiveRuntime(
LiveEntityRuntime runtime) LiveEntityRuntime runtime,
IRenderTraversalOrderSource traversalOrder)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(traversalOrder);
if (_live is not null) if (_live is not null)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"The shadow render scene is already bound to a live runtime."); "The shadow render scene is already bound to a live runtime.");
} }
_live = new LiveRenderProjectionJournal(runtime, _journal); _live = new LiveRenderProjectionJournal(
runtime,
_journal,
traversalOrder);
return _live; return _live;
} }

View file

@ -63,12 +63,24 @@ internal sealed class StaticRenderProjectionJournal :
if (!publication.RequiresActivation) if (!publication.RequiresActivation)
return; return;
if (publication.RenderTraversalOrder == 0)
{
throw new InvalidOperationException(
$"Landblock 0x{landblockId:X8} has no render traversal order.");
}
_candidates.Clear(); _candidates.Clear();
for (int i = 0; i < publication.Landblock.Entities.Count; i++) for (int i = 0; i < publication.Landblock.Entities.Count; i++)
{ {
WorldEntity entity = publication.Landblock.Entities[i]; WorldEntity entity = publication.Landblock.Entities[i];
if (entity.ServerGuid == 0) if (entity.ServerGuid == 0)
_candidates.Add(ProjectStaticEntity(landblockId, entity)); {
_candidates.Add(ProjectStaticEntity(
landblockId,
entity,
RenderTraversalSortKey.Compose(
publication.RenderTraversalOrder,
i)));
}
} }
if (build.EnvCells is { } envCells) if (build.EnvCells is { } envCells)
@ -237,6 +249,7 @@ internal sealed class StaticRenderProjectionJournal :
{ {
PreviousTransform = new PreviousRenderTransform( PreviousTransform = new PreviousRenderTransform(
tracked.Record.Transform.LocalToWorld), tracked.Record.Transform.LocalToWorld),
SortKey = tracked.Record.SortKey,
}; };
if (tracked.Record.ProjectionClass if (tracked.Record.ProjectionClass
!= RenderProjectionClass.ActiveAnimatedStatic) != RenderProjectionClass.ActiveAnimatedStatic)
@ -290,7 +303,8 @@ internal sealed class StaticRenderProjectionJournal :
private RenderProjectionRecord ProjectStaticEntity( private RenderProjectionRecord ProjectStaticEntity(
uint landblockId, uint landblockId,
WorldEntity entity) WorldEntity entity,
RenderSortKey sortKey)
{ {
RenderProjectionId id = StaticEntityId(landblockId, entity.Id); RenderProjectionId id = StaticEntityId(landblockId, entity.Id);
uint fullCellId = entity.ParentCellId ?? landblockId; uint fullCellId = entity.ParentCellId ?? landblockId;
@ -303,7 +317,10 @@ internal sealed class StaticRenderProjectionJournal :
landblockId, landblockId,
fullCellId, fullCellId,
entity, entity,
spatiallyVisible: true); spatiallyVisible: true) with
{
SortKey = sortKey,
};
} }
private static RenderProjectionRecord ProjectEnvCellShell( private static RenderProjectionRecord ProjectEnvCellShell(

View file

@ -1,6 +1,8 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.App.World; using AcDream.App.World;
@ -18,7 +20,8 @@ internal sealed record GpuLandblockSpatialPublication(
LoadedLandblock Landblock, LoadedLandblock Landblock,
IReadOnlyList<ulong> AdditionalRenderIds, IReadOnlyList<ulong> AdditionalRenderIds,
IReadOnlyList<WorldEntity> StaticEntities, IReadOnlyList<WorldEntity> StaticEntities,
bool RequiresActivation = true); bool RequiresActivation = true,
uint RenderTraversalOrder = 0);
/// <summary> /// <summary>
/// Render-thread-owned registry of currently-loaded landblocks and their /// Render-thread-owned registry of currently-loaded landblocks and their
@ -83,6 +86,14 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
} }
private readonly Dictionary<uint, LoadedLandblock> _loaded = new(); private readonly Dictionary<uint, LoadedLandblock> _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<uint> _renderTraversalLandblockSlots = [];
private readonly Stack<int> _freeRenderTraversalLandblockSlots = [];
private readonly Dictionary<uint, int> _renderTraversalSlotByLandblock = [];
private readonly Dictionary<uint, LandblockStreamTier> _tierByLandblock = new(); private readonly Dictionary<uint, LandblockStreamTier> _tierByLandblock = new();
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new(); private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
private readonly Dictionary<uint, AnimatedEntityIndex> _animatedIndexByLandblock = new(); private readonly Dictionary<uint, AnimatedEntityIndex> _animatedIndexByLandblock = new();
@ -252,32 +263,127 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
if (!_availability.IsWorldAvailable) if (!_availability.IsWorldAvailable)
yield break; 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<uint, WorldEntity>? byId = null; IReadOnlyDictionary<uint, WorldEntity>? byId = null;
if (includeAnimatedIndex) if (includeAnimatedIndex)
{ {
if (!_animatedIndexByLandblock.TryGetValue(kvp.Key, out var cached) if (!_animatedIndexByLandblock.TryGetValue(landblockId, out var cached)
|| !ReferenceEquals(cached.Source, kvp.Value)) || !ReferenceEquals(cached.Source, landblock))
{ {
var rebuilt = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count); var rebuilt = new Dictionary<uint, WorldEntity>(landblock.Entities.Count);
foreach (var entity in kvp.Value.Entities) foreach (var entity in landblock.Entities)
rebuilt[entity.Id] = entity; rebuilt[entity.Id] = entity;
cached = new AnimatedEntityIndex(kvp.Value, rebuilt); cached = new AnimatedEntityIndex(landblock, rebuilt);
_animatedIndexByLandblock[kvp.Key] = cached; _animatedIndexByLandblock[landblockId] = cached;
} }
byId = cached.EntitiesById; byId = cached.EntitiesById;
} }
if (_aabbs.TryGetValue(kvp.Key, out var aabb)) if (_aabbs.TryGetValue(landblockId, out var aabb))
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId); yield return (landblockId, aabb.Min, aabb.Max, landblock.Entities, byId);
else 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;
}
/// <summary> /// <summary>
/// Total live entities currently parked in the pending bucket waiting /// Total live entities currently parked in the pending bucket waiting
/// for their landblock to arrive. Useful diagnostic for verifying the /// for their landblock to arrive. Useful diagnostic for verifying the
@ -689,7 +795,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
retained, retained,
Array.Empty<ulong>(), Array.Empty<ulong>(),
Array.Empty<WorldEntity>(), Array.Empty<WorldEntity>(),
RequiresActivation: false); RequiresActivation: false,
RenderTraversalOrder:
GetRenderTraversalOrder(retained.LandblockId));
} }
// Reconcile the only fallible logical-owner edge before consuming any // Reconcile the only fallible logical-owner edge before consuming any
@ -727,18 +835,19 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
tier = LandblockStreamTier.Near; tier = LandblockStreamTier.Near;
} }
if (_loaded.Remove(landblock.LandblockId, out displaced)) if (RemoveLoadedLandblock(landblock.LandblockId, out displaced))
{ {
RemoveLoadedLandblockFromFlatView(displaced); RemoveLoadedLandblockFromFlatView(displaced);
_animatedIndexByLandblock.Remove(landblock.LandblockId); _animatedIndexByLandblock.Remove(landblock.LandblockId);
} }
_loaded[landblock.LandblockId] = landblock; AddLoadedLandblock(landblock.LandblockId, landblock);
AddLoadedLandblockToFlatView(landblock); AddLoadedLandblockToFlatView(landblock);
_tierByLandblock[landblock.LandblockId] = tier; _tierByLandblock[landblock.LandblockId] = tier;
return CreateSpatialPublication( return CreateSpatialPublication(
_loaded[landblock.LandblockId], _loaded[landblock.LandblockId],
(IEnumerable<ulong>?)mergedRenderIds ?? Array.Empty<ulong>()); (IEnumerable<ulong>?)mergedRenderIds ?? Array.Empty<ulong>(),
GetRenderTraversalOrder(landblock.LandblockId));
} }
/// <summary> /// <summary>
@ -804,7 +913,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
private static GpuLandblockSpatialPublication CreateSpatialPublication( private static GpuLandblockSpatialPublication CreateSpatialPublication(
LoadedLandblock landblock, LoadedLandblock landblock,
IEnumerable<ulong> additionalRenderIds) IEnumerable<ulong> additionalRenderIds,
uint renderTraversalOrder)
{ {
ulong[] renderIds = additionalRenderIds as ulong[] ulong[] renderIds = additionalRenderIds as ulong[]
?? additionalRenderIds.ToArray(); ?? additionalRenderIds.ToArray();
@ -815,7 +925,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
landblock.LandblockId, landblock.LandblockId,
landblock, landblock,
renderIds, renderIds,
staticEntities); staticEntities,
RenderTraversalOrder: renderTraversalOrder);
} }
/// <summary> /// <summary>
@ -1027,7 +1138,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_animatedIndexByLandblock.Remove(canonical); _animatedIndexByLandblock.Remove(canonical);
_tierByLandblock.Remove(canonical); _tierByLandblock.Remove(canonical);
if (_loaded.Remove(canonical, out LoadedLandblock? removed)) if (RemoveLoadedLandblock(canonical, out LoadedLandblock? removed))
RemoveLoadedLandblockFromFlatView(removed); RemoveLoadedLandblockFromFlatView(removed);
for (int i = 0; i < retainedLive.Count; i++) for (int i = 0; i < retainedLive.Count; i++)
@ -1453,7 +1564,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
canonical, canonical,
_loaded[canonical], _loaded[canonical],
effectiveRenderIds, effectiveRenderIds,
staticEntities); staticEntities,
RenderTraversalOrder: GetRenderTraversalOrder(canonical));
} }
private void DrainVisibilityTransitions() private void DrainVisibilityTransitions()

View file

@ -55,10 +55,13 @@ public sealed class LiveRenderProjectionJournalTests
Load(harness.State, LandblockId); Load(harness.State, LandblockId);
Assert.True(record.IsSpatiallyVisible); 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( Assert.Equal(
RenderProjectionDeltaKind.UpdateFlags, RenderProjectionDeltaKind.UpdateFlags,
harness.Journal.Pending[1].Kind); harness.Journal.Pending[2].Kind);
const uint secondLandblock = 0x1235FFFFu; const uint secondLandblock = 0x1235FFFFu;
const uint secondCell = 0x12350100u; const uint secondCell = 0x12350100u;
@ -66,13 +69,16 @@ public sealed class LiveRenderProjectionJournalTests
Assert.True(harness.Runtime.RebucketLiveEntity(Guid, secondCell)); Assert.True(harness.Runtime.RebucketLiveEntity(Guid, secondCell));
harness.Projections.SynchronizeActiveSources(); 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( Assert.Equal(
RenderProjectionDeltaKind.Rebucket, RenderProjectionDeltaKind.Rebucket,
harness.Journal.Pending[2].Kind); harness.Journal.Pending[4].Kind);
Assert.Equal( Assert.Equal(
incarnation, incarnation,
harness.Journal.Pending[2].Record.OwnerIncarnation); harness.Journal.Pending[4].Record.OwnerIncarnation);
Assert.Equal(1, harness.Projections.ProjectionCount); Assert.Equal(1, harness.Projections.ProjectionCount);
} }
@ -357,7 +363,10 @@ public sealed class LiveRenderProjectionJournalTests
var runtime = new LiveEntityRuntime(state, resources); var runtime = new LiveEntityRuntime(state, resources);
var journal = new RenderProjectionJournal( var journal = new RenderProjectionJournal(
RenderSceneGeneration.FromRaw(1)); RenderSceneGeneration.FromRaw(1));
var projections = new LiveRenderProjectionJournal(runtime, journal); var projections = new LiveRenderProjectionJournal(
runtime,
journal,
new GpuWorldRenderTraversalOrderSource(state));
sink = projections; sink = projections;
var scene = new ArchRenderScene(RenderSceneGeneration.FromRaw(1)); var scene = new ArchRenderScene(RenderSceneGeneration.FromRaw(1));
return new Harness(state, runtime, journal, projections, scene); return new Harness(state, runtime, journal, projections, scene);

View file

@ -262,6 +262,79 @@ public sealed class RenderScenePViewFrameProductTests
Assert.Equal(2, logs.Count); 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) private static RenderProjectionRecord Project(WorldEntity entity)
{ {
bool dynamic = entity.ServerGuid != 0; bool dynamic = entity.ServerGuid != 0;

View file

@ -137,6 +137,74 @@ public sealed class StaticRenderProjectionJournalTests
StaticRenderProjectionJournal.StaticEntityId(otherLandblock, 42)); 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] [Fact]
public void StaleFarPublication_DoesNotMutateProjectionJournal() public void StaleFarPublication_DoesNotMutateProjectionJournal()
{ {
@ -307,14 +375,16 @@ public sealed class StaticRenderProjectionJournalTests
private static GpuLandblockSpatialPublication Publication( private static GpuLandblockSpatialPublication Publication(
LandblockBuild build, LandblockBuild build,
bool requiresActivation = true) => bool requiresActivation = true,
uint renderTraversalOrder = 1) =>
new( new(
build.LandblockId, build.LandblockId,
build.Landblock, build.Landblock,
build.EnvCells?.Shells.Select(shell => shell.GeometryId).ToArray() build.EnvCells?.Shells.Select(shell => shell.GeometryId).ToArray()
?? [], ?? [],
build.Landblock.Entities.Where(entity => entity.ServerGuid == 0).ToArray(), build.Landblock.Entities.Where(entity => entity.ServerGuid == 0).ToArray(),
requiresActivation); requiresActivation,
renderTraversalOrder);
private static LandblockBuild Build( private static LandblockBuild Build(
uint landblockId, uint landblockId,

View file

@ -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),
],
};
}