From b9cbf5e040306e1e4796bb14d66f7be3f5fcddc2 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 05:18:33 +0200 Subject: [PATCH] perf(rendering): reconcile only changed attachments --- docs/ISSUES.md | 10 +- .../2026-07-25-modern-runtime-slice-h.md | 5 +- ...7-25-slice-h-a3-attached-pose-reconcile.md | 45 +++++++ .../EquippedChildRenderController.cs | 117 ++++++++++++++++-- .../Rendering/Vfx/EntityEffectPoseRegistry.cs | 18 +++ .../Update/LiveObjectFrameController.cs | 2 +- .../EquippedChildProjectionWithdrawalTests.cs | 61 +++++++++ .../World/UpdateFrameOrchestratorTests.cs | 2 +- 8 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 docs/research/2026-07-25-slice-h-a3-attached-pose-reconcile.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 69911ba4..192ed506 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -328,7 +328,7 @@ graph corrupts collision state, which is worse than the allocation. ## #238 — EquippedChildRenderController ticks twice per frame with six ToArray snapshots -**Status:** OPEN +**Status:** DONE — 2026-07-25, Modern Runtime Slice H-a3 **Severity:** MEDIUM **Filed:** 2026-07-24 **Component:** render / live entities @@ -350,6 +350,14 @@ the ordering and skip the redundant recomposition instead. Plan Slice H-a. **Files:** `src/AcDream.App/Rendering/EquippedChildRenderController.cs:265-310,1421-1429`; `src/AcDream.App/Update/LiveObjectFrameController.cs:204,241`. +**Resolution:** Both required authority boundaries remain. The first is still a +complete parent-first pose pass; the post-network boundary now compares the +exact parent's pose version, identity, visibility, and cell and recomposes only +changed branches. Publishing a changed child dirties its descendants naturally +within the same parent-first walk. Per-heartbeat pending dictionaries use +retained typed snapshots instead of arrays. Evidence: +`docs/research/2026-07-25-slice-h-a3-attached-pose-reconcile.md`. + --- ## #239 — Inbound net path allocates 3-4 arrays/objects per packet diff --git a/docs/plans/2026-07-25-modern-runtime-slice-h.md b/docs/plans/2026-07-25-modern-runtime-slice-h.md index 37a8b0b2..2b6673b2 100644 --- a/docs/plans/2026-07-25-modern-runtime-slice-h.md +++ b/docs/plans/2026-07-25-modern-runtime-slice-h.md @@ -62,7 +62,7 @@ cooldown step. Landed evidence: [`../research/2026-07-25-slice-h-a2-cooldown-scope.md`](../research/2026-07-25-slice-h-a2-cooldown-scope.md). -### H-a3 — equipped-child transition work +### H-a3 — equipped-child transition work — COMPLETE 1. Preserve both frame ordering points: pre-network presentation and post-network spatial reconciliation have different authority boundaries. @@ -77,6 +77,9 @@ Landed evidence: Gate: equip, unparent, reparent, pose loss, rollback, delete, GUID reuse, and subtree-withdrawal suites preserve exact event and resource counts. +Landed evidence: +[`../research/2026-07-25-slice-h-a3-attached-pose-reconcile.md`](../research/2026-07-25-slice-h-a3-attached-pose-reconcile.md). + ### H-a4 — diagnostics and frame scratch 1. Reuse one synchronous `RetailPViewFrameInput` owned by diff --git a/docs/research/2026-07-25-slice-h-a3-attached-pose-reconcile.md b/docs/research/2026-07-25-slice-h-a3-attached-pose-reconcile.md new file mode 100644 index 00000000..a373ebd3 --- /dev/null +++ b/docs/research/2026-07-25-slice-h-a3-attached-pose-reconcile.md @@ -0,0 +1,45 @@ +# Slice H-a3 — equipped-child post-network reconciliation + +## Preserved ordering + +Retail live-object presentation still has two authority boundaries: + +1. the complete parent-first attachment update after animation; +2. post-network spatial reconciliation after accepted inbound mutations. + +The second boundary was not deleted or delayed. + +## Change + +`EntityEffectPoseRegistry` now carries a monotonic change version for the +current pose-owner lifetime. It advances only when root, indexed parts, part +availability, or cell changes. + +Each attached projection remembers the exact parent entity, pose version, +draw/ancestor visibility, and parent cell used for its last composition. + +- The animation-order pass remains a complete parent-first walk. +- The post-network pass keeps transition retries but skips a child whose + remembered parent presentation is unchanged. +- A changed root recomposes its direct child; publishing that child's pose + advances its version, so descendants update later in the same parent-first + pass. +- Hidden/NoDraw ancestry and cell changes participate even when the geometric + pose itself is unchanged. +- Pose failure still withdraws the same complete subtree. + +The per-frame transition retry path no longer allocates dictionary `ToArray` +snapshots. It copies key/value pairs into retained typed scratch lists, keeping +the old snapshot-before-callback semantics. + +## Gate + +- Existing equip/unparent/reparent/rollback/pose-loss/teardown tests pass. +- A root → child → grandchild test proves unchanged post-network reconcile + performs zero compositions and a changed root performs exactly two in + parent-first order. +- 1,000 stable post-network reconciles allocate zero managed bytes on the + calling thread. + +Release gate: 3,783 App tests passed / 3 skipped; 8,267 complete-solution +tests passed / 5 skipped. diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index bf270828..4aead8b7 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -49,9 +49,20 @@ public sealed class EquippedChildRenderController : IDisposable private readonly Dictionary _pendingPoseLossRemovalByChild = new(); private readonly Dictionary _pendingOrphanRemovalByChild = new(); private readonly List _pendingProjectionChildrenScratch = new(); + private readonly List> + _pendingOrdinaryRemovalScratch = new(); + private readonly List> + _pendingProjectionSubtreeScratch = new(); + private readonly List> + _pendingUnparentScratch = new(); private readonly AttachmentUpdateOrder _updateOrder = new(); private readonly Func _parentOfAttached; private readonly Func _tickAttached; + private readonly Func _reconcileAttached; + private int _activePoseCompositionVisits; + + internal int LastFullPoseCompositionVisits { get; private set; } + internal int LastReconcilePoseCompositionVisits { get; private set; } public IEnumerable AttachedEntityIds { @@ -82,6 +93,7 @@ public sealed class EquippedChildRenderController : IDisposable ?? throw new ArgumentNullException(nameof(withdrawProjection)); _parentOfAttached = static child => child.ParentGuid; _tickAttached = TickChild; + _reconcileAttached = ReconcileChild; _objects.ObjectMoved += OnObjectMoved; _objects.MoveRolledBack += OnMoveRolledBack; @@ -265,25 +277,53 @@ public sealed class EquippedChildRenderController : IDisposable public void Tick() { RetryPendingProjectionTransitions(); + _activePoseCompositionVisits = 0; IReadOnlyList failed = _updateOrder.ForEachParentFirst( _attachedByChild, _parentOfAttached, _tickAttached); + LastFullPoseCompositionVisits = _activePoseCompositionVisits; + for (int i = 0; i < failed.Count; i++) + WithdrawForPoseLoss(failed[i]); + } + + /// + /// Preserve the post-network reconciliation boundary while recomposing + /// only attachment branches whose published parent pose/presentation + /// changed after the full animation-order pass. + /// + public void ReconcileSpatialMutations() + { + RetryPendingProjectionTransitions(); + _activePoseCompositionVisits = 0; + IReadOnlyList failed = _updateOrder.ForEachParentFirst( + _attachedByChild, + _parentOfAttached, + _reconcileAttached); + LastReconcilePoseCompositionVisits = _activePoseCompositionVisits; for (int i = 0; i < failed.Count; i++) WithdrawForPoseLoss(failed[i]); } private void RetryPendingProjectionTransitions() { - foreach ((uint rootGuid, PendingOrdinaryRemoval pending) in - _pendingOrdinaryRemovalByRoot.ToArray()) + CopyEntries( + _pendingOrdinaryRemovalByRoot, + _pendingOrdinaryRemovalScratch); + for (int i = 0; i < _pendingOrdinaryRemovalScratch.Count; i++) { + (uint rootGuid, PendingOrdinaryRemoval pending) = + _pendingOrdinaryRemovalScratch[i]; AdvanceOrdinaryRemoval(rootGuid, pending); } - foreach ((uint childGuid, PendingProjectionSubtree pending) in - _pendingDetachedRemovalByChild.ToArray()) + CopyEntries( + _pendingDetachedRemovalByChild, + _pendingProjectionSubtreeScratch); + for (int i = 0; i < _pendingProjectionSubtreeScratch.Count; i++) { + (uint childGuid, PendingProjectionSubtree pending) = + _pendingProjectionSubtreeScratch[i]; AdvanceDetachedRemoval(childGuid, pending); } @@ -291,9 +331,11 @@ public sealed class EquippedChildRenderController : IDisposable RetryProjectionSubtrees(_pendingPoseLossRemovalByChild); RetryProjectionSubtrees(_pendingOrphanRemovalByChild); - foreach ((uint childGuid, PendingUnparentTransition pending) in - _pendingUnparentByChild.ToArray()) + CopyEntries(_pendingUnparentByChild, _pendingUnparentScratch); + for (int i = 0; i < _pendingUnparentScratch.Count; i++) { + (uint childGuid, PendingUnparentTransition pending) = + _pendingUnparentScratch[i]; if (!_liveEntities.IsCurrentRecord(pending.Record) || pending.Record.PositionAuthorityVersion != pending.PositionAuthorityVersion) @@ -309,11 +351,21 @@ public sealed class EquippedChildRenderController : IDisposable ResolveAndTryRealize(_pendingProjectionChildrenScratch[i]); } + private static void CopyEntries( + Dictionary source, + List> destination) + { + destination.Clear(); + foreach (KeyValuePair entry in source) + destination.Add(entry); + } + private bool TickChild(uint childGuid) { if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child)) return false; + _activePoseCompositionVisits++; if (TryResolveExactAttachment(child, out WorldEntity parent) && _poses.TryGetRootPose(parent.Id, out Matrix4x4 parentWorld) && _poses.TryGetPartPoseSnapshot( @@ -339,6 +391,7 @@ public sealed class EquippedChildRenderController : IDisposable return false; ApplyParentDrawVisibility(child.Entity, parent); child.Entity.ParentCellId = parent.ParentCellId; + CaptureParentPresentation(child, parent); PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose); if (TryResolveExactAttachment(child, out parent) && parent.ParentCellId is { } parentCellId) @@ -349,6 +402,39 @@ public sealed class EquippedChildRenderController : IDisposable return false; } + private bool ReconcileChild(uint childGuid) + { + if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child) + || !TryResolveExactAttachment(child, out WorldEntity parent)) + { + return false; + } + + return ParentPresentationMatches(child, parent) || TickChild(childGuid); + } + + private bool ParentPresentationMatches( + AttachedChild child, + WorldEntity parent) + => ReferenceEquals(child.LastParentEntity, parent) + && child.LastParentPoseVersion + == _poses.GetPoseChangeVersion(parent.Id) + && child.LastParentDrawVisible == parent.IsDrawVisible + && child.LastParentAncestorDrawVisible + == parent.IsAncestorDrawVisible + && child.LastParentCellId == parent.ParentCellId; + + private void CaptureParentPresentation( + AttachedChild child, + WorldEntity parent) + { + child.LastParentEntity = parent; + child.LastParentPoseVersion = _poses.GetPoseChangeVersion(parent.Id); + child.LastParentDrawVisible = parent.IsDrawVisible; + child.LastParentAncestorDrawVisible = parent.IsAncestorDrawVisible; + child.LastParentCellId = parent.ParentCellId; + } + private bool TryRealize( ParentAttachmentRelation pending, ParentProjectionCandidateKind candidateKind) @@ -473,7 +559,7 @@ public sealed class EquippedChildRenderController : IDisposable ? changes.Select(change => new PartOverride(change.PartIndex, change.NewModelId)).ToArray() : Array.Empty()); entity.SetIndexedPartPoses(pose.PartLocal, childPartAvailability); - _attachedByChild[childGuid] = new AttachedChild( + var attached = new AttachedChild( parentRecord, childRecord, pending.ParentGuid, @@ -488,6 +574,8 @@ public sealed class EquippedChildRenderController : IDisposable pose.AttachedParts, scale, entity); + CaptureParentPresentation(attached, parentEntity); + _attachedByChild[childGuid] = attached; _pendingUnparentByChild.Remove(childGuid); if ((parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0) { @@ -1421,9 +1509,11 @@ public sealed class EquippedChildRenderController : IDisposable private void RetryProjectionSubtrees( Dictionary pendingByRoot) { - foreach ((uint rootGuid, PendingProjectionSubtree pending) in - pendingByRoot.ToArray()) + CopyEntries(pendingByRoot, _pendingProjectionSubtreeScratch); + for (int i = 0; i < _pendingProjectionSubtreeScratch.Count; i++) { + (uint rootGuid, PendingProjectionSubtree pending) = + _pendingProjectionSubtreeScratch[i]; AdvanceProjectionSubtree(pendingByRoot, rootGuid, pending); } } @@ -1476,7 +1566,14 @@ public sealed class EquippedChildRenderController : IDisposable Matrix4x4[] PartPoseBuffer, MeshRef[] AttachedPartBuffer, float Scale, - WorldEntity Entity); + WorldEntity Entity) + { + public WorldEntity? LastParentEntity { get; set; } + public ulong LastParentPoseVersion { get; set; } + public bool LastParentDrawVisible { get; set; } + public bool LastParentAncestorDrawVisible { get; set; } + public uint? LastParentCellId { get; set; } + } private readonly record struct PendingUnparentTransition( LiveEntityRecord Record, diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs index a4b8ccfc..0e9a6362 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectPoseRegistry.cs @@ -28,6 +28,7 @@ public sealed class EntityEffectPoseRegistry : public bool[] PartAvailable = Array.Empty(); public uint CellId; public ulong LifetimeVersion; + public ulong ChangeVersion; } private readonly Dictionary _poses = new(); @@ -112,7 +113,10 @@ public sealed class EntityEffectPoseRegistry : } if (changed) + { + record.ChangeVersion++; EffectPoseChanged?.Invoke(entity.Id); + } } public void Publish( @@ -142,7 +146,10 @@ public sealed class EntityEffectPoseRegistry : record.CellId = cellId; changed |= CopyParts(record, partLocal, availability); if (changed) + { + record.ChangeVersion++; EffectPoseChanged?.Invoke(localEntityId); + } } /// Refresh only the moving root while retaining current part poses. @@ -159,6 +166,7 @@ public sealed class EntityEffectPoseRegistry : record.RootWorld = rootWorld; record.CellId = cellId; + record.ChangeVersion++; EffectPoseChanged?.Invoke(entity.Id); return true; } @@ -258,6 +266,16 @@ public sealed class EntityEffectPoseRegistry : ? record.LifetimeVersion : 0UL; + /// + /// Monotonic version of the current lifetime's root, part, availability, + /// or cell pose. Synchronous presentation owners use it to avoid repeating + /// a derived composition when the published parent pose is unchanged. + /// + public ulong GetPoseChangeVersion(uint localEntityId) => + _poses.TryGetValue(localEntityId, out PoseRecord? record) + ? record.ChangeVersion + : 0UL; + private ulong NextLifetimeVersion() { _nextLifetimeVersion++; diff --git a/src/AcDream.App/Update/LiveObjectFrameController.cs b/src/AcDream.App/Update/LiveObjectFrameController.cs index fe1120cf..92f3fcf2 100644 --- a/src/AcDream.App/Update/LiveObjectFrameController.cs +++ b/src/AcDream.App/Update/LiveObjectFrameController.cs @@ -268,7 +268,7 @@ internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcileP public void Reconcile() { _entityEffects.RefreshLiveOwnerPoses(); - _equippedChildren.Tick(); + _equippedChildren.ReconcileSpatialMutations(); _renderProjections?.SynchronizeActiveSources(); _particleSink.RefreshAttachedEmitters(); _lights.Refresh(); diff --git a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs index 024114e8..03cca8d2 100644 --- a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs @@ -257,6 +257,67 @@ public sealed class EquippedChildProjectionWithdrawalTests Assert.True(record.IsSpatiallyProjected); } + [Fact] + public void PostNetworkReconcile_SkipsStableTree_AndUpdatesChangedBranchParentFirst() + { + using var fixture = new ControllerFixture((_, _, _) => + new( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null)); + LiveEntityRecord root = fixture.Spawn(0x70000241u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000242u, + generation: 1, + LiveEntityProjectionKind.Attached); + LiveEntityRecord grandchild = fixture.Spawn( + 0x70000243u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(root, child); + fixture.InstallAttached(child, grandchild); + fixture.Poses.Publish(root.WorldEntity!, Array.Empty()); + + fixture.Controller.Tick(); + Assert.Equal(2, fixture.Controller.LastFullPoseCompositionVisits); + + fixture.Controller.ReconcileSpatialMutations(); + Assert.Equal(0, fixture.Controller.LastReconcilePoseCompositionVisits); + + root.WorldEntity!.SetPosition(new Vector3(7f, 8f, 9f)); + Assert.True(fixture.Poses.UpdateRoot(root.WorldEntity)); + fixture.Controller.ReconcileSpatialMutations(); + + Assert.Equal(2, fixture.Controller.LastReconcilePoseCompositionVisits); + Assert.Equal(root.WorldEntity.Position, child.WorldEntity!.Position); + Assert.Equal(child.WorldEntity.Position, grandchild.WorldEntity!.Position); + } + + [Fact] + public void StablePostNetworkReconcile_AllocatesNoTransitionSnapshots() + { + using var fixture = new ControllerFixture((_, _, _) => + new( + ExactProjectionWithdrawalDisposition.Completed, + Failure: null)); + LiveEntityRecord root = fixture.Spawn(0x70000244u, generation: 1); + LiveEntityRecord child = fixture.Spawn( + 0x70000245u, + generation: 1, + LiveEntityProjectionKind.Attached); + fixture.InstallAttached(root, child); + fixture.Poses.Publish(root.WorldEntity!, Array.Empty()); + fixture.Controller.Tick(); + fixture.Controller.ReconcileSpatialMutations(); + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 1_000; i++) + fixture.Controller.ReconcileSpatialMutations(); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0L, allocated); + Assert.Equal(0, fixture.Controller.LastReconcilePoseCompositionVisits); + } + [Fact] public void AcceptedValidParent_WithdrawsWorldProjectionBeforePosePrerequisitesExist() { diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index 5b2ef59c..f355a708 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -356,7 +356,7 @@ public sealed class UpdateFrameOrchestratorTests source, "public void Reconcile()", "_entityEffects.RefreshLiveOwnerPoses", - "_equippedChildren.Tick", + "_equippedChildren.ReconcileSpatialMutations", "_particleSink.RefreshAttachedEmitters", "_lights.Refresh");