Introducción
“Find Your Way to Oz” es un nuevo Experimento de Google Chrome que Disney llevó a la Web. Te permite realizar un viaje interactivo a través de un circo de Kansas, que te lleva a la tierra de Oz después de que te arrastra una gran tormenta.
Nuestro objetivo era combinar la riqueza del cine con las capacidades técnicas del navegador para crear una experiencia divertida y envolvente con la que los usuarios puedan establecer una conexión sólida.
El trabajo es demasiado grande para capturarlo en su totalidad en esta pieza, así que analizamos y extrajimos algunos capítulos de la historia de la tecnología que consideramos interesantes. A lo largo del camino, extrajimos algunos instructivos enfocados de dificultad creciente.
Muchas personas trabajaron arduamente para que esta experiencia fuera posible, demasiadas para mencionarlas aquí. Visita el sitio para consultar la página de créditos en la sección del menú y conocer la historia completa.
¿Qué ocurre detrás de escena?
Find Your Way to Oz para computadoras es un mundo inmersivo y rico. Usamos 3D y varias capas de efectos inspirados en el cine tradicional que se combinan para crear una escena casi realista. Las tecnologías más destacadas son WebGL con Three.js, sombreadores personalizados y elementos animados de DOM con funciones de CSS3. Además, la API de getUserMedia (WebRTC) para experiencias interactivas permite que el usuario agregue su imagen directamente desde la cámara web y WebAudio para obtener sonido 3D.
Pero la magia de una experiencia tecnológica como esta es cómo se combina todo. Este también es uno de los desafíos principales: ¿cómo combinar efectos visuales y elementos interactivos en una escena para crear un todo coherente? Esta complejidad visual era difícil de administrar, lo que dificultaba saber en qué etapa del desarrollo nos encontrábamos en cada momento.
Para abordar el problema de los efectos visuales interconectados y la optimización, usamos mucho un panel de control que capturara todos los parámetros de configuración relevantes que estábamos revisando en ese momento. La escena se podía ajustar en vivo en el navegador para cualquier cosa, desde el brillo hasta la profundidad de campo, la gamma, etc. Cualquier persona podía intentar modificar los valores de los parámetros significativos en la experiencia y participar en el descubrimiento de lo que funcionaba mejor.
Antes de compartir nuestro secreto, queremos advertirte que podría fallar, al igual que si hurgaras dentro del motor de un automóvil. Asegúrate de no tener nada importante activado y visita la URL principal del sitio. Agrega ?debug=on a la dirección. Espera a que se cargue el sitio y, una vez que estés dentro, presiona la tecla Ctrl-I
. Verás que aparece un menú desplegable en el lado derecho. Si desmarcas la opción "Salir de la ruta de la cámara", puedes usar las teclas A, W, S, D y el mouse para moverte libremente por el espacio.

No analizaremos todos los parámetros de configuración aquí, pero te recomendamos que experimentes: las teclas revelan diferentes parámetros de configuración en diferentes escenas. En la secuencia final de la tormenta, hay una clave adicional: Ctrl-A
, con la que puedes activar o desactivar la reproducción de la animación y volar. En esta escena, si presionas Esc
(para salir de la función de bloqueo del mouse) y vuelves a presionar Ctrl-I
, puedes acceder a la configuración específica de la escena de tormenta. Observa a tu alrededor y captura algunas vistas bonitas como la que se muestra a continuación.

