Airdrop
Article technique
Facebook Instant Game
Nous avons développé un jeu instantané Facebook pour Red Bull, afin de promouvoir leur campagne média « AirDrop » pour le lancement mondial d’une nouvelle gamme de boissons.
Il s’agissait d’un projet intensif et challengeant pour lequel nous avons dû faire preuve de créativité technique… alors nous avons décidé de vous partager notre expérience !
Le jeu que nous avons créé est une sorte de « sky chaser » infinie . En gros, vous êtes le pilote d’une montgolfière qui doit attraper autant de canettes Organics(tm) que possible et les déposer à des endroits spécifiques pour gagner des points. Le gameplay est enrichi par l’ajout de plusieurs bonus, de mécanismes de jeu spéciaux et de fonctionnalités sociales.
Voici quelques-unes des techniques que nous avons utilisées pour développer ce projet.
Le rendu
Nous avons utilisé Three.js pour toute la scène en 3D et pixi.js pour tout ce qui concerne le 2D, l’UI, le HUD du jeu et les particules (réalisées avec les particules PIXI).
Avec les versions les plus récentes des deux systèmes, il est vraiment facile de partager le même contexte WebGL, et d’améliorer ainsi les performances.
Dans de nombreux cas, superposer du HTML/CSS par-dessus des canvas entraînera une baisse de performance en raison de la manière dont le navigateur gère la phase de composition. Nous ne pouvions pas nous permettre une telle chose, d’où la décision de n’utiliser que le rendu WebGL pendant le jeu.
Il est important de noter qu’il n’y a pas de lumière dans la scène ci-dessous. Toute la fausse lumière est apportée par une carte de réflexion sphérique utilisant une texture matcap pour améliorer encore plus les performances du jeu.
const canvas = document.createElement('canvas');
const rendererThree = new THREE.WebGLRenderer({
canvas: canvas.element
});
const rendererPixi = new PIXI.WebGLRenderer({
view: canvas.element,
context: rendererThree.context
});
function loop() {
rendererThree.state.reset();
rendererThree.render(scene, camera);
rendererThree.state.reset();
rendererPixi.reset();
rendererPixi.render(stage, undefined, false);
rendererPixi.reset();
}
La couleur finale est obtenue en multipliant la texture diffuse avec le lookup matcap, ce qui donne un aspect beaucoup plus soigné.
vec3 r = reflect(normalize(vec3(vPos)), normal);
float m = 2.82842712474619 * sqrt(r.z+1.01);
vec3 matcapColor = texture2D(uMatcap, r.xy / m + .5).rgb;
vec3 matcapColor = texture2D(uMatcap, vN).rgb;
vec3 diffuseColor = texture2D(uDiffuse, uv).rgb;
vec3 color = diffuseColor * (matcapColor.r * 1.3);
Moteur de jeu
Le découplage du moteur de jeu/rendu avait plutôt bien fonctionné pour notre dernier développement…et vous savez ce qu’on dit sur le fait de ne jamais changer une équipe qui gagne.
Nous avons donc appliqué le même schéma pour ce jeu. Cela nous a permis de tester/valider la logique sans nous soucier du rendu (et vice versa).
Le gameplay étant un peu plus simple cette fois (voir UPS Delivery Day), nous avons pris le temps d’optimiser un peu le système de détection des collisions.
L’idée était d’éviter d’utiliser une bibliothèque (collision, physique, …) et d’obtenir un code léger à la fin. Comme chaque objet correspond à un disque évoluant sur un plan 2D, le jeu ne nécessitait pas une grande précision. Une simple formule était donc suffisante pour détecter les collisions :
/**
* Calculate the distance between 2 points
*
* @param {object} p1 - First point
* @param {number} p1.x - First x coordinates
* @param {number} p1.y - First y coordinates
* @param {object} p2 - Second point
* @param {number} p2.x - Second x coordinates
* @param {number} p2.y - Second y coordinates
*
* @returns {number} - Distance between centers
*/
function calculateDistance(p1, p2) {
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
return Math.sqrt((dx * dx) + (dy * dy));
}
// Check if distance is smaller than the sum of the radiuses
calculateDistance(balloon, obj) < balloon.r + obj.r; // eslint-disable-line
Le moteur de jeu gère donc les mouvements selon le même principe (ceci n’est possible que parce qu’il n’y a pas de différence d’altitude entre la montgolfière et les autres objets).
Avec l’aide de deux petites fonctions (worldToGame et gameToWorld), nous pouvions facilement passer de l’un à l’autre de ces mondes…
/* eslint-disable no-undef, no-unused-vars */
class GameCamera extends component(OrthographicCamera) {
worldToGame(v3) {
this.updateMatrixWorld();
_v3.copy(v3);
_v3.project(this);
_v2.set(
(_v3.x + 1) / 2,
1 - ((_v3.y + 1) / 2)
);
return _v2;
}
gameToWorld(x, y) {
_v3.x = map(x, 0, 1, this.left, this.right);
_v3.y = 0.0;
_v3.z = map(y, 0, 1, -this.top, this.bottom);
return _v3;
}
}
view rawgist-game-camera.js hosted with ❤ by GitHub
Assets 3D
Nous voulions adopter un look and feel low poly pour ce jeu; ludique mais suffisamment moderne pour le public du jeu et le tone of voice de RedBull.
Si vous travaillez avec des ombres plates dans Blender, il ajoute automatiquement des vertices supplémentaires à votre maillage lors de l’exportation. Blender fait ça pour que les normals ne soient pas « lissées » lorsqu’elles sont transmises au fragment shader.
Pour garder la taille du fichier au minimum, nous avons exporté les assets avec les paramètres réglés sur « Smooth »”, sans les normals. De cette manière, notre fichier final ne contient que deux buffers : les positions et les UVs.
Nous avons ensuite approximé les normals dans notre fragment shader en utilisant l’extension WebGL OES_standard_derivatives.
En procédant ainsi, nous avons pu réduire d’environ 70% la taille des ressources 3D et le nombre total de points.
vec3 fdx = vec3( dFdx( vPos.x ), dFdx( vPos.y ), dFdx( vPos.z ) );
vec3 fdy = vec3( dFdy( vPos.x ), dFdy( vPos.y ), dFdy( vPos.z ) );
vec3 normal = normalize( cross( fdx, fdy ) );
Texture
Facebook exige que les jeux instantanés se chargent en moins de 5 secondes, il était donc crucial de limiter la taille de l’ensemble du jeu au strict minimum. Le principal problème concernait les textures du terrain.
Peindre chaque tuiles du terrain n’était pas une option pour nous, car cela aurait donné des fichiers de texture énormes. C’est pourquoi nous avons décidé d’utiliser des carrés de gradient à la place, et de mapper les UVs par dessus.
Cela nous a permis non seulement d’économiser en termes de taille de texture, mais aussi de créer des scénarios intéressants. Par exemple, si les coordonnées UV sont comprises dans une fourchette donnée, elles peuvent être identifiées comme de l’eau et nous pouvons les animer en tant que telles :
// Water
if (vUv.x > startX && vUv.x < endX && vUv.y > 1.0 - endY) {
float tt = uTime * 3.0;
uv += texture2D(uNoise, uv * (80.0 * uv.x + uv.y * 100.0 + tt * 0.1) + tt * 0.08).r * 0.028;
uv -= uv.x * 0.006;
}
Un monde infini
Pour obtenir l’effet de globe infini, nous avons divisé notre terrain en neuf tuiles différentes. Nous avons d’abord essayé de modéliser nos tuiles courbées autour d’une sphère, avec le point d’origine au centre.
L’idée était de les faire tourner autour de la sphère. Nous avons rapidement réalisé les limites de cette méthode : tout d’abord, il aurait été assez compliqué de modéliser les dales en tant que telles ; et nous nous serions retrouvés avec un nombre limité de dales.
Nous avons abandonné cette idée et décidé d’adopter une approche différente.
Nous avons modélisé nos dales comme des objets plats, puis nous les avons « pliées » dans le vertex shader. Cela a simplifié la modélisation 3D, mais nous a également donné la possibilité d’affiner et d’ajuster la courbure des dales tout au long du processus de développement.
Nous avons utilisé glslify pour diviser notre code GLSL en différents fichiers et fonctions. Cela nous a permis de réutiliser la courbure non seulement pour le terrain, mais aussi pour les « objets volants ». C’est ainsi qu’ils sont capables de suivre parfaitement la même courbe.
// Bend a vertex so that there is an effect like "globe"
const float bendingStart = 150.0;
const float bendingEnd = 270.0;
const float bendingForce = 30.0;
vec3 bend(vec3 worldPos) {
float bending = smoothstep(bendingStart, bendingEnd, -worldPos.z);
bending = clamp(bending, 0.0, 1.0);
return vec3(
worldPos.x,
worldPos.y - bending * bendingForce,
worldPos.z - bending * bendingForce
);
}
#pragma glslify: export(bend)
Dès qu’une dale passe derrière la caméra, elle est marquée comme libre et peut être réutilisée. Dans la version finale, nous avons appliqué un léger brouillard, de sorte que l’utilisateur ne voit pas la dale immédiatement. Pour éviter trop de « répétition », nous avons utilisé une petite astuce : lorsqu’une nouvelle dale est ajoutée, il y a 50% de chances qu’elle soit retournée.
Effet de brume
Au cours du jeu, si vous rencontrez un « nuage », votre écran se remplit de brume. Vous pouvez soit attendre qu’elle disparaisse, soit la nettoyer en balayant furieusement votre écran.
Pour réaliser cette fonctionnalité, nous avons généré un effet de traînée en utilisant une scène hors écran rendue dans un objet frame buffer. Nous avons ensuite procédé au post-traitement en ajustant la luminosité, en ajoutant du flou, un peu de bruit et de la réfraction à l’aide d’une carte normale.
Nous avons construit un module de post-traitement personnalisé en utilisant un triangle au lieu du classique fullscreen-quad, ce qui semble apporter quelques avantages en termes de performances.
Le trail FBO est créé en tant que UnsignedByteType en raison de l’absence de OES_texture_float sur les appareils Samsung.
Comme nous n’avons pas besoin de beaucoup de précision, la texture est relativement petite (128px) avec LinearFilter, de sorte qu’elle est « lissée » lorsqu’elle est mise à l’échelle et appliquée dans notre post-traitement.
Qualité d’adaptation
Les exigences minimales pour un jeu Facebook instantané sont les suivantes :
- iOS 8 et versions ultérieures
- Android 5.0 et versions ultérieures
Ce qui signifie une très large gamme d’appareils, du bas au haut de gamme. Il était crucial d’avoir un taux de rafraîchissement régulier sur tous ces appareils tout en préservant une bonne qualité sur les appareils haut de gamme.
Nous avons trouvé de l’aide pour y parvenir grâce à une extension WebGL très intéressante appelée WEBGL_debug_renderer_info qui donne des informations de débogage sur la carte vidéo utilisée.
var canvas = document.createElement('canvas');
var gl = canvas.getContext('webgl');
var debug = gl.getExtension('WEBGL_debug_renderer_info');
gl.getParameter(debug.UNMASKED_RENDERER_WEBGL); // "NVIDIA GeForce GTX 1050 Ti OpenGL Engine"
view raw
En regardant ces benchmarks, il apparaît clairement que dans la plupart des cas, entre les mêmes séries/modèles, plus le nombre est grand, meilleur est le GPU. En appliquant une série de RegExp sur le nom du chipset, nous avons pu calculer un « score » du GPU de l’utilisateur.
Dans ce cas, nous avons appliqué l’adaptation de la qualité uniquement sur mobile, et nos paramètres ressemblent à ceci :
import device from 'render/device';
const settings = {
dpr: Math.min(1.5, window.devicePixelRatio || 1),
gyroscope: true,
fxaa: true,
shadow: true,
shadowSize: 1024,
};
if (device.isMobile) {
if (device.score <= 0) {
settings.dpr = Math.min(1.5, settings.dpr);
}
if (device.score < 6) {
settings.shadow = false;
}
if (device.score <= 10) {
settings.dpr = Math.min(1.8, settings.dpr);
settings.shadowSize = 512;
}
if (device.oldAdreno) {
settings.dpr = Math.min(1, settings.dpr);
settings.fxaa = false;
settings.shadow = false;
settings.gyroscope = false;
}
if (device.gpu.gpu === 'mali-450 mp') {
settings.dpr = 1;
settings.fxaa = false;
settings.gyroscope = false;
}
//...
}
export default settings;
Social + Facebook
Cela faisait partie du briefing initial : créer un jeu pour la plateforme Instant Game de Facebook.
Super, quelque chose de nouveau !
Nous avons rapidement compris qu’il s’agissait d’utiliser des technologies web (HTML / JS / WebGL) et, même si nous n’avions pas accès à la documentation complète (accès anticipé), nous sommes assez confiants quant à la faisabilité de tout ça.
Nous avons finalement eu accès à 100% de la documentation et au groupe privé « Instant Game Developer Community » (de loin celui qui fait le plus de bruit dans ma timeline ?), et nous avons commencé à creuser : une petite démo tic-tac-toe, des « guides » intéressants et un SDK bien documenté… nous étions prêts à nous lancer !
L’onboarding a été assez simple… Tout est question d’asynchronisme et de belles promesses ! ?
Le plus grand avantage pour nous a été la gestion facile des tableaux des scores. La possibilité d’ajouter le score du « prochain ami à tirer » directement dans l’interface via `getConnectedPlayerEntriesAsync()`, nous a semblé être une fonctionnalité très stimulante…
La gestion des données/stats par utilisateur a également grandement simplifié l’implémentation des “bonus” and “cadeaux”.
Enfin, le système de `payload` via `getEntryPointData()` nous a permis d’intégrer facilement les comportements liés aux messages du bot.
Le fait que toutes les fonctions de partage soient entièrement intégrées et que le tableau de bord analytique soit très complet est également remarquable (mais nous n’en attendions pas moins de Facebook) ?
Même si nous avons rencontré quelques limites, ce fut une très bonne expérience dans l’ensemble. D’autant plus que nous pouvions compter sur le soutien créatif, stratégique et technique d’une équipe Facebook dédiée. Cela a donné lieu à des conférences téléphoniques épiques entre l’Autriche, le Royaume-Uni, l’Allemagne, l’Italie et… la Belgique.