The unofficial Unity Performance Field Guide!

Version 1

About

The best way to optimize your game is to build good habits and write decently performant code the first time on instinct.

Part of why doing that is so hard, is that sometimes you don’t know the best way or the pitfalls of the engine until it’s too late. This field-guide is an attempt to articulate a series of from-the-trenches tips and tricks. So you can build good habits and generate less optimization work for yourself later.

This guide concentrates knowledge from Society of Play community discussions into a single reference point.

Credits

Curator + Editor: Tyler Tomaseski
Optimization Contributors:
Scott Robertson, Liz Gravis, & Tyler Tomaseski

Disclaimer

The (unofficial) Unity Performance Field Guide is not officially supported or sponsored by Unity in any way.

How to contribute

The Unity Performance Field-Guide is an open-source project and can be found here. Have tips? I'll soon be adding a guide to the Git Repo on how to contribute tips! Or even simpler, submit a tip via this google form!

The tips!

Animator Key Hashing

Tags: CodeArt
Difficulty

Instead of...

Setting and getting values from animators with strings.

void Update() 
{
    Animator.SetFloat("PropertyName", ...)
}

Try instead...

Generating string hashes once and then using generated ints.

int Key_PropertyName;

void Awake() 
{
    Key_PropertyName = Animator.StringToHash("PropertyName");
}

void Update() 
{
    Animator.SetFloat(Key_PropertyName, ...)
}

Because...

Every time you pass a string to an animator, it will "hash" the string. Which is slow. You can instead do this once on your end, and reuse the generated integer id.



FPS conditional FX

Tags: CodeArt
Difficulty

Instead of...

Spawning all fx.

void SpawnFX() {
    GameObject.Instantiate(/*...*/);
}

Try instead...

Don't spawn some fx when FPS is low or when off-screen.

void SpawnFX() {
    //only spawn if framerate is higher than 25fps
    if (Time.deltaTime < 1.0f / 25.0f)
        GameObject.Instantiate(/*...*/);
}

Because...

Some FX may not be critical to your game (like footstep dust). Try not spawning it or limiting it while the game's FPS is low.



Empty Start(), Update(), Etc...

Tags: Code
Difficulty

Instead of...

Leaving empty Unity callback methods laying around.

void Start() {

}

void Update() {
    
}

Try instead...

Deleting them!

//JohnTravoltaMeme.gif

Because...

Turns out Unity will not optimize out those functions that they put there for you. Remember that Start function in every MonoBehaviour that you left there? They're slowing you down. Delete 'em!



Game Initialization

Tags: Code
Difficulty

Instead of...

Static initializers or static constructors for static variables. Multiple [InitializeOnLoad].

public static class Globals {
    static Vector3 GlobalVal1 = new Vector3(...);
    static float GlobalVal2 = ...;
    static SomeClass GlobalVal3 = ...;
    static SomeClass[] GlobalVal4 = new SomeClass[...];

    static Globals() {}
}

public static class InitClass1 {
    [InitializeOnLoad]
    static void SomeInitMethod() {}
}

public static class InitClass2 {
    [InitializeOnLoad]
    static void SomeInitMethod() {}
}

Try instead...

Create one specific unified initialization routine to run for each of Unity’s initialization callbacks and attributes (like [InitializeOnLoad]).

public static class GameInitialization {
    [InitializeOnLoad]
    static void GameInitializationRunner() {
        Globals.InitGlobals();
        InitClass1.SomeInitMethod();
        InitClass2.SomeInitMethod();
    }
}

public static class Globals {
    static Vector3 GlobalVal1;
    static float GlobalVal2;
    static SomeClass GlobalVal3;
    static SomeClass[] GlobalVal4;

    static void InitGlobals() {
        //initialize values
        //...
    }
}

public static class InitClass1 {
    static void SomeInitMethod() {}
}

public static class InitClass2 {
    static void SomeInitMethod() {}
}

Because...

Static initializers make editor performance slower during startup, compiling, play mode entry/exit, and a few other places. They also inject a small amount of overhead for any code that accesses the class with the static initializer or constructor, which adds up.



