How many network objects in a scene is too many?

Hi all,
i have a design question regarding network objects.

My game contains a lot of trees, mineable stone etc which the player can cut down / mine. I want the health (and possibly other properties) of these objects to be shared across the network, i.e networkvariables. Hence (naively?) all my trees have network objects. This means that i have a lot of network objects in my scene (several hundred). Is this the wrong way to do it? Should i instead have some kind of managers which manages several objects and only have one network object themselves? Afaik i cant add networkobject components dynamically?

As it is right now it seems like having many network objects is killing performance? In my profiler NetworkUpdate takes about 20% of my CPU time. I have very few (2) network transforms in my scene.

Thanks.

Regards,
Slaymuel

You could reduce the number to only spawn those in view of each player. If it’s a large map maybe reduce the size of the map loaded around the player. I’d also be interested in more tips on this.

Incidentally how are you spawning so many objects, I usually run into trouble when I go into the hundreds.

Thanks for your reply. I guess spawning only the objects that are in the view might work. Might be a bit dangerous when there is latency though, and i guess it wont be very performance friendly.

The trees are scene objects so they are automatically spawned, in previous versions of netcode it broke down when spawning the trees (because they were too many) but seems to work now. Except for the (large) performance loss.

I think i will try to create a “manager” which keeps track of trees and the properties. As a player damages a tree, for instance the host, the manager would update and send the new health to the clients. Then only one network object is needed. If this fails i will think more about how to implement your idea.

Profile the network transform, its probably sending updates every frame/change. Which is a lot,

Did you by any chance solve this issue? I have the exact same problem… tons of trees and minable stones.

My idea is to use what i call a passive network object. The idea here is that until a tree is cut down or something, it will remain dormant.
When a player then interact with the tree, it will send a ServerRPC that this tree is now an active network object, and spawn a network object based on the coordinates of the tree.

Anyone have a better solution?

To answer the original question:
If you have a bunch of in-scene placed objects that you want to maintain state via NetworkVariable(s), then there are a couple of possible paths to take.

What Is a NetworkObject Really?
In reality, a NetworkObject boils down to the following things:

  • It represents a GameObject in a scene hierarchy

  • It is associated with a GlobalObjectIdHash value that tells everyone “What kind of Object” it is.

  • For dynamically spawned, this tells clients which network prefab to use when instantiating

  • For in-scene placed, this tells clients which object “in a specific scene” it is (the prefab instance resolution is handled when the scene is loaded).

  • Since you can load the same scene additively many times, if you have scene management enabled it uses the scene handle and the GlobalObjectIdHash value to determine “which instance” it is.

  • When spawned, it is assigned a NetworkObjectId.

  • The NetworkObjectId is used to determine “which spawned NetworkObject” a message might be associated with.

  • ** Think of this as the beginning “address” when communicating something associated with the NetworkObject changed.

How NetworkBehaviours Are Associated With NetworkObjects:
When a NetworkObject is instantiated and spawned, all NetworkBehaviour components associated with the NetworkObject are collected into a single list. Each NetworkBehaviour is identified by its position in this list (i.e. its index value) and this index value is actually the NetworkBehaviourId.

How NetworkVariables Are Associated With NetworkBehaviours and NetworkObjects:
When a NetworkVariable changes, the message sent to synchronize all clients will contain:

  • The NetworkObjectId: To be able to know which NetworkObject the message pertains to
  • The NetworkBehaviourId: To be able to know which NetworkBehaviour associated with the NetworkObject the message pertains to.
  • The NetworkVariable Field Identifier: an internal value determined very similarly to the NetworkBehaviourId that tells what NetworkVariable field the changes should be applied to.

The main idea is that you can have one NetworkObject with many NetworkBehaviours that each have (n) NetworkVariables.

One NetworkObject Many Children (With NetworkBehaviours)
This option does require you to handle any visualizations based on changes to NetworkVariables (i.e. chopping down a tree might iterate through a few different visual phases until there is nothing or a stump) for all clients include late joining. You can nest as many children as you want under a single NetworkObject and everything should “work as expected”. Of course, you should also think about the cost to synchronize clients and how you are handling your resources’ states.
If you have a bunch of NetworkVariables using things like individual numeric types (i.e. not a struct or class that implements INetworkSerializable), then each NetworkVariable will have a “cost” associated with it. It consumes space in the NetworkVariable Field table. It is individually processed during synchronization (i.e. each NetworkVariable is serialized individually and added to the over-all serialized data block associated with the NetworkObject). As such, if you have several “state related NetworkVariable properties” to track an in-game resource you might think about:

  • Combining all of the properties into a single struct or class that implements INetworkSerializable

  • As opposed to using several bool values to determine “on-off” states, you might think about using flags with a byte or short (depending upon the number of states you might have).

  • If you have several properties that “might or might not” change, then you might think about using flags to determine which property has changed

  • So, as opposed to serializing everything, you first serialize the “Changes” ushort value that contains the flag values of the properties updated first (primarily for when reading the serialized data you need to know which properties are included in the serialized stream reader).

  • You would want to set a “synchronize all” flag when first Synchronizing (see NetworkBehaviour.OnSynchronize) to send everything to a newly joined client.

  • The idea is that whatever your last set of “changed properties” flag was when you sent an update would still be set, so OnSynchronize is invoked when a full synchronization is required…as such you would set all flags or a “synch all” flag to denote that you want to write all properties and read all properties.

