Monday, April 21, 2014

Creating a flexible audio system in Unity

Unity's audio system isn't without its disadvantages. One of it's major issues is that a single AudioSource can only play one audio clip at a time. You may say "well, that kind of makes sense" but why not fire a background thread for each play request?

Playing only one audio clip at a time can be a problem in scenarios where you have an AudioSource attached to a prefab, your Player for example, and you have multiple audio clips you'd like to be played in succession. Your player jumps, so you want to play a jumping sound effect, but within the time that jumping sound effect is playing, they get hit by something, so you swap out the audio clip and play a player hit sound effect, but if you're trying to use a single AudioSource, that'll cut off the currently playing jumping sound effect. It'll sound bad, jarring and confusing to the player. Most obvious solution is to simply attach a new audio source for every audio clip you'd like to play. That may get nightmarish if you end up having a lot of possible audio clips to play.

My solution has been to create a central controller that'll listen for game events to spawn and pool AudioSource game objects in the scene at a specified location (in case the audio clip is a 3D sound), load it with a specified AudioClip, and play it, and return the instance back to the object pool for later use. This allows you to play multiple audio clips at a single location, at a single time, without cutting each other off. You also get the benefit of keeping your game prefabs clean and tidy.

I'm always reluctant to share my code because I use StrangeIoC, which not everyone is using (though you probably should!) and the code structure may seem alien, but a keen developer should be able to adapt the solution to their needs. Let's go through a working example.

I've attempted to comment this Gist well enough so that people who aren't familiar with StrangeIoC can still follow along. The basic execution is


  1. Player is hit, dispatch a request to play the "player is hit" sound effect
  2. This is a fatality event, dispatch a request to also play the "player fatality" sound effect
  3. PlaySoundFxCommand receives both events
  4. For each separate event, attempt to obtain an audio source prefab from the object pool. If one is not available, it will be instantiated
  5. If the _soundFxs Dictionary doesn't already have a reference to the requested AudioClip, load it via Resources.Load and store reference for future calls
  6. Setup the AudioSource (assign AudioClip to play, position, etc)
  7. Play the AudioSource
  8. Start a Coroutine to iterate every frame while the AudioClip is still playing
  9. Once the AudioClip is done, deactivate the AudioSource and return it back to the object pool

With this system, you never have to worry about audio clips cutting each other off, everything is centralized, and you don't have to manually manage the different possible AudioSources. However, you do need to keep an eye on memory usage as we are pooling and saving reference to a lot of resources, which may hinder how well this system can scale. A possible improvement is to limit the number of AudioClips we save reference to in the _soundFxs Dictionary, and when that limit is reached, remove an entry. You could go as far as to figure out which sound effect are least used, and remove those first. 









3 comments:

  1. Great information. Thanks for providing us such a useful information. Keep up the good work and continue providing us more quality information from time to time. Audio System

    ReplyDelete
  2. Thanks, really good. Our SoundFx pool was a similar Dictionary based one but we did not get our head around marrying it up with StrangeIoC until now.

    Just wondering why would not use just a plain [Inject] for the IRoutineRunner.

    [Inject(RoutineRunnerTypes.SoundFxAudioSource)]
    public IRoutineRunner RoutineRunner { get; set; }

    Team Brookvale

    ReplyDelete
    Replies
    1. Essentially, using a name injection allows me to stop all coroutines running specific to this command.

      When you setup a RoutineRunner, all you're doing is attaching an empty MonoBehavioir script to the ContextRoot game object. You're also limited to calling StartCoroutine by passing a method reference, vs. starting it by String name.

      Due to the Unity limitation of being unable to stop a specific coroutine when you start it by passing a method reference (vs. calling it by String name, which you can specifically stop by name), I use a named injection to attach a separate MonoBehaviour script to the ContextRoot so that I can stop all coroutines dealing with the PlaySoundFxCommand (via the StopAllCoroutines method).

      Delete