TMPro frequent SetText() calls

Tags: Code
Difficulty

Instead of...

TMPro.TMP_Base.SetText() every frame

TMP_Text textComponent;

void Update() {
    this.textComponent.SetText((int)Time.time);
}

Try instead...

Set once, only update when the text changes!

TMP_Text textComponent;
int lastVal = -1;

void Update() {
    int val = (int)Time.time;
    if (val != lastVal) {
        lastVal = val;
        this.textComponent.SetText(val);
    }
}

Because...

Rebuilding TMPro text meshes is slow and it does it every time you set the text value! And so is its rich text support. Avoid setting text unless the contents have changed.



Deep transform hierarchies

Tags: Code
Difficulty

Instead of...

Maintaining large Transform hierarchies (for UI or temporary shared motion or scene organization)

//Unity transform hierarchy

> Gameobject Category Container
    > My GO Container
        > Sub Container
            > Regional Offset
                > Extra transform in prefab
                    > Crates Container
                        > Crate group Offset
                            > Crate

Try instead...

Keep Transform hierarchies as flat as possible

//Unity Transform Hierarcy
> Gameobject Category Container //just sits above relevant hierarchies
> Regional Offset
    > Crate

Because...

They make internal per-frame Transform operations slower. Transform hierarchies reduce the multithreading ability of the animation system and IJobParallelForTransform jobs. They make most UI operations slower. If you NEED to maintain deep transform parenting, consider unparenting objects at runtime.



Avoid C# dictionaries

Tags: Code
Difficulty

Instead of...

Using C#'s Dictionary. Especially if you're using string-keys.

static Dictionary lookupDictionary = ...;

public string ID;

void Awake() {
    lookupDictionary[ID] = this;
}

...

static void SomeFunc(string ID) {
    var obj = lookupDictionary[ID];
    
    ...
    
}

Try instead...

An int-key dictionary. Or even better, an alternative data-structure. For game catered datastructures, check out GDX. GDX is an open-source collection of custom data-types geared towards gamedev and performance.

//
// EXAMPLE 1
//an optimized string-key dictionary from GDX

static StringKeyDictionary$lt:object$gt: lookupDictionary = ...;

public string ID;

void Awake() {
    lookupDictionary.AddWithExpandCheck(ID, this);
}

...

static void SomeFunc(string ID) {
    var obj;
    TryGetValue(ID, out obj);
    ...   
}

//
// EXAMPLE 2
// alternate and even better, sparse-set from  GDX

static SparseSet sparseSet = ...;
static object[] instances = ..;

private int sparseIndex;
private int denseIndex;

void Awake() {
    sparseSet.AddWithExpandCheck$lt:object$gt:(this, ref instances, out sparseIndex, out denseIndex);
}

...

static void SomeFunc(int sparseIndex) {
    var obj;
    int denseIndex = sparseSet.GetDenseIndexUnchecked(int sparseIndex);
    int index = sparseSet.DenseArray[denseIndex];
    var obj = instances[index];
    ...   
}

Because...

C#'s built in data types are geared towards maximum flexibility and sacrifice performance for it. Especially string-key dictionaries. String-key dictionaries will hash strings every time you use it, which is very slow. Getting the right data-type will not only be faster, but give you catered architecture to best solve your problems.



Time.deltaTime and other static accessors

Tags: Code
Difficulty

Instead of...

Accessing static variables like Time.deltaTime whenever you need them

public class SomeGameplayClass : MonoBehaviour 
{
    void Update() {
        //..
        timer += Time.deltaTime;
        //...
        transform.Rotate(0, 0, degreesPerSecond * Time.deltaTime);
        ///
    }
}

Try instead...

Cache them once per frame in your own static variables before any logic runs for the frame.

[DefaultExecutionOrder(-1000)]
public class MyTime : MonoBehaviour
{
    public static float deltaTime;

    void Update() 
    {
        deltaTime = Time.deltaTime;
    }

    void FixedUpdate() 
    {
        deltaTime = Time.deltaTime;
    }
}

