A 5-minute music track set to Decompress On Load froze the main thread for about 390 ms on every cold play, in a Development Build. Switching the clip to Compressed In Memory or Streaming brought the worst frame down to about 2 ms. Object pooling the AudioSource made the play call about 0.02 ms faster, which is real but irrelevant to that spike.
I recently took over a Unity project at work whose GameManager plays audio by instantiating a GameObject with an AudioSource, then destroying it after a set time. The project was built in a hurry, and the frame rate sometimes drops when the SoundManager does something. Clicking the spike in the Profiler showed file-related markers alongside the SoundManager.
The advice I got was to pool the SoundManager’s objects. I had read about object pooling but never used it. So I built a separate test project at home to answer one question before touching the work code: is creating and destroying the GameObject actually what costs the frames?
My hypothesis going in was that it probably isn’t. Creating one GameObject is cheap. Loading and decoding a long audio file is not.
The test separates two variables that the work project mixes together: how the AudioSource is created, and how the clip is imported.
Three playback methods
| Method | What it does |
|---|---|
| Instantiate | Mimics the work SoundManager: Instantiate a prefab with an AudioSource, Play(), Destroy after 10 s |
| Pooled | Gets an AudioSource from UnityEngine.Pool.ObjectPool<AudioSource>, plays it, releases it after 10 s |
| Direct | Plays on one AudioSource that already exists in the scene. No GameObject work at all: the baseline |
If Direct spikes as badly as Instantiate, the GameObject was never the problem.
Five import-setting variants of the same mp3 (Vorbis, same quality, Load In Background off for all)
| Clip | Load Type | Preload Audio Data |
|---|---|---|
| BGM_DOL_PreOn | Decompress On Load | on |
| BGM_DOL_PreOff | Decompress On Load | off |
| BGM_CIM_PreOn | Compressed In Memory | on |
| BGM_CIM_PreOff | Compressed In Memory | off |
| BGM_Stream | Streaming | (default) |
Cold and warm plays
AudioClip.UnloadAudioData() before every play, so Unity has to load the data at play time. This is the first time a track plays in a real game.Loaded, so it has a single phase.Each clip × phase × method cell ran 5 times: 135 recorded runs per session.
Unity has shipped a generic pool in UnityEngine.Pool since 2021.1, so there’s no need to write your own. You hand it four callbacks: how to create an item, and what to do on get, release and destroy.
pool = new ObjectPool<AudioSource>(
createFunc: CreateSource,
actionOnGet: src => src.gameObject.SetActive(true),
actionOnRelease: OnReleaseSource, // Stop(), clip = null, SetActive(false)
actionOnDestroy: src => Destroy(src.gameObject),
collectionCheck: true,
defaultCapacity: prewarmCount,
maxSize: maxSize);
Playing a sound becomes pool.Get(), set the clip, Play(). When its time is up, pool.Release(src) puts it back.
Things I didn’t know until I used it
defaultCapacity only sizes the internal stack; it creates nothing. The first Get() still calls Instantiate. To pay that cost at startup, prewarm by getting N items and releasing them all:using (ListPool<AudioSource>.Get(out List<AudioSource> temp))
{
for (int i = 0; i < count; i++) temp.Add(pool.Get());
foreach (AudioSource src in temp) pool.Release(src);
}
maxSize caps what the pool keeps, not what it hands out. Release more than maxSize items and the extras are destroyed.collectionCheck: true throws if you release the same object twice. It’s a cheap safety net while developing.ListPool, used in the prewarm above, lends you a temporary List<T> without allocating a new one each time.For each play, the harness records three numbers and appends a row to a CSV:
Stopwatch.The frame and GC numbers come from ProfilerRecorder, which works in Development Builds without the Profiler window attached:
mainThread = ProfilerRecorder.StartNew(ProfilerCategory.Internal, "Main Thread");
gcAlloc = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocated In Frame");
LastValue returns the previous frame, so Update collects the numbers first and handles input second. The frame in which a play happened is then read on the next Update.
Each method is also wrapped in a ProfilerMarker (AudioBench.Pooled and so on), so it can be found by name in the Profiler’s timeline.
Things that would have skewed the numbers
vSyncCount = 0 and an uncapped frame rate.Across two Development Build runs (270 rows), a cold play of Decompress On Load is the only case that costs more than one frame: about 394 ms, roughly 24 frames lost at 60 fps.
The playback method barely matters
| Method | Play call, warm (ms) | Worst frame, cold Decompress On Load (ms) |
|---|---|---|
| Instantiate | 0.066 | 391.5 |
| Pooled | 0.033 | 397.3 |
| Direct | 0.027 | 393.7 |
The warm play call is measured on the Compressed In Memory clips. Pooling halves the call cost compared with Instantiate, but the saving is about 0.03 ms, under 0.2% of a 16.6 ms frame. Against the decompression freeze, the three methods are within 6 ms of each other.
GC was 0.4–0.5 KB per play for every method, including Instantiate. Creating one GameObject happens mostly in native code and barely touches the managed heap.
Preload Audio Data made no difference to cold plays. Preload only decides whether the data loads at scene load; unloading before each play removes that, and the on/off cold medians were within a few ms.
The first Build run suggested that Instantiate and Pooled made the cold freeze worse than Direct. Running it again in reverse order showed that this was noise.
| Cold Decompress On Load, median worst frame | Instantiate | Pooled | Direct |
|---|---|---|---|
| Run 1 | 455 ms | 421 ms | 403 ms |
| Run 2 (clip order reversed) | 386 ms | 390 ms | 393 ms |
In run 1, the gap came entirely from one clip, BGM_DOL_PreOff, whose cold plays scattered between 420 and 700 ms. My first explanation was an order effect: perhaps the first Decompress On Load clip in a run is always slower. Run 2 put BGM_DOL_PreOn first instead. If the order effect were real, the scatter would have moved to that clip. It didn’t: both clips stayed tight at about 385–405 ms, and the three methods ended within 7 ms of each other.
So the honest statement is: in 2 of 3 Build blocks, the freeze was about 390 ms whatever the method, and one block scattered for a reason I haven’t identified. One untested guess is that run 1 was the first launch after building, so the OS had not cached the asset file yet.
If I had stopped after one run, I would have published a difference between methods that doesn’t exist.
For a long music track, the spike comes from the clip’s Load Type, not from how the AudioSource is created. Pooling would not have fixed it.
Next, I’ll check the Load Type and the profiler markers in the actual work project before changing any code.