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

@ -20,8 +20,9 @@ retail-faithful gameplay
| F0b — survivor/dispatcher/selection referee | complete | Exact PView routes, dispatcher candidates and final group payloads, material/alpha/clip/light/selection fields, and accepted picking parts. Release gate: 3,690 App tests / 3 skips and 8,174 complete-solution tests / 5 skips. |
| F1 — scene types and contained adapter | complete | `Arch 2.1.0` is pinned in App only behind acdream contracts. Five narrow archetypes, generation/incarnation/sequence gates, borrowed-query invalidation, memory accounting, deterministic digesting, and update-thread enforcement pass 11 focused tests. Release gate: 3,701 App tests / 3 skips and 8,185 complete-solution tests / 5 skips. |
| F2 — static projection journal | complete | Static and EnvCell-shell projections now append ordered deltas only after the final activation receipt and exact detach receipt. Same-landblock rehydrate reconciles retained/new/omitted identities; stale Far completions are inert. Seven focused lifecycle tests pass inside the 3,708-App / 8,192-solution Release gate. The scene remains unconstructed and non-drawing in production. |
| F3 — live/equipped projection | active | Live/equipped accepted-state publication and final-frame synchronization have not landed. |
| F4F5 | pending | Dynamic indices and continuous shadow comparison have not started. |
| F3 — live/equipped projection | complete | Exact EntityReady/resource teardown, visibility, attachment pose/removal, and active-only final-frame seams now drive generation/incarnation-gated live records. Duplicate CreateObject, pending/loaded rebucket, hidden/appearance, reentrancy, session clear, attachment, and GUID-generation replacement pass 11 focused tests. Production still constructs no render scene. |
| F4 — dynamic indices | active | Packed spatial/class/visibility query indices have not landed. |
| F5 | pending | Continuous shadow comparison has not started. |
| G0G5 | pending | No production consumer has switched. |
The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is

View file