Para lograrlo y asegurarnos de que fuera lo suficientemente flexible para nuestras necesidades, usamos una biblioteca encantadora llamada dat.gui (consulta aquí un instructivo anterior sobre cómo usarla). Nos permitió cambiar rápidamente la configuración que se mostraba a los visitantes del sitio.
Un poco como la pintura mate
En muchas películas y animaciones clásicas de Disney, crear escenas significaba combinar diferentes capas. Había capas de acción en vivo, animación en celdas, incluso sets físicos y capas superiores creadas pintando sobre vidrio: una técnica llamada pintura mate.
En muchos sentidos, la estructura de la experiencia que creamos es similar, aunque algunas de las “capas” son mucho más que imágenes estáticas. De hecho, afectan la apariencia de los elementos según cálculos más complejos. Sin embargo, al menos a nivel general, estamos tratando con vistas compuestas una sobre la otra. En la parte superior, se ve una capa de IU, con una escena 3D debajo, que a su vez está compuesta por diferentes componentes de escena.
La capa de interfaz superior se creó con DOM y CSS 3, lo que significaba que la edición de las interacciones se podía realizar de muchas maneras, independientemente de la experiencia en 3D, con comunicación entre ambas según una lista seleccionada de eventos. Esta comunicación usa Backbone Router y el evento onHashChange de HTML5 que controla qué área debe animarse dentro o fuera. (fuente del proyecto: /develop/coffee/router/Router.coffee).
Instructivo: Compatibilidad con hojas de sprites y Retina
Una técnica de optimización divertida en la que nos basamos para la interfaz fue combinar las muchas imágenes superpuestas de la interfaz en un solo PNG para reducir las solicitudes del servidor. En este proyecto, la interfaz estaba compuesta por más de 70 imágenes (sin contar las texturas en 3D) que se cargaban todas de antemano para reducir la latencia del sitio web. Puedes ver la hoja de sprites en vivo aquí:
Pantalla normal: http://findyourwaytooz.com/img/home/interface_1x.png Pantalla Retina: http://findyourwaytooz.com/img/home/interface_2x.png
Estas son algunas sugerencias sobre cómo aprovechamos el uso de las hojas de sprites y cómo usarlas para dispositivos Retina y obtener una interfaz lo más nítida y ordenada posible.
Cómo crear hojas de sprites
Para crear SpriteSheets, usamos TexturePacker, que genera archivos en cualquier formato que necesites. En este caso, exportamos como EaselJS, que es muy limpio y también se podría haber usado para crear sprites animados.
Usa la hoja de sprites generada
Una vez que hayas creado tu hoja de sprites, deberías ver un archivo JSON como este:
{
"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]
},
}
Aquí:
- image hace referencia a la URL de la hoja de sprites.
- Los marcos son las coordenadas de cada elemento de la IU [x, y, ancho, alto].
- las animaciones son los nombres de cada recurso
Ten en cuenta que usamos las imágenes de alta densidad para crear la hoja de sprites y, luego, creamos la versión normal solo cambiando el tamaño a la mitad.
Resumen
Ahora que todo está listo, solo necesitamos un fragmento de JavaScript para usarlo.
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);
};
Y así es como lo usarías:
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'));
Para obtener más información sobre las densidades de píxeles variables, puedes leer este artículo de Boris Smus.
La canalización de contenido 3D
La experiencia del entorno se configura en una capa WebGL. Cuando piensas en una escena en 3D, una de las preguntas más difíciles es cómo te asegurarás de poder crear contenido que permita el máximo potencial expresivo en cuanto a modelado, animación y efectos. En muchos sentidos, el problema radica en la canalización de contenido: un proceso acordado para crear contenido para la escena en 3D.
Queríamos crear un mundo impresionante, por lo que necesitábamos un proceso sólido que permitiera a los artistas 3D crearlo. Se les debería dar la mayor libertad expresiva posible en su software de modelado y animación en 3D, y tendríamos que renderizarlo en pantalla a través de código.
Llevamos tiempo trabajando en este tipo de problemas porque, cada vez que creábamos un sitio 3D, encontrábamos limitaciones en las herramientas que podíamos usar. Así que creamos esta herramienta, llamada 3D Librarian, que es una investigación interna. Y estaba casi listo para aplicarse a un trabajo real.
Esta herramienta tenía un historial: originalmente, era para Flash y te permitía incorporar una gran escena de Maya como un solo archivo comprimido optimizado para descomprimir el entorno de ejecución. La razón por la que fue óptima fue porque empaquetó la escena de manera eficaz en básicamente la misma estructura de datos que se manipula durante la renderización y la animación. Cuando se carga el archivo, se debe analizar muy poco. La descompresión en Flash fue bastante rápida porque el archivo estaba en formato AMF, que Flash podía descomprimir de forma nativa. El uso del mismo formato en WebGL requiere un poco más de trabajo en la CPU. De hecho, tuvimos que volver a crear una capa de código de JavaScript para descomprimir datos, que básicamente descomprimiría esos archivos y recrearía las estructuras de datos necesarias para que WebGL funcione. Descomprimir toda la escena 3D es una operación que consume bastante CPU: descomprimir la escena 1 en Find Your Way To Oz requiere alrededor de 2 segundos en una máquina de gama media a alta. Por lo tanto, esto se hace con la tecnología de Web Workers, en el momento de la "configuración de la escena" (antes de que se inicie la escena), para no colgar la experiencia del usuario.
Esta práctica herramienta puede importar la mayor parte de la escena 3D: modelos, texturas y animaciones de huesos. Creas un solo archivo de biblioteca que el motor 3D puede cargar. En esta biblioteca, puedes incluir todos los modelos que necesites en tu escena y, voilà, crearlos en ella.
Sin embargo, teníamos un problema: ahora estábamos trabajando con WebGL, el nuevo chico en el bloque. Era un chico bastante difícil: establecía el estándar para las experiencias 3D basadas en el navegador. Por lo tanto, creamos una capa ad hoc de JavaScript que tomara los archivos de escenas 3D comprimidos de 3D Librarian y los tradujera correctamente a un formato que WebGL pudiera entender.
Instructivo: Let There Be Wind
Un tema recurrente en “Find Your Way To Oz” fue el viento. Un hilo de la historia está estructurado para ser un crescendo de viento.
La primera escena del carnaval es relativamente tranquila. A medida que avanza por las distintas escenas, el usuario experimenta un viento cada vez más fuerte, que culmina en la escena final, la tormenta.
Por lo tanto, era importante proporcionar un efecto de viento envolvente.
Para crear esto, propagamos las 3 escenas de carnaval con objetos que eran suaves y, por lo tanto, se suponía que se verían afectados por el viento, como carpas, banderas, la superficie de la cabina de fotos y el propio globo.

