Studium przypadku – Inside World Wide Labze

Labirynty świata to gra, w której smartfon pomaga Ci pokonać trójwymiarowe labirynty w labiryntach w różnych witrynach, aby osiągnąć zamierzone cele.

Labirynt światowy

Gra zawiera wiele funkcji HTML5. Na przykład zdarzenie DeviceOrientation pobiera ze smartfona dane przechylenia, które są następnie wysyłane na komputer przez WebSocket. Dzięki temu gracze odnajdują drogę w przestrzeniach 3D utworzonych za pomocą WebGL i pracowników sieciowych.

W tym artykule wyjaśnię dokładnie, jak te funkcje są używane, a także wyjaśnię ogólny proces programowania i najważniejsze punkty optymalizacji.

DeviceOrientation

Zdarzenie DeviceOrientation (przykład) jest używane do pobierania danych przechylenia ze smartfona. Gdy ze zdarzeniem DeviceOrientation używane jest addEventListener, wywołanie zwrotne z obiektem DeviceOrientationEvent jest wywoływane jako argument w regularnych odstępach czasu. Same interwały różnią się w zależności od używanego urządzenia. Na przykład w przypadku iOS + Chrome i iOS + Safari wywołanie zwrotne jest wywoływane co 1/20 sekundy, a w Androidzie 4 i Chrome – mniej więcej co 1/10 sekundy.

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

Obiekt DeviceOrientationEvent zawiera dane przechylenia dla każdej osi X, Y i Z w stopniach (nie w radianach). Więcej informacji znajdziesz w artykule HTML5Rocks. Jednak zwracane wartości różnią się też w zależności od urządzenia i przeglądarki, z której korzystasz. Zakresy rzeczywistych wartości zwróconych zostały przedstawione w tabeli poniżej:

Orientacja urządzenia.

Wartości u góry wyróżnione na niebiesko to wartości zdefiniowane w specyfikacji W3C. Te podświetlone na zielono odpowiadają specyfikacjom, a te wyróżnione na czerwono różnią się od nich. Co ciekawe, tylko kombinacja Android i Firefox zwróciła wartości zgodne ze specyfikacjami. Jednak przy implementacji lepiej jest uwzględniać wartości, które występują często. W związku z tym World Wide Maze używa zwracanych wartości z iOS jako standardu i odpowiednio dostosowuje swoje działanie do urządzeń z Androidem.

if android and event.gamma > 180 then event.gamma -= 360

Nexus 10 nie działa jednak w ten sposób. 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. Tę kwestię zajmujemy oddzielnie. (być może domyślnie jest orientacja pozioma).

To pokazuje, że nawet jeśli interfejsy API korzystające z urządzeń fizycznych mają ustawione specyfikacje, nie ma gwarancji, że zwrócone wartości będą zgodne z tymi specyfikacjami. Dlatego warto przetestować je na wszystkich potencjalnych urządzeniach. Oznacza to również, że mogą zostać wprowadzone nieoczekiwane wartości, co wymaga stosowania obejść. W ramach pierwszego etapu samouczka gracze po raz pierwszy poprosili o kalibrację urządzeń. Jeśli jednak napotkają nieoczekiwane wartości nachylenia, urządzenie nie zostanie skalibrowane prawidłowo do pozycji zerowej. Dlatego ma wewnętrzny limit czasu i jeśli w tym czasie nie uda się przeprowadzić kalibracji, odtwarzacz przełączy się na sterowanie za pomocą klawiatury.

WebSocket

W World Wide Maze smartfon i komputer są połączone przez WebSocket. Dokładniej rzecz ujmując, są one połączone za pośrednictwem serwera przekazującego między nimi, np. smartfon-serwer. Dzieje się tak, ponieważ WebSocket nie może bezpośrednio łączyć przeglądarek. (Kanały danych WebRTC umożliwiają połączenia peer-to-peer i eliminują potrzebę korzystania z serwera przekazującego, ale w momencie implementacji tej metody mogła być używana tylko w Chrome Canary i Firefox Nightly).

Używam do wdrożenia biblioteki Socket.IO (v0.9.11), która zawiera funkcje ponownego podłączania w przypadku przekroczenia limitu czasu lub rozłączenia połączenia. Użyłem jej w połączeniu z NodeJS, ponieważ to połączenie NodeJS i Socket.IO wykazało w kilku testach implementacji WebSocket najlepszą wydajność po stronie serwera.

Parowanie według numerów

  1. Komputer jest połączony z serwerem.
  2. Serwer przypisuje komputerowi losowo wygenerowaną liczbę i zapamiętuje kombinację liczby i PC.
  3. Na urządzeniu mobilnym podaj numer i połącz się z serwerem.
  4. Jeśli podany numer jest taki sam jak numer na podłączonym komputerze, Twoje urządzenie mobilne jest z nim sparowane.
  5. Jeśli nie ma wyznaczonego komputera, występuje błąd.
  6. Gdy dane pochodzą z urządzenia mobilnego, są wysyłane do komputera, z którym są sparowane, i odwrotnie.

