Étude de cas : Inside World Wide Maze

World Wide Maze est un jeu dans lequel vous utilisez votre smartphone pour faire rouler une balle dans des labyrinthes 3D créés à partir de sites Web, dans le but d'atteindre leurs objectifs.

World Wide Maze

Le jeu utilise de nombreuses fonctionnalités HTML5. Par exemple, l'événement DeviceOrientation récupère les données d'inclinaison du smartphone, qui sont ensuite envoyées à l'ordinateur via WebSocket, où les joueurs accèdent aux espaces 3D créés par WebGL et Web Workers.

Cet article explique précisément comment ces fonctionnalités sont utilisées, le processus global de développement et les points clés de l'optimisation.

DeviceOrientation

L'événement DeviceOrientation (exemple) permet de récupérer les données d'inclinaison du smartphone. Lorsque addEventListener est utilisé avec l'événement DeviceOrientation, un rappel avec l'objet DeviceOrientationEvent est invoqué en tant qu'argument à intervalles réguliers. Les intervalles eux-mêmes varient en fonction de l'appareil utilisé. Par exemple, dans iOS + Chrome et iOS + Safari, le rappel est invoqué tous les 1/20e de seconde, tandis que dans Android 4 et Chrome, il est invoqué tous les 1/10e de seconde.

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

L'objet DeviceOrientationEvent contient des données d'inclinaison pour chacun des axes X, Y et Z en degrés (et non en radians). En savoir plus sur HTML5Rocks Cependant, les valeurs renvoyées varient également en fonction de l'appareil utilisé et du navigateur utilisés. Les plages des valeurs renvoyées réelles sont décrites dans le tableau ci-dessous:

Orientation de l'appareil.

Les valeurs indiquées en haut et en bleu correspondent à celles définies dans les spécifications W3C. Celles indiquées en vert correspondent à ces spécifications, tandis que celles affichées en rouge s'écartent. Étonnamment, seule la combinaison Android-Firefox a renvoyé des valeurs correspondant aux spécifications. Néanmoins, lorsqu'il s'agit d'implémentation, il est plus logique de s'adapter aux valeurs qui reviennent fréquemment. World Wide Maze utilise donc les valeurs de retour iOS de manière standard et s'adapte aux appareils Android en conséquence.

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

Cependant, il n'est toujours pas compatible avec la Nexus 10. Bien que la Nexus 10 renvoie la même plage de valeurs que les autres appareils Android, il existe un bug qui inverse les valeurs bêta et gamma. Ce problème est traité séparément. Il s'agit peut-être de l'orientation paysage par défaut.

Comme le montre ce constat, même si les API impliquant des appareils physiques ont des spécifications définies, rien ne garantit que les valeurs renvoyées correspondront à ces spécifications. Il est donc essentiel de les tester sur tous les appareils potentiels. Cela signifie également que des valeurs inattendues peuvent être saisies, ce qui nécessite la création de solutions de contournement. World Wide Maze invite les nouveaux joueurs à calibrer leur appareil lors de la première étape du tutoriel, mais il ne se calibrera pas correctement en position zéro s'il reçoit des valeurs d'inclinaison inattendues. Par conséquent, elle est associée à une limite de temps interne et invite le joueur à passer aux commandes au clavier s'il ne peut pas effectuer le calibrage dans le délai imparti.

WebSocket

Dans World Wide Maze, votre smartphone et votre PC sont connectés via WebSocket. Plus précisément, ils sont connectés via un serveur relais entre eux, c'est-à-dire un smartphone à un serveur vers un PC. En effet, WebSocket ne peut pas se connecter directement les uns aux autres. (L'utilisation des canaux de données WebRTC permet une connectivité peer-to-peer et élimine le recours à un serveur relais. Toutefois, au moment de la mise en œuvre, cette méthode ne pouvait être utilisée qu'avec Chrome Canary et Firefox Nightly.)

J'ai choisi l'implémentation à l'aide d'une bibliothèque appelée Socket.IO (v0.9.11), qui inclut des fonctionnalités de reconnexion en cas de délai d'expiration ou de déconnexion de la connexion. Je l'ai utilisé en association avec NodeJS, car cette combinaison NodeJS + Socket.IO a montré les meilleures performances côté serveur dans plusieurs tests de mise en œuvre de WebSocket.

Association par numéro

  1. Votre PC se connecte au serveur.
  2. Le serveur fournit à votre PC un nombre généré de manière aléatoire, et se souvient de la combinaison entre le nombre et le PC.
  3. Sur votre appareil mobile, indiquez un numéro et connectez-vous au serveur.
  4. Si le numéro indiqué est identique à celui d'un PC connecté, votre appareil mobile est associé à cet PC.
  5. Si aucun PC n'est désigné, une erreur s'affiche.
  6. Lorsque les données arrivent de votre appareil mobile, elles sont envoyées au PC auquel il est associé, et inversement.

