Contact

Type to explore Drawk studio productions

Discover cross-platform games, mobile apps, architecture articles, and interactive GLSL labs.

← Back to Posts

Eliminating GC Hiccups: Zero-Allocation Real-Time C# in Unity

TL;DR: At 60 FPS, you have exactly 16.6 milliseconds to calculate physics, tick gameplay logic, and record draw commands. When your code creates temporary objects on the managed heap, Unity's Garbage Collector eventually pauses the main thread for 30 to 50 milliseconds to clean up memory, causing a visible micro-stutter. The only permanent fix is adopting zero-allocation habits in your hot loops.

flowchart TD subgraph BUDGET["THE 16.6ms 60 FPS FRAME BUDGET"] B1["Input & Gameplay Logic: 4.0ms"] --> B2["Physics Simulation: 3.5ms"] B2 --> B3["Animation & IK Evaluation: 3.0ms"] B3 --> B4["Render Command Recording: 5.0ms"] B4 --> B5["Headroom Buffer: 1.1ms"] end subgraph GCPAUSE["THE GC SPIKE (Frame Hitch)"] G1["Frame Budget: 16.6ms"] -. "Memory threshold crossed!" .-> G2["Stop-the-World GC Pause (+40ms)"] G2 --> G3["Frame Time Spikes to 56.6ms"] G3 --> G4["Visible Hitch / Stutter (Dropped 3 Frames)"] end
┌────────────────────────────────────────────────────────────────────────┐
│                   MEMORY ALLOCATION: STACK VS HEAP                     │
├───────────────────────────────────┬────────────────────────────────────┤
│   STACK ALLOCATION (FAST & CLEAN) │   HEAP ALLOCATION (TRIGGERS GC)    │
├───────────────────────────────────┼────────────────────────────────────┤
│ • Local primitives (int, float)   │ • Any 'new' class instance         │
│ • Local structs (Vector3, Matrix) │ • Arrays (new int[], new Raycast[])│
│ • Zero garbage collector tracking │ • Captured lambda closures         │
│ • Instantly reclaimed on return   │ • String concatenation ('a' + 'b') │
│ • Cost: 0 clock cycles to free    │ • Cost: 40ms stop-the-world hitch  │
└───────────────────────────────────┴────────────────────────────────────┘

There is nothing quite as jarring as a sudden micro-stutter during an intense gameplay moment. You are aiming a shot or timing a jump, and for 40 milliseconds the screen freezes completely.

In Unity games written in C#, the number one cause of unexpected hitching is the Garbage Collector (GC).

Unlike languages with manual memory management like C or C++, C# manages heap objects automatically. When your code creates temporary objects, the runtime eventually pauses the main game thread to inspect references, trace heap trees, and reclaim orphaned memory.

To ship buttery 60+ FPS games on mobile, consoles, and web, you have to adopt a simple golden rule: Zero heap allocations in the hot path.

1. Cache Component References in Awake()

One of the most common beginner mistakes is repeatedly querying components during frame updates:

// BAD: Searches the GameObject hierarchy every single frame
void Update() {
    GetComponent<Rigidbody>().AddForce(Vector3.up * Time.deltaTime);
}

GetComponent<T>() traverses internal lists and can trigger subtle allocations depending on the Unity version. Always cache references during initialization:

// GOOD: Stored once, zero overhead in Update
private Rigidbody _rigidbody;

void Awake() {
    _rigidbody = GetComponent<Rigidbody>();
}

void FixedUpdate() {
    _rigidbody.AddForce(Vector3.up * Time.fixedDeltaTime);
}

2. Zero-Allocation Physics Queries

Standard Unity physics queries like Physics.RaycastAll or Physics.OverlapSphere allocate a brand new managed array every time they execute:

// BAD: Allocates a new RaycastHit[] array on the heap every frame
void Update() {
    RaycastHit[] hits = Physics.RaycastAll(transform.position, transform.forward, 50f);
    // Process hits...
}

If five enemies run this code at 60 FPS, you are generating thousands of temporary array allocations per second.

Unity provides non-allocating alternatives for every major physics query. Pre-allocate a static or instance buffer and use the NonAlloc methods:

// GOOD: Reuses the same buffer, zero heap allocations
private static readonly RaycastHit[] _hitBuffer = new RaycastHit[16];

void Update() {
    int hitCount = Physics.RaycastNonAlloc(transform.position, transform.forward, _hitBuffer, 50f);
    for (int i = 0; i < hitCount; i++) {
        // Process _hitBuffer[i]
    }
}

3. Beware Coroutines and Closures

Coroutines are convenient for sequential timing, but they carry hidden allocation costs:

// BAD: Creates a new WaitForSeconds object every execution
IEnumerator FlashRoutine() {
    yield return new WaitForSeconds(0.2f);
}

Every new WaitForSeconds() instantiates a managed object on the heap. Pre-cache frequently used intervals in variables, or use float timers inside standard Update() loops.

Additionally, avoid lambda expressions that capture local variables (closures) inside per-frame updates. Capturing local state causes the compiler to generate a hidden closure class instance on the managed heap.

4. Split Dynamic UI Canvases

Unity UI (uGUI) batches all elements on a canvas into a single unified mesh.

If you have a canvas that contains static background graphics, borders, icons, and a rapidly updating score counter, every time the score text changes, Unity marks the entire canvas dirty and recalculates every vertex on the screen.

The Fix: Keep static UI elements on a root canvas, and place high-frequency dynamic numbers and health bars on dedicated child sub-canvases. When the score changes, only the child canvas rebuilds its small vertex buffer.

Real-Time Allocation Checklist

Code Pattern Dangerous Form (Allocates Heap) Zero-Allocation Pattern
Physics Raycast Physics.RaycastAll(...) Physics.RaycastNonAlloc(..., buffer)
Component Query GetComponent<T>() in Update() Cache reference in Awake()
String Concatenation "Score: " + score Custom integer-to-char buffer or string formatting
Timer Delay yield return new WaitForSeconds(1f) Pre-cached instance or float timer in Update()
Collection Traversal foreach (var x in dict) (older Unity) Index loop or cached enumerator
UI Updates Single monolithic Canvas Sub-canvases separating static from dynamic text

Discipline in memory allocation keeps the garbage collector idle, and an idle garbage collector means 60 FPS without a single hitch.

References, Standards & Verified Data

  • ECMA-334 / ISO/IEC 23270 (C# Language Specification): ECMA-334 Standard - Clauses covering Value Types, Reference Types, Boxing Conversions, and ref struct guarantees that prevent heap escape.
  • Microsoft CLR Generational Garbage Collection: Microsoft Learn - Fundamentals of Garbage Collection - Architectural specification of generational heaps (Gen 0, Gen 1, Gen 2), Large Object Heap (LOH) threshold (85,000 bytes), and GC pause latency mechanics.
  • Modern Memory Primitives: Microsoft Learn - Span<T> Documentation - Explains contiguous stack-allocated memory manipulation with zero managed heap allocation overhead.
  • Unity Engine GC Architecture: Unity Manual - Optimizing Garbage Collection - Details Unity's implementation of the Boehm-Demers-Weiser conservative collector, explaining why memory fragmentation forces permanent heap growth on iOS and Android devices.
  • Frame Budget Timing Standard: At 60 frames per second, the total time budget for physics, gameplay logic, animations, and rendering is exactly 16.67 milliseconds. At 120 FPS, the budget shrinks to 8.33 milliseconds. A single GC collection pause lasting 10 to 30 milliseconds causes visible stutter and frame drops.

← Back to All Posts