diff --git a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md index a89ec894..553b20ac 100644 --- a/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md +++ b/docs/plans/2026-07-24-modern-runtime-slices-f-g-render-scene.md @@ -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. | -| F4–F5 | 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. | | G0–G5 | pending | No production consumer has switched. | The exact pre-F/G runtime rollback anchor remains `e7d9d6fa`. F0 is diff --git a/src/AcDream.App/Composition/LivePresentationRuntimeBindings.cs b/src/AcDream.App/Composition/LivePresentationRuntimeBindings.cs index c26f61db..cb3ed253 100644 --- a/src/AcDream.App/Composition/LivePresentationRuntimeBindings.cs +++ b/src/AcDream.App/Composition/LivePresentationRuntimeBindings.cs @@ -59,6 +59,17 @@ internal sealed class LivePresentationRuntimeBindings : IDisposable () => source.ProjectionPoseReady -= handler)); } + public void BindProjectionRemoved( + EquippedChildRenderController source, + Action 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) diff --git a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs new file mode 100644 index 00000000..f30af866 --- /dev/null +++ b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs @@ -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(); +} + +/// +/// Change-driven render projection for canonical live records. It retains +/// exact record references and local presentation IDs only; server GUID lookup +/// remains exclusively in . +/// +internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink +{ + private const byte LiveProjectionDomain = 3; + + private readonly LiveEntityRuntime _runtime; + private readonly RenderProjectionJournal _journal; + private readonly Dictionary _byLocalId = []; + private readonly List _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); + } + } + + /// + /// Mirrors only registered live/attached sources after simulation, + /// animation, and attachment pose composition have committed. Resident + /// statics are never scanned. + /// + 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); +} diff --git a/src/AcDream.App/Rendering/Scene/RenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/RenderProjectionJournal.cs index 4d99b99a..d8d8b72e 100644 --- a/src/AcDream.App/Rendering/Scene/RenderProjectionJournal.cs +++ b/src/AcDream.App/Rendering/Scene/RenderProjectionJournal.cs @@ -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); diff --git a/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs b/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs new file mode 100644 index 00000000..e9f4635f --- /dev/null +++ b/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs @@ -0,0 +1,130 @@ +using System.Numerics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Scene; + +/// +/// Pure conversion from an accepted presentation +/// snapshot into the packed render-scene record. This method never mutates +/// source AABB state or acquires gameplay identity. +/// +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); + } +} diff --git a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs index 16240b56..fc491ea8 100644 --- a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs +++ b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs @@ -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> @@ -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)); + landblockId, + 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) diff --git a/src/AcDream.App/Update/LiveObjectFrameController.cs b/src/AcDream.App/Update/LiveObjectFrameController.cs index 6b76745e..a6ba5de5 100644 --- a/src/AcDream.App/Update/LiveObjectFrameController.cs +++ b/src/AcDream.App/Update/LiveObjectFrameController.cs @@ -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 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(); } diff --git a/src/AcDream.App/World/LiveEntityHydrationPorts.cs b/src/AcDream.App/World/LiveEntityHydrationPorts.cs index d11bc854..5687f64e 100644 --- a/src/AcDream.App/World/LiveEntityHydrationPorts.cs +++ b/src/AcDream.App/World/LiveEntityHydrationPorts.cs @@ -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 _prepareEffects; private readonly Func _presentState; private readonly Func _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 prepareEffects, Func presentState, - Func replayEffects) + Func 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); } } diff --git a/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs b/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs new file mode 100644 index 00000000..5c4c1e76 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs @@ -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(), + 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(), + Array.Empty(), + Array.Empty(), + 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); +} diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs index 852e8ac5..30be7b3a 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -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(); + 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 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;