En la actualidad, los juegos para computadoras de escritorio suelen compilarse en torno a un motor de física principal. Por lo tanto, cuando se debe simular un objeto flexible en el mundo 3D, se ejecuta una simulación de física completa para crear un comportamiento flexible creíble.
En WebGL o JavaScript, aún no tenemos el lujo de ejecutar una simulación física completa. En Oz, tuvimos que encontrar una forma de crear el efecto del viento, sin simularlo.
Incorporamos la información de "sensibilidad al viento" para cada objeto en el modelo 3D. Cada vértice del modelo 3D tenía un “atributo de viento” que especificaba en qué medida se suponía que ese vértice se vería afectado por el viento. Por lo tanto, esta sensibilidad al viento especificada de los objetos 3D. Luego, tuvimos que crear el viento.
Para ello, generamos una imagen que contiene ruido de Perlin. El objetivo de esta imagen es cubrir un área determinada de viento. Por lo tanto, una buena forma de pensar en ella es imaginar una imagen de ruido similar a una nube que se coloca sobre un área rectangular determinada de la escena en 3D. Cada píxel, valor de nivel de gris, de esta imagen especifica qué tan fuerte es el viento en un momento determinado en el área 3D que lo “rodea”.
Para producir el efecto de viento, la imagen se mueve, en el tiempo, a una velocidad constante, en una dirección específica: la dirección del viento. Y para asegurarnos de que el "área con viento" no afecte a todo en la escena, unimos la imagen del viento alrededor de los bordes, confinada al área de efecto.
Instructivo sencillo sobre el viento en 3D
Ahora, crearemos el efecto del viento en una escena 3D simple en Three.js.
Crearemos viento en un “campo de hierba procedural” simple.
Primero, crearemos la escena. Tendremos un terreno plano simple con textura. Y cada parte de la hierba se representará con un cono 3D invertido.

