Skip to content

Commit 44b7386

Browse files
fix: network clock adjustment overshoot and per-frame NetworkManager scene search (#4195)
* fix: stop the network clock adjustment on its target instead of stepping past it Once a client's clock reached the offset it adjusts towards, each frame stepped the full AdjustmentRatio past it and back, so LocalTime and ServerTime alternated between running fast and slow every frame. * fix: keep a list of NetworkManagers instead of searching the scene for them every frame UnifiedUpdateConnections called Object.FindObjectsByType<NetworkManager>() on every update, whose cost grows with the number of MonoBehaviours in the scene: about 1.5 ms a call with 10,000 and 14 ms with 50,000. NetworkManager.Instances is filled in Awake and emptied in OnDestroy, and the system visits that. * docs: describe the clock adjustment fix by what users saw * fix: bind connection updates to the hybrid world's own NetworkManager UnifiedUpdateConnections now runs only in client and server worlds that hold the NetworkManager they were created for, and updates that NetworkManager alone. This replaces the NetworkManager.Instances list, which a derived NetworkManager declaring its own Awake never entered. * fix: create a server only world for hybrid mode in dedicated server builds Netcode for Entities cannot create a single world host in a dedicated server build, which every hybrid server used. A build for only one side now logs an error when asked to start the side it does not support. * fix: treat a destroyed NetworkManager as shutting down in connection updates The hybrid world outlives its NetworkManager if ShutdownInternal throws before disposing it, so UnifiedUpdateConnections now disconnects every connection and announces none instead of throwing each frame. HybridNetworkManager.Value is renamed to NetworkManager, the clock adjustment comment says what the clamp does, and the unused Unity.Entities using that failed the Standards check is removed. * ci: TEMPORARY run the unified job on b1.xlarge to test the memory pressure theory Revert before merge. * fix: return no RTT for a hybrid connection that has already been removed A client drains its incoming queue while it disconnects, after the unified transport has removed the server connection, so a queued TimeSyncMessage made GetCurrentRtt throw KeyNotFoundException. It now returns 0 for a connection it no longer has, as UnityTransport does. * ci: TEMPORARY run the unified job on b1.large with 6000.7.0b4 to test whether 6000.7.0b6 raised memory use Replaces the b1.xlarge test. Revert before merge. * ci: compile testproject in a separate editor session before the unified test pass The IL post-processor and compiler processes the editor starts stay resident through the test pass and, on a b1.large agent, leave too little memory for the hybrid tests. The project is now imported and compiled in its own batchmode session that exits first, any build process that outlives it is stopped, and the job log lists the largest processes left before the tests start. Also drops the temporary 6000.7.0b4 editor pin. * ci: pin the unified warm-up to the Release code configuration The test runner compiles in Release. An editor whose code optimization preference is Debug builds the other configuration in the warm-up, and the test session then recompiles every assembly and restarts the IL post-processor. CI's agents already default to Release, so this is a guard rather than a fix there; it removed the recompile on a machine set to Debug.
1 parent e85a580 commit 44b7386

11 files changed

Lines changed: 241 additions & 58 deletions

File tree

‎.yamato/unified-tests.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,24 @@ unified_test_{{ project.name }}_{{ platform.name }}_{{ editor }}:
3535

3636
commands:
3737
- unity-downloader-cli --fast --wait -u {{ editor }} -c Editor --arch {{ platform.architecture }}
38+
# Import and compile testproject in its own editor session first. The IL post-processor and compiler processes it starts
39+
# otherwise stay resident for the whole test pass and, on this agent, leave too little memory for the tests.
40+
- mkdir -p warmup
41+
# -releaseCodeOptimization pins the Release configuration the test runner compiles. An editor whose code optimization
42+
# preference is Debug would otherwise build the other configuration, and the test session would recompile everything.
43+
- .Editor/Unity -batchmode -nographics -releaseCodeOptimization -logFile warmup/editor.log -projectPath {{ project.path }} -quit
44+
# Stop any build processes that outlive the warm-up editor, then list what is left so the job log shows it.
45+
- dotnet build-server shutdown || true
46+
- pkill -f Unity.ILPP.Runner || true
47+
- pkill -f VBCSCompiler || true
48+
- pkill -f bee_backend || true
49+
- ps -eo rss,comm --sort=-rss | head -15
3850
- UnifiedTestRunner --testproject={{ project.path }} --suite=playmode --artifacts-path=test-results --editor-location=.Editor --rerun-strategy=Test --retry={{ num_test_retries }} --clean-library-on-rerun --timeout={{ test_timeout }}
3951
artifacts:
4052
logs:
4153
paths:
4254
- "test-results/**/*"
55+
- "warmup/**/*"
4356
dependencies:
4457
- .yamato/_run-all.yml#run_quick_checks
4558
{% endfor -%}

‎com.unity.netcode.gameobjects/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Additional documentation and release notes are available at [Multiplayer Documen
2323

2424
### Fixed
2525

26+
- Issue where a client's clock kept alternating between running slightly fast and slightly slow on every frame, even after it had synchronized with the server. Interpolated objects juddered (a periodic motion artifact caused by mismatched frame rates): they moved in uneven steps instead of smoothly. Barely noticeable at the default `NetworkTimeSystem.AdjustmentRatio`, and clearly visible at higher values.
2627
- Issue where the IL post-processor could not resolve core library types, such as `Guid` and `List<T>`, in assemblies compiled against .NET reference assemblies, for example when targeting .NET 10. Those split the core library across `System.Runtime`, `System.Collections` and other assemblies.
2728
- Fixed issue where a prefab added to a `NetworkPrefabsList` while a session was running was registered more than once and logged a duplicate `GlobalObjectIdHash` error. (#4184)
2829
- Issue where moving a `NetworkObject` into another scene made the clients that did not observe it log "Trying to synchronize NetworkObjectId but it was not spawned". The scene migration is now only sent to the clients that observe the `NetworkObject`. (#4176)

‎com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedBootstrap.cs‎

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,17 +71,17 @@ public override bool Initialize(string defaultWorldName)
7171
RegisterCreatedWorlds();
7272
Debug.Log($"Starting a world for {(networkManager.IsServer ? "Host" : "Client")}");
7373
s_WorldCounter++;
74-
LastCreatedWorld = networkManager.IsServer ? CreateSingleWorldHost($"HostSingleWorld-{s_WorldCounter}")
75-
: CreateClientWorld($"ClientWorld-{s_WorldCounter}");
74+
var world = CreateWorld(networkManager);
75+
LastCreatedWorld = world;
7676

77-
if (LastCreatedWorld == null)
77+
if (world == null)
7878
{
7979
s_WorldCounter--;
8080
Debug.LogError($"[{nameof(UnifiedBootstrap)}] World is null!");
8181
return false;
8282
}
8383

84-
if (!LastCreatedWorld.IsCreated)
84+
if (!world.IsCreated)
8585
{
8686
s_WorldCounter--;
8787
Debug.LogError($"[{nameof(UnifiedBootstrap)}] World was not created!");
@@ -90,10 +90,10 @@ public override bool Initialize(string defaultWorldName)
9090

9191
if (networkManager.LogLevel <= LogLevel.Developer)
9292
{
93-
NetworkLog.LogInfo($"[{nameof(UnifiedBootstrap)}] Created world: {LastCreatedWorld.Name} / {LastCreatedWorld.SequenceNumber}");
93+
NetworkLog.LogInfo($"[{nameof(UnifiedBootstrap)}] Created world: {world.Name} / {world.SequenceNumber}");
9494
}
9595

96-
networkManager.NetcodeWorld = (NetcodeWorld)LastCreatedWorld;
96+
networkManager.NetcodeWorld = (NetcodeWorld)world;
9797
s_CreatedWorlds.Add(networkManager.NetcodeWorld);
9898
}
9999
else
@@ -106,10 +106,33 @@ public override bool Initialize(string defaultWorldName)
106106
return true;
107107
}
108108

109-
~UnifiedBootstrap()
109+
/// <summary>
110+
/// Creates the world used by a <see cref="NetworkManager"/>'s hybrid session.
111+
/// </summary>
112+
/// <remarks>
113+
/// A server or host uses a single world host. A dedicated server build uses a server-only world instead, because
114+
/// Netcode for Entities cannot create a client world there. A build that only targets the client or the server
115+
/// cannot start a host.
116+
/// </remarks>
117+
private static World CreateWorld(NetworkManager networkManager)
110118
{
111-
LastCreatedWorld = null;
112-
Instance = null;
119+
#if UNITY_SERVER && !UNITY_EDITOR
120+
if (networkManager.IsClient)
121+
{
122+
Debug.LogError($"[{nameof(UnifiedBootstrap)}] A dedicated server build cannot start a {(networkManager.IsServer ? "host" : "client")} in hybrid mode.");
123+
return null;
124+
}
125+
return CreateServerWorld($"ServerWorld-{s_WorldCounter}");
126+
#elif UNITY_CLIENT && !UNITY_EDITOR
127+
if (networkManager.IsServer)
128+
{
129+
Debug.LogError($"[{nameof(UnifiedBootstrap)}] A client only build cannot start a {(networkManager.IsClient ? "host" : "server")} in hybrid mode. Set the Netcode for Entities client build target to ClientAndServer.");
130+
return null;
131+
}
132+
return CreateClientWorld($"ClientWorld-{s_WorldCounter}");
133+
#else
134+
return networkManager.IsServer ? CreateSingleWorldHost($"HostSingleWorld-{s_WorldCounter}") : CreateClientWorld($"ClientWorld-{s_WorldCounter}");
135+
#endif
113136
}
114137
}
115138
}

‎com.unity.netcode.gameobjects/Runtime/Components/Helpers/UnifiedUpdateConnections.cs‎

Lines changed: 66 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
using System.Collections.Generic;
33
using Unity.Collections;
44
using Unity.Entities;
5-
using UnityEngine;
65

76
namespace Unity.Netcode.Components
87
{
@@ -26,37 +25,57 @@ public void SendMessage<T>(T message) where T : unmanaged, IRpcCommand
2625
}
2726
}
2827

