Notifications
Article
Game Development and Garbage Collection
Published 5 months ago
62
0
Fixing Spikes with the Landscape Builder Team
If you're not aware of it, Garbage Collection in C# can have a big impact on game performance.
Essentially, whenever a variable goes out of scope or becomes null after being set, it is left in heap memory waiting to be correctly returned as available memory. Ok, so that's not a great description but you can Google or Bing "C# Garbage Collection" to get all the facts.
You might ask, "So, why should I care about GC?" And that would be a good question.
For your game, GC is like a hit or a spike that really saps your game performance. If you can see this kind of stop-start, slow-fast effect happening and your frame rate is all over the place then keep reading. If you are seeing this, then GC may be the issue or at least a contributor.
GC isn't unique to C#, it’s been around for the last 25 years that I can remember in various languages. You need to either manually allocate and free memory (as the programmer), or you let some background system do it for you (which is what happens in C#).
In Landscape Builder 2, we've been doing lots of performance testing to identify any GC issues at runtime. In the editor, or during once-off scene building, GC is less of a problem. Yes, you get a big hit, but then you should already have a strategy to deal with loading "stuff" at the beginning of gameplay or during a scene change or reset.
Although performance analysis and remediation are complex subjects there are a few basic rules that anyone can follow:
  1. Sort issues by order of magnitude and deal with biggest ones first
  2. Reproduce the problem with and without the “fix” to confirm the “fix” has a positive impact on performance (and doesn’t break anything else)
  3. Repeat steps 1 and 2 until you achieve your performance goal then stop
