Étude de cas - Trouvez le chemin vers Oz

Présentation

Découvrez le chemin vers Oz est une nouvelle expérience Google Chrome proposée par Disney sur le Web. Il vous permet d'effectuer un voyage interactif à travers un cirque du Kansas, qui vous mène au pays d'Oz après avoir été emporté par une grosse tempête.

Notre objectif était d'associer la richesse du cinéma aux capacités techniques du navigateur pour créer une expérience amusante et immersive avec laquelle les utilisateurs peuvent créer un lien fort.

Le travail est un peu trop ambitieux pour être représenté dans son intégralité dans ce document. Nous avons donc examiné en détail quelques chapitres de l'histoire de la technologie qui nous semblent intéressants. Au fil du temps, nous avons extrait des tutoriels axés sur l'augmentation de la difficulté.

De nombreuses personnes ont travaillé dur pour rendre cette expérience possible: trop nombreuses pour être listées ici. Rendez-vous sur le site pour consulter la page des crédits dans la section "Menu" pour voir l'histoire complète.

Aperçu des coulisses

Découvrez le monde fantastique d'Oz sur ordinateur. Nous utilisons la 3D et plusieurs calques d'effets traditionnels inspirés de la réalisation de films qui s'associent pour créer une scène presque réaliste. Les technologies les plus populaires sont WebGL avec Three.js, les nuanceurs personnalisés et les éléments animés DOM utilisant les fonctionnalités CSS3. En outre, l'API getUserMedia (WebRTC) permet à l'utilisateur d'ajouter des images directement depuis sa webcam et de WebAudio afin d'offrir un son 3D. Il s'agit également d'une expérience interactive.

Mais la magie d'une expérience technologique comme celle-ci réside dans la façon dont elle prend forme. C'est également l'un des principaux défis: comment combiner les effets visuels et les éléments interactifs en une seule scène pour créer un ensemble cohérent ? Cette complexité visuelle était difficile à gérer: il était difficile de déterminer à quelle étape du développement nous étions à un moment donné.

Pour résoudre le problème de l'interconnexion et de l'optimisation des effets visuels, nous avons largement utilisé un panneau de configuration capable de rassembler tous les paramètres pertinents que nous examinions à l'époque. La scène peut être ajustée en direct dans le navigateur en fonction de la luminosité, de la profondeur de champ, du gamma, etc. Chacun pourrait essayer d'ajuster les valeurs des paramètres significatifs de l'expérience et participer à la découverte de ce qui fonctionnait le mieux.

Avant de partager notre secret, nous souhaitons vous avertir qu'il risque de s'écraser, comme si vous deviez fouiller dans le moteur d'une voiture. Assurez-vous de ne rien indiquer d'important, puis accédez à l'URL principale du site et ajoutez ?debug=on à l'adresse. Attendez que le site se charge. Une fois dans la zone (appuyez sur la touche ?), appuyez sur la touche Ctrl-I. Un menu déroulant s'affiche sur la droite. Si vous décochez l'option "Quitter le tracé de la caméra", vous pouvez utiliser les touches A, W, S, D et la souris pour vous déplacer librement dans l'espace.

Chemin d'accès à la caméra.

Nous n'aborderons pas tous les paramètres ici, mais nous vous encourageons à faire des tests: les touches révèlent différents paramètres selon les scènes. La dernière séquence de la tempête comporte une touche supplémentaire: Ctrl-A. Elle vous permet d'activer/de désactiver la lecture de l'animation et de voler. Dans cette scène, si vous appuyez sur Esc (pour quitter la fonctionnalité de verrouillage de la souris), puis sur Ctrl-I, vous pouvez accéder aux paramètres spécifiques à la scène de l'orage. Regardez autour de vous et prenez des photos de cartes postales comme celle illustrée ci-dessous.

Scène de tempête

Pour ce faire et pour nous assurer qu'il est suffisamment flexible pour répondre à nos besoins, nous avons utilisé une belle bibliothèque appelée dat.gui (voir ici un précédent tutoriel expliquant comment l'utiliser). Cela nous a permis de modifier rapidement les paramètres présentés aux visiteurs du site.

Un peu comme la peinture mate

Dans de nombreux films et animations classiques de Disney, créer des scènes consistait à combiner différentes couches. Des couches d'actions réelles, d'animations de cellules, et même de décors physiques, ainsi que des couches supérieures, étaient créées par la peinture sur verre. C'est ce qu'on appelle la peinture mate.

À bien des égards, la structure de l'expérience que nous avons créée est similaire, même si certains des « calques » sont bien plus que des visuels statiques. En fait, ils affectent l'apparence des choses selon des calculs plus complexes. Néanmoins, au moins au niveau global, nous traitons des vues, composées les unes au-dessus des autres. En haut, vous voyez une couche d'interface utilisateur, avec une scène 3D en dessous: elle-même composée de différents composants de scène.

La couche d'interface supérieure a été créée à l'aide du DOM et du CSS 3. Ainsi, il est possible de modifier les interactions de différentes façons, indépendamment de l'expérience 3D, et de communiquer entre les deux selon une liste d'événements spécifique. Cette communication utilise l'événement HTML5 "Backbone Router + onHashChange" qui détermine la zone à animer. (source du projet: /develop/coffee/router/Router.coffee).

Tutoriel: Compatibilité avec les feuilles de sprites et Retina

Une technique d'optimisation amusante sur laquelle nous nous sommes appuyés était de combiner les nombreuses images de superposition de l'interface dans un seul fichier PNG afin de réduire le nombre de requêtes de serveur. Dans ce projet, l'interface était composée de plus de 70 images (sans compter les textures 3D) chargées à l'avance pour réduire la latence du site Web. Vous pouvez voir la feuille de sprites en ligne ici:

Affichage normal : http://findyourwaytooz.com/img/home/interface_1x.png Écran Retina : http://findyourwaytooz.com/img/home/interface_2x.png

Voici quelques conseils sur la façon dont nous avons exploité les feuilles de sprites, et comment les utiliser pour les appareils Retina et obtenir une interface aussi nette et soignée que possible.

Créer des feuilles de sprites

Pour créer des Sprite Sheets, nous avons utilisé TexturePacker qui génère le format dont vous avez besoin. Dans le cas présent, nous avons exporté le fichier au format EaselJS, qui est très propre et aurait pu également servir à créer des sprites animés.

Utiliser la feuille de sprites générée

