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.
Curator + Editor: Tyler Tomaseski
Optimization Contributors:
Scott Robertson, Liz Gravis, & Tyler Tomaseski
The (unofficial) Unity Performance Field Guide is not officially supported or sponsored by Unity in any way.
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!
Setting and getting values from animators with strings.
void Update()
{
Animator.SetFloat("PropertyName", ...)
}
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, ...)
}
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.
Spawning all fx.
void SpawnFX() {
GameObject.Instantiate(/*...*/);
}
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(/*...*/);
}
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.
Leaving empty Unity callback methods laying around.
void Start() {
}
void Update() {
}
Deleting them!
//JohnTravoltaMeme.gif
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!
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() {}
}
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() {}
}
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.TMP_Base.SetText() every frame
TMP_Text textComponent;
void Update() {
this.textComponent.SetText((int)Time.time);
}
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);
}
}
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.
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
Keep Transform hierarchies as flat as possible
//Unity Transform Hierarcy
> Gameobject Category Container //just sits above relevant hierarchies
> Regional Offset
> Crate
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.
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];
...
}
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];
...
}
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.
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);
///
}
}
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;
//...
}
}
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.
Checking a collections length inside the for-loop.
for (... ; i < collection.Count ; …)
{
…
}
Cache the collections length and use the cached int in the for-loop.
int count = collection.Count;
for (… ; i < count ; …)
{
…
}
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!
Doing everything every frame no matter the framerate.
public class AI : MonoBehaviour {
void FixedUpdate() {
UpdateAiFSM();
Move();
}
//...
}
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();
}
//...
}
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.
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;
//...
}
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;
}
}
Transform access is slow. It involves expensive managed/native interop and struct copying.
Using animator StateMachineBehaviour or Animation Events.
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);
//...
}
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.