Możesz też nawiązać pierwsze połączenie z urządzenia mobilnego. W takim przypadku urządzenia są po prostu odwrócone.

Synchronizacja kart

Funkcja synchronizacji kart dostępna w Chrome jeszcze bardziej ułatwia parowanie. Dzięki temu strony otwarte na komputerze można łatwo otwierać na urządzeniu mobilnym (i odwrotnie). Komputer PC pobiera numer połączenia wydany przez serwer i dołącza go do adresu URL strony za pomocą history.replaceState.

history.replaceState(null, null, '/maze/' + connectionNumber)

Jeśli synchronizacja kart jest włączona, adres URL jest zsynchronizowany po kilku sekundach, a na urządzeniu mobilnym można otworzyć tę samą stronę. Urządzenie mobilne sprawdzi adres URL otwartej strony i jeśli zostanie do niego dołączony numer, natychmiast rozpocznie łączenie. Dzięki temu nie musisz wpisywać numerów ręcznie ani skanować kodów QR aparatem.

Czas oczekiwania

Ponieważ serwer przekazujący znajduje się w Stanach Zjednoczonych, uzyskanie do niego dostępu z Japonii powoduje około 200 ms opóźnienia w dostarczaniu danych przechylenia smartfona do komputera. Czas oczekiwania na odpowiedź był wyraźnie powolny w porównaniu z środowiskiem lokalnym używanym podczas programowania, ale wstawienie czegoś takiego jak filtr dolnoprzepustowy (użyłem EMA) pozwoliło to wyeliminować do dyskretnych poziomów. (W praktyce do prezentacji potrzebny był też filtr dolnoprzepustowy – wartości zwracane z czujnika przechylenia obejmowały znaczną ilość szumu, a stosowanie tych wartości na ekranie prowadziło do częstych drgań). Ta metoda nie zadziałała w przypadku skoków, które były wyraźnie powolne, ale nie udało się rozwiązać tego problemu.

Ponieważ od samego początku spodziewałem się problemów z opóźnieniami, rozważałem skonfigurowanie serwerów przekazujących na całym świecie, tak aby klienty mogły łączyć się z najbliższymi dostępnymi zasobami (co pozwoli zminimalizować czas oczekiwania). Skorzystałem jednak z usługi Google Compute Engine (GCE), która wtedy dostępna była tylko w Stanach Zjednoczonych, więc nie było to możliwe.

Problem z algorytmem Nagle'a

Algorytm Nagle jest zwykle stosowany w systemach operacyjnych w celu wydajnej komunikacji przez buforowanie na poziomie TCP. Okazało się jednak, że nie można było wysyłać danych w czasie rzeczywistym, gdy ten algorytm był włączony. (W szczególności w połączeniu z opóźnionym potwierdzeniem TCP). Nawet bez opóźnienia ACK ten sam problem występuje, jeśli ACK jest opóźniony w pewnym stopniu ze względu na takie czynniki jak serwer znajdujący się za granicą.

Problem z opóźnieniem Nagle nie występował w przypadku protokołu WebSocket w Chrome na Androida, w tym opcji TCP_NODELAY do wyłączania Nagle, natomiast występował w przypadku zestawu WebKit WebSocket używanego w Chrome na iOS, który nie ma włączonej tej opcji. Ten problem też wystąpił w przeglądarce Safari, która korzysta z tego samego mechanizmu WebKit. Problem został zgłoszony firmie Apple przez Google i najwyraźniej został rozwiązany w rozwojowej wersji WebKit.

W tym przypadku dane przechylenia wysyłane co 100 ms są łączone w fragmenty, które docierają do komputera co 500 ms. Gra nie może działać w tych warunkach, więc można uniknąć tego opóźnienia, ponieważ serwer wysyła dane w krótkich odstępach czasu (co około 50 ms). Uważam, że otrzymywanie ACK w krótkich odstępach czasu wprowadza algorytm Nagle w błąd, aby uznać, że wysyłanie danych jest dozwolone.

Algorytm Nagle 1

Powyższe wykresy przedstawia przedziały rzeczywistych otrzymanych danych. Pokazuje odstępy czasowe między pakietami; zielony kolor oznacza odstępy wyjściowe, a czerwony – interwały wejściowe. Minimalna wartość to 54 ms, maksymalna to 158 ms, a środkowa prawie 100 ms. Tutaj używam iPhone'a z serwerem przekazującym w Japonii. Dane wyjściowe i wejściowe trwają około 100 ms, a działanie jest płynne.

Algorytm Nagle 2

Ten wykres przedstawia natomiast wyniki korzystania z serwera w Stanach Zjednoczonych. Chociaż odstępy między wartościami wyjściowymi wynoszą 100 ms, przedziały wejściowe zmieniają się w zakresie od 0 ms do 500 ms, co oznacza, że komputer odbiera dane we fragmentach.

ALT_TEXT_HERE

Na koniec wykres przedstawia rezultaty unikania opóźnień dzięki wysyłaniu przez serwer danych zastępczych. Nie jest on tak skuteczny, jak w przypadku serwera japońskiego, ale jasne jest, że odstępy wejściowe są względnie stabilne, wynoszące około 100 ms.