public class SomeGameplayClass : MonoBehaviour 
{
    void Update() {
        //..
        this.timer += MyTime.deltaTime;
        //...
    }
}

Because...

Many static accessors go through the same managed/native interop as Transform does. Many that don’t still involve expensive interactions with the operating system. Many do both.



Check array length once in a for-loop

Tags: Code
Difficulty

Instead of...

Checking a collections length inside the for-loop.

for (... ; i < collection.Count ; …)
{
    …
}

Try instead...

Cache the collections length and use the cached int in the for-loop.

int count = collection.Count;
for (… ; i < count ; …)
{
    …
}

Because...

The compiler doesn't know if you've edited the array, so it politely asks the collection every for-loop what it's length is. This isn't a herculean task, but it takes a little time. And this adds up across your entire code-base!



Update ticketing/queuing

Tags: Code
Difficulty

Instead of...

Doing everything every frame no matter the framerate.

public class AI : MonoBehaviour {
    void FixedUpdate() {
        UpdateAiFSM();
        Move();
    }

    //...
}

Try instead...

Take turns running certain logic. So some things only update every X frames.

[DefaultExecutionOrder(-1000)]
public class UpdateQueue : MonoBehaviour
{
    public static int UpdateQueueTicketNumber;
    public static int UpdateQueueIndex;
    public const int QueueLength = 5; //conditional logic updates every 5th frame

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static int GetQueueTicket() {
        UpdateQueueTicketNumber++;
        UpdateQueueTicketNumber %= QueueLength;
        return UpdateQueueTicketNumber;
    }

    public static bool ShouldUpdate(int ticketNumber) {
        return UpdateQueueIndex == ticketNumber;
    }

    void FixedUpdate() {
        UpdateQueueIndex++;
        UpdateQueueIndex %= QueueLength;
    }
}

//...

public class AI : MonoBehaviour {
    int ticketNumber;

    void Awake() {
        ticketNumber = UpdateQueue.GetQueueTicket();
    }
    
    void FixedUpdate() {
        if (UpdateQueue.ShouldUpdate(ticketNumber))
            UpdateAiFSM();
        Move();
    }

    //...
}

Because...

Some logic, like AI, may not need to "think" every frame. Your slime enemies need to move every frame, but probably only need to "think" every X frames.

A simple ticketing system can allow non-frame dependent logic to run less frequently. This will help distribute your logic across multiple frames.



Transform.position and other extern APIs

Tags: Code
Difficulty

Instead of...

Using Transform.position, Transform.rotation, etc. multiple times per-frame

void Update() 
{
    this.transform.position += Vector3.left * (Input.GetKeyDown(KeyCode.A) ? 1f : 0f);
    this.transform.position += Vector3.right * (Input.GetKeyDown(KeyCode.D) ? 1f : 0f);

    //...
    
    this.transform.rotation = Quaternion.LookDirection(...);
    this.transform.rotation = this.transform.rotation * tiltRotation;

    //...
}

Try instead...

Access any Transform data you need twice per simulation step in an IJobParallelForTransform job: once for reading from Transforms, once for writing to them.

[BurstCompile]
public struct TransformJob : IJobParallelForTransform
{      
    public void Execute(int index, TransformAccess transform)
    {
        Vector3 position = transform.position;
        Quaternion rotation = transform.rotation;

        //
        // Your logic
        //

        transform.position = position;
        transform.rotation = rotation;
    }
}

Because...

Transform access is slow. It involves expensive managed/native interop and struct copying.



Animator events & animator state behaviours

Tags: CodeArt
Difficulty

Instead of...

Using animator StateMachineBehaviour or Animation Events.

Try instead...

Avoid reading from animators at all if possible. If necessary, read animation data explicitly after animations finish for that frame.

void LateUpdate() {
    //...
    AnimatorStateInfo stateInfo = animator.GetCurrentAnimatorStateInfo(0);
    //...
}

Because...

StateMachineBehaviour callbacks and Animation Events lock animation work to the main thread, severely limiting multithreading and creating a bottleneck.

This requires a good amount of architecture and tools for authoring animation events & behaviours.



Good luck out there!

We're rooting for you!