perf(rendering): reconcile only changed attachments

This commit is contained in:
Erik 2026-07-25 05:18:33 +02:00
parent 6b56f4bef2
commit b9cbf5e040
8 changed files with 246 additions and 14 deletions

View file

@ -328,7 +328,7 @@ graph corrupts collision state, which is worse than the allocation.
## #238 — EquippedChildRenderController ticks twice per frame with six ToArray snapshots ## #238 — EquippedChildRenderController ticks twice per frame with six ToArray snapshots
**Status:** OPEN **Status:** DONE — 2026-07-25, Modern Runtime Slice H-a3
**Severity:** MEDIUM **Severity:** MEDIUM
**Filed:** 2026-07-24 **Filed:** 2026-07-24
**Component:** render / live entities **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`; **Files:** `src/AcDream.App/Rendering/EquippedChildRenderController.cs:265-310,1421-1429`;
`src/AcDream.App/Update/LiveObjectFrameController.cs:204,241`. `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 ## #239 — Inbound net path allocates 3-4 arrays/objects per packet

View file

@ -62,7 +62,7 @@ cooldown step.
Landed evidence: Landed evidence:
[`../research/2026-07-25-slice-h-a2-cooldown-scope.md`](../research/2026-07-25-slice-h-a2-cooldown-scope.md). [`../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 1. Preserve both frame ordering points: pre-network presentation and
post-network spatial reconciliation have different authority boundaries. 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 Gate: equip, unparent, reparent, pose loss, rollback, delete, GUID reuse, and
subtree-withdrawal suites preserve exact event and resource counts. 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 ### H-a4 — diagnostics and frame scratch
1. Reuse one synchronous `RetailPViewFrameInput` owned by 1. Reuse one synchronous `RetailPViewFrameInput` owned by

View file

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

View file

@ -49,9 +49,20 @@ public sealed class EquippedChildRenderController : IDisposable
private readonly Dictionary<uint, PendingProjectionSubtree> _pendingPoseLossRemovalByChild = new(); private readonly Dictionary<uint, PendingProjectionSubtree> _pendingPoseLossRemovalByChild = new();
private readonly Dictionary<uint, PendingProjectionSubtree> _pendingOrphanRemovalByChild = new(); private readonly Dictionary<uint, PendingProjectionSubtree> _pendingOrphanRemovalByChild = new();
private readonly List<uint> _pendingProjectionChildrenScratch = new(); private readonly List<uint> _pendingProjectionChildrenScratch = new();
private readonly List<KeyValuePair<uint, PendingOrdinaryRemoval>>
_pendingOrdinaryRemovalScratch = new();
private readonly List<KeyValuePair<uint, PendingProjectionSubtree>>
_pendingProjectionSubtreeScratch = new();
private readonly List<KeyValuePair<uint, PendingUnparentTransition>>
_pendingUnparentScratch = new();
private readonly AttachmentUpdateOrder<AttachedChild> _updateOrder = new(); private readonly AttachmentUpdateOrder<AttachedChild> _updateOrder = new();
private readonly Func<AttachedChild, uint?> _parentOfAttached; private readonly Func<AttachedChild, uint?> _parentOfAttached;
private readonly Func<uint, bool> _tickAttached; private readonly Func<uint, bool> _tickAttached;
private readonly Func<uint, bool> _reconcileAttached;
private int _activePoseCompositionVisits;
internal int LastFullPoseCompositionVisits { get; private set; }
internal int LastReconcilePoseCompositionVisits { get; private set; }
public IEnumerable<uint> AttachedEntityIds public IEnumerable<uint> AttachedEntityIds
{ {
@ -82,6 +93,7 @@ public sealed class EquippedChildRenderController : IDisposable
?? throw new ArgumentNullException(nameof(withdrawProjection)); ?? throw new ArgumentNullException(nameof(withdrawProjection));
_parentOfAttached = static child => child.ParentGuid; _parentOfAttached = static child => child.ParentGuid;
_tickAttached = TickChild; _tickAttached = TickChild;
_reconcileAttached = ReconcileChild;
_objects.ObjectMoved += OnObjectMoved; _objects.ObjectMoved += OnObjectMoved;
_objects.MoveRolledBack += OnMoveRolledBack; _objects.MoveRolledBack += OnMoveRolledBack;
@ -265,25 +277,53 @@ public sealed class EquippedChildRenderController : IDisposable
public void Tick() public void Tick()
{ {
RetryPendingProjectionTransitions(); RetryPendingProjectionTransitions();
_activePoseCompositionVisits = 0;
IReadOnlyList<uint> failed = _updateOrder.ForEachParentFirst( IReadOnlyList<uint> failed = _updateOrder.ForEachParentFirst(
_attachedByChild, _attachedByChild,
_parentOfAttached, _parentOfAttached,
_tickAttached); _tickAttached);
LastFullPoseCompositionVisits = _activePoseCompositionVisits;
for (int i = 0; i < failed.Count; i++)
WithdrawForPoseLoss(failed[i]);
}
/// <summary>
/// Preserve the post-network reconciliation boundary while recomposing
/// only attachment branches whose published parent pose/presentation
/// changed after the full animation-order pass.
/// </summary>
public void ReconcileSpatialMutations()
{
RetryPendingProjectionTransitions();
_activePoseCompositionVisits = 0;
IReadOnlyList<uint> failed = _updateOrder.ForEachParentFirst(
_attachedByChild,
_parentOfAttached,
_reconcileAttached);
LastReconcilePoseCompositionVisits = _activePoseCompositionVisits;
for (int i = 0; i < failed.Count; i++) for (int i = 0; i < failed.Count; i++)
WithdrawForPoseLoss(failed[i]); WithdrawForPoseLoss(failed[i]);
} }
private void RetryPendingProjectionTransitions() private void RetryPendingProjectionTransitions()
{ {
foreach ((uint rootGuid, PendingOrdinaryRemoval pending) in CopyEntries(
_pendingOrdinaryRemovalByRoot.ToArray()) _pendingOrdinaryRemovalByRoot,
_pendingOrdinaryRemovalScratch);
for (int i = 0; i < _pendingOrdinaryRemovalScratch.Count; i++)
{ {
(uint rootGuid, PendingOrdinaryRemoval pending) =
_pendingOrdinaryRemovalScratch[i];
AdvanceOrdinaryRemoval(rootGuid, pending); AdvanceOrdinaryRemoval(rootGuid, pending);
} }
foreach ((uint childGuid, PendingProjectionSubtree pending) in CopyEntries(
_pendingDetachedRemovalByChild.ToArray()) _pendingDetachedRemovalByChild,
_pendingProjectionSubtreeScratch);
for (int i = 0; i < _pendingProjectionSubtreeScratch.Count; i++)
{ {
(uint childGuid, PendingProjectionSubtree pending) =
_pendingProjectionSubtreeScratch[i];
AdvanceDetachedRemoval(childGuid, pending); AdvanceDetachedRemoval(childGuid, pending);
} }
@ -291,9 +331,11 @@ public sealed class EquippedChildRenderController : IDisposable
RetryProjectionSubtrees(_pendingPoseLossRemovalByChild); RetryProjectionSubtrees(_pendingPoseLossRemovalByChild);
RetryProjectionSubtrees(_pendingOrphanRemovalByChild); RetryProjectionSubtrees(_pendingOrphanRemovalByChild);
foreach ((uint childGuid, PendingUnparentTransition pending) in CopyEntries(_pendingUnparentByChild, _pendingUnparentScratch);
_pendingUnparentByChild.ToArray()) for (int i = 0; i < _pendingUnparentScratch.Count; i++)
{ {
(uint childGuid, PendingUnparentTransition pending) =
_pendingUnparentScratch[i];
if (!_liveEntities.IsCurrentRecord(pending.Record) if (!_liveEntities.IsCurrentRecord(pending.Record)
|| pending.Record.PositionAuthorityVersion || pending.Record.PositionAuthorityVersion
!= pending.PositionAuthorityVersion) != pending.PositionAuthorityVersion)
@ -309,11 +351,21 @@ public sealed class EquippedChildRenderController : IDisposable
ResolveAndTryRealize(_pendingProjectionChildrenScratch[i]); ResolveAndTryRealize(_pendingProjectionChildrenScratch[i]);
} }
private static void CopyEntries<T>(
Dictionary<uint, T> source,
List<KeyValuePair<uint, T>> destination)
{
destination.Clear();
foreach (KeyValuePair<uint, T> entry in source)
destination.Add(entry);
}
private bool TickChild(uint childGuid) private bool TickChild(uint childGuid)
{ {
if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child)) if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child))
return false; return false;
_activePoseCompositionVisits++;
if (TryResolveExactAttachment(child, out WorldEntity parent) if (TryResolveExactAttachment(child, out WorldEntity parent)
&& _poses.TryGetRootPose(parent.Id, out Matrix4x4 parentWorld) && _poses.TryGetRootPose(parent.Id, out Matrix4x4 parentWorld)
&& _poses.TryGetPartPoseSnapshot( && _poses.TryGetPartPoseSnapshot(
@ -339,6 +391,7 @@ public sealed class EquippedChildRenderController : IDisposable
return false; return false;
ApplyParentDrawVisibility(child.Entity, parent); ApplyParentDrawVisibility(child.Entity, parent);
child.Entity.ParentCellId = parent.ParentCellId; child.Entity.ParentCellId = parent.ParentCellId;
CaptureParentPresentation(child, parent);
PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose); PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose);
if (TryResolveExactAttachment(child, out parent) if (TryResolveExactAttachment(child, out parent)
&& parent.ParentCellId is { } parentCellId) && parent.ParentCellId is { } parentCellId)
@ -349,6 +402,39 @@ public sealed class EquippedChildRenderController : IDisposable
return false; 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( private bool TryRealize(
ParentAttachmentRelation pending, ParentAttachmentRelation pending,
ParentProjectionCandidateKind candidateKind) ParentProjectionCandidateKind candidateKind)
@ -473,7 +559,7 @@ public sealed class EquippedChildRenderController : IDisposable
? changes.Select(change => new PartOverride(change.PartIndex, change.NewModelId)).ToArray() ? changes.Select(change => new PartOverride(change.PartIndex, change.NewModelId)).ToArray()
: Array.Empty<PartOverride>()); : Array.Empty<PartOverride>());
entity.SetIndexedPartPoses(pose.PartLocal, childPartAvailability); entity.SetIndexedPartPoses(pose.PartLocal, childPartAvailability);
_attachedByChild[childGuid] = new AttachedChild( var attached = new AttachedChild(
parentRecord, parentRecord,
childRecord, childRecord,
pending.ParentGuid, pending.ParentGuid,
@ -488,6 +574,8 @@ public sealed class EquippedChildRenderController : IDisposable
pose.AttachedParts, pose.AttachedParts,
scale, scale,
entity); entity);
CaptureParentPresentation(attached, parentEntity);
_attachedByChild[childGuid] = attached;
_pendingUnparentByChild.Remove(childGuid); _pendingUnparentByChild.Remove(childGuid);
if ((parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0) if ((parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0)
{ {
@ -1421,9 +1509,11 @@ public sealed class EquippedChildRenderController : IDisposable
private void RetryProjectionSubtrees( private void RetryProjectionSubtrees(
Dictionary<uint, PendingProjectionSubtree> pendingByRoot) Dictionary<uint, PendingProjectionSubtree> pendingByRoot)
{ {
foreach ((uint rootGuid, PendingProjectionSubtree pending) in CopyEntries(pendingByRoot, _pendingProjectionSubtreeScratch);
pendingByRoot.ToArray()) for (int i = 0; i < _pendingProjectionSubtreeScratch.Count; i++)
{ {
(uint rootGuid, PendingProjectionSubtree pending) =
_pendingProjectionSubtreeScratch[i];
AdvanceProjectionSubtree(pendingByRoot, rootGuid, pending); AdvanceProjectionSubtree(pendingByRoot, rootGuid, pending);
} }
} }
@ -1476,7 +1566,14 @@ public sealed class EquippedChildRenderController : IDisposable
Matrix4x4[] PartPoseBuffer, Matrix4x4[] PartPoseBuffer,
MeshRef[] AttachedPartBuffer, MeshRef[] AttachedPartBuffer,
float Scale, 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( private readonly record struct PendingUnparentTransition(
LiveEntityRecord Record, LiveEntityRecord Record,

View file

@ -28,6 +28,7 @@ public sealed class EntityEffectPoseRegistry :
public bool[] PartAvailable = Array.Empty<bool>(); public bool[] PartAvailable = Array.Empty<bool>();
public uint CellId; public uint CellId;
public ulong LifetimeVersion; public ulong LifetimeVersion;
public ulong ChangeVersion;
} }
private readonly Dictionary<uint, PoseRecord> _poses = new(); private readonly Dictionary<uint, PoseRecord> _poses = new();
@ -112,8 +113,11 @@ public sealed class EntityEffectPoseRegistry :
} }
if (changed) if (changed)
{
record.ChangeVersion++;
EffectPoseChanged?.Invoke(entity.Id); EffectPoseChanged?.Invoke(entity.Id);
} }
}
public void Publish( public void Publish(
uint localEntityId, uint localEntityId,
@ -142,8 +146,11 @@ public sealed class EntityEffectPoseRegistry :
record.CellId = cellId; record.CellId = cellId;
changed |= CopyParts(record, partLocal, availability); changed |= CopyParts(record, partLocal, availability);
if (changed) if (changed)
{
record.ChangeVersion++;
EffectPoseChanged?.Invoke(localEntityId); EffectPoseChanged?.Invoke(localEntityId);
} }
}
/// <summary>Refresh only the moving root while retaining current part poses.</summary> /// <summary>Refresh only the moving root while retaining current part poses.</summary>
public bool UpdateRoot(WorldEntity entity) public bool UpdateRoot(WorldEntity entity)
@ -159,6 +166,7 @@ public sealed class EntityEffectPoseRegistry :
record.RootWorld = rootWorld; record.RootWorld = rootWorld;
record.CellId = cellId; record.CellId = cellId;
record.ChangeVersion++;
EffectPoseChanged?.Invoke(entity.Id); EffectPoseChanged?.Invoke(entity.Id);
return true; return true;
} }
@ -258,6 +266,16 @@ public sealed class EntityEffectPoseRegistry :
? record.LifetimeVersion ? record.LifetimeVersion
: 0UL; : 0UL;
/// <summary>
/// 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.
/// </summary>
public ulong GetPoseChangeVersion(uint localEntityId) =>
_poses.TryGetValue(localEntityId, out PoseRecord? record)
? record.ChangeVersion
: 0UL;
private ulong NextLifetimeVersion() private ulong NextLifetimeVersion()
{ {
_nextLifetimeVersion++; _nextLifetimeVersion++;

View file

@ -268,7 +268,7 @@ internal sealed class LiveSpatialPresentationReconciler : ILiveSpatialReconcileP
public void Reconcile() public void Reconcile()
{ {
_entityEffects.RefreshLiveOwnerPoses(); _entityEffects.RefreshLiveOwnerPoses();
_equippedChildren.Tick(); _equippedChildren.ReconcileSpatialMutations();
_renderProjections?.SynchronizeActiveSources(); _renderProjections?.SynchronizeActiveSources();
_particleSink.RefreshAttachedEmitters(); _particleSink.RefreshAttachedEmitters();
_lights.Refresh(); _lights.Refresh();

View file

@ -257,6 +257,67 @@ public sealed class EquippedChildProjectionWithdrawalTests
Assert.True(record.IsSpatiallyProjected); 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<Matrix4x4>());
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<Matrix4x4>());
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] [Fact]
public void AcceptedValidParent_WithdrawsWorldProjectionBeforePosePrerequisitesExist() public void AcceptedValidParent_WithdrawsWorldProjectionBeforePosePrerequisitesExist()
{ {

View file

@ -356,7 +356,7 @@ public sealed class UpdateFrameOrchestratorTests
source, source,
"public void Reconcile()", "public void Reconcile()",
"_entityEffects.RefreshLiveOwnerPoses", "_entityEffects.RefreshLiveOwnerPoses",
"_equippedChildren.Tick", "_equippedChildren.ReconcileSpatialMutations",
"_particleSink.RefreshAttachedEmitters", "_particleSink.RefreshAttachedEmitters",
"_lights.Refresh"); "_lights.Refresh");