diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index 7b75f45d30..88e1f873a7 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -10,6 +10,9 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Added +- Added additional section under Project Settings > Multiplayer > Netcode for GameObjects, shown when Netcode for Entities is installed and provides users a way to restore the recommended settings. (#4144) +- Added alignment of the Netcode for Entities tick rates with `NetworkConfig.TickRate` when a session with `GhostObject` prefabs is started, so ghost updates land on the same (relative) interval as the rest of Netcode for GameObjects. (#4144) + ### Changed ### Deprecated @@ -18,6 +21,8 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Fixed +- Issue where the hybrid mode `NetcodeConfig` validation messages were not interpolated and did not check that automatic bootstrapping was disabled. (#4144) + ### Security ### Obsolete diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs new file mode 100644 index 0000000000..21a3949d17 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs @@ -0,0 +1,81 @@ +#if UNIFIED_NETCODE +using Unity.Netcode.Logging; +#if !UNIFIED_NETCODE_7_0_0 +using NetcodeConfig = Unity.NetCode.NetCodeConfig; +#endif +using UnityEditor; + +namespace Unity.Netcode.GameObjects.Editor.Configuration +{ + /// + /// Writes the values NGO recommends for hybrid mode, once, the first time a + /// is available. + /// + /// + /// This does not create . This finds the one N4E created and modifies it. + /// Nothing tracks the project after that write. The defaults are inert in a project with no hybrid prefabs, and + /// re-aligns the tick rate at start-up in a project that has them, so there is no + /// reason to scan for ghost prefabs from the editor. + /// + internal static class HybridNetcodeConfigApplier + { + [InitializeOnLoadMethod] + private static void OnApplicationStart() + { + // N4E assigns NetcodeConfig.Global from its own [InitializeOnLoadMethod] and creates the asset when + // there is none. delayCall runs after those have completed, which is what makes Global reliable here + // without a lookup of our own. + EditorApplication.delayCall += OnDelayCall; + } + + private static void OnDelayCall() + { + EditorApplication.delayCall -= OnDelayCall; + ApplyDefaults(false); + } + + /// + /// Writes the NGO hybrid mode defaults into the project's . + /// + /// + /// Driven by the button in Project Settings: + /// - When true: re-applies the full tuned set even though this project has already had it applied once. + /// - When false: writes only if this project has never had them written. From that point forward, the user's + /// edits are not overwritten. + /// + internal static void ApplyDefaults(bool force) + { + if (EditorApplication.isPlayingOrWillChangePlaymode) + { + return; + } + + var settings = NetcodeForGameObjectsProjectSettings.instance; + if (!force && settings.HybridDefaultsVersion >= HybridNetcodeDefaults.Version) + { + return; + } + + // A project with no config yet leaves the marker unrecorded so that the next domain reload tries again. + // N4E creates one on any domain reload that finds none. + var config = NetcodeConfig.Global; + if (config == null) + { + return; + } + + if (HybridNetcodeDefaults.ApplyRecommended(config, HybridNetcodeDefaults.DefaultTickRate)) + { + EditorUtility.SetDirty(config); + AssetDatabase.SaveAssetIfDirty(config); + new ContextualLogger(config).Info(new Context(LogLevel.Developer, $"Applied the hybrid mode defaults to '{config.name}'. These are tuned for Netcode for GameObjects and can be changed freely; they will not be re-applied automatically. Use Project Settings > Multiplayer > Netcode for GameObjects to restore them.").AddTag("Unified")); + } + + // Recorded even when the config already matched and nothing was written. Leaving it unrecorded would make + // the next domain reload a first application again, which would revert the user's next edit. + settings.HybridDefaultsVersion = HybridNetcodeDefaults.Version; + settings.SaveSettings(); + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs.meta b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs.meta new file mode 100644 index 0000000000..5188468189 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6ee59cdde40fe6846a172aecaff9e8e3 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs index c916494ba0..350d942bac 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs @@ -37,6 +37,19 @@ private void OnEnable() [SerializeField] public bool GenerateDefaultNetworkPrefabs = true; +#if UNIFIED_NETCODE + /// + /// The version of the hybrid mode default values already written into this project's NetcodeConfig, or zero + /// when they have never been written. + /// + /// + /// A version rather than a flag so a later revision of those values re-applies exactly once. Recording it is + /// what keeps them a one-shot: a user who changes them is not overwritten on the next domain reload. + /// + [SerializeField] + public int HybridDefaultsVersion; +#endif + internal void SaveSettings() { Save(true); diff --git a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs index a8b521117c..a313fcd4b4 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeSettingsProvider.cs @@ -1,5 +1,8 @@ using System.Collections.Generic; using System.IO; +#if UNIFIED_NETCODE && !UNIFIED_NETCODE_7_0_0 +using NetcodeConfig = Unity.NetCode.NetCodeConfig; +#endif using UnityEditor; using UnityEngine; using Directory = UnityEngine.Windows.Directory; @@ -192,6 +195,10 @@ private static void OnGuiHandler(string obj) networkPrefabsPath, GUILayout.Width(s_MaxLabelWidth + 270)); GUILayout.EndVertical(); + +#if UNIFIED_NETCODE + DrawHybridSettings(settings); +#endif } EditorGUILayout.EndFoldoutHeaderGroup(); GUILayout.EndVertical(); @@ -205,6 +212,41 @@ private static void OnGuiHandler(string obj) settings.SaveSettings(); } } + +#if UNIFIED_NETCODE + /// + /// Displays the NetcodeConfig the NGO hybrid mode defaults were written into, and offers a way to restore + /// those defaults for anyone who has since changed them. + /// + /// The project settings holding the applied-defaults marker. + private static void DrawHybridSettings(NetcodeForGameObjectsProjectSettings settings) + { + GUILayout.BeginVertical("Box"); + GUILayout.Label("Hybrid (Netcode for Entities)", EditorStyles.boldLabel); + + var config = NetcodeConfig.Global; + if (config == null) + { + EditorGUILayout.HelpBox("No NetcodeConfig has been assigned yet. Open Project Settings > Multiplayer, which creates one, then reload the project.", MessageType.Warning); + GUILayout.EndVertical(); + return; + } + + EditorGUILayout.ObjectField(new GUIContent("Applied to", "The NetcodeConfig that Netcode for GameObjects wrote its hybrid mode defaults into."), config, typeof(NetcodeConfig), false); + + if (settings.HybridDefaultsVersion < HybridNetcodeDefaults.Version) + { + EditorGUILayout.HelpBox("The Netcode for GameObjects hybrid defaults have not been applied to this config yet.", MessageType.Info); + } + + if (GUILayout.Button(new GUIContent("Apply Recommended Hybrid Defaults", "Restores the snapshot, interpolation and transport values Netcode for GameObjects recommends for hybrid mode. Applied automatically once; use this to get back to them after changing them."))) + { + HybridNetcodeConfigApplier.ApplyDefaults(true); + } + + GUILayout.EndVertical(); + } +#endif } internal class NetcodeSettingsLabel : NetcodeGUISettings diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs new file mode 100644 index 0000000000..ec9b1adb02 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs @@ -0,0 +1,164 @@ +#if UNIFIED_NETCODE +#if !UNIFIED_NETCODE_7_0_0 +using Unity.NetCode; +using NetcodeConfig = Unity.NetCode.NetCodeConfig; +#endif + +namespace Unity.Netcode +{ + /// + /// The values NGO needs when running in hybrid mode (i.e. Netcode for Entities is + /// installed and a registered network prefab carries a ). + /// + /// + /// This lives in the runtime assembly rather than the editor one because + /// is internal to Netcode for Entities, and Unity.Netcode.Runtime is the only NGO assembly it grants InternalsVisibleTo to. + /// Nothing here touches the AssetDatabase; the editor-side applier drives all of it. + /// + internal static class HybridNetcodeDefaults + { + /// + /// Bump whenever changes so that an upgrading project re-applies exactly once. + /// Persisted as NetcodeForGameObjectsProjectSettings.HybridDefaultsVersion. + /// + internal const int Version = 2; + + // Mirrors NetworkConfig.TickRate's default. The editor writes the defaults before any NetworkManager is + // necessarily loaded, so it has nothing to read the real rate from. NetworkManager re-aligns the config when a + // session carrying ghost prefabs starts, which is what makes writing a fixed value here safe. + internal const uint DefaultTickRate = 30; + + // Tuned against 2000 GenericPhysicsBallNGO instances in the ngo-examples project. A hybrid ghost costs ~4.87 + // bytes per snapshot, so 4096 carries ~840 of them at the full tick rate. This is a cap and not a cost: + // below that count it puts no more on the wire than the N4E default would. Kept small because a snapshot is + // sent unreliably: losing any one of its fragments loses the whole snapshot. + internal const int SnapshotPacketSize = 4096; + + // A ceiling on despawn bytes, not a reservation, so unused headroom is free. 0.2 is also N4E's clamp minimum. + internal const float PercentReservedForDespawn = 0.2f; + + // Expressed in milliseconds rather than net ticks deliberately. N4E rounds this up to whole network ticks, so + // it holds >= 50ms of interpolation buffer at any tick rate. The net-tick form does not: 2 net ticks is 66.7ms + // at 30Hz but only 33.3ms at 60Hz, and 33.3ms is the buffer the stress test stuttered at. + internal const uint InterpolationTimeMS = 50; + + internal const float InterpolationDelayMaxDeltaTicksFraction = 0.15f; + internal const float InterpolationTimeScaleMin = 0.9f; + internal const float InterpolationTimeScaleMax = 1.33f; + + // A full snapshot fragments into ~3 datagrams and each fragment consumes a queue slot. + internal const int ClientQueueCapacity = 128; + + /// + /// Applies the two settings hybrid mode cannot run without. + /// + /// The config to correct. + /// True if anything changed. + internal static bool ApplyRequired(NetcodeConfig config) + { + var changed = false; + + // NetworkManager gates the world spin-up, so N4E must not bootstrap worlds on its own. + if (config.EnableClientServerBootstrap != NetcodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap) + { + config.EnableClientServerBootstrap = NetcodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap; + changed = true; + } + + if (config.HostWorldModeSelection != NetcodeConfig.HostWorldMode.SingleWorld) + { + config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.SingleWorld; + changed = true; + } + + return changed; + } + + /// + /// Drives N4E's tick rates from so that ghost transform updates land on + /// the same interval NGO uses for everything else. + /// + /// The config to correct. + /// The owning 's configured tick rate. + /// True if anything changed. + internal static bool ApplyTickRate(NetcodeConfig config, uint tickRate) + { + var rate = (int)tickRate; + if (config.ClientServerTickRate.SimulationTickRate == rate && config.ClientServerTickRate.NetworkTickRate == rate) + { + return false; + } + + // Both are written: leaving NetworkTickRate at 0 would track SimulationTickRate anyway, but writing it + // keeps the two visibly locked in the inspector, which is the invariant InterpolationTimeMS relies on. + config.ClientServerTickRate.SimulationTickRate = rate; + config.ClientServerTickRate.NetworkTickRate = rate; + return true; + } + + /// + /// Applies the full NGO-recommended set: , , and the + /// values tuned against the stress test. + /// + /// The config to correct. + /// The owning 's configured tick rate. + /// True if anything changed. + internal static bool ApplyRecommended(NetcodeConfig config, uint tickRate) + { + var changed = ApplyRequired(config); + changed |= ApplyTickRate(config, tickRate); + + changed |= Set(ref config.GhostSendSystemData.DefaultSnapshotPacketSize, SnapshotPacketSize); + changed |= Set(ref config.GhostSendSystemData.PercentReservedForDespawnMessages, PercentReservedForDespawn); + + // The net-tick form has to be cleared or it wins over the millisecond form. + changed |= Set(ref config.ClientTickRate.InterpolationTimeNetTicks, 0u); + changed |= Set(ref config.ClientTickRate.InterpolationTimeMS, InterpolationTimeMS); + changed |= Set(ref config.ClientTickRate.InterpolationDelayMaxDeltaTicksFraction, InterpolationDelayMaxDeltaTicksFraction); + changed |= Set(ref config.ClientTickRate.InterpolationTimeScaleMin, InterpolationTimeScaleMin); + changed |= Set(ref config.ClientTickRate.InterpolationTimeScaleMax, InterpolationTimeScaleMax); + + changed |= Set(ref config.ClientSendQueueCapacity, ClientQueueCapacity); + changed |= Set(ref config.ClientReceiveQueueCapacity, ClientQueueCapacity); + + return changed; + } + + /// + /// Reports the first required setting that is still wrong, for the runtime start-up check. + /// + /// The config to inspect. + /// Populated with a user-facing description of what is wrong. + /// True when cannot support hybrid mode as-is. + internal static bool IsMissingRequired(NetcodeConfig config, out string reason) + { + if (config.HostWorldModeSelection != NetcodeConfig.HostWorldMode.SingleWorld) + { + reason = $"{nameof(NetcodeConfig.HostWorldModeSelection)} must be {nameof(NetcodeConfig.HostWorldMode.SingleWorld)} but is {config.HostWorldModeSelection}"; + return true; + } + + if (config.EnableClientServerBootstrap != NetcodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap) + { + reason = $"{nameof(NetcodeConfig.EnableClientServerBootstrap)} must be {nameof(NetcodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap)} because {nameof(NetworkManager)} owns world creation in hybrid mode"; + return true; + } + + reason = null; + return false; + } + + private static bool Set(ref T target, T value) + where T : System.IEquatable + { + if (target.Equals(value)) + { + return false; + } + + target = value; + return true; + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs.meta b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs.meta new file mode 100644 index 0000000000..243ea091b2 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6c63255817e13664ea56764bbb3e76c7 \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs index 185d682f66..ec9a5d02a5 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -1407,13 +1407,32 @@ private bool UnifiedIsConfiguredCorrectly() Log.Error(new Context(LogLevel.Error, $"You must create a {nameof(NetcodeConfig)} and set it to a single world in order to run in hybrid mode!").AddTag("Unified")); return false; } - if (NetcodeConfig.Global.HostWorldModeSelection != NetcodeConfig.HostWorldMode.SingleWorld) + if (HybridNetcodeDefaults.IsMissingRequired(NetcodeConfig.Global, out var reason)) { - Log.Error(new Context(LogLevel.Error, $"You must configure {nameof(NetcodeConfig)} to only use a single world in order to run in hybrid mode!").AddTag("Unified")); + Log.Error(new Context(LogLevel.Error, $"The {nameof(NetcodeConfig)} cannot be used in hybrid mode: {reason}.").AddTag("Unified")); return false; } return true; } + + /// + /// Drives the tick rates from so that ghost + /// updates land on the same interval as the rest of Netcode for GameObjects. + /// + /// + /// The editor writes when it applies the hybrid defaults, + /// because no is necessarily loaded at that point. This is where the rate a + /// project actually configured gets picked up. + /// + private void UnifiedAlignTickRate() + { + if (!HybridNetcodeDefaults.ApplyTickRate(NetcodeConfig.Global, NetworkConfig.TickRate)) + { + return; + } + + Log.Info(new Context(LogLevel.Developer, $"The {nameof(NetcodeConfig)} tick rates have been set to {nameof(NetworkConfig)}.{nameof(NetworkConfig.TickRate)} ({NetworkConfig.TickRate}).").AddTag("Unified")); + } #endif /// @@ -1457,6 +1476,7 @@ public bool StartServer() ShutdownInternal(); return false; } + UnifiedAlignTickRate(); if (LogLevel <= LogLevel.Developer) { Log.Info(new Context(LogLevel.Developer, "Creating world: Default world")); @@ -1536,6 +1556,7 @@ public bool StartClient() ShutdownInternal(); return false; } + UnifiedAlignTickRate(); Log.Info(new Context(LogLevel.Developer, "Creating world: Default world")); InitializeNetcodeWorld(); } @@ -1610,6 +1631,7 @@ public bool StartHost() ShutdownInternal(); return false; } + UnifiedAlignTickRate(); Log.Info(new Context(LogLevel.Developer, "Creating world: Default world")); InitializeNetcodeWorld(); } diff --git a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs new file mode 100644 index 0000000000..c4be6457ff --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs @@ -0,0 +1,212 @@ +#if UNIFIED_NETCODE +using NUnit.Framework; +#if !UNIFIED_NETCODE_7_0_0 +using Unity.NetCode; +using NetcodeConfig = Unity.NetCode.NetCodeConfig; +#endif +using Unity.Netcode.GameObjects.Editor.Configuration; +using UnityEditor; +using UnityEngine; + +namespace Unity.Netcode.GameObjects.EditorTests +{ + /// + /// Validates the values NGO applies in hybrid mode. + /// + internal class HybridNetcodeDefaultsTests + { + // Stands in for a value the user chose. Far enough from SnapshotPacketSize that a partial apply cannot + // look like a pass. + private const int k_UserPacketSize = 9000; + + private NetcodeConfig m_Config; + + [SetUp] + public void SetUp() + { + m_Config = ScriptableObject.CreateInstance(); + m_Config.Reset(); + } + + [TearDown] + public void TearDown() + { + Object.DestroyImmediate(m_Config); + } + + [Test] + public void ApplyRecommendedReportsNoChangeWhenConfigAlreadyMatches() + { + Assert.IsTrue(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "The first apply should report a change."); + Assert.IsFalse(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "Applying an already matching config should report no change."); + } + + [Test] + public void ApplyRequiredAdjustsBothSettings() + { + m_Config.EnableClientServerBootstrap = NetcodeConfig.AutomaticBootstrapSetting.EnableAutomaticBootstrap; + m_Config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.BinaryWorlds; + + Assert.IsTrue(HybridNetcodeDefaults.ApplyRequired(m_Config), "The first apply should report a change."); + Assert.AreEqual(NetcodeConfig.AutomaticBootstrapSetting.DisableAutomaticBootstrap, m_Config.EnableClientServerBootstrap, "Automatic bootstrapping should be disabled."); + Assert.AreEqual(NetcodeConfig.HostWorldMode.SingleWorld, m_Config.HostWorldModeSelection, "Hybrid mode should use a single world."); + + Assert.IsFalse(HybridNetcodeDefaults.ApplyRequired(m_Config), "Applying an already correct config should report no change."); + } + + [Test] + public void IsMissingRequiredDetectsEachViolation() + { + HybridNetcodeDefaults.ApplyRequired(m_Config); + Assert.IsFalse(HybridNetcodeDefaults.IsMissingRequired(m_Config, out _), "An adjusted config should be valid for hybrid mode."); + + m_Config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.BinaryWorlds; + Assert.IsTrue(HybridNetcodeDefaults.IsMissingRequired(m_Config, out var worldReason), "Binary worlds should be reported as invalid."); + Assert.That(worldReason, Does.Contain(nameof(NetcodeConfig.HostWorldModeSelection)), "The reason should name the setting that is wrong."); + + m_Config.HostWorldModeSelection = NetcodeConfig.HostWorldMode.SingleWorld; + m_Config.EnableClientServerBootstrap = NetcodeConfig.AutomaticBootstrapSetting.EnableAutomaticBootstrap; + Assert.IsTrue(HybridNetcodeDefaults.IsMissingRequired(m_Config, out var bootstrapReason), "Automatic bootstrapping should be reported as invalid."); + Assert.That(bootstrapReason, Does.Contain(nameof(NetcodeConfig.EnableClientServerBootstrap)), "The reason should name the setting that is wrong."); + } + + [TestCase(30u)] + [TestCase(60u)] + public void ApplyTickRateLocksSimulationAndNetworkRates(uint tickRate) + { + Assert.IsTrue(HybridNetcodeDefaults.ApplyTickRate(m_Config, tickRate), "The first apply should report a change."); + Assert.AreEqual((int)tickRate, m_Config.ClientServerTickRate.SimulationTickRate, "SimulationTickRate should be the requested rate."); + Assert.AreEqual((int)tickRate, m_Config.ClientServerTickRate.NetworkTickRate, "NetworkTickRate should track SimulationTickRate."); + + Assert.IsFalse(HybridNetcodeDefaults.ApplyTickRate(m_Config, tickRate), "Re-applying the same rate should report no change."); + } + + /// + /// The editor writes .
+ /// adjusts it at start-up for a project running at any other rate.
+ /// That pass leaves the tuned values alone.
+ ///
+ [Test] + public void TickRateOnlyPassAdjustsTheRateAndLeavesTheTunedValuesAlone() + { + const uint managerTickRate = 60; + + HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate); + + Assert.IsTrue(HybridNetcodeDefaults.ApplyTickRate(m_Config, managerTickRate), "The tick rate pass should report a change."); + Assert.AreEqual((int)managerTickRate, m_Config.ClientServerTickRate.SimulationTickRate, "SimulationTickRate should follow the NetworkManager."); + Assert.AreEqual((int)managerTickRate, m_Config.ClientServerTickRate.NetworkTickRate, "NetworkTickRate should follow the NetworkManager."); + Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, m_Config.GhostSendSystemData.DefaultSnapshotPacketSize, "A tick rate pass should leave DefaultSnapshotPacketSize alone."); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeMS, m_Config.ClientTickRate.InterpolationTimeMS, "A tick rate pass should leave InterpolationTimeMS alone."); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMax, m_Config.ClientTickRate.InterpolationTimeScaleMax, "A tick rate pass should leave InterpolationTimeScaleMax alone."); + } + + [Test] + public void ApplyRecommendedProducesTheTunedValues() + { + Assert.IsTrue(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "The first apply should report a change."); + + Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, m_Config.GhostSendSystemData.DefaultSnapshotPacketSize, "DefaultSnapshotPacketSize should be the tuned value."); + Assert.AreEqual(HybridNetcodeDefaults.PercentReservedForDespawn, m_Config.GhostSendSystemData.PercentReservedForDespawnMessages, "PercentReservedForDespawnMessages should be the tuned value."); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeMS, m_Config.ClientTickRate.InterpolationTimeMS, "InterpolationTimeMS should be the tuned value."); + Assert.AreEqual(0u, m_Config.ClientTickRate.InterpolationTimeNetTicks, "The net tick form wins over the millisecond form, so it is cleared."); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMin, m_Config.ClientTickRate.InterpolationTimeScaleMin, "InterpolationTimeScaleMin should be the tuned value."); + Assert.AreEqual(HybridNetcodeDefaults.InterpolationTimeScaleMax, m_Config.ClientTickRate.InterpolationTimeScaleMax, "InterpolationTimeScaleMax should be the tuned value."); + Assert.AreEqual(HybridNetcodeDefaults.ClientQueueCapacity, m_Config.ClientSendQueueCapacity, "ClientSendQueueCapacity should be the tuned value."); + Assert.AreEqual(HybridNetcodeDefaults.ClientQueueCapacity, m_Config.ClientReceiveQueueCapacity, "ClientReceiveQueueCapacity should be the tuned value."); + + Assert.IsFalse(HybridNetcodeDefaults.ApplyRecommended(m_Config, HybridNetcodeDefaults.DefaultTickRate), "Re-applying an unchanged config should report no change."); + } + + /// + /// Why the millisecond form is used rather than . + /// + /// + /// N4E rounds the millisecond value up to whole network ticks.
+ /// It holds at least the configured wall clock buffer at any tick rate.
+ ///
+ /// The tick rate to resolve the buffer against. + [TestCase(30u)] + [TestCase(60u)] + public void InterpolationBufferHoldsAtLeastFiftyMillisecondsAtAnyTickRate(uint tickRate) + { + HybridNetcodeDefaults.ApplyRecommended(m_Config, tickRate); + + var bufferMs = m_Config.ClientTickRate.CalculateInterpolationBufferTimeInMs(in m_Config.ClientServerTickRate); + Assert.GreaterOrEqual(bufferMs, HybridNetcodeDefaults.InterpolationTimeMS, "The interpolation buffer should hold the configured wall clock time."); + } + + [Test] + public void NetTickFormWouldRegressTheBufferAtHigherTickRates() + { + // If this stops being true, the millisecond form and its extra rounding are no longer buying anything. + HybridNetcodeDefaults.ApplyTickRate(m_Config, 60); + m_Config.ClientTickRate = new ClientTickRate + { + InterpolationTimeNetTicks = 2, + InterpolationTimeMS = 0, + }; + + var bufferMs = m_Config.ClientTickRate.CalculateInterpolationBufferTimeInMs(in m_Config.ClientServerTickRate); + Assert.Less(bufferMs, HybridNetcodeDefaults.InterpolationTimeMS, "The net tick form should fall short of the millisecond form at 60Hz."); + } + + /// + /// The editor writes because no + /// is loaded to read the rate from. + /// + [Test] + public void DefaultTickRateMatchesTheNetworkConfigDefault() + { + Assert.AreEqual(new NetworkConfig().TickRate, HybridNetcodeDefaults.DefaultTickRate, "DefaultTickRate should track the NetworkConfig.TickRate default."); + } + + /// + /// Once the marker is recorded nothing writes the config again.
+ /// Only the Project Settings button overrides it.
+ ///
+ [Test] + public void ApplyDefaultsIsAOneShotUnlessItIsForced() + { + var config = NetcodeConfig.Global; + Assert.IsNotNull(config, "This project should have a NetcodeConfig to adjust."); + + var settings = NetcodeForGameObjectsProjectSettings.instance; + var restoreVersion = settings.HybridDefaultsVersion; + var restoreConfig = EditorJsonUtility.ToJson(config); + try + { + settings.HybridDefaultsVersion = HybridNetcodeDefaults.Version; + config.GhostSendSystemData.DefaultSnapshotPacketSize = k_UserPacketSize; + + HybridNetcodeConfigApplier.ApplyDefaults(false); + Assert.AreEqual(k_UserPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "A recorded marker should stop the defaults from being written a second time."); + + HybridNetcodeConfigApplier.ApplyDefaults(true); + Assert.AreEqual(HybridNetcodeDefaults.SnapshotPacketSize, config.GhostSendSystemData.DefaultSnapshotPacketSize, "The Project Settings button should re-apply regardless of the marker."); + } + finally + { + Restore(config, restoreConfig); + settings.HybridDefaultsVersion = restoreVersion; + settings.SaveSettings(); + } + } + + /// + /// Puts the project's own back the way the test found it. + /// + /// + /// Serialized rather than field by field because the applier writes across three nested structures. + /// + /// The project config the test mutated. + /// Its state before the test ran. + private static void Restore(NetcodeConfig config, string serializedConfig) + { + EditorJsonUtility.FromJsonOverwrite(serializedConfig, config); + EditorUtility.SetDirty(config); + AssetDatabase.SaveAssetIfDirty(config); + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs.meta new file mode 100644 index 0000000000..d3d6b0bac1 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e5729f2ab235729478cc8552f87478fc \ No newline at end of file diff --git a/com.unity.netcode.gameobjects/Tests/Editor/Unity.Netcode.Editor.Tests.asmdef b/com.unity.netcode.gameobjects/Tests/Editor/Unity.Netcode.Editor.Tests.asmdef index c56c041e1e..4f5b07056a 100644 --- a/com.unity.netcode.gameobjects/Tests/Editor/Unity.Netcode.Editor.Tests.asmdef +++ b/com.unity.netcode.gameobjects/Tests/Editor/Unity.Netcode.Editor.Tests.asmdef @@ -35,6 +35,11 @@ "expression": "", "define": "MULTIPLAYER_TOOLS" }, + { + "name": "com.unity.netcode", + "expression": "7.0.0", + "define": "UNIFIED_NETCODE_7_0_0" + }, { "name": "Unity", "expression": "6000.1.0a1", diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Unified.meta new file mode 100644 index 0000000000..ebb2d11c0b --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4368bd44e3db2794bb788f8102cc171a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs new file mode 100644 index 0000000000..a2cd98ae4c --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs @@ -0,0 +1,273 @@ +#if UNIFIED_NETCODE +using System; +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using Unity.Entities; +#if !UNIFIED_NETCODE_7_0_0 +using Unity.NetCode; +#endif +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Measurement harness (not a pass/fail behaviour test) for how much of an N4E snapshot hybrid prefab transform + /// synchronization consumes, swept across and ghost + /// count. This is where HybridNetcodeDefaults.SnapshotPacketSize comes from; re-run it when that value is + /// reconsidered or when N4E changes its snapshot encoding. + /// + /// + /// Spawns N hybrid ghosts, keeps every one dirty on every tick, and reads the client-side + /// singleton over a fixed sample window. Each case emits one PKTSZ| line: + /// packet size, requested ghosts, ghosts the client spawned, samples, mean snapshot bits, p95 bits, max bits, + /// mean ghosts per snapshot, p95 ghosts, bytes per ghost, fraction of snapshots that hit the size cap, fraction + /// that could not carry every ghost, effective per ghost update rate, tick rate. Bytes per ghost and the + /// effective update rate are the two that decide the default: raising the cap only helps while the latter is + /// still below the tick rate. + /// + [TestFixture(HostOrServer.UnifiedHost)] + [Explicit("Measurement harness, not a regression test. The 24 auto-expanded cases take ~162s, so it only runs when selected by name: -testFilter \".*UnifiedSnapshotPacketSizeMeasurement.*\"")] + internal class UnifiedSnapshotPacketSizeMeasurement : NetcodeIntegrationTest + { + protected override int NumberOfClients => 1; + + // Delta-compression baselines need several snapshots to settle; the first ones are much larger. + private const int k_WarmupSnapshots = 30; + private const int k_SampleSnapshots = 100; + private const int k_SpawnsPerFrame = 100; + private const float k_SpawnTimeout = 240.0f; + private const float k_SampleTimeout = 240.0f; + + private GameObject m_Prefab; + private Transform[] m_Instances; + private GhostObject[] m_Ghosts; + private float[] m_Phases; + private int m_Frame; + + public UnifiedSnapshotPacketSizeMeasurement(HostOrServer hostOrServer) : base(hostOrServer) + { + } + + protected override bool OnSetVerboseDebug() + { + return false; + } + + protected override IEnumerator OnSetup() + { + m_Instances = null; + m_Ghosts = null; + m_Phases = null; + m_Frame = 0; + // UnifiedHost sets m_AllPrefabsAsHybrid, so this yields a NetworkObject + GhostObject + NetworkObjectBridge prefab. + m_Prefab = CreateNetworkObjectPrefab("PktSizeGhost"); + return base.OnSetup(); + } + + /// + /// Every instance orbits on its own phase so that no chunk is ever unchanged. N4E static-optimizes + /// unchanged chunks, so leaving these still would measure nothing. + /// + private void MoveAll() + { + if (m_Instances == null) + { + return; + } + m_Frame++; + var time = m_Frame * 0.01f; + for (int i = 0; i < m_Instances.Length; i++) + { + var instance = m_Instances[i]; + if (instance == null) + { + continue; + } + var angle = time + m_Phases[i]; + var radius = 20.0f + (i % 17); + var position = new Vector3(radius * Mathf.Cos(angle), (i % 32) * 0.5f, radius * Mathf.Sin(angle)); + var rotation = Quaternion.Euler(0.0f, angle * Mathf.Rad2Deg, 0.0f); + instance.SetLocalPositionAndRotation(position, rotation); + // On a single-world host the GameObject transform is also written by the presentation-time smoothing + // system, so drive the authoritative LocalTransform directly as well. + var ghost = m_Ghosts[i]; + if (ghost != null) + { + ghost.Position = position; + ghost.Rotation = rotation; + } + } + } + + private static Entity CreateMetricsSingleton(EntityManager entityManager) + { + var typeList = new NativeArray(8, Allocator.Temp); + typeList[0] = ComponentType.ReadWrite(); + typeList[1] = ComponentType.ReadWrite(); + typeList[2] = ComponentType.ReadWrite(); + typeList[3] = ComponentType.ReadWrite(); + typeList[4] = ComponentType.ReadWrite(); + typeList[5] = ComponentType.ReadWrite(); + typeList[6] = ComponentType.ReadWrite(); + typeList[7] = ComponentType.ReadWrite(); + var singleton = entityManager.CreateEntity(entityManager.CreateArchetype(typeList)); + typeList.Dispose(); + entityManager.SetName(singleton, (FixedString64Bytes)"MetricsMonitor"); + return singleton; + } + + private static double Mean(List values) + { + double total = 0; + for (int i = 0; i < values.Count; i++) + { + total += values[i]; + } + return values.Count == 0 ? 0 : total / values.Count; + } + + private static uint Percentile(List values, double fraction) + { + if (values.Count == 0) + { + return 0; + } + var sorted = new List(values); + sorted.Sort(); + var index = (int)Math.Round(fraction * (sorted.Count - 1)); + return sorted[Mathf.Clamp(index, 0, sorted.Count - 1)]; + } + + [UnityTest] + public IEnumerator MeasureSnapshotSize( + [Values(0, 4000, 8000, 15000)] int packetSize, + [Values(250, 500, 1000, 2000, 2500, 3000)] int objectCount) + { + var hostWorld = m_ServerNetworkManager.NetcodeWorld; + var clientWorld = m_ClientNetworkManagers[0].NetcodeWorld; + Assert.IsNotNull(hostWorld, "Host has no NetcodeWorld!"); + Assert.IsNotNull(clientWorld, "Client has no NetcodeWorld!"); + + var sendDataQuery = hostWorld.EntityManager.CreateEntityQuery(ComponentType.ReadWrite()); + var sendData = sendDataQuery.GetSingleton(); + sendData.DefaultSnapshotPacketSize = packetSize; + sendDataQuery.SetSingleton(sendData); + + var tickRate = 30; + var tickRateQuery = hostWorld.EntityManager.CreateEntityQuery(ComponentType.ReadOnly()); + if (tickRateQuery.CalculateEntityCount() == 1) + { + var configured = tickRateQuery.GetSingleton(); + tickRate = configured.NetworkTickRate > 0 ? configured.NetworkTickRate : Mathf.Max(1, configured.SimulationTickRate); + } + + CreateMetricsSingleton(clientWorld.EntityManager); + var snapshotMetricsQuery = clientWorld.EntityManager.CreateEntityQuery(ComponentType.ReadOnly()); + + var clientSpawnManager = m_ClientNetworkManagers[0].SpawnManager; + var preSpawnCount = clientSpawnManager.SpawnedObjects.Count; + + m_Instances = new Transform[objectCount]; + m_Ghosts = new GhostObject[objectCount]; + m_Phases = new float[objectCount]; + var random = new System.Random(12345); + for (int i = 0; i < objectCount; i++) + { + m_Phases[i] = (float)(random.NextDouble() * Mathf.PI * 2.0f); + var spawned = SpawnObject(m_Prefab, m_ServerNetworkManager); + m_Instances[i] = spawned.transform; + m_Ghosts[i] = spawned.GetComponent(); + if ((i + 1) % k_SpawnsPerFrame == 0) + { + MoveAll(); + yield return null; + } + } + + var deadline = Time.realtimeSinceStartup + k_SpawnTimeout; + while ((clientSpawnManager.SpawnedObjects.Count - preSpawnCount) < objectCount && Time.realtimeSinceStartup < deadline) + { + MoveAll(); + yield return null; + } + var spawnedOnClient = clientSpawnManager.SpawnedObjects.Count - preSpawnCount; + + var sizes = new List(k_SampleSnapshots); + var counts = new List(k_SampleSnapshots); + uint lastSnapshotTick = 0; + var snapshotsSeen = 0; + deadline = Time.realtimeSinceStartup + k_SampleTimeout; + while (snapshotsSeen < (k_WarmupSnapshots + k_SampleSnapshots) && Time.realtimeSinceStartup < deadline) + { + MoveAll(); + yield return null; + + if (snapshotMetricsQuery.CalculateEntityCount() != 1) + { + continue; + } + var metrics = snapshotMetricsQuery.GetSingleton(); + if (metrics.SnapshotTick == 0 || metrics.SnapshotTick == lastSnapshotTick) + { + continue; + } + lastSnapshotTick = metrics.SnapshotTick; + snapshotsSeen++; + if (snapshotsSeen > k_WarmupSnapshots) + { + sizes.Add(metrics.TotalSizeInBits); + counts.Add(metrics.TotalGhostCount); + } + } + + // Sanity check that the ghosts really did move (a static ghost measures nothing useful). + var hostNetworkObject = m_Instances[0].GetComponent(); + if (clientSpawnManager.SpawnedObjects.TryGetValue(hostNetworkObject.NetworkObjectId, out var clientClone)) + { + Debug.Log($"PKTDIAG|hostGO={m_Instances[0].position}|hostGhost={m_Ghosts[0].Position}|client={clientClone.transform.position}|frames={m_Frame}"); + } + + // The unfragmented default is driver derived; approximate with the configured MaxMessageSize for the cap check. + var effectiveCapBytes = packetSize > 0 ? packetSize : 1400; + var capHits = 0; + var incomplete = 0; + for (int i = 0; i < sizes.Count; i++) + { + if ((sizes[i] / 8.0) >= (effectiveCapBytes * 0.95)) + { + capHits++; + } + if (counts[i] < objectCount) + { + incomplete++; + } + } + + var meanBits = Mean(sizes); + var meanGhosts = Mean(counts); + var capHitFraction = sizes.Count == 0 ? 0.0 : (double)capHits / sizes.Count; + var incompleteFraction = sizes.Count == 0 ? 0.0 : (double)incomplete / sizes.Count; + var bytesPerGhost = meanGhosts <= 0 ? 0.0 : (meanBits / 8.0) / meanGhosts; + var effectiveHz = objectCount <= 0 ? 0.0 : (meanGhosts / objectCount) * tickRate; + + Debug.Log($"PKTSZ|{packetSize}|{objectCount}|{spawnedOnClient}|{sizes.Count}|{meanBits:F1}|{Percentile(sizes, 0.95)}|" + + $"{Percentile(sizes, 1.0)}|{meanGhosts:F1}|{Percentile(counts, 0.95)}|{bytesPerGhost:F3}|{capHitFraction:F3}|{incompleteFraction:F3}|{effectiveHz:F2}|{tickRate}"); + + Assert.AreEqual(objectCount, spawnedOnClient, $"Client only spawned {spawnedOnClient} of {objectCount} hybrid ghosts!"); + Assert.AreEqual(k_SampleSnapshots, sizes.Count, $"Only collected {sizes.Count} of {k_SampleSnapshots} snapshot samples!"); + } + + protected override IEnumerator OnTearDown() + { + m_Instances = null; + m_Ghosts = null; + m_Phases = null; + return base.OnTearDown(); + } + } +} +#endif diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs.meta new file mode 100644 index 0000000000..499c4104e4 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 41f3e6a37f69bf640974318dffc22496 \ No newline at end of file diff --git a/testproject/Assets/NetCodeConfig.asset b/testproject/Assets/NetCodeConfig.asset index 8988bbd48c..4f87186053 100644 --- a/testproject/Assets/NetCodeConfig.asset +++ b/testproject/Assets/NetCodeConfig.asset @@ -63,7 +63,7 @@ MonoBehaviour: CleanupConnectionStatePerTick: 1 m_FirstSendImportanceMultiplier: 1 m_IrrelevantImportanceDownScale: 1 - m_TempStreamSize: 4192 + m_TempStreamSize: 8192 m_UseCustomSerializer: 0 ConnectTimeoutMS: 1000 MaxConnectAttempts: 60