Przejdź do treści
ROBIEGRY.PL / PORTAL DLA TWÓRCÓW GIER / OD POMYSŁU DO PREMIERY
Strona główna Silniki i TechnologieSkoki Garbage Collectora w Unity: jak znaleźć alokacje i usunąć przycięcia?
Silniki i Technologie

Skoki Garbage Collectora w Unity: jak znaleźć alokacje i usunąć przycięcia?

Zbliżenie wykresu trendów danych na ekranie komputera
Rate this post

Czy każdy powtarzający się spadek płynności w grze stworzonej w silniku Unity oznacza, że trzeba natychmiast przepisać cały kod na struktury i całkowicie wyeliminować typy referencyjne? Intuicyjna reakcja wielu zespołów deweloperskich polega na natychmiastowym wdrożeniu agresywnej polityki zerowych alokacji, zamianie wszystkich ciągów znaków na tablice bajtów oraz obudowaniu każdego obiektu pulą instancji (object pooling). Taki odruch często prowadzi do nadmiernego skomplikowania architektury, wydłużenia czasu produkcji i wprowadzenia trudnych do wyśledzenia błędów związanych z cyklem życia obiektów, podczas gdy rzeczywista przyczyna przycięć tkwiła w zupełnie innym miejscu lub wymagała zaledwie punktowej korekty. [3]

Zanim podejmie się decyzję o głębokiej refaktoryzacji, konieczne jest precyzyjne oddzielenie faktów od powszechnych założeń. Zrozumienie, w jaki sposób środowisko uruchomieniowe C# zarządza pamięcią na stercie zarządzanej (managed heap), w którym momencie uruchamia się mechanizm odśmiecania pamięci oraz jak zlokalizować pojedyncze linijki kodu obciążające ten proces, pozwala rozwiązać problem przycięć (stutteringu) w sposób chirurgiczny, zachowując jednocześnie czytelność i łatwość utrzymania projektu.

Skąd biorą się przycięcia związane z Garbage Collectorem w Unity

Alokacja, śmieci i moment uruchomienia GC

Zarządzana sterta w środowisku Unity (opartym historycznie na silniku Mono, a na platformach docelowych najczęściej kompilowanym przez IL2CPP) działa w sposób automatyczny, odciążając programistę od konieczności ręcznego zwalniania pamięci za pomocą wskaźników. Za każdym razem, gdy w kodzie tworzony jest obiekt typu referencyjnego za pośrednictwem słowa kluczowego new, tworzona jest tymczasowa tablica, wykonywane jest pudełkowanie (boxing) lub wywoływana metoda zwracająca nowy obiekt, alokator rezerwuje odpowiednią liczbę bajtów na stercie. Sam proces alokacji pamięci jest zazwyczaj niezwykle szybki – sprowadza się do przesunięcia wskaźnika dostępnej przestrzeni.

Prawdziwy koszt pojawia się w momencie, gdy wolna przestrzeń na stercie zarządzanej ulegnie wyczerpaniu lub gdy silnik uzna, że nadszedł czas na przeprowadzenie cyklu odśmiecania. Domyślny Garbage Collector stosowany w Unity (wariant kolektora Boehm-Demers-Weiser) jest tradycyjnie odśmiecaczem typu Stop-the-World. Oznacza to, że w chwili uruchomienia procedury zbierania nieużywanych obiektów, główny wątek wykonawczy gry zostaje całkowicie zatrzymany. Kolektor musi przejrzeć graf referencji począwszy od korzeni (GC roots, takich jak zmienne statyczne czy stos wywołań wątków), oznaczyć obiekty wciąż używane, a następnie zwolnić pamięć zajmowaną przez te, do których nie prowadzi już żadne aktywne odwołanie.

Czas trwania tego zatrzymania zależy nie tyle od liczby bajtów zaalokowanych w danej klatce, ile od całkowitej wielkości sterty oraz liczby istniejących na niej obiektów, które algorytm musi przeanalizować. Jeżeli gra generuje drobne alokacje w każdej klatce (na przykład 2 kilobajty na klatkę przy częstotliwości 60 FPS), sterta zapełnia się systematycznie w tempie 120 kilobajtów na sekundę. W pewnym momencie kolektor uruchamia pełny cykl czyszczenia, a zatrzymanie głównego wątku na 15, 30 lub nawet 50 milisekund powoduje natychmiastowe wypadnięcie klatki i widoczne dla gracza szarpnięcie obrazu. Na wydajnej stacji roboczej z procesorem o wysokim taktowaniu pojedynczego rdzenia zatrzymanie to może trwać 3 milisekundy i pozostać niezauważone, lecz na urządzeniu mobilnym lub konsoli o ograniczonej przepustowości pamięci ten sam cykl zamieni się w uciążliwe zacięcie.