A continuación, te mostramos cómo crear esta escena simple en Three.js con CoffeeScript.
En primer lugar, configuraremos Three.js y lo conectaremos con la cámara, el controlador del mouse y alguna luz:
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()
Las llamadas a las funciones initGrass y initTerrain propagan la escena con hierba y terreno, respectivamente:
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
Aquí, creamos una cuadrícula de 15 por 15 fragmentos de hierba. Agregamos un poco de aleatoriedad a cada posición de hierba para que no se alineen como soldados, lo que se vería extraño.
Este terreno es solo un plano horizontal, ubicado en la base de los trozos de hierba (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 )
Hasta ahora, lo que hicimos fue crear una escena de Three.js y agregar algunos trozos de hierba, hechos de conos invertidos generados de forma procedural, y un terreno simple.
Hasta ahora, nada sofisticado.
Ahora es momento de comenzar a agregar viento. En primer lugar, queremos incorporar la información de sensibilidad al viento en el modelo 3D de hierba.
Incrustaremos esta información como un atributo personalizado para cada vértice del modelo 3D de hierba. Y usaremos la regla que dice que el extremo inferior del modelo de hierba (la punta del cono) tiene cero sensibilidad, ya que está unido al suelo. La parte superior del modelo de hierba (base del cono) tiene la máxima sensibilidad al viento, ya que es la parte más alejada del suelo.
A continuación, se muestra cómo se vuelve a codificar la función instanceGrass para agregar la sensibilidad al viento como un atributo personalizado para el modelo 3D de hierba.
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
Ahora usamos un material personalizado, windMaterial, en lugar del MeshPhongMaterial que usábamos anteriormente. WindMaterial une el WindMeshShader que veremos en un momento.
Por lo tanto, el código en instanceGrass itera por todos los vértices del modelo de hierba y, para cada uno, agrega un atributo de vértice personalizado, llamado windFactor. Este windFactor se establece en 0 para el extremo inferior del modelo de hierba (donde se supone que debe tocar el terreno) y tiene un valor de 1 para el extremo superior del modelo de hierba.
El otro ingrediente que necesitamos es agregar el viento real a nuestra escena. Como se analizó, usaremos el ruido de Perlin para esto. Generaremos de forma procedural una textura de ruido de Perlin.
Para mayor claridad, asignaremos esta textura al terreno en lugar de la textura verde anterior. Esto te permitirá tener una idea más clara de lo que sucede con el viento.
Por lo tanto, esta textura de ruido de Perlin cubrirá espacialmente la extensión de nuestro terreno, y cada píxel de la textura especificará la intensidad del viento del área del terreno donde cae ese píxel. El rectángulo del terreno será nuestra “área de viento”.
El ruido de Perlin se genera de forma procedural a través de un sombreador llamado NoiseShader. Este sombreador usa algoritmos de ruido simplex en 3D de https://github.com/ashima/webgl-noise . La versión de WebGL se tomó textualmente de uno de los ejemplos de Three.js de MrDoob, en http://mrdoob.github.com/three.js/examples/webgl_terrain_dynamic.html.
NoiseShader toma un tiempo, una escala y un conjunto de parámetros de offset como uniformes y genera una buena distribución 2D del ruido 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) }
...
Usaremos este sombreador para renderizar nuestro ruido de Perlin en una textura. Esto se hace en la función 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 )
Lo que hace el código anterior es configurar noiseMap como un destino de renderización de Three.js, equiparlo con NoiseShader y, luego, renderizarlo con una cámara ortográfica para evitar distorsiones de perspectiva.
Como se mencionó, ahora también usaremos esta textura como la textura de renderización principal del terreno. Esto no es realmente necesario para que funcione el efecto de viento. Sin embargo, es bueno tenerlo para que podamos comprender mejor visualmente lo que sucede con la generación eólica.
Esta es la función initTerrain modificada, que usa noiseMap como textura:
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 )
Ahora que tenemos nuestra textura de viento en su lugar, echemos un vistazo a WindMeshShader, que se encarga de deformar los modelos de hierba según el viento.
Para crear este sombreador, partimos del sombreador estándar MeshPhongMaterial de Three.js y lo modificamos. Esta es una buena forma rápida y no muy precisa de comenzar con un sombreador que funcione, sin tener que empezar desde cero.
No copiaremos todo el código del sombreador aquí (no dudes en consultarlo en el archivo de código fuente), ya que la mayor parte sería una réplica del sombreador MeshPhongMaterial. Pero veamos las partes modificadas relacionadas con el viento en el sombreador de vértices.
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;
Por lo tanto, lo que hace este sombreador es, primero, calcular la coordenada de búsqueda de textura windUV, según la posición 2D, xz (horizontal) del vértice. Esta coordenada UV se usa para buscar la fuerza del viento, vWindForce, de la textura del viento de ruido de Perlin.
Este valor de vWindForce se combina con el atributo personalizado windFactor específico del vértice, que se analizó anteriormente, para calcular la cantidad de deformación que necesita el vértice. También tenemos un parámetro global windScale para controlar la intensidad general del viento y un vector windDirection que especifica en qué dirección debe producirse la deformación del viento.
Esto crea una deformación basada en el viento de nuestras piezas de hierba. Sin embargo, aún no terminamos. Tal como está ahora, esta deformación es estática y no transmitirá el efecto de un área con viento.
Como mencionamos, tendremos que deslizar la textura del ruido con el tiempo, a través del área del viento, para que nuestro vidrio pueda ondear.
Para ello, se desplaza con el tiempo el uniforme vOffset que se pasa al NoiseShader. Este es un parámetro vec2 que nos permitirá especificar el desplazamiento del ruido a lo largo de una dirección determinada (nuestra dirección del viento).
Lo hacemos en la función render, a la que se llama en cada fotograma:
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
...
¡Y eso es todo! Acabamos de crear una escena con “hierba procedural” afectada por el viento.
Agrega a la mezcla
Ahora, le daremos un poco más de vida a nuestra escena. Agreguemos un poco de en el aire para que la escena sea más interesante.

