Years ago, I went to the asset store and, credit card at hand, I got greedy. I couldn't stop buying good-looking assets at highly discounted price, so I kept stacking them in the cart. More content would be a great fit for my game, I thought. Only that I didn't really have solid foundations for proper Unity memory management techniques. And so I paid the price for my ignorance.
I don't really know what bothered me most...
Was it that I spent over $200 on the asset store?
Or was it the fact that I ended up using just a quarter the content I bought?
It doesn't matter... I learned the lesson.
I experienced what happens if your game is not ready to scale and you trade dollars for extra content.
Today's post is about avoiding common pitfalls that won't let your game scale the way you want. No matter what, it's important to have the feeling we can keep adding content without excluding players with legacy devices.
Now, let me mention something first.
Every game is different, i.e. each game has different bottlenecks at different points.
The following suggestions are guidelines I've found especially helpful when coaching other game developers. At the end of the day, it is your call to decide which ones to adopt.
So no mater what...
Profile, profile, profile (hint: ctrl + 9).
Avoid Direct References to Memory-Hungry Content
Are you still using direct references in Unity?
I'm joking. That's fine.
Direct references are those links you make between components and content. A few examples: a Material directly referencing an albedo Texture, an AudioSource linking to an AudioClip, etc...
Every time you link a field to some content, that content is whole loaded in memory when the component shows up in your scene (with some possible exceptions such as videos and audio clips with certain import settings).
The trick is to not abuse direct references to content that is hungry on memory and you infrequently use. Let's see a few examples.
Case A: Global References
Let me explain this with an image.
Did you catch that?
That's the memory profiler in detailed mode. And its pretty upset.
Because guess what it found?
That profiler shows a global object (SkyboxManager) containing direct references to heavy skyboxes, even though only a single one is used at a time.
As my grandmother used to say, what a waste of RAM.
Global objects are permanent and remain alive throughout the entire game, i.e. they do not cease to exist after scene transitions. Usually they invoke DontDestroyOnLoad on themselves and are commonly called Singletons.
This SkyboxManager is always present, pestering memory by keeping a list of direct references to big skybox textures. This adds a considerable overhead with no little to no benefits.
How many skybox textures are we using in that scene at any given time? One.
And how many are we paying for with RAM currency? Six.
Then why the heck would we keep them loaded? Is a lower latency worth the high price we're paying?
Think about it. More memory waste has nasty consequences:
That's a worse deal than Unity increasing their prices back in 2019 (I didn't say that).
Sure, your game might not do this with skyboxes...
But it might with other content type. Characters, particle systems, sounds, visual effects, AudioManager, you name it. I've seen this so often.
Does your game do this?
Ask the profiler and don't be afraid of the utterly uncomfortable answer.
The solution to this? Use indirect references (check out my Unity Addressables Tutorial)
But wait, of course there is more to this...
Case B: Scene References
Normally, you will be better off by having direct references in your local scene rather than as global objects.
And that's much better, yeah.
The problem is, we might still pay way too much for way too little.
Let me explain.
You have a huge city in your game. In a small chest on the second floor of a bakery, you're storing your most precious slices of bread. So there you put a tiny particle system rendering high-quality god-rays. How else could you convey the mightiness of bread slices?
Since you'll be looking at it from up close, that particle system uses a huge sprite sheet animation. High resolution, high frame-rate spreadsheet that takes about 32MB of RAM.
Tell me, how often will you be staring at that chest? Like 0.01% of the time you spend in the city? Paying 32MB of RAM for that sounds like a steal to me, just like that time I spent over $200 in the asset store to just use a quarter of it.
The solution? Decrease the quality of the texture Use indirect references instead (check out my Unity Addressables Tutorial). Load the particle system when the player comes in the bakery, unload it when they leave.
And problem solved.
Do not decrease the quality of your game, really. Implement instead a solid memory management strategy. Your player will reward you for this with love (and fresh dollars).
Audio: (Maybe) Use Streaming
Audio can take a big chunk of your memory if you don't use the right import settings.
The worst case for your memory is parameters like decompress on load and disabling load in background. That will make sure that the audio is always present sucking as much memory as it can.
But of course, those were the best settings to improve the CPU performance of your game.
If you're trying to reduce memory usage, the best combination is to set longer clips to streaming and to enable load in background. Only small chunks of your audio clips will be in memory at a time.
It's a matter of deciding where to pay the price.
Surely, don't forget to set the audio clip compression settings to something that makes sense for your game's performance budget (hint: vorbis is great).
Textures: Tweak Import Settings
Review your texture import settings.
One of the most important, well-known but often ignored parameters is texture compression. For instance, in Android you should be using nowadays something like ASTC 6x6. Although this absolutely depends on your target audience. If you plan on selling to users with slightly old phones, you might be better off with ETC2.
You'll need to do your research here. Check which devices support what compression formats and decide whom are you excluding: legacy devices or quality (hint: indirect references help you maintaining asset quality).
Enable mipmaps only for textures that will really benefit from it. Generally speaking, mipmapping will make your textures look way better for specific scenarios and will also increase GPU performance, but it has a 33% memory overhead.
Lastly, set the maximum texture size to a level that is balances size and perceived visual quality.
Consider enforcing an asset post processing policy to standardize your texture import settings. Coherence is important in a project. One day you wake up too tired, set the wrong import settings and the day after you've got a mob of angry players burning your office.
Mesh and Animation: Compression
Depending on your project (as always), your biggest consumption could actually come from meshes and animation clips. If memory is an issue there, consider playing around with their well-hidden compression settings.
Compressing the mesh will deteriorate its quality, but you'll get good savings on memory. With four compression levels you cannot do wrong (none, low, medium, high). Try them out.
You can also compress animation clips. By doing that, you will reduce their accuracy because Unity will strip out animation keys. Your character might rotate or move in a weird way.
Reach a compromise between memory and quality. It'll be easier to negotiate this with a computer than negotiating your salary raise with your boss if you cannot meet quality expectations.
No matter what, favor using indirect references over decreasing quality.
Static Batching: Take It Easy
Static batching is great.
But it can also be a great pain in the neck.
As I explained in the Ultimate Unity Batching Guide for Unity 2020+, static batching can skyrocket your frame-rate but also your memory usage in certain conditions.
If you have 1000 identical static stones and each mesh needs 1MB, your static batches can well end up robbing you 1000MB of RAM. Without static batching, it'd take about 1MB because 1000 stones would still share a single mesh. The batch, however, contains the stone's mesh 1000 times.
However, if your 1000 stones had different meshes, then your memory usage with static batching would remain mostly the same. The individual stones couldn't re-use the mesh memory anyway, so merging them into a single batch will be no different.
For cases where static batching won't make you happy, try GPU instancing. You'll pay a higher CPU toll, but it's always about your use case. Check my ultimate guide on Unity Draw Call Batching for 2020 for more details.
Let's keep adding more tools to your shelf. You never know when you'll need them.
Shaders: Contain Them
It's easy to stop paying attention to shaders.
The thing is, assets from the asset store usually come with surprise.
It's not uncommon to see tons of custom shaders with 3rd party content. If these accumulate (and they will), you'll have a big problem in your hands: too many shader variants.
I've seen games with over 100MB of RAM eaten by shaders. That's 100MB less of real content you can have or 5% increased chances of crashes (magic number TM).
The solution relies on:
Enable shader compilation logs to check what's going on in your device. It shocked me to see the endless output.
Maybe you will be too.
Garbage Collection: Careful There
I've seen many games where the heap grows faster than weed in Amsterdam. And as a habitant of The Netherlands, I can tell you that's pretty fast.
The heap is the chunk of memory where your game stores most of your programming variables (classes mostly). The heap naturally increases or decreases when things happen in your game.
If your heap exceeds its current maximum, then one of two things will happen:
- Most likely: Unity extends the reserved heap so the new data fits.
- Unlikely but often seen: your game crashes.
This is the key: the reserved heap can only grow.
If you aren't careful with your memory allocations, you won't make the life of your operating system easy. And as the saying says, you should always be nice to the cook serving the soup.
Do you know the only way you get that memory back?
You'll get your memory back in two cases:
- When your game runs out of memory and crashes, losing your players' savegame.
- Or when the user closes your game because of lag.
In other words, be careful on the amount of data you allocate in your scripts.
There's no conclusion.
Just play around, have fun and Always Be Profiling (ABP).
With enough effort, you can have it all: CPU, GPU and memory performance. But we never have enough time/budget to get it right, so we end up trading performance hits.
And that's fine. It is all about balancing resources.
The biggest problem is to have bottlenecks is in all three areas: CPU, GPU, Memory.
But even then, don't despair.
When I was porting Star Trek Bridge Crew from PS4 to Oculus Quest, I was facing bottlenecks in every subsystem.
The truth is, there's always a way to make small gains in every corner. Stacking small gains will always get you there (over time).
With great effort you can manage to free up memory space. With free memory to work with, you can then add static batching to reduce the CPU load. And with more free CPU resources, you can then add occlusion culling to reduce GPU load.
And so is the chain.
Wherever you are, I'm sure you can pull it off. Just keep pushing. Be resilient.
(But if you can't, Better Call Ruben)
P.S. Let's meet up if you're attending PG Connects London 2020. Drop me an e-mail at [email protected]