Optimization Approaches for Project Assets

Tutorial

·

advanced

·

+10XP

·

60 mins

·

(350)

Unity Technologies

Optimization Approaches for Project Assets

It’s common for a project to grow beyond a designer’s initial vision, or for that initial vision to be more resource-intensive than originally thought. Much of a project’s optimization will be unique to its design, but even the simplest project can benefit from optimization. In this tutorial, you will learn about different approaches to take for optimizing projects.

Languages available:

1. Optimization Approaches for Project Assets

Verified: Unity 2019.4 LTS

It’s common for a project to grow beyond a designer’s initial vision, or for that initial vision to be more resource-intensive than originally thought. Much of a project’s optimization will be unique to its design, but even the simplest project can benefit from optimization.

The less work Unity has to do to load, access, and unload your assets, the more optimally your project will run. Some tips for better memory handling include:

  • All 2D (Sprites and UI) graphic elements should be packed into Sprite Atlases. A Sprite Atlas is a large texture that contains multiple Sprites and lets the graphics processing unit (GPU) process them in batches rather than individually. In the case of games with large amounts of graphics, Sprite Atlases can be separated by character, world, or purpose. To learn more about the Sprite Atlas, see Introduction to Sprite Atlas.
  • All textures that are not packed in a Sprite Atlas should have dimensions that are powers of 2 in order to take advantage of memory handling optimizations on the GPU. They don’t necessarily need to be square, but being square and a power of 2 further aids optimization.
  • Longer audio such as background music should have its Load Type in Import Settings set to Streaming rather than Decompress on Load or Compressed in Memory. The tradeoff is a potential slight delay in starting, but the benefit is that only a fraction of RAM is necessary to stream vs. loading and decompressing the entire song in memory.

If you have limited assets, creating some controlled randomized parameters can be a simple way to add variety. Some examples include:

A single sound effect can sound like several different takes by randomly varying the pitch by a small amount, for example 5 percent in either direction. The code snippet below is one possible implementation:

public void PlayAtRandomPitch(AudioSource audio, AudioClip clip, float minPitch = 0.95f, float maxPitch = 1.05f)
{
    	float originalPitch = audio.pitch;
    	audio.pitch = Random.Range(minPitch, maxPitch);
    	audio.PlayOneShot(clip);
    	audio.pitch = originalPitch;
}

A single or small set of Sprites or particles can look like more by simply varying the color of the renderer. To set a GameObject’s Sprite and color randomly, attach the following script:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class RandomSprite : MonoBehaviour {
	public Sprite[] sprites;
	SpriteRenderer sprite;
	public Color colorOne, colorTwo;

    // Use this for initialization
    void Start () {
	sprite = GetComponent<SpriteRenderer>();
	sprite.sprite = sprites[Random.Range(0, sprites.Length)];
	sprite.color = Color.Lerp(colorOne, colorTwo, Random.value);
    }
}

Non-player characters (NPCs), weapons, or anything that can be assembled piece-by-piece, can be built of randomly selected different parts. Individual parts are quicker to make than full assets and can be assembled in combinations the designer may never have thought of. In the following implementation, the Random Assembly component would be attached to a player or NPC. Manually assign your SpriteRenderer components by dragging them into their respective slots in the Inspector. Finally, drag as many Sprites as you’d like for each option. During Start, the character is assembled from random pieces. This could be extended to a character creation utility, where pieces are specifically chosen by the user, and/or combined with the above script to add color as well as configuration.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class RandomAssembly : MonoBehaviour {
	public Sprite[] headSprites;
	public Sprite[] bodySprites;
	public Sprite[] swordSprites;
	public Sprite[] shieldSprites;

	public SpriteRenderer headSpriteRenderer;
	public SpriteRenderer bodySpriteRenderer;
	public SpriteRenderer swordSpriteRenderer;
	public SpriteRenderer shieldSpriteRenderer;
    
    // Use this for initialization
    void Start () {
	headSpriteRenderer.sprite = headSprites[Random.Range(0, headSprites.Length)];
	bodySpriteRenderer.sprite = bodySprites[Random.Range(0, bodySprites.Length)];
	swordSpriteRenderer.sprite = swordSprites[Random.Range(0, swordSprites.Length)];
	shieldSpriteRenderer.sprite = shieldSprites[Random.Range(0, shieldSprites.Length)];
    }
}

Finally, there are designer-friendly ways to optimize the management of resources in code.

