Jeder Schuss, jeder Treffereffekt, jeder neue Gegner: In vielen Unity-Projekten beginnt all das mit Instantiate und endet mit Destroy. In einer ruhigen Testszene fällt das niemandem auf. Dann kommt der Bosskampf mit vierzig Projektilen auf dem Bildschirm, und genau in dem Moment, in dem sich das Spiel am besten anfühlen soll, zuckt die Framerate.
Wenn Sie sich schon einmal gefragt haben, warum Ihr Unity-Spiel beim Spawnen ruckelt, liegt die Antwort oft nicht an der Grafikkarte. Sie liegt am ständigen Erzeugen und Wegwerfen von Objekten. Dieser Beitrag erklärt, woher diese Spitzen kommen, wie Object Pooling in Unity mit dem eingebauten ObjectPool<T> sie beseitigt, welche Fehler Sie vermeiden sollten und wie Sie im Profiler nachweisen, dass der Umbau wirklich gewirkt hat.
Warum Spawnen ruckelt: Instantiate, Destroy und der Garbage Collector
Ein GameObject zu erzeugen ist nicht umsonst. Unity muss das Objekt aufbauen, seine Komponenten einrichten und Callbacks wie Awake und OnEnable ausführen. Das Zerstören kostet erneut Arbeit. Passiert das ein paar Mal pro Level, spielt es keine Rolle. Passiert es dutzende Male pro Sekunde, summiert es sich schnell.
Der zweite Teil des Problems ist der Speicher. Ihre Skripte belegen verwalteten Speicher auf dem sogenannten Managed Heap, und der Garbage Collector muss später alles finden und aufräumen, worauf nichts mehr verweist. Die Unity-Dokumentation beschreibt als Standard die inkrementelle Garbage Collection. Der Collector verteilt seine Arbeit dabei auf mehrere Frames, statt das Spiel in einer langen Pause anzuhalten. Das reduziert Spitzen, aber Unity sagt klar, dass die Garbage Collection dadurch nicht schneller wird. Ändern sich Referenzen zu häufig, kann der inkrementelle Collector sogar auf eine vollständige, nicht inkrementelle Sammlung zurückfallen. Und auf der Web-Plattform wird die inkrementelle Garbage Collection gar nicht unterstützt.
Kurz gesagt: Je weniger Sie während des Spiels belegen und wegwerfen, desto weniger Arbeit hat der Garbage Collector, und desto stabiler bleibt Ihre Frame-Zeit. Genau hier setzt Pooling an.
Das Prinzip: Objekte ausleihen statt neu bauen
Die Idee hinter Object Pooling ist einfach. Statt für jeden Schuss ein neues Projektil zu erzeugen und es danach zu zerstören, halten Sie einen Vorrat an Projektilen bereit. Brauchen Sie eines, nehmen Sie es aus dem Pool und aktivieren es. Hat es seine Aufgabe erledigt, deaktivieren Sie es und legen es zurück. Der nächste Schuss verwendet dasselbe Objekt wieder.
Das Unity-Handbuch beschreibt es genauso: Object Pooling verringert den Aufwand durch wiederholtes Instanziieren und Zerstören und begrenzt die Gesamtzahl der Allokationen und Freigaben. Das senkt den Aufwand für die Garbage Collection und die Last auf der CPU.
Eine eigene Pool-Klasse müssen Sie dafür nicht mehr schreiben. Seit Unity 2021 bringt der Namespace UnityEngine.Pool fertige Implementierungen mit. Die wichtigste für GameObjects ist ObjectPool<T>, ein stapelbasierter Pool für beliebige Referenztypen. Eine Einschränkung sollten Sie von Anfang an kennen: Wie viele zentrale Unity-APIs ist UnityEngine.Pool nicht thread-sicher und sollte nur vom Main Thread aus aufgerufen werden.
ObjectPool<T> in der Praxis
Die Parameter im Überblick
Der Konstruktor erwartet einige Delegates und drei Einstellungen. Laut Unity-Dokumentation erfüllen sie folgende Aufgaben:
- createFunc wird aufgerufen, wenn der Pool leer ist und eine neue Instanz gebraucht wird. Meist ruft sie einfach
Instantiatemit Ihrem Prefab auf. - actionOnGet läuft, wenn ein Objekt aus dem Pool genommen wird. Hier aktivieren und initialisieren Sie es.
- actionOnRelease läuft, wenn ein Objekt in den Pool zurückkehrt. Hier deaktivieren Sie es und räumen es auf.
- actionOnDestroy läuft, wenn ein Objekt zurückgegeben wird, der Pool aber schon voll ist. Typischerweise rufen Sie hier
Destroyauf. - collectionCheck wirft eine Exception, wenn Sie ein Objekt zurückgeben, das schon im Pool liegt. Wichtig: Diese Prüfung läuft nur im Editor, nicht in Player-Builds.
- defaultCapacity ist die Startgröße des internen Speichers. Sie erzeugt vorab keine Objekte.
- maxSize ist die Obergrenze. Hält der Pool so viele inaktive Objekte, werden weitere zurückgegebene Objekte über
actionOnDestroyzerstört.
Code-Beispiel: Projektile aus dem Pool
Das folgende Beispiel orientiert sich am Projektil-Beispiel aus dem Unity-Handbuch, gekürzt und an Unity 6 angepasst. Ein Spawner erstellt den Pool einmal in Awake und ruft beim Schießen Get statt Instantiate auf:
using UnityEngine;
using UnityEngine.Pool;
public class ProjectileSpawner : MonoBehaviour
{
[SerializeField] private Projectile projectilePrefab;
[SerializeField] private Transform muzzle;
[SerializeField] private bool collectionCheck = true;
[SerializeField] private int defaultCapacity = 20;
[SerializeField] private int maxSize = 100;
private ObjectPool<Projectile> pool;
private void Awake()
{
pool = new ObjectPool<Projectile>(
createFunc: () =>
{
Projectile projectile = Instantiate(projectilePrefab);
projectile.Pool = pool;
return projectile;
},
actionOnGet: projectile => projectile.gameObject.SetActive(true),
actionOnRelease: projectile => projectile.gameObject.SetActive(false),
actionOnDestroy: projectile => Destroy(projectile.gameObject),
collectionCheck: collectionCheck,
defaultCapacity: defaultCapacity,
maxSize: maxSize);
}
public void Fire()
{
Projectile projectile = pool.Get();
projectile.transform.SetPositionAndRotation(muzzle.position, muzzle.rotation);
projectile.Launch();
}
}
Das Projektil kennt seinen Pool und gibt sich selbst zurück, entweder bei einem Treffer oder wenn seine Lebensdauer abgelaufen ist:
using UnityEngine;
using UnityEngine.Pool;
[RequireComponent(typeof(Rigidbody))]
public class Projectile : MonoBehaviour
{
[SerializeField] private float speed = 30f;
[SerializeField] private float lifetime = 3f;
public IObjectPool<Projectile> Pool { get; set; }
private Rigidbody body;
private float releaseTime;
private bool released;
private void Awake()
{
body = GetComponent<Rigidbody>();
}
public void Launch()
{
released = false;
releaseTime = Time.time + lifetime;
body.linearVelocity = transform.forward * speed;
}
private void Update()
{
if (Time.time >= releaseTime)
ReturnToPool();
}
private void OnCollisionEnter(Collision collision)
{
ReturnToPool();
}
private void ReturnToPool()
{
if (released)
return;
released = true;
body.linearVelocity = Vector3.zero;
body.angularVelocity = Vector3.zero;
Pool.Release(this);
}
}
Zwei Details sind hier wichtig. Erstens setzt das Projektil die Geschwindigkeit seines Rigidbody zurück, bevor es in den Pool geht, damit der nächste Schuss nicht mit altem Schwung startet. Zweitens verhindert das Flag released, dass dasselbe Projektil zweimal zurückgegeben wird, wenn Kollision und Timeout im selben Frame zusammenfallen. Auf beide Punkte kommen wir gleich zurück.
Den Pool vorwärmen
Das ist eines der häufigsten Missverständnisse. Viele gehen davon aus, dass defaultCapacity: 20 sofort zwanzig Projektile erzeugt. Die Unity-Dokumentation sagt etwas anderes: Instanzen entstehen erst, wenn Get aufgerufen wird und der Pool leer ist. Ein frischer Pool ist leer, also läuft bei der ersten Salve trotzdem für jedes Projektil Instantiate, mitten im Geschehen.
Wenn Sie diese erste Spitze vermeiden wollen, wärmen Sie den Pool beim Laden des Levels vor. Nehmen Sie die gewünschte Anzahl Objekte heraus und geben Sie sie direkt wieder zurück:
using System.Collections.Generic;
public void Prewarm(int count)
{
var warmup = new List<Projectile>(count);
for (int i = 0; i < count; i++)
warmup.Add(pool.Get());
foreach (Projectile projectile in warmup)
pool.Release(projectile);
}
Halten Sie count bei oder unter maxSize. Sonst zerstört der Pool die überzähligen Objekte sofort wieder, und das Vorwärmen war umsonst.
Die typischen Stolperfallen
Zustand zurücksetzen
Ein Objekt aus dem Pool trägt alles aus seinem letzten Einsatz mit sich. Ein gepoolter Gegner kommt womöglich mit alter Position, wenig Lebenspunkten, einer halb abgespielten Animation, einer laufenden Coroutine oder noch aktiven Event-Abos zurück. Das Unity-Handbuch nennt die üblichen Aufgaben beim Zurücksetzen: Coroutines stoppen, Events abmelden, Physik zurücksetzen, Animationen leeren und Partikelsysteme stoppen.
Sie können entweder in actionOnRelease oder in actionOnGet zurücksetzen oder dem gepoolten Objekt eine eigene Reset-Methode geben, die der Pool aufruft. Das Handbuch empfiehlt außerdem, das GameObject beim Zurückgeben zu deaktivieren, damit es im Pool keine Update-Aufrufe mehr bekommt. Aus praktischer Sicht zeigt sich ein vergessener Reset selten als Absturz. Er zeigt sich als seltsames Verhalten: ein Gegner, der schon halb tot erscheint, oder ein Projektil, das seitlich davonfliegt. Sehen Sie so etwas nach der Einführung eines Pools, prüfen Sie zuerst den Reset.
Die richtige Poolgröße
Ist maxSize zu klein, zerstört der Pool in intensiven Momenten zurückgegebene Objekte und muss sie kurz danach neu erzeugen. Das Ruckeln, das Sie loswerden wollten, ist dann wieder da. Ist der Wert zu groß, hält der Pool Speicher für Objekte belegt, die kaum gebraucht werden.
Eine allgemeingültige Zahl gibt es dafür nicht. ObjectPool<T> bietet drei Eigenschaften, die helfen: CountActive (Objekte, die gerade in Gebrauch sind), CountInactive (Objekte, die im Pool warten) und CountAll (beide zusammen). Unsere Empfehlung aus der Praxis: Protokollieren Sie diese Werte in der intensivsten Szene Ihres Spiels und leiten Sie defaultCapacity, maxSize und die Anzahl fürs Vorwärmen aus dem ab, was Sie tatsächlich sehen.
Doppeltes Zurückgeben und Szenenwechsel
Dasselbe Objekt zweimal zurückzugeben ist ein unangenehmer Fehler, weil danach zwei Stellen im Spiel dieselbe Instanz verwenden. collectionCheck fängt das mit einer Exception ab, aber nur im Editor. In einem Player-Build rutscht der Fehler still durch. Lassen Sie die Prüfung während der Entwicklung also aktiv und sichern Sie Ihren eigenen Code ab, so wie mit dem Flag released im Beispiel oben.
Auch Szenenwechsel brauchen Aufmerksamkeit. Laut Unity-Handbuch ist ein Pool standardmäßig an die Szene gebunden, in der er erstellt wurde. Wird diese Szene entladen, zerstört Unity den Pool und alle gepoolten Instanzen mit. Brauchen Sie einen Pool über Szenen hinweg, legen Sie den Pool-Manager unter ein Root-Objekt, das mit DontDestroyOnLoad markiert ist, und hängen Sie zurückgegebene Objekte wieder unter dieses Root-Objekt. Zwischen Levels zerstört Clear alle inaktiven Instanzen und gibt den Speicher frei.
Wann sich Pooling nicht lohnt
Das Folgende ist unsere Einschätzung aus der Praxis, keine offizielle Regel. Pooling lohnt sich für Objekte, die oft und in großer Zahl entstehen und verschwinden: Projektile, Treffereffekte, Schadenszahlen, Gegner in Wellen. Es lohnt sich nicht für Objekte, die selten entstehen oder das ganze Spiel über existieren, etwa die Spielfigur, Level-Geometrie oder ein Menü, das einmal geöffnet wird. Ein Pool bringt dort nur zusätzlichen Verwaltungscode, hält Speicher belegt und schafft eine weitere Quelle für Reset-Fehler.
Unsere Faustregel lautet deshalb: erst messen, dann poolen. Führen Sie einen Pool erst ein, wenn der Profiler zeigt, dass das Spawnen Sie tatsächlich etwas kostet.
Nicht nur GameObjects: Listen und Collections poolen
Pooling ist nicht auf GameObjects beschränkt. Eine häufige Quelle für Allokationen sind temporäre Listen, die in jedem Frame neu angelegt werden. Das Unity-Handbuch zeigt das Problem an einem einfachen Beispiel: eine Update-Methode, die in jedem Frame eine neue List<float> erzeugt. Die Lösung ist, die Liste als Feld zu halten und vor jeder Nutzung Clear aufzurufen. Clear entfernt die Werte, behält aber den belegten Speicher, sodass die Liste erst dann neu belegt, wenn sie wachsen muss.
Für temporäre Collections bietet UnityEngine.Pool außerdem fertige Pools wie ListPool<T>, HashSetPool<T>, DictionaryPool<TKey, TValue> und den allgemeinen CollectionPool<TCollection, TItem>. Die using-Variante gibt die Liste am Ende des Blocks automatisch an den Pool zurück:
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Pool;
public class TargetScanner : MonoBehaviour
{
private void Update()
{
using (ListPool<Vector3>.Get(out List<Vector3> positions))
{
// positions hier befüllen und verwenden
}
// ab hier ist die Liste wieder im Pool
}
}
Im Profiler nachweisen, dass es wirkt
Pooling ohne Messung ist Raten. Der Unity Profiler zeigt Ihnen deutlich, ob der Umbau geholfen hat. Die Unity-Dokumentation beschreibt die Schritte so:
- Öffnen Sie den Profiler über Window > Analysis > Profiler.
- Zeichnen Sie eine Szene mit vielen Spawns auf, am besten Ihren intensivsten Moment.
- Wählen Sie im Modul CPU Usage einen Frame aus und öffnen Sie die Ansicht Hierarchy.
- Sehen Sie sich die Spalte GC Alloc an. Sie zeigt, wie viele Bytes Unity in diesem Frame und Thread auf dem Managed Heap belegt hat.
Behalten Sie außerdem die Kategorie GarbageCollector im CPU-Diagramm im Blick. Sie zeigt, wie viel Zeit der Garbage Collector beansprucht. In der Ansicht Timeline erscheinen GC.Alloc-Samples in leuchtendem Magenta. Wenn Sie genau wissen wollen, woher eine Allokation stammt, aktivieren Sie in der Profiler-Toolbar Call Stacks. Unity empfiehlt das statt Deep Profiling, weil Deep Profiling selbst die Performance beeinflussen kann.
Vergleichen Sie eine Aufzeichnung vor und nach dem Umbau. War das Spawnen die Ursache, sollten die GC-Alloc-Werte bei viel Action spürbar sinken. Messen Sie nach Möglichkeit auf dem Zielgerät mit einem Development Build, denn der Editor bringt eigenen Overhead mit, und ein Desktop-PC verdeckt Probleme, die ein Smartphone oder Handheld zeigt. Warum Hardware-Unterschiede bei Garbage-Collection-Pausen so stark ins Gewicht fallen, beschreiben wir in unserem Beitrag zur Cross-Platform-Unity-Entwicklung von Mobile bis Konsole.
Checkliste: Object Pooling in fünf Schritten
- Erst messen. Prüfen Sie im Profiler, ob das Spawnen tatsächlich Allokationen und Spitzen verursacht.
- Den Pool beim Laden vorwärmen.
defaultCapacityerzeugt keine Objekte, also rufen Sie einmal in einer SchleifeGetundReleaseauf. - Den Zustand vollständig zurücksetzen. Position, Physik, Animation, Coroutines, Events und Partikel gehören in Ihren Reset.
- maxSize aus echten Spielwerten ableiten. Protokollieren Sie
CountActiveundCountInactivein Ihrer intensivsten Szene. - collectionCheck während der Entwicklung aktiv lassen und nach dem Umbau erneut profilen.
Fazit
Ruckler beim Spawnen sind selten ein Rätsel. Meist entstehen sie durch das ständige Erzeugen und Zerstören von Objekten und dadurch, dass der Garbage Collector danach aufräumen muss. ObjectPool<T> aus UnityEngine.Pool löst das mit wenig Code, solange Sie den Zustand sauber zurücksetzen, die Poolgröße an echten Daten ausrichten und den Pool beim Laden vorwärmen. Der Profiler zeigt Ihnen dann, ob es gewirkt hat, und er zeigt Ihnen auch, wo Pooling gar nicht nötig ist.
Ein praktischer nächster Schritt: Öffnen Sie heute den Profiler, zeichnen Sie Ihre belebteste Szene auf und sehen Sie sich die Spalte GC Alloc an. Wenn Sie bei Performance-Problemen in einem Unity-Projekt Unterstützung brauchen, hilft Ihnen ProjectMakers über unsere Leistungen zur Game-Entwicklung. Und wer bei der Framerate weitermachen möchte, findet in unserem Beitrag zu Real-Time Global Illumination in Unity das nächste Thema rund um Performance und Beleuchtung.