Jak rozpoznać, że winny jest GC, a nie renderowanie lub fizyka

Jednym z najczęstszych błędów diagnostycznych jest przypisywanie każdego spadku liczby klatek na sekundę działalności Garbage Collectora. Przycięcie animacji może wynikać z wielu niezależnych czynników: przeciążenia wątku renderowania (Render Thread) zbyt dużą liczbą wywołań rysowania (draw calls), kosztownego przetwarzania shaderów, zatoru po stronie procesora graficznego (GPU bound), zbyt gęstej siatki kolizji w silniku PhysX czy operacji wejścia-wyjścia na dysku w głównym wątku.

Diagnostyka wymaga weryfikacji charakterystycznego wzorca w profilu klatki. Gdy przyczyną problemu jest odśmiecanie pamięci, wykres czasu klatki wykazuje regularne lub nieregularne, ale bardzo ostre „szpilki” (spikes). Na osi czasu procesora pojawia się wówczas blok oznaczony jako GarbageCollector.Collect, GC.Collect lub pokrewne procedury wewnętrzne silnika, które pochłaniają lwią część budżetu klatki, podczas gdy w klatkach poprzedzających obciążenie CPU było stabilne i niskie. Jeśli natomiast wykres wykazuje stałe, podwyższone czasy trwania pętli Update, powolne przetwarzanie transformacji w PreLateUpdate lub wysokie opóźnienia w Gfx.WaitForPresentOnGfxThread, optymalizacja alokacji pamięci nie przyniesie zauważalnej poprawy płynności.

Wykresy analizy trendów rynkowych na ekranie monitora
Źródło: Pexels | Autor: AlphaTradeZone

Warto zachować analityczny krytycyzm wobec powszechnego przekonania, że każdy odnotowany w profilerze pik GC wymaga natychmiastowej eliminacji. Jeśli do czyszczenia pamięci dochodzi podczas wyciemnienia ekranu (fade out), otwierania menu pauzy czy przechodzenia między poziomami, czas poświęcony na usuwanie tych alokacji jest często czasem zmarnowanym. Prawdziwym celem inżynierskim jest eliminacja alokacji w tak zwanych gorących ścieżkach kodu (hot paths) – metodach wywoływanych co klatkę, w pętli fizyki oraz w trakcie intensywnej rozgrywki, gdzie każde zatrzymanie wątku bezpośrednio niszczy responsywność sterowania.

Najpierw pomiar: jak przygotować wiarygodne profilowanie projektu

Unity Profiler jako punkt startowy

Wbudowany moduł CPU Usage w narzędziu Unity Profiler stanowi podstawowe środowisko badawcze. Aby efektywnie diagnozować źródła problemu, należy zrezygnować z pobieżnego sprawdzania uśrednionego czasu klatki na rzecz szczegółowej analizy dwóch widoków: Timeline oraz Hierarchy. Widok Timeline pozwala zwizualizować relacje czasowe pomiędzy wątkami i błyskawicznie zidentyfikować moment, w którym główny wątek zamiera z powodu wywołania kolektora. Widok Hierarchy z kolei umożliwia posortowanie wywołań według kolumny GC Alloc. [2]

Podczas rejestrowania danych pomiarowych kluczowe jest nagranie powtarzalnego, reprezentatywnego scenariusza rozgrywki. Badanie powinno obejmować fragmenty, w których gracz wykonuje typowe, powtarzalne czynności: ciągły bieg przez lokację, walkę z udziałem sztucznej inteligencji, ostrzał z broni automatycznej czy nawigację po dynamicznie generowanym ekwipunku. Analiza pojedynczej, losowej klatki z pikiem GC daje niewiele informacji o systemowym zachowaniu aplikacji. Należy zbadać kilkaset kolejnych klatek poprzedzających skok, aby sprawdzić, które systemy stale zasilają stertę niepotrzebnymi danymi, przygotowując grunt pod nagłą interwencję kolektora.