Błąd?

Mimo że domyślna przeglądarka w Androidzie 4 (ICS) ma interfejs API WebSocket, nie może się ona połączyć, co powoduje wystąpienie zdarzenia connect_failed w Socket.IO. Przekroczenie limitu wewnętrznego i po stronie serwera nie można też zweryfikować połączenia. Nie testowałem jeszcze tego rozwiązania przy użyciu samego protokołu WebSocket, więc może to być problem z Socket.IO.

Skalowanie serwerów usługi przekaźnika

Ponieważ rola serwera przekazującego nie jest tak skomplikowana, skalowanie w górę i zwiększanie liczby serwerów nie powinno być trudne, pod warunkiem że ten sam komputer i urządzenie mobilne są zawsze połączone z tym samym serwerem.

Fizyka

Poruszanie się piłką w grze (toczenie się w dół, zderzanie się z podłożem, zderzanie się ze ścianami, zbieranie przedmiotów itp.) odbywa się dzięki symulatorowi fizyki 3D. Użyłem Ammo.js – popularnego mechanizmu fizyki bullet w języku JavaScript, który wykorzystuje Emscripten – wraz z Physijs, aby użyć go jako „procesu internetowego”.

Procesy internetowe

Web Workers to interfejs API do uruchamiania JavaScriptu w oddzielnych wątkach. Kod JavaScript uruchamiany jako instancja robocza jest uruchamiany jako wątek niezależny od tego, który pierwotnie go nazywał, dzięki czemu możliwe jest wykonywanie ciężkich zadań przy zachowaniu responsywności strony. Physijs efektywnie korzysta z zasobów Web Workers, aby zapewnić płynne działanie silnika fizyki 3D, który zwykle wymaga dużych nakładów pracy. World Wide Maze obsługuje mechanizm fizyczny i renderowanie obrazów WebGL przy całkowicie różnych liczbach klatek. Oznacza to, że nawet jeśli liczba klatek na maszynie o niskiej specyfikacji spadnie z powodu dużego obciążenia renderowania WebGL, mechanizm fizyczny utrzyma 60 klatek na sekundę w grze i nie będzie ograniczać sterowania rozgrywką.

kl./s

Ten obraz przedstawia wynikową liczbę klatek na urządzeniu Lenovo G570. W górnym polu widać liczbę klatek dla WebGL (renderowanie obrazów), a w dolnym – liczbę klatek dla silnika fizycznego. Procesor graficzny jest zintegrowany ze zintegrowanym układem graficznym Intel HD Graphics 3000, dlatego liczba klatek w renderowaniu obrazów nie osiągnęła oczekiwanej szybkości 60 kl./s. Ponieważ jednak mechanizm fizyczny osiągnął oczekiwaną liczbę klatek, rozgrywka nie różni się tak bardzo od wydajności na komputerze o wysokiej specyfikacji.

Ponieważ wątki z aktywnymi zasobami roboczymi Web Workers nie mają obiektów konsoli, dane muszą być wysyłane do wątku głównego za pomocą postMessage, aby można było wygenerować dzienniki debugowania. Używanie console4Worker powoduje utworzenie odpowiednika obiektu konsoli w obrębie instancji roboczej, co znacznie ułatwia debugowanie.

Skrypty service worker

W najnowszych wersjach Chrome można ustawiać punkty przerwania podczas uruchamiania procesów Web Workers, co przydaje się też przy debugowaniu. Możesz je znaleźć w panelu „Workers” (Instancje robocze) w Narzędziach dla programistów.

Występy

Etapy o dużej liczbie wielokątów mogą czasami przekraczać 100 tys. wielokątów,ale wydajność nie zmniejszyła się nawet wtedy, gdy zostały wygenerowane w całości jako Physijs.ConcaveMesh (btBvhTriangleMeshShape w punkcie).

Początkowo liczba klatek spadła, gdy wzrosła liczba obiektów wymagających wykrywania kolizji, ale wyeliminowanie niepotrzebnego przetwarzania w Physijs poprawiła wydajność. Ulepszenia zostały wprowadzone do rozwidlenia oryginalnego Physijs.

Duchy

Obiekty, które wykrywają kolizje, ale nie mają wpływu na kolizję, a tym samym nie mają żadnego wpływu na inne obiekty, są w aplikacji bullet nazywane „obiektami-duchami”. Chociaż Physijs oficjalnie nie obsługuje obiektów-ghost, można je tam utworzyć, manipulując flagami po wygenerowaniu Physijs.Mesh. W ramach labiryntu World Wide Lab do wykrywania kolizji między przedmiotami i punktami celów używane są duchy.

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 – CF_NO_CONTACT_RESPONSE. Aby dowiedzieć się więcej, przeszukaj forum, Stack Overflow lub dokumentację punktowaną. Physijs to kod dla Ammo.js, a Ammo.js jest w zasadzie taki sam jak bullet, dlatego większość czynności, które można wykonać w aplikacji bullet, można wykonać w Physijs.

Problem z przeglądarką Firefox 18