Vous pouvez également établir la connexion initiale depuis votre appareil mobile. Dans ce cas, les appareils sont simplement inversés.

Synchronisation des onglets

La fonctionnalité de synchronisation des onglets spécifique à Chrome facilite le processus d'association. Il permet d'ouvrir facilement les pages ouvertes sur un ordinateur sur un appareil mobile (et inversement). Le PC prend le numéro de connexion émis par le serveur et l'ajoute à l'URL d'une page à l'aide de history.replaceState.

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

Si la synchronisation des onglets est activée, l'URL est synchronisée au bout de quelques secondes et la même page peut être ouverte sur l'appareil mobile. L'appareil mobile vérifie l'URL de la page ouverte, et si un numéro est ajouté, la connexion commence immédiatement. Ainsi, vous n'avez plus besoin de saisir des chiffres manuellement ni de scanner des codes QR avec un appareil photo.

Latence

Étant donné que le serveur de relais se trouve aux États-Unis, l'accès depuis le Japon entraîne un délai d'environ 200 ms avant que les données d'inclinaison du smartphone n'atteignent l'ordinateur. Les temps de réponse étaient clairement lents par rapport à ceux de l'environnement local utilisé pendant le développement, mais l'insertion d'un filtre passe-bas (à l'aide du format EMA, par exemple) a permis de rendre les niveaux plus discrets. (Dans la pratique, un filtre passe-bas était également nécessaire pour la présentation. Les valeurs renvoyées par le capteur d'inclinaison incluaient un bruit considérable, et l'application de ces valeurs à l'écran provoquait de nombreuses secousses.) L'opération n'a pas fonctionné avec les sauts, qui étaient clairement lents, mais aucune action n'a pu être effectuée pour résoudre ce problème.

Comme je m'attendais à des problèmes de latence dès le départ, j'ai envisagé de mettre en place des serveurs relais dans le monde entier afin que les clients puissent se connecter au plus proche disponible (et ainsi minimiser la latence). Cependant, j'ai fini par utiliser Google Compute Engine (GCE), qui n'existait qu'aux États-Unis à l'époque, et ce n'était donc pas possible.

Problème d'algorithme de Nagle

L'algorithme Nagle est généralement intégré aux systèmes d'exploitation pour une communication efficace grâce à la mise en mémoire tampon au niveau du TCP, mais je me suis rendu compte que je ne pouvais pas envoyer de données en temps réel lorsque cet algorithme était activé. (Plus précisément en cas de combinaison avec accusé de réception retardé TCP. Même sans ACK retardé, le même problème se produit si ACK est retardé dans une certaine mesure en raison de facteurs tels que la localisation du serveur à l'étranger.)

Le problème de latence de Nagle ne s'est pas produit avec WebSocket dans Chrome pour Android, qui inclut l'option TCP_NODELAY pour désactiver Nagle. Il se trouvait plutôt avec WebKit WebSocket utilisé dans Chrome pour iOS, pour lequel cette option n'était pas activée. (Safari, qui utilise le même WebKit, rencontrait également ce problème. Le problème a été signalé à Apple via Google et semble avoir été résolu dans la version de développement de WebKit.

Lorsque ce problème se produit, les données d'inclinaison envoyées toutes les 100 ms sont combinées en fragments qui n'atteignent que le PC toutes les 500 ms. Le jeu ne peut pas fonctionner dans ces conditions. Il évite donc cette latence en demandant au serveur d'envoyer des données à de courts intervalles (environ toutes les 50 ms). Je crois que le fait de recevoir ACK à de courts intervalles trompe l'algorithme Nagle en pensant qu'il est acceptable d'envoyer des données.

Algorithme Nagle 1

Les graphiques ci-dessus représentent les intervalles des données reçues. Il indique les intervalles de temps entre les paquets. Le vert représente les intervalles de sortie et le rouge les intervalles d'entrée. La valeur minimale est de 54 ms, la valeur maximale est de 158 ms et la valeur intermédiaire est proche de 100 ms. Ici, j'ai utilisé un iPhone avec un serveur de relais situé au Japon. La sortie et l'entrée durent environ 100 ms, et le fonctionnement est fluide.

Algorithme Nagle 2

En revanche, ce graphique montre les résultats liés à l'utilisation du serveur aux États-Unis. Alors que les intervalles de sortie en vert restent stables à 100 ms, les intervalles d'entrée fluctuent entre 0 ms minimum et 500 ms au maximum, ce qui indique que le PC reçoit les données par fragments.

ALT_TEXT_HERE

Enfin, ce graphique montre le résultat obtenu en demandant au serveur d'envoyer des données d'espace réservé afin d'éviter la latence. Bien que les performances ne soient pas aussi bonnes que celles du serveur japonais, il est clair que les intervalles d'entrée restent relativement stables, environ 100 ms.

Un bug ?

Bien que le navigateur par défaut d'Android 4 (ICS) dispose d'une API WebSocket, il ne peut pas se connecter, ce qui entraîne un événement connect_failed de Socket.IO. En interne, le délai expire, et le côté serveur ne peut pas non plus vérifier la connexion. (Je n'ai pas testé cela avec WebSocket seul, il pourrait donc s'agir d'un problème Socket.IO.)

Scaling des serveurs de relais

Le rôle du serveur de relais n'étant pas si compliqué, la mise à l'échelle du nombre de serveurs ne devrait pas être difficile tant que vous vous assurez que le même PC et l'appareil mobile sont toujours connectés au même serveur.

Physique

Les mouvements de balle dans le jeu (descente en descente, en collision avec le sol, avec des murs, pour collecter des objets, etc.) sont effectués dans un simulateur physique 3D. J'ai utilisé Ammo.js (un port du moteur physique largement utilisé Bullet vers JavaScript avec Emscripten) ainsi que Physijs pour l'utiliser comme un "travailleur du Web".

Web Workers

Web Workers est une API permettant d'exécuter JavaScript dans des threads distincts. Le code JavaScript lancé en tant que Web Worker s'exécute en tant que thread distinct de celui qui l'a appelé à l'origine. Il est donc possible d'effectuer des tâches lourdes tout en préservant la réactivité de la page. Physijs utilise efficacement Web Workers pour assurer le bon fonctionnement du moteur physique 3D habituellement intensif. World Wide Maze gère le moteur physique et le rendu des images WebGL à des fréquences d'images complètement différentes. Ainsi, même si la fréquence d'images chute sur une machine de faible caractéristiques en raison de la charge de rendu WebGL importante, le moteur physique maintient plus ou moins la fréquence d'images à 60 FPS sans gêner les commandes du jeu.

Lecteur d'empreinte digitale

Cette image montre la fréquence d'images obtenue sur un Lenovo G570. La zone du haut indique la fréquence d'images pour WebGL (rendu des images), et la zone inférieure indique la fréquence d'images du moteur physique. Le GPU est une puce Intel HD Graphics 3000 intégrée. Par conséquent, la fréquence d'images du rendu d'image n'a pas atteint la fréquence attendue de 60 FPS. Cependant, comme le moteur physique a atteint la fréquence d'images attendue, le gameplay n'est pas très différent des performances d'un ordinateur de pointe.

Étant donné que les threads avec des workers Web actifs ne disposent pas d'objets de console, les données doivent être envoyées au thread principal via postMessage pour générer des journaux de débogage. L'utilisation de console4Worker crée l'équivalent d'un objet de console dans le worker, ce qui facilite considérablement le processus de débogage.

Service workers

Les versions récentes de Chrome vous permettent de définir des points d'arrêt lors du lancement de Web Workers, ce qui est également utile pour le débogage. Vous le trouverez dans le panneau "Nœuds de calcul" des outils pour les développeurs.

Performances

Les étapes comportant un nombre élevé de polygones dépassent parfois les 100 000 polygones,mais les performances n'ont pas particulièrement souffert, même lorsqu'elles étaient entièrement générées en tant que Physijs.ConcaveMesh (btBvhTriangleMeshShape dans Bullet).

Au début, la fréquence d'images a chuté à mesure que le nombre d'objets nécessitant la détection de collision augmentait, mais l'élimination des traitements inutiles dans Physijs a permis d'améliorer les performances. Cette amélioration a été apportée à une copie des Physijs d'origine.

Objets fantômes

Les objets qui détectent les collisions, mais qui n'ont aucun impact en cas de collision et qui, par conséquent, n'ont aucun effet sur les autres objets sont appelés "objets fantômes" dans Bullet. Bien que Physijs ne soit pas officiellement compatible avec les objets fantômes, il est possible de les créer en modifiant les options après avoir généré un Physijs.Mesh. World Wide Maze utilise des objets fantômes pour détecter les collisions d'objets et de points d'objectif.

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

Pour collision_flags, 1 correspond à CF_STATIC_OBJECT et 4 à CF_NO_CONTACT_RESPONSE. Pour en savoir plus, consultez le forum, Stack Overflow ou la documentation Bullet. Étant donné que Physijs est un wrapper pour Ammo.js et pour Ammo.js, la plupart des opérations pouvant être effectuées dans Bullet peuvent être effectuées dans Physijs.

Le problème de Firefox 18

La mise à jour de Firefox de la version 17 à la version 18 a modifié la façon dont les travailleurs du Web échangeaient des données, et Physijs a cessé de fonctionner en conséquence. Le problème a été signalé sur GitHub et résolu en quelques jours. Bien que cette efficacité Open Source m'ait impressionnée, l'incident m'a également rappelé que World Wide Maze se compose de plusieurs frameworks Open Source. J'ai rédigé cet article dans l'espoir de vous faire part de vos commentaires.

asm.js

Bien que cela ne concerne pas directement World Wide Maze, Ammo.js est déjà compatible avec la fonction asm.js récemment annoncée par Mozilla. Cela n'a rien d'étonnant, car asm.js a été créé pour accélérer le JavaScript généré par Emscripten, et le créateur d'Emmscripten est également le créateur d'Ammo.js. Si Chrome est également compatible avec asm.js, la charge de calcul du moteur physique devrait considérablement diminuer. La vitesse a été nettement plus rapide lors des tests effectués avec Firefox Nightly. Il serait peut-être préférable d'écrire des sections plus rapides en C/C++, puis de les transférer vers JavaScript à l'aide d'Emscripten.

WebGL

Pour l'implémentation WebGL, j'ai utilisé la bibliothèque la plus développée : three.js (r53). Bien que la révision 57 ait déjà été publiée par les dernières étapes du développement, d'importantes modifications avaient été apportées à l'API. Je suis donc resté(e) à la révision d'origine pour la publication.

Effet de halo

L'effet de halo ajouté au cœur de la balle et aux éléments est mis en œuvre à l'aide d'une version simple de la méthode Kawase Method MGF. Toutefois, alors que la méthode Kawase fait éclore toutes les zones lumineuses, World Wide Maze crée des cibles de rendu distinctes pour les zones qui doivent briller. Cela est dû au fait qu'une capture d'écran du site Web doit être utilisée pour les textures de scène et qu'en extrayant simplement toutes les zones lumineuses, l'ensemble du site Web serait mis en valeur si, par exemple, son arrière-plan est blanc. J'ai également envisagé de tout traiter en HDR, mais j'ai décidé de ne pas le faire cette fois, car l'implémentation aurait été assez compliquée.

Halo

L'angle supérieur gauche montre le premier passage, au cours duquel les zones de halo ont été affichées séparément, puis un floutage appliqué. L'image en bas à droite montre la deuxième étape, au cours de laquelle la taille de l'image a été réduite de 50 %, puis un flou a été appliqué. L'angle supérieur droit montre le troisième passage, au cours duquel l'image a de nouveau été réduite de 50 %, puis floutée. Les trois ont ensuite été superposés pour créer l'image composite finale affichée en bas à gauche. Pour le floutage, j'ai utilisé VerticalBlurShader et HorizontalBlurShader, inclus dans three.js, ce qui peut encore être optimisé.

Ballon réfléchissant

La réflexion sur la balle est basée sur un échantillon de three.js. Toutes les directions sont affichées à partir de la position de la balle et utilisées comme cartes de l'environnement. Les cartes de l'environnement doivent être mises à jour chaque fois que la balle bouge, mais comme une mise à jour à 60 FPS est intensive, elles le sont toutes les trois images. Le résultat n'est pas aussi fluide que la mise à jour de chaque frame, mais la différence est pratiquement imperceptible, sauf indication contraire.

Nuanceur, nuanceur...

WebGL nécessite des nuanceurs (nuanceurs de sommets, nuanceurs de fragments) pour tout le rendu. Bien que les nuanceurs inclus dans three.js autorisent déjà un large éventail d'effets, il est inévitable d'écrire les vôtres pour obtenir des ombres et des optimisations plus élaborées. Étant donné que World Wide Maze occupe le processeur avec son moteur physique, j'ai essayé d'utiliser le GPU à la place en écrivant autant que possible en langage de nuance (GLSL), même lorsque le traitement du processeur (via JavaScript) aurait été plus facile. Les effets des vagues s'appuient naturellement sur les nuanceurs, tout comme les feux d'artifice au niveau des objectifs et l'effet de maillage utilisé lorsque la balle apparaît.

Balles de nuanceur

Ce qui précède provient de tests sur l'effet de maillage utilisé lorsque la balle apparaît. Celui de gauche est celui utilisé dans le jeu, composé de 320 polygones. Celle du centre utilise environ 5 000 polygones, et celle de droite en compte environ 300 000. Même avec autant de polygones, le traitement avec des nuanceurs peut maintenir une fréquence d'images stable de 30 FPS.

Maillage du nuanceur

Les petits éléments disséminés à travers l'étape sont tous intégrés dans un seul maillage, et les mouvements individuels dépendent des nuanceurs qui déplacent chacune des extrémités du polygone. Ceci est le résultat d'un test visant à déterminer si les performances affecteront les performances en cas de présence d'un grand nombre d'objets. Environ 5 000 objets y sont affichés, composés d'environ 20 000 polygones. Les performances n'ont pas du tout souffert.

poly2tri

Les étapes sont formées à partir des informations de contour reçues du serveur, puis polygonées par JavaScript. La triangulation, un élément clé de ce processus, est mal implémentée par three.js et échoue généralement. J'ai donc décidé d'intégrer moi-même une autre bibliothèque de triangulation appelée poly2tri. Il s'est avéré que trois.js avaient de toute évidence essayé la même chose par le passé. Je l'ai donc fait fonctionner simplement en y ajoutant des commentaires. Résultat : le nombre d'erreurs a considérablement diminué, ce qui a permis de multiplier les niveaux de lecture. L'erreur occasionnelle persiste et, pour une raison quelconque, poly2tri gère les erreurs en émettant des alertes. J'ai donc modifié cette règle pour générer des exceptions à la place.

poly2tri

Ce qui précède montre comment le contour bleu est triangulé et les polygones rouges sont générés.

Filtrage anisotrope

Étant donné que le mappage MIP isotrope standard réduit la taille des images sur les axes horizontal et vertical, la visualisation des polygones à partir d'angles obliques permet aux textures situées au bout des étapes de World Wide Maze de ressembler à des textures allongées horizontalement et à basse résolution. L'image en haut à droite de cette page Wikipédia en montre un bon exemple. En pratique, une résolution horizontale plus importante est requise. WebGL (OpenGL) résout le problème à l'aide d'une méthode appelée filtrage anisotrope. Dans three.js, la définition d'une valeur supérieure à 1 pour THREE.Texture.anisotropy active le filtrage anisotrope. Toutefois, il s'agit d'une extension qui peut ne pas être compatible avec tous les GPU.

Optimisation

Comme le mentionne également cet article des bonnes pratiques WebGL, le moyen le plus crucial d'améliorer les performances WebGL (OpenGL) consiste à minimiser les appels de dessin. Lors du développement initial de World Wide Maze, toutes les îles, tous les ponts et tous les garde-fous du jeu étaient des objets distincts. Il en résulte parfois plus de 2 000 appels de dessin, ce qui rend les étapes complexes pénibles. Toutefois, une fois que j'ai regroupé les mêmes types d'objets dans un seul maillage, les appels de dessin sont tombés à environ 50 %, ce qui a considérablement amélioré les performances.

J'ai utilisé la fonctionnalité de traçage de Chrome pour une optimisation plus poussée. Les profileurs inclus dans les outils pour les développeurs Chrome peuvent déterminer dans une certaine mesure les temps de traitement globaux des méthodes, mais le traçage peut vous indiquer précisément la durée de chaque partie, jusqu'à 1/1 000e de seconde. Consultez cet article pour en savoir plus sur l'utilisation du traçage.

Optimisation

Les résultats de trace ci-dessus proviennent de la création de cartes d'environnement pour le reflet de la balle. En insérant console.time et console.timeEnd dans des emplacements apparemment pertinents dans three.js, nous obtenons un graphique qui ressemble à ceci. Le temps s'écoule de gauche à droite, et chaque couche s'apparente à une pile d'appels. L'imbrication d'un élément console.time dans un console.time permet d'effectuer d'autres mesures. Le graphique du haut correspond à la pré-optimisation et le bas à la post-optimisation. Comme le montre le graphique du haut, updateMatrix (le mot est tronqué) a été appelé pour chacun des rendus 0 à 5 lors de la pré-optimisation. Cependant, je l'ai modifié pour qu'il ne soit appelé qu'une seule fois, car ce processus n'est requis que lorsque les objets changent de position ou d'orientation.

Le processus de traçage lui-même utilise naturellement des ressources. Par conséquent, une insertion excessive de console.time peut entraîner un écart important par rapport aux performances réelles, ce qui complique l'identification des axes d'optimisation.

Outil d'ajustement des performances

En raison de la nature d'Internet, il est probable que ce jeu soit joué sur des systèmes dont les spécifications sont très variables. Find Your Way to Oz, publié début février, utilise une classe appelée IFLAutomaticPerformanceAdjust pour réduire les effets en fonction des fluctuations de la fréquence d'images, ce qui permet d'assurer une lecture fluide. World Wide Maze s'appuie sur la même classe IFLAutomaticPerformanceAdjust et réduit les effets comme suit pour rendre le jeu aussi fluide que possible:

  1. Si la fréquence d'images est inférieure à 45 FPS, les plans de l'environnement ne sont plus mis à jour.
  2. S'il reste sous 40 FPS, la résolution du rendu est réduite à 70% (50% du ratio de la surface).
  3. S'il est toujours inférieur à 40 FPS, l'anticrénelage FXAA est éliminé.
  4. S'il reste en dessous de 30 FPS, les effets de halo sont supprimés.

Fuite de mémoire

L'élimination soignée des objets est un vrai casse-tête avec three.js. Mais les laisser tranquilles entraînerait évidemment des fuites de mémoire. J'ai donc conçu la méthode ci-dessous. @renderer fait référence à THREE.WebGLRenderer. La dernière révision de three.js utilise une méthode de désallocation légèrement différente. Cette méthode ne fonctionnera probablement pas telle quelle.

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

Personnellement, je pense que le meilleur atout de l'application WebGL est de pouvoir concevoir une mise en page en HTML. Créer des interfaces 2D (affichage de scores ou de texte, par exemple) dans Flash ou OpenFrameworks (OpenGL) n'est pas une mince affaire. Flash dispose au moins d'un IDE, mais OpenFrameworks est difficile à utiliser si vous n'y êtes pas habitué (l'utilisation d'un outil comme Cocos2D peut vous faciliter la tâche). Le langage HTML, en revanche, permet de contrôler avec précision tous les aspects de la conception de l'interface avec CSS, comme lors de la création de sites Web. Bien que les effets complexes, tels que les particules qui se condensent dans un logo, soient impossibles, certains effets 3D compatibles avec les transformations CSS sont possibles. Les effets de texte "GOAL" et "TIME IS UP" de World Wide Maze sont animés à l'aide de l'échelle dans la transition CSS (implémentée avec Transports en commun). (Évidemment, les gradations d'arrière-plan utilisent WebGL.)

Chaque page du jeu (titre, RÉSULTAT, CLASSEMENT, etc.) possède son propre fichier HTML. Une fois ceux-ci chargés en tant que modèles, $(document.body).append() est appelé avec les valeurs appropriées au moment approprié. Un problème est survenu, car les événements de souris et de clavier n'ont pas pu être définis avant l'ajout. Essayer el.click (e) -> console.log(e) avant l'ajout n'a donc pas fonctionné.

Internationalisation (i18n)

Le langage HTML était également pratique pour créer la version en anglais. J'ai choisi d'utiliser i18next, une bibliothèque Web i18n, pour mes besoins d'internationalisation. J'ai pu l'utiliser telle quelle, sans y avoir été modifiée.

La modification et la traduction du texte du jeu ont été effectuées dans la feuille de calcul Google Docs. Étant donné qu'i18next nécessite des fichiers JSON, j'ai exporté les feuilles de calcul au format TSV, puis les avons converties avec un convertisseur personnalisé. J'ai effectué de nombreuses mises à jour juste avant la sortie. Il m'aurait donc été beaucoup plus facile d'automatiser le processus d'exportation à partir de la feuille de calcul Google Docs.

La fonctionnalité de traduction automatique de Chrome fonctionne également normalement, car les pages sont conçues en HTML. Cependant, il arrive que cette fonctionnalité ne détecte pas correctement la langue, au lieu de la confondre avec une autre totalement différente (par exemple, vietnamien). Cette fonctionnalité est donc actuellement désactivée. (Vous pouvez la désactiver à l'aide de balises Meta.)

RequireJS

J'ai choisi RequireJS comme système de module JavaScript. Les 10 000 lignes de code source du jeu sont divisées en environ 60 classes (= fichiers Coffee) et compilées en fichiers js individuels. ExigerJS charge ces fichiers individuels dans l'ordre approprié en fonction de la dépendance.

define ->
  class Hoge
    hogeMethod: ->

La classe définie ci-dessus (hoge.coffee) peut être utilisée comme suit:

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

Pour fonctionner, hoge.js doit être chargé avant moge.js, et comme "hoge" est désigné comme premier argument de "define", hoge.js est toujours chargé en premier (rappelé une fois le chargement de hoge.js terminé). Ce mécanisme est appelé AMD, et n'importe quelle bibliothèque tierce peut être utilisée pour le même type de rappel, à condition qu'elle soit compatible avec AMD. Même celles qui ne le sont pas (par exemple, three.js) fonctionneront de la même manière tant que les dépendances sont spécifiées à l'avance.

La procédure est semblable à l'importation d'AS3. Cela ne devrait donc pas sembler étrange. Si vous vous retrouvez avec plus de fichiers dépendants, cette solution est possible.

r.js

ExigerJS comprend un optimiseur appelé r.js. Cette opération regroupe le code JavaScript principal avec tous les fichiers js dépendants en un seul, puis réduit sa taille à l'aide d'UglifyJS (ou Closure Compiler). Cela réduit le nombre de fichiers et la quantité totale de données que le navigateur doit charger. La taille totale des fichiers JavaScript pour World Wide Maze est d'environ 2 Mo, mais elle peut être réduite à 1 Mo grâce à l'optimisation r.js. Si le jeu pouvait être distribué à l'aide de gzip, la taille serait encore réduite à 250 Ko. (GAE rencontre un problème qui empêche la transmission de fichiers gzip de 1 Mo ou plus. Le jeu est donc actuellement distribué non compressé sous la forme de 1 Mo de texte brut.)

Constructeur de niveaux

Les données des étapes sont générées comme suit, entièrement sur le serveur GCE aux États-Unis:

  1. L'URL du site Web à convertir en étape est envoyée via WebSocket.
  2. PhantomJS effectue une capture d'écran, et les positions des balises div et img sont récupérées et affichées au format JSON.
  3. Sur la base de la capture d'écran de l'étape 2 et des données de positionnement des éléments HTML, un programme C++ personnalisé (OpenCV, Boost) supprime les zones inutiles, génère des îles, les relie à des ponts, calcule la position des garde-fous et des éléments, définit le point d'objectif, etc. Les résultats sont générés au format JSON et renvoyés au navigateur.

PhantomJS

PhantomJS est un navigateur qui ne requiert aucun écran. Il peut charger des pages Web sans ouvrir de fenêtre. Il peut donc être utilisé dans des tests automatisés ou pour faire des captures d'écran côté serveur. Son moteur de navigateur est WebKit, le même que celui utilisé par Chrome et Safari. Par conséquent, sa mise en page et ses résultats d'exécution JavaScript sont également plus ou moins identiques à ceux des navigateurs standards.

Avec PhantomJS, JavaScript ou CoffeeScript est utilisé pour écrire les processus que vous souhaitez exécuter. Il est très facile d'effectuer des captures d'écran, comme illustré dans cet exemple. Je travaillais sur un serveur Linux (CentOS), donc j'ai dû installer des polices pour afficher le japonais (M+ FONTS). Même dans ce cas, le rendu des polices est géré différemment que sous Windows ou Mac OS, de sorte que la même police peut sembler différente sur d'autres ordinateurs (la différence est minime).

La récupération des positions des tags img et div s'effectue globalement de la même manière que pour les pages standards. Vous pouvez également utiliser jQuery sans aucun problème.

stage_builder

Dans un premier temps, j'ai envisagé d'utiliser une approche davantage basée sur le DOM pour générer les étapes (comme avec Firefox 3D Inspector), puis j'ai essayé d'effectuer une analyse DOM dans PhantomJS. Au final, j'ai choisi une approche de traitement des images. À cette fin, j'ai écrit un programme C++ utilisant OpenCV et Boost appelé "stage_builder". Elle effectue les opérations suivantes:

  1. Charge la capture d'écran et le ou les fichiers JSON.
  2. Convertit les images et le texte en "îles".
  3. Crée des ponts pour relier les îles.
  4. Élimine les ponts inutiles pour créer un labyrinthe.
  5. Place les éléments volumineux.
  6. Place les petits éléments.
  7. Places de garde-fous
  8. Génère les données de positionnement au format JSON.

Chaque étape est détaillée ci-dessous.

Chargement de la capture d'écran et du ou des fichiers JSON

Le cv::imread habituel est utilisé pour charger des captures d'écran. J'ai testé plusieurs bibliothèques pour les fichiers JSON, mais le format picojson me semblait le plus facile à utiliser.

Conversion d'images et de texte en "îles"

Compilation d'étape

La capture d'écran ci-dessus présente la section "Actualités" de aid-dcc.com (cliquez pour afficher la taille réelle). Les images et les éléments de texte doivent être convertis en îles. Afin d'isoler ces sections, nous devons supprimer la couleur d'arrière-plan blanche, c'est-à-dire la couleur la plus répandue dans la capture d'écran. Voici comment cela se présentera une fois l'opération terminée:

Compilation d'étape

Les sections blanches sont les îles potentielles.

Le texte étant trop fin et net, nous allons l'épaissir avec cv::dilate, cv::GaussianBlur et cv::threshold. Le contenu de l'image étant également manquant, nous remplirons ces zones avec du blanc, en fonction de la sortie des données de la balise img de PhantomJS. L'image obtenue se présente comme suit:

Compilation d'étape

Le texte forme désormais des amas appropriés, et chaque image est une île à part entière.

Créer des ponts pour relier les îles

Une fois les îles prêtes, elles sont reliées par des ponts. Chaque île recherche les îles adjacentes à gauche, à droite, en haut et en bas, puis relie un pont au point le plus proche de l'île la plus proche. Le résultat ressemble à ceci:

Compilation d'étape

Éliminer les ponts inutiles pour créer un labyrinthe

Conserver tous les ponts rendrait la scène trop facile à parcourir, et certains doivent être éliminés pour créer un labyrinthe. Une île (par exemple, celle en haut à gauche) est choisie comme point de départ, et tous les ponts sauf un (sélectionnés au hasard) qui se connectent à cette île sont supprimés. La même chose est faite pour l'île suivante, reliée par le pont restant. Une fois que le chemin atteint une impasse ou mène à une île visitée précédemment, il revient à un point permettant d'accéder à une nouvelle île. Le labyrinthe est terminé une fois que toutes les îles ont été traitées de cette manière.

Compilation d'étape

Placer des éléments volumineux

Un ou plusieurs objets volumineux sont placés sur chaque île en fonction de ses dimensions, à partir des points les plus éloignés des bords des îles. Bien que cela ne soit pas très clair, ces points sont indiqués en rouge ci-dessous:

Compilation d'étape

À partir de tous ces points possibles, celui qui se trouve en haut à gauche est défini comme point de départ (cercle rouge), celui en bas à droite est défini comme objectif (cercle vert) et les six autres maximum sont choisis pour le placement de grand élément (cercle violet).

Placer de petits éléments

Compilation d'étape

Un nombre suffisant de petits objets sont placés le long de lignes à une distance définie des bords de l'île. L'image ci-dessus (qui ne provient pas d'aid-dcc.com) montre les lignes d'emplacement projetées en gris, décalées et placées à intervalles réguliers à partir des bords de l'île. Les points rouges indiquent l’emplacement des petits éléments. Comme cette image provient d'une version en cours de développement, les éléments sont disposés en lignes droites, mais la version finale disperse les éléments de manière un peu plus irrégulière de chaque côté des lignes grises.

Pose de garde-fous

Les garde-fous sont en fait placés le long des limites extérieures des îles, mais doivent être tronqués au niveau des ponts pour permettre l'accès. La bibliothèque Geometry Boost s'est avérée utile à cet effet, en simplifiant les calculs géométriques, par exemple en déterminant l'endroit où les données de délimitation des îles se croisent avec les lignes de chaque côté d'un pont.

Compilation d'étape

Les lignes vertes délimitant les îles constituent les garde-fous. L'image peut être difficile à voir, mais les ponts ne sont pas délimités par des lignes vertes. Il s'agit de l'image finale utilisée pour le débogage. Elle contient tous les objets devant être générés en JSON. Les points bleu clair sont de petits éléments, et les points gris correspondent à des points de redémarrage. Lorsque la balle tombe dans l'océan, la partie reprend au point de redémarrage le plus proche. Les points de redémarrage sont disposés plus ou moins de la même manière que les petits éléments, à des intervalles réguliers à une distance définie du bord de l'île.

Générer des données de positionnement au format JSON

J'ai également utilisé picojson pour la sortie. Il écrit les données sur la sortie standard, qui est ensuite reçue par l'appelant (Node.js).

Créer un programme C++ sur un Mac à exécuter sous Linux

Le jeu a été développé sur un Mac et déployé sous Linux, mais comme OpenCV et Boost existent pour les deux systèmes d'exploitation, le développement lui-même n'était pas difficile une fois l'environnement de compilation établi. J'ai utilisé les outils de ligne de commande dans Xcode pour déboguer le build sur Mac, puis j'ai créé un fichier de configuration à l'aide de automake/autoconf pour que le build puisse être compilé sous Linux. Ensuite, j'ai simplement dû utiliser « configure && make » sous Linux pour créer le fichier exécutable. J'ai rencontré des bugs spécifiques à Linux en raison des différences de version du compilateur, mais j'ai réussi à les résoudre relativement facilement à l'aide de gdb.

Conclusion

Un jeu de ce type pourrait être créé avec Flash ou Unity, ce qui apporterait de nombreux avantages. Toutefois, cette version ne nécessite aucun plug-in, et les fonctionnalités de mise en page HTML5 + CSS3 se sont révélées extrêmement puissantes. Il est absolument important d’avoir les bons outils pour chaque tâche. J'ai été personnellement surpris de voir à quel point ce jeu s'est avéré efficace pour un jeu entièrement conçu en HTML5. Même s'il manque encore de nombreux aspects, j'ai hâte de voir comment il va se développer à l'avenir.