Profilowanie w edytorze a pomiar w buildzie

Jednym z najpoważniejszych błędów metodologicznych jest opieranie optymalizacji pamięci wyłącznie na danych zebranych w edytorze Unity. Sam edytor jest olbrzymią aplikacją napisaną w C# i C++, która stale alokuje pamięć w celu obsługi własnego interfejsu, okien inspektora, hierarchii sceny oraz skryptów narzędziowych. Wywołania takie jak EditorUtility, odświeżanie komponentów przez silnik w trybie podglądu, a nawet rysowanie linii pomocniczych (Gizmos) wprowadzają gigantyczny szum pomiarowy. Wiele operacji silnika Unity alokuje pamięć wyłącznie wtedy, gdy kod działa wewnątrz edytora, ponieważ silnik generuje dodatkowe metadane na potrzeby debugowania.

Wiarygodne wnioski można wyciągnąć wyłącznie wtedy, gdy aplikacja zostanie skompilowana jako Development Build z włączonymi opcjami Autoconnect Profiler oraz Deep Profile Support (zależnie od potrzeb), a następnie uruchomiona na docelowej platformie sprzętowej lub w środowisku możliwie bliskim urządzeniu końcowemu. Różnice między środowiskami dotyczą nie tylko czystej mocy obliczeniowej, ale również implementacji kompilatora – kod skompilowany przez IL2CPP zachowuje się pod względem alokacji i zarządzania pamięcią nieco inaczej niż kod interpretowany przez maszynę wirtualną Mono w edytorze. [5]

Popularna rada mówi często: profiluj zawsze i wyłącznie buildy. Istnieje jednak sytuacja, w której ta reguła spowalnia pracę: wstępne, szybkie wykrywanie jawnych wycieków i rażących błędów w pętli Update można z powodzeniem zrealizować w edytorze, pod warunkiem, że ignoruje się alokacje pochodzące z podsystemów GUI edytora i koncentruje wyłącznie na nazwach własnych komponentów gry.

Deep Profile i Memory Profiler — narzędzia używane celowo

Tryb Deep Profile instrumentuje każdą metodę w kodzie C#, co pozwala zobaczyć pełne drzewo wywołań aż do najdrobniejszych funkcji pomocniczych. Jest to funkcja kusząca, ale niebezpieczna. Instrumentacja wprowadza kolosalny narzut obliczeniowy, drastycznie zakłamując proporcje czasowe poszczególnych operacji, a w większych projektach może doprowadzić do wyczerpania pamięci samego profilera lub zablokowania działania gry. Deep Profile należy włączać wyłącznie na krótki czas, w mocno izolowanych scenari

, a nie to, co powstało i natychmiast stało się śmieciem w ułamku sekundy pętli `Update`.
Akapit 3: Alternatywa techniczna: manualne markery

uszach testowych.

Wykresy i laptop na czarnym tle widziane z góry
Źródło: Pexels | Autor: Tima Miroshnichenko

Z kolei dedykowany pakiet Memory Profiler oferuje zupełnie inną perspektywę: pozwala na wykonanie zrzutu pamięci (snapshot) i precyzyjne podejrzenie obiektów, które w danym momencie rezydują na stercie wraz z relacjami referencji między nimi. To doskonałe narzędzie do wykrywania wycieków pamięci (obiektów, które powinny zostać zniszczone po zmianie sceny, ale nadal wiszą na statycznych zdarzeniach), jednak ma ograniczoną użyteczność w walce z chwilowymi szarpnięciami. Memory Profiler pokazuje bowiem to, co przetrwało, a nie to, co powstało i natychmiast stało się śmieciem w ułamku sekundy wewnątrz pętli Update.

Zamiast polegać na globalnym, obciążającym Deep Profilerze, znacznie czystszą techniką diagnostyczną jest ręczne instrumentowanie podejrzanych fragmentów kodu przy użyciu struktury ProfilerMarker z przestrzeni nazw Unity.Profiling. Taki marker ma niemal zerowy narzut wydajnościowy w buildzie produkcyjnym, a pozwala w izolowany sposób opakować podejrzany algorytm i wyświetlić go pod własną nazwą na osi czasu Unity Profilera:

static readonly ProfilerMarker s_InventoryUpdateMarker = new ProfilerMarker("Inventory.RefreshUI");

void Update()
{
    using (s_InventoryUpdateMarker.Auto())
    {
        RefreshInventorySlots();
    }
}

Jak dojść od wpisu GC Alloc do konkretnej linijki kodu

Włączanie rejestrowania stosu wywołań (Call Stacks)

Zobaczenie w oknie Hierarchy wpisu wskazującego, że metoda ProcessEnemies() generuje 4.2 KB alokacji w każdej klatce, to dopiero połowa sukcesu. Jeśli metoda liczy kilkadziesiąt linii i wywołuje liczne funkcje pomocnicze, ręczne zgadywanie winowajcy bywa frustrujące. Unity Profiler posiada wbudowaną, często pomijaną funkcję: Call Stacks.

W prawym górnym rogu modułu CPU Usage znajduje się przełącznik Call Stacks. Po jego aktywowaniu dla alokacji GC (wybranie opcji GC.Alloc) silnik zaczyna rejestrować pełny ślad stosu dla każdego zdarzenia przydziału pamięci na stercie zarządzanej. Po zatrzymaniu profilera i kliknięciu w konkretny wpis na liście, w dolnym panelu pojawia się kompletna ścieżka wykonania wraz z numerem pliku i dokładną linijką kodu źródłowego, która wywołała alokator. Zwykle wychodzi tak, że rejestrowanie stosu wywołań wiąże się ze zauważalnym spadkiem liczby FPS podczas samego pomiaru, dlatego opcję tę włącza się wyłącznie na czas polowania na konkretną alokację, po czym natychmiast ją wyłącza. [1]

Porównywanie stanów za pomocą Profile Analyzera

Kiedy projekt jest duży, pojedyncze zrzuty klatek nie dają pewności, czy wprowadzona zmiana realnie wyeliminowała problem, czy tylko przesunęła go w czasie. Narzędzie Profile Analyzer (dostępne w Unity Package Managerze) pozwala na bezpośrednie porównanie dwóch niezależnych sesji profilowania: przed optymalizacją i po niej.

Wczytanie kilkuset klatek z przebiegu bazowego oraz drugiego zestawu danych po wprowadzeniu poprawek pozwala na zestawienie wykresów mediany, wartości maksymalnych oraz sumarycznej wielkości GC Alloc w zadanym przedziale czasu. Dzięki temu zyskujemy twarde dowody inżynieryjne: czarno na białym widać, czy dana klasa całkowicie przestała zanieczyszczać stertę, czy jedynie zredukowano wielkość jej pojedynczej alokacji o połowę.

Najczęstsze źródła alokacji w kodzie Unity i C#

Większość problemów ze skokami Garbage Collectora nie wynika z architektury wysokiego poziomu, lecz ze specyficznych nawyków pisania kodu, które w aplikacjach webowych czy backendowych są całkowicie nieszkodliwe, natomiast w pętli 60 klatek na sekundę stają się destrukcyjne.

Manipulacje na łańcuchach znaków i formatowanie tekstu

W języku C# typ string jest niemutowalny. Każde użycie operatora +, metody string.Format, interpolacji $"{health}/{maxHealth}" czy wywołania value.ToString() skutkuje utworzeniem nowego obiektu na stercie zarządzanej. Jeśli w interfejsie użytkownika co klatkę aktualizowany jest licznik punktów lub pozycja kompasu, silnik produkuje setki krótkotrwałych obiektów tekstowych:

// ŹLE: Generuje alokacje w każdej klatce z powodu nowego stringa i boxing int
void Update()
{
    scoreText.text = "Score: " + currentScore.ToString();
}

Rozwiązaniem jest aktualizowanie interfejsu wyłącznie wtedy, gdy wartość faktycznie uległa zmianie (podejście sterowane zdarzeniami zamiast ciągłego odpytywania w Update), a w przypadku dynamicznych wartości liczbowych – stosowanie struktur buforowych, własnych parserów operujących na tablicach znaków (char[]) lub bibliotek takich jak TextMeshPro, które oferują metody ustawiania wartości liczbowych (np. SetText("{0}", currentScore)) bez generowania śmieci.

Ukryte pudełkowanie (boxing) i operacje na typach generycznych