Aktualizacja Firefoksa z wersji 17 na 18 zmieniła sposób wymiany danych między zasobami roboczymi Web Workers, co w efekcie spowodowało przerwanie działania oprogramowania Physijs. Problem został zgłoszony na GitHubie i został rozwiązany w ciągu kilku dni. Choć efektywność tego typu oprogramowania open source zrobiła na mnie wrażenie, incydent ten przypomniał mi także, że „World Wide Maze” składa się z kilku różnych platform open source. Piszę ten artykuł, ponieważ mam nadzieję, że uzyskam Twoją opinię.

asm.js

Chociaż nie dotyczy to bezpośrednio labiryntu World Wide Maze, Ammo.js obsługuje już nowy rozszerzenie asm.js w Mozilli (nie ma w tym nic dziwnego, bo asm.js powstał w celu przyspieszenia generowania kodu JavaScript przez Emscripten, a twórcą Emscripten jest również Ammo.js). Jeśli Chrome obsługuje również asm.js, obciążenie obliczeniowe mechanizmu fizycznego powinno znacznie się zmniejszyć. Szybkość była znacznie większa po przetestowaniu aplikacji Firefox Nightly. Być może lepiej byłoby napisać sekcje wymagające większej szybkości w języku C/C++, a potem przenieść je do JavaScriptu przy użyciu Emscripten?

WebGL

Do implementacji WebGL użyłem najbardziej aktywnie rozwiniętej biblioteki three.js (r53). Mimo że wersja 57 została już opublikowana w ramach ostatnich etapów programowania, wprowadzono duże zmiany w interfejsie API, więc nie mogę korzystać z oryginalnej wersji.

Efekt poświaty

Efekt poświaty dodawany do rdzenia piłki i przedmiotów jest implementowany przy użyciu prostej wersji tzw. „metody Kawase MGF”. Choć metoda Kawase'a sprawia, że wszystkie jasne obszary kwitną, World Wide Maze tworzy osobne cele renderowania do obszarów, które wymagają blasku. Dzieje się tak, ponieważ jako tło tekstu należy wykorzystać zrzut ekranu ze strony internetowej, a wyodrębnienie wszystkich jasnych obszarów sprawi, że cała witryna będzie świecić, jeśli na przykład ma białe tło. Zastanawiałem się też nad przetwarzaniem wszystkiego w HDR, ale tym razem już z tego nie korzystam, bo implementacja byłaby dość skomplikowana.

Poświata

W lewym górnym rogu widać pierwsze przejście, w którym obszary blasku były renderowane osobno, a następnie zastosowano rozmycie. W prawym dolnym rogu widać drugi bilet, w którym rozmiar obrazu został zmniejszony o 50% i zastosowano rozmycie. W prawym górnym rogu widać 3 przejście – zdjęcie zostało ponownie zmniejszone o 50%, a następnie zamazane. Zostały one nałożone, aby utworzyć końcowy obraz złożony, widoczny w lewym dolnym rogu. Do rozmycia użyłem VerticalBlurShader i HorizontalBlurShader zawartego w 3.js, więc jest jeszcze miejsce na dalszą optymalizację.

Piłka odblaskowa

Odbicie w piłce jest oparte na przykładzie z tagu trzy.js. Wszystkie wskazówki są generowane na podstawie pozycji piłki i używane jako mapy środowiska. Mapy środowiska muszą być aktualizowane za każdym razem, gdy piłka się porusza. Jednak aktualizacje przy 60 klatkach na sekundę są intensywne, dlatego są aktualizowane co 3 klatki. Nie jest to tak łatwe, jak aktualizacja każdej klatki, ale różnica jest praktycznie niezauważalna, jeśli nie wskazano inaczej.

Shader, program do cieniowania...

WebGL wymaga cieniowania wierzchołków, a także cieniowania fragmentów: każdego renderowania. Choć moduły do cieniowania uwzględnione w 3.js już pozwalają na stosowanie szerokiego zakresu efektów, to samodzielne pisanie jest nieuniknione, jeśli chodzi o bardziej zaawansowane cieniowanie i optymalizację. Ponieważ labirynt World Wide w ramach procesora fizycznego wykorzystuje silnik fizyki, postanowiłem wykorzystać go, pisząc jak najwięcej w języku cieniowania (GLSL), nawet jeśli przetwarzanie procesora (przy użyciu JavaScriptu) byłoby łatwiejsze. Efekty fal oceanicznych bazują na cieniowaniu, podobnie jak fajerwerki w punktach celu i efekt siatki, który pojawia się, gdy pojawia się piłka.

Kule do cienia

Powyższe wyniki pochodzą z testów efektu siatki, który pojawia się, gdy pojawia się piłka. Ten po lewej to ten używany w grze, który składa się z 320 wielokątów. Ten po prawej ma około 5 tysięcy wielokątów, a ten po prawej – około 300 tysięcy. Nawet przy tylu wielokątach przetwarzanie przy użyciu programów do cieniowania może utrzymać stałą szybkość 30 klatek na sekundę.

Siatka cienia

