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

@ -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);

View file

@ -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;

View file

@ -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,

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