Une fois votre feuille de sprites créée, un fichier JSON semblable à celui-ci doit s'afficher:

{
   "images": ["interface_2x.png"],
   "frames": [
       [2, 1837, 88, 130],
       [2, 2, 1472, 112],
       [1008, 774, 70, 68],
       [562, 1960, 86, 86],
       [473, 1960, 86, 86]
   ],

   "animations": {
       "allow_web":[0],
       "bottomheader":[1],
       "button_close":[2],
       "button_facebook":[3],
       "button_google":[4]
   },
}

Où :

  • "image" fait référence à l'URL de la feuille de sprites
  • Les cadres correspondent aux coordonnées de chaque élément d'interface utilisateur [x, y, width, height].
  • Les animations sont les noms de chaque composant

Notez que nous avons utilisé des images haute densité pour créer la feuille de sprites, puis que nous avons créé la version normale en la redimensionnant de moitié.

Synthèse

Maintenant que tout est prêt, il nous suffit d'utiliser un extrait de code JavaScript pour l'utiliser.

var SSAsset = function (asset, div) {
  var css, x, y, w, h;

  // Divide the coordinates by 2 as retina devices have 2x density
  x = Math.round(asset.x / 2);
  y = Math.round(asset.y / 2);
  w = Math.round(asset.width / 2);
  h = Math.round(asset.height / 2);

  // Create an Object to store CSS attributes
  css = {
    width                : w,
    height               : h,
    'background-image'   : "url(" + asset.image_1x_url + ")",
    'background-size'    : "" + asset.fullSize[0] + "px " + asset.fullSize[1] + "px",
    'background-position': "-" + x + "px -" + y + "px"
  };

  // If retina devices

  if (window.devicePixelRatio === 2) {

    /*
    set -webkit-image-set
    for 1x and 2x
    All the calculations of X, Y, WIDTH and HEIGHT is taken care by the browser
    */

    css['background-image'] = "-webkit-image-set(url(" + asset.image_1x_url + ") 1x,";
    css['background-image'] += "url(" + asset.image_2x_url + ") 2x)";

  }

  // Set the CSS to the DIV
  div.css(css);
};

Et voici comment vous l'utiliseriez:

logo = new SSAsset(
{
  fullSize     : [1024, 1024],               // image 1x dimensions Array [x,y]
  x            : 1790,                       // asset x coordinate on SpriteSheet         
  y            : 603,                        // asset y coordinate on SpriteSheet
  width        : 122,                        // asset width
  height       : 150,                        // asset height
  image_1x_url : 'img/spritesheet_1x.png',   // background image 1x URL
  image_2x_url : 'img/spritesheet_2x.png'    // background image 2x URL
},$('#logo'));

Pour en savoir un peu plus sur les densités de pixels variables, lisez cet article de Boris Smus.

Pipeline de contenu 3D

L'expérience de l'environnement est configurée sur un calque WebGL. Lorsque l'on pense à une scène en 3D, l'une des questions les plus délicates est de savoir comment créer des contenus qui exploitent pleinement le potentiel d'expression des côtés de modélisation, d'animation et d'effets. À bien des égards, le problème se situe au cœur du pipeline de contenu: un processus convenu à suivre pour créer du contenu pour la scène 3D.

Nous voulions créer un monde inspirant. Il nous fallait donc un processus solide permettant aux artistes 3D de le faire. Ils devraient disposer d'une liberté d'expression maximale dans leur logiciel de modélisation et d'animation 3D, et nous devrions les afficher à l'écran via du code.

Nous travaillions à ce genre de problème depuis un certain temps, car à chaque fois que nous avions créé un site en 3D, nous avions rencontré des limites dans les outils que nous pouvions utiliser. Nous avions donc créé cet outil, 3D Librarian: une étude interne. Et il était sur le point de l’appliquer à un vrai emploi.

Cet outil avait une certaine histoire: à l'origine, il était conçu pour Flash et il permettait d'importer une grande scène Maya sous la forme d'un seul fichier compressé optimisé pour la décompression lors de l'exécution. Elle était optimale en raison du fait qu'elle empaquetait efficacement la scène dans la même structure de données que celle manipulée lors du rendu et de l'animation. Très peu d'analyses doivent être effectuées sur le fichier une fois chargé. La décompression dans Flash a été assez rapide, car le fichier était au format AMF, que Flash a pu décompresser de manière native. L'utilisation du même format dans WebGL nécessite un peu plus de travail sur le processeur. En fait, nous avons dû recréer une couche de code JavaScript de décompression de données, qui décompresserait ces fichiers et recréait les structures de données nécessaires au fonctionnement de WebGL. L'extraction de l'intégralité de la scène 3D sollicite légèrement le processeur. En effet, la décompression de la scène 1 du film Find Your Way To Oz nécessite environ deux secondes sur un ordinateur de milieu à haut de gamme. Par conséquent, cette opération est effectuée à l'aide de la technologie Web Workers, au moment de la "configuration de la scène" (avant le lancement réel de la scène), afin que l'expérience ne soit pas suspendue par l'utilisateur.

Cet outil pratique permet d'importer une grande partie de la scène 3D: modèles, textures, animations d'os. Vous créez un seul fichier de bibliothèque, qui peut ensuite être chargé par le moteur 3D. Ajoutez toutes les mannequins dont vous avez besoin dans cette bibliothèque, et voilà !

Mais nous rencontrions un problème: nous étions confrontés à WebGL, le nouvel enfant du quartier. C'était un enfant difficile à relever: il constituait la norme en matière d'expériences 3D dans un navigateur. Nous avons donc créé un calque JavaScript ad hoc qui récupérerait les fichiers de scènes 3D compressés du Bibliothèque 3D et les traduireait correctement dans un format compatible avec WebGL.

Tutoriel: Laissez le vent souffler

Un thème récurrent dans "Trouvez le chemin vers Oz" était le vent. Le fil de l'intrigue est structuré comme un crescendo de vent.

La première scène du carnaval est relativement calme. En parcourant les différentes scènes, l'utilisateur expérimente un vent de plus en plus fort, qui aboutit à la scène finale, la tempête.

Il était donc important de fournir un effet de vent immersif.

Pour ce faire, nous avons rempli les trois scènes de carnaval avec des objets doux qui étaient censés être influencés par le vent, comme des tentes, des drapeaux à la surface de la cabine photo et le ballon lui-même.

Chiffon doux.