Małe elementy rozrzucone na scenie są zintegrowane w jedną siatkę, a poszczególne ruchy polegają na poruszaniu się końcówkami wielokątów przez cieniowanie. W ten sposób sprawdzamy, czy wydajność może pogorszyć się w przypadku dużej liczby obiektów. Na terenie obiektu znajduje się około 5000 obiektów złożonych z około 20 000 wielokątów. Skuteczność nie pogorszyła się.

poly2tri

Etapy są tworzone na podstawie informacji o konspektach otrzymanych z serwera, a następnie wielokątów za pomocą JavaScriptu. Kluczową częścią tego procesu jest triangulacja, która jest słabo zaimplementowana przez 3.js i zwykle kończy się niepowodzeniem. Postanowiłem więc samodzielnie zintegrować inną bibliotekę triangulacyjną o nazwie poly2tri. Jak się okazuje, w 3.js udawało mi się to samo w przeszłości, więc udało mi się to osiągnąć, komentując fragment tekstu. W rezultacie liczba błędów znacznie się zmniejszyła, dzięki czemu można zagrać na większej liczbie etapów. Czasami błąd się powtarza, a z jakiegoś powodu poly2tri obsługuje błędy przez wysyłanie alertów, więc zmodyfikowałem go tak, by zamiast nich dodał wyjątki.

poly2tri

Powyżej widać, jak niebieski kontur jest triangulowany i generowane są czerwone wielokąty.

Filtrowanie anizotropowe

Standardowe mapowanie izotropowe MIP zmniejsza rozmiar obrazów zarówno na osi poziomej, jak i pionowej, dlatego wyświetlanie wielokątów pod kątem ukośnym sprawia, że tekstury na dalekim końcu labiryntu w ramach World Wide Maze wyglądają jak podłużne w poziomie tekstury o niskiej rozdzielczości. Dobrym przykładem jest prawy górny róg tej strony w Wikipedii. W praktyce wymagana jest większa rozdzielczość pozioma, a WebGL (OpenGL) rozwiązuje ten problem, stosując metodę filtrowania anizotropowego. Ustawienie w tagu Trzy.js wartości większej niż 1 dla elementu THREE.Texture.anisotropy włącza filtrowanie anizotropowe. Ta funkcja jest jednak rozszerzeniem i może nie być obsługiwana przez niektóre procesory graficzne.

Optymalizuj

Jak wspominamy w tym artykule o sprawdzonych metodach korzystania z WebGL, najważniejszym sposobem na poprawę wydajności WebGL (OpenGL) jest zminimalizowanie liczby wywołań rysowania. Na początku prac nad labiryntem World Wide w grze wszystkie wyspy, mosty i bariery były oddzielnymi obiektami. Czasem powodowało to ponad 2000 wywołań,co sprawiało, że skomplikowane etapy były nieporęczne. Jednak gdy udało mi się spakować wszystkie obiekty tego samego typu w jedną siatkę, liczba połączeń zmalała do około 50, co znacznie poprawiło wydajność.

Do dalszej optymalizacji użyłem funkcji śledzenia w Chrome. Programy profilujące dostępne w Narzędziach dla programistów w Chrome mogą do pewnego stopnia określić ogólny czas przetwarzania metod, ale śledzenie może dokładnie określić czas trwania poszczególnych etapów (z dokładnością do 1/1000 sekundy). Szczegółowe informacje o korzystaniu z tej funkcji znajdziesz w tym artykule.

Optymalizacja

Powyższe wyniki to ślady po utworzeniu map środowiska dla odbicia piłki. Wstawienie parametrów console.time i console.timeEnd w pozornie odpowiednich lokalizacjach w tagu Trzy.js daje wykres podobny do tego. Czas płynie od lewej do prawej, a każda warstwa przypomina stos wywołań. Zagnieżdżenie konsoli.time w obrębie console.time umożliwia dalsze pomiary. Górny wykres przedstawia treść przed optymalizacją, a dolny – po jej optymalizacji. Jak widać na górnym wykresie, podczas wstępnej optymalizacji wywoływano funkcję updateMatrix (chociaż słowo jest obcięte) w przypadku każdego z wyrenderowanych elementów od 0 do 5. Został on jednak zmodyfikowany tak, aby wywoływał się tylko raz, ponieważ ten proces jest wymagany tylko w przypadku zmiany pozycji lub orientacji obiektów.

Samo proces śledzenia w naturalny sposób pochłania zasoby, dlatego nadmierne użycie atrybutu console.time może spowodować znaczne odchylenie od rzeczywistej skuteczności, co utrudnia wskazanie obszarów do optymalizacji.

Dostosowywanie skuteczności

