feat(rendering): journal live scene projections

Mirror exact live-root and equipped-child lifetimes behind the non-drawing render-scene boundary. Ready, visibility, final-pose, rebucket, removal, and resource teardown edges retain canonical LiveEntityRuntime identity while active-only synchronization avoids resident-static scans.

Co-authored-by: Erik Nilsson <erikn@users.noreply.github.com>
This commit is contained in:
Erik 2026-07-24 22:05:42 +02:00
parent 5d19c56d15
commit 58e7c2eb99
10 changed files with 949 additions and 148 deletions

View file

@ -0,0 +1,379 @@
using System.Numerics;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Scene.Arch;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Rendering;
public sealed class LiveRenderProjectionJournalTests
{
private const uint LandblockId = 0x1234FFFFu;
private const uint CellId = 0x12340100u;
private const uint Guid = 0x80000001u;
[Fact]
public void EntityReady_RegistersExactRootOnceAfterResourcesCommit()
{
Harness harness = CreateHarness(loadLandblock: true);
LiveEntityRecord record = Materialize(harness, Guid, instance: 7);
LiveEntityReadyCandidate candidate =
LiveEntityReadyCandidate.Capture(record);
Assert.True(harness.Projections.OnEntityReady(candidate));
Assert.True(harness.Projections.OnEntityReady(candidate));
Assert.Equal(1, harness.Journal.Count);
Assert.Equal(1, harness.Projections.ProjectionCount);
RenderProjectionDelta registered = harness.Journal.Pending[0];
Assert.Equal(RenderProjectionDeltaKind.Register, registered.Kind);
Assert.Equal(RenderProjectionClass.LiveDynamicRoot,
registered.Record.ProjectionClass);
Assert.Equal(record.LocalEntityId, registered.Record.Source.LocalEntityId);
Assert.Equal(Guid, registered.Record.Source.ServerGuid);
Assert.Equal(LandblockId, registered.Record.Residency.OwnerLandblockId);
Assert.True((registered.Record.Flags & RenderProjectionFlags.Draw) != 0);
}
[Fact]
public void PendingToLoadedAndLoadedToLoaded_RebucketWithoutLogicalRecreate()
{
Harness harness = CreateHarness(loadLandblock: false);
BindVisibility(harness);
LiveEntityRecord record = Materialize(harness, Guid, instance: 1);
Assert.False(record.IsSpatiallyVisible);
harness.Projections.OnEntityReady(
LiveEntityReadyCandidate.Capture(record));
RenderOwnerIncarnation incarnation =
harness.Journal.Pending[0].Record.OwnerIncarnation;
Load(harness.State, LandblockId);
Assert.True(record.IsSpatiallyVisible);
Assert.Equal(2, harness.Journal.Count);
Assert.Equal(
RenderProjectionDeltaKind.UpdateFlags,
harness.Journal.Pending[1].Kind);
const uint secondLandblock = 0x1235FFFFu;
const uint secondCell = 0x12350100u;
Load(harness.State, secondLandblock);
Assert.True(harness.Runtime.RebucketLiveEntity(Guid, secondCell));
harness.Projections.SynchronizeActiveSources();
Assert.Equal(3, harness.Journal.Count);
Assert.Equal(
RenderProjectionDeltaKind.Rebucket,
harness.Journal.Pending[2].Kind);
Assert.Equal(
incarnation,
harness.Journal.Pending[2].Record.OwnerIncarnation);
Assert.Equal(1, harness.Projections.ProjectionCount);
}
[Fact]
public void SynchronizeActiveSources_MirrorsFinalTransformAppearanceAndHiddenFlags()
{
Harness harness = CreateHarness(loadLandblock: true);
LiveEntityRecord record = Materialize(harness, Guid, instance: 2);
harness.Projections.OnEntityReady(
LiveEntityReadyCandidate.Capture(record));
harness.Journal.DrainTo(harness.Scene);
WorldEntity entity = record.WorldEntity!;
entity.SetPosition(new Vector3(20, 30, 40));
entity.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f);
entity.ApplyAppearance(
[new MeshRef(0x01010077u, Matrix4x4.Identity)],
new PaletteOverride(0x04000001u, []),
[new PartOverride(0, 0x01010077u)]);
entity.IsDrawVisible = false;
harness.Projections.SynchronizeActiveSources();
Assert.Equal(
[
RenderProjectionDeltaKind.UpdateTransform,
RenderProjectionDeltaKind.UpdateAppearance,
RenderProjectionDeltaKind.UpdateFlags,
],
harness.Journal.Pending.ToArray()
.Select(delta => delta.Kind)
.ToArray());
harness.Journal.DrainTo(harness.Scene);
Assert.True(harness.Scene.OpenQuery().TryGet(
LiveRenderProjectionJournal.ProjectionId(entity.Id),
out var projected));
Assert.Equal(entity.Position, projected.Transform.Position);
Assert.True((projected.Flags & RenderProjectionFlags.Hidden) != 0);
Assert.True((projected.Flags & RenderProjectionFlags.Draw) == 0);
Assert.Equal(1, projected.MeshSet.MeshCount);
}
[Fact]
public void EquippedPoseAndRemoval_UseSameIncarnationWithoutGuidOwnershipMap()
{
Harness harness = CreateHarness(loadLandblock: true);
LiveEntityRecord record = Materialize(
harness,
Guid,
instance: 3,
projectionKind: LiveEntityProjectionKind.Attached);
harness.Projections.OnEntityReady(
LiveEntityReadyCandidate.Capture(record));
RenderOwnerIncarnation incarnation =
harness.Journal.Pending[0].Record.OwnerIncarnation;
harness.Journal.DrainTo(harness.Scene);
record.WorldEntity!.SetPosition(new Vector3(9, 8, 7));
harness.Projections.OnProjectionPoseReady(Guid);
Assert.Single(harness.Journal.Pending.ToArray());
Assert.Equal(
RenderProjectionDeltaKind.UpdateTransform,
harness.Journal.Pending[0].Kind);
Assert.Equal(
incarnation,
harness.Journal.Pending[0].Record.OwnerIncarnation);
harness.Projections.OnProjectionRemoved(record.WorldEntity.Id);
harness.Projections.OnProjectionRemoved(record.WorldEntity.Id);
Assert.Equal(2, harness.Journal.Count);
Assert.Equal(
RenderProjectionDeltaKind.Unregister,
harness.Journal.Pending[1].Kind);
Assert.Equal(0, harness.Projections.ProjectionCount);
}
[Fact]
public void GenerationReplacement_UnregistersOldAndRejectsStaleReadyCandidate()
{
Harness harness = CreateHarness(loadLandblock: true);
LiveEntityRecord first = Materialize(harness, Guid, instance: 4);
LiveEntityReadyCandidate stale = LiveEntityReadyCandidate.Capture(first);
harness.Projections.OnEntityReady(stale);
uint firstLocalId = first.WorldEntity!.Id;
harness.Journal.DrainTo(harness.Scene);
LiveEntityRegistrationResult replacement =
harness.Runtime.RegisterLiveEntity(Spawn(Guid, instance: 5));
LiveEntityRecord second = replacement.Record!;
WorldEntity secondEntity = harness.Runtime.MaterializeLiveEntity(
Guid,
CellId,
localId => Entity(localId, Guid))!;
second.InitialHydrationCompleted = true;
LiveEntityReadyCandidate current = LiveEntityReadyCandidate.Capture(second);
Assert.True(harness.Projections.OnEntityReady(current));
Assert.False(harness.Projections.OnEntityReady(stale));
Assert.Equal(2, harness.Journal.Count);
Assert.Equal(
[
RenderProjectionDeltaKind.Unregister,
RenderProjectionDeltaKind.Register,
],
harness.Journal.Pending.ToArray()
.Select(delta => delta.Kind)
.ToArray());
Assert.NotEqual(firstLocalId, secondEntity.Id);
Assert.Equal(1, harness.Projections.ProjectionCount);
harness.Journal.DrainTo(harness.Scene);
Assert.False(harness.Scene.OpenQuery().TryGet(
LiveRenderProjectionJournal.ProjectionId(firstLocalId),
out _));
Assert.True(harness.Scene.OpenQuery().TryGet(
LiveRenderProjectionJournal.ProjectionId(secondEntity.Id),
out _));
}
[Fact]
public void DuplicateCreateAndReady_DoNotRegisterProjectionTwice()
{
Harness harness = CreateHarness(loadLandblock: true);
LiveEntityRecord record = Materialize(harness, Guid, instance: 5);
harness.Projections.OnEntityReady(
LiveEntityReadyCandidate.Capture(record));
harness.Journal.DrainTo(harness.Scene);
LiveEntityRegistrationResult duplicate =
harness.Runtime.RegisterLiveEntity(Spawn(Guid, instance: 5));
Assert.Same(record, duplicate.Record);
Assert.True(harness.Projections.OnEntityReady(
LiveEntityReadyCandidate.Capture(record)));
Assert.Equal(0, harness.Journal.Count);
Assert.Equal(1, harness.Projections.ProjectionCount);
Assert.Equal(1, harness.Scene.Counts.Total);
}
[Fact]
public void SessionClear_UnregistersEveryTrackedProjectionExactlyOnce()
{
Harness harness = CreateHarness(loadLandblock: true);
LiveEntityRecord record = Materialize(harness, Guid, instance: 6);
harness.Projections.OnEntityReady(
LiveEntityReadyCandidate.Capture(record));
harness.Runtime.Clear();
Assert.Equal(2, harness.Journal.Count);
Assert.Equal(
RenderProjectionDeltaKind.Unregister,
harness.Journal.Pending[1].Kind);
Assert.Equal(0, harness.Projections.ProjectionCount);
Assert.Equal(0, harness.Runtime.Count);
}
[Fact]
public void ResetTracking_DropsDerivedSourcesWithoutTouchingGameplayRuntime()
{
Harness harness = CreateHarness(loadLandblock: true);
LiveEntityRecord record = Materialize(harness, Guid, instance: 7);
harness.Projections.OnEntityReady(
LiveEntityReadyCandidate.Capture(record));
harness.Projections.ResetTracking();
Assert.Equal(0, harness.Projections.ProjectionCount);
Assert.Equal(1, harness.Runtime.Count);
Assert.Same(record, harness.Runtime.Records.Single());
}
private static Harness CreateHarness(bool loadLandblock)
{
var state = new GpuWorldState();
if (loadLandblock)
Load(state, LandblockId);
ILiveRenderProjectionSink? sink = null;
var resources = new DelegateLiveEntityResourceLifecycle(
static _ => { },
entity => sink?.OnResourceUnregister(entity));
var runtime = new LiveEntityRuntime(state, resources);
var journal = new RenderProjectionJournal(
RenderSceneGeneration.FromRaw(1));
var projections = new LiveRenderProjectionJournal(runtime, journal);
sink = projections;
var scene = new ArchRenderScene(RenderSceneGeneration.FromRaw(1));
return new Harness(state, runtime, journal, projections, scene);
}
private static void BindVisibility(Harness harness) =>
harness.Runtime.ProjectionVisibilityChanged +=
harness.Projections.OnProjectionVisibilityChanged;
private static LiveEntityRecord Materialize(
Harness harness,
uint guid,
ushort instance,
LiveEntityProjectionKind projectionKind =
LiveEntityProjectionKind.World)
{
LiveEntityRecord record =
harness.Runtime.RegisterLiveEntity(Spawn(guid, instance)).Record!;
WorldEntity? entity = harness.Runtime.MaterializeLiveEntity(
guid,
CellId,
localId => Entity(localId, guid),
projectionKind);
Assert.NotNull(entity);
record.InitialHydrationCompleted = true;
return record;
}
private static void Load(GpuWorldState state, uint landblockId) =>
state.AddLandblock(new LoadedLandblock(
landblockId,
new LandBlock(),
Array.Empty<WorldEntity>(),
PhysicsDatBundle.Empty));
private static WorldEntity Entity(uint localId, uint guid) => new()
{
Id = localId,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(1, 2, 3),
Rotation = Quaternion.Identity,
MeshRefs =
[
new MeshRef(0x01010001u, Matrix4x4.Identity),
],
ParentCellId = CellId,
};
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance)
{
var position = new CreateObject.ServerPosition(
CellId,
10f,
10f,
5f,
1f,
0f,
0f,
0f);
var timestamps = new PhysicsTimestamps(
Position: 1,
Movement: 1,
State: 1,
Vector: 1,
Teleport: 0,
ServerControlledMove: 1,
ForcePosition: 0,
ObjDesc: 1,
Instance: instance);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"fixture",
null,
null,
0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: instance,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
private sealed record Harness(
GpuWorldState State,
LiveEntityRuntime Runtime,
RenderProjectionJournal Journal,
LiveRenderProjectionJournal Projections,
ArchRenderScene Scene);
}

