Ostatnim razem przedstawiłem teorię stojącą za ideą Object Poolingu. Omówiłem czym jest i dlaczego warto go używać. Dziś nadszedł czas na praktykę. W tym wpisie pokażę bardzo prosty przykład implementacji Object Poolingu.
Będzie to symulator cząsteczek spadających z jednego punktu na obszarze gry. Cząsteczki te mają krótki okres życia ale za to będą bardzo często się spawnować. Mój program będzie musiał poradzić sobie ze sporą ilością obiektów. Object Pooling sprawdzi się tu świetnie.
Kod przedstawionego przykładu pobrać można klikając w obrazek powyżej. Aby go uruchomić, wystarczy pobrać archiwum, rozpakować i uruchomić plik index.html w dowolnej przeglądarce.
Oczywiście, to co tu pokaże to nie jest jedyny sposób na implementację mechanizmu Object Poolingu. Podejść jest na pewno więcej i z pewnością istnieją lepsze. Mi zależało na tym aby przykład był jak najmniej skomplikowany. Chodzi bardziej o pokazanie jak coś działa niż o prezentację idealnej architektury.
Mój przykład zawiera trzy pliki: dokument HTML oraz dwa skrypty JSowe. Oto jak wygląda element body w dokumencie HTML:
1 2 3 4 |
<canvas id="canvas" width="300" height="300"></canvas> <script src="dblList.js" type="text/javascript"></script> <script src="app.js" type="text/javascript"></script> |
Nic wyjątkowego, oprócz canvas, na którym będę rysował moje obiekty, dołączam też dwa skrypty. Pierwszy z nich znajduje się w pliku dblList.js i zawiera deklarację klasy listy dwukierunkowej, strukturę danych, którą opisałem już kiedyś na blogu. Drugi skrypt, zawarty w app.js to kod implementujący mój dzisiejszy przykład.
Nie będę po raz kolejny opisywał działania listy dwukierunkowej. Tych, którzy nie znają tej struktury danych odsyłam do podlinkowanego wyżej posta. Dziś przyda mi się ona bardzo, ponieważ pozwoli wyeleminować operacje push oraz slice na tablicach. Jak wspomniałem w poście teoretycznym, te dwie metody są bardzo wymagające wydajnościowo.
Skupię się na zawartości pliku app.js, tam właśnie znajduje się moja implementacja Object Poolingu. Całość wrzucona jest do środka metody onload obiektu okna i jest dostępna globalnie. Wiem, nie najlepsza praktyka, ale tak jak pisałem wcześniej nie na tym chce się dziś skupić.
Na początku definiuje dwa główne elementy: konstruktor obiektu cząsteczki oraz sam obiekt Object Poolingu. Tutaj leżą sobie luzem, ale w prawdziwej grze dobrze byłoby opakować je w osobny moduł, lub stworzyć klasę nadrzędną obsługującą oba te elementy.
Klasa cząsteczki jest dość prosta:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
function Particle() { this.x = 10; this.y = 10; this.xSpeed = Math.random() * 2.5; this.width = 5; this.height = 5; this.active = false; this.toRemove = false; this.lifeTime = 30; this.aliveFor = 0; this.update = function(){ this.y += 4.5; this.x += this.xSpeed; this.aliveFor++; if(this.aliveFor>this.lifeTime) { this.toRemove = true; } } this.reset = function(){ this.x = 10; this.y = 10; this.xSpeed = Math.random() * 2.5; this.active = false; this.aliveFor = 0; this.toRemove = false; } } |
Powyższy obiekt oprócz standardowych informacji, takich jak rozmiar czy prędkość poruszania się, posiada dodaję także licznik służący do śledzenia czasu wyświetlania się cząsteczki. Są też dwie flagi toRemove oznaczająca czy cząsteczka ma zostać usunięta z gry oraz active, która oznajmia czy cząsteczka znajduje się aktualnie w grze czy nie. W Object Poolingu, nie usuwam, żadnego obiektu z gry, po prostu go „deaktywuje”. Nie tworzę też nowych obiektów, jeśli naprawdę nie ma na to potrzeby, zamiast tego po prostu „włączam” obiekt „wyłączony”. To taki recycling obiektów, przyjazny środowisku 🙂 .
Metoda update modyfikuje współrzędne obiektu oraz oblicza czas jego wyświetlania. Jeżeli obiekt jest w grze już zbyt długo flaga toRemove zostaje podniesiona.
Na koniec zostaje metoda reset. W Object Poolingu zawsze znajduje się metoda przywracająca obiekt do jakiegoś domyślnego stanu. Jest to koniecznie jeżeli chcemy korzystać z tych samych obiektów wiele razy.
Kolejny element dzisiejszego przykład to zbiór obiektów, czyli tytułowy Object Pool:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
var ParticlePool = { baseSize: 0, sizeIncrement: 10, particles: [], indexes: new DoublyLinkedList(), grow: function(){ var newSize = this.baseSize + this.sizeIncrement; for (var i = this.baseSize; i < newSize; i++) { this.particles.push(new Particle()) this.indexes.append(i); } this.baseSize = newSize; }, spawnParticle: function(){ if(this.indexes.head === null) { this.grow(); } var index = this.indexes.viewAt(0); this.particles[index].active = true; this.indexes.removeAt(0); console.log(this.indexes.view()); console.log(this.baseSize); }, recycle: function(index){ this.particles[index].reset(); this.indexes.append(index); } }; |
Jak widać mój zbiór obiektów dodałem w formie literału obiektu, ale nic nie stoi na przeszkodzie aby stworzyć bardziej uniwersalną klasę, którą można stosować dla różnych elementów gry. Ja w imię prostoty wybrałem powyższą drogę.
Pierwsze dwa pola dotyczą rozmiaru Object Pool’a. baseSize to po prostu ilość elementów w zbiorze. Jak widać początkowo równy jest on zero. Zbiór ma w sobie „inteligentny” system, powiększający go, jeżeli zajdzie taka potrzeba. sizeIncrement to ilość obiektów, która zostaje dodana do zbioru okaże się on zbyt mały.
Kolejne pole particles to tablica, która przechowuje faktyczne instancje obiektów ze zbioru. Tak, wiem, że pisałem, że tablice są be, ale spokojnie, ta będzie zmieniana bardzo rzadko, a to właśnie zmiany w strukturze tablicy bywają najbardziej wymagające wydajnościowo.
Ostatnie pole to indexes. Zawiera ono dwukierunkową listę, która przechowywać będzie indeksy wolnych obiektów, to tą strukturą danych będę najwięcej manipulował podczas działania programu. Nie jest to tablica, więc powinienem trochę zyskać na wydajności. Oczywiście w zwykłej aplikacji webowej, nie byłoby sensu bawić się w takie rzeczy, jest to optymalizacja trochę „na siłę”, ale jeśli chodzi o gry, to ta oszczędność ma znaczenie.
Po polach do mojego obiektu ParticlePool dodaję trzy metody: grow, spawnParticle oraz recycle. Pierwsza wywoływana jest gdy program zażąda nowego obiektu, a wszystkie znajdujące się w zbiorze będą już aktywne. Metoda w takiej sytuacji zwiększy tablicę particles o dziesięć nowych elementów, jest to jedyny moment, kiedy tablica jest modyfikowana. grow dodaje również indeksy nowo utworzonych (i gotowych do użycia) elementów do listy indexes. Metoda ta zostanie wywołana przynajmniej raz, gdy program po raz pierwszy poprosi o element ze zbioru.
Kolejna metoda to spawnParticle. Jak sugeruje nazwa służy ona do dodawania nowych cząsteczek do gry. Pierwszą rzeczą, którą należy wykonać jest sprawdzenie czy lista indexes nie jest pusta (head listy równe null oznacza, że nie ma w niej żadnych elementów). Jeżeli zajdzie taka sytuacja wywoływana jest metoda grow. W przeciwnym wypadku pobierana jest pierwsza wartość z listy i cząsteczka w tablicy particles spod pobranego indeksu ustawiana jest jako aktywna. Na koniec, usuwam pierwszy element z indexes. W tej metodzie zostawiłem też na koniec logi do konsoli, pokazujące które indeksy są aktualnie wolne, oraz jak duża jest lista particles. W ten sposób, po zajeżeniu do konsoli, można zobaczyć, że nowe instancje obiektów faktycznie nie są tworzone.
Ostatnia metoda mojego zbioru obiektów to recycle. Służy ona do zrecyklingowania zużytej cząsteczki 🙂 . Metoda ta przyjmuje liczbę, która jest indeksem elementu w tablicy particles. Element znajdujący się pod tyk indeksem zostaje zresetowany, a sam indeks ląduje w liście indexes.
Teraz pozostało już tylko napisać działającą atrapę game loopa i zobaczyć jak to wszystko sprawdza się w naturze. Oto jak wygląda reszta programu:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
var timer = 0; function handleTimer(){ timer++; if(timer > 2) { timer = 0; } } function theLoop() { update(); render(); requestAnimationFrame(theLoop); } function update(){ handleTimer(); if(timer === 2) { ParticlePool.spawnParticle(); } for (var i = 0; i < ParticlePool.particles.length; i++) { var part = ParticlePool.particles[i]; if(part.toRemove){ ParticlePool.recycle(i); } if(part.active){ part.update(); } } } function render(){ ctx.clearRect(0,0,300,300) for (var i = 0; i < ParticlePool.particles.length; i++) { var part = ParticlePool.particles[i]; if(part.active){ ctx.fillRect(part.x,part.y,part.width,part.height); } } } theLoop(); |
Większość tego kodu powinna być jasna. Najpierw tworzę prosty licznik czasu, którym odliczam czas do kolejnego nowego obiektu cząsteczki. Następnie dodaję prosty game loop na bazie requestAnimationFrame, zawierający funkcje update oraz render. Obie iterują po tablicy particles zbioru obiektów i wchodzą w interakcje z tymi jej elementami, które są aktywne.
render po prostu wyrysowuje na obszarze gry aktywne cząsteczki. update natomiast, najpierw sprawdza czy cząsteczki nie są oflagowane jako do usunięcia, jeśli tak wywołuje metodę recycle zbioru i przekazuje jej indeks aktualnego obiegu pętli. Jeśli cząsteczka nie jest przeznaczona do recyklingu, wywoływana jest jej metoda update. Do tego główne update sprawdza czy nie jest pora na dodanie do gry nowej cząsteczki, jeśli tak wywoływane jest spawnParticle.
I to w zasadzie wszystko. Po otwarciu przykładu w przeglądarce zobaczyć można, że dzięki tej konstrukcji do gry cały czas dodawana jest bardzo duża ilość obiektów cząsteczek, a faktyczna liczba znajdujących się w programie obiektów nie przekracza dwudziestu. To bardzo fajne osiągnięcie. Warto sprawdzić co się dzieje, gdy zamiast jednego obiekt, metoda update tworzy dwa, trzy, dziesięć lub więcej. Zużycie pamięci powinno być cały czas stabilne.
Co można byłoby zrobić lepiej? Sporo rzeczy. Na pewno podzielenie całości na zgrabne klasy i moduły spowodowałoby że rozwiązanie byłoby bardziej uniwersalne. Dobrze byłoby dodać do zbioru obiektów jego własne metody update oraz render, dzięki temu byłoby bardziej „przenaszalny”. Zachęcam do zabawy z przykładowym kodem na własną rękę oraz do prób budowania własnych zbiorów obiektów, w ten sposób można się wiele nauczyć.
Na dziś to wszystko. Dajcie znać czy podobają się wam wpisy o poprawianiu wydajności w grach w JS. Mam jeszcze kilka pomysłów na wpisy o tej tematyce.
Jak zawsze zachęcam też do polubienia mojej strony na facebooku. Zawsze zamieszczam tam informacje o wszystkich nowościach. Jest to też dobre miejsce na kontakt ze mną. Na wszystkie pytania zawsze odpowiem :).
Dzień dobry, od niedawna bawie się w rysowanie po canvie z pomocą czystego javascript. Napotkałam na mur w momencie gdzie chciałam jednocześnie rysować i odtwarzać pliki audio za pomocą eventu keydown. Czy lista dwukierunkowa jest lepsza wydajnościowo niż push() na tablicy,żeby wypchnąć cząsteczkę bądź audio z Pola obiektów?
Jedna metoda na nakładające się dźwięki on keydown spodowała praktycznie zatrzymywanie sie/lag animacji.
Jak mogłabym rysować cząsteczki z tego artykułu w 2+ miejscach na raz na kanwie,wciskając klawisze f oraz h?
Nie jestem pewna czego szukam, rysuje na kanwie obiektami, wygenerowałam cząsteczki funkcją i wepchałam je do pustej tablicy, teraz próbuje object poolingu dla rysowania obiektów oraz audio ale animacja dostaje lagów,wszystko mam na localhoscie więc nie podrzuce linka,proszę o porady/hasła co powinnam wygooglować żeby ruszyć z miejsca, dziękuje i pozdrawiam 🙂