Caso de éxito: Find Your Way to Oz

Introducción

"Encuentra tu camino a Oz" es un nuevo experimento de Google Chrome que lanzó la Web a través de Disney. Te permite realizar un viaje interactivo por un circo de Kansas, que te llevará a la tierra de Oz después de que te arrasre una gran tormenta.

Nuestro objetivo era combinar la riqueza del cine con las capacidades técnicas del navegador para crear una experiencia divertida e inmersiva con la que los usuarios pudieran establecer una conexión sólida.

El trabajo es demasiado grande para retratarlo en su totalidad, así que nos sumergimos y sacamos algunos capítulos de la historia sobre tecnología que creemos que son interesantes. A lo largo del camino, extrajimos algunos tutoriales enfocados de mayor dificultad.

Muchas personas trabajaron arduamente para que esta experiencia fuera posible: demasiadas personas para incluirlas aquí. Visita el sitio para consultar la página de créditos en la sección del menú de la historia completa.

Un vistazo más profundo

Descubre tu camino a Oz en las computadoras de escritorio es un mundo inmersivo lleno de recursos. Usamos el formato 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, los sombreadores personalizados y los elementos animados del DOM que usan funciones CSS3. Además, la API de getUserMedia (WebRTC) para experiencias interactivas permite al usuario agregar su imagen directamente desde Webcam y WebAudio para obtener sonido 3D.

Pero la magia de una experiencia tecnológica como esta es cómo se une. Este también es uno de los principales desafíos: ¿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 encontramos en un momento dado.

Para abordar el problema de la optimización y los efectos visuales interconectados, utilizamos en gran medida un panel de control que capturaba todos los parámetros de configuración relevantes que estábamos revisando en ese momento. La escena se puede ajustar en vivo en el navegador según el brillo, la profundidad de campo, la gama, etc. Cualquiera podría intentar retocar los valores de los parámetros significativos de la experiencia y participar en descubrir qué funciona mejor.

Antes de compartir nuestro secreto, queremos advertirte que podría fallar, como si estuvieras dando vueltas dentro del motor de un auto. Asegúrate de no tener ningún dato importante. Visita la URL principal del sitio y agrega ?debug=on a la dirección. Espera a que se cargue el sitio y, una vez que ingreses (¿presiona?), la clave Ctrl-I. Verás que aparece un menú desplegable a la derecha. Si desmarcas la opción “Salir de la ruta de la cámara”, puedes usar las teclas A, W, S y D, y el mouse para moverte libremente por el espacio.

Ruta de acceso a la cámara.

No revisaremos todos los parámetros de configuración aquí, pero te recomendamos que experimentes: las claves revelan diferentes parámetros de configuración en diferentes escenas. En la secuencia de tormenta final, 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, podrás acceder a los parámetros de configuración específicos de la escena de la tormenta. Echa un vistazo a los alrededores y captura algunas vistas agradables como la que se muestra a continuación.

Escena de la tormenta

Para lograrlo y asegurarnos de que fuera lo suficientemente flexible a nuestras necesidades, utilizamos una adorable biblioteca llamada dat.gui (haz clic aquí para ver un instructivo anterior sobre cómo usarla). Nos permitió cambiar rápidamente los parámetros de configuración que estaban expuestos a los visitantes del sitio.

Un poco como una pintura mate

En muchas animaciones y películas clásicas de Disney, la creación de escenas implicaba combinar diferentes capas. Había capas de acción real, animación de celdas, incluso escenarios físicos y capas superiores creadas con la pintura sobre vidrio: una técnica llamada pintura con acabado.

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 el aspecto de las cosas de acuerdo con cálculos más complejos. Sin embargo, al menos a grandes rasgos, tratamos las vistas, una sobre la otra. En la parte superior, verás una capa de la IU con una escena en 3D debajo. Esta capa contiene diferentes componentes de la escena.

La capa de interfaz superior se creó con DOM y CSS 3, lo que significaba que la edición de las interacciones podía realizarse de muchas maneras, independientemente de la experiencia 3D, con la comunicación entre los dos según una lista seleccionada de eventos. Esta comunicación utiliza el router de red troncal + el evento HTML5 onHashChange que controla qué área debe animarse de entrada/salida. (fuente del proyecto: /develop/coffee/router/Router.coffee).

Instructivo: Compatibilidad con hojas de objeto y Retina