If you already have a bunch of in-scene placed NetworkObjects and want to “re-design” your approach this way, you can first create your (in the context of trees) “Forest” GameObject and add a NetworkObject component to that. Then you can drag and drop your trees under the Forest. Then you should be able to reselect all of your trees together (now children of the Forest) and in the inspector view remove their NetworkObject components. At this point, you will have a single Forest parent NetworkObject and a bunch of children GameObjects with NetworkBehaviour components that will all be associated with the single NetworkObject.

Of course, if the resource (i.e. tree) is fully consumed you will have to have a strategy to making that visually appear as “consumed” (whether nothing renders or a stump renders or the like). This logic would be based on the resource’s change in state and need to be invoked on all client instances when state changes.
(Remember, OnValueChanged only invokes after a NetworkObject is spawned so you would need to invoke this same logic during NetworkBehaviour.OnNetworkSpawn to make sure late joining clients properly visually synchronize with the resource’s current state).

Single NetworkObject Forrest Management
This is a bit more of a complex approach, and I could write a whole bunch on various ways to handle this. This is where you don’t have any children but provide some form of “data” (an array, a bitmap where each pixel represents a tree, etc.) that represents the forest. You would want to follow a similar pattern (i.e. INetworkSerializable implementation) that would send everything when first Synchronizing (see NetworkBehaviour.OnSynchronize) and then each time a resource is consumed only a specific block of that data is updated and upon being updated the tree(s) the data is associated with change their visual representation (or the like).

The idea for both approaches is that you don’t really need to have a NetworkObject represent everything in your game that you want to be synchronized. It is something we really need to have better documentation for and can be easy to start a design using a NetworkObject for almost everything in your game…only to realize later that it can become a costly design pattern.

Do You Really Need a NetworkObject Per Instance?
Some guides to determine if using a NetworkObject per instance could become too costly:

  • How many instances do you plan on having at any given time?

  • If you are in the 100’s upwards of 1000+, then you need to think of alternate approaches.

  • This only applies to things that don’t really “move”… if you are wanting 1000’s of things moving around then you definitely need to come up with some cleaver custom approach.

  • Is your object strictly for maintaining state and does not move?

  • If this is true, then you might think about nesting it as a child under some “GlobalManager” NetworkObject where several other things could be managed this way.

  • Say you have a castle with 30+ doors that simply open and close…they are in-scene placed and don’t move…when opened they animate and rotate/slide open… but their status is really just “open or closed”…so a single NetworkObject with 30+ child GameObject doors that each have a DoorNetworkBehaviour component would be better than 30+ NetworkObjects that all have a DoorNetworkBehaviour component.

Let me know if any of this information helps?

13 Likes

As a follow up to anyone looking over this answer, the included sample project provides a way to “enable and disable” various children where you have only 1 NetworkObject and 1 NetworkBehaviour that control several “objects”.

9538621–1346872–SimpleEnableDisable.zip (17.1 KB)

1 Like

This comment is pure gold, even provided a sample of the complex approach. Considering how hard is it to find information about netcode advanced concepts without haviing access to professional implementations, this is really nice of you, thanks a lot!

1 Like

Hi noel,

I have a problem with interactable objects on Unity Netcode. Let me explain in detail:
I have a train, and it’s already loaded into the scene with a NetworkObject component attached.
The hierarchy under my train looks like this:

Train (NetworkObject - Root Object)
→ RandomWagons (not a NetworkObject)
→ W1 (not a NetworkObject)
→ W2 (not a NetworkObject)
→ W3 (not a NetworkObject)
→ W4 (not a NetworkObject)
→ W5 (not a NetworkObject)
→ W6 (not a NetworkObject)

→ StaticWagons (not a NetworkObject)
→ W0 (not a NetworkObject)
→ W7 (not a NetworkObject)

After the scene is loaded, I use Addressables to load wagon prefabs into each “W” (short for Wagon) object. The loaded wagons and all of their children do not have NetworkObjects.