Aujourd'hui, les jeux sur ordinateur sont généralement construits autour d'un moteur physique de base. Ainsi, lorsqu'un objet doux doit être simulé en 3D, une simulation physique complète est exécutée pour celui-ci, créant ainsi un comportement mou crédible.

Avec WebGL / JavaScript, nous n'avons pas (encore) le luxe d'exécuter une simulation physique complète. Nous avons donc dû trouver un moyen de créer l'effet du vent sans le simuler.

Nous avons intégré les informations sur la "sensibilité au vent" de chaque objet dans le modèle 3D lui-même. Chaque sommet du modèle 3D était associé à un attribut "vent" qui précise l'impact du vent sur ce sommet. Nous avons donc spécifié la sensibilité au vent des objets 3D. Ensuite, nous devions créer le vent lui-même.

Pour ce faire, nous avons généré une image contenant Perlin Noise. Cette image est destinée à couvrir une certaine "zone venteuse". Pour bien comprendre, imaginez une image représentant un nuage, comme le bruit, sur une zone rectangulaire donnée de la scène 3D. Chaque pixel, valeur de niveau de gris, de cette image indique la force du vent à un moment donné dans la zone 3D "qui l'entoure".

Pour produire l'effet de vent, l'image est déplacée, dans le temps, à une vitesse constante, dans une direction spécifique (la direction du vent). Et pour nous assurer que la "zone venteuse" n'affecte pas tout le contenu de la scène, nous encapsulons l'image du vent sur les bords, en se limitant à la zone d'effet.

Tutoriel simple sur le vent en 3D

À présent, créons l'effet du vent dans une scène 3D simple dans Three.js.

Nous allons créer du vent dans un simple "champ de gazon procédural".

Commençons par créer la scène. Nous allons créer un terrain plat et simple, avec des textures créées à partir de reliefs. Chaque brin d'herbe sera simplement représenté par un cône en 3D à l'envers.

Terrain herbeux
Terrain rocailleux

Voici comment créer cette scène simple dans Three.js à l'aide de CoffeeScript.

Tout d'abord, nous allons configurer Three.js, puis le connecter avec l'appareil photo, le contrôleur de souris et l'éclairage.

constructor: ->

   @clock =  new THREE.Clock()

   @container = document.createElement( 'div' );
   document.body.appendChild( @container );

   @renderer = new THREE.WebGLRenderer();
   @renderer.setSize( window.innerWidth, window.innerHeight );
   @renderer.setClearColorHex( 0x808080, 1 )
   @container.appendChild(@renderer.domElement);

   @camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 1, 5000 );
   @camera.position.x = 5;
   @camera.position.y = 10;
   @camera.position.z = 40;

   @controls = new THREE.OrbitControls( @camera, @renderer.domElement );
   @controls.enabled = true

   @scene = new THREE.Scene();
   @scene.add( new THREE.AmbientLight 0xFFFFFF )

   directional = new THREE.DirectionalLight 0xFFFFFF
   directional.position.set( 10,10,10)
   @scene.add( directional )

   # Demo data
   @grassTex = THREE.ImageUtils.loadTexture("textures/grass.png");
   @initGrass()
   @initTerrain()

   # Stats
   @stats = new Stats();
   @stats.domElement.style.position = 'absolute';
   @stats.domElement.style.top = '0px';
   @container.appendChild( @stats.domElement );
   window.addEventListener( 'resize', @onWindowResize, false );
   @animate()

Les appels de fonctions initGrass et initTerrain remplissent la scène avec les champs "herbe" et "terrain", respectivement:

initGrass:->
   mat = new THREE.MeshPhongMaterial( { map: @grassTex } )
   NUM = 15
   for i in [0..NUM] by 1
       for j in [0..NUM] by 1
           x = ((i/NUM) - 0.5) * 50 + THREE.Math.randFloat(-1,1)
           y = ((j/NUM) - 0.5) * 50 + THREE.Math.randFloat(-1,1)
           @scene.add( @instanceGrass( x, 2.5, y, 5.0, mat ) )

instanceGrass:(x,y,z,height,mat)->
   geometry = new THREE.CylinderGeometry( 0.9, 0.0, height, 3, 5 )
   mesh = new THREE.Mesh( geometry, mat )
   mesh.position.set( x, y, z )
   return mesh

Ici, nous créons une grille de 15 couches d'herbe sur 15. Nous ajoutons un peu de randomisation à chaque position d'herbe afin qu'elles ne soient pas alignées comme des soldats, ce qui aurait l'air étrange.

Ce terrain est juste une surface plane horizontale, placée à la base des morceaux d'herbe (y = 2,5).

initTerrain:->
  @plane = new THREE.Mesh( new THREE.PlaneGeometry(60, 60, 2, 2), new THREE.MeshPhongMaterial({ map: @grassTex }))
  @plane.rotation.x = -Math.PI/2
  @scene.add( @plane )

Jusqu'à présent, nous avons simplement créé une scène Three.js, puis ajouté quelques morceaux d'herbe composés de cônes inversés générés de manière procédurale, et un terrain simple.

Rien de compliqué jusqu'à présent.

Il est maintenant temps de commencer à ajouter du vent. Tout d'abord, nous voulons intégrer les informations sur la sensibilité au vent dans le modèle 3D de l'herbe.

Nous allons intégrer ces informations en tant qu'attribut personnalisé pour chaque sommet du modèle 3D "Gazon". Nous allons appliquer la règle suivante: l'extrémité inférieure du modèle "herbe" (extrémité du cône) n'a pas de sensibilité, car elle est fixée au sol. La partie supérieure du modèle en herbe (la base du cône) a une sensibilité maximale au vent, car il s'agit de la partie la plus éloignée du sol.

Voici comment la fonction instanceGrass est recodée afin d'ajouter la sensibilité au vent en tant qu'attribut personnalisé pour le modèle 3D "herbe".

instanceGrass:(x,y,z,height)->

  geometry = new THREE.CylinderGeometry( 0.9, 0.0, height, 3, 5 )

  for i in [0..geometry.vertices.length-1] by 1
      v = geometry.vertices[i]
      r = (v.y / height) + 0.5
      @windMaterial.attributes.windFactor.value[i] = r * r * r

  # Create mesh
  mesh = new THREE.Mesh( geometry, @windMaterial )
  mesh.position.set( x, y, z )
  return mesh

Nous utilisons désormais un matériau personnalisé, windMaterial, au lieu de MeshPhongMaterial que nous avons utilisé précédemment. WindMaterial encapsule le WindMeshShader que nous allons voir dans une minute.

