Skip to content

Commit 7cabd63

Browse files
committed
more fixes
1 parent f90a041 commit 7cabd63

10 files changed

Lines changed: 673 additions & 33 deletions

File tree

Basis Server/BasisNetworkServer/BasisServerHandleEvents.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ public static void Stop()
9090
thread.Join(500);
9191
}
9292
lock (_pending) { _pending.Clear(); }
93+
// Departures must be dropped too: a restarted server announcing the previous
94+
// session's leavers would tell clients to despawn players that never existed.
95+
lock (_pendingLeaves) { _pendingLeaves.Clear(); }
9396
_peerSeq.Clear();
9497
}
9598

Basis Server/BasisServerTests/BasisConnectionLifecycleTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,14 @@ public void DisconnectBroadcast_NotifiesEveryOtherPeer_WithTheLeaverId()
584584
(_, FakeNetPeer b) = Connected(witnessB);
585585
NetworkServer.RebuildPeerSnapshot();
586586

587+
// The broadcaster's queues are process-wide, so drop anything another test left pending;
588+
// otherwise a stale id rides along in this test's packet and is read as the leaver.
589+
BasisServerHandleEvents.JoinBroadcast.Stop();
590+
587591
BasisServerHandleEvents.HandlePeerDisconnected(leaving, Info());
592+
// Departures are coalesced now, so the notice goes out on the next flush rather than inline.
593+
// The invariant below is unchanged — only when it is observable moved.
594+
BasisServerHandleEvents.JoinBroadcast.Flush();
588595

589596
// Both remaining peers get one disconnect notice carrying the leaver's ushort id.
590597
foreach (FakeNetPeer witness in new[] { a, b })

Basis Server/BasisServerTests/JoinBroadcastTests.cs

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ public void Flush_SendsOnlyJoinsNewerThanEachPeersOwn()
8282
NetworkServer.RebuildPeerSnapshot();
8383

8484
// middle and late are the two joins being announced in this flush.
85-
BasisServerHandleEvents.JoinBroadcast.Enqueue(middleSeq, RecordFor(9102));
86-
BasisServerHandleEvents.JoinBroadcast.Enqueue(lateSeq, RecordFor(9103));
85+
BasisServerHandleEvents.JoinBroadcast.Enqueue(middleSeq, middle.Id, RecordFor(9102));
86+
BasisServerHandleEvents.JoinBroadcast.Enqueue(lateSeq, late.Id, RecordFor(9103));
8787

8888
BasisServerHandleEvents.JoinBroadcast.Flush();
8989

@@ -117,7 +117,7 @@ public void Flush_CoalescesManyJoinsIntoOneSendPerPeer()
117117
const int joins = 25;
118118
for (int i = 0; i < joins; i++)
119119
{
120-
BasisServerHandleEvents.JoinBroadcast.Enqueue(BasisServerHandleEvents.JoinBroadcast.NextSeq(), RecordFor((ushort)(9300 + i)));
120+
BasisServerHandleEvents.JoinBroadcast.Enqueue(BasisServerHandleEvents.JoinBroadcast.NextSeq(), 9300 + i, RecordFor((ushort)(9300 + i)));
121121
}
122122

123123
BasisServerHandleEvents.JoinBroadcast.Flush();
@@ -129,6 +129,63 @@ public void Flush_CoalescesManyJoinsIntoOneSendPerPeer()
129129
BasisServerHandleEvents.JoinBroadcast.UnregisterPeer(observer.Id);
130130
}
131131