28+
/// <summary>
29+
/// This is used to track the <see cref="NetworkManager"/>, as a singleton, when running a hybrid session.
30+
/// </summary>
31+
internal struct HybridNetworkManager : IComponentData
32+
{
33+
public UnityObjectRef<NetworkManager> NetworkManager;
34+
}
35+
36+
// Only exists in client and server worlds, and only updates in a world created for a hybrid session.
37+
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation | WorldSystemFilterFlags.ClientSimulation)]
2938
internal partial class UnifiedUpdateConnections : SystemBase
3039
{
3140
private List<NetcodeConnection> m_TempConnections = new List<NetcodeConnection>();
3241

3342
private Dictionary<int, NetcodeConnection> m_NewConnections = new Dictionary<int, NetcodeConnection>();
3443

44+
private EntityQuery m_HybridNetworkManagerQuery;
45+
46+
protected override void OnCreate()
47+
{
48+
m_HybridNetworkManagerQuery = GetEntityQuery(ComponentType.ReadOnly<HybridNetworkManager>());
49+
RequireForUpdate(m_HybridNetworkManagerQuery);
50+
}
51+
3552
protected override void OnUpdate()
3653
{
37-
var isServer = World.IsServer();
3854
var commandBuffer = new EntityCommandBuffer(Allocator.Temp);
39-
foreach (var networkManager in Object.FindObjectsByType<NetworkManager>())
55+
var networkManager = m_HybridNetworkManagerQuery.GetSingleton<HybridNetworkManager>().NetworkManager.Value;
56+
// A missing NetworkManager counts as shutting down: the world outlives it if ShutdownInternal throws before disposing the world.
57+
var isShuttingDown = !networkManager || networkManager.ShutdownInProgress || !networkManager.IsListening;
58+
foreach (var (networkId, connectionState, entity) in SystemAPI.Query<NetworkId, ConnectionState>().WithNone<NetworkStreamConnection>().WithEntityAccess())
4059
{
41-
foreach (var (networkId, connectionState, entity) in SystemAPI.Query<NetworkId, ConnectionState>().WithNone<NetworkStreamConnection>().WithEntityAccess())
60+
commandBuffer.RemoveComponent<ConnectionState>(entity);
61+
m_TempConnections.Add(new NetcodeConnection
4262
{
43-
commandBuffer.RemoveComponent<ConnectionState>(entity);
44-
m_TempConnections.Add(new NetcodeConnection
45-
{
46-
World = World,
47-
Entity = entity,
48-
NetworkId = networkId.Value
49-
});
50-
}
63+
World = World,
64+
Entity = entity,
65+
NetworkId = networkId.Value
66+
});
67+
}
5168

52-
foreach (var con in m_TempConnections)
53-
{
54-
NetworkManager.OnNetCodeDisconnect?.Invoke(con);
55-
}
69+
foreach (var con in m_TempConnections)
70+
{
71+
NetworkManager.OnNetCodeDisconnect?.Invoke(con);
72+
}
5673

57-
m_TempConnections.Clear();
74+
m_TempConnections.Clear();
5875

59-
// TODO: We should figure out how to associate the N4E NetworkId with the NGO ClientId
76+
// TODO: We should figure out how to associate the N4E NetworkId with the NGO ClientId
77+
if (!isShuttingDown)
78+
{
6079
foreach (var (networkId, entity) in SystemAPI.Query<NetworkId>().WithAll<NetworkStreamConnection>()
6180
.WithNone<NetworkStreamInGame>().WithEntityAccess())
6281
{
@@ -67,43 +86,44 @@ protected override void OnUpdate()
6786
m_NewConnections.Add(networkId.Value, newConnection);
6887
}
6988
}
89+
}
7090