Ze względu na charakter internetu gra będzie prawdopodobnie uruchamiana na systemach o bardzo różnych parametrach. Seria Znajdź drogę do Oz, opublikowany na początku lutego, wykorzystuje klasę o nazwie IFLAutomaticPerformanceAdjust do skalowania efektów w zależności od wahań liczby klatek i zapewniania płynności odtwarzania. W ramach labiryntu World Wide w ramach tej samej klasy IFLAutomaticPerformanceAdjust rozgrywka jest skalowana w podany niżej sposób, aby zapewnić jak największą płynność rozgrywki:

  1. Jeśli liczba klatek spadnie poniżej 45 kl./s, mapy środowiska przestaną się aktualizować.
  2. Jeśli spadnie ona poniżej 40 kl./s, rozdzielczość renderowania zostanie zmniejszona do 70% (50% proporcji powierzchni).
  3. Jeśli spadnie ona poniżej 40 kl./s, funkcja FXAA (anti-aliasing) zostanie wyeliminowana.
  4. Jeśli spadnie ona poniżej 30 kl./s, efekty poświaty zostaną wyeliminowane.

Wyciek pamięci

Sprawne eliminowanie obiektów w przypadku pliku trzy.js to spory problem. Pozostawienie ich jednak bez zmian prowadzi oczywiście do wycieku pamięci, więc opracowałem metodę opisaną poniżej. @renderer odnosi się do elementu THREE.WebGLRenderer. (W najnowszej wersji trzy.js używana jest nieco inna metoda zmiany lokalizacji, więc prawdopodobnie taka opcja nie będzie działać w obecnej postaci).

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 stron w języku HTML. Tworzenie interfejsów 2D, takich jak wyświetlanie punktacji lub danych tekstowych we Flashu lub w oprogramowaniu openFrameworks (OpenGL), jest uciążliwe. Flash ma przynajmniej IDE, ale openFrameworks nie jest przyzwyczajony do korzystania z niego (ułatwi to użycie czegoś takiego jak Cocos2D). HTML natomiast umożliwia precyzyjną kontrolę wszystkich aspektów projektowania frontendu za pomocą CSS, tak jak podczas tworzenia witryn. Chociaż złożone efekty, takie jak skondensowanie cząstek w logo, są niemożliwe, możliwe są jednak pewne efekty 3D przy użyciu Przekształceń CSS. Efekty tekstowe „GOAL” i „TIME IS UP” w World Wide w labiryncie są animowane z użyciem skali w ramach przejścia CSS (zaimplementowanego za pomocą funkcji Transport publiczny). (Oczywiście gradacje tła opierają się na WebGL.)

Każda strona w grze (tytuł, WYNIK, RANKING itp.) ma własny plik HTML, a gdy zostaną one wczytane jako szablony, funkcja $(document.body).append() jest wywoływana z odpowiednimi wartościami w odpowiednim czasie. Wystąpił błąd, ponieważ nie można było skonfigurować zdarzeń myszy i klawiatury przed dodaniem elementu, więc próba dodania polecenia el.click (e) -> console.log(e) przed dodaniem nie powiodła się.

Internacjonalizacja (i18n)

Praca w języku HTML była również wygodna przy tworzeniu angielskiej wersji językowej. Do swoich potrzeb w zakresie internacjonalizacji wybrałem i18next, bibliotekę internetową i18n, która mogła zostać bez modyfikacji.

Edytowano i przetłumaczono tekst w grze w arkuszu kalkulacyjnym Dokumentów Google. Ponieważ i18next wymaga plików JSON, wyeksportowałem arkusze kalkulacyjne do pliku TSV, a następnie skonwertowałem je za pomocą niestandardowego konwertera. Tuż przed premierą wprowadziłem wiele zmian, więc zautomatyzowanie procesu eksportu z poziomu arkusza kalkulacyjnego Dokumentów Google znacznie ułatwiłoby ten proces.