Después de todo, se supone que el se ve afectado por el viento, por lo que tiene mucho sentido que haya volando en nuestra escena de viento.
El se configura en la función initDust como un sistema de partículas.
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
Aquí se crean 130 partículas de. Además, ten en cuenta que cada uno de ellos está equipado con un WindParticleShader especial.
Ahora, en cada fotograma, moveremos un poco las partículas con CoffeeScript, independientemente del viento. Este es el código.
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 )
Además, compensaremos cada posición de partícula según el viento. Esto se hace en WindParticleShader. Específicamente, en el sombreador de vértices.
El código de este sombreador es una versión modificada de ParticleMaterial de Three.js, y así es como se ve su núcleo:
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;
Este sombreador de vértices no es muy diferente del que teníamos para la deformación del césped basada en el viento. Toma la textura de ruido de Perlin como entrada y, según la posición del mundo de, busca un valor vWindForce en la textura de ruido. Luego, usa este valor para modificar la posición de la partícula de.
Riders On The Storm
La más aventurera de nuestras escenas de WebGL fue probablemente la última, que puedes ver si haces clic en el globo para entrar en el ojo del tornado y llegar al final de tu recorrido en el sitio, y el video exclusivo de la próxima versión.

Cuando creamos esta escena, sabíamos que necesitábamos tener un elemento central en la experiencia que fuera impactante. El tornado giratorio actuaría como elemento central y las capas de otro contenido moldearían esta función para crear un efecto dramático. Para lograrlo, creamos lo que sería el equivalente de un estudio de cine ambientado en este sombreador extraño.
Usamos un enfoque mixto para crear el compuesto realista. Algunos eran trucos visuales, como formas de luz para crear un efecto de destello de lente o gotas de lluvia que se animaban como capas sobre la escena que observabas. En otros casos, teníamos superficies planas dibujadas para que parecieran moverse, como las capas de nubes que vuelan bajo que se mueven según un código de sistema de partículas. Mientras que los fragmentos de escombros que orbitan alrededor del tornado eran capas de una escena en 3D ordenadas para moverse delante y detrás del tornado.
El motivo principal por el que tuvimos que compilar la escena de esta manera fue para asegurarnos de tener suficiente GPU para controlar el sombreador de tornado en equilibrio con los otros efectos que estábamos aplicando. Al principio, tuvimos grandes problemas de equilibrio de la GPU, pero más tarde esta escena se optimizó y se volvió más liviana que las escenas principales.
Instructivo: El sombreador de tormenta
Para crear la secuencia final de la tormenta, se combinaron muchas técnicas diferentes, pero el elemento central de este trabajo fue un sombreador GLSL personalizado que parece un tornado. Probamos muchas técnicas diferentes, desde sombreadores de vértices para crear remolinos geométricos interesantes hasta animaciones basadas en partículas y hasta animaciones en 3D de formas geométricas retorcidas. Ninguno de los efectos parecía recrear la sensación de un tornado ni requirió demasiado procesamiento.
Un proyecto completamente diferente nos proporcionó la respuesta. Un proyecto paralelo que involucraba juegos para la ciencia con el objetivo de mapear el cerebro del ratón del Instituto Max Planck (brainflight.org) generó efectos visuales interesantes. Logramos crear películas del interior de una neurona de ratón con un sombreador volumétrico personalizado.

