Studium przypadku – Inside World Wide Labze

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.

World Wide Maze

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ą się wtedy poruszać po przestrzeniach 3D utworzonych przez WebGLWeb 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 ze smartfona. 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 – mniej więcej co 1/10 sekundy.

window.addEventListener('deviceorientation', function (e) {
  // do something here..
});

Obiekt DeviceOrientationEvent zawiera dane o przechyleniu dla osi X, YZ 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:

Orientacja urządzenia.

Wartości u góry wyróżnione na niebiesko są zdefiniowane w specyfikacjach W3C. Wyróżnione na zielono spełniają te wymagania, a 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 stworzenia obejścia. W pierwszym kroku samouczka World Wide Maze prosi nowych graczy o skalibrowanie urządzeń, ale nie będzie można tego zrobić prawidłowo, 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

  1. Komputer łączy się z serwerem.
  2. Serwer przypisuje komputerowi losowo wygenerowany numer i zapamiętuje tę kombinację.
  3. Na urządzeniu mobilnym określ numer i połącz się z serwerem.
  4. Jeśli numer jest taki sam jak na połączonym komputerze, urządzenie mobilne jest sparowane z tym komputerem.
  5. Jeśli nie ma wyznaczonego komputera PC, wystąpi błąd.
  6. 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 jeszcze bardziej 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ą parametru 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 przekaźnikowy 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 powolne 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 zminimalizuje 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.

Algorytm Nagle 1

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 średni wynosi około 100 ms. Tutaj używam iPhone'a z serwerem przekaźnikowym w Japonii. Zarówno dane wyjściowe, jak i wejściowe mają czas przetwarzania około 100 ms, a działanie jest płynne.

Algorytm Nagle 2

Ten wykres pokazuje natomiast wyniki korzystania z serwera w Stanach Zjednoczonych. Chociaż zielone przedziały czasu wyjścia utrzymują się na poziomie 100 ms, przedziały czasu wejścia wahają się od 0 ms do 500 ms, co wskazuje, że komputer PC odbiera dane w porcjach.

ALT_TEXT_HERE

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 może to być problem 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 do JavaScriptu za pomocą Emscripten, a także Physijs, aby wykorzystać go jako „proces w tle”.

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 sprzęcie 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ą.

FPS

Ten obraz pokazuje uzyskane częstotliwości klatek na komputerze Lenovo G570. Górne pole pokazuje liczbę klatek na sekundę 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 elementami 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.

Skrypty service worker

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 liczba 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 „duch”

Obiekty, które mają wykrywanie kolizji, ale nie mają wpływu na kolizję, a tym samym 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 web workera, 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 w przypadku testów z 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 najbardziej aktywnie rozwijanej biblioteki three.js (r53). Chociaż wersja 57 została już wydana w ostatnich etapach rozwoju, w interfejsie API wprowadzono istotne zmiany, więc w wersji opublikowanej została wykorzystana pierwotna wersja.

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 należy 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.

Poświata

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 przetwarzany obraz, w którym rozmiar obrazu został zmniejszony o 50%, a następnie zastosowano rozmycie. W prawym górnym rogu widać trzecią passę, w której obraz został ponownie zmniejszony o 50%, a następnie rozmyty. Następnie nałożyliśmy te 3 obrazy, aby utworzyć ostateczny obraz złożony, który widać w lewym dolnym rogu. Do rozmycia użyłem funkcji VerticalBlurShaderHorizontalBlurShader, 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 ją wskażesz.

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 przy pojawianiu się piłki.

Kule cieniujące

Powyższe dane pochodzą z testów efektu siatki użytego, 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.

Sieć cieniowania

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. Użyto tu około 5000 obiektów składających się z około 20 tys. poligonów. Nie wpłynęło to w ogóle na wydajność.

poly2tri

Etapy są tworzone na podstawie informacji o konturach otrzymanych od serwera, a następnie dzielone na wielokąty za pomocą kodu JavaScript. 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 rezultacie znacznie zmniejszyła się liczba błędów, co pozwoliło na dodanie większej liczby 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.

poly2tri

Powyższy obraz pokazuje, jak niebieski kontur jest dzielony na trójkąty i jak generowane są czerwone wielokąty.

Filtrowanie anizotropowe