Here’s an important detail: since “W7” is the last wagon, I made a special script for it and marked it as a NetworkBehaviour. This script is loaded during scene loading as part of the prefab, and because it’s placed under the Train’s hierarchy, it shows up in the ChildNetworkBehaviours of the Train NetworkObject. (I confirmed that NetworkVariable changes on this script are synchronized across server and clients.) Its only purpose is to be present at scene load.

Now back to the main issue:
The wagon prefabs that are loaded contain interactable objects like SlideDoor and Drawer. I’d love to attach a NetworkBehaviour script to these objects (without requiring a NetworkObject) and create an IsOpenBool as a NetworkVariable.
However, the problem is this:
according to the source code, NetworkBehaviour only recognizes a NetworkObject if it’s in its direct parent hierarchy.

Furthermore, I’m not sure if the NetworkObject can synchronize with ChildNetworkBehaviours that are added later at runtime.

I tried creating a custom NetworkObject, but it’s sealed in the source code.
What I wish is this: if I could make a CustomNetworkBehaviour that, upon being loaded into the scene, could subscribe itself to a specific NetworkObject that I assign or attach itself to the closest NetworkObject in its parent hierarchy. That way, I could create a more dynamic and lightweight system with fewer NetworkObjects.

What can I do in this situation? What are your recommendations?
Are my thoughts fundamentally flawed or problematic in any way?
I would greatly appreciate a detailed explanation.
Kind regards,

Extra Edit:

If the system I described were in place, the NetworkBehaviour script under SlideDoor could use NetworkBehaviourReference (linked to the top-level Train’s NetworkObject) to call the RPC below. This way, other clients would be able to request changes from the server, and when a change occurred, each client would handle the action locally on its own.

InteractSlideDoor.cs : NetworkBehaviour:


What version of the Unity editor and Netcode for GameObjects are you using?

Unity 6.036f and netcode latest version

Thank you for that information.