132+
private static List<ushort> DepartureIdsIn(FakeNetPeer peer)
133+
{
134+
var sends = peer.Sent.Where(s => s.Channel == BasisNetworkCommons.DisconnectionChannel).ToList();
135+
Assert.Single(sends);
136+
NetDataReader reader = new NetDataReader(sends[0].Data);
137+
List<ushort> ids = new List<ushort>();
138+
while (reader.AvailableBytes >= sizeof(ushort)) ids.Add(reader.GetUShort());
139+
return ids;
140+
}
141+
142+
[Fact]
143+
public void Flush_CoalescesDeparturesIntoOneSendPerPeer()
144+
{
145+
using var scope = new ServerStaticsScope();
146+
BasisServerHandleEvents.JoinBroadcast.Stop();
147+
148+
FakeNetPeer watcher = new FakeNetPeer(9500, "127.0.0.1");
149+
BasisServerHandleEvents.JoinBroadcast.RegisterPeer(watcher.Id, BasisServerHandleEvents.JoinBroadcast.NextSeq());
150+
NetworkServer.AuthenticatedPeers[(ushort)watcher.Id] = watcher;
151+
NetworkServer.RebuildPeerSnapshot();
152+
153+
BasisServerHandleEvents.JoinBroadcast.EnqueueLeave(9601);
154+
BasisServerHandleEvents.JoinBroadcast.EnqueueLeave(9602);
155+
BasisServerHandleEvents.JoinBroadcast.EnqueueLeave(9603);
156+
157+
BasisServerHandleEvents.JoinBroadcast.Flush();
158+
159+
// Three departures, one packet — the client reads ids until the buffer runs out.
160+
Assert.Equal(new List<ushort> { 9601, 9602, 9603 }, DepartureIdsIn(watcher));
161+
162+
BasisServerHandleEvents.JoinBroadcast.UnregisterPeer(watcher.Id);
163+
}
164+
165+
[Fact]
166+
public void Flush_DropsBothWhenAPlayerLeavesBeforeItsJoinWasAnnounced()
167+
{
168+
using var scope = new ServerStaticsScope();
169+
BasisServerHandleEvents.JoinBroadcast.Stop();
170+
171+
FakeNetPeer watcher = new FakeNetPeer(9700, "127.0.0.1");
172+
BasisServerHandleEvents.JoinBroadcast.RegisterPeer(watcher.Id, BasisServerHandleEvents.JoinBroadcast.NextSeq());
173+
NetworkServer.AuthenticatedPeers[(ushort)watcher.Id] = watcher;
174+
NetworkServer.RebuildPeerSnapshot();
175+
176+
// Joins and leaves ride different channels, so a departure could otherwise overtake the
177+
// matching arrival and leave a player spawned forever. Cancelling the pair removes the race.
178+
const int flapper = 9701;
179+
BasisServerHandleEvents.JoinBroadcast.Enqueue(BasisServerHandleEvents.JoinBroadcast.NextSeq(), flapper, RecordFor((ushort)flapper));
180+
BasisServerHandleEvents.JoinBroadcast.EnqueueLeave(flapper);
181+
182+
BasisServerHandleEvents.JoinBroadcast.Flush();
183+
184+
Assert.Empty(watcher.Sent);
185+
186+
BasisServerHandleEvents.JoinBroadcast.UnregisterPeer(watcher.Id);
187+
}
188+
132189
[Fact]
133190
public void Flush_WithNothingPending_SendsNothing()
134191
{

Basis/Packages/com.basis.framework/Constraints/BasisConstraintSystem.cs

Lines changed: 150 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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
{

Basis/Packages/com.basis.framework/Players/Local/BasisLocalPlayer.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Basis.Scripts.BasisCharacterController;
44
using Basis.Scripts.BasisSdk.Helpers;
55
using Basis.Scripts.Common;
6+
using Basis.Scripts.Constraints;
67
using Basis.Scripts.Device_Management;
78
using Basis.Scripts.Device_Management.Devices.Desktop;
89
using Basis.Scripts.Drivers;
@@ -409,6 +410,14 @@ public async Task CreateAvatar(byte LoadMode, BasisLoadableBundle BasisLoadableB
409410
CurrentAvatarUniqueID = BasisLoadableBundle.BasisRemoteBundleEncrypted.RemoteBeeFileLocation;
410411
await BasisAvatarFactory.LoadAvatarLocal(this, LoadMode, BasisLoadableBundle, this.transform.position, Quaternion.identity);
411412
OnLocalAvatarChanged?.Invoke();
413+
414+
// Tell the constraint solver which hierarchy is ours. It bands how often it re-reads a
415+
// constraint's state by distance from here, and exempts this one entirely — our own
416+
// constraints have to keep up frame for frame, a remote across the room does not.
417+
// Told nothing, it treats every avatar as near and refreshes everything at full rate:
418+
// correct, just without the saving.
419+
BasisConstraintSystem.SetPriorityRoot(
420+
BasisAvatar != null ? BasisAvatar.transform.root : null);
412421
BasisDataStore.SaveAvatar(CurrentAvatarUniqueID, LoadMode, LoadFileNameAndExtension);
413422
}
414423

0 commit comments

Comments
 (0)