Pudełkowanie zachodzi wtedy, gdy typ wartościowy (taki jak int, float, struct czy typ wyliczeniowy enum) jest rzutowany niejawnie na typ referencyjny – najczęściej object lub interfejs. Runtime musi wówczas zaalokować blok pamięci na stercie i skopiować do niego wartość ze stosu.

Osoba analizująca wykresy danych na laptopie
Źródło: Pexels | Autor: ThisIsEngineering

Klasycznym przykładem jest przekazywanie struktur do metod przyjmujących object, korzystanie ze starych API bazujących na wiadomościach tekstowych (np. SendMessage) czy używanie typu wyliczeniowego jako klucza w słowniku bez dostarczenia odpowiedniego komparatora:

// ŹLE: Domyślny słownik w starszych konfiguracjach boxingował enum przy porównywaniu
Dictionary<GameState, Action> stateHandlers = new Dictionary<GameState, Action>();

// DOBRZE: Wymuszenie dedykowanego komparatora zapobiega boxingowi
Dictionary<GameState, Action> safeHandlers = new Dictionary<GameState, Action>(new GameStateComparer());

Szczególną czujność należy zachować przy sprawdzaniu równości struktur lub wywoływaniu metod wbudowanych typu Equals czy GetHashCode, jeśli struktura nie implementuje interfejsu IEquatable<T> i nie przeciąża tych metod w sposób jawny.

Nieoptymalne API silnika Unity

Część starszych interfejsów programistycznych silnika zwraca nowo zaalokowane tablice zamiast modyfikować istniejące struktury danych. Każde odwołanie do takich właściwości jak Input.touches, Camera.allCameras czy metody fizyki typu Physics.RaycastAll i Collider.GetContacts rezerwuje nową tablicę na stercie:

// ŹLE: Tworzy nową tablicę Collider[] w każdym wywołaniu
Collider[] hits = Physics.OverlapSphere(transform.position, radius);

// DOBRZE: Użycie wersji NonAlloc z buforem przygotowanym wcześniej
int count = Physics.OverlapSphereNonAlloc(transform.position, radius, hitBuffer);
for (int i = 0; i < count; i++)
{
    ProcessHit(hitBuffer[i]);
}

Identyczna zasada dotyczy porównywania tagów obiektów. Zapis gameObject.tag == "Player" tworzy kopię łańcucha znaków zwracanego przez natywną warstwę C++ silnika, podczas gdy wywołanie gameObject.CompareTag("Player") operuje bezpośrednio na identyfikatorach w warstwie wewnętrznej bez jakiejkolwiek alokacji na stercie zarządzanej.

Wyrażenia lambda, domknięcia i delegaty

Przekazywanie funkcji jako parametrów (delegatów) jest fundamentem nowoczesnego C#, jednak w krytycznych pętlach wydajnościowych niesie ryzyko ukrytych alokacji. Jeśli wyrażenie lambda lub funkcja anonimowa odwołuje się do zmiennej lokalnej ze swojego zakresu otaczającego, kompilator tworzy niejawny obiekt domknięcia (closure class), który musi zostać zaalokowany na stercie w momencie tworzenia delegata:

int targetId = 42;
// ŹLE: Domknięcie przechwytuje targetId, powodując alokację obiektu domknięcia
enemies.Find(e => e.Id == targetId);

// DOBRZE: Zwykła pętla for po liście eliminuje alokację delegata i domknięcia
Enemy foundEnemy = null;
for (int i = 0; i < enemies.Count; i++)
{
    if (enemies[i].Id == targetId)
    {
        foundEnemy = enemies[i];
        break;
    }
}

Krótka lista weryfikacyjna dla pętli Update i FixedUpdate

Podczas przeglądu kodu w metodach wykonywanych co klatkę warto każdorazowo przetestować implementację pod kątem czterech zasadniczych reguł:

  • Zero słów kluczowych new w pętli: kolekcje, tablice pomocnicze i obiekty buforujące muszą być alokowane w metodach Awake lub Start i czyszczone za pomocą metody Clear().
  • Konsekwentna zamiana metod tablicowych na warianty NonAlloc: dotyczy to systemów fizyki 2D i 3D, zapytań o komponenty (GetComponentsNonAlloc) oraz systemów wejścia.
  • Eliminacja dynamicznego tworzenia instrukcji w korutynach: używanie yield return new WaitForSeconds(1f) alokuje obiekt co wywołanie; referencje do instrukcji oczekiwania należy buforować w polach klasy.
  • Zastąpienie LINQ imperatywnymi pętlami: metody rozszerzające z biblioteki System.Linq generują alokacje iteratorów i delegatów, które kumulują się w zawrotnym tempie.

