Networking an Object Pooler with Unity & PUN

Recently, I had an issue with Iris Burning that lead to a substantial refactor of important code: namely, the in-game buildings are quite complex objects, with custom shaders, materials, elaborate scripts, animation, a cool “under construction” effect, and so on.  All of this amounted to one big problem: the process of instantiating them into the scene took more than 2000 milliseconds to accomplish.  (That’s two full seconds of 0 FPS.)  This was absolutely, positively unacceptable, as it interrupted the flow of gameplay, introduced glitching into the sound, and would have definitely marked points off of an objective review of the game.

I sought out options, and the solution turned out to be object pooling.  I won’t get too detailed with this, but the basic gist is that the process of instantiation/allocation is inefficient and slow, and to get an instant spawn of an object, you are best off spawning everything you could possibly need at the beginning of the game and hiding them from view, retrieving and enabling the objects as necessary so that you re-use premade objects.

This proved quite simple to implement in Unity:

  1. Acquire a list of things to pool, either stored as an array in the Inspector, or as a ScriptableObject
  2. On Start(), spawn everything in the pool at once via Instantiate()
  3. Immediately disable the game object (spawned.setActive(false);) after instantiation.
  4. When you want to spawn an object, call it by name: Transform obj = ObjectPooler.Request("Office Space", spawnPosition, spawnRotation);
  5. When you are done with the object, put it back: ObjectPooler.CheckIn(obj);

The ObjectPooler then handles the Active state of the pooled objects, and uses SendMessage("Start"); to simulate a proper “spawn” each time an object is spawned.  As a tradeoff, loading times during the instantiation phase take close to 2 full seconds added to the normal load time, but the act of placing an object is totally instant, sub 100ms on the lowest-end target hardware in my tests.

Now, this is great and all, but what about networked multiplayer?  After all, Iris was designed with the primary mode of play first, the idea being that it is easier to develop mechanics under multiplayer and then port them to single player than it would be to do the other way around.

The problems introduced by networking using the object pool method were unbeknownst to me at the time, but it turned out to be something obvious.  Before I can explain it, a bit of technical background on how my multiplayer framework of choice, Photon, synchronizes data between players.

Photon asserts synchronizatoin by way of views.  A View, or PhotonView, is attached to a GameObject or prefab like any other component would be, and upon instantiation, synchronizes data between two copies of the same object on different computers or game sessions.  The View is smart enough to know which properties to track, because you specifically tell it by dragging and dropping the script reference in the Inspector as needed, minimizing network traffic and only serializing data between clients as it changes.

Of note, this is also extremely similar to the Network Identity used by Unity’s own networking implementation.

Now, for the problematic aspect.  The PhotonView, being a behavior component assigned to a game object, only receives MonoBehaviour events such as Update() and FixedUpdate() if its object is enabled.  As a result, this poses a major problem: If the object pooler spawns an object and immediately disables it, the other clients have no idea that this SetActive event took place.

“I know!” I thought.  “I’ll just send an RPC to the object on the other clients that disables the object!”  Great!  It works.  Except now, on the other end, the object is disabled, and once again, is not receiving events.  The other client will thusly have no idea whether or not to re-enable the object.

After a bit of experimentation, I came to the conclusion that the best course of action was to have the ObjectPooler track the SetActive state of the game object.  The next hurdle was figuring out exactly how to accomplish that.  Fortunately, it was not too complex!

When I developed the ObjectPooler, I structured it so that all instantiated objects, whether remote or local, were stored in one monolithic array of an object type I called PooledObject.  A PooledObject simply stores a human-readable name (for .Request()), a reference to the instantiated object, and the active state that the object should have.  The only remaining problem from here is that the array of spawned objects will not, and cannot, be in any guaranteed order due to the haphazard nature of asynchronous RPCs.  Thus, trying to use array indices to indicate which PooledObject to turn on or off would almost always result in an IndexOutOfRangeException on the target machine, which would crash the ObjectPooler behavior script entirely.

This part puzzled me for a while, until I remembered a specific property Photon has: All PhotonViews have a unique ID assigned to them that is consistent across all clients, guaranteed.  This means it can be used to identify which object to enable or disable.

Here is the code.  spawnedObjects is the (static) array of PooledObjects.  I will concede that there is use of GetComponent<T>() present that could easily be optimized into the PooledObject itself, but it is not a priority for now, as there will never be a case where I want to enable and disable an object every frame (and no hope that doing so would even work reliably in a networked game).


Excerpt from ObjectPooler.cs:

/* The locally called method for setting the active state. */
public static void SetActive(Transform obj, bool state) {
    // Iterate all known spawned objects, O(n) with respect to the size of the pool
    for (int i = 0; i &lt; spawnedRemote.Count; i++) {
        // If we succesfully find the object,
        if (obj == spawnedRemote[i].spawned) {
            // obtain it's photon view,
            PhotonView otherView = obj.GetComponent();

            // and then assert that it does, in fact, have a view, and that it belongs to us,
            // so we can call the RPC.
            if (otherView != null &amp;&amp; otherView.isMine)
                local.photonView.RPC("SetActiveState", PhotonTargets.All, otherView.viewID, state);
            return;
        }
    }
}

/* The RPC called on all clients, including the local one, to set their active state. */
[PunRPC]
public void SetActiveState(int index, bool state) {
    // Search for a pooled object by iterating over the array.
    // This does mean twice the searching done on the local client and could likely be optimized out
    // by changing the state in SetActive if the view belongs to us.
    PooledObject foundObject = null;
    for (int i = 0; i < spawnedRemote.Count; i++) {
        // Get the photon view, and if it does exist,
        PhotonView otherView = spawnedRemote[i].spawned.GetComponent();
        if (otherView != null &&; index == otherView.viewID) {
            // and its ID number matches the one passed to the RPC, we know it's the right object.
            // Cache it out and break from the loop.
            foundObject = spawnedRemote[i];
            break;
        }
    }
    
    // If, at this point, an object matching said PhotonView was found, set its active state.
    if (foundObject != null)
        foundObject.UpdateActiveState(state);
}

This implementation seems to have worked and has since been optimized using the annotations above, but the idea stands: you can object pool over the network and sync their states if you can consistently identify the objects between clients.

Thusfar, this has been the most annoying thing to do over the network, as it takes a normally benign operation in a single-player game (SetActive(bool)) and makes it four times as complicated, easily. However, given that this has been the only real difficulty so far in my refactor of the Iris netcode, I am confident in future challenges ahead while working with Photon.