Ponieważ standardowe mapowanie MIP w przypadku mapowania izotropicznego zmniejsza rozmiary obrazów zarówno na osi poziomej, jak i pionowej, oglądanie wielokątów pod kątem powoduje, że tekstury na odległym końcu 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 początkowej fazy tworzenia gry World Wide Maze wszystkie wyspy, mosty i bariery 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 siatki, 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.

Optymalizacja

Powyżej przedstawione wyniki zostały uzyskane podczas tworzenia map środowiska dla odbicia kuli. Wstawianie funkcji console.timeconsole.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 najbardziej płynnej rozgrywki zmniejsza efekty w takim porządku:

  1. Jeśli liczba klatek na sekundę spadnie poniżej 45 fps, mapy środowiska przestaną się aktualizować.
  2. Jeśli nadal jest poniżej 40 fps, rozdzielczość renderowania jest zmniejszana do 70% (50% współczynnika powierzchni).
  3. Jeśli nadal spada poniżej 40 FPS, FXAA (antyaliasing) zostaje wyłączony.
  4. Jeśli nadal jest poniżej 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 front-endu 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 itp.) ma własny plik HTML. Gdy zostaną one załadowane jako szablony, funkcja $(document.body).append() zostanie wywołana 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 wprowadzania zmian.

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 eksportu 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: ->

Klasa zdefiniowana powyżej (hoge.coffee) może być używana 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 (wywoływany ponownie po zakończeniu wczytywania pliku hoge.js). Ten mechanizm nosi nazwę 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:

  1. Adres URL witryny, która ma zostać przekształcona w etap, jest wysyłany przez WebSocket.
  2. PhantomJS wykonuje zrzut ekranu, a pozycje tagów div i img są pobierane i wyprowadzane w formacie JSON.
  3. 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 układ i wyniki wykonywania kodu JavaScript są mniej więcej takie same jak w standardowych przeglądarkach.

W PhantomJS do pisania procesów, które mają być wykonywane, używa się języka 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 stronach standardowych. 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 aplikacji Firefox 3D Inspector) i próbowałem coś takiego jak analiza DOM w PhantomJS. Ostatecznie zdecydowałem się na przetwarzanie obrazu. W tym celu napisałem program C++, który korzysta z OpenCV i Boost o nazwie „stage_builder”. Wykonuje te zadania:

  1. Ładuje zrzut ekranu i pliki JSON.
  2. Konwertuje obrazy i tekst na „wyspy”.
  3. Tworzy mosty łączące wyspy.
  4. Wyeliminuje niepotrzebne mosty, aby utworzyć labirynt.
  5. umieszczanie dużych elementów;
  6. umieszczanie małych przedmiotów;
  7. bariery ochronne;
  8. 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”

Kompilacja etapu

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:

Kompilacja etapu

Białe sekcje to potencjalne wyspy.

Tekst jest zbyt drobny i ostry, więc pogrubimy go za pomocą cv::dilate, cv::GaussianBlurcv::threshold. Brakuje też treści obrazu, więc wypełnimy te obszary kolorem białym na podstawie danych tagu img z PhantomJS. Wynikowy obraz wygląda tak:

Kompilacja etapu

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:

Kompilacja etapu

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 stworzyć labirynt. Jako punkt początkowy wybrana zostaje jedna wyspa (np. ta w lewym górnym rogu), a wszystkie mosty łączące ją z wyspami (z wyjątkiem jednego wybranego losowo) zostają usunięte. 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.

Kompilacja etapu

Umieszczanie dużych elementów

Na każdej wyspie umieszczane są duże obiekty (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:

Kompilacja etapu

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 wybiera się maksymalnie 6 punktów do umieszczenia dużych elementów (fioletowy okrąg).

umieszczanie małych przedmiotów,

Kompilacja etapu

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 umieszczone 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 barier;

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 Boost, która uprościła obliczenia geometryczne, np. określenie, gdzie dane dotyczące granicy wyspy przecinają się z liniami po obu stronach mostu.

Kompilacja etapu

Zielone linie obrysowujące wyspy to bariery ochronne. Na tym obrazie może być trudno dostrzec, że na moście nie ma zielonych linii. Jest to ostateczny obraz służący do debugowania, który zawiera wszystkie obiekty, które muszą 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 zadanym 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 Linuxie. 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 związanych z Linuksem, 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. Ważne jest, aby do każdego zadania mieć odpowiednie narzędzia. Byłem zaskoczony, jak dobrze gra się w tę grę, która 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.