10 Tips: How To Improve a Unity Games Performance to ‘Perfection’ (Noob Friendly)
Imagine: You’ve created a game that you’re so excited to share. You’ve polished the mechanics, you’re confident in its narrative, you’ve nailed a cohesive art-style. You’re feeling nervous, but good. Hitting that publish button, you make it available to an audience wider than your handful of friends, Mum and cat who’s been watching you tirelessly build the game for the past — who knows how long. Your game starts to get played by strangers around the world, it’s a wonderful feeling. Feedback starts rolling in and, with sweat on your brow, you nervously scroll through the comments. Most are positive but there’s a commonality that you can’t escape from: ‘frame drops’, ‘long loading times’, ‘lagging’, ‘unable to run’. You didn’t design this game with performance in mind and it shows.
Yup, you guessed it. That was my exact situation when I created my first game: Perfection! This game reflected my own insecurities around not being able to reach ‘perfection’ and explored what a world could be like where this was assumed to be attainable. The game was generally well received by gamers, YouTubers and even some journalists, gaining praise for its relatable narrative. However, one big problem with Perfection was, ironically, how imperfectly the game ran. Low frame rates bogged down the game and made it difficult for players become fully immersed in its narrative. 3 years later and I’m working as an Associate Technical Designer so I decided to revisit the project as a challenge to see whether I can apply what I’ve learnt over the years to polish this games performance and to have a good chuckle over the many mistakes I made. I’ll share with you how I managed to get the game, which ran at under 20 fps, to 60 fps or more in 3 weeks!
The first thing to do is assess your games performance to find the areas that need improvement. This is where the profiler comes in handy! Window > Analysis > Profiler.
The profiler records multiple areas of your games performance during run time. Pinpointing where your game is loosing performance will save you many headaches, find what aspects of the game you need to improve on and you can then research how in more detail how to do so. (After reading this of course…)
On the left hand side of the profiler there are separate profilers for CPU usage, GPU usage, rendering, memory usage, audio, physics and networking. The top half of the profiler displays data from each profiler overtime. Run the game in editor with the Profiler open and look for spikes in performance. The bottom half of the Profiler window displays detailed information from the currently selected profiler about the currently frame of data. You can inspect script code, and how your game uses certain Assets and resources that might be slowing it down.
2. Multiple Camera Rendering
Be careful when you have multiple cameras in your levels, whether that be cameras for item inspection, being used for reflective textures such as mirrors or for triggered events where you want to have control over the players view. If you’re not keeping on top of how many cameras you have in your scene and when they need to be turned off then you will run the risk of having your level be drawn multiple times which will GREATLY impact performance. I personally had a security camera in my scene that was rendering all of the time.
To optimised this I severely reduced the security cameras frames per seconds manually, having it only render the camera when the specified amount of frames had elapsed within the Update() of a script on the camera itself.
As you can see in the script above, I also put checked in for when the GameObject is active and whether the player IsFacingObject. This is to limit the amount of logic happening in the update (more on optimising scripts later). I made sure to stop the camera rendering completely when the player left the room via a collision trigger which would call .SetActive(false) on the camera GameObject. I also used Dot Product to check for then the player was facing the camera and only rendered the camera while the player was directly looking at the GameObject that it was rendering to (in my case, the TV Screen). I used the Dot Product method to optimise a lot of my environments which proved extremely useful. Learn more about using Dot Products in Unity: https://www.youtube.com/watch?v=8cZo-c-f1yc&ab_channel=WorldofZero
3. Use Layers
You can use Layers for UI, raycasts and to specify what assets of a scene a camera can render. Alongside my security camera I was also using a reflective / refractive material for some water that was part of my terrain. By default it was set up to reflect all layers of the scene which greatly impacted performance. To optimise this I duplicated the objects closest to the water that I wanted to be reflected (rocks, trees, etc) and set them up to be in a new layer that would only be rendered by the water. For every camera I made sure to only render what layers were needed.
4. Optimise Your 3D Models
This is where I struggled as I’m not a modeller and didn’t have access to the modellers who worked on this game.
On the top right bar of your editor there’ll be an option for Stats. Selecting this will show you your current Frame Rate as well as statistics for Audio, Graphics and Network. Under Graphics you can view your Tris and Verts Count. By surveying this you can then hide and show models within your scene to see what parts are making the most difference to the overall count. To my surprise I noticed that small models such as vents and plants had a massive amount of faces — one small plant had 4176 faces and that was used in multiple places throughout the game! There was no need for such miniscule parts of my environment to have such a high poly count so I opened up the .fbx models in Blender and used Decimate to un-subdivide the models to have less faces.
Unfortunately I couldn’t use this technique on most of the larger models in my game which used more complex textures as it would de-align the texture maps, meaning I’d have to go through and UV unwrap them again.
As a rule of thumb it’s always good to use a low poly mesh that will follow all the major shapes and features of the mesh for your games, and then use a high poly mesh which holds the details and texture of the mesh to bake into a normal map so that the detailed appearance is created via normal map instead of its actual geo.
Setting up LODing for meshes will help with this as well, so that when a player is further away then it’s poly count will be very low (perhaps even a billboard) until the player comes closer, when it then automatically switches to a more high poly version.
5. Draw Call Batching
Every time a model is drawn to the screen you have to issue a drawCall to the GPU. Another issue I found was that my meshes were all unique instances and not set up for batching, because, as it states within the Unity manual:
‘Only GameObjects sharing the same Material can be batched together. Therefore, if you want to achieve good batching, you should aim to share Materials among as many different GameObjects as possible.’ — docs.unity3d.com
We had originally built the scene entirely in Maya and exported multiple meshes into one .fbx to save time setting up the environment within Unity. However this meant that every duplicated object within Maya was its own mesh in Unity. This would add to draw calls greatly as they’d be seen as completely different meshes even though they’re identical. To rectify this I made sure to use one instance of the associated mesh and material on all of its duplicates. Unfortunately, it’s not as simple as 1 draw call per model however, lots of other factors come into play such as per pixel lighting, dynamic reflections and vert count.
There are two types of batching:
- Dynamic batching: for small enough Meshes, this transforms their vertices on the CPU, groups many similar vertices together, and draws them all in one go.
- Static batching: combines static (not moving) GameObjects into big Meshes, and renders them in a faster way.
You can enable / disable these in Player Settings > Other Settings. It’s wise to look into both Batching methods to see what would work best for you and your game. Both have limitations and if you’re not careful they can hinder performance more than help it, for example when using Static Batching the entire static mesh group will be rendered if any part of the static group is visible to the camera which could end up in a lot of unneeded vertices!
6. Occlusion Culling
Another issue I ran into with having multiple meshes exported as one .fbx mesh was that occlusion wasn’t as effective because if one mesh of the group was rendered then the rest of them were, even though the player could not directly see them, which added to the overall draw calls. For example, veiw below how all of the drawers are one .fbx, meaning that the one in the hallway is rendering even thought the player is looking at one in a different room, but because they share the same .fbx then they are all rendering. The floor and cieling were also exported as one giant mesh which means that it is constantly being rendered which isn’t ideal but it is very low poly so it’s not as much of a problem.
However, even with these issues that I didn’t have the time / expertise to fix, setting up occlusion properly still helped my overall games performance.
You can set objects to be Ocludee Static / Occluder Static within the Static dropdown of a GameObject.
Occludee: Marks the GameObject as an object that can be culled (I made everything an ocludee).
Occluders: Marks the GameObject as an object that could occlude objects behind it. This was extremely useful to use for large, solid objects such as walls. I used it on my walls to make sure the terrain was occluded behind ones that didn’t have a window.
Occlusion Areas: Use the Occlussion Area Component to define View Volumes in the occlusion culling system where the camera is likely to be at run time. For me this was relatively simple as my game is indoors and very linear so I put my volumes within the buildings corridors and rooms that were accessible to the player. At bake time, Unity generates higher precision data within View Volumes. At runtime, Unity performs higher precision calculations when the Camera’s position is within a View Volume. If you do not specify a View Volume the Unity will create one at bake time that includes all Occludees / Occluders which can lead to slow bake times, large data size and resource-intensive runtime calculations in larger scenes.
Once these are set up you can then bake your Occlusion by going to Window > Rendering > Occlusion Culling. In the Object tab make sure that ‘ALL’ is selected and then you can Bake in the ‘Bake’ tab. Generally the default Bake settings are good but in case you find issues where GameObjects aren’t occlusing as expects then you might need to tweak these values. Read up more about Occlusion here: https://docs.unity3d.com/Manual/occlusion-culling-window.html
7. Light Baking
Baking lights was always something that I struggled to do effectively as my scene changed at run time from day to night, meaning that the lighting changed drastically. In hindsight I should have found a way to load to a new scene when changing from day to night so that I could have two seperate setups of the same scene that could both be performant.
Instead I chose to use both Baked and Mixed lighting modes. Mixed lighting modes combine some realtime and some baked lighting if Baked Global Illumination is enabled in your Scene. Baked lights cannot be changed at runtime, but reduce the cost of rendering shadows as its light is precalculated.
Because I was baking daytime lights onto my scene it was a little brighter than I wanted for when it changed to night time, but luckily I had already set up two seperate post processes for the player to render the day / night versions of the scene so I simply increased the intensity of the post process that I was using for the night version. Another possible option I could look into for further optimisation is to set up my baked lights for both a day and night scene, bake them to create seperate lightmaps and then switch the lightmaps at runtime.
I also used Light Probes to improve the quality of lighting on moving objects in my scene. Light probes are positions in the scene where light is measured during the bake, this information can then be used during runtime to approximate indirect light values from the nearest light probe when it hits a dynamic GameObject.
Light Baking is a very useful and complex tool that I’ll admit to not having fully grasped yet, however this introduction to Lightmaps was very insightful: https://www.youtube.com/watch?v=u5RTVMBWabg&t=440s&ab_channel=Unity
8. Optimising Code
We’re finally at the fun part, optimising our scripts! Of course, every script will be different and there are endless tips on how to optimise C# so I’ll stick to the main points to look out for.
Update(): Every piece of code in the Upate gets run every frame during run time while that script is active, hence why in the security camera example in point 2 I had multiple conditional checks before running any actions within the Update(). Putting code into the Update of a script is a rookie move because it seems very logical at first, if your code is always running then it’s easy to test and make sure that your logic will always trigger. However, when optimising you must consider whether code needs to be ran every frame. If not then think about whether it can be put in its own void function instead and then triggered when it is needed. The more limited the amount of code in the Update(), the more performantly it will run!
For example, I had a script that was checking for certain actions to be done by the player to update an integer variable ‘Observant’. (An integer in non-programmer language means ‘ whole number’). This check was running in a seperate script to the one managing interactions and checking for whether the player was ‘Inspecting’ every Frame. Whenever a player inspected an object this check would return true and that integer would be incremented. (To increment means to +1). To make this more performant I made the ‘Observant’ variable global so it could be easily accessed from a separate script:
I then moved the logic of the increment call within the same void function of the players interacting script. Now every time a player inspected an object they would also update the ‘Observant’ Integer that was in a seperate script. It’s all about breaking down the logic and finding a more direct route to get the same result.
If you need code to run frequently, but not necessarily every frame then you can also use coroutines. I used coroutines for short-lasting events and animations such as lights flickering or fading the screen in between scenes loading. However they have their own draw backs, such as it creating garbage and being more difficult to debug. Read more in depth Update() optimisation tips on this wonderful blog: https://learn.unity.com/tutorial/fixing-performance-problems#5c7f8528edbc2a002053b595
Find.GameObject: I had used this sneaky function a lot when I was starting out with game dev. I thought I was clever for using it as it saved me time so that I could let the game find the objects I needed without having to manually assign everything within the inspector. I ended up using it in most of my scripts and spent a long time reassigning everything manually when it came to optimising the game. I removed this function because it loops through all existing GameObjects and compares their names, trying to find the right one. As you can imagine, this affects performance, especially if it’s being done often throughout the game. When you directly assign them (which you can do by creating a public variable for the GameObject and assigning the correct one to it within the Inspector) you create direct links that are initialised when the scene is loaded, instead of Unity having to find and create those relationships. This will only work with GameObjects that are already in the editor however, so if you’re planning on spawning something during run time then you might need to use this. If you’re unsure whether you’ll need it then just put a check beforehand to see if the reference has already been established
Lastly, turn off scripts when they aren’t needed!
9. Optimise GameObject components
Unity offers many components that define the behaviour of a GameObject, making our lives much easier!
Simply select a GameObject and add a new component in the Inspector window and Tadar- Your GameObject now has physics, UI, collision, or so much more to choose from!
However, some of these components are more performance expensive than others, so make sure you’re using the best ones for your GameObjects purpose.
Colliders: Mesh colliders have a much higher performance overhead than other colliders so they must be used sparingly. It seems like the easiest option as they’ll automatically create collision that perfectly matches your GameObjects geo, however using Mesh Colliders will lead to checking for collisions against potentially every triangular face of the mesh! If you need to create collision that fit certain shapes then see if you can do this by using multiple primitive collisions instead and changing their scale / relative location.
Rigidbody: If you’re anything like me then you’ll get excited at the thought of having simple physics in your game and this is eaily attainable, with no code needed, by adding a Rigidbody component to any GameObject. As you can imagine, too many of these can impact performance, especially if its coupled with a Mesh Collider. In my game I noticed I was using this component on actors that were static due to misunderstanding what was needed for my mechanic. I had items that could be picked up and examined by the player, however this was all handled via scripts and was not using gravity. Removing these Rigidbodies really helped to improve my performance.
10. Adjustable Quality Settings
Give Players option to change the quality settings at runtime so that they have some control over the games performance. This is more simple than you might think. You can rename or add new Quality Settings in Edit > Project Settings > Quality.
After doing this you can simply add a dropdown menu to your game with the same amount of Quality levels as what’s in your project settings and call SetQualityLevel on Dropdown value changed.
This tutorial from Brackey’s was extremely helpful for creating a Settings Menu: https://www.youtube.com/watch?v=YOaYQrN1oYQ&ab_channel=Brackeys
Bonus 11. There WILL be bugs. Don’t panic.
When working with a game that is not made with performance or longevity in mind it will easily get to a point where fixing one bug will create another. When I began improving this project again I was hoping to add lots of new content as well as improve performance and, even though I was able to add a new feature and improve design on simple aspects such as the doors, I ultimately had to abandon the ideas of larger feature inclusions.
I spent three weeks improving the performance and then when I eventually released the game update I then spent another month trying to fix a PC specific, game breaking bug. Every time I thought I had a fix I had to ask someone else to play it on their PC who had the issue and simply cross my fingers, hoping for the best. It was infuriating. But, that’s game development for you. It is a fickle process and it’s anything BUT smooth, but it’s fun and challenging!
If You’ve Reached This Far Then Thank You!
Please check out my game to experience these improvements for yourself: https://yagmanx.itch.io/perfection
There’s always more to learn when it comes to game development but doing exercises like this is really useful to see how far you come. If you have more tips then please do leave them in the comments, let’s help each other become better developers!