How We Optimised Our Scripts in Unity

Ondřej Kofroň
Jul 16 · 15 min read

An article about C# scripts optimisation and best practices to improve performance of a game in Unity engine.


There are a lot of great articles and tutorials focusing on performance in Unity. This article is not trying to replace them or improve them, this is just a summary of steps that we went through after reading these articles and the steps that helped solve our issues. I strongly recommend to go through https://learn.unity.com/ at least.


During the development of our game we ran into issues that caused an occasional lag during the gameplay. After some time spent with the Unity Profiler we found two types of issues:

  • Unoptimised shaders
  • Unoptimised C# scripts

Most of the issues came from the second group, so I decided to focus this article on C# scripting (and maybe also because I have never written a single shader in my entire life). If you want to know more about best practices for writing your shaders just wait for an article that my colleague is about to write soon.

Finding the Weak Spot

The point of this article is not to give a tutorial how to use the profiler, I just want to highlight what we focused on during the profiling.

Unity Profiler is always the best way to go while trying to find the scripts causing your lag. I strongly recommend to profile the game directly on the device instead of profiling in the editor. So because our game is an iOS game I just had to connect the device and use the Build Settings shown in the picture below and the profiler was connected automatically

Build Settings for profiling

If you try to google “Random lag in Unity” or any other similar phrase you can find that most people recommend to focus on Garbage Collection, so I did exactly that. Garbage is generated anytime you stop using some object (instance of a class) then from time to time Unity’s Garbage collector is ran to clear the mess and deallocate the memory which takes insane amount of time causing frame rate to drop.

How to find the scripts causing garbage allocation in the profiler?

Just Select CPU Usage -> Choose Hierarchy view -> Sort by GC Alloc

Profiler settings for GC

Your goal should be to get to all zeros in the GC alloc column in your gameplay scene.

Another good point is to sort the records by “Time ms” (execution time) and optimise the scripts to take as little time as possible. This was a huge thing for us, because one of your components contains a large for-loop that took almost forever to execute (yeah we have not found a way to get rid of the loop, yet) so optimising the execution time for all the scripts was an absolute necessity for us, because we needed to save some execution time for this time-consuming for-loop while maintaining 60 fps.

So based on the profiling, I split the optimisation into two parts :

  • Getting rid of the garbage
  • Lowering down the execution time

Part 1: Fighting the Garbage

This part focuses on what we did to get rid of all the garbage. These are the absolute basics that every developer should know and it also became an important part of our code review with every pull/merge request on daily basis.

1st Rule: No New Objects in Update Methods

Ideally you should have no “new” keywords used in the Update, FixedUpdate or LateUpdate methods. You should always try to use, what you already have.

Sometimes the new object creation is hidden in some Unity’s internal methods so it is not so obvious. We will discuss these later.

2nd Rule: Create Once and Reuse, Reuse and Reuse!

This basically means to allocate everything you can in the Start and Awake methods. The rule is very similar to the 1st one. Actually it’s just an another way of removing “new” keywords from the Update methods.

You should always try to move all code that:

  • creates new instances,
  • finds any game objects,

out of the Update methods and move it to Start or Awake.

Here are examples of changes that we did:

Allocate Lists in the Start method, Clear them when needed and reuse wherever you want.

//Bad codeprivate List<GameObject> objectsList;void Update()
{
objectsList = new List<GameObject>();
objectsList.Add(......)
}
//Better Codeprivate List<GameObject> objectsList;void Start()
{
objectsList = new List<GameObject>();
}
void Update()
{
objectsList.Clear();
objectsList.Add(......)
}

Store references and reuse them like this:

//Bad code
void Update()
{
var levelObstacles = FindObjectsOfType<Obstacle>();
foreach(var obstacle in levelObstacles) { ....... }
}
//Better codeprivate Object[] levelObstacles;void Start()
{
levelObstacles = FindObjectsOfType<Obstacle>();
}
void Update()
{
foreach(var obstacle in levelObstacles) { ....... }
}

