Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions com.unity.netcode.gameobjects/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Writes the <see cref="NetcodeConfig"/> values NGO recommends for hybrid mode, once, the first time a
/// <see cref="NetcodeConfig"/> is available.
/// </summary>
/// <remarks>
/// This does not create <see cref="NetcodeConfig"/>. 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
/// <see cref="NetworkManager"/> 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.
/// </remarks>
Comment on lines +14 to +19

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// <remarks>
/// This does not create <see cref="NetCodeConfig"/>. 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
/// <see cref="NetworkManager"/> 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.
/// </remarks>

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);
}

/// <summary>
/// Writes the NGO hybrid mode defaults into the project's <see cref="NetcodeConfig"/>.
/// </summary>
/// <param name="force">
/// 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.
/// </param>
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))
Comment thread
EmandM marked this conversation as resolved.
{
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ private void OnEnable()
[SerializeField]
public bool GenerateDefaultNetworkPrefabs = true;

#if UNIFIED_NETCODE
/// <summary>
/// The version of the hybrid mode default values already written into this project's NetcodeConfig, or zero
/// when they have never been written.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[SerializeField]
public int HybridDefaultsVersion;
Comment thread
EmandM marked this conversation as resolved.
#endif

internal void SaveSettings()
{
Save(true);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -205,6 +212,41 @@ private static void OnGuiHandler(string obj)
settings.SaveSettings();
}
}

#if UNIFIED_NETCODE
/// <summary>
/// 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.
/// </summary>
/// <param name="settings">The project settings holding the applied-defaults marker.</param>
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// The <see cref="NetcodeConfig"/> values NGO needs when running in hybrid mode (i.e. Netcode for Entities is
/// installed and a registered network prefab carries a <see cref="GhostObject"/>).
/// </summary>
/// <remarks>
/// This lives in the runtime assembly rather than the editor one because <see cref="NetcodeConfig.HostWorldModeSelection"/>
/// 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.
/// </remarks>
internal static class HybridNetcodeDefaults
{
/// <summary>
/// Bump whenever <see cref="ApplyRecommended"/> changes so that an upgrading project re-applies exactly once.
/// Persisted as NetcodeForGameObjectsProjectSettings.HybridDefaultsVersion.
/// </summary>
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;

/// <summary>
/// Applies the two settings hybrid mode cannot run without.
/// </summary>
/// <param name="config">The config to correct.</param>
/// <returns>True if anything changed.</returns>
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;
}

/// <summary>
/// Drives N4E's tick rates from <see cref="NetworkConfig.TickRate"/> so that ghost transform updates land on
/// the same interval NGO uses for everything else.
/// </summary>
/// <param name="config">The config to correct.</param>
/// <param name="tickRate">The owning <see cref="NetworkManager"/>'s configured tick rate.</param>
/// <returns>True if anything changed.</returns>
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;
}

/// <summary>
/// Applies the full NGO-recommended set: <see cref="ApplyRequired"/>, <see cref="ApplyTickRate"/>, and the
/// values tuned against the stress test.
/// </summary>
/// <param name="config">The config to correct.</param>
/// <param name="tickRate">The owning <see cref="NetworkManager"/>'s configured tick rate.</param>
/// <returns>True if anything changed.</returns>
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;
}

/// <summary>
/// Reports the first required setting that is still wrong, for the runtime start-up check.
/// </summary>
/// <param name="config">The config to inspect.</param>
/// <param name="reason">Populated with a user-facing description of what is wrong.</param>
/// <returns>True when <paramref name="config"/> cannot support hybrid mode as-is.</returns>
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<T>(ref T target, T value)
where T : System.IEquatable<T>
{
if (target.Equals(value))
{
return false;
}

target = value;
return true;
}
}
}
#endif

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading