Fallstudie – Inside World Wide Maze

World Wide Maze ist ein Spiel, bei dem du mit deinem Smartphone einen rollenden Ball durch 3D-Labyrinthe navigierst, die aus Websites erstellt wurden, um deine Ziele zu erreichen.

World Wide Labyrinth

Das Spiel bietet reichlich Einsatz von HTML5-Funktionen. Das Ereignis DeviceOrientation ruft beispielsweise Neigungsdaten vom Smartphone ab und wird dann über WebSocket an den PC gesendet. Dort finden Spieler 3D-Bereiche, die von WebGL und Web Workers erstellt wurden.

In diesem Artikel erläutere ich die genaue Verwendung dieser Funktionen, den allgemeinen Entwicklungsprozess und wichtige Punkte für die Optimierung.

DeviceOrientation

Das DeviceOrientation-Ereignis (Beispiel) wird verwendet, um Neigungsdaten vom Smartphone abzurufen. Wenn addEventListener mit dem DeviceOrientation-Ereignis verwendet wird, wird in regelmäßigen Abständen ein Callback mit dem DeviceOrientationEvent-Objekt als Argument aufgerufen. Die Intervalle selbst variieren je nach verwendetem Gerät. In iOS + Chrome und iOS + Safari wird der Callback beispielsweise ungefähr im 20-Sekunden-Takt aufgerufen, in Android 4 und Chrome dagegen etwa an Zehntelsekunden.

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

Das DeviceOrientationEvent-Objekt enthält Neigungsdaten für jede der X-, Y- und Z-Achsen in Grad (nicht im Bogenmaß). Weitere Informationen zu HTML5Rocks Die Rückgabewerte variieren jedoch auch je nach verwendeter Kombination aus Gerät und Browser. Die Bereiche der tatsächlichen Rückgabewerte sind in der folgenden Tabelle dargestellt:

Geräteausrichtung.

Die oben blau hervorgehobenen Werte entsprechen denen in den W3C-Spezifikationen. Die grün hervorgehobenen Elemente entsprechen diesen Spezifikationen, während die rot hervorgehobenen Spezifikationen abweichen. Überraschenderweise gab nur die Android-Firefox-Kombination Werte zurück, die den Spezifikationen entsprachen. Bei der Implementierung ist es jedoch sinnvoller, Werte zu berücksichtigen, die häufig vorkommen. World Wide Maze verwendet daher die iOS-Rückgabewerte standardmäßig und nimmt entsprechende Anpassungen für Android-Geräte vor.

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

Das Nexus 10 wird jedoch immer noch nicht unterstützt. Obwohl das Nexus 10 den gleichen Wertebereich wie andere Android-Geräte zurückgibt, gibt es einen Fehler, der die Beta- und Gammawerte umkehrt. Dies wird separat behandelt. (Möglicherweise wird standardmäßig das Querformat verwendet?)

Dies zeigt, dass selbst wenn APIs mit physischen Geräten bestimmte Spezifikationen festgelegt haben, gibt es keine Garantie, dass die zurückgegebenen Werte diesen Spezifikationen entsprechen. Daher ist es wichtig, sie auf allen potenziellen Geräten zu testen. Es bedeutet auch, dass unerwartete Werte eingegeben werden können, wofür Behelfslösungen erstellt werden müssen. World Wide Maze fordert neue Spieler auf, ihre Geräte wie in Schritt 1 des Tutorials zu kalibrieren, kalibriert sich jedoch nicht richtig auf die Nullposition, wenn unerwartete Neigungswerte erkannt werden. Es gibt daher ein internes Zeitlimit und fordert den Spieler auf, zur Tastatursteuerung zu wechseln, wenn die Kalibrierung nicht innerhalb dieses Zeitlimits möglich ist.

WebSocket

In World Wide Maze sind Ihr Smartphone und Ihr PC über WebSocket verbunden. Genauer gesagt sind sie über einen Relay-Server verbunden, d.h. Smartphone zu Server und PC. Das liegt daran, dass WebSocket nicht in der Lage ist, Browser direkt miteinander zu verbinden. Die Verwendung von WebRTC-Datenkanälen ermöglicht Peer-to-Peer-Verbindungen und macht einen Relay-Server aus. Zum Zeitpunkt der Implementierung konnte diese Methode jedoch nur mit Chrome Canary und Firefox Nightly verwendet werden.

Für die Implementierung habe ich eine Bibliothek namens Socket.IO (v0.9.11) verwendet, die Funktionen für die Wiederherstellung der Verbindung bei Zeitüberschreitungen oder Verbindungsabbrüchen enthält. Ich habe sie zusammen mit NodeJS verwendet, da diese Kombination aus NodeJS und Socket.IO bei mehreren WebSocket-Implementierungstests die beste serverseitige Leistung aufwies.

Kopplung über Nummern

  1. Ihr PC stellt eine Verbindung zum Server her.
  2. Der Server teilt Ihrem PC eine zufällig generierte Zahl zu und merkt sich die Kombination aus Zahl und PC.
  3. Geben Sie auf Ihrem Mobilgerät eine Nummer an und stellen Sie eine Verbindung zum Server her.
  4. Wenn die angegebene Nummer mit der eines verbundenen PCs übereinstimmt, ist Ihr Mobilgerät mit diesem PC gekoppelt.
  5. Wenn kein festgelegter PC vorhanden ist, tritt ein Fehler auf.
  6. Wenn Daten von Ihrem Mobilgerät eingehen, werden sie an den PC gesendet, mit dem es gekoppelt ist, und umgekehrt.

Sie können die Verbindung aber auch von Ihrem Mobilgerät aus herstellen. In diesem Fall werden die Geräte einfach umgekehrt.

Tab-Synchronisierung

Die für Chrome spezifische Funktion „Tab-Synchronisierung“ erleichtert den Kopplungsprozess. Damit können auf dem PC geöffnete Seiten problemlos auch auf einem Mobilgerät geöffnet werden (und umgekehrt). Der PC fügt die vom Server ausgegebene Verbindungsnummer mithilfe von history.replaceState an die URL einer Seite an.

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

Wenn die Tab-Synchronisierung aktiviert ist, wird die URL nach einigen Sekunden synchronisiert und dieselbe Seite kann auf dem Mobilgerät geöffnet werden. Das Mobilgerät prüft die URL der geöffneten Seite. Wird eine Zahl angehängt, wird sofort eine Verbindung hergestellt. So müssen Sie keine Nummern manuell eingeben oder QR-Codes mit einer Kamera scannen.

Latenz

Da sich der Relay-Server in den USA befindet, führt der Zugriff von Japan aus zu einer Verzögerung von etwa 200 ms, bevor die Neigungsdaten des Smartphones den PC erreichen. Die Antwortzeiten waren im Vergleich zu der lokalen Umgebung, die während der Entwicklung genutzt wurde, deutlich schleppend, aber durch Einfügen eines Tiefpassfilters (ich habe EMA verwendet) wurde dies auf unaufdringliche Ebene verbessert. (In der Praxis wurde auch zu Präsentationszwecken ein Tiefpassfilter benötigt. Die Rückgabewerte des Neigungssensors enthielten eine erhebliche Menge an Rauschen, und die Anwendung dieser Werte auf den Bildschirm führt zu einer starken Erschütterung.) Das funktionierte nicht bei Sprüngen, die eindeutig langsam waren, aber nichts könnte das Problem lösen.

Da ich von Anfang an Latenzprobleme erwartet hatte, überlegte ich, Relay-Server auf der ganzen Welt einzurichten, damit Clients eine Verbindung zum nächstgelegenen verfügbaren Bereich herstellen können (und so die Latenz minimieren). Da es aber schließlich die Google Compute Engine (GCE) gab, die es zu dieser Zeit nur in den USA gab, war das nicht möglich.

Das Nagle-Algorithmus

Der Nagle-Algorithmus ist in der Regel in Betriebssystemen integriert, um eine effiziente Kommunikation durch eine Zwischenspeicherung auf TCP-Ebene zu ermöglichen. Ich habe jedoch festgestellt, dass ich keine Daten in Echtzeit senden konnte, während dieser Algorithmus aktiviert war. Dies gilt insbesondere in Kombination mit der verzögerten TCP-Bestätigung. Auch ohne verzögerte ACK tritt das gleiche Problem auf, wenn ACK aufgrund von Faktoren wie z. B. Standort des Servers im Ausland zu einem bestimmten Grad verzögert ist.

Das Nagle-Latenzproblem trat bei WebSocket in Chrome für Android nicht auf, das die Option TCP_NODELAY zum Deaktivieren von Nagle umfasst. Es trat jedoch bei dem WebKit-WebSocket auf, das in Chrome für iOS verwendet wird, für das diese Option nicht aktiviert ist. Dieses Problem tritt ebenfalls bei Safari auf, das dasselbe WebKit verwendet. Das Problem wurde Apple über Google gemeldet und offenbar in der Entwicklerversion von WebKit behoben.

Wenn dieses Problem auftritt, werden Neigungsdaten, die alle 100 ms gesendet werden, in Blöcke zusammengefasst, die den PC nur alle 500 ms erreichen. Das Spiel funktioniert unter diesen Bedingungen nicht, sodass diese Latenz vermieden wird, indem die Serverseite Daten in kurzen Intervallen (etwa alle 50 ms) sendet. Ich glaube, dass der Nagle-Algorithmus glaubt, dass es in Ordnung ist, Daten in kurzen Abständen zu empfangen, wenn er ACK täuscht.

Nagle-Algorithmus 1

Im obigen Diagramm werden die Intervalle der tatsächlich empfangenen Daten grafisch dargestellt. Er zeigt die Zeitintervalle zwischen Paketen an. Grün steht für Ausgabeintervalle und Rot für Eingabeintervalle. Der Mindestwert beträgt 54 ms, der Höchstwert 158 ms und der Mittelwert etwa 100 ms. Hier habe ich ein iPhone mit einem Relay-Server in Japan verwendet. Sowohl Ausgabe als auch Eingabe dauern etwa 100 ms und der Betrieb ist reibungslos.

Nagle-Algorithmus 2

Im Gegensatz dazu zeigt diese Grafik die Ergebnisse der Nutzung des Servers in den USA. Während die grünen Ausgabeintervalle bei 100 ms konstant bleiben, schwankt die Eingabeintervalle zwischen Tiefstwerten von 0 ms und Höchstwerte von 500 ms, was darauf hinweist, dass der PC Daten in Teilen empfängt.

ALT_TEXT_HERE

Zu guter Letzt zeigt dieses Diagramm die Ergebnisse der Vermeidung von Latenzen durch den Server, der Platzhalterdaten sendet. Es funktioniert zwar nicht so gut wie beim japanischen Server, doch die Eingabeintervalle sind bei etwa 100 ms relativ stabil.

Ein Programmfehler?

Obwohl der Standardbrowser in Android 4 (ICS) über eine WebSocket API verfügt, kann er keine Verbindung herstellen, was zu einem Socket.IO-Ereignis „connect_failed“ führt. Intern ist eine Zeitüberschreitung aufgetreten und die Serverseite kann ebenfalls keine Verbindung verifizieren. (Ich habe dies nicht allein mit WebSocket getestet, es könnte also ein Socket.IO-Problem vorliegen.)

Relay-Server skalieren

Da die Rolle des Relay-Servers nicht so kompliziert ist, sollte es nicht schwierig sein, mehrere Server hochzuskalieren und die Anzahl der Server zu erhöhen, solange immer derselbe PC und dasselbe Mobilgerät mit demselben Server verbunden sind.

Physik

Die Ballbewegung im Spiel (abwärts rollen, mit dem Boden kollidieren, mit Wänden kollidieren, Gegenstände sammeln usw.) wird mit einem 3D-Physiksimulator ausgeführt. Ich habe Ammo.js verwendet – eine Portion der weitverbreiteten Bullet-Physik-Engine mit Emscripten in JavaScript und Physijs, um sie als „Web Worker“ zu nutzen.

Web Worker

Web Worker ist eine API zum Ausführen von JavaScript in separaten Threads. JavaScript, das als Web Worker gestartet wird, wird als Thread ausgeführt, das getrennt von dem Thread ist, der ihn ursprünglich aufgerufen hat. Dadurch können umfangreiche Aufgaben ausgeführt werden, während die Seite reaktionsfähig bleibt. Physijs nutzt Web Worker effizient, damit das normalerweise intensive 3D-Physikmodul reibungslos läuft. World Wide Maze verarbeitet das Physikmodul und das WebGL-Bildrendering mit völlig unterschiedlichen Framerates. Selbst wenn die Framerate auf einem Low Specs-Gerät aufgrund einer hohen WebGL-Renderinglast sinkt, sorgt die Physikfunktion selbst mehr oder weniger auf 60 fps und behindert die Spielsteuerung nicht.

fps

Auf diesem Bild sind die Framerates auf einem Lenovo G570 zu sehen. Das obere Feld zeigt die Framerate für WebGL (Bildrendering) und das untere die Framerate für die Physikmaschine. Bei der GPU handelt es sich um einen integrierten Intel HD Graphics 3000-Chip, sodass die Framerate für das Bildrendering nicht die erwarteten 60 fps erreichte. Da das Physikmodul jedoch die erwartete Framerate erreicht hat, unterscheidet sich das Gameplay nicht so sehr von der Leistung auf einem anspruchsvollen Gerät.

Da Threads mit aktiven Web Workern keine Konsolenobjekte haben, müssen Daten über postMessage an den Hauptthread gesendet werden, damit Debugging-Protokolle erstellt werden können. Mit console4Worker wird das Äquivalent eines Konsolenobjekts im Worker erstellt, was die Fehlerbehebung erheblich vereinfacht.

Service Worker

Mit aktuellen Versionen von Chrome können Sie beim Starten von Web Workers Haltepunkte festlegen, was auch für die Fehlerbehebung hilfreich ist. Diese finden Sie in den Entwicklertools im Bereich „Worker“.

Leistung

Phasen mit einer hohen Anzahl an Polygonen überschreiten manchmal die Zahl von 100.000 Polygonen. Die Leistung war jedoch nicht besonders beeinträchtigt, selbst wenn sie vollständig als Physijs.ConcaveMesh (btBvhTriangleMeshShape in Bullet) generiert wurden.

Anfangs sank die Framerate mit zunehmender Anzahl von Objekten, für die eine Kollisionserkennung erforderlich war. Durch den Wegfall unnötiger Verarbeitung in Physijs verbesserte sich die Leistung. Diese Verbesserung wurde an einer Abspaltung des ursprünglichen Physijs vorgenommen.

Geisterobjekte

Objekte, die eine Kollisionserkennung haben, aber keine Auswirkungen auf die Kollision und somit keine Auswirkungen auf andere Objekte haben, werden in Bullet als „Ghost Objects“ (Geisterobjekte) bezeichnet. Ghost-Objekte werden von Physijs zwar nicht offiziell unterstützt, aber Sie können sie dort erstellen, indem Sie nach dem Generieren eines Physijs.Mesh an Flags herumspielen. World Wide Maze verwendet Geisterobjekte für die Kollisionserkennung von Gegenständen und Zielpunkten.

hit = new Physijs.SphereMesh(geometry, material, 0)
hit._physijs.collision_flags = 1 | 4
scene.add(hit)

Für collision_flags ist 1 CF_STATIC_OBJECT und 4 CF_NO_CONTACT_RESPONSE. Weitere Informationen finden Sie im Bullet-Forum, in Stack Overflow oder in der Bullet-Dokumentation. Da Physijs ein Wrapper für Ammo.js ist und Ammo.js im Grunde mit Bullet identisch ist, sind die meisten Aktionen in Bullet auch in Physijs möglich.

Das Problem mit Firefox 18

Das Firefox-Update von Version 17 auf 18 veränderte die Art und Weise, wie Web Worker Daten ausgetauscht haben, und Physijs funktionierte nicht mehr. Das Problem wurde auf GitHub gemeldet und nach einigen Tagen behoben. Diese Open-Source-Effizienz hat mich zwar beeindruckt, aber der Vorfall erinnert mich auch daran, dass World Wide Maze aus mehreren verschiedenen Open-Source-Frameworks besteht. Ich schreibe diesen Artikel, um Ihnen Feedback zu geben.

asm.js

Obwohl das von World Wide Maze nicht direkt betroffen ist, unterstützt Ammo.js bereits das kürzlich von Mozilla entwickelte asm.js-Script. Dies ist nicht verwunderlich, da asm.js im Grunde erstellt wurde, um von Emscripten generierten JavaScript-Code zu beschleunigen, und der Entwickler von Emscripten ist auch der Entwickler von Ammo.js. Wenn auch asm.js von Chrome unterstützt wird, sollte die Rechenleistung der Physik Engine erheblich sinken. Die Geschwindigkeit war beim Testen mit Firefox Nightly merklich schneller. Vielleicht wäre es am besten, Abschnitte zu schreiben, die mehr Geschwindigkeit in C/C++ erfordern, und sie dann mit Emscripten in JavaScript zu portieren?

WebGL

Für die WebGL-Implementierung habe ich die aktivste Bibliothek verwendet: three.js (r53). Obwohl Version 57 bereits in einer späteren Entwicklungsphase veröffentlicht wurde, hatte ich große Änderungen an der API vorgenommen, sodass ich bei der ursprünglichen Version zur Veröffentlichung geblieben bin.

Schein-Effekt

Der Scheineffekt, der dem Kern des Balls und den Gegenständen hinzugefügt wird, wird mit einer einfachen Version der sogenannten Kawase-Methode MGF implementiert. Während die Kawase-Methode jedoch alle hellen Bereiche zum Blühen bringt, erstellt World Wide Maze separate Rendering-Ziele für Bereiche, die leuchten müssen. Dies liegt daran, dass für die Texturen im Anzeigebereich ein Website-Screenshot verwendet werden muss. Wenn einfach alle hellen Bereiche extrahiert werden, würde die gesamte Website zum Beispiel leuchten, wenn sie einen weißen Hintergrund hat. Außerdem habe ich darüber nachgedacht, alles in HDR zu verarbeiten, aber dieses Mal habe ich mich dagegen entschieden, da die Implementierung ziemlich kompliziert gewesen wäre.

Lichtschein

Oben links ist der erste Durchlauf zu sehen, bei dem die Scheinbereiche separat gerendert und anschließend unkenntlich gemacht wurden. Unten rechts sehen Sie den zweiten Durchgang, bei dem die Bildgröße um 50% reduziert und anschließend eine Unkenntlichmachung angewendet wurde. Oben rechts ist der dritte Durchlauf zu sehen, bei dem das Bild wieder um 50% reduziert und anschließend unkenntlich gemacht wurde. Die drei wurden dann übereinandergelegt, um das endgültige zusammengesetzte Bild zu erstellen, das unten links zu sehen war. Für die Unkenntlichmachung habe ich VerticalBlurShader und HorizontalBlurShader verwendet, die in third.js enthalten sind, sodass es also noch Raum für weitere Optimierungen gibt.

Reflexionsball

Die Reflexion auf dem Ball basiert auf einem Beispiel von third.js. Alle Richtungen werden von der Position des Balls aus wiedergegeben und als Umgebungskarten verwendet. Umgebungskarten müssen jedes Mal aktualisiert werden, wenn sich der Ball bewegt. Da die Aktualisierung mit 60 fps jedoch aufwendig ist, werden sie stattdessen alle drei Frames aktualisiert. Das Ergebnis ist nicht ganz so flüssig wie die Aktualisierung jedes Frames, aber der Unterschied ist praktisch nicht wahrnehmbar, sofern nicht anders angegeben.

Shader, Shader, Shader...

WebGL erfordert für das gesamte Rendering Shader (Vertex-Shader, Fragment-Shader). Die Shader in drei.js ermöglichen bereits eine Vielzahl von Effekten. Für eine aufwendigere Schattierung und Optimierung ist es jedoch unvermeidlich, einen eigenen Shader zu schreiben. Da World Wide Maze die CPU mit seinem Physikmodul beschäftigt, habe ich versucht, stattdessen die GPU zu nutzen und so viel wie möglich in Shading Language (GLSL) zu schreiben, selbst wenn die CPU-Verarbeitung (über JavaScript) einfacher gewesen wäre. Die Ozeanwelleneffekte stützen sich natürlich auf Shader, ebenso wie das Feuerwerk an Zielpunkten und der Mesh-Effekt, wenn der Ball erscheint.

Shader-Bälle

Die obigen Angaben stammen aus Tests zum Mesh-Effekt, der beim Erscheinen des Balls angewendet wurde. Das Symbol links wird im Spiel verwendet und besteht aus 320 Polygonen. Das eine in der Mitte hat etwa 5.000 Polygone, das rechte mit etwa 300.000 Polygonen. Selbst bei dieser Anzahl von Polygonen kann die Verarbeitung mit Shadern eine konstante Framerate von 30 fps aufrechterhalten.

Shader Mesh

Die kleinen Elemente, die auf der Bühne verstreut sind, sind alle in ein Netz integriert. Die einzelnen Bewegungen basieren auf den Shadern, die die Polygonspitzen bewegen. Dies ist ein Test, um festzustellen, ob die Leistung bei einer großen Anzahl von Objekten leiden würde. Hier sind etwa 5.000 Objekte aus etwa 20.000 Polygonen aufgeführt. Die Leistung hat überhaupt nicht nachgelassen.

poly2tri

Phasen werden anhand der vom Server empfangenen Umrissinformationen gebildet und dann durch JavaScript polygonisiert. Die Triangulation ist ein wesentlicher Bestandteil dieses Prozesses, wird durch drei.js schlecht implementiert und schlägt in der Regel fehl. Daher habe ich mich entschlossen, eine andere Triangulationsbibliothek namens poly2tri selbst einzubinden. Es hat sich herausgestellt, dass drei.js in der Vergangenheit offensichtlich dasselbe versucht hat. Also habe ich es einfach durch Kommentar auskommentiert. Die Fehlerrate ist dadurch erheblich gesunken, sodass viel mehr spielbare Phasen möglich sind. Der gelegentliche Fehler besteht weiterhin und aus irgendeinem Grund behebt poly2tri Fehler, indem es Benachrichtigungen ausgibt. Daher habe ich den Fehler so geändert, dass stattdessen Ausnahmen ausgegeben werden.

poly2tri

Das obige Beispiel zeigt, wie der blaue Umriss trianguliert und rote Polygone generiert werden.

Anisotrope Filterung

Da die standardmäßige isotrope MIP-Kartierung die Bilder sowohl auf horizontale als auch auf vertikale Achsen verkleinert, lassen die Texturen am hinteren Ende der World Wide Maze-Stufen durch die Betrachtung von Polygonen aus schrägen Winkeln wie horizontal verlängerte Texturen mit niedriger Auflösung aussehen. Das Bild oben rechts auf dieser Wikipedia-Seite ist ein gutes Beispiel dafür. In der Praxis ist eine höhere horizontale Auflösung erforderlich, die WebGL (OpenGL) mithilfe einer Methode auflöst, die als anisotrope Filterung bezeichnet wird. In drei.js wird die anisotrope Filterung aktiviert, wenn für THREE.Texture.anisotropy ein Wert größer als 1 festgelegt wird. Bei dieser Funktion handelt es sich jedoch um eine Erweiterung, die möglicherweise nicht von allen GPUs unterstützt wird.

Optimieren

Wie in diesem Artikel zu Best Practices für WebGL erwähnt, besteht der wichtigste Weg zur Verbesserung der Leistung von WebGL (OpenGL) darin, Draw-Aufrufe zu minimieren. Bei der anfänglichen Entwicklung von World Wide Maze waren alle spielinternen Inseln, Brücken und Leitplanken separate Objekte. Dies führte manchmal zu über 2.000 Zeichenaufrufen, wodurch komplexe Phasen unübersichtlich wurden. Nachdem ich jedoch die gleichen Objekttypen in ein Mesh-Netzwerk gesteckt hatte, sank die Zahl der Draw-Aufrufe auf ungefähr fünfzig, was die Leistung deutlich verbesserte.

Ich habe die Chrome-Nachverfolgungsfunktion zur weiteren Optimierung verwendet. Die in den Entwicklertools von Chrome enthaltenen Profiler können die Gesamtverarbeitungszeit einer Methode bis zu einem gewissen Grad bestimmen, aber durch Tracing können Sie genau ermitteln, wie lange jedes Teil dauert, bis zu einer Tausendstelsekunde. Weitere Informationen zur Verwendung von Tracing finden Sie in diesem Artikel.

Optimierung

Die obigen Ergebnisse sind Trace-Ergebnisse aus der Erstellung von Umgebungskarten für die Reflexion des Balls. Wenn Sie console.time und console.timeEnd in scheinbar relevante Stellen in third.js einfügen, erhalten wir eine Grafik, die wie folgt aussieht. Die Zeit fließt von links nach rechts und jede Schicht ist so etwas wie ein Aufrufstack. Wenn du „console.time“ in einem console.time verschachtelst, sind weitere Messungen möglich. Das obere Diagramm ist die vor der Optimierung und die unterste für die nach der Optimierung. Wie die obere Grafik zeigt, wurde das updateMatrix (obwohl das Wort abgeschnitten) für jedes der Renderings 0 bis 5 während der Voraboptimierung aufgerufen. Ich habe es jedoch so geändert, dass es nur einmal aufgerufen wird, da dieser Prozess nur erforderlich ist, wenn Objekte ihre Position oder Ausrichtung ändern.

Der Tracing-Prozess selbst verbraucht natürlich Ressourcen. Daher kann ein übermäßiges Einfügen von console.time zu einer erheblichen Abweichung von der tatsächlichen Leistung führen, wodurch sich Bereiche für die Optimierung schwer identifizieren lassen.

Leistungsanpassung

Aufgrund der Beschaffenheit des Internets wird das Spiel wahrscheinlich auf Systemen mit sehr unterschiedlichen Spezifikationen gespielt. Find Your Way to Oz wurde Anfang Februar veröffentlicht. Dabei wird eine Klasse namens IFLAutomaticPerformanceAdjust verwendet, um Effekte je nach Framerate-Schwankungen zu reduzieren und so für eine reibungslose Wiedergabe zu sorgen. World Wide Maze baut auf derselben IFLAutomaticPerformanceAdjust-Klasse auf und reduziert die Effekte in der folgenden Reihenfolge, um das Gameplay so reibungslos wie möglich zu gestalten:

  1. Wenn die Framerate unter 45 fps fällt, werden die Umgebungskarten nicht mehr aktualisiert.
  2. Wenn sie immer noch unter 40 fps fällt, wird die Rendering-Auflösung auf 70% (50% des Oberflächenverhältnis) reduziert.
  3. Falls die fps immer noch unter 40 fps fällt, wird FXAA (Anti-Aliasing) eliminiert.
  4. Wenn sie immer noch unter 30 fps fällt, werden Scheineffekte beseitigt.

Speicherleck

Die ordentliche Eliminierung von Objekten ist mit drei.js mühsam. Alleine zu lassen würde offensichtlich zu Speicherlecks führen. Daher entwickelte ich die folgende Methode. @renderer bezieht sich auf THREE.WebGLRenderer. In der neuesten Version von third.js wurde eine etwas andere Methode zur Aufhebung der Freigabe verwendet, sodass diese Methode wahrscheinlich nicht wie gewohnt funktioniert.

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

Ich persönlich finde, das Beste an der WebGL-App ist die Möglichkeit, Seitenlayouts in HTML zu entwerfen. Das Erstellen von 2D-Schnittstellen wie Score- oder Textdarstellungen in Flash oder openFrameworks (OpenGL) ist recht mühsam. Flash verfügt zumindest über eine IDE, aber openFrameworks ist schwer, wenn Sie es nicht gewöhnt sind (die Verwendung von Cocos2D kann dies einfacher machen). HTML hingegen ermöglicht eine präzise Steuerung aller Aspekte des Front-End-Designs mit CSS, so wie bei der Erstellung von Websites. Komplexe Effekte wie die Zusammenfassung von Partikeln zu einem Logo sind zwar unmöglich, aber mit den Funktionen von CSS-Transformationen sind einige 3D-Effekte möglich. Die Texteffekte „GOAL“ und „TIME IS UP“ von World Wide Maze werden in CSS Transition mithilfe von Skalierung animiert (mit Transition implementiert). Für die Hintergrundabstufungen wird WebGL verwendet.

Jede Seite im Spiel (Titel, ERGEBNIS, RANGFOLGE usw.) verfügt über eine eigene HTML-Datei. Sobald diese als Vorlagen geladen wurden, wird $(document.body).append() mit den entsprechenden Werten zum entsprechenden Zeitpunkt aufgerufen. Ein Problem bestand darin, dass Maus- und Tastaturereignisse nicht vor dem Anfügen festgelegt werden konnten. Daher hat es nicht funktioniert, die el.click (e) -> console.log(e) vor dem Anfügen zu versuchen.

Internationalisierung (i18n)

Die Arbeit in HTML war auch beim Erstellen der englischsprachigen Version praktisch. Für meine Internationalisierungsanforderungen habe ich i18next, eine Web-I18n-Bibliothek, verwendet, die ich unverändert nutzen konnte.

Die Bearbeitung und Übersetzung der In-Game-Texte erfolgte in der Tabelle von Google Docs. Da i18next JSON-Dateien benötigt, habe ich die Tabellen in TSV exportiert und dann mit einem benutzerdefinierten Converter konvertiert. Ich habe kurz vor der Veröffentlichung zahlreiche Aktualisierungen vorgenommen, sodass die Automatisierung des Exportvorgangs aus der Tabelle in Google Text & Tabellen die Dinge viel einfacher hätte.

Auch die automatische Übersetzungsfunktion von Chrome funktioniert normal, da die Seiten in HTML erstellt werden. Manchmal erkennt das Tool die Sprache jedoch nicht richtig. Stattdessen wird sie mit einer ganz anderen Sprache verwechselt (z. B. Vietnamesisch), sodass diese Funktion derzeit deaktiviert ist. Diese Funktion kann mithilfe von Meta-Tags deaktiviert werden.

RequireJS

Ich habe RequireJS als JavaScript-Modulsystem ausgewählt. Die 10.000 Zeilen des Spiels sind in etwa 60 Klassen (Coffee-Dateien) aufgeteilt und in einzelne JS-Dateien kompiliert. RequireJS lädt diese einzelnen Dateien in der richtigen Reihenfolge, je nach Abhängigkeit.

define ->
  class Hoge
    hogeMethod: ->

Die oben definierte Klasse (hoge.coffee) kann wie folgt verwendet werden:

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

Zum Funktionieren muss hoge.js vor moge.js geladen werden. Da „hoge“ als erstes Argument von „define“ festgelegt ist, wird hoge.js immer zuerst geladen (wird nach dem Laden von hoge.js zurückgerufen). Dieser Mechanismus wird als AMD bezeichnet. Jede Bibliothek von Drittanbietern kann für dieselbe Art von Callback verwendet werden, solange sie AMD unterstützt. Selbst Websites, bei denen das nicht der Fall ist (z.B. third.js), erzielen eine ähnliche Leistung, solange Leistungsabhängigkeiten im Voraus festgelegt werden.

Das funktioniert ähnlich wie das Importieren von AS3, daher sollte es nicht allzu seltsam erscheinen. Wenn am Ende weitere abhängige Dateien vorliegen, ist dies eine mögliche Lösung.

r.js

Erforderlich ist ein Optimierer namens r.js. Dadurch wird die JS-Hauptdatei mit allen abhängigen JS-Dateien in einer Datei gebündelt und dann mit UglifyJS (oder Closure Compiler) komprimiert. Dadurch reduziert sich die Anzahl der Dateien und die Gesamtdatenmenge, die der Browser laden muss. Die Gesamtgröße der JavaScript-Datei für World Wide Maze beträgt rund 2 MB und kann mit der r.js-Optimierung auf etwa 1 MB reduziert werden. Wenn das Spiel mit gzip vertrieben werden könnte, würde die Größe weiter auf 250 KB reduziert. (Bei GAE besteht ein Problem, bei dem Gzip-Dateien mit einer Größe von mindestens 1 MB nicht übertragen werden können. Das Spiel wird daher derzeit unkomprimiert als 1 MB Klartext bereitgestellt.)

Bühnenbauer

Phasendaten werden wie folgt generiert und vollständig auf dem GCE-Server in den USA ausgeführt:

  1. Die URL der Website, die in eine Phase umgewandelt werden soll, wird über WebSocket gesendet.
  2. PhantomJS erstellt einen Screenshot. Die Positionen von div- und img-Tags werden abgerufen und im JSON-Format ausgegeben.
  3. Basierend auf dem Screenshot aus Schritt 2 und Positionsdaten von HTML-Elementen löscht ein benutzerdefiniertes C++-Programm (OpenCV, Boost) unnötige Bereiche, erzeugt Inseln, verbindet die Inseln mit Brücken, berechnet die Position von Leitplanken und Elementen, legt den Zielpunkt usw. fest. Die Ergebnisse werden im JSON-Format ausgegeben und an den Browser zurückgegeben.

PhantomJS

PhantomJS ist ein Browser, der keinen Bildschirm benötigt. Damit lassen sich Webseiten laden, ohne Fenster zu öffnen. Daher kann es für automatisierte Tests oder zum Erstellen von Screenshots auf Serverseite verwendet werden. Das Browsermodul ist WebKit, das auch von Chrome und Safari verwendet wird. Daher entsprechen das Layout und die JavaScript-Ausführungsergebnisse mehr oder weniger denen von Standardbrowsern.

Bei PhantomJS wird JavaScript oder CoffeeScript verwendet, um die Prozesse zu schreiben, die ausgeführt werden sollen. Das Erstellen von Screenshots ist ganz einfach, wie in diesem Beispiel gezeigt. Da ich an einem Linux-Server (CentOS) gearbeitet habe, musste ich Schriftarten zur Anzeige von Japanisch installieren (M+ Schriftarten). Selbst dann wird die Schriftart-Rendering anders gehandhabt als unter Windows oder Mac OS, sodass dieselbe Schriftart auf anderen Computern anders aussehen kann (der Unterschied ist jedoch minimal).

Das Abrufen der Positionen von img und div-Tags erfolgt im Grunde auf dieselbe Weise wie auf Standardseiten. jQuery kann ebenfalls problemlos verwendet werden.

stage_builder

Zunächst habe ich über einen eher DOM-basierten Ansatz zum Generieren von Phasen nachgedacht (ähnlich wie der Firefox 3D Inspector) und versuchte beispielsweise, eine DOM-Analyse in PhantomJS durchzuführen. Letztendlich habe ich mich aber auf einen Ansatz zur Bildverarbeitung entschieden. Zu diesem Zweck habe ich ein C++-Programm mit dem Namen "stage_builder" entwickelt, das OpenCV und Boost verwendet. Er führt Folgendes aus:

  1. Lädt den Screenshot und die JSON-Datei(en).
  2. Wandelt Bilder und Text in Inseln um
  3. Erzeugt Brücken, um die Inseln zu verbinden.
  4. Beseitigt unnötige Brücken, um ein Labyrinth zu bilden.
  5. Platziert große Elemente
  6. Platziert kleine Gegenstände.
  7. Orte mit Leitplanken.
  8. Gibt Positionsdaten im JSON-Format aus.

Die einzelnen Schritte werden im Folgenden detailliert beschrieben.

Screenshot und JSON-Datei(en) laden

Die übliche cv::imread wird zum Laden von Screenshots verwendet. Ich habe mehrere Bibliotheken für die JSON-Dateien getestet, aber picojson erschien mir am einfachsten.

Bilder und Text in Inseln umwandeln

Phasenerstellung

Unten sehen Sie einen Screenshot des Bereichs „News“ von aid-dcc.com. (Klicken Sie hier, um die tatsächliche Größe zu sehen.) Die Bilder und Textelemente müssen in Inseln konvertiert werden. Um diese Abschnitte zu isolieren, sollten wir die weiße Hintergrundfarbe löschen – mit anderen Worten die vorherrschende Farbe im Screenshot. Anschließend sieht das so aus:

Phasenerstellung

Die weißen Abschnitte sind die potenziellen Inseln.

Da der Text zu fein und scharf ist, wird er mit cv::dilate, cv::GaussianBlur und cv::threshold dicker. Da auch Bildinhalte fehlen, füllen wir diese Bereiche mit Weiß auf Grundlage der Datenausgabe des img-Tags von PhantomJS aus. Das resultierende Bild sieht so aus:

Phasenerstellung

Der Text bildet nun geeignete Klumpen und jedes Bild stellt eine richtige Insel dar.

Bau von Brücken zwischen den Inseln

Sobald die Inseln bereit sind, sind sie mit Brücken verbunden. Jede Insel sucht nach benachbarten Inseln links, rechts, oben und unten und verbindet dann eine Brücke mit dem nächstgelegenen Punkt der nächstgelegenen Insel, was ungefähr so aussieht:

Phasenerstellung

Beseitigung unnötiger Brücken, um ein Labyrinth zu bilden

Die Beibehaltung aller Brücken würde die Navigation auf der Bühne zu einfach machen, sodass einige beseitigt werden müssen, um ein Labyrinth zu bilden. Eine Insel (z. B. die links oben) wird als Ausgangspunkt ausgewählt. Bis auf eine Brücke, die mit dieser Insel verbunden ist, werden alle zufällig ausgewählten Brücke gelöscht. Dasselbe passiert für die nächste Insel, die durch die verbleibende Brücke verbunden ist. Sobald der Pfad eine Sackgasse erreicht oder zu einer zuvor besuchten Insel zurückführt, kehrt er zu einem Punkt zurück, der Zugang zu einer neuen Insel ermöglicht. Das Labyrinth ist voll, wenn alle Inseln auf diese Weise verarbeitet wurden.

Phasenerstellung

Große Elemente platzieren

Auf jeder Insel werden abhängig von ihren Abmessungen ein oder mehrere große Elemente platziert, wobei die Punkte ausgewählt werden, die am weitesten von den Rändern der Inseln entfernt sind. Diese Punkte sind zwar nicht ganz klar, unten sind sie jedoch rot dargestellt:

Phasenerstellung

Von all diesen möglichen Punkten wird der Punkt oben links als Startpunkt (roter Kreis) und der Punkt unten rechts als Ziel (grüner Kreis) festgelegt. Für die Platzierung großer Elemente werden maximal sechs Punkte ausgewählt (lila Kreis).

Kleine Gegenstände platzieren

Phasenerstellung

Eine geeignete Anzahl kleiner Gegenstände wird in bestimmten Abständen von den Inselrändern entlang von Linien platziert. Im Bild oben (nicht von aid-dcc.com) sind die projizierten Placement-Linien grau, versetzt und in regelmäßigen Abständen vom Rand der Insel entfernt platziert. Die roten Punkte geben an, wo die kleinen Elemente platziert werden. Da dieses Bild aus der Entwicklungsversion stammt, sind die Elemente in geraden Linien angeordnet, aber in der endgültigen Version sind die Elemente etwas unregelmäßiger auf beiden Seiten der grauen Linien verteilt.

Leitplanken anbringen

Die Leitplanken sind im Prinzip entlang der Außengrenzen der Inseln platziert, müssen aber bei Brücken abgeschnitten werden, um den Zugang zu ermöglichen. Dafür bietet sich die Geometry-Bibliothek von Boost an. Sie vereinfachte geometrische Berechnungen wie die Ermittlung, wo sich Daten zu Inselgrenzen mit den Linien auf beiden Seiten einer Brücke überschneiden.

Phasenerstellung

Die grünen Linien um die Inseln sind die Leitplanken. Es ist auf diesem Bild möglicherweise schwierig zu erkennen, aber es gibt keine grünen Linien an den Stellen, an denen sich die Brücken befinden. Dies ist das endgültige Bild für die Fehlerbehebung. Es enthält alle Objekte, die in JSON ausgegeben werden müssen. Die hellblauen Punkte sind kleine Elemente und die grauen Punkte sind Vorschläge für einen Neustart. Wenn der Ball ins Meer stürzt, wird das Spiel ab dem nächsten Startpunkt fortgesetzt. Neustartpunkte werden mehr oder weniger wie kleine Elemente in regelmäßigen Abständen in einem festgelegten Abstand vom Rand der Insel angeordnet.

Positionierungsdaten im JSON-Format ausgeben

Ich habe auch picojson für die Ausgabe verwendet. Die Daten werden in die Standardausgabe geschrieben, die dann vom Aufrufer (Node.js) empfangen wird.

Ein C++-Programm auf einem Mac erstellen, das unter Linux ausgeführt werden soll

Das Spiel wurde auf einem Mac entwickelt und unter Linux bereitgestellt, aber da OpenCV und Boost für beide Betriebssysteme vorhanden waren, war die Entwicklung selbst nicht schwierig, nachdem die Kompilierungsumgebung eingerichtet wurde. Ich habe Befehlszeilentools in Xcode verwendet, um Fehler im Build auf dem Mac zu beheben. Dann erstellte ich eine Konfigurationsdatei mit automake/autoconf, damit der Build unter Linux kompiliert werden konnte. Dann musste ich unter Linux einfach "configure && make" verwenden, um die ausführbare Datei zu erstellen. Es sind einige Linux-spezifische Fehler aufgrund von unterschiedlichen Compiler-Versionen aufgetreten, aber ich konnte diese mithilfe von gdb relativ einfach beheben.

Fazit

Ein solches Spiel könnte mit Flash oder Unity erstellt werden, was viele Vorteile bietet. Diese Version erfordert jedoch keine Plug-ins und die Layoutfunktionen von HTML5 und CSS3 haben sich als äußerst leistungsstark erwiesen. Es ist definitiv wichtig, für jede Aufgabe die richtigen Tools zur Hand zu haben. Ich war persönlich überrascht darüber, wie gut das Spiel für ein vollständig in HTML5 entwickeltes Spiel funktioniert hat, und obwohl es in vielen Bereichen immer noch fehlt, bin ich gespannt, wie es sich in Zukunft entwickeln wird.