Introduction
Find Your Way to Oz est un nouvel test Google Chrome sur le Web, proposé par Disney. Il vous permet de faire un voyage interactif dans un cirque du Kansas, qui vous mène au pays d'Oz après avoir été emporté par une énorme tempête.
Notre objectif était de combiner la richesse du cinéma et les fonctionnalités techniques du navigateur pour créer une expérience immersive et amusante avec laquelle les utilisateurs peuvent s'identifier.
La tâche est un peu trop vaste pour être traitée dans son intégralité dans cet article. Nous avons donc plongé dans l'histoire de la technologie et sélectionné certains chapitres qui nous semblent intéressants. Nous avons ainsi extrait des tutoriels ciblés de difficulté croissante.
De nombreuses personnes ont travaillé dur pour rendre cette expérience possible. Il y en a trop pour les citer ici. Pour en savoir plus, accédez au site et consultez la page des crédits dans la section du menu.
Dans les coulisses
Find Your Way to Oz sur ordinateur est un monde immersif et riche. Nous utilisons la 3D et plusieurs couches d'effets inspirés du cinéma traditionnel qui se combinent pour créer une scène quasi réaliste. Les technologies les plus utilisées sont WebGL avec Three.js, les nuanceurs personnalisés et les éléments animés DOM à l'aide des fonctionnalités CSS3. En outre, l'API getUserMedia (WebRTC) pour les expériences interactives permet à l'utilisateur d'ajouter son image directement depuis la webcam et WebAudio pour le son 3D.
Mais la magie d'une expérience technologique comme celle-ci réside dans la façon dont elle se met en place. C'est aussi l'un des principaux défis: comment mélanger des effets visuels et des éléments interactifs dans une même scène pour créer un ensemble cohérent ? Cette complexité visuelle était difficile à gérer, car il était difficile de savoir à quelle étape du développement nous nous trouvions à un moment donné.
Pour résoudre le problème d'optimisation et d'interconnexion des effets visuels, nous avons beaucoup utilisé un panneau de contrôle qui capturait tous les paramètres pertinents que nous examinions à ce moment-là. La scène pouvait être ajustée en direct dans le navigateur pour tout ce qui concerne la luminosité, la profondeur de champ, le gamma, etc. Tout le monde pouvait essayer d'ajuster les valeurs des paramètres importants de l'expérience et découvrir ce qui fonctionnait le mieux.
Avant de vous révéler notre secret, nous souhaitons vous prévenir qu'il peut planter, comme si vous fouillaviez le moteur d'une voiture. Assurez-vous que vous n'avez rien d'important en cours, puis accédez à l'URL principale du site et ajoutez ?debug=on à l'adresse. Attendez que le site se charge. Une fois que vous y êtes, appuyez sur la touche Ctrl-I
. Un menu déroulant s'affiche alors sur la droite. Si vous décochez l'option "Quitter le parcours de la caméra", vous pouvez utiliser les touches A, W, S, D et la souris pour vous déplacer librement dans l'espace.

Nous ne détaillerons pas tous les paramètres ici, mais nous vous encourageons à les tester: les clés révèlent différents paramètres dans différentes scènes. Dans la séquence finale de l'orage, il existe une touche supplémentaire: Ctrl-A
. Elle vous permet d'activer ou de désactiver la lecture de l'animation et de voler autour de l'écran. Dans cette scène, si vous appuyez sur Esc
(pour quitter la fonctionnalité de verrouillage de la souris) et sur Ctrl-I
à nouveau, vous pouvez accéder aux paramètres spécifiques à la scène d'orage. Regardez autour de vous et prenez des photos de belles vues comme celle ci-dessous.