Kiedy warto włączyć Incremental GC?

W nowszych wersjach Unity dostępna jest funkcja przyrostowego odśmiecania pamięci (Incremental GC). Zamiast wstrzymywać działanie gry na jeden długi blok czasu, kolektor dzieli fazę oznaczania i czyszczenia na wiele miniaturowych fragmentów rozłożonych na przestrzeni kilkunastu kolejnych klatek. Pozwala to ściąć wysokie skoki czasu klatki do

wartości rzędu 1–2 milisekund, które łatwiej zmieścić w sztywnym budżecie 16.6 ms (dla 60 FPS) lub 8.3 ms (dla 120 FPS). [4]

Laptop z panelem analitycznym śledzącym dane w czasie rzeczywistym
Źródło: Pexels | Autor: Atlantic Ambience

Warto jednak pamiętać, że Incremental GC nie zmniejsza całkowitej pracy, jaką środowisko uruchomieniowe musi wykonać, ani nie zapobiega fragmentacji pamięci. Wprowadza też niewielki narzut na tak zwane bariery zapisu (write barriers), które śledzą referencje modyfikowane w trakcie trwania procesu zbierania śmieci. Choć jest to doskonałe zabezpieczenie przed sporadycznymi szarpnięciami animacji w dynamicznych scenach, traktowanie go jako uniwersalnego zamiennika optymalizacji kodu prowadzi do sytuacji, w której rosnąca sterta pamięci nieodwracalnie obniża średnią wydajność całego projektu.

Skąd biorą się przycięcia związane z Garbage Collectorem w Unity

Aby skutecznie eliminować zacięcia animacji, warto najpierw zrozumieć, co dokładnie dzieje się pod maską środowiska wykonawczego Unity. Silnik opiera się na kolektorze typu Boehm-Demers-Weiser, który historycznie działa w modelu Stop-The-World. Oznacza to, że w chwili wywołania procedury czyszczenia główny wątek gry – odpowiedzialny za logikę, symulację fizyki oraz przesyłanie poleceń do karty graficznej – zostaje całkowicie zatrzymany do momentu zakończenia całego cyklu odśmiecania.

Kolektor nie uruchamia się w losowych odstępach czasu ani przy każdej pojedynczej alokacji. Silnik rezerwuje dla skryptów C# określoną pulę pamięci na stercie zarządzanej. Każde użycie słowa kluczowego new lub operacja pośrednio alokująca dane przesuwa wewnętrzny wskaźnik wolnej pamięci w tym bloku. Dopiero w momencie, gdy rozmiar nowej alokacji przekracza dostępną przestrzeń w zarezerwowanej stercie, runtime podejmuje decyzję o natychmiastowym uruchomieniu procedury GC, aby znaleźć obiekty nieposiadające już żadnych aktywnych referencji.

Proces ten składa się z dwóch faz: oznaczania (mark), podczas której kolektor skanuje wszystkie korzenie referencji i przeszukuje powiązane z nimi obiekty, oraz zamiatania (sweep), w której nieużywana pamięć jest oznaczana jako wolna. Im większa sterta i im więcej obiektów w pamięci, tym dłużej trwa faza przeszukiwania. Jeżeli po zakończeniu czyszczenia na stercie nadal brakuje ciągłego bloku pamięci o wymaganym rozmiarze, sterta zarządzana zostaje rozszerzona o kolejny fragment pamięci systemowej.

To właśnie rozszerzanie sterty i fragmentacja są bezpośrednią przyczyną tzw. GC spike. Silnik Unity nie kompaktuje zarządzanej sterty C# – raz przydzielone adresy pamięci nie są przesuwane w celu scalenia wolnych przestrzeni. Jeśli w pamięci znajduje się tysiące małych, krótkotrwałych obiektów przeplatanych obiektami trwałymi, powstaje zjawisko sita: mimo że sumaryczna ilość wolnej pamięci wynosi kilkadziesiąt megabajtów, próba alokacji jednej większej tablicy może wymusić powiększenie sterty i spowodować drastyczny spadek klatek trwający kilkadziesiąt milisekund.