71-
// If we have any pending connections
72-
if (m_NewConnections.Count > 0)
91+
// If we have any pending connections
92+
if (!isShuttingDown && m_NewConnections.Count > 0)
93+
{
94+
foreach (var entry in m_NewConnections)
7395
{
74-
foreach (var entry in m_NewConnections)
96+
// Server: always connect
97+
// Client: wait until we have synchronized before announcing we are ready to receive snapshots
98+
if (networkManager.IsServer || (!networkManager.IsServer && networkManager.IsConnectedClient))
7599
{
76-
// Server: always connect
77-
// Client: wait until we have synchronized before announcing we are ready to receive snapshots
78-
if (networkManager.IsServer || (!networkManager.IsServer && networkManager.IsConnectedClient))
79-
{
80-
// Set the connection in-game
81-
commandBuffer.AddComponent<NetworkStreamInGame>(entry.Value.Entity);
82-
commandBuffer.AddComponent(entry.Value.Entity, default(ConnectionState));
83-
NetworkManager.OnNetCodeConnect?.Invoke(entry.Value);
84-
m_TempConnections.Add(entry.Value);
85-
}
100+
// Set the connection in-game
101+
commandBuffer.AddComponent<NetworkStreamInGame>(entry.Value.Entity);
102+
commandBuffer.AddComponent(entry.Value.Entity, default(ConnectionState));
103+
NetworkManager.OnNetCodeConnect?.Invoke(entry.Value);
104+
m_TempConnections.Add(entry.Value);
86105
}
106+
}
87107

88-
// Remove any connections that have "gone in-game".
89-
foreach (var connection in m_TempConnections)
90-
{
91-
m_NewConnections.Remove(connection.NetworkId);
92-
}
108+
// Remove any connections that have "gone in-game".
109+
foreach (var connection in m_TempConnections)
110+
{
111+
m_NewConnections.Remove(connection.NetworkId);
93112
}
113+
}
94114