Ainsi, le code d'instanceGrass passe par tous les sommets du modèle en herbe et, pour chacun d'eux, il ajoute un attribut de sommet personnalisé, appelé windFactor. La valeur de "windFactor" est 0 pour le bas du modèle "herbe" (là où elle est censée toucher le terrain) et de 1 pour le haut du modèle "herbe".

L'autre ingrédient dont nous avons besoin est d'ajouter le vent réel à notre scène. Comme nous l'avons vu, nous allons utiliser le bruit de Perlin pour cela. Nous allons générer de manière procédurale une texture de bruit de Perlin.

Par souci de clarté, nous allons attribuer cette texture au terrain lui-même, à la place de la texture verte précédente. Vous pourrez ainsi mieux voir ce qui se passe au niveau du vent.

Ainsi, cette texture de bruit de Perlin couvrira l'espace jusqu'à l'extension de notre terrain, et chaque pixel de la texture indiquera l'intensité du vent de la zone du relief où se trouve ce pixel. Le rectangle de relief va être notre "zone de vent".

Le bruit de Perlin est généré de manière procédurale via un nuanceur appelé NoiseShader. Ce nuanceur utilise des algorithmes de bruit simplex 3D provenant de la page https://github.com/ashima/webgl-noise . La version WebGL a été extraite mot à mot de l'un des exemples Three.js de MrDoob, à l'adresse http://mrdoob.github.com/three.js/examples/webgl_terrain_dynamic.html.

NoiseShader utilise un temps, une échelle et un ensemble de paramètres de décalage en tant qu'expressions uniformes, puis génère une distribution 2D du bruit de Perlin.

class NoiseShader

  uniforms:     
    "fTime"  : { type: "f", value: 1 }
    "vScale"  : { type: "v2", value: new THREE.Vector2(1,1) }
    "vOffset"  : { type: "v2", value: new THREE.Vector2(1,1) }

...

Nous allons utiliser ce nuanceur pour convertir le bruit de Perlin en texture. Pour ce faire, utilisez la fonction initNoiseShader.

initNoiseShader:->
  @noiseMap  = new THREE.WebGLRenderTarget( 256, 256, { minFilter: THREE.LinearMipmapLinearFilter, magFilter: THREE.LinearFilter, format: THREE.RGBFormat } );
  @noiseShader = new NoiseShader()
  @noiseShader.uniforms.vScale.value.set(0.3,0.3)
  @noiseScene = new THREE.Scene()
  @noiseCameraOrtho = new THREE.OrthographicCamera( window.innerWidth / - 2, window.innerWidth / 2,  window.innerHeight / 2, window.innerHeight / - 2, -10000, 10000 );
  @noiseCameraOrtho.position.z = 100
  @noiseScene.add( @noiseCameraOrtho )

  @noiseMaterial = new THREE.ShaderMaterial
      fragmentShader: @noiseShader.fragmentShader
      vertexShader: @noiseShader.vertexShader
      uniforms: @noiseShader.uniforms
      lights:false

  @noiseQuadTarget = new THREE.Mesh( new THREE.PlaneGeometry(window.innerWidth,window.innerHeight,100,100), @noiseMaterial )
  @noiseQuadTarget.position.z = -500
  @noiseScene.add( @noiseQuadTarget )

Le code ci-dessus permet de configurer noiseMap en tant que cible de rendu Three.js, d'y équiper le NoiseShader, puis d'effectuer le rendu avec une caméra orthographie, afin d'éviter les distorsions de perspective.

Comme nous l'avons vu, nous allons maintenant utiliser cette texture également comme texture principale de rendu du relief. Ce n'est pas vraiment nécessaire pour que l'effet du vent en lui-même fonctionne. C'est une fonctionnalité très pratique qui nous permet de mieux comprendre visuellement les évolutions liées à la production d'énergie éolienne.

Voici la fonction initTerrain retravaillée, qui utilise soundMap comme texture:

initTerrain:->
  @plane = new THREE.Mesh( new THREE.PlaneGeometry(60, 60, 2, 2), new THREE.MeshPhongMaterial( { map: @noiseMap, lights: false } ) )
  @plane.rotation.x = -Math.PI/2
  @scene.add( @plane )

Maintenant que la texture du vent est en place, examinons WindMeshShader, qui est responsable de la déformation des modèles d'herbe en fonction du vent.

Pour créer ce nuanceur, nous avons commencé à partir du nuanceur MeshPhongMaterial standard Three.js et nous l'avons modifié. Il s'agit d'un bon moyen rapide et pratique de commencer à utiliser un nuanceur qui fonctionne, sans avoir à partir de zéro.

