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,
static runtime => runtime.Clear());
LiveRenderProjectionJournal? liveRenderProjections =
renderSceneShadow?.BindLiveRuntime(liveEntities);
renderSceneShadow?.BindLiveRuntime(
liveEntities,
new GpuWorldRenderTraversalOrderSource(worldState));
Fault(LivePresentationCompositionPoint.CanonicalRuntimeCreated);
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 RenderProjectionJournal _journal;
private readonly IRenderTraversalOrderSource _traversalOrder;
private readonly Dictionary<uint, TrackedProjection> _byLocalId = [];
private readonly List<LiveEntityRecord> _activeRootScratch = [];
private readonly List<TrackedProjection> _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)

View file

@ -178,6 +178,35 @@ internal readonly record struct RenderDegradeState(byte Level, uint Revision);
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>
/// Immutable source facts required to compare the derived presentation scene
/// 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 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<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>

View file

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

View file

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

View file

@ -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<ulong> AdditionalRenderIds,
IReadOnlyList<WorldEntity> StaticEntities,
bool RequiresActivation = true);
bool RequiresActivation = true,
uint RenderTraversalOrder = 0);
/// <summary>
/// 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();
// 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, (Vector3 Min, Vector3 Max)> _aabbs = new();
private readonly Dictionary<uint, AnimatedEntityIndex> _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<uint, WorldEntity>? 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<uint, WorldEntity>(kvp.Value.Entities.Count);
foreach (var entity in kvp.Value.Entities)
var rebuilt = new Dictionary<uint, WorldEntity>(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;
}
/// <summary>
/// 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<ulong>(),
Array.Empty<WorldEntity>(),
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<ulong>?)mergedRenderIds ?? Array.Empty<ulong>());
(IEnumerable<ulong>?)mergedRenderIds ?? Array.Empty<ulong>(),
GetRenderTraversalOrder(landblock.LandblockId));
}
/// <summary>
@ -804,7 +913,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
private static GpuLandblockSpatialPublication CreateSpatialPublication(
LoadedLandblock landblock,
IEnumerable<ulong> additionalRenderIds)
IEnumerable<ulong> 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);
}
/// <summary>
@ -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()