Sounds simple but over the years I’ve seen people have a hard time sticking to the rules. First, you need a way of sorting the data and deciding what things need attention in what order. Unity has the Profiler which also has this very useful button called “Deep Profile” which will let you find out exactly which method is causing you grief. Thank you, Unity team!
By default, Unity’s profiler will sort by duration (Time ms column) which will mostly satisfy our rule #1. We can also sort by “GC Alloc” which might help us too. When looking for CPU scripting issues I tend to turn off the “Rendering” data on the left in profiler. Part of determining the noisiest method or biggest issue is to remove some of the back ground “noise”, which is this case is the “Rendering” data. Rendering is certainly important but here we’re just trying to deal with GC issues in our code.
Here is an example of a racing game we have been testing with Landscape Builder. Although not a LB issue per se, it nicely demonstrates what can go horribly wrong when certain events happen all at the same time in a game.
The interesting thing about GC, is that all unnecessary GC will eventually lead to spikes or slow-downs in your game. So, sorting by GC Alloc in the profiler and fixing them all, if possible, is a good thing. In the following example we can see two small issues with LBLighting.
Although small, we are essentially leaking memory every frame. Not leaking in terms of creating orphaned memory locations that cannot be recovered, but “leaking” memory that the Garbage Collector will need to recover.
ParticleSystem[] weatherParticles = weatherCamera.GetComponentsInChildren<ParticleSystem>();
Although not generally recommended, sometimes you might need to run GetComponentsInChildren every frame, which will lead to garbage allocation. To avoid this, Unity has provided a variant that lets you supply a pre-sized list.
// Declare this outside Update (initialise to the expect maximum number of items) List<ParticleSystem> particleSystemList = new List<ParticleSystem>(5); // Inside loop or update weatherCamera.GetComponentsInChildren(particleSystemList); numWeatherParticleSystems = (particleSystemList == null ? 0 : particleSystemList.Count);
Here is some more innocent looking code from LBLighting.
_Tag = weatherParticle.gameObject.tag; if (_Tag == rainParticleTag) { _currentParticleStrength = currentRainStrength * 500f; _currentParticleMaxWindEffect = rainMaxWindEffect; } else if (_Tag == hailParticleTag) { _currentParticleStrength = currentHailStrength * 500f; _currentParticleMaxWindEffect = hailMaxWindEffect; } else if (_Tag == snowParticleTag) { _currentParticleStrength = currentSnowStrength * 500f; _currentParticleMaxWindEffect = snowMaxWindEffect; }
Unfortunately, this was “leaking” 240 bytes of “garbage” every frame. Again, Unity have thought about this scenario and provided a GC-friendly function. Here is the fix. Notice we are also recycling the gameobject.
weatherParticleGameObject = weatherParticle.gameObject; // Use CompareTag to eliminate garbage collection if (weatherParticleGameObject.CompareTag(rainParticleTag)) { _currentParticleStrength = currentRainStrength * 500f; _currentParticleMaxWindEffect = rainMaxWindEffect; } else if (weatherParticleGameObject.CompareTag(hailParticleTag)) { _currentParticleStrength = currentHailStrength * 500f; _currentParticleMaxWindEffect = hailMaxWindEffect; } else if (weatherParticleGameObject.CompareTag(snowParticleTag)) { _currentParticleStrength = currentSnowStrength * 500f; _currentParticleMaxWindEffect = snowMaxWindEffect; }
Sometimes Unity generates a lot of garbage with no clear way of avoiding it. An example is retrieving tree data from terrains. Being able to pass Unity a pre-sized list would be nice...
If you’ve been paying attention to my Rule #1, you will have noticed that there is a serious issue in the profiler (not related to GC), just above our trees example.
We have 31993 implicit conversions from Color to Color32. More alarmingly, it turns out that Unity, under the covers, does a Mathf.Clamp01 on the red, green, blue, and alpha values of each colour. So, it creates 31993 new color32 instances (a Color32..ctor() for the number of trees in our landscape), and then 127972 calls to Mathf.Clamp01. This code isn’t running every frame, and, we can configure it in LB (or just turn it off if we don’t need it), nevertheless it’s a big problem which is taking a huge amount of time. In this case (62.66 + 5.36 + 1.28) 69.3ms. Ouch!
// Get the ambient colour for this time of day Color ambientColor = LerpColor(timeFloat, Color.white, nightAmbientLight); // Iterate through all terrains for (int i = 0; i < allTerrains.Length; i++) { // Check if terrains have been removed during this update if (allTerrains != null) { // Check for condition when terrainData is missing if (allTerrains[i].terrainData != null) { // Iterate through all trees in this terrain TreeInstance[] trees = allTerrains[i].terrainData.treeInstances; for (int i2 = 0; i2 < trees.Length; i2++) { // Set the lightmap colour (does nothing for SpeedTree trees as they have their own in-built lighting) trees[i2].lightmapColor = ambientColor; } // Send the new tree instances array back to the terrain if (allTerrains != null) { allTerrains[i].terrainData.treeInstances = trees; } else { break; } } } else { break; } }
Thankfully, the fix is pretty simple.
// Get the ambient colour for this time of day // Convert from Color to Color32 outside the loop to avoid expensive Color32.op_Impicit conversions inside the loop. Color32 ambientColor32 = LerpColor(timeFloat, Color.white, nightAmbientLight); // Iterate through all terrains for (int i = 0; i < numTerrains; i++) { // Check if terrains have been removed during this update if (allTerrains != null) { // Check for condition when terrainData is missing if (allTerrains[i].terrainData != null) { // Iterate through all trees in this terrain TreeInstance[] trees = allTerrains[i].terrainData.treeInstances; numTreeInstances = (trees == null ? 0 : trees.Length); for (int i2 = 0; i2 < numTreeInstances; i2++) { // Set the lightmap colour (does nothing for SpeedTree trees as they have their own in-built lighting) trees[i2].lightmapColor = ambientColor32; } // Send the new tree instances array back to the terrain if (allTerrains != null && numTreeInstances > 0) { allTerrains[i].terrainData.treeInstances = trees; } else { break; } } } else { break; } }
And the effect? See for yourself. From 69.3ms per spike to less than 0.01. The profiler is showing 0.00. We know its more than nothing but pretty good for changing a few lines of code.
Which brings me to rule #3. If profiler says 0.00, its probably time to move on the next performance issue. Also, check you frame rate on your target lowest powered devices. If you are exceeding that FPS goal, know when to stop. Having a FPS goal at a certain CPU / GPU consumption rate and/or battery power rate might be part of goal on mobile, however, you still need to know when it is good enough.
Performance is a relative measurement, not absolute. What do users expect of your game?
There are many other GC-related examples. Hopefully, this article will help get you started analysing and fixing your particular issues.
Stephen Strong
Landscape Builder Team

Stephen Strong
- Owner
3
Comments