The same applies to FindGameObjectsWithTag method or any other method that returns a new array.

3rd Rule: Beware of Strings and Avoid String Concatenation

Strings are horrible when it comes to garbage allocations. Even basic string operations can generate a lot of garbage. Why is that? Strings are just arrays and these arrays are immutable. That means whenever you try to concatenate two strings together a new array is created and the old one becomes garbage. Thankfully you can use StringBuilder to avoid or minimise this garbage allocation.

Here is an example how to improve this:

//Bad code
void Start()
{
text = GetComponent<Text>();
}
void Update()
{
text.text = "Player " + name + " has score " + score.toString();
}
//Better code
void Start()
{
text = GetComponent<Text>();
builder = new StringBuilder(50);
}
void Update()
{
//StringBuilder has overloaded Append method for all types
builder.Length = 0;
builder.Append("Player ");
builder.Append(name);
builder.Append(" has score ");
builder.Append(score);
text.text = builder.ToString();
}

The example shown above is ok, but there is still a lot of space to improve the code. As you can see, almost entire string can be considered as static. So what we did is that we split the string into two parts, into two UI.Text objects. First one containing only the static text “Player “ + name + “ has score “ which can be assigned in the Start method and the second one containing the score value which is updated every frame. Always make static strings really static and generate them in Start or Awake method. With this improvement it is almost ok, but still some garbage is generated by calling Int.ToString(), Float.ToString() etc.

We solved this by generating and pre-allocating all possible strings. It might sound stupid and memory consuming, but it perfectly fits our needs and solve this issue completely. So we ended up with a static array that you can access directly using indices to get the required string representing the number :