View file

@ -1,6 +1,7 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Items;
@ -1496,6 +1497,63 @@ public sealed class LiveEntityHydrationControllerTests
Assert.False(expected.IsSpatiallyProjected);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ReadyPublisher_PublishesRenderProjectionAfterExistingReadyStages(
bool projectionAccepted)
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
LiveEntityRecord record = fixture.Record;
var operations = new List<string>();
var projection = new RecordingLiveProjectionSink(
_ =>
{
operations.Add("projection");
return projectionAccepted;
});
var publisher = new LiveEntityReadyPublisher(
fixture.Runtime,
_ => { operations.Add("effects-owner"); return true; },
_ => { operations.Add("state"); return true; },
_ => { operations.Add("effects-replay"); return true; },
projection);
bool result = publisher.Publish(
LiveEntityReadyCandidate.Capture(record));
Assert.Equal(projectionAccepted, result);
Assert.Equal(
["effects-owner", "state", "effects-replay", "projection"],
operations);
}
[Fact]
public void ReadyPublisher_RevalidatesAfterReentrantRenderProjectionCallback()
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
LiveEntityReadyCandidate candidate =
LiveEntityReadyCandidate.Capture(fixture.Record);
var projection = new RecordingLiveProjectionSink(
_ =>
{
fixture.Runtime.RegisterLiveEntity(
Spawn(Generation: 1, PositionSequence: 2));
return true;
});
var publisher = new LiveEntityReadyPublisher(
fixture.Runtime,
static _ => true,
static _ => true,
static _ => true,
projection);
Assert.False(publisher.Publish(candidate));
Assert.Equal(2UL, fixture.Record.CreateIntegrationVersion);
}
[Fact]
public void EquippedChildReadyCandidate_RejectsVersionAdvancedByPoseCallback()
{
@ -1937,6 +1995,36 @@ public sealed class LiveEntityHydrationControllerTests
}
}
private sealed class RecordingLiveProjectionSink(
Func<LiveEntityReadyCandidate, bool> ready)
: ILiveRenderProjectionSink
{
public bool OnEntityReady(LiveEntityReadyCandidate candidate) =>
ready(candidate);
public void OnProjectionVisibilityChanged(
LiveEntityRecord record,
bool visible)
{
}
public void OnProjectionPoseReady(uint serverGuid)
{
}
public void OnProjectionRemoved(uint localEntityId)
{
}
public void OnResourceUnregister(WorldEntity entity)
{
}
public void SynchronizeActiveSources()
{
}
}
private sealed class RecordingOrigin(bool isKnown) : ILiveEntityWorldOriginCoordinator
{
public bool IsKnownValue { get; set; } = isKnown;