Here is some pseudo code for the more universal way to handle this:

    /// <summary>
    /// Placed on the Train Root Object 
    /// </summary>
    public class TrainWagonManager : NetworkBehaviour
    {
        private Dictionary<int, WagonLogic> m_WagonLookupTable = new Dictionary<int, WagonLogic>();
        private NetworkVariable<Dictionary<int, WagonState>> m_WagonStatesTable = new NetworkVariable<Dictionary<int, WagonState>>(new Dictionary<int, WagonState>());

        public void RegisterWagon(WagonLogic wagonLogic)
        {
            if (m_WagonLookupTable.ContainsKey(wagonLogic.HashId))
            {
                Debug.LogError($"{wagonLogic.name} has a duplicate hash identifier! Wagon not registered!");
                return;
            }
            m_WagonLookupTable.Add(wagonLogic.HashId, wagonLogic);

            if (IsServer)
            {
                m_WagonStatesTable.Value.Add(wagonLogic.HashId, new WagonState() { HashId = wagonLogic.HashId });
                m_WagonStatesTable.CheckDirtyState();
            }
        }

        public void SetWagonState(WagonLogic wagonLogic,  WagonState state)
        {
            if (m_WagonStatesTable.Value.ContainsKey(wagonLogic.HashId))
            {
                m_WagonStatesTable.Value[wagonLogic.HashId] = state;
                m_WagonStatesTable.CheckDirtyState();
            }
            else
            {
                Debug.LogError($"{wagonLogic.name} does not have a wagon state!");
            }
        }

        public WagonState GetWagonState(int hashId)
        {
            if (m_WagonStatesTable.Value.ContainsKey(hashId))
            {
                return m_WagonStatesTable.Value[hashId];
            }
            return new WagonState();
        }

        protected override void OnNetworkPostSpawn()
        {
            if (!IsServer)
            {
                m_WagonStatesTable.OnValueChanged += OnWagonStatesChanged;
            }
            base.OnNetworkPostSpawn();
        }

        public override void OnNetworkDespawn()
        {
            m_WagonStatesTable.OnValueChanged -= OnWagonStatesChanged;
            base.OnNetworkDespawn();
        }

        private void OnWagonStatesChanged(Dictionary<int, WagonState> previous, Dictionary<int, WagonState> current)
        {
            UpdateStates();
        }

        protected override void OnNetworkSessionSynchronized()
        {
            UpdateStates();
            base.OnNetworkSessionSynchronized();
        }

        private void UpdateStates()
        {
            foreach (var wagonLogic in m_WagonLookupTable)
            {
                if (!m_WagonStatesTable.Value.ContainsKey(wagonLogic.Value.HashId))
                {
                    Debug.LogError($"{wagonLogic.Value.name} does not have a state entry!");
                    continue;
                }
                wagonLogic.Value.UpdateState(m_WagonStatesTable.Value[wagonLogic.Value.HashId]);
            }
        }

        [Rpc(SendTo.Server)]
        public void SetWagonStateRpc(WagonState wagonState, RpcParams rpcParams = default)
        {
            // You can add logic here to determine if the client is allowed to change the state
            // i.e. if (rpcParams.Receive.SenderClientId != somevalue) exit early 
            if (!m_WagonStatesTable.Value.ContainsKey(wagonState.HashId))
            {
                Debug.LogError($"[Client-{rpcParams.Receive.SenderClientId}] Trying to set wagon state for Hash: {wagonState.HashId} but that hash does not exist!");
                return;
            }

            var wagon = m_WagonLookupTable[wagonState.HashId];
            wagon.UpdateState(wagonState);
            m_WagonStatesTable.Value[wagonState.HashId] = wagonState;
        }
    }

    /// <summary>
    /// Placed on the addressable wagon assets.
    /// HashId could be any unique identifier you want.
    /// </summary>
    public class WagonLogic : MonoBehaviour
    {
        private TrainWagonManager m_TrainWagonManager;
        public int HashId { get; private set; }

        private void Start()
        {
            m_TrainWagonManager = transform.root.GetComponent<TrainWagonManager>();
            // Generate a unique identifier for this wagon instance.
            // As an example, I am getting the hash for the Train's GlobalObjectId, NetworkObjectId, and name of the wagon
            HashId = $"{m_TrainWagonManager.NetworkObject.PrefabIdHash}-{m_TrainWagonManager.NetworkObjectId}-{name}".GetHashCode();
            m_TrainWagonManager.RegisterWagon(this);
        }

        public void SetDoorState(bool isOpen)
        {
            // Get the current state
            var wagonState = m_TrainWagonManager.GetWagonState(HashId);
            if (wagonState.HashId != HashId)
            {
                Debug.LogError($"{name} has a HashId of {HashId}, but the {nameof(TrainWagonManager)} thinks it is HashId {wagonState.HashId}!");
                return;
            }
            // Update current state
            wagonState.IsOpen = isOpen;

            // If not the server, then send the updated state
            if (!m_TrainWagonManager.IsServer)
            {
                m_TrainWagonManager.SetWagonStateRpc(wagonState);
                return;
            }

            // If the server, then synchronize and locally updated wagon state
            m_TrainWagonManager.SetWagonState(this, wagonState);
            UpdateState(wagonState);
        }

        private void ApplyDoorState(bool isOpen)
        {
            // Do animation or the like to open or close door
        }

        /// <summary>
        /// For updating all of the wagon's states
        /// </summary>
        /// <param name="wagonState"></param>
        public void UpdateState(WagonState wagonState)
        {
            ApplyDoorState(wagonState.IsOpen);
            // Do any other update based on any additional wagon state properties here
        }
    }

    public struct WagonState : INetworkSerializable
    {
        public int HashId;
        public bool IsOpen;
        
        // Add other properties for the wagon state here
        // 
        //

        public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
        {
            serializer.SerializeValue(ref HashId);
            serializer.SerializeValue(ref IsOpen);
        }
    }

Where the idea is that as wagons are added they end up registering with a NetworkBehaviour on the root object and the wagon has a “state” structure that is kept synchronized by the server/host. I used a NetworkVariable for the wagon states so late joining clients would be synchronized with wagon states when they join.

Let me know if something like this works for you?

First of all, thank you for taking the time to respond. I had actually designed a structure similar to what you described. After all the wagons are loaded, I use an interface to retrieve the scripts underneath them and those that will be interactable. Then, as you mentioned, these are managed and controlled within the NetworkBehaviour script of the root Train object. I definitely believe this system will work.

image

What I was trying to ask was whether the NetworkBehaviour and NetworkObject structures can be modified as I described above, or if future versions of Netcode would require such changes to support a structure like this. Additionally, I wanted to have the logic I designed evaluated from the perspective of experts like yourself. I would appreciate it if you could provide some insights on this.

Dynamically adding NetworkBehaviours at runtime is not supported and adding that particular kind of feature is not currently on our roadmap.

However, there is some work towards a new “AttachableBehaviour” feature that provides three new components which could possibly be the solution to your issue.

The idea would be to spawn “additional feature” network prefabs where you would “attach” a child GameObject to one of your wagons to provide a specific type of functionality. While you would still need to spawn the “additional feature” network prefab, the 3 components (coming soon to v2.x) will provide you with the ability to enable/disable components (not NetworkBehaviours) as well as attach children to an already spawned NetworkObject without having to attach the entire NetworkObject with all of its children.