Nous n'allons pas copier l'intégralité du code du nuanceur ici (n'hésitez pas à le consulter dans le fichier de code source), car il s'agit principalement d'une réplique du nuanceur MeshPhongMaterial. Examinons les parties modifiées et liées au vent dans Vertex Shader.

vec4 wpos = modelMatrix * vec4( position, 1.0 );
vec4 wpos = modelMatrix * vec4( position, 1.0 );

wpos.z = -wpos.z;
vec2 totPos = wpos.xz - windMin;
vec2 windUV = totPos / windSize;
vWindForce = texture2D(tWindForce,windUV).x;

float windMod = ((1.0 - vWindForce)* windFactor ) * windScale;
vec4 pos = vec4(position , 1.0);
pos.x += windMod * windDirection.x;
pos.y += windMod * windDirection.y;
pos.z += windMod * windDirection.z;

mvPosition = modelViewMatrix *  pos;

Ce nuanceur calcule d'abord les coordonnées de recherche de texture windUV, en fonction de la position 2D, xz (horizontale) du sommet. Cette coordonnée UV permet de rechercher la force du vent (vWindForce) à partir de la texture du vent du bruit de Perlin.

Cette valeur vWindForce est composée avec l'attribut windFactor spécifique aux sommets, tel que décrit ci-dessus, afin de calculer le degré de déformation dont le sommet a besoin. Nous disposons également d'un paramètre windScale global qui contrôle l'intensité globale du vent, et d'un vecteur windDirection, qui indique la direction dans laquelle la déformation du vent doit se produire.

Cela crée une déformation liée au vent sur nos morceaux d'herbe. Mais ce n'est pas fini. En l'état actuel, cette déformation est statique et ne traduit pas l'effet d'une zone venteuse.

Comme nous l'avons mentionné, nous allons devoir faire glisser la texture du bruit au fil du temps, dans la zone du vent, afin que notre verre puisse onduler.

Pour ce faire, on déplace au fil du temps l'uniforme vOffset transmis à NoiseShader. Il s'agit d'un paramètre vec2 qui nous permet de spécifier le décalage du bruit dans une certaine direction (la direction du vent).

Nous le faisons dans la fonction render, qui est appelée à chaque frame:

render: =>
  delta = @clock.getDelta()

  if @windDirection
      @noiseShader.uniforms[ "fTime" ].value += delta * @noiseSpeed
      @noiseShader.uniforms[ "vOffset" ].value.x -= (delta * @noiseOffsetSpeed) * @windDirection.x
      @noiseShader.uniforms[ "vOffset" ].value.y += (delta * @noiseOffsetSpeed) * @windDirection.z
...

Et voilà ! Nous venons de créer une scène avec du "pelage procédural" affecté par le vent.

Ajouter de la poussière à votre campagne

Ajoutons maintenant un peu de piment à notre scène. Ajoutons un peu de poussière volante pour rendre la scène plus intéressante.

Poussière prise
Ajouter de la poussière

Après tout, la poussière est censée être affectée par le vent. Il est donc logique qu'il y ait de la poussière dans la scène du vent.

La poussière est configurée dans la fonction initDust en tant que système de particules.

initDust:->
  for i in [0...5] by 1
      shader = new WindParticleShader()
      params = {}
      params.fragmentShader = shader.fragmentShader
      params.vertexShader   = shader.vertexShader
      params.uniforms       = shader.uniforms
      params.attributes     = { speed: { type: 'f', value: [] } }

      mat  = new THREE.ShaderMaterial(params)
      mat.map = shader.uniforms["map"].value = THREE.ImageUtils.loadCompressedTexture("textures/dust#{i}.dds")
      mat.size = shader.uniforms["size"].value = Math.random()
      mat.scale = shader.uniforms["scale"].value = 300.0
      mat.transparent = true
      mat.sizeAttenuation = true
      mat.blending = THREE.AdditiveBlending
      shader.uniforms["tWindForce"].value      = @noiseMap
      shader.uniforms[ "windMin" ].value       = new THREE.Vector2(-30,-30 )
      shader.uniforms[ "windSize" ].value      = new THREE.Vector2( 60, 60 )
      shader.uniforms[ "windDirection" ].value = @windDirection            

      geom = new THREE.Geometry()
      geom.vertices = []
      num = 130
      for k in [0...num] by 1

          setting = {}

          vert = new THREE.Vector3
          vert.x = setting.startX = THREE.Math.randFloat(@dustSystemMinX,@dustSystemMaxX)
          vert.y = setting.startY = THREE.Math.randFloat(@dustSystemMinY,@dustSystemMaxY)
          vert.z = setting.startZ = THREE.Math.randFloat(@dustSystemMinZ,@dustSystemMaxZ)

          setting.speed =  params.attributes.speed.value[k] = 1 + Math.random() * 10
          
          setting.sinX = Math.random()
          setting.sinXR = if Math.random() < 0.5 then 1 else -1
          setting.sinY = Math.random()
          setting.sinYR = if Math.random() < 0.5 then 1 else -1
          setting.sinZ = Math.random()
          setting.sinZR = if Math.random() < 0.5 then 1 else -1

          setting.rangeX = Math.random() * 5
          setting.rangeY = Math.random() * 5
          setting.rangeZ = Math.random() * 5

          setting.vert = vert
          geom.vertices.push vert
          @dustSettings.push setting

      particlesystem = new THREE.ParticleSystem( geom , mat )
      @dustSystems.push particlesystem
      @scene.add particlesystem

Ici, 130 particules de poussière sont créées. Notez que chacun d'eux est équipé d'un nuanceur WindParticleShader spécial.

À présent, à chaque image, nous allons déplacer légèrement les particules à l'aide de CoffeeScript, indépendamment du vent. Voici le code.

moveDust:(delta)->

  for setting in @dustSettings

    vert = setting.vert
    setting.sinX = setting.sinX + (( 0.002 * setting.speed) * setting.sinXR)
    setting.sinY = setting.sinY + (( 0.002 * setting.speed) * setting.sinYR)
    setting.sinZ = setting.sinZ + (( 0.002 * setting.speed) * setting.sinZR) 

    vert.x = setting.startX + ( Math.sin(setting.sinX) * setting.rangeX )
    vert.y = setting.startY + ( Math.sin(setting.sinY) * setting.rangeY )
    vert.z = setting.startZ + ( Math.sin(setting.sinZ) * setting.rangeZ )

Nous allons également décaler la position de chaque particule en fonction du vent. Cette opération s'effectue dans WindParticleShader. Plus précisément, dans le nuanceur de sommets.

Le code de ce nuanceur est une version modifiée de ParticleMaterial Three.js, et voici à quoi ressemble le cœur:

vec4 mvPosition;
vec4 wpos = modelMatrix * vec4( position, 1.0 );
wpos.z = -wpos.z;
vec2 totPos = wpos.xz - windMin;
vec2 windUV = totPos / windSize;
float vWindForce = texture2D(tWindForce,windUV).x;
float windMod = (1.0 - vWindForce) * windScale;
vec4 pos = vec4(position , 1.0);
pos.x += windMod * windDirection.x;
pos.y += windMod * windDirection.y;
pos.z += windMod * windDirection.z;

mvPosition = modelViewMatrix *  pos;

fSpeed = speed;
float fSize = size * (1.0 + sin(time * speed));

#ifdef USE_SIZEATTENUATION
    gl_PointSize = fSize * ( scale / length( mvPosition.xyz ) );
#else,
    gl_PointSize = fSize;
#endif

gl_Position = projectionMatrix * mvPosition;

Ce nuanceur de sommets n'est pas très différent de ce que nous avons fait pour la déformation de l'herbe basée sur le vent. Elle utilise la texture de bruit de Perlin en entrée et, en fonction de la position dans le monde, utilise une valeur vWindForce dans la texture du bruit. Ensuite, il utilise cette valeur pour modifier la position de la particule de poussière.

Riders On The Storm

La plus audacieuse de nos scènes WebGL est probablement la dernière, que vous pouvez voir en cliquant sur le ballon dans l'œil de la tornade pour arriver à la fin de votre voyage sur le site, ainsi qu'une vidéo exclusive sur la sortie à venir.

Scène d&#39;excursion en montgolfière

Lorsque nous avons créé cette scène, nous savions que nous devions avoir une caractéristique centrale de l'expérience qui aurait un impact. La tornade en rotation servirait de pièce maîtresse et des couches d'autres contenus façonneraient cette caractéristique pour créer un effet spectaculaire. Pour y parvenir, nous avons créé l'équivalent d'un studio de cinéma autour de cet étrange nuanceur.

Nous avons utilisé une approche mixte pour créer le composite réaliste. Il peut s'agir d'astuces visuelles, comme des formes lumineuses pour créer un effet de reflet de l'objectif ou des gouttes de pluie qui s'animent sous forme de couches sur la scène que vous regardez. Dans d'autres cas, des surfaces planes dessinées semblent se déplacer, comme les couches de nuages à faible vol qui se déplacent selon un code de système de particules. Les débris en orbite autour de la tornade étaient des couches d'éléments dans une scène 3D triées pour se déplacer devant et derrière la tornade.

La principale raison pour laquelle nous avons dû créer la scène de cette manière était de nous assurer que nous avions suffisamment de GPU pour gérer le nuanceur de tornade en équilibre avec les autres effets que nous appliquons. Au début, nous avions d'importants problèmes d'équilibrage GPU, mais cette scène a été optimisée par la suite et est devenue plus légère que les scènes principales.

Tutoriel: Storm Shader

Pour créer la séquence finale de la tempête, de nombreuses techniques différentes ont été combinées, mais la pièce maîtresse de ce travail était un nuanceur GLSL personnalisé qui ressemble à une tornade. Nous avons essayé de nombreuses techniques différentes, allant des nuanceurs de sommets pour créer des tourbillons géométriques intéressants, des animations basées sur des particules ou même des animations 3D de formes géométriques torsadées. Aucun des effets ne semblait recréer le sentiment d'une tornade ou exigeait trop de traitements.

Un projet complètement différent nous a finalement donné la réponse. Créé en parallèle par le Max Planck Institute (brainflight.org), un projet impliquant des jeux scientifiques pour cartographier le cerveau de la souris a généré des effets visuels intéressants. Nous avions réussi à créer des films de l'intérieur d'un neurone de souris à l'aide d'un nuanceur volumétrique personnalisé.

Intérieur d&#39;un neurone de souris utilisant un nuanceur volumétrique personnalisé
Dans un neurone de souris utilisant un nuanceur volumétrique personnalisé

Nous avons découvert que l'intérieur d'une cellule cérébrale ressemblait un peu à l'entonnoir d'une tornade. Comme nous utilisions une technique volumétrique, nous savions que nous pouvions visualiser ce nuanceur dans toutes les directions dans l'espace. Nous pourrions configurer le rendu du nuanceur pour qu'il se combine avec la scène de la tempête, en particulier s'il est placé sous des couches de nuages et au-dessus d'un arrière-plan spectaculaire.

La technique du nuanceur implique une astuce qui utilise essentiellement un seul nuanceur GLSL pour afficher un objet entier avec un algorithme de rendu simplifié appelé "rendu en marche à rayons" avec un champ de distance. Avec cette technique, un nuanceur de pixels est créé. Il estime la distance la plus proche d'une surface pour chaque point à l'écran.

Dans la présentation de iq, vous trouverez une bonne référence à cet algorithme: Rendu des mondes avec deux triangles – Iñigo Quilez. Vous pouvez également découvrir la galerie des nuanceurs sur glsl.heroku.com et découvrir de nombreux exemples de cette technique que vous pouvez tester.

Le cœur du nuanceur commence par la fonction principale. Il configure les transformations de la caméra et entre dans une boucle qui évalue de manière répétée la distance par rapport à une surface. L'appel RaytraceFoggy( direction_vector, max_iterations, color, color_multiplier ) est l'endroit où se produit le calcul de la marche du rayon.

for(int i=0;i < number_of_steps;i++) // run the ray marching loop
{
  old_d=d;
  float shape_value=Shape(q); // find out the approximate distance to or density of the tornado cone
  float density=-shape_value;
  d=max(shape_value*step_scaling,0.0);// The max function clamps values smaller than 0 to 0

  float step_dist=d+extra_step; // The point is advanced by larger steps outside the tornado,
  //  allowing us to skip empty space quicker.

  if (density>0.0) {  // When density is positive, we are inside the cloud
    float brightness=exp(-0.6*density);  // Brightness decays exponentially inside the cloud

    // This function combines density layers to create a translucent fog
    FogStep(step_dist*0.2,clamp(density, 0.0,1.0)*vec3(1,1,1), vec3(1)*brightness, colour, multiplier); 
  }
  if(dist>max_dist || multiplier.x < 0.01) { return;  } // if we've gone too far stop, we are done
  dist+=step_dist; // add a new step in distance
  q=org+dist*dir; // trace its direction according to the ray casted
}

L'idée est qu'à mesure que nous progressons dans la forme de la tornade, nous ajoutons régulièrement des apports de couleur à la valeur de couleur finale du pixel, ainsi que des contributions à l'opacité le long du rayon. Cela crée une couche de texture douce à la texture de la tornade.

L'aspect principal suivant de la tornade est la forme elle-même, qui est créée en composant un certain nombre de fonctions. Il s'agit d'abord d'un cône, composé avec du bruit pour créer une arête organique rugueuse, qui est ensuite tordu le long de son axe principal et pivoté dans le temps.

mat2 Spin(float angle){
  return mat2(cos(angle),-sin(angle),sin(angle),cos(angle)); // a rotation matrix
}

// This takes noise function and makes ridges at the points where that function crosses zero
float ridged(float f){ 
  return 1.0-2.0*abs(f);
}

// the isosurface shape function, the surface is at o(q)=0 
float Shape(vec3 q) 
{
    float t=time;

    if(q.z < 0.0) return length(q);

    vec3 spin_pos=vec3(Spin(t-sqrt(q.z))*q.xy,q.z-t*5.0); // spin the coordinates in time

    float zcurve=pow(q.z,1.5)*0.03; // a density function dependent on z-depth

    // the basic cloud of a cone is perturbed with a distortion that is dependent on its spin 
    float v=length(q.xy)-1.5-zcurve-clamp(zcurve*0.2,0.1,1.0)*snoise(spin_pos*vec3(0.1,0.1,0.1))*5.0; 

    // create ridges on the tornado
    v=v-ridged(snoise(vec3(Spin(t*1.5+0.1*q.z)*q.xy,q.z-t*4.0)*0.3))*1.2; 

    return v;
}

Le travail de création de ce type de nuanceur est complexe. Au-delà des problèmes liés à l'abstraction des opérations que vous créez, vous devez résoudre d'importants problèmes d'optimisation et de compatibilité entre plates-formes avant de pouvoir les utiliser en production.

La première partie du problème: optimiser ce nuanceur pour notre scène. Pour gérer ce problème, nous devions adopter une approche "sécurisée" au cas où le nuanceur allait être trop lourd. Pour ce faire, nous avons composé le nuanceur de tornade avec une résolution d'échantillon différente de celle du reste de la scène. Elle provient du fichier stormTest.coffee (oui, c'était un test !).