Descubrimos que el interior de una célula cerebral se parecía un poco al embudo de un tornado. Y como usábamos una técnica volumétrica, sabíamos que podíamos ver este sombreador desde todas las direcciones del espacio. Podríamos configurar la renderización del sombreador para que se combine con la escena de la tormenta, en especial si está intercalada entre capas de nubes y sobre un fondo dramático.
La técnica de sombreadores implica un truco que básicamente usa un solo sombreador GLSL para renderizar un objeto completo con un algoritmo de renderización simplificado llamado renderización de marcha de rayos con un campo de distancia. En esta técnica, se crea un sombreador de píxeles que estima la distancia más cercana a una superficie para cada punto de la pantalla.
Puedes encontrar una buena referencia al algoritmo en la descripción general de iq: Rendering Worlds With Two Triangles - Iñigo Quilez. También puedes explorar la galería de sombreadores en glsl.heroku.com, donde encontrarás muchos ejemplos de esta técnica con los que puedes experimentar.
El corazón del sombreador comienza con la función principal, configura las transformaciones de la cámara y entra en un bucle que evalúa repetidamente la distancia a una superficie. La llamada RaytraceFoggy( direction_vector, max_iterations, color, color_multiplier ) es donde se produce el cálculo principal de marcha de rayos.
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
}
La idea es que, a medida que avanzamos en la forma del tornado, agregamos regularmente contribuciones de color al valor de color final del píxel, así como contribuciones a la opacidad a lo largo del rayo. Esto crea una calidad suave en capas para la textura del tornado.
El siguiente aspecto principal del tornado es la forma real que se crea a partir de la composición de varias funciones. En primer lugar, es un cono que se compone con ruido para crear un borde orgánico áspero y, luego, se tuerce a lo largo de su eje principal y se rota en el tiempo.
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;
}
El trabajo que implica crear este tipo de sombreador es complicado. Además de los problemas relacionados con la abstracción de las operaciones que creas, existen problemas graves de optimización y compatibilidad multiplataforma que debes rastrear y resolver antes de poder usar el trabajo en producción.
La primera parte del problema: optimizar este sombreador para nuestra escena. Para solucionar este problema, necesitábamos tener un enfoque “seguro” en caso de que el sombreador fuera demasiado pesado. Para ello, compusimos el sombreador de tornado en una resolución de muestreo diferente del resto de la escena. Esto es del archivo stormTest.coffee (sí, esto fue una prueba).
Comenzamos con un renderTarget que coincide con el ancho y la altura de la escena para que podamos tener independencia de la resolución del sombreador de tornado a la escena. Luego, decidimos la reducción de la resolución del sombreador de tormenta de forma dinámica en función de la velocidad de fotogramas que obtenemos.
...
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
Por último, renderizamos el tornado en la pantalla con un algoritmo sal2x simplificado (para evitar el aspecto en bloques) en la línea 1107 de stormTest.coffee. Esto significa que, en el peor de los casos, terminaremos teniendo un tornado más desenfocado, pero al menos funcionará sin quitarle el control al usuario.
El siguiente paso de optimización requiere analizar el algoritmo. El factor de procesamiento principal en el sombreador es la iteración que se realiza en cada píxel para intentar aproximar la distancia de la función de superficie: la cantidad de iteraciones del bucle de raymarching. Si usáramos un tamaño de paso más grande, podríamos obtener una estimación de la superficie del tornado con menos iteraciones mientras estuviéramos fuera de su superficie nublada. Cuando estamos dentro, disminuimos el tamaño del paso para obtener precisión y poder mezclar valores para crear el efecto de neblina. Además, crear un cilindro de límite para obtener una estimación de profundidad para el rayo proyectado brindó una buena aceleración.
La siguiente parte del problema era asegurarse de que este sombreador se ejecutara en diferentes tarjetas de video. Cada vez, realizábamos algunas pruebas y comenzamos a tener una idea del tipo de problemas de compatibilidad que podríamos encontrar. El motivo por el que no pudimos hacer mucho mejor que la intuición es que no siempre pudimos obtener buena información de depuración sobre los errores. Una situación típica es un error de GPU con poco más que hacer o incluso una falla del sistema.
Los problemas de compatibilidad entre las tarjetas de video tenían soluciones similares: asegúrate de ingresar constantes estáticas del tipo de datos preciso como se define, p. ej., 0.0 para flotante y 0 para int. Ten cuidado cuando escribas funciones más largas; es preferible dividirlas en varias funciones más simples y variables intermedias, ya que los compiladores parecían no controlar ciertos casos correctamente. Asegúrate de que todas las texturas sean una potencia de 2, no demasiado grandes y, en cualquier caso, ten “precaución” cuando busques datos de textura en un bucle.
Los mayores problemas de compatibilidad que tuvimos fueron por el efecto de iluminación de la tormenta. Usamos una textura prediseñada alrededor del tornado para poder colorear sus filamentos. Era un efecto magnífico y facilitaba la combinación del tornado con los colores de la escena, pero tardaba mucho tiempo en intentar ejecutarse en otras plataformas.