@ -59,6 +59,17 @@ internal sealed class LivePresentationRuntimeBindings : IDisposable
() => source.ProjectionPoseReady -= handler));
}
public void BindProjectionRemoved(
EquippedChildRenderController source,
Action<uint> handler)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(handler);
source.ProjectionRemoved += handler;
Adopt("equipped-child projection removal", new DelegateBinding(
() => source.ProjectionRemoved -= handler));
}
public void Dispose()
{
if (_deactivationStarted && _bindings.Count == 0)

View file

@ -0,0 +1,266 @@
using AcDream.App.World;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Scene;
internal interface ILiveRenderProjectionSink
{
bool OnEntityReady(LiveEntityReadyCandidate candidate);
void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible);
void OnProjectionPoseReady(uint serverGuid);
void OnProjectionRemoved(uint localEntityId);
void OnResourceUnregister(WorldEntity entity);
void SynchronizeActiveSources();
}
/// <summary>
/// Change-driven render projection for canonical live records. It retains
/// exact record references and local presentation IDs only; server GUID lookup
/// remains exclusively in <see cref="LiveEntityRuntime"/>.
/// </summary>
internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
{
private const byte LiveProjectionDomain = 3;
private readonly LiveEntityRuntime _runtime;
private readonly RenderProjectionJournal _journal;
private readonly Dictionary<uint, TrackedProjection> _byLocalId = [];
private readonly List<TrackedProjection> _activeScratch = [];
public LiveRenderProjectionJournal(
LiveEntityRuntime runtime,
RenderProjectionJournal journal)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_journal = journal ?? throw new ArgumentNullException(nameof(journal));
}
public int ProjectionCount => _byLocalId.Count;
public bool OnEntityReady(LiveEntityReadyCandidate candidate)
{
if (!candidate.IsCurrent(_runtime)
|| candidate.WorldEntity is not { } entity
|| !candidate.Record.ResourcesRegistered)
{
return false;
}
Upsert(candidate.Record, entity, candidate.Record.IsSpatiallyVisible);
return candidate.IsCurrent(_runtime);
}
public void OnProjectionVisibilityChanged(
LiveEntityRecord record,
bool visible)
{
ArgumentNullException.ThrowIfNull(record);
if (!_runtime.IsCurrentRecord(record)
|| record.WorldEntity is not { } entity
|| !_byLocalId.ContainsKey(entity.Id))
{
return;
}
Upsert(record, entity, visible);
}
public void OnProjectionPoseReady(uint serverGuid)
{
if (!_runtime.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is not { } entity
|| !_byLocalId.ContainsKey(entity.Id)
|| record.ProjectionKind is not LiveEntityProjectionKind.Attached)
{
return;
}
Upsert(record, entity, record.IsSpatiallyVisible);
}
public void OnProjectionRemoved(uint localEntityId)
{
if (_byLocalId.TryGetValue(
localEntityId,
out TrackedProjection? tracked)
&& tracked.Projection.ProjectionClass is
RenderProjectionClass.EquippedChild)
{
Remove(tracked);
}
}
public void OnResourceUnregister(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
if (_byLocalId.TryGetValue(entity.Id, out TrackedProjection? tracked)
&& ReferenceEquals(tracked.Entity, entity))
{
Remove(tracked);
}
}
/// <summary>
/// Mirrors only registered live/attached sources after simulation,
/// animation, and attachment pose composition have committed. Resident
/// statics are never scanned.
/// </summary>
public void SynchronizeActiveSources()
{
_activeScratch.Clear();
foreach (TrackedProjection tracked in _byLocalId.Values)
{
if (tracked.Record.IsSpatiallyProjected)
_activeScratch.Add(tracked);
}
for (int i = 0; i < _activeScratch.Count; i++)
{
TrackedProjection tracked = _activeScratch[i];
if (!_runtime.IsCurrentRecord(tracked.Record)
|| !ReferenceEquals(tracked.Record.WorldEntity, tracked.Entity)
|| !_byLocalId.TryGetValue(
tracked.Entity.Id,
out TrackedProjection? current)
|| !ReferenceEquals(current, tracked))
{
continue;
}
Upsert(
tracked.Record,
tracked.Entity,
tracked.Record.IsSpatiallyVisible);
}
}
public void ResetTracking()
{
_byLocalId.Clear();
_activeScratch.Clear();
}
internal bool TryGet(
uint localEntityId,
out RenderProjectionRecord record)
{
if (_byLocalId.TryGetValue(
localEntityId,
out TrackedProjection? tracked))
{
record = tracked.Projection;
return true;
}
record = default;
return false;
}
internal static RenderProjectionId ProjectionId(uint localEntityId) =>
RenderProjectionId.FromRaw(
((ulong)LiveProjectionDomain << 56) | localEntityId);
private void Upsert(
LiveEntityRecord record,
WorldEntity entity,
bool spatiallyVisible)
{
RenderProjectionRecord projected = Project(
record,
entity,
spatiallyVisible);
if (!_byLocalId.TryGetValue(entity.Id, out TrackedProjection? tracked))
{
_journal.Register(in projected);
_byLocalId.Add(
entity.Id,
new TrackedProjection(record, entity, projected));
return;
}
if (!ReferenceEquals(tracked.Record, record)
|| !ReferenceEquals(tracked.Entity, entity)
|| tracked.Projection.OwnerIncarnation
!= projected.OwnerIncarnation
|| tracked.Projection.ProjectionClass
!= projected.ProjectionClass)
{
_journal.Register(in projected);
_byLocalId[entity.Id] =
new TrackedProjection(record, entity, projected);
return;
}
projected = projected with
{
PreviousTransform = new PreviousRenderTransform(
tracked.Projection.Transform.LocalToWorld),
};
_journal.AppendDifference(tracked.Projection, projected);
tracked.Projection = projected;
}
private RenderProjectionRecord Project(
LiveEntityRecord record,
WorldEntity entity,
bool spatiallyVisible)
{
uint fullCellId = record.FullCellId != 0
? record.FullCellId
: entity.ParentCellId ?? 0;
uint ownerLandblockId = record.CanonicalLandblockId != 0
? record.CanonicalLandblockId
: fullCellId == 0
? 0
: Canonicalize(fullCellId);
return RenderProjectionRecordFactory.ProjectEntity(
ProjectionId(entity.Id),
record.ProjectionKind is LiveEntityProjectionKind.Attached
? RenderProjectionClass.EquippedChild
: RenderProjectionClass.LiveDynamicRoot,
RenderOwnerIncarnation.FromRaw(
((ulong)record.Generation << 32) | entity.Id),
ownerLandblockId,
fullCellId,
entity,
spatiallyVisible);
}
private void Remove(TrackedProjection tracked)
{
if (!_byLocalId.Remove(tracked.Entity.Id))
return;
_journal.Unregister(
tracked.Projection.Id,
tracked.Projection.OwnerIncarnation);
}
private static uint Canonicalize(uint cellOrLandblockId) =>
(cellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
private sealed class TrackedProjection(
LiveEntityRecord record,
WorldEntity entity,
RenderProjectionRecord projection)
{
public LiveEntityRecord Record { get; } = record;
public WorldEntity Entity { get; } = entity;
public RenderProjectionRecord Projection { get; set; } = projection;
}
}
internal sealed class LiveRenderProjectionResourceLifecycle(
ILiveRenderProjectionSink sink) : ILiveEntityResourceLifecycle
{
private readonly ILiveRenderProjectionSink _sink =
sink ?? throw new ArgumentNullException(nameof(sink));
public void Register(WorldEntity entity)
{
// The exact EntityReady edge follows all resource registration and is
// the first point at which the projection may enter the journal.
}
public void Unregister(WorldEntity entity) =>
_sink.OnResourceUnregister(entity);
}