It’s not uncommon to discover potentially show-stopping lag as a project approaches final quality, making it necessary to restructure its design. One common solution is the adoption of object pooling. In short, object pooling recycles objects when possible rather than creating new ones. To learn more about object pooling, see this Unity tutorial. Pooling can dramatically improve the user experience, but converting a project late in production means potentially rebuilding hundreds of prefabs or ScriptableObjects. In a game project, this could be spells that fire projectiles. For example, say we have 200 spells that all need this conversion. Rather than redesign the spell in a way that forces the designer to rebuild all 200, we can change the way the spell uses the already supplied data.

The following code is excerpted for illustration, and is not intended to represent complete scripts.

Before conversion, the projectile code for a simple spell might look like this:

public SpellProjectile myProjectile;

public void Fire(Vector3 spawnPosition, Quaternion spawnAngle)
{
    	SpellProjectile projectile = Instantiate(myProjectile, spawnPosition, spawnAngle);
}

The spell holds a projectile as a data member and instantiates a new projectile each time it’s activated. This seems innocent enough, but the demands it creates from instantiating and destroying new objects can quickly overwhelm a system’s resources. We could convert the spell using the following code, but because myProjectile and myProjectilePool are different data types and variable names, and we are no longer using the previously designated SpellProjectile, we are essentially throwing it away. This means that the myProjectilePool data member would need to be specified for all 200 spells.

//public SpellProjectile myProjectile;
public SpellProjectilePool myProjectilePool;

	public void Fire(Vector3 spawnPosition, Quaternion spawnAngle)
	{
    		SpellProjectile projectile = myProjectilePool.GetProjectile();
    	projectile.Initialize(spawnPosition, spawnAngle);
	}

Instead, let’s make the SpellProjectilePool a private data member that gets added to our spell’s GameObject automatically. We’ll then use our previously specified projectile Prefab to be handled by the projectile pool.

public SpellProjectile myProjectile;
private SpellProjectilePool myProjectilePool;

void Start()
{
	myProjectilePool = gameObject.AddComponent<SpellProjectilePool>();
	myProjectilePool.projectile = myProjectile;
}

	public void Fire(Vector3 spawnPosition, Quaternion spawnAngle)
	{
    		SpellProjectile projectile = myProjectilePool.GetProjectile();
    	projectile.Initialize(spawnPosition, spawnAngle);
}

This allows a seamless conversion to using object pooling without needing to redo previous prefab setup or wasting previous work.

Any time a number of MonoBehaviours duplicate functionality, consider moving them to a single script. For example, it’s common in games to have many types of pickup (an object your character touches to “pick up” that modifies their abilities or statistics in some way). Rather than a separate script for health, ammunition, gear, etc., make a universal pickup MonoBehaviour and use either an enum or ScriptableObjects for the abilities. Here’s an example implementation using enum:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Pickup : MonoBehaviour {
	public enum PickupType
	{
Gold,
    	Health,
    	Mana
	};

	public PickupType pickupType;
	public int value;

	private void OnTriggerEnter(Collider other)
	{
    	Player player = other.GetComponent<Player>();
    	if (player == null)
        	return;

    	switch (pickupType)
    	{
        	case PickupType.Gold:
            	player.gold += value;
            	break;
        	case PickupType.Health:
            	player.health += value;
            	break;
        	case PickupType.Mana:
            	player.mana += value;
            	break;
    	}
	}
}

This solution is fine if you have a fixed or limited number of pickup types. The more cases (pickup types) you add, the more chances there are to introduce bugs as the pickup code becomes messier. The benefit of using a ScriptableObject to describe a pickup is that you only need to write the pickup code once, and all unique functionality is handled in the PickupAbility ScriptableObjects. In that scenario, the pickup code would look like this:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Pickup : MonoBehaviour {
	public PickupAbility pickup;
	public int value;

	private void OnTriggerEnter(Collider other)
	{
    	Player player = other.GetComponent<Player>();
    	if (player == null)
        	return;

    	pickup.ActivateOn(player);
	}
}

When the pickup is activated, its ActivateOn method is called. This is a universal method that all inheritors of the base class PickupAbility must implement. Here’s the code for the PickupAbilty:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public abstract class PickupAbility : ScriptableObject {
	public int value;
	public abstract void ActivateOn(Player player);
}

The gold pickup:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "Pickups/Gold")] 	
public class pickup_Gold : PickupAbility {
	public override void ActivateOn(Player player)
	{
    		player.gold += value;
	}
}

Because the only requirement for the “pickup” to activate is a player touching it, this could also be used for in-game events. For example, the line in ActivateOn could be changed to:

GameManager.instance.SummonFinalBoss();

Consolidating duplicate functionality optimizes the addition and debugging of new features.

As your experience with Unity grows, you’ll find yourself incorporating optimization into your workflow from the beginning, rather than as an afterthought.

Complete this tutorial