Nous commençons par utiliser un RenderTarget qui correspond à la largeur et à la hauteur de la scène afin de pouvoir disposer d'une indépendance de résolution entre le nuanceur de tornade et la scène. Nous décidons ensuite de sous-échantillonner la résolution du nuanceur de tempête en fonction de la fréquence d'images que nous obtenons.

...
Line 1383
@tornadoRT = new THREE.WebGLRenderTarget( @SCENE_WIDTH, @SCENE_HEIGHT, paramsN )

... 
Line 1403 
# Change settings based on FPS
if @fpsCount > 0
    if @fpsCur < 20
        @tornadoSamples = Math.min( @tornadoSamples + 1, @MAX_SAMPLES )
    if @fpsCur > 25
        @tornadoSamples = Math.max( @tornadoSamples - 1, @MIN_SAMPLES )
    @tornadoW = @SCENE_WIDTH  / @tornadoSamples // decide tornado resWt
    @tornadoH = @SCENE_HEIGHT / @tornadoSamples // decide tornado resHt

Enfin, nous effectuons le rendu de la tornade à l'aide d'un algorithme sal2x simplifié (pour éviter l'aspect bloc) @line 1107 dans stormTest.coffee. Cela signifie que dans le pire des cas, nous finissons par avoir une tornade plus floue, mais au moins cela fonctionne sans perdre le contrôle de l'utilisateur.

L'étape suivante d'optimisation nécessite de se plonger dans l'algorithme. Le facteur de calcul pilote dans le nuanceur est l'itération effectuée sur chaque pixel pour essayer d'estimer la distance de la fonction de la surface: le nombre d'itérations de la boucle de rayonnement. En utilisant une taille de pas plus grande, nous avons pu obtenir une estimation de la surface de la tornade avec moins d'itérations alors que nous étions en dehors de sa surface nuageuse. Une fois à l'intérieur, nous réduisions la taille de pas pour plus de précision et pour pouvoir mélanger les valeurs pour créer l'effet de brume. La création d'un cylindre de délimitation pour obtenir une estimation de la profondeur du rayon projeté a permis d'accélérer le rythme.

La partie suivante du problème consistait à s'assurer que ce nuanceur s'exécuterait sur différentes cartes vidéo. Nous avons effectué quelques tests à chaque fois et avons commencé à comprendre le type de problèmes de compatibilité que nous pourrions rencontrer. Si nous ne pouvions pas faire mieux que l'intuition, c'est parce que nous ne pouvions pas toujours obtenir de bonnes informations de débogage sur les erreurs. Un scénario typique est simplement une erreur de GPU avec peu d'autres tâches à effectuer, ou même un plantage du système.

Les problèmes de compatibilité multi-écrans avaient des solutions similaires: veillez à saisir les constantes statiques avec le type de données défini, IE: 0,0 pour float et 0 pour int.Soyez prudent lorsque vous écrivez des fonctions plus longues. Il est préférable de diviser les éléments en plusieurs fonctions simples et variables provisoires, car les compilateurs semblaient ne pas gérer correctement certains cas. Assurez-vous que les textures sont toutes une puissance de 2, qu'elles ne sont pas trop grandes et, dans tous les cas, faites preuve de "prudence" lorsque vous recherchez des données de texture dans une boucle.

Les principaux problèmes de compatibilité que nous avions rencontrés étaient liés à l'effet d'éclairage de la tempête. Nous avons utilisé une texture préfabriquée enveloppée autour de la tornade afin de pouvoir colorer ses traits. C'était un effet magnifique qui a facilité l'intégration de la tornade dans les couleurs de la scène, mais il a fallu beaucoup de temps pour essayer de courir sur d'autres plateformes.

tornade

Site Web pour mobile

L'expérience sur mobile ne pouvait pas être une traduction directe de la version classique, car les exigences en termes de technologie et de traitement étaient trop lourdes. Nous devions créer quelque chose de nouveau, ciblant spécifiquement les mobinautes.

Nous avons pensé qu'il serait intéressant que la cabine photo du carnaval soit disponible depuis un ordinateur sous la forme d'une application Web mobile utilisant l'appareil photo du mobile de l'utilisateur. Quelque chose que nous n'avions pas vu se concrétiser jusqu'à présent.

Pour plus de saveur, nous avons codé les transformations 3D en CSS3. En l'associant au gyroscope et à l'accéléromètre, nous avons pu approfondir l'expérience. Le site réagit à la façon dont vous tenez, déplacez et regardez votre téléphone.

En rédigeant cet article, nous avons pensé qu'il serait intéressant de vous donner quelques conseils pour vous aider à exécuter le processus de développement pour mobile sans problème. Et voilà ! Allez-y et voyez ce que vous pouvez en apprendre !

Conseils et astuces pour les mobiles

Le pré-chargeur est un élément nécessaire et non évité. Nous savons que c'est parfois le dernier cas. Cela est principalement dû au fait que vous devez continuer à maintenir la liste des éléments que vous préchargez au fur et à mesure que votre projet se développe. Pire encore, nous ne savons pas très bien comment calculer la progression du chargement si vous extrayez différentes ressources, et beaucoup d'entre elles en même temps. C'est là que notre classe abstraite personnalisée et très générique "Task" s'avère pratique. Son idée principale est d'autoriser une structure imbriquée à l'infini dans laquelle une tâche peut avoir ses propres sous-tâches, qui peuvent avoir leurs propres sous-tâches, etc. De plus, chaque tâche calcule sa progression par rapport à la progression de ses sous-tâches (mais pas par rapport à la progression du parent). En disant que MainPreloadTask, AssetPreloadTask et TemplatePreFetchTask sont tous dérivés de Task, nous avons créé une structure qui se présente comme suit:

Préchargeur

Grâce à cette approche et à la classe Task, nous pouvons facilement connaître la progression globale (MainPreloadTask) ou simplement la progression des éléments (AssetPreloadTask) ou la progression du chargement des modèles (TemplatePreFetchTask). Progression régulière d'un fichier particulier. Pour savoir comment procéder, accédez à la classe Task à l'adresse /m/javascripts/raw/util/Task.js, et aux implémentations réelles des tâches à l'adresse /m/javascripts/preloading/task. À titre d'exemple, voici un extrait de la configuration de la classe /m/javascripts/preloading/task/MainPreloadTask.js, qui est notre wrapper de préchargement ultime:

Package('preloading.task', [
  Import('util.Task'),
...

  Class('public MainPreloadTask extends Task', {

    _public: {
      
  MainPreloadTask : function() {
        
    var subtasks = [
      new AssetPreloadTask([
        {name: 'cutout/cutout-overlay-1', ext: 'png', type: ImagePreloader.TYPE_BACKGROUND, responsive: true},
        {name: 'journey/scene1', ext: 'jpg', type: ImagePreloader.TYPE_IMG, responsive: false}, ...
...
      ]),

      new TemplatePreFetchTask([
        'page.HomePage',
        'page.CutoutPage',
        'page.JourneyToOzPage1', ...
...
      ])
    ];
    
    this._super(subtasks);

      }
    }
  })
]);

Dans la classe /m/javascripts/preloading/task/subtask/AssetPreloadTask.js, en plus d'indiquer comment elle communique avec MainPreloadTask (via l'implémentation de tâche partagée), il convient d'indiquer comment nous chargeons les éléments qui dépendent de la plate-forme. En fait, nous avons quatre types d'images. Format standard pour mobile (.ext, où l'ext correspond à l'extension de fichier, généralement .png ou .jpg), Retina pour mobile (-2x.ext), tablette standard (-tab.ext) et Retina de tablette (-tab-2x.ext). Au lieu d'effectuer la détection dans MainPreloadTask et de coder en dur quatre tableaux d'éléments, il nous suffit d'indiquer le nom et l'extension de l'élément à précharger, et si celui-ci dépend de la plate-forme (responsive = true / false). Ensuite, AssetPreloadTask génère le nom du fichier pour nous:

resolveAssetUrl : function(assetName, extension, responsive) {
  return AssetPreloadTask.ASSETS_ROOT + assetName + (responsive === true ? ((Detection.getInstance().tablet ? '-tab' : '') + (Detection.getInstance().retina ? '-2x' : '')) : '') + '.' +  extension;
}

Plus loin dans la chaîne de classes, le code réel qui effectue le préchargement des éléments se présente comme suit (/m/javascripts/raw/util/ImagePreloader.js):

loadUrl : function(url, type, completeHandler) {
  if(type === ImagePreloader.TYPE_BACKGROUND) {
    var $bg = $('<div>').hide().css('background-image', 'url(' + url + ')');
    this.$preloadContainer.append($bg);
  } else {
    var $img= $('<img />').attr('src', url).hide();
    this.$preloadContainer.append($img);
  }

  var image = new Image();
  this.cache[this.generateKey(url)] = image;
  image.onload = completeHandler;
  image.src = url;
}

generateKey : function(url) {
  return encodeURIComponent(url);
}

Tutoriel: Photo Booth HTML5 (iOS6/Android)

Lors du développement d'OZ Mobile, nous avons découvert que nous passions beaucoup de temps à jouer avec la cabine photo au lieu de travailler :D C'était simplement parce que c'était amusant. Nous avons donc réalisé une démonstration pour vous permettre de jouer.

