World Wide Maze to gra, w której za pomocą smartfona kierujesz kulką w labiryncie 3D utworzonym z witryn internetowych, aby dotrzeć do punktów docelowych.
Gra obfituje w funkcje HTML5. Na przykład zdarzenie DeviceOrientation pobiera dane o przechyleniu ze smartfona, które są następnie wysyłane do komputera za pomocą WebSocket. Gracze mogą wtedy poruszać się po przestrzeniach 3D utworzonych przez WebGL i Web Workers.
W tym artykule dokładnie opisuję, jak te funkcje są używane, jak wygląda cały proces tworzenia i co jest kluczowe w optymalizacji.
DeviceOrientation
Zdarzenie DeviceOrientation (przykład) służy do pobierania danych o przechyleniu z telefonu. Gdy funkcja addEventListener
jest używana w związku ze zdarzeniem DeviceOrientation
, co jakiś czas jest wywoływane wywołanie zwrotne z obiektem DeviceOrientationEvent
jako argumentem. Intervale różnią się w zależności od używanego urządzenia. Na przykład w przypadku iOS + Chrome i iOS + Safari wywołanie funkcji zwrotnej jest wywoływane mniej więcej co 1/20 sekundy, a w przypadku Android 4 + Chrome – co 1/10 sekundy.
window.addEventListener('deviceorientation', function (e) {
// do something here..
});
Obiekt DeviceOrientationEvent
zawiera dane pochylenia dla osi X
, Y
i Z
w stopniach (a nie w radianach) (więcej informacji o HTML5Rocks). Wartości zwracane różnią się jednak w zależności od kombinacji urządzenia i przeglądarki. Zakresy rzeczywistych wartości zwracanych podano w tabeli poniżej:
Wartości u góry wyróżnione na niebiesko są zdefiniowane w specyfikacjach W3C. Te wyróżnione na zielono spełniają te wymagania, a te wyróżnione na czerwono – nie. Co zaskakujące, tylko kombinacja Androida i Firefoksa zwróciła wartości zgodne ze specyfikacją. W praktyce jednak lepiej jest uwzględnić wartości, które występują często. Dlatego World Wide Maze używa wartości zwracanych przez iOS jako standardowych i odpowiednio je dostosowuje do urządzeń z Androidem.
if android and event.gamma > 180 then event.gamma -= 360
Nie dotyczy to jednak Nexusa 10. Chociaż Nexus 10 zwraca ten sam zakres wartości co inne urządzenia z Androidem, występuje błąd, który odwraca wartości beta i gamma. Rozpatrzymy to osobno. (Może jest ustawiona domyślnie na orientację poziomą?)
Jak widać, nawet jeśli interfejsy API dotyczące urządzeń fizycznych mają określone specyfikacje, nie ma gwarancji, że zwrócone wartości będą zgodne z tymi specyfikacjami. Dlatego ważne jest, aby przetestować je na wszystkich potencjalnych urządzeniach. Oznacza to też, że mogą zostać wprowadzone nieoczekiwane wartości, co wymaga stosowania obejść. W pierwszym kroku samouczka World Wide Maze użytkownicy po raz pierwszy są proszeni o skalibrowanie urządzeń, ale gra nie będzie mogła skalibrować się prawidłowo do pozycji zerowej, jeśli otrzyma nieoczekiwane wartości pochylenia. Dlatego ma wewnętrzny limit czasu i wyświetla komunikat z prośbą o przełączenie się na sterowanie za pomocą klawiatury, jeśli nie może skalibrować urządzenia w tym czasie.
WebSocket
W World Wide Maze smartfon i komputer są połączone przez WebSocket. Bardziej precyzyjnie, są one połączone przez serwer pośredniczący, np. smartfon z serwerem z komputera PC. Wynika to z tego, że WebSocket nie umożliwia bezpośredniego łączenia przeglądarek. (Korzystanie z kanałów danych WebRTC umożliwia połączenie typu peer-to-peer i eliminuje potrzebę korzystania z serwera przekaźnikowego, ale w momencie implementacji tej metody można było używać tylko w Chrome Canary i Firefox Nightly).
Wybrałem implementację za pomocą biblioteki Socket.IO (wersja 0.9.11), która zawiera funkcje umożliwiające ponowne nawiązanie połączenia w przypadku przekroczenia limitu czasu lub rozłączenia. Używałem go razem z NodeJS, ponieważ ta kombinacja NodeJS + Socket.IO wykazała się najlepszą wydajnością po stronie serwera w kilku testach implementacji WebSocket.
Parowanie według numerów
- Komputer łączy się z serwerem.
- Serwer przypisuje komputerowi losowo wygenerowany numer i zapamiętuje tę kombinację.
- Na urządzeniu mobilnym określ numer i połącz się z serwerem.
- Jeśli numer jest taki sam jak na połączonym komputerze, urządzenie mobilne jest sparowane z tym komputerem.
- Jeśli nie ma wyznaczonego komputera PC, wystąpi błąd.
- Gdy dane są przesyłane z urządzenia mobilnego, są wysyłane na sparowany z nim komputer i odwrotnie.
Możesz też nawiązać pierwsze połączenie za pomocą urządzenia mobilnego. W takim przypadku urządzenia są po prostu odwrócone.
Synchronizacja kart
Funkcja synchronizacji kart w Chrome ułatwia parowanie. Dzięki temu możesz łatwo otwierać na urządzeniu mobilnym strony otwarte na komputerze (i odwrotnie). Komputer PC pobiera numer połączenia wydany przez serwer i dodaje go do adresu URL strony za pomocą history.replaceState
.
history.replaceState(null, null, '/maze/' + connectionNumber)
Jeśli włączona jest synchronizacja kart, adres URL jest synchronizowany po kilku sekundach, a na urządzeniu mobilnym można otworzyć tę samą stronę. Urządzenie mobilne sprawdza adres URL otwartej strony i jeśli dołączona jest liczba, natychmiast rozpoczyna nawiązywanie połączenia. Dzięki temu nie musisz wpisywać numerów ręcznie ani skanować kodów QR za pomocą aparatu.
Czas oczekiwania
Ponieważ serwer pośredniczący znajduje się w Stanach Zjednoczonych, dostęp do niego z Japonii powoduje opóźnienie około 200 ms, zanim dane z pochylenia smartfona dotrą do komputera. Czasy reakcji były wyraźnie wolne w porównaniu z czasami uzyskanymi w środowisku lokalnym używanym podczas tworzenia, ale wstawienie czegoś w rodzaju filtra dolnoprzepustowego (użyłem EMA) pozwoliło osiągnąć niezauważalne czasy. (W praktyce filtr dolnoprzepustowy był potrzebny również do celów prezentacji; wartości z powrotu z czujnika pochylenia zawierały znaczną ilość szumu, a zastosowanie tych wartości do ekranu powodowało znaczne drżenie). Nie działało to w przypadku skoków, które były wyraźnie powolne, ale nie można było tego rozwiązać.
Od początku spodziewałem się problemów z opóźnieniami, więc rozważałem skonfigurowanie serwerów przekazujących na całym świecie, aby klienci mogli łączyć się z najbliższym dostępnym serwerem (co pozwoli zminimalizować opóźnienia). Ostatecznie jednak użyliśmy Google Compute Engine (GCE), która w tym czasie była dostępna tylko w Stanach Zjednoczonych, więc nie było to możliwe.
Problem algorytmu Nagle
Algorytm Nagle jest zwykle włączony w systemach operacyjnych, aby zapewnić wydajną komunikację przez buforowanie na poziomie TCP, ale okazało się, że nie mogę wysyłać danych w czasie rzeczywistym, gdy ten algorytm jest włączony. (szczególnie w połączeniu z opóźnionym potwierdzeniem TCP). Nawet jeśli nie ma opóźnienia ACK
, ten sam problem występuje, gdy ACK
jest opóźniony w pewnym stopniu z powodu czynników takich jak serwer znajdujący się za granicą.
Problem z opóźnieniem Nagle nie wystąpił w przypadku WebSocket w Chrome na Androida, który zawiera opcję TCP_NODELAY
umożliwiającą wyłączenie Nagle, ale wystąpił w przypadku WebSocket WebKit używanego w Chrome na iOS, w którym ta opcja nie jest włączona. (Safari, które używa tego samego WebKit, również miało ten problem. Problem został zgłoszony do Apple przez Google i został rozwiązany w wersji rozwojowej WebKita.
Gdy wystąpi ten problem, dane pochylenia wysyłane co 100 ms są łączone w kawałki, które docierają do komputera tylko co 500 ms. Gra nie może działać w takich warunkach, więc unika opóźnień, wysyłając dane po stronie serwera w krótkich odstępach czasu (co około 50 ms). Uważam, że otrzymywanie ACK
w krótkich odstępach czasu sprawia, że algorytm Nagle myśli, że można wysyłać dane.
Powyższe wykresy przedstawiają przedziały czasu rzeczywistych danych. Wskazuje ona przedziały czasowe między pakietami: zielony oznacza przedziały wyjściowe, a czerwony – wejściowe. Minimalny czas to 54 ms, maksymalny – 158 ms, a średnio – około 100 ms. Tutaj używam iPhone'a z serwerem przekaźnikowym w Japonii. Zarówno wyjście, jak i wejście zajmują około 100 ms, a działanie jest płynne.
Ten wykres pokazuje natomiast wyniki korzystania z serwera w Stanach Zjednoczonych. Chociaż zielone interwały wyjściowe utrzymują się na poziomie 100 ms, interwały wejściowe wahają się między 0 ms a 500 ms, co wskazuje, że komputer PC odbiera dane w porcjach.
Ten wykres pokazuje, jak uniknąć opóźnień, wysyłając dane za pomocą danych zastępczych. Chociaż nie działa tak dobrze jak serwer japoński, interwały wejściowe pozostają stosunkowo stabilne i wynoszą około 100 ms.
Błąd?
Mimo że domyślna przeglądarka w Androidzie 4 (ICS) ma interfejs WebSocket API, nie może się połączyć, co powoduje zdarzenie connect_failed Socket.IO. Wewnętrznie następuje przekroczenie limitu czasu, a po stronie serwera nie można też zweryfikować połączenia. (Nie testowaliśmy tego tylko z WebSocket, więc problem może być związany z Socket.IO).
Skalowanie serwerów przekaźnika
Ponieważ rola serwera przekaźnikowego nie jest tak skomplikowana, zwiększenie liczby serwerów nie powinno być trudne, o ile zadbasz o to, aby ten sam komputer i urządzenie mobilne były zawsze połączone z tym samym serwerem.
Fizyka
Ruch piłki w grze (toczenie się po zboczu, kolizje z podłożem, kolizje ze ścianami, zbieranie przedmiotów itp.) jest realizowany za pomocą symulatora fizyki 3D. Użyłem Ammo.js, czyli portu popularnego silnika fizyki Bullet w JavaScript za pomocą Emscripten, a także Physijs, aby wykorzystać go jako „proces wątek sieciowy”.
Skrypty Web Worker
Web Workers to interfejs API do uruchamiania kodu JavaScript w osobnych wątkach. Kod JavaScript uruchamiany jako Web Worker działa jako osobny wątek od tego, który go wywołał, dzięki czemu można wykonywać czasochłonne zadania, zachowując przy tym responsywność strony. Narzędzie Physijs korzysta z elementów Web Workers, aby zapewnić płynne działanie zazwyczaj intensywnego silnika fizyki 3D. World Wide Maze obsługuje silnik fizyczny i renderowanie obrazu WebGL z zupełnie inną częstotliwością wyświetlania klatek, więc nawet jeśli na urządzeniu o niskich specyfikacjach częstotliwość ta spadnie z powodu dużego obciążenia renderowania WebGL, sam silnik fizyczny będzie mniej więcej utrzymywać 60 FPS i nie będzie to przeszkadzać w sterowaniu grą.
Ten obraz pokazuje uzyskane częstotliwości klatek na Lenovo G570. Górne pole pokazuje liczbę klatek dla WebGL (renderowanie obrazu), a dolne – dla silnika fizycznego. GPU to zintegrowany układ graficzny Intel HD Graphics 3000, więc częstotliwość generowania klatek nie osiągnęła oczekiwanych 60 FPS. Jednak ponieważ silnik fizyki osiągnął oczekiwaną liczbę klatek na sekundę, rozgrywka nie różni się znacząco od wydajności na komputerze o wysokich specyfikacjach.
Ponieważ wątki z aktywnymi Web Workers nie mają obiektów konsoli, aby wygenerować dzienniki debugowania, dane muszą zostać wysłane do głównego wątku za pomocą postMessage. Użycie console4Worker tworzy w Workerze odpowiednik obiektu konsoli, co znacznie ułatwia debugowanie.
Najnowsze wersje Chrome umożliwiają ustawianie punktów przerwania podczas uruchamiania procesów Web Worker, co jest przydatne również podczas debugowania. Znajdziesz go w panelu „Pracownicy” w Narzędziach dla programistów.
Wyniki
Etapy o dużej liczbie wielokątów czasami przekraczają 100 tys. wielokątów,ale wydajność nie ucierpiała na tym nawet wtedy, gdy zostały wygenerowane całkowicie jako Physijs.ConcaveMesh
(btBvhTriangleMeshShape
w Bullet).
Początkowo częstotliwość wyświetlania klatek spadała wraz ze wzrostem liczby obiektów wymagających wykrywania kolizji, ale wyeliminowanie niepotrzebnego przetwarzania w Physijs poprawiło wydajność. To ulepszenie zostało wprowadzone w forku oryginalnego pakietu Physijs.
Obiekty typu „ghost”
Obiekty, które mają wykrywanie kolizji, ale nie mają wpływu na kolizję, a więc nie mają wpływu na inne obiekty, są w Bullet nazywane „obiektmi-widmo”. Chociaż Physijs nie obsługuje oficjalnie obiektów typu ghost, można je tam tworzyć, manipulując flagami po wygenerowaniu Physijs.Mesh
. World Wide Maze używa obiektów-widm do wykrywania kolizji obiektów i punktów docelowych.
hit = new Physijs.SphereMesh(geometry, material, 0)
hit._physijs.collision_flags = 1 | 4
scene.add(hit)
W przypadku collision_flags
1 to CF_STATIC_OBJECT
, a 4 to CF_NO_CONTACT_RESPONSE
. Aby dowiedzieć się więcej, poszukaj informacji na forum Bullet, na Stack Overflow lub w dokumentacji Bullet. Physijs jest owijaczem dla Ammo.js, a Ammo.js jest w podstawie identyczne z Bullet, więc większość rzeczy, które można wykonać w Bullet, można też wykonać w Physijs.
Problem z Firefoxem 18
Aktualizacja Firefoxa z wersji 17 na 18 zmieniła sposób wymiany danych przez procesy robocze w przeglądarce, przez co komponent Physijs przestał działać. Problem został zgłoszony na GitHubie i rozwiązany po kilku dniach. Ta wydajność oprogramowania open source zrobiła na mnie wrażenie, ale przypomniała mi też, że World Wide Maze składa się z kilku różnych frameworków open source. Piszę ten artykuł, aby przekazać Ci opinię.
asm.js
Chociaż nie dotyczy to bezpośrednio World Wide Maze, Ammo.js obsługuje już ogłoszony niedawno przez Mozillę asm.js (nie jest to zaskakujące, ponieważ asm.js został stworzony głównie po to, aby przyspieszyć JavaScript generowany przez Emscripten, a twórca Emscripten jest też twórcą Ammo.js). Jeśli Chrome obsługuje też asm.js, obciążenie procesora związane z fizyką powinno znacznie się zmniejszyć. Prędkość była zauważalnie większa podczas testowania w Firefox Nightly. Może lepiej byłoby napisać sekcje wymagające większej szybkości w języku C/C++, a potem przeportować je do JavaScript za pomocą Emscripten?
WebGL
Do implementacji WebGL użyłem biblioteki three.js (r53), która jest najbardziej aktywnie rozwijana. Chociaż wersja 57 została już wydana w ostatnich etapach rozwoju, w interfejsie API wprowadzono istotne zmiany, więc zdecydowałem się na opublikowanie wersji pierwotnej.
Efekt poświaty
Efekt poświaty dodany do rdzenia piłki i elementów jest implementowany za pomocą prostej wersji tak zwanej metody Kawase MGF. Jednak podczas gdy metoda Kawase powoduje rozjaśnienie wszystkich jasnych obszarów, World Wide Maze tworzy osobne cele renderowania dla obszarów, które mają się świecić. Wynika to z tego, że do tekstur sceny trzeba użyć zrzutu ekranu strony internetowej, a proste wyodrębnienie wszystkich jasnych obszarów spowoduje, że cała strona będzie świecić, jeśli ma na przykład białe tło. Rozważałem też przetworzenie wszystkiego w HDR, ale tym razem zrezygnowałem z tego, ponieważ implementacja byłaby dość skomplikowana.
W lewym górnym rogu widać pierwszy przejazd, w którym obszary z efektem poświaty zostały renderowane osobno, a następnie zastosowano rozmycie. W prawym dolnym rogu widać drugi przetworzony obraz, w którym rozmiar obrazu został zmniejszony o 50%, a następnie zastosowano rozmycie. W prawym górnym rogu widać trzecią iterację, w której obraz został ponownie zmniejszony o 50%, a następnie rozmyty. Następnie zostały one nałożone na siebie, aby utworzyć ostateczny obraz złożony, który widać w lewym dolnym rogu. Do rozmycia użyłem funkcji VerticalBlurShader
i HorizontalBlurShader
, które są dostępne w three.js, więc nadal jest miejsce na dalszą optymalizację.
Odblaskowa kula
Odbicie na piłce jest oparte na próbce z three.js. Wszystkie kierunki są renderowane z pozycji piłki i używane jako mapy środowiska. Mapy środowiska trzeba aktualizować za każdym razem, gdy piłka się porusza, ale ponieważ aktualizacja z częstotliwością 60 FPS jest bardzo obciążająca, mapy są aktualizowane co 3 klatki. Efekt nie jest tak płynny jak w przypadku aktualizacji każdej klatki, ale różnica jest praktycznie niezauważalna, chyba że zwróci się na nią uwagę.
Shader, shader, shader…
WebGL wymaga shaderów (shaderów wierzchołkowych i fragmentowych) do wszystkich operacji renderowania. Chociaż shadery zawarte w three.js umożliwiają już stosowanie wielu efektów, aby uzyskać bardziej rozbudowane cieniowanie i optymalizację, konieczne jest napisanie własnych shaderów. Ponieważ silnik fizyczny w World Wide Maze zajmuje procesor, spróbowałem wykorzystać GPU, pisząc jak najwięcej kodu w języku cieniowania (GLSL), nawet jeśli przetwarzanie przez procesor (za pomocą JavaScript) byłoby łatwiejsze. Efekty fal morskich są oczywiście oparte na shaderach, podobnie jak fajerwerki w miejscach bramek i efekt siatki używany, gdy pojawia się piłka.
Powyższe dane pochodzą z testów efektu siatki używanego, gdy pojawia się piłka. Po lewej stronie znajduje się model używany w grze, który składa się z 320 poligonów. Model pośrodku składa się z około 5000 poligonów, a model po prawej – z około 300 tys. poligonów. Nawet przy tak dużej liczbie wielokątów przetwarzanie za pomocą shaderów może zapewnić płynną liczbę klatek na sekundę wynoszącą 30 FPS.
Małe elementy rozsiane po scenie są zintegrowane w jedną siatkę, a ich ruch zależy od shaderów poruszających poszczególne wierzchołki wielokątów. To wynik testu, który miał sprawdzić, czy wydajność nie spadnie przy dużej liczbie obiektów. Tutaj widać około 5000 obiektów, które składają się z około 20 tys. poligonów. Wydajność w żaden sposób nie ucierpiała.
poly2tri
Etapy są tworzone na podstawie informacji o konturach otrzymanych od serwera, a następnie przekształcane w poligony za pomocą JavaScriptu. Triangulacja, która jest kluczowym elementem tego procesu, jest źle implementowana przez three.js i zwykle kończy się niepowodzeniem. Dlatego postanowiłem zintegrować inną bibliotekę triangulacji o nazwie poly2tri. Okazuje się, że three.js próbowało tego samego w przeszłości, więc udało mi się to naprawić, po prostu odkomentując część kodu. W efekcie błędy znacznie się zmniejszyły, co pozwoliło na dodanie wielu nowych etapów do rozegrania. Błąd występuje sporadycznie i z niejakiego powodu poly2tri obsługuje błędy, wysyłając alerty, więc zmodyfikowałem go tak, aby zamiast tego wyrzucał wyjątki.
Powyższy obraz pokazuje, jak niebieski kontur jest dzielony na trójkąty i jak generowane są czerwone wielokąty.
Filtrowanie anizotropowe
Standardowe mapowanie MIP w przypadku mapowania izotropicznego zmniejsza rozmiary obrazów zarówno na osi poziomej, jak i pionowej, więc oglądanie wielokątów pod kątem powoduje, że tekstury na odległych końcach etapów w World Wide Maze wyglądają jak rozciągnięte w poziomie tekstury o niskiej rozdzielczości. Dobrym przykładem jest obraz w prawym górnym rogu tej strony w Wikipedii. W praktyce wymagana jest większa rozdzielczość pozioma, którą WebGL (OpenGL) zapewnia za pomocą metody zwanej filtrowaniem anizotropowym. W three.js ustawienie wartości większej niż 1 dla THREE.Texture.anisotropy
powoduje włączenie filtrowania anizotropowego. Ta funkcja jest jednak rozszerzeniem i nie wszystkie karty graficzne mogą ją obsługiwać.
Optymalizuj
Jak wspomniano w artykule Sprawdzone metody dotyczące WebGL, najważniejszym sposobem na poprawę wydajności WebGL (OpenGL) jest zminimalizowanie wywołań rysowania. Podczas wstępnej fazy tworzenia gry World Wide Maze wszystkie wyspy, mosty i barierki w grze były osobnymi obiektami. Czasami prowadziło to do ponad 2000 wywołań funkcji draw(), co utrudniało obsługę złożonych etapów. Gdy jednak umieściłem te same typy obiektów w jednym układzie, wywołania rysowania spadły do około 50, co znacznie poprawiło wydajność.
Do dalszej optymalizacji użyłem funkcji śledzenia w Chrome. Profilatory zawarte w Narzędziach deweloperskich w Chrome mogą w pewnym stopniu określić łączny czas przetwarzania metody, ale śledzenie pozwala określić dokładny czas trwania poszczególnych części z dokładnością do 1/1000 s. Więcej informacji o używaniu śledzenia znajdziesz w tym artykule.
Powyżej przedstawione są wyniki tworzenia map środowiska dla odbicia kuli. Wstawianie wartości console.time
i console.timeEnd
w wybranych miejscach w three.js powoduje powstanie wykresu podobnego do tego. Czas płynie z lewej do prawej, a każda warstwa jest czymś w rodzaju stosu wywołań. Umieszczenie konsoli.time wewnątrz console.time
umożliwia dalsze pomiary. Górny wykres przedstawia dane przed optymalizacją, a dolny – po jej zastosowaniu. Jak widać na wykresie u góry, podczas wstępnej optymalizacji funkcja updateMatrix
(choć słowo jest obcięte) została wywołana dla każdej z renderacji 0–5. Zmieniłem jednak ten kod tak, aby był wywoływany tylko raz, ponieważ ten proces jest wymagany tylko wtedy, gdy obiekty zmieniają położenie lub orientację.
Sam proces śledzenia zajmuje zasoby, więc nadmierne wstawianie console.time
może spowodować znaczne odchylenie od rzeczywistej wydajności, co utrudnia wskazywanie obszarów do optymalizacji.
Dostosowywanie skuteczności
Ze względu na charakter Internetu gra będzie prawdopodobnie uruchamiana na systemach o bardzo zróżnicowanych specyfikacjach. Film Find Your Way to Oz, który ukazał się na początku lutego, wykorzystuje klasę IFLAutomaticPerformanceAdjust
, aby zmniejszać efekty w zależności od wahań częstotliwości wyświetlania klatek, co pomaga zapewnić płynne odtwarzanie. Gra World Wide Maze korzysta z tej samej klasy IFLAutomaticPerformanceAdjust
i w celu zapewnienia jak największej płynności rozgrywki zmniejsza efekty w takim porządku:
- Jeśli liczba klatek na sekundę spadnie poniżej 45 FPS, mapy środowiska przestaną się aktualizować.
- Jeśli nadal jest poniżej 40 FPS, rozdzielczość renderowania jest zmniejszana do 70% (50% współczynnika powierzchni).
- Jeśli nadal spada poniżej 40 FPS, eliminowana jest funkcja FXAA (antyaliasing).
- Jeśli nadal jest mniejsza niż 30 FPS, efekty świetlne są eliminowane.
wyciek pamięci;
Wyraźne usuwanie obiektów jest w przypadku three.js dość kłopotliwe. Pozostawienie ich bez zmian spowoduje oczywiście wycieki pamięci, więc opracowałem metodę opisaną poniżej. @renderer
odnosi się do THREE.WebGLRenderer
. (Najnowsza wersja three.js używa nieco innej metody dealokacji, więc prawdopodobnie nie będzie działać w takim stanie.)
destructObjects: (object) =>
switch true
when object instanceof THREE.Object3D
@destructObjects(child) for child in object.children
object.parent?.remove(object)
object.deallocate()
object.geometry?.deallocate()
@renderer.deallocateObject(object)
object.destruct?(this)
when object instanceof THREE.Material
object.deallocate()
@renderer.deallocateMaterial(object)
when object instanceof THREE.Texture
object.deallocate()
@renderer.deallocateTexture(object)
when object instanceof THREE.EffectComposer
@destructObjects(object.copyPass.material)
object.passes.forEach (pass) =>
@destructObjects(pass.material) if pass.material
@renderer.deallocateRenderTarget(pass.renderTarget) if pass.renderTarget
@renderer.deallocateRenderTarget(pass.renderTarget1) if pass.renderTarget1
@renderer.deallocateRenderTarget(pass.renderTarget2) if pass.renderTarget2
HTML
Osobiście uważam, że największą zaletą aplikacji WebGL jest możliwość projektowania układu strony w HTML. Tworzenie interfejsów 2D, takich jak wyświetlanie wyników lub tekstu w Flashu lub openFrameworks (OpenGL), jest dość kłopotliwe. Flash ma przynajmniej IDE, ale openFrameworks jest trudny, jeśli nie jesteś do niego przyzwyczajony (używanie czegoś takiego jak Cocos2D może ułatwić pracę). Z drugiej strony, kod HTML umożliwia dokładne kontrolowanie wszystkich aspektów projektu frontendu za pomocą kodu CSS, tak jak w przypadku tworzenia witryn. Chociaż złożone efekty, takie jak cząsteczki kondensujące się w logo, są niemożliwe, niektóre efekty 3D są możliwe w ramach możliwości transformacji CSS. Efekty tekstowe „GOAL” i „TIME IS UP” w grze World Wide Maze są animowane za pomocą skali w przejściu CSS (wdrożone za pomocą Transit). (Oczywiście przejścia tła używają WebGL).
Każda strona w grze (tytuł, RESULT, RANKING itd.) ma własny plik HTML. Gdy są one załadowane jako szablony, funkcja $(document.body).append()
jest wywoływana z odpowiednimi wartościami we właściwym czasie. Jednym z problemów było to, że nie można było ustawić zdarzeń myszy i klawiatury przed dodaniem, więc próba użycia funkcji el.click (e) -> console.log(e)
przed dodaniem nie działała.
Internacjonalizacja (i18n)
Praca w formacie HTML była też wygodna przy tworzeniu wersji w języku angielskim. Do potrzeb internacjonalizacji wybrałem bibliotekę internetową i18next, której mogłem używać bez modyfikacji.
Edytowanie i tłumaczenie tekstu w grze zostało wykonane w arkuszu kalkulacyjnym w Dokumentach Google. Ponieważ i18next wymaga plików JSON, wyeksportowałam arkusze kalkulacyjne do formatu TSV, a potem przekonwertowałam je za pomocą niestandardowego konwertera. Wprowadziłem wiele zmian tuż przed publikacją, więc zautomatyzowanie procesu eksportowania z arkusza kalkulacyjnego w Dokumentach Google znacznie ułatwiłoby mi pracę.
Funkcja automatycznego tłumaczenia w Chrome też działa normalnie, ponieważ strony są tworzone w HTML. Czasami jednak nie udaje się poprawnie wykryć języka, ponieważ jest on mylony z zupełnie innym (np. (np. wietnamski), więc ta funkcja jest obecnie wyłączona. (można go wyłączyć za pomocą metatagów).
RequireJS
Jako system modułów JavaScript wybrałem RequireJS. 10 tys. wierszy kodu źródłowego gry jest podzielonych na około 60 klas (czyli plików coffee) i skompilowanych w poszczególne pliki js. RequireJS wczytuje te poszczególne pliki w odpowiedniej kolejności na podstawie zależności.
define ->
class Hoge
hogeMethod: ->
Klasy zdefiniowanej powyżej (hoge.coffee) można używać w ten sposób:
define ['hoge'], (Hoge) ->
class Moge
constructor: ->
@hoge = new Hoge()
@hoge.hogeMethod()
Aby wszystko działało, plik hoge.js musi zostać wczytany przed plikiem moge.js, a ponieważ „hoge” jest pierwszym argumentem funkcji „define”, plik hoge.js jest zawsze wczytywany jako pierwszy (jest wywoływany ponownie po zakończeniu wczytywania pliku hoge.js). Ten mechanizm nazywa się AMD. Do tego samego typu wywołania zwrotnego można użyć dowolnej biblioteki zewnętrznej, o ile obsługuje ona AMD. Nawet te, które tego nie robią (np. three.js), będą działać podobnie, o ile zależność zostanie określona z wyprzedzeniem.
Jest to podobne do importowania pliku AS3, więc nie powinno być dziwne. Jeśli masz więcej plików zależnych, to może być możliwe rozwiązanie.
r.js
RequireJS zawiera optymalizator o nazwie r.js. Umożliwia złączenie głównego pliku js ze wszystkimi zależnymi plikami js w jeden, a następnie zminimalizowanie go za pomocą UglifyJS (lub Closure Compiler). Pozwala to zmniejszyć liczbę plików i łączną ilość danych, które musi wczytać przeglądarka. Łączny rozmiar pliku JavaScript w przypadku World Wide Maze wynosi około 2 MB i można go zmniejszyć do około 1 MB dzięki optymalizacji r.js. Jeśli gra mogłaby być rozpowszechniana za pomocą gzip, rozmiar zostałby zmniejszony do 250 KB. (GAE ma problem, który uniemożliwia przesyłanie plików gzip o rozmiarze większym niż 1 MB, więc gra jest obecnie rozpowszechniana w postaci nieskompresowanego tekstu o rozmiarze 1 MB).
Kreator etapów
Dane etapu są generowane w ten sposób:
- Adres URL witryny, która ma zostać przekształcona w etap, jest wysyłany przez WebSocket.
- PhantomJS wykonuje zrzut ekranu, a pozycje tagów div i img są pobierane i wyprowadzane w formacie JSON.
- Na podstawie zrzutu ekranu z kroku 2 i danych dotyczących pozycjonowania elementów HTML niestandardowy program w języku C++ (OpenCV, Boost) usuwa niepotrzebne obszary, generuje wyspy, łączy je za pomocą mostów, oblicza pozycje barierek ochronnych i elementów, ustawia punkt docelowy itp. Wyniki są zapisywane w formacie JSON i zwracane do przeglądarki.
PhantomJS
PhantomJS to przeglądarka, która nie wymaga ekranu. Może wczytywać strony internetowe bez otwierania okien, więc można go używać w automatycznych testach lub do wykonywania zrzutów ekranu po stronie serwera. Mechanizmem przeglądarki jest WebKit, czyli ten sam, który jest używany w Chrome i Safari, więc jej układ i wyniki wykonywania kodu JavaScript są mniej więcej takie same jak w standardowych przeglądarkach.
W przypadku PhantomJS do pisania procesów, które mają być wykonywane, służy język JavaScript lub CoffeeScript. Robienie zrzutów ekranu jest bardzo proste, jak widać na tym przykładzie. Pracowałem na serwerze Linux (CentOS), więc musiałem zainstalować czcionki, aby wyświetlać japoński (M+ FONTS). Nawet wtedy renderowanie czcionek jest obsługiwane inaczej niż w systemie Windows czy Mac OS, więc ta sama czcionka może wyglądać inaczej na innych komputerach (choć różnica jest minimalna).
Pobieranie pozycji tagów img i div jest w podstawie obsługiwane tak samo jak na standardowych stronach. jQuery też można używać bez żadnych problemów.
stage_builder
Początkowo rozważałem zastosowanie bardziej zorientowanego na DOM podejścia do generowania etapów (podobnego do Firefox 3D Inspector) i próbowałem coś takiego jak analiza DOM w PhantomJS. Ostatecznie zdecydowałem się jednak na przetwarzanie obrazu. W tym celu napisałem program w C++, który korzysta z OpenCV i Boost o nazwie „stage_builder”. Wykonuje on te czynności:
- Ładuje zrzut ekranu i pliki JSON.
- Konwertuje obrazy i tekst na „wyspy”.
- Tworzy mosty łączące wyspy.
- Wyeliminuje niepotrzebne mosty, aby utworzyć labirynt.
- umieszczanie dużych elementów;
- umieszczanie małych przedmiotów;
- bariery ochronne;
- Wyprowadza dane pozycjonowania w formacie JSON.
Szczegóły każdego kroku znajdziesz poniżej.
Ładowanie zrzutu ekranu i plików JSON
Do wczytywania zrzutów ekranu służy standardowy przycisk cv::imread
. Przetestowałam kilka bibliotek do obsługi plików JSON, ale picojson wydawał się najłatwiejszy w użyciu.
Konwertowanie obrazów i tekstu na „wyspy”
Powyższy zrzut ekranu przedstawia sekcję Wiadomości na stronie aid-dcc.com (kliknij, aby wyświetlić rzeczywisty rozmiar). Obrazy i elementy tekstowe muszą zostać przekonwertowane na wyspy. Aby wyodrębnić te sekcje, musimy usunąć biały kolor tła, czyli najczęściej występujący kolor na zrzucie ekranu. Oto, jak to wygląda:
Białe sekcje to potencjalne wyspy.
Tekst jest zbyt drobny i ostry, więc pogrubimy go za pomocą cv::dilate
, cv::GaussianBlur
i cv::threshold
. Brakuje też treści obrazu, więc wypełnimy te obszary kolorem białym na podstawie danych tagu img z PhantomJS. Wynikowy obraz będzie wyglądał tak:
Tekst tworzy teraz odpowiednie skupiska, a każdy obraz jest właściwą wyspą.
tworzenie mostów łączących wyspy,
Gdy wyspy są gotowe, są połączone mostami. Każda wyspa szuka sąsiednich wysp po lewej, prawej, górze i dole, a potem łączy mostem najbliższy punkt najbliższej wyspy, co daje coś takiego:
Wyeliminowanie zbędnych mostów, aby utworzyć labirynt
Pozostawienie wszystkich mostów ułatwiłoby poruszanie się po mapie, dlatego niektóre z nich należy usunąć, aby utworzyć labirynt. Jako punkt początkowy wybrana zostaje jedna wyspa (np. ta w lewym górnym rogu), a wszystkie mosty łączące ją z innymi wyspami (z wyjątkiem jednego wybranego losowo) są usuwane. Następnie wykonuje się te same czynności w przypadku kolejnej wyspy połączonej z pozostałym mostem. Gdy ścieżka dochodzi do ślepej uliczki lub prowadzi z powrotem na wcześniej odwiedzoną wyspę, wraca do punktu, który umożliwia dotarcie na nową wyspę. Labirynt zostanie ukończony, gdy przetworzone zostaną wszystkie wyspy.
Umieszczanie dużych elementów
Na każdej wyspie umieszczane są duże elementy (w zależności od wymiarów wyspy) w miejscach najdalej od jej krawędzi. Chociaż nie są one zbyt wyraźne, poniżej zaznaczono je na czerwono:
Spośród wszystkich tych punktów ten w lewym górnym rogu jest ustawiony jako punkt początkowy (czerwony okrąg), ten w prawym dolnym rogu jako punkt docelowy (zielony okrąg), a z pozostałych punktów wybiera się maksymalnie 6 punktów do umieszczenia dużych elementów (fioletowy okrąg).
umieszczanie małych przedmiotów,
Na liniach w odpowiednich odległościach od krawędzi wyspy umieszcza się odpowiednią liczbę małych elementów. Na powyższym obrazie (nie pochodzącym ze strony aid-dcc.com) pokazano szare linie projekcji, przesunięte i rozmieszczone w regularnych odstępach od krawędzi wyspy. Czerwone kropki wskazują miejsca, w których znajdują się małe przedmioty. Ten obraz pochodzi z wersji w trakcie opracowywania, dlatego elementy są rozmieszczone w prostych liniach, ale w wersji końcowej są rozmieszczone nieco bardziej nieregularnie po obu stronach szarych linii.
umieszczanie barierek.
Barierki ochronne są umieszczane wzdłuż zewnętrznych granic wysp, ale muszą być odcięte na mostach, aby umożliwić dostęp. W tym celu przydatna okazała się biblioteka geometryczna Boosta, która uprościła obliczenia geometryczne, np. określenie, gdzie dane dotyczące granicy wyspy przecinają się z liniami po obu stronach mostu.
Zielone linie obrysowujące wyspy to bariery ochronne. Na tym obrazie może być trudno dostrzec, że w miejscach, w których znajdują się mosty, nie ma zielonych linii. Jest to ostateczny obraz służący do debugowania, który zawiera wszystkie obiekty, które mają zostać wyprowadzone do pliku JSON. Jasnoniebieskie kropki to małe elementy, a szare kropki to sugerowane punkty wznowienia. Gdy piłka wpadnie do oceanu, gra zostanie wznowiona od najbliższego punktu restartu. Punkty ponownego uruchamiania są rozmieszczone mniej więcej w taki sam sposób jak małe elementy, w regularnych odstępach w określonym oddaleniu od krawędzi wyspy.
Wyprowadzanie danych pozycjonowania w formacie JSON
Do danych wyjściowych użyłem też biblioteki picojson. Dane są zapisywane na standardowe wyjście, które jest następnie odbierane przez wywołującego (Node.js).
Tworzenie programu C++ na Macu do uruchomienia w systemie Linux
Gra została opracowana na komputerze Mac i wdrożona w systemie Linux, ale ponieważ biblioteki OpenCV i Boost były dostępne dla obu systemów operacyjnych, samo tworzenie nie było trudne, gdy tylko udało się skonfigurować środowisko kompilacji. Do debugowania kompilacji na Macu użyłem narzędzi wiersza poleceń w Xcode, a potem utworzyłem plik konfiguracji za pomocą automake/autoconf, aby można było skompilować kompilację w systemie Linux. Następnie musiałem użyć w Linuxie polecenia „configure && make”, aby utworzyć plik wykonywalny. Z powodu różnic w wersjach kompilatorów napotkałem kilka błędów specyficznych dla systemu Linux, ale udało mi się je stosunkowo łatwo usunąć za pomocą gdb.
Podsumowanie
Tego typu grę można utworzyć w Flashu lub Unity, co przyniesie wiele korzyści. Ta wersja nie wymaga jednak żadnych wtyczek, a funkcje układu w HTML5 + CSS3 okazały się niezwykle potężne. Zdecydowanie ważne jest, aby mieć odpowiednie narzędzia do każdego zadania. Byłem zaskoczony, jak dobrze ta gra wyszła, biorąc pod uwagę, że została stworzona w pełni w HTML5. Chociaż w wielu obszarach wciąż brakuje jej do ideału, z niecierpliwością czekam na jej dalszy rozwój.