Una técnica divertida de optimización en la que nos basamos para la interfaz era combinar las numerosas superposiciones de imágenes de interfaz en un único archivo PNG para reducir las solicitudes del servidor. En este proyecto, la interfaz constaba de más de 70 imágenes (sin contar las texturas 3D) cargadas desde el principio para reducir la latencia del sitio web. Aquí puedes ver la hoja de objetos en vivo:

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 Sprite y cómo usarlas para dispositivos con tecnología retina y lograr que la interfaz sea lo más nítida y prolija posible.

Cómo crear hojas de objeto

Para crear Hojas de cálculo de Sprite, usamos TexturePacker, que produce contenido en cualquier formato que necesites. En este caso, lo exportamos como EaselJS, que es muy claro y también se podría haber usado para crear objetos animados.

Cómo usar la hoja de Sprite generada

Cuando hayas creado la hoja de Sprite, deberías ver un archivo JSON como el siguiente:

{
   "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í:

  • imagen se refiere a la URL de la hoja de objeto
  • Los marcos son las coordenadas de cada elemento de la IU [x, y, ancho, altura].
  • las animaciones son el nombre de cada recurso

Ten en cuenta que usamos las imágenes de alta densidad para crear la hoja de Sprite y, luego, creamos la versión normal cambiando el tamaño a la mitad de su tamaño.

Resumen

Ahora que ya está todo 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 se usaría:

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, consulta este artículo de Boris Smus.

Canalización de contenido 3D

La experiencia del entorno se configura en una capa de WebGL. Cuando piensas en una escena en 3D, una de las preguntas más complicadas es cómo puedes asegurarte de crear contenido que permita el máximo potencial expresivo de los lados del modelado, la animación y los efectos. En muchos sentidos, el centro de este problema es la canalización de contenido: un proceso acordado a seguir para crear contenido para la escena 3D.

Queríamos crear un mundo increíblemente inspirador, por lo que necesitábamos un proceso sólido que permitiera a los artistas 3D crearlo. Deberían brindarles la mayor libertad expresiva posible en su software de animación y modelado en 3D, y tendríamos que renderizarlo en pantalla a través de código.

Estábamos trabajando en este tipo de problema durante algún tiempo porque cada vez que creábamos un sitio 3D, habíamos encontrado limitaciones en cuanto a las herramientas que podíamos usar. Entonces, habíamos creado esta herramienta, llamada 3D Librarian: una pieza de investigación interna. Y estaba casi listo para postularse a un trabajo real.

Esta herramienta tenía un poco de historia: originalmente era para Flash y permitía incorporar una gran escena de Maya como un único archivo comprimido que se optimizó para el desempaquetado en tiempo de ejecución. La razón por la que resultó óptima fue porque empaquetaba la escena de forma efectiva básicamente en la misma estructura de datos que se manipula durante la renderización y la animación. Se necesita muy poco análisis del archivo cuando se carga. El desempaquetado en Flash fue bastante rápido debido a que el archivo estaba en formato AMF, por lo que Flash se podía desempaquetar de forma nativa. Usar el 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 JavaScript que desempaquetaría datos, lo que, básicamente, descomprimir esos archivos y recrearía las estructuras de datos necesarias para que WebGL funcionara. Desempaquetar toda la escena 3D requiere un poco de carga de CPU: el desempaquetado de la escena 1 en Encuentra tu camino a Oz requiere aproximadamente 2 segundos en una máquina de gama media o alta. Por lo tanto, esto se hace usando tecnología de Web Workers, en el momento de la "configuración de la escena" (antes de que se lance la escena), para no bloquear la experiencia para el usuario.

Esta práctica herramienta puede importar al máximo la escena en 3D: modelos, texturas y animaciones de huesos. Creas un único archivo de biblioteca que el motor 3D puede cargar. Ponen todos los modelos que necesitan en su escena dentro de esta biblioteca y, listo, los generan en su escena.

Sin embargo, un problema que tuvimos fue que ahora lidiamos con WebGL: el nuevo niño de la cuadra. Era un niño bastante difícil, que marcaba el estándar para las experiencias 3D basadas en navegadores. Así que creamos una capa de JavaScript ad hoc que tomaría los archivos de escenas en 3D comprimidos de 3D Librarian y los tradujera correctamente a un formato que WebGL entendería.

Instructivo: LetThere Be Wind

Un tema recurrente en “Find Your Way To Oz” era el viento. Se estructura un hilo de la historia para que sea un crescendo de viento.

La primera escena del carnaval es relativamente tranquila. Al pasar 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 ello, completamos las 3 escenas de carnaval con objetos suaves que, por lo tanto, se suponía que se veían afectados por el viento, como las carpas, que marcaban la superficie de la cabina de fotos y el globo en sí.

Paño suave.

Hoy en día, los juegos de escritorio se desarrollan en torno a un motor físico básico. Así, cuando se necesita simular un objeto suave en el mundo en 3D, se ejecuta una simulación física completa para él, lo que crea un comportamiento delicado y creíble.

En WebGL / JavaScript, todavía no tenemos el lujo de ejecutar una simulación física completa. En Oz, tuvimos que encontrar la manera de crear el efecto del viento, sin simularlo realmente.

Incorporamos la información de la "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 ese vértice se suponía que estaba afectado por el viento. Por lo tanto, la sensibilidad del viento especificada en los objetos 3D. Luego, necesitábamos crear el viento mismo.

Para ello, generamos una imagen que contenga Perlin Noise. El objetivo de esta imagen es cubrir una “zona de viento” determinada. Para analizarla, podemos imaginar una imagen de una nube, como el ruido, que se coloca sobre un área rectangular determinada de la escena 3D. Cada píxel, valor de nivel de gris, de esta imagen especifica la intensidad del viento en un momento determinado en el área 3D que lo rodea.

Para producir el efecto del viento, la imagen se mueve, en el tiempo, a velocidad constante, en una dirección específica; la dirección del viento. Y para asegurarnos de que la “zona ventosa” no afecte a todo en la escena, envuelvemos la imagen del viento alrededor de los bordes, confinadas al área de efecto.

Un simple tutorial 3D de viento

Ahora crearemos el efecto del viento en una simple escena en 3D en Three.js.

Vamos a crear viento en un “campo de césped de procedimiento” simple.

Primero, creemos la escena. Vamos a tener un terreno plano y con textura simple. Y cada pedazo de césped se representará con un cono 3D al revés.

Terreno lleno de césped
Tierro lleno de pasto

A continuación, se muestra cómo crear esta escena simple en Three.js usando CoffeeScript.

Primero, configuraremos Three.js y lo conectaremos con la cámara, el controlador del mouse y algunas luces:

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 e initTerrain propagan la escena con el césped y el 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 bits de césped. Agregamos un poco de aleatorización a cada posición de césped, para que no se alineen como soldados, lo que sería extraño.

Este terreno es simplemente un plano horizontal ubicado a la base de los trozos de césped (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 )

Lo que hicimos hasta ahora fue crear una escena de Three.js y agregar algunas partes de césped, hechas de conos invertidos generados mediante procedimientos, y un terreno simple.

Nada sofisticado hasta ahora.

Ahora es el momento de empezar a agregar viento. En primer lugar, queremos incorporar la información sobre la sensibilidad del viento en el modelo 3D de césped.

Incorporaremos esta información como un atributo personalizado para cada vértice del modelo 3D césped. Vamos a usar la regla de que: el extremo inferior del modelo de césped (la punta del cono) tiene una sensibilidad cero, ya que está unido al suelo. La parte superior del modelo de césped (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 atributo personalizado para el modelo 3D de césped.

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 usamos anteriormente. WindMaterial une el WindMeshShader que veremos en un minuto.

Por lo tanto, el código de instanceGrass realiza un bucle a través de todos los vértices del modelo de césped, y para cada vértice agrega un atributo de vértice personalizado, llamado windFactor. Este factor de viento se establece en 0 para el extremo inferior del modelo de césped (donde se supone que debe tocar el terreno) y su valor es 1 para el extremo superior del modelo de césped.

El otro ingrediente que necesitamos es agregar el viento real a nuestra escena. Como dijimos, usaremos ruido Perlin para esto. Generaremos una textura de ruido Perlin de forma procedimental.

Para una mayor claridad, vamos a asignar esta textura al terreno en sí, en lugar de la textura verde anterior que tenía. De esta forma, será más fácil tener una idea de lo que sucede con el viento.

Por lo tanto, esta textura de ruido 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 de terreno será nuestra "área de viento".

El ruido de Perlin se genera a través de un sombreador, llamado NoiseShader. Este sombreador usa algoritmos de ruido simplex 3D de: https://github.com/ashima/webgl-noise . La versión de WebGL de esto se tomó literalmente de una de las muestras 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 compensación, como uniformes, y genera una buena distribución en 2D del ruido 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 )

El código anterior configura noiseMap como un objetivo de renderización de Three.js, lo equipa con el NoiseShader y, luego, lo renderiza con una cámara ortográfica para evitar distorsiones de perspectiva.

Como se explicó, ahora también vamos a usar esta textura como la principal textura de renderización para el terreno. Esto no es realmente necesario para que funcione el efecto del viento. Sin embargo, es bueno tenerla para comprender mejor qué ocurre con la generación de viento.

Esta es la función initTerrain reelaborada que usa nodeMap 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 ya implementamos nuestra textura de viento, echemos un vistazo a WindMeshShader, que es responsable de deformar los modelos de césped en función del viento.

Para crear este sombreador, comenzamos a partir del sombreador estándar Three.js MeshPhongMaterial y lo modificamos. Esta es una buena forma rápida y no sincronizada de comenzar a usar un sombreador que funciona, sin tener que empezar desde cero.

No copiaremos todo el código del sombreador aquí (no dudes en verlo en el archivo de código fuente), porque la mayor parte sería una réplica del sombreador MeshPhongMaterial. Pero veamos las partes modificadas y relacionadas con el viento en Vertex Shader.

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

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

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

mvPosition = modelViewMatrix *  pos;

Por lo tanto, lo que hace este sombreador es, primero, calcular la coordenada de búsqueda de texturas windUV, según la posición 2D, xz (horizontal) del vértice. Esta coordenada UV se usa para buscar la fuerza del viento, vWindForce, a partir de la textura del viento de ruido Perlin.

Este valor de vWindForce está compuesto por el windFactor específico de vértice, que se abordó anteriormente, para calcular cuánta deformación necesita el vértice. También tenemos un parámetro windScale global para controlar la potencia general del viento y un vector de windDirection, que especifica en qué dirección debe ocurrir la deformación del viento.

Esto crea una deformación basada en el viento de nuestros trozos de césped. Sin embargo, aún no terminamos. Tal como está ahora, esta deformación es estática y no transmitirá el efecto de un área ventosa.

Como mencionamos, vamos a tener que deslizar la textura de ruido con el tiempo, a través del área del viento, para que el vidrio pueda ondear.

Para ello, se cambia el uniforme vOffset que se pasa al NoiseShader con el paso del tiempo. Este es un parámetro vec2, que nos permitirá especificar el desplazamiento del ruido en una dirección determinada (la dirección del viento).

Esto 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 "pasado de procedimiento" afectado por el viento.

Poniendo a la mezcla

Ahora, animamos un poco nuestra escena. Agreguemos un poco de voladora para que la escena sea más interesante.

Poner
Cómo agregar

Después de todo, se supone que el se ve afectado por el viento, así que tiene mucho sentido que vuele en nuestra escena del 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. Ten en cuenta que cada uno de ellos incluye un WindParticleShader especial.

Ahora, en cada marco, nos movemos un poco por las partículas usando 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 de eso, vamos a desplazar la posición de cada 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 su núcleo es el siguiente:

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 de lo que teníamos para la deformación del césped basada en el viento. Toma la textura de ruido Perlin como entrada y, según la posición del mundo de, busca un valor vWindForce en la textura de ruido. Luego, usa ese valor para modificar la posición de la partícula de.

Pasajeros en la tormenta

La más aventurera de nuestras escenas de WebGL fue probablemente la última, que puedes ver si haces clic a través del globo hasta el ojo del tornado para llegar al final de tu viaje en el sitio y un video exclusivo del próximo lanzamiento.

Escena de paseo en globo

Cuando creamos esta escena, sabíamos que necesitábamos una característica central de la experiencia que sería impactante. El tornado giratorio actuaría como la pieza central y las capas de otro contenido moldearían esta característica en su lugar para crear un efecto dramático. Para lograrlo, construimos lo que sería el equivalente a un estudio de cine ambientado en torno a este extraño sombreador.

Utilizamos un enfoque mixto para crear una composición realista. Algunos eran trucos visuales, como formas de luz para generar un efecto de destello en el lente, o gotas de lluvia que se animan como capas sobre la escena que estás mirando. En otros casos, teníamos superficies planas dibujadas para que parecieran moverse, como las capas de nubes voladoras bajas que se movían según un código de sistema de partículas. Los restos de escombros orbitando alrededor del tornado eran capas de una escena 3D ordenadas para moverse delante y detrás del tornado.

La razón principal por la que tuvimos que crear la escena de esta manera fue para asegurarnos de tener suficiente GPU para manejar el sombreador de tornados en equilibrio con los otros efectos que estábamos aplicando. Al principio, tuvimos grandes problemas de balanceo 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 tormentas

Para crear la secuencia de tormenta final, se combinaron muchas técnicas diferentes, pero el punto 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 interesantes remolinos geométricos, animaciones basadas en partículas y hasta animaciones 3D de formas geométricas retorcidas. Ninguno de los efectos parecía recrear la sensación de un tornado o requirió demasiado tiempo para procesarlo.

Finalmente, un proyecto completamente diferente nos dio la respuesta. Un proyecto paralelo del Max Planck Institute (brainflight.org) que desarrolló juegos para que la ciencia cree mapas del cerebro del ratón generó interesantes efectos visuales. Habíamos logrado crear películas del interior de una neurona del mouse con un sombreador volumétrico personalizado.

Interior de una neurona del mouse con un sombreador volumétrico personalizado
Dentro de una neurona del mouse con un sombreador volumétrico personalizado

Descubrimos que el interior de la 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 en el espacio. Podríamos configurar la renderización del sombreador para que se combine con la escena de la tormenta, especialmente si la imagen está sándwich bajo 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 rayos de marcha 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 en la pantalla.

En la descripción general de iq, puedes encontrar una buena referencia al algoritmo: Renderización de mundos con dos triángulos - Iñigo Quilez. Además de explorar la galería de sombreadores en glsl.heroku.com, hay muchos ejemplos de esta técnica que se pueden encontrar allí y con los que se pueden 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( dirección_vector, max_iteraciones, color, color_multiplier) es donde se realiza el cálculo de la marcha de rayos central.

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 hacia la forma del tornado, agregamos regularmente contribuciones de color al valor de color final del píxel, así como contribuciones a la opacidad del rayo. Esto crea una capa de calidad suave en la textura del tornado.

El siguiente aspecto central del tornado es la forma en sí misma, que se crea al componer varias funciones. En principio, es un cono, que está compuesto con ruido para crear un borde rugoso orgánico, y luego se retuerce a lo largo de su eje principal y se rota a 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. Más allá de los problemas relacionados con la abstracción de las operaciones que estás creando, existen importantes problemas de optimización y compatibilidad entre plataformas que debes hacer un seguimiento y resolver antes de poder usar el trabajo en producción.

La primera parte del problema es la optimización de este sombreador para nuestra escena. Para lidiar con esto, necesitábamos un enfoque “seguro” en caso de que el sombreador fuera demasiado pesado. Para hacerlo, componemos el sombreador de tornados en una resolución de muestreo diferente del resto de la escena. Esto es del archivo StormTest.coffee (sí, era 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 tornados a la escena. Luego, decidimos que la reducción de muestreo de la resolución del sombreador de tormentas depende dinámicamente 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 mediante un algoritmo sal2x simplificado (para evitar el aspecto en forma de bloques) @line 1107 en StormTest.coffee. Esto significa que, peor aún, terminamos con un tornado más borroso, pero al menos funciona sin quitarle el control al usuario.

El siguiente paso de optimización requiere profundizar en el algoritmo. El factor de procesamiento de control 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 generación de rayos. Si usamos un paso más grande, podríamos obtener una estimación de la superficie de tornado con menos iteraciones mientras estábamos fuera de la superficie nublada. En ese caso, reduciríamos el tamaño del paso para lograr una precisión y una mezcla de valores a fin de crear el efecto neblinoso. Además, crear un cilindro de límite a fin de obtener una estimación de la profundidad para el rayo proyectado dio una buena velocidad.

La siguiente parte del problema fue asegurarse de que este sombreador se ejecutara en diferentes tarjetas de video. Hicimos algunas pruebas cada vez y empezamos a intuir el tipo de problemas de compatibilidad con los que nos encontramos. La razón por la que no podríamos hacerlo mucho mejor que la intuición es que no siempre podíamos obtener buena información de depuración sobre los errores. Una situación típica es solo un error de GPU con poco más por delante, o incluso una falla del sistema.

Los problemas de compatibilidad entre placas de video tenían soluciones similares: asegúrate de que las constantes estáticas se ingresen del tipo de datos preciso tal como se define, IE: 0.0 para float y 0 para int. Ten cuidado cuando escribas funciones más largas; es preferible dividir los elementos en varias funciones más simples y variables provisionales porque los compiladores parecían no manejar ciertos casos correctamente. Asegúrate de que todas las texturas sean una potencia de 2, no demasiado grandes y, en cualquier caso, tienes que tener cuidado cuando busques datos de texturas en un bucle.

Los mayores problemas que tuvimos con respecto a la compatibilidad fueron por el efecto de iluminación de la tormenta. Usamos una textura prefabricada que envuelve al tornado para poder colorear sus hilos. Fue un efecto hermoso y facilitó la combinación del tornado con los colores de la escena, pero trató de funcionar en otras plataformas.

tornado

El sitio web para celulares

La experiencia en 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 al usuario de dispositivos móviles.

Pensamos que sería genial tener la cabina de fotos de carnaval del escritorio como una aplicación web móvil que usaría la cámara móvil del usuario. Algo que no habíamos visto hecho hasta ahora.

Para agregar sabor, codificamos las transformaciones 3D en CSS3. Al 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.

Al escribir este artículo, pensamos que valdría la pena darte algunas pistas sobre cómo ejecutar sin problemas el proceso de desarrollo para dispositivos móviles. Aquí están. ¡Sigue adelante y ve qué puedes aprender de ella!

Sugerencias y trucos para dispositivos móviles

El precargador es algo necesario, no algo que debería evitarse. Sabemos que a veces sucede esto último. Esto se debe principalmente a que debes seguir manteniendo la lista de elementos que precargas a medida que crece tu proyecto. Y 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 constantemente anidada donde una Task puede tener sus propias subtareas, que pueden tener su, etc. Además, cada tarea calcula su progreso con respecto al progreso de sus subtareas (pero no al progreso de su superior). Al hacer que MainPreloadTask, AssetPreloadTask y TemplatePrefetchTask deriven de Task, creamos una estructura que se ve de la siguiente manera:

Precargador

Gracias a este enfoque y a la clase Task, podemos conocer fácilmente el progreso global (MainPreloadTask) o simplemente el progreso de los elementos (AssetPreloadTask) o el progreso de la carga de las plantillas (TemplatePrefetchTask). Progreso uniforme de un archivo en particular. Para ver cómo se hace, consulta la clase Task (/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 precarga final:

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 considerar cómo se comunica con MainPreloadTask (a través de la implementación de Task compartida), también vale la pena señalar 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 de archivo, generalmente .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 cuatro arrays de elementos, solo se indica cuál es el nombre y la extensión del recurso que se precargará y si este depende de la plataforma (adaptable = true / false). Luego, AssetPreloadTask generará el nombre del archivo:

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 precarga 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: Cabina de fotos HTML5 (iOS6/Android)

Cuando desarrollamos OZ Mobile, descubrimos que pasamos mucho tiempo jugando con la cabina de fotos en lugar de trabajar :D. Eso fue simplemente porque es divertido. Así que hicimos una demostración para que pruebes con ella.

Cabina de fotos móvil
Cabina de fotos móvil

Aquí puedes ver una demostración en vivo (ejecútala en tu iPhone o teléfono 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 de frontend no es complejo, pero hay un par de posibles problemas. Analicémoslas ahora:

  1. Tipo de archivo de imagen permitido Queremos que las personas solo puedan subir imágenes (ya que es una cabina de fotos, no una de video). 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 funcionar solo en iOS, por lo que debemos agregar una verificación adicional de la expresión regular una vez que se seleccionó 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();
     }
   }
   });
  1. Cancelación de una carga o selección de archivo Otra incoherencia que notamos durante el proceso de desarrollo es la manera en que los diferentes dispositivos notifican una selección de archivo cancelada. Los teléfonos y las tablets iOS no hacen nada, no envían notificaciones. Por lo tanto, no necesitamos ninguna acción especial en este caso. Sin embargo, los teléfonos Android activan la función add() de todos modos, incluso si no se selecciona ningún archivo. A continuación, te indicamos cómo atender esto:
    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 tamaño masivo de Encuentra tu camino a Oz y la amplia combinación de diferentes tecnologías involucradas, en este artículo solo pudimos cubrir algunos de los enfoques que utilizamos.

Si quieres explorar la enchilada completa, puedes revisar el código fuente completo de Find Your Way To Oz en este vínculo.

Créditos

Haga clic aquí para ver la lista completa de créditos

Referencias