Najpierw pomiar: jak przygotować wiarygodne profilowanie projektu

Profilowanie gry bezpośrednio w edytorze Unity jest najczęstszym błędem diagnostycznym. Sam edytor nakłada potężny narzut: wykonuje dziesiątki operacji w tle, monitoruje stan okien inspektora, przetwarza scenę i używa struktur pamięci, które w finalnym produkcie w ogóle nie istnieją. Pomiary wykonane w oknie Play Mode w edytorze mogą wykazać alokacje pochodzące wyłącznie z kodu edytorowego lub zafałszować rzeczywisty czas trwania operacji GC.

Podstawą rzetelnej analizy jest przygotowanie dedykowanego buildu rozwojowego i połączenie z nim zewnętrznego profilera:

W oknie Build Settings należy zaznaczyć opcje Development Build oraz Autoconnect Profiler. Pozwala to uruchomić grę jako odrębny proces na docelowej platformie (np. urządzeniu mobilnym lub konsoli), podczas gdy dane telemetryczne są przesyłane przez sieć lokalną lub kabel USB bezpośrednio do okna profilera na komputerze deweloperskim.

Warto zachować szczególną ostrożność wobec opcji Deep Profile. Choć funkcja ta automatycznie instrumentuje każdą pojedynczą metodę w skryptach i teoretycznie ułatwia lokalizację wąskich gardeł, w praktyce drastycznie wypacza strukturę czasową klatek i sama wprowadza dodatkowy narzut pamięciowy. Zamiast głębokiego profilowania znacznie bezpieczniej jest posługiwać się standardowym profilem rozwojowym z włączonym rejestrowaniem stosu alokacji lub precyzyjnie dodanymi znacznikami własnymi.

Pomiary należy zawsze wykonywać w warunkach jak najbardziej zbliżonych do rzeczywistej rozgrywki: po ustabilizowaniu się temperatury procesora urządzenia (aby wykluczyć wpływ throttlingu termicznego) i po załadowaniu właściwego poziomu, gdy miną początkowe alokacje inicjalizacyjne. Celem inżynieryjnym w stabilnej fazie gry powinno być dążenie do wartości 0 B w kolumnie GC Alloc w każdej regularnej klatce pętli aktualizacji.

Utrzymanie zera alokacji na stercie w pętli renderowania wymaga dyscypliny, jednak to właśnie ta powtarzalna higiena kodu decyduje o płynności rozgrywki. Eliminując krótkotrwałe obiekty, buforując kolekcje i opierając diagnostykę na pomiarach ze standalone buildu, likwidujemy samą przyczynę problemu, zamiast jedynie maskować jego objawy.

Najczęściej zadawane pytania (FAQ)

Czy każdy pik Garbage Collectora wymaga optymalizacji?

Nie. Najważniejsze jest sprawdzenie, czy GC zatrzymuje główny wątek podczas intensywnej rozgrywki. Alokacje w czasie przejść między poziomami lub otwierania menu mogą mieć mniejsze znaczenie.

Jak sprawdzić, czy przycięcie powoduje Garbage Collector?

W Unity Profiler należy przeanalizować widoki Timeline i Hierarchy oraz sprawdzić, czy w czasie skoku pojawia się GarbageCollector.Collect, GC.Collect lub podobna procedura.

Czy profilowanie w edytorze Unity jest wiarygodne?

Edytor może generować dodatkowy szum pomiarowy. Końcową weryfikację warto przeprowadzić w Development Build uruchomionym na docelowej platformie lub sprzęcie możliwie zbliżonym do urządzenia końcowego.

Czy Deep Profile powinien być włączony przez cały czas?

Nie. Deep Profile wprowadza duży narzut obliczeniowy i może zniekształcać wyniki. Należy używać go krótko, w izolowanych scenariuszach testowych.

Gdzie szukać alokacji powodujących przycięcia?

W Unity Profiler można posortować wywołania według kolumny GC Alloc i przeanalizować metody wykonywane co klatkę, w pętli fizyki oraz podczas intensywnej rozgrywki.

Źródła