diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index ceaff931bb..072ebaae76 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -10,6 +10,10 @@ 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 restor back to 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 - All editor assembly definitions are renamed with `Unity.Netcode.GameObjects.x` variants @@ -32,6 +36,7 @@ Additional documentation and release notes are available at [Multiplayer Documen - Issue with not being able to spawn initially disabled in-scene placed objects. (#4093) - Issue with pre-instantiated network prefab instances being marked as in-scene placed. Now pre-instantiated network prefabs are dynamically spawned. (#4093) - Issue where a user could spawn runtime created `NetworkObject` that has a GlobalObjectIdHash of zero. These are not valid instances and will no longer be allowed to spawn. (#4093) +- Issue where the hybrid mode `NetCodeConfig` validation messages were not interpolated and did not check that automatic bootstrapping was disabled. ### Security 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..36e9db4595 --- /dev/null +++ b/com.unity.netcode.gameobjects/Editor/Configuration/HybridNetcodeConfigApplier.cs @@ -0,0 +1,123 @@ +#if UNIFIED_NETCODE +using Unity.NetCode; +using UnityEditor; +using UnityEngine; + +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 + { + /// + /// Whether the user has to opt into the experimental unified netcode API before NGO writes anything. + /// + /// + /// TODO-RELEASE: Set this to true before the 6000.7.0 release manifest submission if Netcode for Entities + /// ships the unified API as experimental and its scripting defines. + /// Note: This is deliberately not a const: IDE0035 (remove unreachable code) is an error in this repository, so a + /// const would fail the standards job as soon as it was set to false. + /// + internal static readonly bool RequiresExperimentalOptIn = false; + + private static NetCodeConfig s_ScannedConfig; + private static bool s_ConfigScanned; + + [InitializeOnLoadMethod] + private static void OnApplicationStart() + { + // Cross-assembly ordering between the two is not a documented contract. + // Defer rather than racing it. + 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 (RequiresExperimentalOptIn && !settings.EnableUnifiedNetcodeApi) + { + return; + } + + 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 = ResolveGlobalConfig(); + if (config == null) + { + return; + } + + if (HybridNetcodeDefaults.ApplyRecommended(config, HybridNetcodeDefaults.DefaultTickRate)) + { + EditorUtility.SetDirty(config); + AssetDatabase.SaveAssetIfDirty(config); + Debug.Log($"[Netcode] Applied the NGO hybrid mode defaults to '{config.name}'. These are tuned for NGO and can be changed freely; they will not be re-applied automatically. Use Project Settings > Multiplayer > Netcode for GameObjects to restore them.", config); + } + + // 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(); + } + + /// + /// Resolves the config N4E considers global, falling back to a project scan when N4E has not assigned one yet. + /// + /// + /// The scan is done at most once per domain reload, including when it finds nothing, because this is also + /// reached from OnGUI and walks the entire project. A config created + /// after the scan is picked up on the next domain reload. + /// + /// The config to adjust or null if no config exists. + internal static NetCodeConfig ResolveGlobalConfig() + { + if (NetCodeConfig.Global != null) + { + return NetCodeConfig.Global; + } + + if (!s_ConfigScanned) + { + s_ConfigScanned = true; + var guids = AssetDatabase.FindAssets($"t:{nameof(NetCodeConfig)}"); + s_ScannedConfig = guids.Length == 1 ? AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0])) : null; + } + + return s_ScannedConfig; + } + } +} +#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..9f9b9939d9 100644 --- a/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs +++ b/com.unity.netcode.gameobjects/Editor/Configuration/NetcodeForGameObjectsProjectSettings.cs @@ -37,6 +37,29 @@ private void OnEnable() [SerializeField] public bool GenerateDefaultNetworkPrefabs = true; +#if UNIFIED_NETCODE + /// + /// Whether the user has opted into the experimental unified netcode API. + /// + /// + /// Only consulted while holds. Turning it + /// off again hides the hybrid section and leaves the NetCodeConfig exactly as it is; the marker below is what + /// keeps turning it back on from overwriting anything. + /// + [SerializeField] + public bool EnableUnifiedNetcodeApi; + + /// + /// The hybrid mode default values already applied to this project's NetCodeConfig. + /// + /// + /// Zero means they have never been applied. Persisting this value is what keeps the tuned values a one-shot. + /// For users who deliberately change them, they are 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..83769d9ecf 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 +using Unity.NetCode; +#endif using UnityEditor; using UnityEngine; using Directory = UnityEngine.Windows.Directory; @@ -11,6 +14,9 @@ internal static class NetcodeSettingsProvider { private const float k_MaxLabelWidth = 450f; private static float s_MaxLabelWidth; +#if UNIFIED_NETCODE + private static float s_HybridLabelWidth; +#endif private static bool s_ShowEditorSettingFields = true; private static bool s_ShowProjectSettingFields = true; @@ -192,6 +198,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 +215,84 @@ 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 opt-in flag and the applied-defaults marker. + private static void DrawHybridSettings(NetcodeForGameObjectsProjectSettings settings) + { + if (HybridNetcodeConfigApplier.RequiresExperimentalOptIn && !DrawUnifiedNetcodeApiToggle(settings)) + { + return; + } + + GUILayout.BeginVertical("Box"); + GUILayout.Label("Hybrid (Netcode for Entities)", EditorStyles.boldLabel); + + var config = HybridNetcodeConfigApplier.ResolveGlobalConfig(); + if (config == null) + { + EditorGUILayout.HelpBox("No NetCodeConfig could be resolved. 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(); + } + + /// + /// Draws the opt-in for the experimental unified netcode API, writing the NGO hybrid mode defaults the first + /// time it is checked. + /// + /// The project settings holding the opt-in flag. + /// Whether the rest of the hybrid section should draw. + private static bool DrawUnifiedNetcodeApiToggle(NetcodeForGameObjectsProjectSettings settings) + { + const string enableUnifiedApiString = "Enable the experimental unified netcode API"; + + if (s_HybridLabelWidth == 0) + { + s_HybridLabelWidth = Mathf.Min(k_MaxLabelWidth, EditorStyles.label.CalcSize(new GUIContent(enableUnifiedApiString)).x); + } + + EditorGUIUtility.labelWidth = s_HybridLabelWidth; + var enabled = EditorGUILayout.Toggle( + new GUIContent( + enableUnifiedApiString, + "When enabled, Netcode for GameObjects writes the NetCodeConfig values it recommends for hybrid " + + "mode. Disabling it again hides these settings and leaves the NetCodeConfig as it is."), + settings.EnableUnifiedNetcodeApi, + GUILayout.Width(s_HybridLabelWidth + 20)); + EditorGUIUtility.labelWidth = s_MaxLabelWidth; + + if (enabled != settings.EnableUnifiedNetcodeApi) + { + settings.EnableUnifiedNetcodeApi = enabled; + settings.SaveSettings(); + if (enabled) + { + HybridNetcodeConfigApplier.ApplyDefaults(false); + } + } + + return enabled; + } +#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..289e2938e1 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Configuration/HybridNetcodeDefaults.cs @@ -0,0 +1,160 @@ +#if UNIFIED_NETCODE +using Unity.NetCode; + +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 Unity.NetCode, 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 = 1; + + // 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 15000 carries ~3000 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. + internal const int SnapshotPacketSize = 15000; + + // 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 ~11 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 0a3fee02d7..46f00efc9a 100644 --- a/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs +++ b/com.unity.netcode.gameobjects/Runtime/Core/NetworkManager.cs @@ -1415,13 +1415,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")); 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 /// @@ -1465,6 +1484,7 @@ public bool StartServer() ShutdownInternal(); return false; } + UnifiedAlignTickRate(); if (LogLevel <= LogLevel.Developer) { Log.Info(new Context(LogLevel.Developer, "Creating world: Default world")); @@ -1544,6 +1564,7 @@ public bool StartClient() ShutdownInternal(); return false; } + UnifiedAlignTickRate(); Log.Info(new Context(LogLevel.Developer, "Creating world: Default world")); InitializeNetcodeWorld(); } @@ -1618,6 +1639,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..3aef8f5eb8 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Editor/HybridNetcodeDefaultsTests.cs @@ -0,0 +1,212 @@ +#if UNIFIED_NETCODE +using NUnit.Framework; +using Unity.NetCode; +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 = HybridNetcodeConfigApplier.ResolveGlobalConfig(); + Assert.IsNotNull(config, "This project should have a NetCodeConfig to adjust."); + + var settings = NetcodeForGameObjectsProjectSettings.instance; + var restoreOptIn = settings.EnableUnifiedNetcodeApi; + var restoreVersion = settings.HybridDefaultsVersion; + var restoreConfig = EditorJsonUtility.ToJson(config); + try + { + settings.EnableUnifiedNetcodeApi = true; + 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.EnableUnifiedNetcodeApi = restoreOptIn; + 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 a9a05da02b..3a1300b538 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 @@ -13,7 +13,8 @@ "Unity.Mathematics", "UnityEngine.TestRunner", "UnityEditor.TestRunner", - "Unity.Netcode.Runtime.Tests" + "Unity.Netcode.Runtime.Tests", + "Unity.NetCode" ], "includePlatforms": [ "Editor" @@ -34,6 +35,11 @@ "expression": "", "define": "MULTIPLAYER_TOOLS" }, + { + "name": "com.unity.netcode", + "expression": "1.10.1", + "define": "UNIFIED_NETCODE" + }, { "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..53a44d431b --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Unified/UnifiedSnapshotPacketSizeMeasurement.cs @@ -0,0 +1,261 @@ +#if UNIFIED_NETCODE +using System; +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Collections; +using Unity.Entities; +using Unity.Netcode.TestHelpers.Runtime; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Unity.Netcode.RuntimeTests +{ + /// + /// Measurement harness (not a pass/fail behaviour test) used to determine bandwidth consumption based + /// on the when running in hybrid mode. + /// Spawns N hybrid ghosts, keeps every one of them dirty on every tick, and reads the N4E client-side + /// snapshot metrics singleton for a fixed sample window. Results are emitted as "PKTSZ|" log lines. + /// + [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 NetCode.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 NetCode.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