95-
m_TempConnections.Clear();
115+
m_TempConnections.Clear();
96116

97-
// If the local NetworkManager is shutting down or no longer connected, then
98-
// make sure we have disconnected all known connections.
99-
if (networkManager.ShutdownInProgress || !networkManager.IsListening)
117+
// If the local NetworkManager is shutting down or no longer connected, then
118+
// make sure we have disconnected all known connections.
119+
if (isShuttingDown)
120+
{
121+
// Only in-game connections have ConnectionState, and removing it here means each one is only reported once.
122+
foreach (var (networkId, entity) in SystemAPI.Query<NetworkId>().WithAll<ConnectionState>().WithEntityAccess())
100123
{
101-
foreach (var (networkId, entity) in SystemAPI.Query<NetworkId>().WithEntityAccess())
102-
{
103-
commandBuffer.RemoveComponent<ConnectionState>(entity);
104-
NetworkManager.OnNetCodeDisconnect?.Invoke(new NetcodeConnection
105-
{ World = World, Entity = entity, NetworkId = networkId.Value });
106-
}
124+
commandBuffer.RemoveComponent<ConnectionState>(entity);
125+
NetworkManager.OnNetCodeDisconnect?.Invoke(new NetcodeConnection
126+
{ World = World, Entity = entity, NetworkId = networkId.Value });
107127
}
108128
}
109129
commandBuffer.Playback(EntityManager);

