DEV Community

Cover image for Is Your Game Drowning in GC Allocations? Embrace Zero-Allocation C# in Unity
Chathura Rathnayaka
Chathura Rathnayaka

Posted on

Is Your Game Drowning in GC Allocations? Embrace Zero-Allocation C# in Unity

Is Your Game Drowning in GC Allocations? Embrace Zero-Allocation C# in Unity

Introduction

Let's be blunt: if you're striving for peak performance in Unity C# and still find yourself frequently allocating temporary collections like List<T> within your game's "hot paths," you're leaving raw framerate on the table. While Unity's C# runtime is robust, Garbage Collection (GC) spikes are the silent killer of buttery-smooth experiences, especially as your game scales. These insidious micro-stutters frustrate players and degrade the overall feel of your carefully crafted world.

The true secret weapon against this performance drain isn't exclusive to Unity's Data-Oriented Technology Stack (DOTS). It lies in embracing low-level, zero-allocation powerhouses like Span<T> and NativeArray outside pure DOTS contexts. When paired with the Burst Compiler, these aren't just ECS primitives; they are indispensable tools for any compute-heavy C# code, enabling you to unlock raw hardware potential, eliminate cache misses, and bid farewell to those frame-killing GC spikes. Stop treating them as advanced esoterica; they are foundational for elite game development.

Code Layout and Walkthrough

To illustrate, let's consider a common scenario: processing a large number of entities (like projectiles or enemies) every frame.

The Problem: Allocations with List<T>

Imagine a ProjectileManager that needs to filter and update active projectiles, perhaps calculating their distances to a target, every single frame.

using UnityEngine;
using System.Collections.Generic; // The culprit!

public class ProblematicProjectileManager : MonoBehaviour
{
    private List<Vector3> _allProjectilePositions = new List<Vector3>();
    private List<Vector3> _activeProjectilePositions = new List<Vector3>(); // Temporary list

    void Start()
    {
        // Populate with some initial data
        for (int i = 0; i < 1000; i++)
        {
            _allProjectilePositions.Add(new Vector3(i, 0, 0));
        }
    }

    void Update()
    {
        // PROBLEM: Allocates a new list or clears and re-adds, potentially re-allocating capacity.
        _activeProjectilePositions.Clear(); 

        foreach (Vector3 pos in _allProjectilePositions)
        {
            if (pos.x > 0) // Example condition for 'active'
            {
                _activeProjectilePositions.Add(pos); // Each Add might re-allocate
            }
        }

        ProcessProjectiles(_activeProjectilePositions); // Pass to a processing method
    }

    void ProcessProjectiles(List<Vector3> projectiles)
    {
        // Perform calculations, e.g., move, check collisions, etc.
        // Each iteration might still involve boxing if value types are used with IEnumerable.
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, _activeProjectilePositions.Clear() might not deallocate, but Add operations can trigger reallocations if the internal array capacity isn't sufficient. Even without reallocations, the mere act of constantly adding to a List<T> can incur performance overhead and reduce cache locality if the data isn't truly contiguous due to fragmentation or resizing.

The Solution: NativeArray, Span<T>, and Burst

Let's refactor this to be entirely GC-allocation-free in the hot path, leveraging NativeArray for the main data store and Span<T> for efficient, zero-copy views. We'll also mark our processing logic for Burst compilation.

using UnityEngine;
using Unity.Collections; // For NativeArray
using Unity.Burst;     // For BurstCompile
using Unity.Jobs;      // Can use with jobs, but Burst works on static methods too
using System;          // For Span<T>

// A simple struct for our projectile data
public struct ProjectileData
{
    public Vector3 Position;
    public bool IsActive; // Condition to filter on
}

public class OptimizedProjectileManager : MonoBehaviour
{
    // Use NativeArray for our primary data storage - unmanaged, GC-free memory.
    // Allocate once, dispose once.
    private NativeArray<ProjectileData> _allProjectiles;
    private int _projectileCount = 0;

    void Awake()
    {
        _allProjectiles = new NativeArray<ProjectileData>(1000, Allocator.Persistent);
        for (int i = 0; i < _allProjectiles.Length; i++)
        {
            _allProjectiles[i] = new ProjectileData { Position = new Vector3(i, 0, 0), IsActive = (i % 2 == 0) };
        }
        _projectileCount = _allProjectiles.Length;
    }

    void OnDestroy()
    {
        if (_allProjectiles.IsCreated)
        {
            _allProjectiles.Dispose(); // Crucial to dispose NativeArrays!
        }
    }

    void Update()
    {
        // 1. Get a Span<T> view of the entire NativeArray. No allocation!
        //    'AsSpan' is an extension method often provided by libraries,
        //    or you can directly work with NativeArray as it's blittable.
        //    For this example, let's assume we copy relevant data into a temporary Span
        //    or process the NativeArray directly.
        //    A more direct approach for filtering is to write into another NativeArray
        //    or process in-place with Burst.

        // For demonstration, let's process the NativeArray directly with a Burst-compiled method.
        // No temporary Span allocation needed *for the filtering itself* if processing in-place.
        BurstProcessor.ProcessActiveProjectiles(_allProjectiles, _projectileCount);
    }
}

// Separate static class for Burst-compiled logic
[BurstCompile]
public static class BurstProcessor
{
    public static void ProcessActiveProjectiles(NativeArray<ProjectileData> projectiles, int count)
    {
        for (int i = 0; i < count; i++)
        {
            // Working directly on NativeArray or a Span<T> slice
            ProjectileData data = projectiles[i];
            if (data.IsActive)
            {
                // Perform calculations on active projectiles.
                // E.g., data.Position += Vector3.forward * Time.deltaTime;
                // projectiles[i] = data; // If modifying the struct
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In this optimized version:

  1. NativeArray<ProjectileData>: We allocate a NativeArray once at startup using Allocator.Persistent. This memory is outside the C# garbage collector's domain, meaning zero GC impact per frame. It's crucial to Dispose() it when no longer needed to prevent memory leaks.
  2. Span<T> (Implicit/Potential): While the example directly processes the NativeArray, Span<T> shines when you need a view into a portion of contiguous memory (like a NativeArray or even a regular T[] array) without copying. You could use NativeArray<T>.AsReadOnlySpan() or manually construct a Span<T> from a section of a standard array. This is perfect for passing slices of data to functions without new allocations.
  3. Burst Compiler ([BurstCompile]): By adding the [BurstCompile] attribute to BurstProcessor.ProcessActiveProjectiles, Unity's Burst compiler will transform this C# code into highly optimized machine code, often leveraging SIMD (Single Instruction, Multiple Data) instructions. This dramatically speeds up numerical computations and array traversals, working perfectly with NativeArray and Span<T> due to their contiguous, unmanaged memory layout.

This approach ensures zero managed allocations in the Update loop, excellent cache locality (data is tightly packed in memory), and highly optimized execution, leading to consistently smooth framerates.

Conclusion

Span<T>, NativeArray, and the Burst Compiler are not just academic concepts or tools exclusively for DOTS enthusiasts. They are practical, powerful primitives available today to any Unity C# developer serious about performance. By consciously moving your performance-critical data into unmanaged memory with NativeArray and accessing it efficiently with Span<T> views, then accelerating your logic with Burst, you directly eliminate GC spikes. This paradigm shift means consistent framerates, reduced memory footprint, and a noticeable improvement in the responsiveness and feel of your game. Stop treating them as advanced esoterica; embrace them, and elevate your game development to an elite level.

Top comments (0)