Unity Zoptymalizuj swoją grę za pomocą Profilera
Wydajność jest kluczowym aspektem każdej gry i nic dziwnego, niezależnie od tego, jak dobra jest gra, jeśli będzie słabo działać na komputerze użytkownika, nie będzie już tak przyjemna.
Ponieważ nie każdy ma wysokiej klasy komputer lub urządzenie (jeśli kierujesz reklamy na urządzenia mobilne), ważne jest, aby podczas całego procesu tworzenia aplikacji mieć na uwadze wydajność.
Istnieje wiele powodów, dla których gra może działać wolno:
- Renderowanie (zbyt wiele siatek high-poly, złożonych shaderów lub efektów obrazu)
- Dźwięk (głównie spowodowane nieprawidłowymi ustawieniami importu audio)
- Niezoptymalizowany kod (skrypty zawierające funkcje wymagające wydajności w niewłaściwych miejscach)
W tym samouczku pokażę, jak zoptymalizować kod za pomocą Unity Profilera.
Profiler
Historycznie rzecz biorąc, debugowanie wydajności w Unity było żmudnym zadaniem, ale od tego czasu dodano nową funkcję o nazwie Profiler.
Profiler to narzędzie w Unity, które pozwala szybko zlokalizować wąskie gardła w grze poprzez monitorowanie zużycia pamięci, co znacznie upraszcza proces optymalizacji.
Zły występ
Zła wydajność może wystąpić w dowolnym momencie: załóżmy, że pracujesz nad instancją wroga i kiedy umieścisz ją na scenie, działa ona dobrze bez żadnych problemów, ale w miarę pojawiania się większej liczby wrogów możesz zauważyć liczbę klatek na sekundę ) zaczynają spadać.
Sprawdź poniższy przykład:
W Scenie mam Kostkę z dołączonym do niej skryptem, który przesuwa Kostkę z boku na bok i wyświetla nazwę obiektu:
SC_ShowName.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SC_ShowName : MonoBehaviour
{
bool moveLeft = true;
float movedDistance = 0;
// Start is called before the first frame update
void Start()
{
moveLeft = Random.Range(0, 10) > 5;
}
// Update is called once per frame
void Update()
{
//Move left and right in ping-pong fashion
if (moveLeft)
{
if(movedDistance > -2)
{
movedDistance -= Time.deltaTime;
Vector3 currentPosition = transform.position;
currentPosition.x -= Time.deltaTime;
transform.position = currentPosition;
}
else
{
moveLeft = false;
}
}
else
{
if (movedDistance < 2)
{
movedDistance += Time.deltaTime;
Vector3 currentPosition = transform.position;
currentPosition.x += Time.deltaTime;
transform.position = currentPosition;
}
else
{
moveLeft = true;
}
}
}
void OnGUI()
{
//Show object name on screen
Camera mainCamera = Camera.main;
Vector2 screenPos = mainCamera.WorldToScreenPoint(transform.position + new Vector3(0, 1, 0));
GUI.color = Color.green;
GUI.Label(new Rect(screenPos.x - 150/2, Screen.height - screenPos.y, 150, 25), gameObject.name);
}
}
Patrząc na statystyki, widzimy, że gra działa w ponad 800 fps, więc nie ma to żadnego wpływu na wydajność.
Ale zobaczmy, co się stanie, gdy powielimy kostkę 100 razy:
Fps spadł o ponad 700 punktów!
UWAGA: Wszystkie testy przeprowadzono przy wyłączonej funkcji Vsync
Ogólnie rzecz biorąc, dobrym pomysłem jest rozpoczęcie optymalizacji, gdy gra zaczyna się zacinać, zawieszać lub liczba klatek na sekundę spada poniżej 120.
Jak korzystać z Profilera?
Aby rozpocząć korzystanie z Profilera, będziesz potrzebować:
- Rozpocznij grę, naciskając przycisk Graj
- Otwórz Profiler, przechodząc do Okno -> Analiza -> Profiler (lub naciśnij Ctrl + 7)
- Pojawi się nowe okno, które wygląda mniej więcej tak:
- Na początku może to wyglądać onieśmielająco (szczególnie w przypadku tych wszystkich wykresów itp.), ale nie na tym będziemy się skupiać.
- Kliknij zakładkę Oś czasu i zmień ją na Hierarchia:
- Zauważysz 3 sekcje (EditorLoop, PlayerLoop i Profiler.CollectEditorStats):
- Rozwiń PlayerLoop, aby zobaczyć wszystkie części, na które zużywana jest moc obliczeniowa (UWAGA: Jeśli wartości PlayerLoop nie są aktualizowane, kliknij przycisk "Clear" u góry okna Profilera).
Aby uzyskać najlepsze rezultaty, skieruj swoją postać do sytuacji (lub miejsca), w której gra ma największe opóźnienia i poczekaj kilka sekund.
- Po chwili oczekiwania zatrzymaj grę i obserwuj listę PlayerLoop
Musisz sprawdzić wartość GC Alloc, która oznacza alokację zbierania śmieci. Jest to rodzaj pamięci, która została przydzielona przez komponent, ale nie jest już potrzebna i oczekuje na zwolnienie przez funkcję Garbage Collection. W idealnym przypadku kod nie powinien generować żadnych śmieci (lub być jak najbliżej 0).
Czas ms jest również ważną wartością, pokazuje, ile czasu zajęło wykonanie kodu w milisekundach, więc w idealnym przypadku powinieneś dążyć do zmniejszenia również tej wartości (poprzez buforowanie wartości, unikanie wywoływania funkcji wymagających wydajności przy każdej aktualizacji itp..).
Aby szybciej zlokalizować kłopotliwe części, kliknij kolumnę GC Alloc, aby posortować wartości od wyższych do niższych)
- Na wykresie Użycie procesora kliknij w dowolnym miejscu, aby przejść do tej ramki. W szczególności musimy przyjrzeć się szczytom, gdzie liczba klatek na sekundę była najniższa:
Oto, co ujawnił Profiler:
GUI.Repaint alokuje 45,4 KB, czyli całkiem sporo, a jego rozwinięcie ujawniło więcej informacji:
- Pokazuje, że większość alokacji pochodzi z metod GUIUtility.BeginGUI() i OnGUI() w skrypcie SC_ShowName, wiedząc, że możemy rozpocząć optymalizację.
GUIUtility.BeginGUI() reprezentuje pustą metodę OnGUI() (tak, nawet pusta metoda OnGUI() przydziela sporo pamięci).
Użyj Google (lub innej wyszukiwarki), aby znaleźć nazwiska, których nie rozpoznajesz.
Oto część OnGUI(), którą należy zoptymalizować:
void OnGUI()
{
//Show object name on screen
Camera mainCamera = Camera.main;
Vector2 screenPos = mainCamera.WorldToScreenPoint(transform.position + new Vector3(0, 1, 0));
GUI.color = Color.green;
GUI.Label(new Rect(screenPos.x - 150/2, Screen.height - screenPos.y, 150, 25), gameObject.name);
}
Optymalizacja
Zacznijmy optymalizację.
Każdy skrypt SC_ShowName wywołuje własną metodę OnGUI(), co nie jest dobrym rozwiązaniem, biorąc pod uwagę 100 instancji. Co zatem można z tym zrobić? Odpowiedź brzmi: mieć pojedynczy skrypt z metodą OnGUI(), który wywołuje metodę GUI dla każdej kostki.
- Najpierw zastąpiłem domyślną funkcję OnGUI() w skrypcie SC_ShowName publiczną pustą metodą GUIMethod(), która zostanie wywołana z innego skryptu:
public void GUIMethod()
{
//Show object name on screen
Camera mainCamera = Camera.main;
Vector2 screenPos = mainCamera.WorldToScreenPoint(transform.position + new Vector3(0, 1, 0));
GUI.color = Color.green;
GUI.Label(new Rect(screenPos.x - 150/2, Screen.height - screenPos.y, 150, 25), gameObject.name);
}
- Następnie stworzyłem nowy skrypt i nazwałem go SC_GUIMethod:
SC_GUIMethod.cs
using UnityEngine;
public class SC_GUIMethod : MonoBehaviour
{
SC_ShowName[] instances; //All instances where GUI method will be called
void Start()
{
//Find all instances
instances = FindObjectsOfType<SC_ShowName>();
}
void OnGUI()
{
for(int i = 0; i < instances.Length; i++)
{
instances[i].GUIMethod();
}
}
}
SC_GUIMethod zostanie dołączony do losowego obiektu w scenie i wywoła wszystkie metody GUI.
- Przeszliśmy od posiadania 100 indywidualnych metod OnGUI() do posiadania tylko jednej, naciśnijmy przycisk odtwarzania i zobaczmy wynik:
- GUIUtility.BeginGUI() przydziela teraz tylko 368B zamiast 36,7KB, co stanowi dużą redukcję!
Jednak metoda OnGUI() w dalszym ciągu przydziela pamięć, ale ponieważ wiemy, że wywołuje tylko metodę GUIMethod() ze skryptu SC_ShowName, od razu przejdziemy do debugowania tej metody.
Ale Profiler pokazuje tylko informacje globalne. Jak możemy zobaczyć, co dokładnie dzieje się w metodzie?
Aby debugować metodę, Unity ma przydatne API o nazwie Profiler.BeginSample
Profiler.BeginSample pozwala przechwycić określoną sekcję skryptu, pokazując, ile czasu zajęło wykonanie i ile pamięci przydzielono.
- Przed użyciem w kodzie klasy Profiler musimy na początku skryptu zaimportować przestrzeń nazw UnityEngine.Profiling:
using UnityEngine.Profiling;
- Próbkę Profilera przechwytuje się przez dodanie Profiler.BeginSample("SOME_NAME"); na początku przechwytywania i dodanie Profiler.EndSample(); na końcu przechwytywania, np. Ten:
Profiler.BeginSample("SOME_CODE");
//...your code goes here
Profiler.EndSample();
Ponieważ nie wiem, która część metody GUIMethod() powoduje alokację pamięci, umieściłem każdą linię w plikach Profiler.BeginSample i Profiler.EndSample (ale jeśli twoja metoda ma wiele linii, zdecydowanie nie musisz dołączać każdą linię, po prostu podziel ją na równe części, a następnie zacznij od tego).
Oto ostateczna metoda z zaimplementowanymi przykładami Profilera:
public void GUIMethod()
{
//Show object name on screen
Profiler.BeginSample("sc_show_name part 1");
Camera mainCamera = Camera.main;
Profiler.EndSample();
Profiler.BeginSample("sc_show_name part 2");
Vector2 screenPos = mainCamera.WorldToScreenPoint(transform.position + new Vector3(0, 1, 0));
Profiler.EndSample();
Profiler.BeginSample("sc_show_name part 3");
GUI.color = Color.green;
GUI.Label(new Rect(screenPos.x - 150/2, Screen.height - screenPos.y, 150, 25), gameObject.name);
Profiler.EndSample();
}
- Teraz naciskam Play i widzę, co pokazuje w Profilerze:
- Dla wygody wyszukałem "sc_show_" w Profilerze, ponieważ wszystkie próbki zaczynają się od tej nazwy.
- Ciekawe... Dużo pamięci jest przydzielane w części 3 sc_show_names, co odpowiada tej części kodu:
GUI.color = Color.green;
GUI.Label(new Rect(screenPos.x - 150/2, Screen.height - screenPos.y, 150, 25), gameObject.name);
Po krótkim googlowaniu odkryłem, że uzyskanie nazwy obiektu przydziela sporo pamięci. Rozwiązaniem jest przypisanie nazwy obiektu do zmiennej łańcuchowej w funkcji void Start(), w ten sposób zostanie on wywołany tylko raz.
Oto zoptymalizowany kod:
SC_ShowName.cs
using UnityEngine;
using UnityEngine.Profiling;
public class SC_ShowName : MonoBehaviour
{
bool moveLeft = true;
float movedDistance = 0;
string objectName = "";
// Start is called before the first frame update
void Start()
{
moveLeft = Random.Range(0, 10) > 5;
objectName = gameObject.name; //Store Object name to a variable
}
// Update is called once per frame
void Update()
{
//Move left and right in ping-pong fashion
if (moveLeft)
{
if(movedDistance > -2)
{
movedDistance -= Time.deltaTime;
Vector3 currentPosition = transform.position;
currentPosition.x -= Time.deltaTime;
transform.position = currentPosition;
}
else
{
moveLeft = false;
}
}
else
{
if (movedDistance < 2)
{
movedDistance += Time.deltaTime;
Vector3 currentPosition = transform.position;
currentPosition.x += Time.deltaTime;
transform.position = currentPosition;
}
else
{
moveLeft = true;
}
}
}
public void GUIMethod()
{
//Show object name on screen
Profiler.BeginSample("sc_show_name part 1");
Camera mainCamera = Camera.main;
Profiler.EndSample();
Profiler.BeginSample("sc_show_name part 2");
Vector2 screenPos = mainCamera.WorldToScreenPoint(transform.position + new Vector3(0, 1, 0));
Profiler.EndSample();
Profiler.BeginSample("sc_show_name part 3");
GUI.color = Color.green;
GUI.Label(new Rect(screenPos.x - 150/2, Screen.height - screenPos.y, 150, 25), objectName);
Profiler.EndSample();
}
}
- Zobaczmy, co pokazuje Profiler:
Wszystkie próbki przydzielają 0B, więc nie jest przydzielana więcej pamięci.