‎com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1395,6 +1395,12 @@ internal void InitializeNetcodeWorld()
13951395
TypeManager.Initialize();
13961396
UnifiedBootstrap.CurrentNetworkManagerForInitialization = this;
13971397
new UnifiedBootstrap().Initialize("Default World");
1398+
1399+
if (NetcodeWorld != null && NetcodeWorld.IsCreated)
1400+
{
1401+
var entityManager = NetcodeWorld.EntityManager;
1402+
entityManager.AddComponentData(entityManager.CreateEntity(), new HybridNetworkManager { NetworkManager = this });
1403+
}
13981404
}
13991405

14001406
/// <summary>

‎com.unity.netcode.gameobjects/Runtime/Timing/NetworkTimeSystem.cs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,8 +260,13 @@ public bool Advance(double deltaTimeSec)
260260
return true;
261261
}
262262

263-
m_CurrentLocalTimeOffset += deltaTimeSec * (m_DesiredLocalTimeOffset > m_CurrentLocalTimeOffset ? AdjustmentRatio : -AdjustmentRatio);
264-
m_CurrentServerTimeOffset += deltaTimeSec * (m_DesiredServerTimeOffset > m_CurrentServerTimeOffset ? AdjustmentRatio : -AdjustmentRatio);
263+
// Move each offset toward its target by at most AdjustmentRatio of this frame's delta, stopping once it gets there.
264+
// Stepping the full amount every frame overshot the target, so a synced clock alternated between running fast and
265+
// slow, and anything driven by it juddered.
266+
// Math.Clamp throws on a negative step, so a negative delta or AdjustmentRatio applies no adjustment.
267+
var maximumStep = Math.Max(0.0, deltaTimeSec * AdjustmentRatio);
268+
m_CurrentLocalTimeOffset += Math.Clamp(m_DesiredLocalTimeOffset - m_CurrentLocalTimeOffset, -maximumStep, maximumStep);
269+
m_CurrentServerTimeOffset += Math.Clamp(m_DesiredServerTimeOffset - m_CurrentServerTimeOffset, -maximumStep, maximumStep);
265270

266271
return false;
267272
}

‎com.unity.netcode.gameobjects/Runtime/Transports/Unified/UnifiedNetcodeTransport.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,8 @@ public override void DisconnectLocalClient()
476476
public override ulong GetCurrentRtt(ulong clientId)
477477
{
478478
var (transportId, _) = m_NetworkManager.ConnectionManager.ClientIdToTransportId(clientId);
479-
return (ulong)m_Connections[(int)transportId].Connection.RTT;
479+
// Returns 0 when the connection was already removed (for example while shutting down), the same as UnityTransport.
480+
return m_Connections.TryGetValue((int)transportId, out ConnectionInfo connectionInfo) ? (ulong)connectionInfo.Connection.RTT : 0;
480481
}
481482

