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