@@ -79,6 +79,9 @@ private sealed class Registration
7979 /// every constraint that has no null source. Lets the refresh index straight in.
8080 /// </summary>
8181 public bool SourceMapIsIdentity ;
82+
83+ /// <summary>Which avatar or piece of content this belongs to; the refresh groups by it.</summary>
84+ public int AvatarId ;
8285 }
8386
8487 /// <summary>A euler no author will type, so the first refresh always converts.</summary>
@@ -93,6 +96,51 @@ private sealed class Registration
9396 private static readonly List < Transform > sTrackedScratch = new List < Transform > ( ) ;
9497 private static readonly List < Transform > sTargetScratch = new List < Transform > ( ) ;
9598 private static readonly List < Transform > sChainScratch = new List < Transform > ( ) ;
99+
100+ /// <summary>Registration indices grouped by avatar, so a refresh can walk one avatar's worth.</summary>
101+ private static readonly List < List < int > > sAvatarGroups = new List < List < int > > ( ) ;
102+ private static readonly Dictionary < Transform , int > sAvatarLookup = new Dictionary < Transform , int > ( ) ;
103+ private static readonly List < Transform > sAvatarRoots = new List < Transform > ( ) ;
104+
105+ /// <summary>
106+ /// The one avatar refreshed in full every frame — the local player. Everything else is a
107+ /// remote, and a remote's constraint weights are not frame-critical: nobody notices a
108+ /// blend weight landing two frames late on someone across the room, whereas paying for
109+ /// every remote's every source every frame is what the profile was showing.
110+ /// </summary>
111+ private static Transform sPriorityRoot ;
112+
113+ /// <summary>
114+ /// Marks a hierarchy as the one that must stay exact. Call it with the local player's avatar
115+ /// root; everything else falls to the round-robin. Passing null puts every avatar on the
116+ /// rotation, which is the right thing on a server or in a scene with no local player.
117+ /// </summary>
118+ public static void SetPriorityRoot ( Transform root )
119+ {
120+ sPriorityRoot = root ;
121+ sDirty = true ;
122+ }
123+
124+ /// <summary>
125+ /// Distance bands for how often a remote's constraint state is re-read, in metres from the
126+ /// local player. Close enough to read someone's face, their constraints keep up frame for
127+ /// frame; across the room they can lag a few frames without anyone being able to tell.
128+ ///
129+ /// Distance rather than a flat rotation because the cost should track what is actually
130+ /// visible, not how many people happen to be in the instance — a busy room full of distant
131+ /// avatars is exactly the case a rotation handles worst and this handles best.
132+ /// </summary>
133+ private const float NearMetres = 8f ;
134+ private const float MidMetres = 20f ;
135+ private const int NearInterval = 1 ;
136+ private const int MidInterval = 4 ;
137+ private const int FarInterval = 16 ;
138+
139+ private static int sRefreshFrame ;
140+
141+ /// <summary>Reused between rebuilds so the transform tables do not allocate every join.</summary>
142+ private static Transform [ ] sTrackedArray = Array . Empty < Transform > ( ) ;
143+ private static Transform [ ] sTargetArray = Array . Empty < Transform > ( ) ;
96144 /// <summary>Target transform to its row in the results/write arrays; deduplicates stacked constraints.</summary>
97145 private static readonly Dictionary < Transform , int > sTargetRowLookup = new Dictionary < Transform , int > ( ) ;
98146 private static readonly List < int > sOrderScratch = new List < int > ( ) ;
@@ -437,6 +485,10 @@ private static void Rebuild()
437485 sDampState . Clear ( ) ;
438486 sChain . Clear ( ) ;
439487 sChainBind . Clear ( ) ;
488+ sAvatarGroups . Clear ( ) ;
489+ sAvatarLookup . Clear ( ) ;
490+ sAvatarRoots . Clear ( ) ;
491+ sRefreshFrame = 0 ;
440492 sSources . Clear ( ) ;
441493 sWorld . Clear ( ) ;
442494 sLocal . Clear ( ) ;
@@ -494,19 +546,23 @@ private static void Rebuild()
494546 sSourceMapScratch . Add ( SourceIndex ) ;
495547 }
496548 registration . SourceCount = sSourceMapScratch . Count ;
497- registration . SourceMap = sSourceMapScratch . ToArray ( ) ;
498549
499- // The map only diverges when a null source was dropped during the flatten. Noting
500- // that here lets the per-frame refresh skip the indirection on everything else.
550+ // The map only diverges from the identity when a null source was dropped during the
551+ // flatten. Check before allocating rather than after: an identity map says nothing
552+ // the index does not already say, so the common case keeps no array at all. This runs
553+ // once per registration per rebuild, and a rebuild walks every registration there is.
501554 registration . SourceMapIsIdentity = true ;
502- for ( int MapIndex = 0 ; MapIndex < registration . SourceMap . Length ; MapIndex ++ )
555+ for ( int MapIndex = 0 ; MapIndex < sSourceMapScratch . Count ; MapIndex ++ )
503556 {
504- if ( registration . SourceMap [ MapIndex ] != MapIndex )
557+ if ( sSourceMapScratch [ MapIndex ] != MapIndex )
505558 {
506559 registration . SourceMapIsIdentity = false ;
507560 break ;
508561 }
509562 }
563+ registration . SourceMap = registration . SourceMapIsIdentity
564+ ? Array . Empty < int > ( )
565+ : sSourceMapScratch . ToArray ( ) ;
510566
511567 BasisConstraintSlot slot = BasisConstraintDefaults . Identity ( ToKind ( component . constraintType ) ) ;
512568 slot . TargetIndex = targetIndex ;
@@ -519,6 +575,8 @@ private static void Rebuild()
519575 : - 1 ;
520576 BuildChain ( component , ref slot ) ;
521577 registration . Parent = component as BasisParentConstraint ;
578+ registration . AvatarId = InternAvatar ( component . transform , Index ) ;
579+ slot . AvatarId = registration . AvatarId ;
522580 FillScalarState ( component , registration , ref slot ) ;
523581 sSlots . Add ( slot ) ;
524582 // Kept index-parallel with the slots, restoring whatever this registration was
@@ -549,8 +607,11 @@ private static void Rebuild()
549607 sChainLengths . Resize ( math . max ( 1 , longestChain ) , NativeArrayOptions . ClearMemory ) ;
550608
551609 sResults . Resize ( sTargetScratch . Count , NativeArrayOptions . ClearMemory ) ;
552- sTracked . SetTransforms ( sTrackedScratch . ToArray ( ) ) ;
553- sTargets . SetTransforms ( sTargetScratch . ToArray ( ) ) ;
610+ // SetTransforms needs an exact-length array, so these cannot be spans — but they can be
611+ // kept and reused. A rebuild triggered by anything other than a population change hands
612+ // back the same lengths, and this runs on every join.
613+ sTracked . SetTransforms ( FillArray ( sTrackedScratch , ref sTrackedArray ) ) ;
614+ sTargets . SetTransforms ( FillArray ( sTargetScratch , ref sTargetArray ) ) ;
554615
555616 sDirty = false ;
556617 }
@@ -823,10 +884,61 @@ private static Transform GetWorldUpObject(BasisConstraintBase component)
823884 /// — straight off the components, so inspector and script edits take effect the same frame
824885 /// without a rebuild.
825886 /// </summary>
887+ /// <summary>
888+ /// Re-reads the per-frame values off the components. Every field here is one an author or a
889+ /// script can change at any moment, so there is no way to know it moved without looking —
890+ /// and looking at every source of every constraint every frame is the cost. So the local
891+ /// player is looked at in full, and the remotes take turns.
892+ /// </summary>
826893 private static void RefreshDynamicState ( )
827894 {
828- for ( int Index = 0 ; Index < sRegistrations . Count ; Index ++ )
895+ int groupCount = sAvatarGroups . Count ;
896+ if ( groupCount == 0 )
897+ {
898+ return ;
899+ }
900+
901+ sRefreshFrame ++ ;
902+
903+ bool hasReference = sPriorityRoot != null ;
904+ float3 reference = hasReference ? ( float3 ) sPriorityRoot . position : float3 . zero ;
905+
906+ for ( int Group = 0 ; Group < groupCount ; Group ++ )
907+ {
908+ Transform root = sAvatarRoots [ Group ] ;
909+ if ( root == null )
910+ {
911+ sDirty = true ;
912+ continue ;
913+ }
914+
915+ // With no local player to measure from — a server, or before the avatar loads —
916+ // everything is treated as near. Slower, but never wrong, and it means a missing
917+ // SetPriorityRoot call degrades performance rather than behaviour.
918+ int interval = NearInterval ;
919+ if ( hasReference && root != sPriorityRoot )
920+ {
921+ float distanceSq = math . distancesq ( reference , ( float3 ) root . position ) ;
922+ interval = distanceSq <= NearMetres * NearMetres ? NearInterval
923+ : distanceSq <= MidMetres * MidMetres ? MidInterval
924+ : FarInterval ;
925+ }
926+
927+ // Offsetting the phase by the group keeps the far avatars from all landing on the
928+ // same frame, which would put the cost back as a spike instead of a flat line.
929+ if ( interval > 1 && ( sRefreshFrame + Group ) % interval != 0 )
930+ {
931+ continue ;
932+ }
933+ RefreshGroup ( sAvatarGroups [ Group ] ) ;
934+ }
935+ }
936+
937+ private static void RefreshGroup ( List < int > members )
938+ {
939+ for ( int Member = 0 ; Member < members . Count ; Member ++ )
829940 {
941+ int Index = members [ Member ] ;
830942 Registration registration = sRegistrations [ Index ] ;
831943 BasisConstraintBase component = registration . Component ;
832944 if ( component == null )
@@ -1178,6 +1290,36 @@ private static void AppendChainBone(Transform bone)
11781290 sChainBind . Add ( default ) ;
11791291 }
11801292
1293+ /// <summary>
1294+ /// Buckets a registration under the hierarchy root it belongs to. The root stands in for
1295+ /// "which avatar" without the solver needing to know what an avatar is — content that is not
1296+ /// an avatar simply becomes its own group and takes its turn like the rest.
1297+ /// </summary>
1298+ private static int InternAvatar ( Transform member , int registrationIndex )
1299+ {
1300+ Transform root = member . root ;
1301+ if ( ! sAvatarLookup . TryGetValue ( root , out int avatarId ) )
1302+ {
1303+ avatarId = sAvatarRoots . Count ;
1304+ sAvatarLookup [ root ] = avatarId ;
1305+ sAvatarRoots . Add ( root ) ;
1306+ sAvatarGroups . Add ( new List < int > ( ) ) ;
1307+ }
1308+ sAvatarGroups [ avatarId ] . Add ( registrationIndex ) ;
1309+ return avatarId ;
1310+ }
1311+
1312+ /// <summary>Copies into a cached array, growing it only when the count actually changed.</summary>
1313+ private static Transform [ ] FillArray ( List < Transform > source , ref Transform [ ] cache )
1314+ {
1315+ if ( cache . Length != source . Count )
1316+ {
1317+ cache = new Transform [ source . Count ] ;
1318+ }
1319+ source . CopyTo ( cache ) ;
1320+ return cache ;
1321+ }
1322+
11811323 /// <summary>Row this transform is written through, claiming a new one the first time.</summary>
11821324 private static int InternTargetRow ( Transform target )
11831325 {
0 commit comments