482483
public override void Initialize(NetworkManager networkManager = null)

‎com.unity.netcode.gameobjects/Tests/Editor/Timing/ClientNetworkTimeSystemTests.cs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,36 @@ public void RttCatchupSlowdownTest()
138138
Assert.True(Math.Abs(totalServerSpeedUpTime) < k_AcceptableRttOffset, $"Expected server speed up time to be less than {k_AcceptableRttOffset}ms but it was {expectedServerSpeedUpTime}!");
139139
}
140140

141+
/// <summary>
142+
/// Validates that once the clock has caught up with a time sync, its local and server time both advance by
143+
/// exactly the frame's delta time on every frame, instead of alternating between slightly fast and slightly slow.
144+
/// </summary>
145+
[Test]
146+
public void ConvergedClockAdvancesAtRealRate([Values(0.01d, 0.1d)] double adjustmentRatio)
147+
{
148+
const double deltaTime = 1.0d / 60.0d;
149+
double receivedServerTime = 2;
150+
var timeSystem = new NetworkTimeSystem(0.05d, 0.05d, 0.1d, adjustmentRatio);
151+
timeSystem.Reset(receivedServerTime, 0.1d);
152+
// A sync 10 ms ahead, below the hard reset threshold, makes the clock catch up gradually instead of resetting.
153+
receivedServerTime += 0.01d;
154+
timeSystem.Sync(receivedServerTime, 0.1d);
155+
156+
for (int i = 0; i < 600; i++)
157+
{
158+
timeSystem.Advance(deltaTime);
159+
}
160+
161+
for (int i = 0; i < 10; i++)
162+
{
163+
var localTime = timeSystem.LocalTime;
164+
var serverTime = timeSystem.ServerTime;
165+
timeSystem.Advance(deltaTime);
166+
Assert.AreEqual(deltaTime, timeSystem.LocalTime - localTime, 1e-9d, $"Local time advanced at the wrong rate on frame {i}.");
167+
Assert.AreEqual(deltaTime, timeSystem.ServerTime - serverTime, 1e-9d, $"Server time advanced at the wrong rate on frame {i}.");
168+
}
169+
}
170+
141171
/// <summary>
142172
/// Tests whether time resets when there is a huge spike in RTT and is able to stabilize again.
143173
/// </summary>

‎com.unity.netcode.gameobjects/Tests/Runtime/NetworkTransform/UnifiedNetworkTransformTest.cs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,24 @@ public IEnumerator BasicMovementTest()
100100
AssertOnTimeout($"A client failed to synchronize the changed position: {GetVector3Values(newPos)}!");
101101
VerboseDebug("Test Passed!");
102102
}
103+
104+
/// <summary>
105+
/// Validates that each <see cref="NetworkManager"/>'s world only updates that <see cref="NetworkManager"/>'s connections.
106+
/// </summary>
107+
[Test]
108+
public void EachWorldUpdatesConnectionsForItsOwnNetworkManager()
109+
{
110+
foreach (var networkManager in m_NetworkManagers)
111+
{
112+
var world = networkManager.NetcodeWorld;
113+
Assert.IsNotNull(world, $"[Client-{networkManager.LocalClientId}] Has no world!");
114+
Assert.IsNotNull(world.GetExistingSystemManaged<UnifiedUpdateConnections>(), $"[{world.Name}] Has no {nameof(UnifiedUpdateConnections)} system!");
115+
using var query = world.EntityManager.CreateEntityQuery(typeof(HybridNetworkManager));
116+
Assert.AreEqual(1, query.CalculateEntityCount(), $"[{world.Name}] Does not have exactly one {nameof(HybridNetworkManager)}!");
117+
var bound = query.GetSingleton<HybridNetworkManager>().NetworkManager.Value;
118+
Assert.AreSame(networkManager, bound, $"[{world.Name}] Is bound to another {nameof(NetworkManager)}!");
119+
}
120+
}
103121
}
104122
}
105123
#endif

0 commit comments

Comments
 (0)