El sitio web móvil
La experiencia para dispositivos móviles no podía ser una traducción directa de la versión para computadoras de escritorio porque los requisitos de tecnología y procesamiento eran demasiado pesados. Tuvimos que crear algo nuevo, que se orientara específicamente a los usuarios de dispositivos móviles.
Pensamos que sería genial tener el Photo-Booth de Carnival desde una computadora de escritorio como una aplicación web móvil que usaría la cámara del dispositivo móvil del usuario. Algo que no habíamos visto hasta el momento.
Para agregar estilo, codificamos las transformaciones 3D en CSS3. Después de vincularlo con el giroscopio y el acelerómetro, pudimos agregar mucha profundidad a la experiencia. El sitio responde a la forma en que sostienes, mueves y miras el teléfono.
Cuando escribimos este artículo, pensamos que sería conveniente darte algunas sugerencias para que puedas ejecutar el proceso de desarrollo para dispositivos móviles sin problemas. ¡Aquí están! Continúa y descubre qué puedes aprender de ella.
Sugerencias y trucos para dispositivos móviles
El cargador previo es necesario, no algo que se deba evitar. Sabemos que, a veces, sucede lo último. Esto se debe principalmente a que debes seguir manteniendo la lista de elementos que precargas a medida que tu proyecto crece. Lo que es peor, no está muy claro cómo debes calcular el progreso de carga si extraes diferentes recursos, y muchos de ellos al mismo tiempo. Aquí es donde resulta útil nuestra clase abstracta personalizada y muy genérica “Task”. Su idea principal es permitir una estructura anidada sin fin en la que una tarea puede tener sus propias subtareas, que pueden tener sus etc. Además, cada tarea calcula su progreso en relación con el progreso de sus subtareas (pero no con el progreso de la tarea superior). Si hacemos que MainPreloadTask, AssetPreloadTask y TemplatePreFetchTask deriven de Task, creamos una estructura que se ve de la siguiente manera:

Gracias a este enfoque y a la clase Task, podemos conocer fácilmente el progreso global (MainPreloadTask), solo el progreso de los recursos (AssetPreloadTask) o el progreso de la carga de plantillas (TemplatePreFetchTask). Incluso el progreso de un archivo en particular. Para ver cómo se hace, consulta la clase Task en /m/javascripts/raw/util/Task.js y las implementaciones de tareas reales en /m/javascripts/preloading/task. A modo de ejemplo, este es un extracto de cómo configuramos la clase /m/javascripts/preloading/task/MainPreloadTask.js, que es nuestro wrapper de carga previa definitivo:
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);
}
}
})
]);
En la clase /m/javascripts/preloading/task/subtask/AssetPreloadTask.js, además de observar cómo se comunica con MainPreloadTask (a través de la implementación compartida de Task), también vale la pena observar cómo cargamos los recursos que dependen de la plataforma. Básicamente, tenemos cuatro tipos de imágenes. Estándar para dispositivos móviles (.ext, donde ext es la extensión del archivo, por lo general, .png o .jpg), retina para dispositivos móviles (-2x.ext), estándar para tablets (-tab.ext) y retina para tablets (-tab-2x.ext). En lugar de realizar la detección en MainPreloadTask y codificar de forma fija cuatro arrays de recursos, solo indicamos el nombre y la extensión del recurso que se precargará y si depende de la plataforma (responsive = true / false). Luego, AssetPreloadTask generará el nombre del archivo por nosotros:
resolveAssetUrl : function(assetName, extension, responsive) {
return AssetPreloadTask.ASSETS_ROOT + assetName + (responsive === true ? ((Detection.getInstance().tablet ? '-tab' : '') + (Detection.getInstance().retina ? '-2x' : '')) : '') + '.' + extension;
}
Más abajo en la cadena de clases, el código real que realiza la carga previa de recursos se ve de la siguiente manera (/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);
}
Instructivo: Photo Booth HTML5 (iOS6/Android)
Cuando desarrollamos OZ para dispositivos móviles, descubrimos que pasábamos mucho tiempo jugando con la cabina de fotos en lugar de trabajar :D. Simplemente porque es divertido. Por lo tanto, creamos una demostración para que la pruebes.

Puedes ver una demostración en vivo aquí (ejecutala en tu teléfono iPhone o Android):
http://u9html5rocks.appspot.com/demos/mobile_photo_booth
Para configurarlo, necesitas una instancia de aplicación gratuita de Google App Engine en la que puedas ejecutar el backend. El código del frontend no es complejo, pero hay algunos posibles problemas. Veamos cuáles son:
- Tipos de archivos de imagen permitidos
Queremos que las personas solo puedan subir imágenes (ya que es un fotomatón, no un videomatón). En teoría, puedes especificar el filtro en HTML, de la siguiente manera:
input id="fileInput" class="fileInput" type="file" name="file" accept="image/*"
Sin embargo, parece que solo funciona en iOS, por lo que debemos agregar una verificación adicional a la RegExp una vez que se haya seleccionado un archivo:
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();
}
}
});
- Cancelación de una carga o selección de archivos Otra incoherencia que notamos durante el proceso de desarrollo es la forma en que los diferentes dispositivos notifican una selección de archivos cancelada. Los teléfonos y las tablets iOS no hacen nada, no notifican nada. Por lo tanto, no necesitamos ninguna acción especial para este caso. Sin embargo, los teléfonos Android activan la función add() de todos modos, incluso si no se selecciona ningún archivo. Sigue estos pasos para hacerlo:
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();
}
}
El resto funciona bastante bien en todas las plataformas. Diviértete.
Conclusión
Debido al gran tamaño de Find Your Way To Oz y a la amplia combinación de diferentes tecnologías involucradas, en este artículo solo pudimos abordar algunos de los enfoques que usamos.
Si tienes curiosidad por explorar toda la enchilada, no dudes en consultar el código fuente completo de Find Your Way To Oz en este vínculo.
Créditos
Haz clic aquí para ver la lista completa de créditos.
Referencias
- CoffeeScript: http://coffeescript.org/
- Backbone.js: http://backbonejs.org/
- Three.js: http://mrdoob.github.com/three.js/
- Max Planck Institute (brainflight.org) - http://brainflight.org/