Funkcja automatycznego tłumaczenia w Chrome również działa normalnie, ponieważ strony są tworzone w języku HTML. Czasami jednak nie jest prawidłowo wykrywany, zamiast tego błędnie rozpoznaje język (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 000 wierszy kodu źródłowego gry zostało podzielonych na około 60 klas (= pliki kawy) i skompilowanych w pojedyncze pliki js. WymagajJS wczytuje te poszczególne pliki w odpowiedniej kolejności w zależności od zależności.

define ->
  class Hoge
    hogeMethod: ->

Zdefiniowanej powyżej klasy (hoge.coffee) można użyć w następujący sposób:

define ['hoge'], (Hoge) ->
  class Moge
    constructor: ->
      @hoge = new Hoge()
      @hoge.hogeMethod()

Aby działało, kod hoge.js musi być wczytywany przed moge.js, a ponieważ „hoge” jest ustawiony jako pierwszy argument „define”, hoge.js jest zawsze ładowany jako pierwszy (wywoływany po zakończeniu wczytywania hoge.js). Mechanizm ten nosi nazwę AMD. Do tego samego rodzaju wywołań zwrotnych można używać dowolnej biblioteki zewnętrznej, o ile obsługuje ona technologię AMD. Nawet te, które nie działają (np. 3.js), będą działać podobnie, o ile dependy zostaną określone z wyprzedzeniem.

Działa to podobnie do importowania AS3, więc nie powinno się wydawać dziwne. Jeśli uzyskasz więcej zależnych plików, to jest możliwe rozwiązanie.

r.js

WymagajJS zawiera optymalizator o nazwie r.js. Spowoduje to połączenie głównego kodu js ze wszystkimi zależnymi plikami js w jeden, a następnie zminimalizowanie go przy użyciu UglifyJS (lub Closure Compiler). Zmniejsza to liczbę plików i całkowitą ilość danych, które musi wczytać przeglądarka. Całkowity rozmiar pliku JavaScript w labiryncie World Wide Lab to około 2 MB i można go zmniejszyć za pomocą optymalizacji r.js. Jeśli gra można rozpowszechniać za pomocą programu gzip, jej rozmiar zostanie dodatkowo zmniejszony do 250 KB. (W GAE występuje problem, który uniemożliwia przesyłanie plików gzip o wielkości co najmniej 1 MB, dlatego gra jest obecnie rozpowszechniana bez kompresji jako 1 MB zwykłego tekstu).

Konstruktor sceny

Dane etapu są generowane w ten sposób i w całości wykonywane na serwerze GCE w Stanach Zjednoczonych:

  1. Adres URL witryny, która ma zostać przekonwertowana na etap, jest wysyłany przez WebSocket.
  2. PhantomJS robi zrzut ekranu, pobiera zawartość tagów div oraz img i pobiera dane wyjściowe w formacie JSON.
  3. Na podstawie zrzutu ekranu z kroku 2 oraz danych pozycjonowania elementów HTML niestandardowy program w C++ (OpenCV, Boost) usuwa niepotrzebne obszary, generuje wyspy, łączy wyspy z mostami, oblicza barierę i pozycję elementów, ustawia punkt celu itp. Wyniki są wyświetlane 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, dzięki czemu może być używana w testach automatycznych oraz do robienia zrzutów ekranu po stronie serwera. Mechanizm obsługi przeglądarki to WebKit, taki sam jak Chrome i Safari, dzięki czemu układ i wyniki wykonywania JavaScriptu są mniej więcej takie same jak w standardowych przeglądarkach.

W przypadku PhantomJS JavaScript lub CoffeeScript jest używany do zapisywania procesów, które mają zostać wykonane. Robienie zrzutów ekranu jest bardzo łatwe, co pokazuje ten przykład. Pracowałem na serwerze z Linuksem (CentOS), więc musiałem zainstalować czcionki wyświetlane w języku japońskim (M+ FONTS). Nawet wtedy renderowanie czcionek jest obsługiwane inaczej niż w systemie Windows i macOS, więc ta sama czcionka może wyglądać inaczej na innych komputerach (różnica jest jednak minimalna).

Pobieranie pozycji tagów img i div odbywa się w taki sam sposób jak na stronach standardowych. Biblioteka jQuery może być również używana bez żadnych problemów.

stage_builder

Początkowo rozważałem użycie metody opartej na DOM (podobnie jak w przypadku inspektora 3D w Firefoksie), a potem próbowaliśmy zrobić to w przypadku analizy DOM w PphantomJS. Ostatecznie jednak postawiłem się na metodę przetwarzania obrazów. W tym celu napisałem program w C++, który korzysta z OpenCV i Boost, o nazwie „stage_builder”. Wykonuje te działania:

  1. Wczytuje zrzut ekranu i pliki JSON.
  2. Przekształca obrazy i tekst w „wyspy”.
  3. Tworzy mosty łączące wyspy.
  4. Eliminuje niepotrzebne mosty, aby tworzyć labirynt.
  5. Umieszcza duże elementy.
  6. Umieszcza małe przedmioty.
  7. Umieszcza bariery.
  8. Na wyjściu generuje dane o pozycjonowaniu w formacie JSON.

Poniżej znajdziesz szczegółowe informacje o każdym kroku.

Wczytuję zrzut ekranu i pliki JSON

Do wczytywania zrzutów ekranu używany jest standardowy format cv::imread. Przetestowałem kilka bibliotek do plików JSON, ale zastosowanie pliku picojson wydaje się najłatwiejsze.

Konwersja obrazów i tekstu na „wyspy”

Kompilacja etapowa

Powyżej znajduje się zrzut ekranu sekcji Wiadomości na stronie aid-dcc.com (kliknij, aby zobaczyć jej rozmiar). Obrazy i elementy tekstowe muszą zostać przekształcone w wyspy. Aby wyodrębnić te sekcje, musimy usunąć biały kolor tła – czyli najbardziej powszechny kolor na zrzucie ekranu. Po wykonaniu tych czynności będzie to wyglądało tak:

Kompilacja etapowa

Białe sekcje to potencjalne wyspy.

Tekst jest zbyt drobny i ostry, więc pogrubimy go za pomocą atrybutów cv::dilate, cv::GaussianBlur i cv::threshold. Brakuje treści graficznych, więc wypełniamy te obszary białymi kolorami na podstawie danych tagu img z PphantomJS. Wynikowy obraz będzie wyglądał tak:

Kompilacja etapowa

Tekst tworzy teraz odpowiednie bloki, a każdy obraz jest właściwą wyspą.

Tworzę mosty łączące wyspy

Gdy wyspy są gotowe, są one połączone mostami. Każda wyspa szuka przyległych wysp po lewej, prawej, nad i poniżej, a następnie łączy most z najbliższym punktem najbliższej wyspy. Efektem jest podobny wynik:

Kompilacja etapowa

Eliminacja niepotrzebnych mostów w celu utworzenia labiryntu

Pozostawienie wszystkich mostów sprawiłoby, że poruszanie się po scenie byłoby zbyt łatwe, dlatego trzeba usunąć niektóre z nich, aby utworzyć labirynt. Jako punkt początkowy wybierana jest jedna wyspa (np. ta w lewym górnym rogu), a wszystkie łączące się z nią mosty oprócz jednego (wybrane losowo) zostaną usunięte. To samo zrobimy z kolejną wyspą, która jest połączona pozostałym mostem. Gdy ścieżka wkracza na ślepy zaułek lub prowadzi z powrotem na odwiedzoną wcześniej wyspę, cofa się do punktu umożliwiającego dostęp do nowej wyspy. Labirynt ukończony, gdy wszystkie wyspy zostaną w ten sposób przetworzone.

Kompilacja etapowa

Umieszczanie dużych elementów

Na każdej wyspie w zależności od jej wymiarów umieszczany jest co najmniej jeden duży obiekt, który jest wybierany z punktów znajdujących się najdalej od krawędzi wysp. Te punkty są widoczne poniżej na czerwono, choć nie jest to bardzo oczywiste:

Kompilacja etapowa

Ze wszystkich tych możliwych punktów punkt początkowy jest ustawiony jako punkt początkowy (czerwone kółko), cel w prawym dolnym rogu (zielone kółko), a maksymalnie sześć z pozostałych jest wybieranych do umieszczenia dużych elementów (fioletowe kółko).

Umieszczanie małych elementów

Kompilacja etapowa

Odpowiednie liczby małych przedmiotów są umieszczone wzdłuż linii w określonych odległościach od krawędzi wyspy. Na powyższej ilustracji (spoza aid-dcc.com) rzutowane linie miejsca docelowego są wyszarzone i przesunięte oraz rozmieszczone w regularnych odstępach od krawędzi wyspy. Czerwone kropki wskazują, gdzie znajdują się małe elementy. Ten obraz pochodzi z wersji w połowie projektu, więc elementy są ułożone w liniach prostych, ale w ostatecznej wersji elementy rozmieściliśmy nieco bardziej nieregularnie na każdej ze stron szarych linii.

Umieszczanie barierek

Barierki są w zasadzie rozmieszczone wzdłuż zewnętrznych granic wysp, ale muszą być odcięte na mostach, aby można było na nie wejść. Przydatna okazała się biblioteka geometrii Boost, która upraszcza obliczenia geometryczne, na przykład do określania, gdzie dane granic wyspy przecinają się z liniami po obu stronach mostu.

Kompilacja etapowa

Granicą górną są zielone linie otaczające wyspy. Może być niedoskonała na tym zdjęciu, ale w miejscach, w których znajdują się mosty, nie ma zielonych linii. To końcowy obraz używany do debugowania, w którym uwzględniane są wszystkie obiekty, które należy przesłać do JSON. Jasnoniebieskie kropki to małe elementy, a szare kropki to proponowane punkty ponownego uruchamiania. Kiedy piłka wpada do oceanu, gra jest wznawiana od najbliższego punktu ponownego rozpoczęcia gry. Punkty ponownego uruchamiania są rozmieszczone mniej więcej tak samo jak małe przedmioty, w regularnych odstępach czasu w określonej odległości od krawędzi wyspy.

Wyjściowe dane o pozycjonowaniu w formacie JSON

Użyłem też picojson jako danych wyjściowych. Służy do zapisywania danych na standardowym wyjściu, które jest następnie odbierane przez element wywołujący (Node.js).

Tworzenie programu w C++ na Macu do uruchomienia w Linuksie

Gra została stworzona na Maca i wdrożona na Linuksie. Jednak ponieważ OpenCV i Boost były dostępne dla obu systemów operacyjnych, po utworzeniu środowiska kompilowanego samo programowanie nie było trudne. Użyłem narzędzi wiersza poleceń w Xcode do debugowania kompilacji na Macu, a następnie utworzyłem plik konfiguracji za pomocą automake/autoconf, aby można było skompilować kompilację w Linuksie. Następnie musiałem użyć polecenia „Skonfiguruj i utwórz” w Linuksie, aby utworzyć plik wykonywalny. Z powodu różnic w wersjach kompilatora wystąpiły pewne błędy typowe dla systemu Linux, ale udało mi się je stosunkowo łatwo rozwiązać, korzystając z gdb.

Podsumowanie

Taką grę można tworzyć za pomocą Flasha lub Unity, co przynosi liczne korzyści. Ta wersja nie wymaga jednak żadnych wtyczek, a funkcje układu HTML5 i CSS3 okazały się niezwykle przydatne. Na pewno warto mieć odpowiednie narzędzia do każdego zadania. Osobiście byłem zaskoczony, jak dobrze okazała się gra w całości utworzona w formacie HTML5 i chociaż w wielu obszarach brakuje jej w wielu obszarach, z niecierpliwością czekam na dalszy rozwój tej gry.