public static readonly string[] NUMBERS_THREE_DECIMAL = {
"000", "001", "002", "003", "004", "005", "006",..........

4th Rule: Cache Values Returned by Accessors

This can be very tricky, because even a simple accessor like this one generates Garbage:

//Bad Code
void Update()
{
gameObject.tag;
//or
gameObject.name;
}

Try to avoid using the accessors in the Update method. Call the Accessor only once in the Start method and cache the return value.

In general I recommend to NOT call any String accessors or Array accessors in the Update methods. In most of the cases You only need to get the reference once in the Start method.

Here are two common examples of another unoptimised accessor code:

//Bad Code
void Update()
{
//Allocates new array containing all touches
Input.touches[0];
}
//Better Code
void Update()
{
Input.GetTouch(0);
}
//Bad Code
void Update()
{
//Returns new string(garbage) and compare the two strings
gameObject.Tag == "MyTag";
}
//Better Code
void Update()
{
gameObject.CompareTag("MyTag");
}

5th Rule: Use Non Alloc Functions

For certain Unity functions you can find their alternatives that don’t allocate anything. In our case these functions are all related to Physics. Our collision detection is based on

Physics2D. CircleCast();

For this one specifically it is possible to find a function that does not allocate anything called

Physics2D. CircleCastNonAlloc();

Many other functions have alternatives like this one, so always check the documentation for NonAlloc functions.

6th Rule: Don’t Use LINQ

Just don’t. I mean don’t use it in any code that is executed often. I know the code is easier to read when using LINQ, but in many cases the performance and memory allocation of such code is horrible. Of course it is possible to use it sometimes, but I want to keep this simple and honestly in our game we don’t use LINQ at all.

7th Rule: Create Once and Reuse, Reuse and Reuse vol 2.

This time it is about object pooling. I will not go into details of object pooling because it has been said many times, for example check this tutorial https://learn.unity.com/tutorial/object-pooling

In our case the scenario for object pooling is this one. We have a generated level that is full of obstacles that live only for a certain period of time, until a player passes this level section. These obstacles are instantiated from prefabs when certain conditions are met. The code is in the Update method. This code is absolutely inefficient considering both memory and execution time. We solved this be generating a pool of 40 obstacles and taking these obstacles from the pool when needed and returning the objects back to the pool after they are not needed anymore.

8th Rule: Look out for Boxing!

Boxing generates garbage! But what is boxing? The most common occurrence of boxing is when you pass a value type (int, float, bool etc) into a function that expects a parameter of type Object.

Here is an example of boxing that we needed to solve in our project:

We implemented our own messaging system in the project. Every message can contain an unlimited amount of data. The data were stored in a dictionary that was defined like this

Dictionary<string, object> data;

And we had a setter to set values into this dictionary

public Action SetAttribute(string attribute, object value)
{
data[attribute] = value;
}

The boxing here is pretty obvious. You can call the function like this

SetAttribute("my_int_value", 12);

So the value “12” is boxed and that generates garbage.

We solved this by having separate data containers for each primitive type and the previous Object container is used only for reference types.

Dictionary<string, object> data;
Dictionary<string, bool> dataBool;
Dictionary<string, int> dataInt;
.......

and having separate setters for each data type

SetBoolAttribute(string attribute, bool value)
SetIntAttribute(string attribute, int value)

And all these setters were implemented to call the same generic function

SetAttribute<T>(ref Dictionary<string, T> dict, string attribute, T value)

And the boxing is gone!

To find more details check this article https://docs.microsoft.com/cs-cz/dotnet/csharp/programming-guide/types/boxing-and-unboxing

9th Rule: Loops Are Always Suspicious

This is very similar the first and second rule. Just try to remove all unnecessary code from Loops for both performance and memory allocation reasons.

We try to avoid loops in Update methods in general, but when it is really need we at least avoid any allocation in such loops. So follow again all 1–8 rules and apply this for Loops in general and not just for Update methods.

10th Rule: No Garbage in External libraries

In case you find out that some of the garbage is generated by a code that you downloaded from Asset store you have multiple options how to solve this, but before doing any reverse engineering and debugging just check the Asset store again and update the library. In our case, all assets we used were still maintained by authors and they keep doing performance updates, so this solved all our issues. Keep your dependencies up to date! I would rather get rid of the library instead of keeping an unmaintained one.

Part 2: Pushing the Execution Time to Its Limits

Some of the rules mentioned here make barely noticeable difference if the code is not called often. In our case we have a large Loop that is executed every frame so even these little changes made a significant difference for us.

Some of these changes when used incorrectly or in an inappropriate situation might lead to even worse execution times. Always check the profiler after every single optimisation change in the code to be sure that it is going the desired direction.

Honestly some of these rules lead to a code that is much harder to read and sometimes even breaks coding best practices, for example code inlining mentioned in the rules below.

A lot of these rules overlap with the rules mentioned in the first part of this article. Usually garbage allocating code performs poorly compered to non allocating code. So I recommend to go through the first part of the article before reading this one.

1st Rule: Proper Order of Execution

Move your code from FixedUpdate, Update, LateUpdate methods to Start and Awake methods. I know this sounds crazy but trust me, if you dig deep into your code, you can find hundreds of lines of code that can be moved to one of the methods that are executed only once.

In our case such code was usually related to:

  • GetComponent<> calls
  • Calculations that actually returns same result for every frame
  • Repeatedly instantiating same objects, usually Lists
  • Finding some GameObjects
  • Getting references to Transforms and using other accessors

Here is a list of examples of code, that we moved from Update methods to Start methods:

//There must be a good reason to keep GetComponent in UpdategameObject.GetComponent<LineRenderer>();
gameObject.GetComponent<CircleCollider2D>();
//Examples of calculations returning same result every frameMathf.FloorToInt(Screen.width / 2);var width = 2f * mainCamera.orthographicSize * mainCamera.aspect;var castRadius = circleCollider.radius * transform.lossyScale.x;var halfSize = GetComponent<SpriteRenderer>().bounds.size.x / 2f;//Finding objects
var levelObstacles = FindObjectsOfType<Obstacle>();
var levelCollectibles = FindGameObjectsWithTag("COLLECTIBLE");
//References
objectTransform = gameObject.transform;
mainCamera = Camera.main;

2nd Rule: Run the Code Only When It Is Needed

In our case this was mostly relevant for the scripts that update UI. Here is an example how we changed the implementation of a code that displays current state of “collectibles” in the level.

//Bad codeText text;
GameState gameState;
void Start()
{
gameState = StoreProvider.Get<GameState>();
text = GetComponent<Text>();
}
void Update()
{
text.text = gameState.CollectedCollectibles.ToString();
}

Because we only have few collectibles in each level, it does not make any sense to change the UI text every frame, so instead we change the text only when the actual number changes.

//Better codeText text;
GameState gameState;
int collectiblesCount;
void Start()
{
gameState = StoreProvider.Get<GameState>();
text = GetComponent<Text>();
collectiblesCount = gameState.CollectedCollectibles;
}
void Update()
{
if(collectiblesCount != gameState.CollectedCollectibles) {

//This code is ran only about 5 times each level
collectiblesCount = gameState.CollectedCollectibles;
text.text = collectiblesCount.ToString();
}
}

The code above is much better, especially if the code is more complex than just simple UI change.

If you are looking for a more complex solution I recommend to implement an Observer pattern (https://en.wikipedia.org/wiki/Observer_pattern) using C#’s Events (https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/events/)

Anyway this was still not good enough for us and we wanted to implemented a completely generic solution so we created a library that implements Flux (https://facebook.github.io/flux/) into Unity. This leads to a very simple solution where you have all game state stored in a “Store” object and all UI elements and other components get notified when any state is changed and they react to this change with no code needed in Update method. If you are interested in this solution, my colleague will write an article about this soon, so subscribe or follow us and stay tuned :).

3rd Rule: Loops Are Always Suspicious

This is exactly the same rule as the one mentioned in the first part of this article. If you have any loop in the code iterating over large number of elements always apply all the rules mentioned above in both parts of this article to improve the performance of the loop.

4th Rule: For over Foreach

Foreach loop is so easy to write but “so complex” to execute. Foreach loop internally uses Enumerators to iterate given set of data and to return the value. This is more more complex than just iterating indices in a simple For loop.

So in our project whenever it was possible we changed Foreach loops to For loops like this:

//Bad code
foreach (GameObject obstacle in obstacles)
//Better code
var count = obstacles.Count;
for (int i = 0; i < count; i++) {
obstacles[i];
}

In our case of the large for loop, this change was really significant. The simple for loop resulted in a 2 times faster code.

5th Rule: Arrays over List

In our code, we found out that most of the Lists either have fixed length or we can calculate the maximum number of items. So we reimplemented these using arrays which in some cases led to even 2x faster iteration of the data.

In some cases you can not avoid using Lists or any other complex data structures. The common situation is if you need to add or remove elements often, if this case it is better to use Lists. Anyway in general always use Arrays for fixed size lists.

6th Rule: Float Operations over Vector Operations

This difference is barely noticeable unless you do thousands of operations like this, which was exactly our case, so this performance increase became significant for us.

We did changes like this:

Vector3 pos1 = new Vector3(1,2,3);
Vector3 pos2 = new Vector3(4,5,6);
//Bad code
var pos3 = pos1 + pos2;
//Better code
var pos3 = new Vector3(pos1.x + pos2.x, pos1.y + pos2.y, ......);
Vector3 pos1 = new Vector3(1,2,3);//Bad code
var pos2 = pos1 * 2f;
//Better code
var pos2 = new Vector3(pos1.x * 2f, pos1.y * 2f, ......);

7th Rule: Finding Objects Properly

Always consider if you really need to use GameObject.Find() method. This method is a beast and takes insane amount of time. You should never have such method in any of the Update methods. We found out that most of our Find calls could be replaced by direct reference association in the Editor, which is of course the better way to go.

//Bad CodeGameObject player;void Start()
{
player = GameObject.Find("PLAYER");
}
//Better Code//Assign the reference to the player object in editor
[SerializeField]
GameObject player;
void Start()
{
}

In case you can not do it like this you should at least consider using Tags and finding the object by its Tag using GameObject.FindWithTag.

So in general: Direct Reference > GameObject.FindWithTag() > GameObject.Find()

8th Rule: Work Only with Relevant Objects

In our case this was significant for collision detection using RayCasts (CircleCasts etc) . Instead of detecting all collisions and making decision which ones are relevant in code, we moved our game objects to proper layers so we can calculate collisions only on relevant objects.

Here is an example

//Bad Codevoid DetectCollision()
{
var count = Physics2D.CircleCastNonAlloc(
position, radius, direction, results, distance);
for (int i = 0; i < count; i++) {
var obj = results[i].collider.transform.gameObject;
if(obj.CompareTag("FOO")) {
ProcessCollision(results[i]);
}
}
}
//Better Code
//We added all objects with tag FOO into the same layer
void DetectCollision()
{
//8 is number of the desired layer
var mask = 1 << 8;
var count = Physics2D.CircleCastNonAlloc(
position, radius, direction, results, distance, mask);
for (int i = 0; i < count; i++) {
ProcessCollision(results[i]);
}
}

9th Rule: Use Tags Properly

There are no doubts that tags are very useful and can improve performance of your code, but keep in mind that there is only one correct way of comparing object tags!

//Bad Code
gameObject.Tag == "MyTag";
//Better Code
gameObject.CompareTag("MyTag");

10th Rule: Beware of Tricky Camera!

It is so simple to use Camera.main, but the performance of such action is really bad. The reason is that behind each Camera.main call Unity actually does FindGameObjectsWithTag() to get the result, so we already know that it is not a good idea to call this frequently and the best way to solve this is to cache the reference in the Start or Awake method.

//Bad codevoid Update()
{
Camera.main.orthographicSize //Some operation with camera
}
//Better Codeprivate Camera cam;void Start()
{
cam = Camera.main;
}
void Update()
{
cam.orthographicSize //Some operation with camera
}

11th Rule: LocalPosition over Position

Use Transform.LocalPosition everywhere you can instead of Transform.Position for both getters and setters. The reason is that there are much more operations executed behind each Transform.Position call, specifically calculating the global position in case of calling a getter or calculating local position from the global one in case of calling a setter. In our case we found out that we could use LocalPositions in 99 percents of occurrences of Transform.Position with no other changes needed to be done in the code.

12th Rule: Don’t Use LINQ

Already discussed in the first part. Just don’t use it, that’s it.

13th Rule: Don’t Be Afraid to Break Best Practices (sometimes)

Sometimes even a simple function call can be too expensive. In this case, you should always consider Code Inlining. What that means? Basically it means you just take the code from the function and copy the code directly to the place where you wanted to use the function to avoid any additional method calls.

In most of the cases this will not make any difference because the code inlining is done automatically at compile time, but there are certain rules by which the compiler decided whether the code will be inlined or not (for example Virtual methods are never inlined, for more details check https://docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity8.html). So just open profiler, run the game on the actual device and see if there is any space for improvement.

In our case, we found a few functions that we decided to inline for better performance, especially in the large for-loop that we have in the game.

Conclusion

By applying the rules mentioned in the article we easily managed to get a stable 60 fps in an iOS game even on an iPhone 5S. Some of these rules might be too specific for our use case, but I still think that most of them should be in your mind while coding or doing code reviews to avoid any problems in later stages. It is always easier to continuously write code with performance aspects in mind than refactoring large chunks of the code later.

We are Lonely Vertex, a small indie game studio located in Prague, Czech Republic. Currently getting ready to release our first game, Sine. You can subscribe for newsletter, read our development blog posts or follow our progress on Twitter or Facebook.

Lonely Vertex Development

Lonely Vertex is small indie studio. We are using medium to share posts and topics related to game development. You might see some tutorials as well as game previews and thoughts behind development of our games.

Thanks to Jan Slifka

Ondřej Kofroň

Written by

Lonely Vertex Development

Lonely Vertex is small indie studio. We are using medium to share posts and topics related to game development. You might see some tutorials as well as game previews and thoughts behind development of our games.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade