Optimization With Draw Call Batching

Optimization With Draw Call Batching

A deep dive into Unity optimization using draw call batching.

Table of contents

No heading

No headings in the article.

This week, I want to share with you my journey into optimization in Unity 3d.

I'm currently working on a project where I’m instantiating at runtime, several thousand meshes in the same scene. It quickly led to an awful performance and something had to be done.

The 3d artist (@atomonun21) was already aware of this possibility and employed some techniques to minimize the performance impact:

  • Keeping the number of materials per mesh at a minimum (check here);

  • Using LODs to reduce the number of triangles being rendered at a given moment;

Even so, the performance was still subpar.

So, we checked the draw call count and it was indeed above the roof. A draw call is a request issued by the engine to the graphics API to draw an object on the screen (hence the name). This is a quite “heavy” procedure so we should always try to keep the draw call number at a minimum. But, with more GameObjects, more draw calls.

You can check the number of draw calls in the Rendering Profiler.

Note: check this explanation for why draw calls were removed from the Rendering Statistics Window.

Luckily, Unity uses a technique called draw call batching, to reduce the total number of draw calls. To put it simply, instead of having a draw call per GameObject, it groups, or batches multiple GameObjects in the same draw call.

However, to take advantage of batching we need to follow some rules (to be honest a lot of rules), otherwise we “break it” and GameObjects cannot be batched together.

For example, one simple rule to remember is:

“Only GameObjects sharing the same Material can be batched together.”

So you should always try to share Materials among GameObjects. You can have a closer look at what’s being batched or not, using the Frame Debugger window.

In this case, since both cubes are sharing the same material (and a lot more rules that we didn’t cover), they are dynamically batched into the same call.

In this second example, the cubes cannot be batched because they have different materials.

Now, please pay close attention to the following (trust me, I’ve learned it the hard way):

“If you need to access shared Material properties from the scripts, then it is important to note that modifying Renderer.material creates a copy of the Material. Instead, use Renderer.sharedMaterial to keep Materials shared.”

// e.g. changes the material color (reflects in all objects that are using it)
gameObject.GetComponent<Renderer>().sharedMaterial.color = Color.red;

// e.g. creates a copy of the material and changes it’s color (the material is no longer shared)
gameObject.GetComponent<Renderer>().material.color = Color.red;

In this project, most, if not all objects were static so I had to dig deeper into this type of batching. GameObjects that share the same material and are marked as static in the Editor are available for being batched together.

It sounded really easy to use this in the project since most objects were static. However, these objects were instantiated at runtime. So I had no way of marking them as static in the Editor.

At first, I tried to mark the prefab (the one I was instantiating at runtime) as static, assuming it would be equal to having all the objects marked as static already in the Editor. Big mistake!

  • 100 meshes marked as static in the Editor: 2 Draw Calls / 100 Batched Draw Calls;

  • 100 meshes instantiated from a prefab marked as static:101 Draw Calls / 0 Batched Draw Calls;

Note: In both tests, I used the same mesh and single material.

Then I found out about the following command: StaticBatchingUtility.Combine. It allows you to combine meshes generated at runtime preparing them for static batching, something like marking the meshes as “static” at runtime. After instantiating all objects, simply call the Combine method with a reference to the objects parent:

  • 100 meshes instantiated from a prefab: 6 Draw Calls / 100 Batched Draw Calls;

using UnityEngine;

public class Builder : MonoBehaviour
{
    [SerializeField]
    private GameObject _prefab;

    [SerializeField]
    private Vector2 _grid;

    [SerializeField]
    private Vector2 _offset;


    private void Start()
    {
        Build();
    }

    private void Build()
    {
        int count = 0;

        for (int i = 0; i < _grid.x; i++)
        {
            for (int j = 0; j < _grid.y; j++)
            {
                GameObject instance = GameObject.Instantiate(_prefab, transform);
                instance.transform.localPosition = new Vector3(_offset.x * i, 0, _offset.y * j);
                instance.name = "" + count;
                count++;
            }

            // prepare all gameObject children to static batching
            StaticBatchingUtility.Combine(gameObject);
        }
    }
}

If you cannot use Static batching, another option I found out while researching this topic was GPU Instancing:

“Use GPU Instancing to draw (or render) multiple copies of the same Mesh at once, using a small number of draw calls. “

“GPU Instancing only renders identical Meshes with each draw call, but each instance can have different parameters (for example, color or scale) to add variation and reduce the appearance of repetition.”

Let’s suppose, for example, that the rocks in the last example were not static. Since they are copies of the same Mesh, we could reduce the number of draw calls by enabling the GPU Instancing option in the Material.

  • 100 dynamic meshes instantiated from a prefab: 6 Draw Calls / 100 Batched Draw Calls;

These were just a couple of examples about my experience with Draw Call batching. There is still a lot more to cover on this topic. To read more about batching and all it’s rules please read the following post.