Cabine photo mobile
Photomaton mobile

Vous pouvez voir une démonstration en direct ici (exécutez-la sur votre iPhone ou votre téléphone Android):

http://u9html5rocks.appspot.com/demos/mobile_photo_booth

Pour le configurer, vous avez besoin d'une instance d'application Google App Engine sans frais dans laquelle vous pouvez exécuter le backend. Le code de l'interface n'est pas complexe, mais il existe plusieurs problèmes potentiels. Examinons-les maintenant:

  1. Type de fichier image autorisé Nous souhaitons que les utilisateurs puissent uniquement importer des images (car il s'agit d'une cabine photo, et non d'une cabine vidéo). En théorie, il vous suffit de spécifier le filtre en HTML, comme suit : input id="fileInput" class="fileInput" type="file" name="file" accept="image/*" Toutefois, cela ne semble fonctionner que sur iOS. Nous devons donc ajouter une vérification supplémentaire par rapport à l'expression régulière une fois qu'un fichier a été sélectionné:
   this.$fileInput.fileupload({
          
   dataType: 'json',
   autoUpload : true,
   
   add : function(e, data) {
     if(!data.files[0].name.match(/(\.|\/)(gif|jpe?g|png)$/i)) {
      return self.onFileTypeNotSupported();
     }
   }
   });
  1. Annulation d'une importation ou d'une sélection de fichiers Une autre incohérence que nous avons remarquée au cours du processus de développement concerne la manière dont différents appareils notifient une sélection de fichiers annulée. Les téléphones et tablettes iOS ne font rien et n'envoient aucune notification. Aucune action particulière n'est donc nécessaire dans ce cas. Toutefois, les téléphones Android déclenchent quand même la fonction add(), même si aucun fichier n'est sélectionné. Voici comment procéder:
    add : function(e, data) {

    if(data.files.length === 0 || (data.files[0].size === 0 && data.files[0].name === "" && data.files[0].fileName === "")) {
            
    return self.onNoFileSelected();

    } else if(data.files.length > 1) {

    return self.onMultipleFilesSelected();            
    }
    }

Le reste fonctionne de manière plutôt fluide sur toutes les plates-formes. Amusez-vous bien !

Conclusion

Compte tenu de la taille considérable de Find Your Way To Oz et de la large palette de technologies impliquées, dans cet article, nous n'avons pu aborder que quelques-unes des approches que nous avons utilisées.

Si vous souhaitez découvrir l'intégralité de l'enchilada, n'hésitez pas à consulter le code source complet du livre Découvrez le chemin vers Oz en cliquant sur ce lien.

Crédits

Cliquez ici pour consulter la liste complète des crédits.

Références