How to Improve the Performance of Your Unity Game
Introduction
One of the biggest challenges in video game development is optimizing the game to run smoothly on a wide range of devices. This becomes even more critical when developing games on mobile phones, where resources are scarce. In this blog, we will share simple tips and techniques to improve the performance of your Unity game. The techniques outlined here are applicable to game development in general, so keep reading even if your game is not built with the Unity engine.
Texture Optimizations
In this section, we will explore ways to enhance performance by optimizing textures.
Use a Texture Atlas
Typically, in the Unity engine every texture requires a separate draw call. So if you have 4 textured objects in your scene, the engine will make 4 draw calls to render them. Every draw call imposes a performance hit. Texture atlases combine all textures in one big texture, allowing the Unity engine to render multiple objects with one draw call.
Imagine you have four 128×128 textures with the following colors: red, green, blue and yellow as shown below. On the left, you see regular separate textures, on the right you see the texture atlas containing all the individual textures in one big texture.
How to Create a Texture Atlas
Follow these steps to create a texture atlas in the Unity engine:
- Create a folder in your assets and put all the textures you want to include in the atlas inside this folder.
- Right click in the project window and select Create > 2D > Sprite Atlas.
- Drag and drop your textures to the Objects for Packing section.
- Set the Max Texture Size value located above the Objects for Packing section. For the example above we set it to 512. We don’t set it to 256 because the atlas adds a padding between the textures, so we set it to the next biggest size which is 512.
- Click the Pack Preview button at the bottom right of the inspector. This will create the atlas. Now you should see a packed preview of your textures in the preview window as shown below.
- To use the atlas, simply assign the individual texture to your sprite as you would normally do without an atlas. The Unity engine will automatically detect that this texture exists in a texture atlas and it will pick it from there.
To verify if your atlas is making a difference, check the Batches number in the Statistics window. This number shows the amount of draw calls being made. Click on the Stats button to open the Statistics window:
You should notice that the Batches number is lower than it was before you used the sprite atlas. You can see below how in our sample project the number of draw calls went down from 4 to 1:
Reduce the Max Size of Sprites
If you click on your texture in the Unity engine you will see that the Max Size value is by default set to 2048:
This level of detail might be overkill for some objects. For instance, if you have objects that are not the primary focus or you have objects in the background, then you don’t need them to be very detailed and can afford losing some visual quality since the player probably won’t notice. Set this value to the lowest size possible that still provides satisfactory visual quality.
Limit Texture Size
Choose the appropriate size of the texture for your objects depending on their size and significance within the scene. For example, if your game features an office environment with post-it notes on the monitors, there’s no need for these notes to be high resolution. They are small objects that only serve as decoration and don’t have a purpose. Giving each note a 1024×1024 texture is excessive. The same reasoning applies to objects located far in the background that the player can’t interact with. Keep the high resolution textures for objects that are close to the player’s view and are visible most of the time.
PNG vs JPEG
Choosing the format of your texture can also affect performance. PNGs offer higher visual quality than JPEGs because PNGs use lossless compression while JPEGs use lossy compression. To keep things simple, PNGs lose less quality during compression than JPEGs. But the downside is that PNGs can increase load times and their sizes are usually larger. PNGs are used when more details and sharper edges are desired. Moreover, PNGs support transparency while JPEGs don’t so any image that needs an alpha channel will be a PNG. If you need none of these things, then you can use a JPEG which will save you some memory and reduce load times.
Shader Optimizations
In this section, we will cover what optimizations you can apply to your custom shaders.
Simplify Shaders
Not all the objects in your game need complicated shaders with intricate lighting or effects. For instance, you User Interface (UI) elements or background objects. These objects don’t interact with lighting so they should be assigned very simple shaders with no heavy lighting computations.
Also, you should always research on the internet if there’s an alternative way of achieving your desired shader effects using simpler, and less computation-heavy ways. You might be surprised by the clever techniques available to replicate visual effects without resorting to complex calculations. For example, if you need to generate a random number inside your shader, you could sample a small noise texture instead of using a function. In fact, many shader effects are achieved only by sampling textures.
Optimize Shader Precision
In the Unity engine there are multiple shader precisions that you can use to store data:
- float (High precision)
- half (Medium precision)
- fixed (Low precision)
- double (Double precision)
One technique that we found very effective in improving our games’ performance is choosing carefully the precision of our shader variables. Always try to use the lowest level of precision that you need to accomplish your task. For instance, if you are doing calculations on colors and you know that your color components (RGBA) will never go above 1, then you can use a fixed4 color instead of half4 or float4. If you are working with short vectors or High Dynamic Range (HDR) colors, then half4 should be enough and so on.
Minimize Fragment Shader Computations
Vertex shaders perform calculations for every vertex while fragment shaders for every pixel. As such, fragment shaders run more frequently than vertex shaders. If possible, try to move calculations from the fragment to the vertex shader. For example, if you can calculate texture coordinates at the vertex level then you can do so in the vertex shader and pass this information to the fragment shader.
Another thing to watch out for is texture sampling, i.e calling:
tex2D(_MainTex, i.uv);
Try to minimize texture sampling in the fragment shader. Review your fragment shader and see if there are too many sampling calls or if some are redundant. Sampling can often be a bottleneck in rendering, as it is computationally intensive and executes for every pixel.
Graphics Optimizations
In this section, we will explore ways to enhance performance by optimizing graphics.
Set Stationary Objects as Static
If you have stationary objects in your scene such as buildings, terrain, furniture and so on, select them and check the Static checkbox:
The Unity engine can combine objects set as Static into a single mesh during rendering, reducing the number of draw calls. Keep in mind that once an object is marked as static, it should not be moved, rotated, or scaled during gameplay to avoid disturbing the performance optimizations.
Use Baked Lighting
If you have lights in your scene, you can use baked lighting to reduce real-time lighting computations. Baked lighting pre-calculates how lights interact with your scene and stores this information in textures called lightmaps. This way, the engine will sample light shading directly from the lightmaps rather than performing light calculations on the fly, significantly reducing overhead and improving performance. Note that baked lighting only applies to objects that are set as static. This is because when objects move, their shading and lighting change as well, requiring real-time calculations to update their shading. Additionally, shadows change as objects move, which requires real-time light calculations as well.
How to Bake Lighting in the Unity Engine
Follow these steps to bake the lighting:
- Check the Static checkbox to all the objects in your scene that don’t move.
- Select your lights and set the Mode to Baked.
- On the top menu bar, click Window > Rendering > Lighting.
- Click the Generate Lighting button and wait for it to finish.
- You will find the lightmaps in the folder Scenes > <your scene name>.
- Now when you run the game, the lights set to Bake mode won’t compute lighting in real-time and you should see improvements in your game’s performance.
Limit the Number of Lights
If you can’t use baked lighting or you have too many lights in your scene, consider getting rid of some of them. You don’t need tons of lights to achieve good lighting. We’ve already discussed baked lighting, but there are other techniques that you can use to enhance visuals without using lights such as ambient occlusion, post-processing effects, colors, materials and more.
Use Blob Shadows
Shadows are computationally expensive and can drastically hurt performance, especially on mobile devices. Blob shadows work by simply projecting a texture into the ground below the object, giving the impression of a shadow. This significantly improves performance compared to real-time light shadows. The shape of the shadow is usually a simple circle or oval, so it’s more convenient for cartoony or low resolution games. Here’s an example of a blob shadow, notice the circular texture projected on the ground:
How to Implement a Blob Shadow in the Unity Engine
Note
Unfortunately the StandardAssets package, which in the past contained the Projector component, is no longer available in the newer Unity engine versions. So you will have to either implement it yourself or find a free asset that you can use.
Here are the steps to add a blob shadow to you object:
- Create an Empty GameObject.
- Attach a projector component to it and give it a texture of your liking. You should rotate your empty object such that it projects the texture on the ground.
- Locate the object you want to add a shadow to in your Hierarchy, then drag the blob shadow GameObject you created and make it a child of the object. Now the projector will project a texture on the ground and follow the parent object around when it moves.
Physics Optimizations
In this section we will highlight some tips related to physics calculations that can improve the performance of your game.
Use Simple Colliders
Use simple collider shapes such as boxes, spheres and capsules and avoid mesh colliders when possible. Primitive shapes are much less computationally expensive than complex shapes. For example, if you have a car object in your game, you can assign it a simple box collider. For your character, a capsule collider is a good choice.
Remove Unnecessary Colliders
If you have an object in your game that doesn’t collide with anything, like a ghost that passes through walls or a UI element, remove its collider. This will help reduce unnecessary collision checks and improve performance. Same thing applies to objects that are unreachable, such as a sun or moon in the distance.
Remove Unnecessary Rigidbodies
If you have objects that don’t need physics interactions such as UI elements, remove their Rigidbody components to reduce physics calculations.
Script Optimizations
In this section we will go through some optimization tips that you can use in your C# scripts to improve performance.
Limit Computations in Update()
The Update() function runs every frame, so it’s best to avoid heavy calculations within it. See if your calculations in Update() can be pre-calculated in Start() or Awake() and stored in a member variable that you can use in Update().
You should never call GameObject.Find() or GetComponent() inside Update(), always call these two functions in Awake() or Start() and store their return value in a member variable so you can access them in Update() if required.
A common oversight is forgetting Debug.Log() calls in Update(), or having a function called in Update() that down the line uses Debug.Log() itself. Developers always use debug and logging calls during development which is fine but it’s easy to forget them and eventually they might make it to a release build. Do a global text search for Debug.Log in your IDE and make sure that no log statements are being called every frame.
Avoid Frequent Calls to Instantiate() and Destroy()
Sometimes you need to repeatedly instantiate and destroy objects. Imagine a game where the player fights a horde of enemies. One way to implement this is to call Instantiate() everytime you want to create a new GameObject representing the enemy and call Destroy() on it when the player defeats it. If you have lots of enemies, this will quickly slow down your game because Instantiate() and Destroy() involve memory allocation and deallocation. To mitigate this you can create a list of enemy objects and change their active state:
public List<GameObject> enemies;
private void SpawnEnemy()
{
int spawnIndex = -1;
for(int i = 0; i < enemies.Count; i++)
{
if(enemies[i].activeSelf == false)
{
spawnIndex = i;
break;
}
}
if(spawnIndex > -1)
{
enemies[spawnIndex].SetActive(true);
enemies[spawnIndex].transform.position = Vector3.zero;
}
}
private void KillEnemy(int index)
{
enemies[index].SetActive(false);
}
In this code, the enemies list is populated from the inspector. You can also instantiate it inside Awake() or Start(). The SpawnEnemy() function simply looks for any enemy GameObject that is inactive and activates it then sets its position. Of course you can further optimize this by removing the for loop however, the point of this code is just to give you a general idea. The KillEnemy() function de-activates the GameObject so that it can be re-spawned later. As you can see, no Instantiate() or Destroy() calls are made during spawning and killing the enemy GameObject, which can significantly improve performance.
Avoid Nested Loops
If somewhere in your code you have nested loops such as the one below, try to see if you can restructure it to use one loop.
for(int i = 0; i < x; i++)
{
for (int j = 0; j < y; j++)
{
Compute();
}
}
Use Efficient Data Structures
In some cases switching data structures can significantly improve performance. For example, if you have a list of GameObjects and you want to search for one, you’ll have to loop through the list:
public List<GameObject> gameObjects;
private GameObject Search(string name)
{
for (int i = 0; i < gameObjects.Count; i++)
{
if (gameObjects[i].name == name)
{
return gameObjects[i];
}
}
return null;
}
If you have a long list of game objects, this could slow down your game. You can however access the GameObject you are searching for directly with one step if you convert the list to a Dictionary:
public Dictionary<string, GameObject> gameObjects;
private GameObject Search(string name)
{
if (gameObjects.ContainsKey(name))
{
return gameObjects[name];
}
return null;
}
Audio Optimizations
Below are several ways to optimize audio in your Unity game.
Use Compressed Audio Formats
The WAV audio file format is uncompressed while MP3 files are compressed. Only use WAV files if you need high audio fidelity to produce crisp and clear sounds. Otherwise, MP3 files are sufficient, they require less storage and use less memory.
Set the Audio Files Settings
If you click on your audio file, you will see two settings named Force to Mono and Load in Background:
If your sound file is a mono sound, then you can check the Force to Mono checkbox. This may improve performance as mono sounds require less processing power. Load in Background allows audio clips to be loaded asynchronously in the background while the game continues to run. This is useful for loading long audio clips that don’t need to be loaded immediately such as background music, resulting in less lag when the music is being loaded.
Use the Profiler
The last tip for this blog is using the Unity engine profiler. Most of the time when your game is running in low FPS, you don’t really know what is causing the slowdown. It could be one of your shaders, one of your scripts or something else. This is where the profiler comes in. In order to see the profiler, click the Profiler tab at the bottom menu:
Now if you click at some part of the graph, like a peak for example, the game will pause and you can analyze what’s causing this slowdown. At the bottom section, click the Time ms header once or twice to sort the rows by frame duration in descending order and identify what is taking the longest to compute. Here it seems like the Render.OpaqueGeometry is taking the longest to compute. In our example it’s taking 0.02 ms because we have an empty scene with only a few boxes for illustration purposes. For you however, you may find this value to be much higher. This could indicate that the shaders used by the objects in the scene are too computationally heavy and might need optimization, or that you have too many objects in the scene and it’s taking too long to render them all.
Conclusion
You are now familiar with some of the techniques that can be used to improve the performance of your Unity game. There are more advanced techniques that we haven’t covered here because we wanted to give you efficient tips that are relatively easy to implement but still very effective. We suggest you start with these simple tips, they are mostly sufficient to increase the FPS of your game. If you see that you are still facing performance issues, you can consider trying more advanced techniques.
We Need Your Help!
Help us increase our apps’ visibility in the app stores. Check out our apps and if there’s anything you like, download it on your phone and try it out. Don’t forget to leave us a review. We appreciate your support, it truly makes a difference!