March 21 2022

Don't Let Offscreen Objects Ruin Your Performance - Unity CullingGroup API


Learn how to make your expensive scripts cost nothing while they are offscreen.

Offwhat GameObject?

First off: what is an offscreen object?

An object is offscreen when you cannot see it from your camera’s perspective. This may happen because your object is “behind” your camera (therefore culled by frustrum) or behind walls/other occluders (occlusion culling).

So if an object is offscreen and we cannot see it, we don’t have to dedicate expensive computational resources to it right?

Wrong. That’s what I thought when I was a kid. Not anymore.

The Performance Cost(s) of Offscreen Objects

Here are some of the hardware costs you are paying for offscreen objects:

  • The culling price: is an object offscreen at all? Unity determines this by checking its visibility through frustrum and occlusion culling, which cost you resources.
  • The memory price: even if your object isn’t visible, you still have its components loaded in memory.
  • The scripting price: attached scripts and most built-in components still run even if your object is offscreen.
  • The rendering price: maybe you don’t see your object, but that doesn’t mean it is not being rendered. Maybe occlusion culling fails at detecting its visibility and you still render it.
  • The audio price: last time I checked, audio propragates differently from visuals, so it is still processed.
  • The physics price: a rolling ball appear behind a wall after a few seconds; physics are still executed.
  • The animation price: although this can be configured, some parts of the animation computation are still evaluated on offscreen objects.

So what can we do about this? Let’s talk about the CullingGroup API.

You Have a Rogue Behind You

You’re playing a RPG and you’ve got a friend behind you. You just don’t know it yet.

Since that rogue character is offscreen, you surely don’t have to execute its behavior?

Ok wait, if we completely froze it, it wouldn’t stab you with a dagger. So apparently we still have to run some code. Let’s think about some aspects:

  • Multiplayer: you still need to sync the player’s rigidbody with the new position and velocity, right? Let’s better not mess with multiplayer.
  • Animator: you don’t necessarily have to process the animation of the player, as it isn’t visible.
  • UI: if the player has fancy UI attached to their wrist or a health bar, you don’t necessarily have to update its contents/materials and incur in world-space canvas rebuilds, right?
  • Particle systems: you don’t have to simulate them, maybe you can just catch up later when they become visible.
  • Facial expressions: you surely don’t have to make the character look at you in the eye… if you can’t even see them. Here we don’t break the rules of politeness.

In general, you want to avoid cosmetic operations on offscreen objects, such as tweaking materials’ properties, minor rotations and such.

But… how can you do this?

Let’s assume you have a script called AngerRenderer. It only does one thing: to make the character redder the angrier it is. And it does so by simply adjusting a material property.

Something like this:

class AngerRenderer : MonoBehavior
{
	void Update()
  {
  	var skinColor = Color.Lerp(Color.white, Color.red, 1f - _health / 100);
  	_material.SetProperty("_MainColor", skinColor);
    // More costly operations here
    // Play sweat particles
    // Play steam particles
  }
}

Of course it doesn’t compile. It shouldn’t, as I want to make the point instead.

What can we do now to disable this “costly” script whenever that character isn’t in view?

Unity CullingGroup API: Optimizing Offscreen GameObjects in Unity

So we use the CullingGroup API that Unity offers us.

This API informs us of visibility updates. And guess what, when you know whether your object becomes visible or invisible, then you can just disable the components you want or stop executing its behavior.

The code could then look more like this:

class AngerRenderer : MonoBehavior
{
  CullingGroup _cullingGroup;
  BoundingSphere[] _boundingSpheres;
  float _playerRadius = 1f;
  
  void Start()
  {
  	_cullingGroup = new CullingGroup();
    _cullingGroup.targetCamera = Camera.main;
    _boundingSpheres = new BoundingSphere[1];
  }
  
  void OnDestroy()
  {
  	_cullingGroup.Dispose();
		_cullingGroup = null;
  }
  
  void Update()
  {
  	if (_cullingGroup.IsVisible(0))
    {
    	var skinColor = Color.Lerp(Color.white, Color.red, 1f - _health / 100);
  		_material.SetProperty("_MainColor", skinColor);
      // More costly operations here
      // Play sweat particles
    	// Play steam particles
    }

    _boundingSpheres[0] = new BoundingSphere(transform.position, _playerRadius);
    _cullingGroup.SetBoundingSpheres(spheres);
    _cullingGroup.SetBoundingSphereCount(1);
  }
}

Now, you must select a radius that works for your player, take the right position at its center and so on. You get the idea.

And you can do many more things there, like receiving visibility updates instead of polling. Or you can also pack more bounding spheres into the array to reduce the overhead of running your bounding spheres.

The key takeaway is: you can often skip certain behaviors while your object isn’t being rendered. Figure out what expensive scripts you don’t have to run on invisible objects and your users’ hardware will thank you for that.

Discover step-by-step how the CullingGroup API works in practice (with examples) in PerformanceTaskforce’s lesson “2021.10”:

https://www.PerformanceTaskforce.com

All in all, a solid performance tool to have in your toolbox.

Till next time,

Ruben (The Gamedev Guru)

The Gamedev Guru Logo

Performance Labs SL
Paseo de la Castellana 194, Ground Floor B
28046 Madrid, Spain

This website is not sponsored by or affiliated with Facebook, Unity Technologies, Gamedev.net or Gamasutra.

The content you find here is based on my own opinions. Use this information at your own risk.
Some icons provided by Icons8