Antegods Design Update 8: Treading into Multithreading

As mentioned before, we’re changing things around. Instead of irregular long posts, we’ll now keep you up-to-date on the development of our stonepunk arena action game Antegods with shorter weekly posts. And instead of bundling all game development disciplines together, these will be about one discipline each. In this post, code lead Niels jumps into the challenge of multithreaded code in the singlethreaded programming environment that is Unity.

Unity and multithreading

I mentioned this in an earlier blogpost, but Antegods makes extensive use of multiple threads. Even though Unity by itself is very singlethreaded, it’s actually started to be more multithreading friendly. For example, in version 5.4 they released the second phase of multithreading their renderer. And I understand that their physics calculations are also running on different threads. So Unity is just fine with working with threads, as long as you don’t touch any Unity specific objects, like Transforms, GameObjects and Meshes.

Particle Playground

We use several third-party plugins, and one of them is Particle Playground. Now Particle Playground makes a lot of use of multithreading by queueing up particle systems to be calculated in its Update method, waiting for them in the LateUpdate until they’re done, copying over all the state data to Unity and letting Unity’s own particle system (Shuriken) do the rendering.

However, Particle Playground turned out to have a nasty bug in its threading code, causing huge hiccups during gameplay. While we were preparing to show Antegods at Nordic, we noticed that the game was no longer hitting 60 fps on our test machines. This was particularly due to the massive amounts of ‘trails’ that are in the game. Trails are an effect for which a mesh is generated, that gives it a nice sensation of going fast.

image

In this screenshot you see quite a lot of trails, most of them from the totems’ wings. Now usually there should be a maximum of 40 trails in total (10 totems, each with 2 wings, each with 2 trails). This would eat up around 2 ms, updating and generating its mesh; already too much in my opinion, but this was acceptable for the GDC build as it was running at 60 fps anyway.

Energy balls

The problem was that we introduced a new way of displaying energy, by creating one droplets per unit of energy. Once you picked up an energy ball, it would fly to your character, leaving a nice trail behind. This suddenly meant that we easily passed our maximum of 40, hitting up to a 100 trails, thus eating up our sweet 16.67 ms frametime.

Analyzing

The updating and generation code was actually very easy to thread. As we already had a local copy of the vertex data, we just had to rebuild the vertex data for each frame and assign it to its mesh. Looking at the profiler showed us that about 30% of the time was being spent in updating the trail points; this isn’t a very intensive operation, but there were a whole lot of them. The other 70% was being consumed in generating the mesh: calculating vertex positions and UV coordinates.

Like I said earlier, threading this was actually quite easy, using a similar approach to Particle Playground’s:

image

It stores some data it needs for running threaded (mainly the delta time), then puts a task into our ThreadPool, which runs it parallel to other Update calls. Then in the LateUpdate we check to see if the thread is done, and if it’s not, we’ll wait for it to be done. If it’s done we’ll update the mesh on the Unity side.

Solution

For this to work, I had to use a thread pool, and I ended up needing to make even more changes, as I had to keep track of the number of trails being updated. The last trail that was updated would need to actually generate the meshes to minimize draw calls by combining them. I also had to cache the creation of theAction<T> object and the data that was being passed along to the task.

The final result looks like this (note that cachedAction leads to the UpdateThreaded method):

 

protected void Update()
{
    RemoveFadedTrails();
            
    threadReady = false;
    
    cachedData.deltaTime = Time.deltaTime;

    OctarineThreadPool.Instance.EnqueueTask(cachedAction, cachedData);
}


private void UpdateThreaded(System.Object data)
{
    RunData runData = (RunData)data;

    if (activeTrail != null)
    {
        UpdatePoints(activeTrail, runData.deltaTime);
        GenerateMesh(activeTrail);
    }

    for (int i = fadingTrails.Count - 1; i >= 0; i--)
    {
        if (fadingTrails[i] != null && fadingTrails[i].activePointCount == 0)
        {
            fadingTrails[i].CanBeDisposed = true;
            continue;
        }

        UpdatePoints(fadingTrails[i], runData.deltaTime);
        GenerateMesh(fadingTrails[i]);
    }

    threadReady = true;
}

 

Related posts