View file

@ -43,6 +43,45 @@ internal sealed class RenderProjectionJournal
id,
incarnation));
public void AppendDifference(
in RenderProjectionRecord prior,
in RenderProjectionRecord current)
{
if (prior.Id != current.Id
|| prior.OwnerIncarnation != current.OwnerIncarnation
|| prior.ProjectionClass != current.ProjectionClass)
{
throw new ArgumentException(
"A channel update cannot change projection identity, incarnation, or class.",
nameof(current));
}
if (prior.Transform != current.Transform
|| prior.Bounds != current.Bounds
|| prior.SortKey != current.SortKey
|| prior.Source.TransformFingerprint
!= current.Source.TransformFingerprint)
{
Update(RenderProjectionDeltaKind.UpdateTransform, in current);
}
if (prior.MeshSet != current.MeshSet
|| prior.Material != current.Material
|| prior.DegradeState != current.DegradeState
|| prior.Source.GeometryFingerprint
!= current.Source.GeometryFingerprint
|| prior.Source.AppearanceFingerprint
!= current.Source.AppearanceFingerprint)
{
Update(RenderProjectionDeltaKind.UpdateAppearance, in current);
}
if (prior.Residency != current.Residency)
Update(RenderProjectionDeltaKind.Rebucket, in current);
if (prior.Flags != current.Flags)
Update(RenderProjectionDeltaKind.UpdateFlags, in current);
}
public RenderDeltaApplyResult DrainTo(IRenderScene scene)
{
ArgumentNullException.ThrowIfNull(scene);

View file

@ -0,0 +1,130 @@
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Scene;
/// <summary>
/// Pure conversion from an accepted <see cref="WorldEntity"/> presentation
/// snapshot into the packed render-scene record. This method never mutates
/// source AABB state or acquires gameplay identity.
/// </summary>
internal static class RenderProjectionRecordFactory
{
private const float DefaultAabbRadius = 5.0f;
public static RenderProjectionRecord ProjectEntity(
RenderProjectionId id,
RenderProjectionClass projectionClass,
RenderOwnerIncarnation incarnation,
uint ownerLandblockId,
uint fullCellId,
WorldEntity entity,
bool spatiallyVisible)
{
ArgumentNullException.ThrowIfNull(entity);
CurrentRenderProjectionFingerprint fingerprint =
CurrentRenderSceneOracle.CreateProjectionFingerprint(
ownerLandblockId,
entity);
RenderProjectionFlags flags = RenderProjectionFlags.Selectable;
if (spatiallyVisible
&& entity.IsDrawVisible
&& entity.IsAncestorDrawVisible)
{
flags |= RenderProjectionFlags.Draw;
}
if (!entity.IsDrawVisible)
flags |= RenderProjectionFlags.Hidden;
if (!entity.IsAncestorDrawVisible)
flags |= RenderProjectionFlags.AncestorHidden;
RenderTransform transform = RenderTransform.FromRoot(
entity.Position,
entity.Rotation,
entity.Scale);
(Vector3 minimum, Vector3 maximum) = CalculateBounds(entity);
return new RenderProjectionRecord(
id,
projectionClass,
incarnation,
transform,
new PreviousRenderTransform(transform.LocalToWorld),
new RenderMeshSet(
RenderAssetHandle.FromRaw(
fingerprint.Geometry.Low ^ fingerprint.Geometry.High),
entity.MeshRefs.Count,
0),
new RenderMaterialVariant(
fingerprint.Appearance.Low,
fingerprint.Appearance.High,
1.0f),
new RenderSpatialResidency(
RenderSpatialBucket.FromRaw(fullCellId),
ownerLandblockId,
fullCellId),
new RenderWorldBounds(minimum, maximum),
flags,
default,
id.ToSortKey(),
RenderDirtyMask.All,
new RenderSourceMetadata(
entity.Id,
entity.ServerGuid,
entity.SourceGfxObjOrSetupId,
entity.ParentCellId ?? 0,
entity.EffectCellId ?? 0,
entity.BuildingShellAnchorCellId ?? 0,
fingerprint.Transform,
fingerprint.Geometry,
fingerprint.Appearance));
}
private static (Vector3 Minimum, Vector3 Maximum) CalculateBounds(
WorldEntity entity)
{
Vector3 position = entity.Position;
if (entity.HasLocalBounds)
{
Vector3 localMin = entity.LocalBoundMin;
Vector3 localMax = entity.LocalBoundMax;
Vector3 minimum = default;
Vector3 maximum = default;
for (int cornerIndex = 0; cornerIndex < 8; cornerIndex++)
{
Vector3 corner = new(
(cornerIndex & 1) == 0 ? localMin.X : localMax.X,
(cornerIndex & 2) == 0 ? localMin.Y : localMax.Y,
(cornerIndex & 4) == 0 ? localMin.Z : localMax.Z);
Vector3 transformed =
Vector3.Transform(corner, entity.Rotation);
if (cornerIndex == 0)
{
minimum = transformed;
maximum = transformed;
}
else
{
minimum = Vector3.Min(minimum, transformed);
maximum = Vector3.Max(maximum, transformed);
}
}
Vector3 margin = new(DefaultAabbRadius);
return (
position + minimum - margin,
position + maximum + margin);
}
float radius = DefaultAabbRadius;
for (int i = 0; i < entity.MeshRefs.Count; i++)
{
radius = MathF.Max(
radius,
DefaultAabbRadius
+ entity.MeshRefs[i].PartTransform.Translation.Length());
}
Vector3 extent = new(radius);
return (position - extent, position + extent);
}
}

View file

@ -1,4 +1,3 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.Core.World;
@ -25,7 +24,6 @@ internal sealed class StaticRenderProjectionJournal :
{
private const byte StaticEntityDomain = 1;
private const byte EnvCellShellDomain = 2;
private const float DefaultAabbRadius = 5.0f;
private readonly RenderProjectionJournal _journal;
private readonly Dictionary<uint, Dictionary<RenderProjectionId, TrackedProjection>>
@ -115,7 +113,7 @@ internal sealed class StaticRenderProjectionJournal :
if (accepted != retained.Record)
{
AppendUpdates(retained.Record, accepted);
_journal.AppendDifference(retained.Record, accepted);
current[candidate.Id] = new TrackedProjection(accepted);
}
continue;
@ -217,61 +215,18 @@ internal sealed class StaticRenderProjectionJournal :
uint landblockId,
WorldEntity entity)
{
CurrentRenderProjectionFingerprint fingerprint =
CurrentRenderSceneOracle.CreateProjectionFingerprint(
landblockId,
entity);
RenderProjectionFlags flags = RenderProjectionFlags.Selectable;
if (entity.IsDrawVisible && entity.IsAncestorDrawVisible)
flags |= RenderProjectionFlags.Draw;
if (!entity.IsDrawVisible)
flags |= RenderProjectionFlags.Hidden;
if (!entity.IsAncestorDrawVisible)
flags |= RenderProjectionFlags.AncestorHidden;
RenderTransform transform = RenderTransform.FromRoot(
entity.Position,
entity.Rotation,
entity.Scale);
(Vector3 minimum, Vector3 maximum) = CalculateBounds(entity);
RenderProjectionId id = StaticEntityId(landblockId, entity.Id);
uint fullCellId = entity.ParentCellId ?? landblockId;
return new RenderProjectionRecord(
return RenderProjectionRecordFactory.ProjectEntity(
id,
InteriorEntityPartition.IsIndoorCellId(entity.ParentCellId)
? RenderProjectionClass.IndoorCellStatic
: RenderProjectionClass.OutdoorStatic,
default,
transform,
new PreviousRenderTransform(transform.LocalToWorld),
new RenderMeshSet(
RenderAssetHandle.FromRaw(
fingerprint.Geometry.Low ^ fingerprint.Geometry.High),
entity.MeshRefs.Count,
0),
new RenderMaterialVariant(
fingerprint.Appearance.Low,
fingerprint.Appearance.High,
1.0f),
new RenderSpatialResidency(
RenderSpatialBucket.FromRaw(fullCellId),
landblockId,
fullCellId),
new RenderWorldBounds(minimum, maximum),
flags,
default,
id.ToSortKey(),
RenderDirtyMask.All,
new RenderSourceMetadata(
entity.Id,
entity.ServerGuid,
entity.SourceGfxObjOrSetupId,
entity.ParentCellId ?? 0,
entity.EffectCellId ?? 0,
entity.BuildingShellAnchorCellId ?? 0,
fingerprint.Transform,
fingerprint.Geometry,
fingerprint.Appearance));
fullCellId,
entity,
spatiallyVisible: true);
}
private static RenderProjectionRecord ProjectEnvCellShell(
@ -332,95 +287,6 @@ internal sealed class StaticRenderProjectionJournal :
RenderSceneHash128.Empty));
}
private void AppendUpdates(
in RenderProjectionRecord prior,
in RenderProjectionRecord current)
{
if (prior.Transform != current.Transform
|| prior.Bounds != current.Bounds
|| prior.SortKey != current.SortKey
|| prior.Source.TransformFingerprint
!= current.Source.TransformFingerprint)
{
_journal.Update(
RenderProjectionDeltaKind.UpdateTransform,
in current);
}
if (prior.MeshSet != current.MeshSet
|| prior.Material != current.Material
|| prior.DegradeState != current.DegradeState
|| prior.Source.GeometryFingerprint != current.Source.GeometryFingerprint
|| prior.Source.AppearanceFingerprint != current.Source.AppearanceFingerprint)
{
_journal.Update(
RenderProjectionDeltaKind.UpdateAppearance,
in current);
}
if (prior.Residency != current.Residency)
{
_journal.Update(
RenderProjectionDeltaKind.Rebucket,
in current);
}
if (prior.Flags != current.Flags)
{
_journal.Update(
RenderProjectionDeltaKind.UpdateFlags,
in current);
}
}
private static (Vector3 Minimum, Vector3 Maximum) CalculateBounds(
WorldEntity entity)
{
Vector3 position = entity.Position;
if (entity.HasLocalBounds)
{
Vector3 localMin = entity.LocalBoundMin;
Vector3 localMax = entity.LocalBoundMax;
Vector3 minimum = default;
Vector3 maximum = default;
for (int cornerIndex = 0; cornerIndex < 8; cornerIndex++)
{
Vector3 corner = new(
(cornerIndex & 1) == 0 ? localMin.X : localMax.X,
(cornerIndex & 2) == 0 ? localMin.Y : localMax.Y,
(cornerIndex & 4) == 0 ? localMin.Z : localMax.Z);
Vector3 transformed = Vector3.Transform(corner, entity.Rotation);
if (cornerIndex == 0)
{
minimum = transformed;
maximum = transformed;
}
else
{
minimum = Vector3.Min(minimum, transformed);
maximum = Vector3.Max(maximum, transformed);
}
}
Vector3 margin = new(DefaultAabbRadius);
return (
position + minimum - margin,
position + maximum + margin);
}
float radius = DefaultAabbRadius;
for (int i = 0; i < entity.MeshRefs.Count; i++)
{
radius = MathF.Max(
radius,
DefaultAabbRadius
+ entity.MeshRefs[i].PartTransform.Translation.Length());
}
Vector3 extent = new(radius);
return (position - extent, position + extent);
}
private RenderOwnerIncarnation NextIncarnation()
{
if (_nextIncarnation == ulong.MaxValue)

View file

@ -2,6 +2,7 @@ using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Settings;
using AcDream.App.World;
@ -136,6 +137,7 @@ internal sealed class LiveObjectFrameController : ILiveObjectFramePhase
_animatedEntities;
private readonly EquippedChildRenderController _equippedChildren;
private readonly LiveEffectFrameController _effects;
private readonly ILiveRenderProjectionSink? _renderProjections;
public LiveObjectFrameController(
RetailInboundEventDispatcher inboundEvents,
@ -149,7 +151,8 @@ internal sealed class LiveObjectFrameController : ILiveObjectFramePhase
LiveEntityAnimationPresenter animationPresenter,
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animatedEntities,
EquippedChildRenderController equippedChildren,
LiveEffectFrameController effects)
LiveEffectFrameController effects,
ILiveRenderProjectionSink? renderProjections = null)
{
_inboundEvents = inboundEvents ?? throw new ArgumentNullException(nameof(inboundEvents));
_localPlayerFrame = localPlayerFrame
@ -168,6 +171,7 @@ internal sealed class LiveObjectFrameController : ILiveObjectFramePhase
_equippedChildren = equippedChildren
?? throw new ArgumentNullException(nameof(equippedChildren));
_effects = effects ?? throw new ArgumentNullException(nameof(effects));
_renderProjections = renderProjections;
}
public void Tick(float deltaSeconds) =>
@ -202,6 +206,7 @@ internal sealed class LiveObjectFrameController : ILiveObjectFramePhase
if (_animatedEntities.Count > 0)
_animationPresenter.Present(schedules);
_equippedChildren.Tick();
_renderProjections?.SynchronizeActiveSources();
// Retail CPhysicsObj::animate_static_object @ 0x00513DF0 runs
// Script -> Particle -> process_hooks. acdream intentionally retains
// the TS-51 static-order difference here: ProcessHooks precedes the
@ -221,24 +226,28 @@ internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcileP
private readonly EquippedChildRenderController _equippedChildren;
private readonly ParticleHookSink _particleSink;
private readonly LiveEntityLightController _lights;
private readonly ILiveRenderProjectionSink? _renderProjections;
public LiveSpatialPresentationReconciler(
EntityEffectController entityEffects,
EquippedChildRenderController equippedChildren,
ParticleHookSink particleSink,
LiveEntityLightController lights)
LiveEntityLightController lights,
ILiveRenderProjectionSink? renderProjections = null)
{
_entityEffects = entityEffects ?? throw new ArgumentNullException(nameof(entityEffects));
_equippedChildren = equippedChildren
?? throw new ArgumentNullException(nameof(equippedChildren));
_particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
_lights = lights ?? throw new ArgumentNullException(nameof(lights));
_renderProjections = renderProjections;
}
public void Reconcile()
{
_entityEffects.RefreshLiveOwnerPoses();
_equippedChildren.Tick();
_renderProjections?.SynchronizeActiveSources();
_particleSink.RefreshAttachedEmitters();
_lights.Refresh();
}