Pour y parvenir et nous assurer qu'il était suffisamment flexible pour répondre à nos besoins, nous avons utilisé une excellente bibliothèque appelée dat.gui (cliquez ici pour consulter un ancien tutoriel sur son utilisation). Cela nous a permis de modifier rapidement les paramètres exposés aux visiteurs du site.
Un peu comme la peinture matte
Dans de nombreux films et animations Disney classiques, la création de scènes impliquait de combiner différentes couches. Il y avait des couches d'images réelles, d'animation sur celluloïd, même des décors physiques et des couches supérieures créées en peignant sur du verre, une technique appelée "matte-painting".
À bien des égards, la structure de l'expérience que nous avons créée est similaire, même si certaines des "couches" sont bien plus que des visuels statiques. En fait, ils affectent l'apparence des éléments en fonction de calculs plus complexes. Toutefois, au moins d'un point de vue global, nous avons affaire à des vues, superposées les unes aux autres. En haut, vous voyez une couche d'UI, 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 de DOM et de CSS 3. Cela signifie que l'édition des interactions peut être effectuée de nombreuses manières, indépendamment de l'expérience 3D, avec une communication entre les deux selon une liste d'événements sélectionnés. Cette communication utilise Backbone Router et l'événement HTML5 onHashChange qui contrôle la zone à animer. (source du projet: /develop/coffee/router/Router.coffee).
Tutoriel: feuilles d'éléments et compatibilité Retina
Une technique d'optimisation amusante que nous avons utilisée pour l'interface a consisté à combiner les nombreuses images de superposition de l'interface dans un seul fichier PNG afin de réduire les requêtes du serveur. Dans ce projet, l'interface était composée de plus de 70 images (sans compter les textures 3D) chargées toutes en amont pour réduire la latence du site Web. Vous pouvez consulter la feuille d'animation en direct ici:
Écran 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 d'éléments pour les appareils Retina et pour obtenir une interface aussi nette et soignée que possible.
Créer des feuilles d'images
Pour créer des feuilles de sprites, nous avons utilisé TexturePacker, qui produit des fichiers dans n'importe quel format dont vous avez besoin. Dans ce cas, nous avons exporté en tant que EaselJS, qui est très propre et qui aurait également pu être utilisé pour créer des sprites animés.
Utiliser la feuille d'animation générée
Une fois votre feuille d'éléments créée, vous devriez voir un fichier JSON semblable à celui-ci:
{
"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, largeur, hauteur].
- Les animations sont les noms de chaque composant.
Notez que nous avons utilisé les images haute densité pour créer la feuille d'animation, puis nous avons créé la version normale en la redimensionnant à la moitié de sa taille.
Synthèse
Maintenant que nous avons tout configuré, il ne nous reste plus qu'à utiliser un extrait JavaScript.
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);
};
Voici comment l'utiliser:
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 plus sur les densités de pixels variables, consultez cet article de Boris Smus.
Pipeline de contenu 3D
L'expérience de l'environnement est configurée sur une couche WebGL. Lorsque vous pensez à une scène 3D, l'une des questions les plus délicates est de savoir comment créer du contenu qui exploite au maximum le potentiel d'expression en termes de modélisation, d'animation et d'effets. À bien des égards, le pipeline de contenu est au cœur de ce problème. Il s'agit d'un processus convenu à suivre pour créer du contenu pour la scène 3D.
Nous voulions créer un monde fascinant. Nous avions donc besoin d'un processus solide qui permettrait aux artistes 3D de le créer. Ils doivent disposer d'une grande liberté d'expression dans leur logiciel de modélisation et d'animation 3D, et nous devons l'afficher à l'écran via du code.
Nous travaillons sur ce type de problème depuis un certain temps, car chaque fois que nous créions un site 3D, nous rencontrions des limites dans les outils que nous pouvions utiliser. Nous avons donc créé cet outil, appelé 3D Librarian, dans le cadre d'une recherche interne. Il était presque prêt à être appliqué à un vrai travail.
Cet outil a une longue histoire: à l'origine, il était destiné à Flash et permettait d'importer une grande scène Maya en tant que fichier compressé unique optimisé pour le décompression d'exécution. Il était optimal, car il empaquetait efficacement la scène dans la même structure de données que celle manipulée lors du rendu et de l'animation. L'analyse du fichier lors de son chargement est très limitée. Le décompression dans Flash était assez rapide, car le fichier était au format AMF, que Flash pouvait décompresser en mode natif. L'utilisation du même format dans WebGL nécessite un peu plus de travail du processeur. En fait, nous avons dû recréer une couche de code JavaScript de décompression de données, qui décompresserait essentiellement ces fichiers et recréerait les structures de données nécessaires au fonctionnement de WebGL. Décompresser l'intégralité de la scène 3D est une opération légèrement gourmande en CPU: décompresser la scène 1 de Find Your Way To Oz nécessite environ deux secondes sur une machine de milieu à haut de gamme. Cette opération est donc effectuée à l'aide de la technologie Web Workers, au moment de la "configuration de la scène" (avant le lancement de la scène), afin de ne pas bloquer l'expérience pour l'utilisateur.
Cet outil pratique peut importer la plupart 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. Vous insérez tous les modèles dont vous avez besoin dans votre scène dans cette bibliothèque, et voilà !
Le problème était que nous avions maintenant affaire à WebGL, le nouveau venu. C'était un défi de taille: nous définissions la norme pour les expériences 3D basées sur le navigateur. Nous avons donc créé une couche JavaScript ad hoc qui récupère les fichiers de scène 3D compressés de 3D Librarian et les traduit correctement dans un format compris par WebGL.
Tutoriel: Que le vent soit
Le thème récurrent de "Find Your Way To Oz" est le vent. Un fil de l'intrigue est structuré comme un crescendo de vent.
La première scène du carnaval est relativement calme. Au fur et à mesure des différentes scènes, l'utilisateur ressent un vent de plus en plus fort, qui culmine dans la scène finale, la tempête.
Il était donc important de proposer un effet de vent immersif.
Pour ce faire, nous avons rempli les trois scènes de carnaval d'objets souples, censés être affectés par le vent, comme des tentes, des drapeaux, la surface de la cabine photo et le ballon lui-même.

Les jeux pour ordinateur de bureau sont généralement conçus autour d'un moteur physique de base. Par conséquent, lorsqu'un objet souple doit être simulé dans le monde 3D, une simulation physique complète est exécutée pour lui, créant un comportement souple crédible.
En 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 spécifiait dans quelle mesure ce sommet devait être affecté par le vent. Il s'agit donc de la sensibilité au vent spécifiée des objets 3D. Nous avons ensuite dû créer le vent lui-même.
Pour ce faire, nous avons généré une image contenant du bruit Perlin. Cette image est destinée à couvrir une certaine "zone de vent". Il est donc judicieux d'imaginer une image de bruit semblable à un nuage appliquée sur une certaine zone rectangulaire de la scène 3D. Chaque pixel, valeur de niveau de gris, de cette image indique l'intensité du vent à un moment donné dans la zone 3D "l'entourant".
Pour produire l'effet de vent, l'image est déplacée, dans le temps, à une vitesse constante, dans une direction spécifique, celle du vent. Pour nous assurer que la "zone venteuse" n'affecte pas tout dans la scène, nous enroulons l'image du vent autour des bords, en la limitant à la zone d'effet.
Tutoriel simple sur le vent en 3D
Créons maintenant l'effet du vent dans une scène 3D simple dans Three.js.
Nous allons créer du vent dans un "champ d'herbe procédural" simple.
Commençons par créer la scène. Nous allons créer un terrain plat simple et texturé. Chaque brin d'herbe sera simplement représenté par un cône 3D à l'envers.

Voici comment créer cette scène simple dans Three.js à l'aide de CoffeeScript.
Tout d'abord, nous allons configurer Three.js et l'associer à la caméra, à la souris et à une lumière, par exemple:
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 fonction initGrass et initTerrain remplissent la scène avec de l'herbe et du 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 x 15 bits d'herbe. Nous ajoutons un peu de randomisation à chaque position d'herbe afin qu'elles ne s'alignent pas comme des soldats, ce qui serait étrange.
Ce terrain n'est qu'un plan horizontal, placé à la base des brins 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, ajouté quelques brins d'herbe, constitués de cônes inversés générés de manière procédurale, et un terrain simple.
Rien d'extraordinaire pour l'instant.
Il est maintenant temps d'ajouter du vent. Tout d'abord, nous voulons intégrer les informations sur la sensibilité au vent au modèle 3D de l'herbe.
Nous allons intégrer ces informations en tant qu'attribut personnalisé, pour chaque sommet du modèle 3D de l'herbe. Nous allons utiliser la règle suivante: l'extrémité inférieure du modèle d'herbe (pointe du cône) n'a aucune sensibilité, car elle est fixée au sol. La partie supérieure du modèle d'herbe (base du cône) est la plus sensible au vent, car elle est 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 de l'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 du MeshPhongMaterial que nous utilisions précédemment. WindMaterial encapsule le WindMeshShader que nous allons voir dans une minute.
Le code de instanceGrass parcourt donc tous les sommets du modèle d'herbe et ajoute un attribut de sommet personnalisé, appelé windFactor, pour chaque sommet. Ce facteur de vent est défini sur 0 pour la partie inférieure du modèle d'herbe (où il est censé toucher le terrain) et sur 1 pour la partie supérieure du modèle d'herbe.
L'autre ingrédient dont nous avons besoin est d'ajouter le vent à notre scène. Comme indiqué, nous allons utiliser le bruit Perlin pour cela. Nous allons générer de manière procédurale une texture de bruit 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 comprendre ce qui se passe avec le vent.
Cette texture de bruit Perlin couvrira donc spatialement l'extension de notre terrain, et chaque pixel de la texture spécifiera l'intensité du vent dans la zone de terrain où se trouve ce pixel. Le rectangle du terrain va être notre "zone de vent".
Le bruit 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 https://github.com/ashima/webgl-noise . La version WebGL a été extraite textuellement de l'un des exemples Three.js de MrDoob, disponible à l'adresse http://mrdoob.github.com/three.js/examples/webgl_terrain_dynamic.html.
NoiseShader prend un temps, une échelle et un ensemble de paramètres de décalage, en tant qu'uniformes, et produit une belle distribution 2D de bruit 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 afficher notre bruit Perlin sur une texture. Cette opération est effectuée dans 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 configure noiseMap comme cible de rendu Three.js, l'équipe du NoiseShader, puis le rend avec une caméra orthographique afin d'éviter les distorsions de perspective.
Comme indiqué, nous allons maintenant utiliser cette texture également comme texture de rendu principale pour le terrain. Ce n'est pas vraiment nécessaire pour que l'effet de vent fonctionne. Mais c'est utile pour mieux comprendre visuellement ce qui se passe avec la production d'énergie éolienne.
Voici la fonction initTerrain retravaillée, qui utilise noiseMap 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 notre texture de vent est en place, examinons WindMeshShader, qui est chargé de déformer les modèles d'herbe en fonction du vent.
Pour créer ce nuanceur, nous sommes partis du nuanceur MeshPhongMaterial standard de Three.js et l'avons modifié. C'est un bon moyen rapide et efficace de commencer à utiliser un nuanceur qui fonctionne, sans avoir à repartir de zéro.
Nous ne allons pas copier l'intégralité du code du nuanceur ici (n'hésitez pas à le consulter dans le fichier de code source), car la majeure partie serait un réplique du nuanceur MeshPhongMaterial. Mais examinons les parties modifiées, liées au vent, dans le nuanceur de sommets.
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 de vent de bruit Perlin.
Cette valeur vWindForce est composée avec l'attribut personnalisé windFactor, spécifique au sommet, décrit ci-dessus, afin de calculer la quantité de déformation dont le sommet a besoin. Nous avons également un paramètre global windScale, qui permet de contrôler l'intensité globale du vent, et un vecteur windDirection, qui spécifie dans quelle direction la déformation du vent doit se produire.
Cela crée une déformation de nos brins d'herbe en fonction du vent. Mais nous n'avons pas encore terminé. Dans sa forme actuelle, cette déformation est statique et ne transmet pas l'effet d'une zone venteuse.
Comme nous l'avons indiqué, nous allons devoir faire glisser la texture de bruit au fil du temps, sur la zone de vent, pour que notre verre puisse onduler.
Pour ce faire, décalez au fil du temps l'uniforme vOffset transmis au NoiseShader. Il s'agit d'un paramètre vec2 qui nous permettra de spécifier le décalage du bruit dans une certaine direction (celle 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 de l'herbe procédurale affectée par le vent.
Ajouter de la poussière à la combinaison
Maintenant, ajoutons un peu de piquant à notre scène. Ajoutons un peu de poussière pour rendre la scène plus intéressante.

Après tout, la poussière est censée être affectée par le vent. Il est donc tout à fait logique que de la poussière vole dans notre scène de 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
130 particules de poussière sont créées. Notez que chacun d'eux est équipé d'un WindParticleShader spécial.
À chaque frame, nous allons déplacer un peu 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 )
De plus, nous allons décaler la position de chaque particule en fonction du vent. Cela se fait dans WindParticleShader. Plus précisément, dans le nuanceur de sommets.
Le code de ce nuanceur est une version modifiée de ParticleMaterial dans Three.js. Voici son noyau:
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 sommet n'est pas très différent de celui que nous avions pour la déformation de l'herbe en fonction du vent. Il utilise la texture de bruit Perlin comme entrée et, en fonction de la position de la poussière dans le monde, recherche une valeur vWindForce dans la texture de bruit. Il utilise ensuite cette valeur pour modifier la position de la particule de poussière.
Riders On The Storm
La scène la plus aventureuse de nos scènes WebGL est probablement la dernière. Pour la voir, cliquez sur le ballon et entrez dans l'œil du cyclone pour atteindre la fin de votre parcours sur le site et regarder une vidéo exclusive de la prochaine version.

Lorsque nous avons créé cette scène, nous savions que nous devions inclure une fonctionnalité centrale qui aurait un impact sur l'expérience. La tornade en rotation servirait de pièce maîtresse, et les couches d'autres contenus façonneraient cette fonctionnalité pour créer un effet dramatique. Pour ce faire, nous avons créé l'équivalent d'un décor de studio de cinéma autour de cet étrange nuanceur.
Nous avons utilisé une approche mixte pour créer le composite réaliste. Il s'agissait d'astuces visuelles, comme des formes lumineuses pour créer un effet de lumière parasite ou des gouttes de pluie qui s'animent en couches au-dessus de la scène que vous regardez. Dans d'autres cas, nous avons dessiné des surfaces planes pour qu'elles semblent bouger, comme les couches de nuages bas qui se déplacent selon un code de système de particules. Les débris qui tournent autour de la tornade étaient des calques d'une scène 3D triés 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 appliquions. Au départ, nous avons rencontré de gros problèmes d'équilibrage du GPU, mais cette scène a ensuite été optimisée et est devenue plus légère que les scènes principales.
Tutoriel: Le nuanceur d'orage
Pour créer la séquence finale de l'orage, de nombreuses techniques différentes ont été combinées, mais le point central de ce travail était un nuanceur GLSL personnalisé qui ressemble à une tornade. Nous avons essayé de nombreuses techniques différentes, des nuanceurs de sommets pour créer des tourbillons géométriques intéressants aux animations basées sur des particules, en passant par des animations 3D de formes géométriques torsadées. Aucun des effets ne semblait recréer l'impression d'une tornade ni ne nécessitait trop de traitement.
C'est finalement un projet complètement différent qui nous a fourni la réponse. Un projet parallèle impliquant des jeux pour la science afin de cartographier le cerveau de la souris de l'Institut Max Planck (brainflight.org) a généré des effets visuels intéressants. Nous avons réussi à créer des films de l'intérieur d'un neurone de souris à l'aide d'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. Étant donné que nous utilisions une technique volumétrique, nous savions que nous pouvions afficher ce nuanceur dans toutes les directions de l'espace. Nous pourrions définir le rendu du nuanceur pour qu'il se combine à la scène d'orage, en particulier s'il est pris en sandwich entre des couches de nuages et au-dessus d'un arrière-plan dramatique.
La technique de nuanceur implique un tour de passe-passe qui consiste essentiellement à utiliser un seul nuanceur GLSL pour afficher un objet entier avec un algorithme de rendu simplifié appelé "ray marching rendering" (rendu par marche de rayon) avec un champ de distance. Dans cette technique, un nuanceur de pixel est créé, qui estime la distance la plus proche d'une surface pour chaque point de l'écran.
Une bonne référence de l'algorithme se trouve dans la présentation d'iq: Rendering Worlds With Two Triangles - Iñigo Quilez (Rendu de mondes avec deux triangles – Iñigo Quilez). Vous pouvez également explorer la galerie de nuanceurs sur glsl.heroku.com. Vous y trouverez 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 à plusieurs reprises la distance à une surface. L'appel RaytraceFoggy( direction_vector, max_iterations, color, color_multiplier ) est l'endroit où le calcul de base de la marche de rayon se produit.
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 que, à mesure que nous avançons dans la forme de la tornade, nous ajoutons régulièrement des contributions de couleur à la valeur de couleur finale du pixel, ainsi que des contributions à l'opacité le long du rayon. Cela crée une texture douce en couches pour la tornade.
L'aspect essentiel 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'un cône, qui est composé de bruit pour créer une bordure organique rugueuse, puis 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;
}
La création de ce type de nuanceur est délicate. En plus des problèmes liés à l'abstraction des opérations que vous créez, vous devez identifier et résoudre de graves problèmes d'optimisation et de compatibilité multiplate-forme avant de pouvoir utiliser le travail en production.
Première partie du problème: optimiser ce nuanceur pour notre scène. Pour y remédier, nous avons dû adopter une approche "sûre" au cas où le nuanceur serait trop lourd. Pour ce faire, nous avons composé le nuanceur de tornade à une résolution d'échantillonnage différente de celle du reste de la scène. Il s'agit du fichier stormTest.coffee (oui, c'était un test !).
Nous commençons avec un renderTarget correspondant à la largeur et à la hauteur de la scène afin de pouvoir dissocier la résolution du nuanceur de tornade de la scène. Nous décidons ensuite du sous-échantillonnage de la résolution du nuanceur d'orage 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 affichons la tornade à l'écran à l'aide d'un algorithme sal2x simplifié (pour éviter l'aspect bloc) à la ligne 1107 dans stormTest.coffee. Dans le pire des cas, nous obtenons une tornade plus floue, mais au moins, elle fonctionne sans priver l'utilisateur de contrôle.
L'étape d'optimisation suivante nécessite d'examiner l'algorithme. Le facteur de calcul moteur du nuanceur est l'itération effectuée sur chaque pixel pour essayer d'approximer la distance de la fonction de surface: le nombre d'itérations de la boucle de raymarching. En utilisant une taille d'étape plus importante, nous avons pu obtenir une estimation de la surface de la tornade avec moins d'itérations lorsque nous étions en dehors de sa surface nuageuse. À l'intérieur, nous avons réduit la taille d'étape pour plus de précision et pour pouvoir mélanger les valeurs afin de créer l'effet de brume. La création d'un cylindre de délimitation pour obtenir une estimation de la profondeur du rayon généré a également permis d'accélérer le processus.
La partie suivante du problème consistait à s'assurer que ce nuanceur s'exécuterait sur différentes cartes vidéo. Nous avons effectué des tests à chaque fois et avons commencé à développer une intuition sur le type de problèmes de compatibilité que nous pourrions rencontrer. Nous n'avons pas pu faire beaucoup mieux que l'intuition, car nous n'avons pas toujours pu obtenir de bonnes informations de débogage sur les erreurs. Un scénario typique est une erreur de GPU sans autre explication, ou même un plantage du système.
Les problèmes de compatibilité entre les cartes vidéo ont été résolus de manière similaire: assurez-vous que les constantes statiques sont saisies avec le type de données précis tel que défini, par exemple: 0,0 pour float et 0 pour int.Faites attention lorsque vous écrivez des fonctions plus longues. Il est préférable de les diviser en plusieurs fonctions plus simples et en variables provisoires, car les compilateurs ne semblent pas gérer correctement certains cas. Assurez-vous que les textures sont toutes une puissance de 2, qu'elles ne sont pas trop volumineuses et, dans tous les cas, faites preuve de prudence lorsque vous recherchez des données de texture dans une boucle.
Les plus gros problèmes de compatibilité que nous avons rencontrés concernaient l'effet d'éclairage de la tempête. Nous avons utilisé une texture prédéfinie enroulée autour de la tornade pour pouvoir colorer ses volutes. C'était un effet magnifique qui permettait de mélanger facilement la tornade aux couleurs de la scène, mais il a fallu beaucoup de temps pour essayer de la faire fonctionner sur d'autres plates-formes.

Site Web mobile
L'expérience mobile ne pouvait pas être une simple traduction de la version pour ordinateur, car les exigences technologiques et de traitement étaient trop lourdes. Nous avons dû créer quelque chose de nouveau, qui ciblait spécifiquement les utilisateurs mobiles.
Nous avons pensé qu'il serait intéressant de proposer le photomaton du Carnaval sur ordinateur sous la forme d'une application Web mobile qui utiliserait la caméra de l'utilisateur. C'est quelque chose que nous n'avions jamais vu auparavant.
Pour ajouter du piquant, nous avons codé des transformations 3D en CSS3. En l'associant au gyroscope et à l'accéléromètre, nous avons pu ajouter beaucoup de profondeur à l'expérience. Le site réagit à la façon dont vous tenez, déplacez et regardez votre téléphone.
Lorsque nous avons rédigé cet article, nous avons pensé qu'il était utile de vous donner quelques conseils pour mener à bien le processus de développement mobile. Les voici ! N'hésitez pas à voir ce que vous pouvez en tirer !
Conseils et astuces pour les appareils mobiles
Le préchargeur est nécessaire, et non à éviter. Nous savons que cela peut parfois arriver. Cela est principalement dû au fait que vous devez continuer à gérer la liste des éléments que vous préchargez à mesure que votre projet se développe. Pire encore, il n'est pas très clair comment calculer la progression du chargement si vous extrayez différentes ressources, et de nombreuses d'entre elles en même temps. C'est là que notre classe abstraite personnalisée et très générique "Task" est utile. L'idée principale est de permettre une structure infiniment imbriquée où 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 à celle de ses sous-tâches (mais pas par rapport à celle de la tâche parente). En faisant dériver toutes les tâches MainPreloadTask, AssetPreloadTask et TemplatePreFetchTask de Task, nous avons créé une structure semblable à celle-ci:

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, consultez la classe Task à l'adresse /m/javascripts/raw/util/Task.js et les implémentations de tâches réelles à l'adresse /m/javascripts/preloading/task. 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 de noter comment elle communique avec MainPreloadTask (via l'implémentation de la tâche partagée), il est également intéressant de noter comment nous chargeons les éléments dépendants de la plate-forme. Il existe quatre types d'images. Standard pour mobile (.ext, où ext est l'extension de fichier, généralement .png ou .jpg), Retina pour mobile (-2x.ext), standard pour tablette (-tab.ext) et Retina pour tablette (-tab-2x.ext). Au lieu de procéder à la détection dans MainPreloadTask et de coder en dur quatre tableaux d'assets, nous indiquons simplement le nom et l'extension de l'asset à précharger, ainsi que si l'asset dépend de la plate-forme (responsive = true / false). AssetPreloadTask génère ensuite le nom du fichier:
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: Photomaton HTML5 (iOS6/Android)
Lorsque nous avons développé OZ Mobile, nous avons constaté que nous passions beaucoup de temps à jouer avec le photomaton au lieu de travailler :D Tout simplement parce que c'est amusant. Nous avons donc créé une démo pour vous.

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 sur laquelle vous pouvez exécuter le backend. Le code du front-end n'est pas complexe, mais il peut y avoir quelques pièges. Passons-les en revue:
- Type de fichier image autorisé
Nous souhaitons que les utilisateurs puissent importer uniquement des images (puisqu'il s'agit d'un photomaton, et non d'un vidéomaton). En théorie, vous pouvez simplement spécifier le filtre en HTML, comme suit :
input id="fileInput" class="fileInput" type="file" name="file" accept="image/*"
Cependant, cela ne semble fonctionner que sur iOS. Nous devons donc ajouter une vérification supplémentaire à l'RegExp 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();
}
}
});
- Annuler une importation ou une sélection de fichiers Une autre incohérence que nous avons remarquée au cours du processus de développement est la façon dont les différents appareils envoient une notification lorsqu'une sélection de fichiers est annulée. Les téléphones et tablettes iOS ne font rien, ils n'envoient aucune notification. Aucune action spéciale n'est donc requise dans ce cas. Toutefois, les téléphones Android déclenchent 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 plutôt bien sur les différentes plates-formes. Amusez-vous bien !
Conclusion
Étant donné la taille massive de Find Your Way To Oz et la grande variété de technologies impliquées, nous n'avons pu aborder que quelques-unes des approches que nous avons utilisées dans cet article.
Si vous souhaitez explorer l'intégralité de l'enchilada, n'hésitez pas à consulter le code source complet de Find Your Way To Oz à l'aide de ce lien.
Crédits
Cliquez ici pour obtenir la liste complète des crédits.
Références
- CoffeeScript : http://coffeescript.org/
- Backbone.js : http://backbonejs.org/
- Three.js : http://mrdoob.github.com/three.js/
- Institut Max Planck (brainflight.org) : http://brainflight.org/