iToverDose/Software· 13 JUNI 2026 · 12:07

Warum Unity-Spiele mit Speicher-Chaos Ihre Framerate killen

Garbage Collection in Unity-Spielen führt zu ruckelnden Frame-Raten. Mit moderner C#-Memory-Disziplin lässt sich das vermeiden – ohne Abstriche bei der Performance. So geht's.

DEV Community4 min0 Kommentare

Unity-Entwickler erreichen 2026 immer noch eine beunruhigende Anzahl an Frame-Einbrüchen in Spielen, obwohl die Technologie längst Fortschritte gemacht hat. Die Ursache liegt oft in vermeidbaren Speicherproblemen: Jede unnötige Speicherallokation im C#-Code summiert sich zu spürbaren Geschwindigkeitsverlusten. Doch es gibt Abhilfe.

Seit Jahren stehen Entwicklern moderne Tools wie Burst-Compiler, DOTS und asynchrone Muster zur Verfügung – trotzdem wird die fundamentale Bedeutung von Speicherdisziplin unterschätzt. Jede string-Konkatenation, jedes dynamische List-Vergrößern oder sogar harmlos wirkende LINQ-Abfragen führen zu Garbage Collection (GC)-Spitzen, die die Framerate abrupt bremsen. Die Lösung liegt in einem bewussten Umgang mit Speicher und der Nutzung von C#-Features wie Span, Memory und ArrayPool.

Warum Garbage Collection in Spielen zum Problem wird

Die verwaltete Speicherumgebung in C# erleichtert die Entwicklung, doch sie hat einen entscheidenden Nachteil: Jede Allokation auf dem Managed Heap muss später vom Garbage Collector (GC) aufgeräumt werden. Dieser Prozess verbraucht Rechenzeit und kann sogar den Haupt-Thread des Spiels kurzzeitig blockieren. Besonders in Echtzeitanwendungen wie Spielen führt das zu spürbaren Rucklern.

  • Temporäre Objekte – etwa durch string-Konkatenation oder List-Vergrößerung – erhöhen die GC-Last.
  • Häufige Allokationen in Hot-Code-Pfaden summieren sich zu spürbaren Performance-Einbußen.
  • Der GC ist keine kostenlose Operation – selbst wenn er „unsichtbar“ läuft, kostet er CPU-Zyklen, die dem Spiel fehlen.

Ziel sollte es sein, die Anzahl der Allokationen auf ein Minimum zu reduzieren, insbesondere in Code-Abschnitten, die pro Frame oder häufig ausgeführt werden.

String-Operationen optimieren: Keine unnötigen Kopien

Strings in C# sind unveränderlich. Jede Veränderung oder Konkatenation mit dem + Operator erzeugt eine neue Zeichenkette im Speicher. Besonders in Logik-Funktionen wie Spielstatusmeldungen summieren sich diese Operationen schnell zu einem Problem.

Das klassische, aber ineffiziente Beispiel:

void LogPlayerScore(int playerId, int score) {
    string log = "Spieler " + playerId + " hat " + score + " Punkte erzielt!";
    Debug.Log(log);
}

Jedes + erzeugt eine temporäre Zeichenkette. Besser eignet sich StringBuilder, der die Zeichenkette Stück für Stück zusammenbaut, ohne Zwischenkopien zu erzeugen.

Die optimierte Variante mit `StringBuilder`:

using System.Text;

private static readonly StringBuilder s_stringBuilder = new StringBuilder();

void LogPlayerScoreOptimized(int playerId, int score) {
    s_stringBuilder.Clear();
    s_stringBuilder
        .Append("Spieler ")
        .Append(playerId)
        .Append(" hat ")
        .Append(score)
        .Append(" Punkte erzielt!");
    Debug.Log(s_stringBuilder.ToString());
}
  • StringBuilder vermeidet temporäre String-Objekte.
  • Der statische StringBuilder wird wiederverwendet und reduziert die Allokationen weiter.

Für Szenarien mit festen Puffergrößen oder direkter Speicherzugriff kann zusätzlich Span<char> eingesetzt werden, um komplett allokationsfrei zu arbeiten.

Dynamische Collections und Array-Pooling: Effizienter Umgang mit Listen

Ein häufiger Performance-Fallenfall ist die unkontrollierte Nutzung von List<T>. Jedes new List<T>() oder eine implizite Vergrößerung der Kapazität führt zu Speicherallokationen auf dem Heap. Besonders problematisch wird es, wenn solche Listen in Methoden erzeugt und zurückgegeben werden – die GC muss sie später aufräumen.

Das problematische Muster:

List<Vector3> GetNearbyPositions(Vector3 center, float radius) {
    List<Vector3> positions = new List<Vector3>();
    // ... Logik, um Positionen zu sammeln ...
    return positions;
}
  • Jeder Aufruf dieser Methode erzeugt eine neue List<T>.
  • Während der Füllung der Liste können zusätzliche Allokationen durch automatische Vergrößerung entstehen.

Die optimierte Lösung mit `ArrayPool<T>` und `Span<T>`:

using System.Buffers;
using UnityEngine;

void ProcessTemporaryVectorData(int count) {
    // Puffer aus dem gemeinsamen Pool mieten – reduziert Allokationen
    Vector3[] buffer = ArrayPool<Vector3>.Shared.Rent(count);
    
    // Span<T> als Sicht auf den Puffer erstellen
    Span<Vector3> data = buffer.AsSpan(0, count);
    
    // Direkter Zugriff auf die Elemente ohne Zwischenkopien
    for (int i = 0; i < data.Length; i++) {
        data[i] = new Vector3(Mathf.Sin(i), Mathf.Cos(i), 0);
    }
    
    Debug.Log($"Verarbeitet {data.Length} Vektoren. Erster: {data[0]}");
    
    // Puffer zurückgeben, um ihn wiederzuverwenden
    ArrayPool<Vector3>.Shared.Return(buffer);
}
  • ArrayPool<T> stellt wiederverwendbare Arrays bereit und reduziert Allokationen.
  • Span<T> ermöglicht direkten, allokationsfreien Zugriff auf die Daten.
  • Der Puffer wird nach Gebrauch zurückgegeben, um Ressourcen freizugeben.

LINQ und seine versteckten Kosten: Wenn Abfragen zur Last werden

LINQ ist bequem und elegant, doch viele Standardmethoden wie Where, Select oder OrderBy erzeugen implizit neue Objekte. Dazu zählen Enumeratoren, temporäre Collections oder sogar Zwischenpuffer. In Performance-kritischen Code-Abschnitten summieren sich diese Overheads zu spürbaren Framerate-Einbrüchen.

Das klassische LINQ-Beispiel mit versteckten Allokationen:

using System.Linq;

IEnumerable<GameObject> GetActiveEnemies(List<GameObject> allObjects) {
    return allObjects
        .Where(obj => obj != null && obj.activeSelf && obj.CompareTag("Enemy"))
        .ToList();
}
  • Where erzeugt einen Enumerator.
  • ToList() erstellt eine neue List<T>.
  • Beide Schritte führen zu Allokationen, die der GC später bereinigen muss.

Die effizientere Alternative mit manueller Iteration:

void GetActiveEnemiesNoAlloc(List<GameObject> allObjects, List<GameObject> results) {
    results.Clear();
    foreach (var obj in allObjects) {
        if (obj != null && obj.activeSelf && obj.CompareTag("Enemy")) {
            results.Add(obj);
        }
    }
}
  • Die results-Liste wird außerhalb der Methode verwaltet und wiederverwendet.
  • Keine temporären Collections, keine LINQ-Overheads.
  • Klare Kontrolle über die verwendeten Ressourcen.

Fazit: Speicherdisziplin als Schlüssel zu flüssigen Spielen

Garbage Collection ist kein unvermeidliches Übel, sondern oft das Ergebnis von mangelnder Speicherdisziplin. Moderne C#-Features wie StringBuilder, ArrayPool und Span bieten Entwicklern die Werkzeuge, um Allokationen gezielt zu reduzieren – ohne auf Ausdrucksstärke oder Komfort verzichten zu müssen.

Der Wechsel zu einem datenorientierten Mindset, selbst in MonoBehaviour-Skripten, lohnt sich: Weniger Allokationen bedeuten weniger GC-Last und damit stabilere Frameraten. Spiele profitieren von kürzeren Ladezeiten, reibungsloserem Gameplay und einer besseren Spielerfahrung. Es ist an der Zeit, die Speichernutzung in Unity-Projekten ernst zu nehmen – für Performance, die mit der Hardware Schritt hält.

KI-Zusammenfassung

Learn how to stop Unity stutters with proven C# memory discipline. Use Span, ArrayPool, and StringBuilder to cut GC spikes and keep frame rates smooth in 2026.

Kommentare

00
KOMMENTAR SCHREIBEN
ID #M54B8O

0 / 1200 ZEICHEN

Menschen-Check

8 + 8 = ?

Erscheint nach redaktioneller Prüfung

Moderation · Spam-Schutz aktiv

Noch keine Kommentare. Sei der erste.