View file

@ -1,5 +1,6 @@
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.Core.Net;
@ -80,16 +81,19 @@ internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
private readonly Func<LiveEntityRecord, bool> _prepareEffects;
private readonly Func<LiveEntityRecord, bool> _presentState;
private readonly Func<LiveEntityRecord, bool> _replayEffects;
private readonly ILiveRenderProjectionSink? _renderProjection;
public LiveEntityReadyPublisher(
LiveEntityRuntime runtime,
EntityEffectController effects,
LiveEntityPresentationController presentation)
LiveEntityPresentationController presentation,
ILiveRenderProjectionSink? renderProjection = null)
: this(
runtime,
record => effects.PrepareLiveEntityOwner(record.ServerGuid),
record => presentation.OnLiveEntityReady(record.ServerGuid),
record => effects.ReplayPendingForLiveEntity(record.ServerGuid))
record => effects.ReplayPendingForLiveEntity(record.ServerGuid),
renderProjection)
{
ArgumentNullException.ThrowIfNull(effects);
ArgumentNullException.ThrowIfNull(presentation);
@ -99,7 +103,8 @@ internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
LiveEntityRuntime runtime,
Func<LiveEntityRecord, bool> prepareEffects,
Func<LiveEntityRecord, bool> presentState,
Func<LiveEntityRecord, bool> replayEffects)
Func<LiveEntityRecord, bool> replayEffects,
ILiveRenderProjectionSink? renderProjection = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_prepareEffects = prepareEffects
@ -108,6 +113,7 @@ internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
?? throw new ArgumentNullException(nameof(presentState));
_replayEffects = replayEffects
?? throw new ArgumentNullException(nameof(replayEffects));
_renderProjection = renderProjection;
}
public bool Publish(LiveEntityReadyCandidate candidate)
@ -125,7 +131,13 @@ internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
return false;
}
return _replayEffects(expectedRecord)
if (!_replayEffects(expectedRecord)
|| !candidate.IsCurrent(_runtime))
{
return false;
}
return (_renderProjection?.OnEntityReady(candidate) ?? true)
&& candidate.IsCurrent(_runtime);
}
}

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;