Estudo de caso: Encontre seu caminho até Oz

Introdução

"Find Your Way to Oz" é um novo experimento do Google Chrome que a Disney trouxe para a Web. Ele permite que você faça uma viagem interativa por um circo do Kansas, que leva você à Terra de Oz depois de ser varrido por uma tempestade enorme.

Nosso objetivo era combinar a riqueza do cinema com os recursos técnicos do navegador para criar uma experiência divertida e imersiva com a qual os usuários pudessem se conectar.

O trabalho é grande demais para ser capturado em um único artigo. Por isso, selecionamos alguns capítulos da história da tecnologia que achamos interessantes. Ao longo do caminho, extraímos alguns tutoriais focados de dificuldade crescente.

Muitas pessoas trabalharam muito para tornar essa experiência possível: muitas para serem listadas aqui. Acesse o site e confira a página de créditos na seção de menu para saber a história completa.

Por trás das cenas

O Find Your Way to Oz para computador é um mundo imersivo e rico. Usamos 3D e várias camadas de efeitos inspirados em filmes tradicionais que se combinam para criar uma cena quase realista. As tecnologias mais importantes são o WebGL com o Three.js, shaders personalizados e elementos animados do DOM que usam recursos do CSS3. Além disso, a API getUserMedia (WebRTC) para experiências interativas permite que o usuário adicione a imagem diretamente da webcam e do WebAudio para som em 3D.

Mas a magia de uma experiência tecnológica como essa é como ela é criada. Esse é também um dos principais desafios: como combinar efeitos visuais e elementos interativos em uma cena para criar um todo consistente? Essa complexidade visual era difícil de gerenciar, o que dificultava saber em qual estágio do desenvolvimento estávamos.

Para resolver o problema de efeitos visuais e otimização interconectados, usamos muito um painel de controle que capturava todas as configurações relevantes que estávamos analisando naquele momento. A cena pode ser ajustada em tempo real no navegador para qualquer coisa, desde brilho até profundidade de campo, gama etc. Qualquer pessoa pode tentar ajustar os valores dos parâmetros significativos na experiência e descobrir o que funcionou melhor.

Antes de compartilhar nosso segredo, queremos avisar que ele pode falhar, assim como se você mexesse no motor de um carro. Verifique se não há nada importante e acesse o URL principal do site. Em seguida, adicione ?debug=on ao endereço. Aguarde o carregamento do site e, quando estiver dentro (pressione?) a tecla Ctrl-I, um menu suspenso vai aparecer no lado direito. Se você desmarcar a opção "Sair do caminho da câmera", poderá usar as teclas A, W, S, D e o mouse para se mover livremente pelo espaço.

Caminho da câmera.

Não vamos abordar todas as configurações aqui, mas recomendamos que você teste: as teclas revelam configurações diferentes em cenas diferentes. Na sequência final da tempestade, há uma chave adicional: Ctrl-A, com a qual você pode alternar a reprodução da animação e voar. Nessa cena, se você pressionar Esc (para sair da funcionalidade de bloqueio do mouse) e pressionar novamente Ctrl-I, poderá acessar as configurações específicas da cena de tempestade. Dê uma olhada e capture algumas imagens de cartão-postal legais, como a abaixo.

Cena de tempestade

Para que isso acontecesse e para garantir que ele fosse flexível o suficiente para nossas necessidades, usamos uma biblioteca chamada dat.gui (confira aqui um tutorial anterior sobre como usá-la). Isso nos permitiu mudar rapidamente quais configurações foram expostas aos visitantes do site.

Um pouco como pintura fosca

Em muitos filmes e animações clássicas da Disney, a criação de cenas significava combinar diferentes camadas. Havia camadas de ação real, animação de células, até mesmo cenários físicos e camadas superiores criadas pintando em vidro: uma técnica chamada pintura em tela.

A estrutura da experiência que criamos é semelhante de muitas maneiras, embora algumas das “camadas” sejam muito mais do que visuais estáticos. Na verdade, eles afetam a aparência das coisas de acordo com cálculos mais complexos. No entanto, pelo menos no nível geral, estamos lidando com visualizações compostas uma sobre a outra. Na parte de cima, há uma camada de interface com uma cena 3D abaixo dela, que é composta por diferentes componentes de cena.

A camada de interface superior foi criada usando DOM e CSS 3, o que significa que a edição das interações pode ser feita de várias maneiras, independentemente da experiência 3D, com comunicação entre as duas de acordo com uma lista selecionada de eventos. Essa comunicação usa o Backbone Router + evento HTML5 onHashChange, que controla qual área deve ser animada. (fonte do projeto: /develop/coffee/router/Router.coffee).

Tutorial: folhas de sprites e suporte a Retina

Uma técnica de otimização divertida que usamos na interface foi combinar as muitas imagens de sobreposição da interface em um único PNG para reduzir as solicitações do servidor. Neste projeto, a interface era composta por mais de 70 imagens (sem contar as texturas 3D) carregadas de uma só vez para reduzir a latência do site. Confira a folha de sprites ao vivo aqui:

Tela normal: http://findyourwaytooz.com/img/home/interface_1x.png Tela Retina: http://findyourwaytooz.com/img/home/interface_2x.png

Confira algumas dicas de como aproveitamos as Sprite Sheets e como usá-las em dispositivos Retina para deixar a interface o mais nítida e organizada possível.

Como criar Spritesheets

Para criar SpriteSheets, usamos o TexturePacker, que gera saídas em qualquer formato necessário. Nesse caso, exportamos como EaselJS, que é muito simples e poderia ter sido usado para criar sprites animados.

Como usar a SpriteSheet gerada

Depois de criar a spritesheet, você vai encontrar um arquivo 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]
   },
}

Em que:

  • image se refere ao URL da folha de sprite
  • Os frames são as coordenadas de cada elemento da interface [x, y, largura, altura]
  • as animações são os nomes de cada recurso

Usamos as imagens de alta densidade para criar a spritesheet e, em seguida, criamos a versão normal reduzindo o tamanho à metade.

Reunindo tudo

Agora que tudo está pronto, só precisamos de um snippet de JavaScript para usá-lo.

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);
};

E é assim que você usaria:

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 entender um pouco mais sobre as densidades de pixel variáveis, leia este artigo de Boris Smus.

Pipeline de conteúdo 3D

A experiência do ambiente é configurada em uma camada do WebGL. Quando você pensa em uma cena 3D, uma das perguntas mais difíceis é como garantir que você possa criar conteúdo que permita o máximo potencial de expressão nos aspectos de modelagem, animação e efeitos. Em muitos aspectos, o problema está no pipeline de conteúdo: um processo acordado a ser seguido para criar conteúdo para a cena 3D.

Queríamos criar um mundo incrível, então precisávamos de um processo sólido que permitisse que artistas 3D o criassem. Eles precisariam ter o máximo de liberdade de expressão possível no software de modelagem e animação 3D e renderizar na tela por meio de código.

Já faz algum tempo que trabalhamos nesse tipo de problema, porque sempre que criamos um site 3D, encontramos limitações nas ferramentas que podemos usar. Criamos uma ferramenta chamada 3D Librarian, que é parte de uma pesquisa interna. E estava quase pronto para ser aplicado a um trabalho real.

Essa ferramenta já existia há algum tempo: originalmente, ela era para Flash e permitia que você trouxesse uma grande cena do Maya como um único arquivo compactado otimizado para descompactação no tempo de execução. O motivo era que ele empacotava a cena de forma eficaz na mesma estrutura de dados que é manipulada durante a renderização e a animação. É necessário fazer muito pouca análise do arquivo quando ele é carregado. A descompactação no Flash foi muito rápida porque o arquivo estava no formato AMF, que o Flash podia descompactar de forma nativa. O uso do mesmo formato no WebGL exige um pouco mais de trabalho na CPU. Na verdade, tivemos que recriar uma camada de código Javascript de descompactação de dados, que basicamente descompactaria esses arquivos e recriaria as estruturas de dados necessárias para o WebGL funcionar. Descompactar toda a cena 3D é uma operação moderadamente pesada para a CPU: descompactar a cena 1 em Find Your Way To Oz requer cerca de dois segundos em uma máquina de nível médio a alto. Isso é feito usando a tecnologia Web Workers, no momento da "configuração de cena" (antes do lançamento da cena), para não travar a experiência do usuário.

Essa ferramenta prática pode importar a maior parte da cena 3D: modelos, texturas e animações de ossos. Você cria um único arquivo de biblioteca, que pode ser carregado pelo mecanismo 3D. Você coloca todos os modelos necessários na cena dentro dessa biblioteca e, pronto, eles são gerados na cena.

O problema é que estávamos lidando com o WebGL, a nova criança da turma. Foi um desafio e tanto: esse era o padrão para experiências 3D baseadas em navegador. Então, criamos uma camada ad hoc do Javascript que pegava os arquivos de cena 3D compactados do 3D Librarian e os traduzia corretamente para um formato que o WebGL entendesse.

Tutorial: Let There Be Wind

Um tema recorrente em “Find Your Way To Oz” era o vento. Uma linha da história é estruturada para ser um crescendo do vento.

A primeira cena do carnaval é relativamente calma. Ao passar pelas várias cenas, o usuário experimenta um vento cada vez mais forte, culminando na cena final, a tempestade.

Por isso, era importante oferecer um efeito de vento imersivo.

Para criar isso, preenchemos as três cenas de carnaval com objetos que eram macios e, portanto, deveriam ser afetados pelo vento, como tendas, bandeiras na superfície da cabine de fotos e o próprio balão.

Pano macio.

Hoje em dia, os jogos para computador geralmente são criados em torno de um mecanismo de física principal. Portanto, quando um objeto macio precisa ser simulado no mundo 3D, uma simulação de física completa é executada para ele, criando um comportamento macio crível.

No WebGL / Javascript, ainda não temos o luxo de executar uma simulação de física completa. Então, em Oz, tivemos que encontrar uma maneira de criar o efeito do vento sem realmente simulá-lo.

Incorporamos as informações de "sensibilidade ao vento" para cada objeto no modelo 3D. Cada vértice do modelo 3D tinha um "atributo de vento" que especificava o quanto esse vértice deveria ser afetado pelo vento. Essa sensibilidade ao vento especificada dos objetos 3D. Depois, precisamos criar o vento.

Para isso, geramos uma imagem com ruído de Perlin. Essa imagem tem o objetivo de cobrir uma determinada "área de vento". Uma boa maneira de pensar nisso é imaginar uma imagem de ruído semelhante a uma nuvem sendo colocada sobre uma determinada área retangular da cena 3D. Cada pixel, valor de nível de cinza, dessa imagem especifica a intensidade do vento em um determinado momento na área 3D "ao redor".

Para produzir o efeito do vento, a imagem é movida, no tempo, a uma velocidade constante, em uma direção específica, a direção do vento. Para garantir que a "área com vento" não afete tudo na cena, colocamos a imagem do vento em volta das bordas, confinada à área de efeito.

Tutorial simples de vento em 3D

Agora vamos criar o efeito do vento em uma cena 3D simples no Three.js.

Vamos criar o vento em um simples "campo de grama procedural".

Primeiro, vamos criar a cena. Vamos ter um terreno plano simples com textura. E cada pedaço de grama será representado por um cone 3D invertido.

Terreno com grama
Terreno com grama

Veja como criar essa cena simples no Three.js usando CoffeeScript.

Primeiro, vamos configurar o Three.js e conectá-lo à câmera, ao mouse e a uma 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()

As chamadas de função initGrass e initTerrain preenchem a cena com grama e 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

Aqui estamos criando uma grade de 15 x 15 de grama. Adicionamos um pouco de aleatoriedade a cada posição de grama para que elas não se alinhem como soldados, o que seria estranho.

Esse terreno é apenas um plano horizontal, colocado na base das peças de grama (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 )

Até agora, criamos uma cena do Three.js e adicionamos um pouco de grama, cones invertidos gerados proceduralmente e um terreno simples.

Nada demais até agora.

Agora é hora de começar a adicionar o vento. Primeiro, queremos incorporar as informações de sensibilidade ao vento no modelo 3D da grama.

Vamos incorporar essas informações como um atributo personalizado para cada vértice do modelo 3D da grama. E vamos usar a regra de que: a extremidade inferior do modelo de grama (ponta do cone) tem sensibilidade zero, porque está fixada ao solo. A parte de cima do modelo de grama (base do cone) tem a sensibilidade máxima ao vento, porque é a parte mais distante do solo.

Confira como a função instanceGrass é recodificada para adicionar a sensibilidade ao vento como um atributo personalizado para o modelo 3D de grama.

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

Agora usamos um material personalizado, windMaterial, em vez do MeshPhongMaterial que usamos anteriormente. WindMaterial envolve o WindMeshShader que vamos conhecer em um minuto.

Portanto, o código em instanceGrass faz loops em todos os vértices do modelo de grama e, para cada vértice, adiciona um atributo de vértice personalizado chamado windFactor. Esse windFactor é definido como 0 para a extremidade inferior do modelo de grama (onde ele deve tocar o terreno) e como 1 para a extremidade superior.

O outro ingrediente que precisamos é adicionar o vento real à nossa cena. Como discutido, vamos usar o ruído de Perlin para isso. Vamos gerar proceduralmente uma textura de ruído de Perlin.

Para maior clareza, vamos atribuir essa textura ao terreno em si, em vez da textura verde anterior. Isso vai facilitar a percepção do que está acontecendo com o vento.

Portanto, essa textura de ruído de Perlin vai cobrir espacialmente a extensão do terreno, e cada pixel da textura vai especificar a intensidade do vento da área do terreno em que o pixel está. O retângulo do terreno será a "área de vento".

O ruído de Perlin é gerado proceduralmente por um sombreador chamado NoiseShader. Este sombreador usa algoritmos de ruído de Simplex 3D de: https://github.com/ashima/webgl-noise . A versão do WebGL foi tirada literalmente de um dos exemplos do Three.js do MrDoob, em: http://mrdoob.github.com/three.js/examples/webgl_terrain_dynamic.html.

O NoiseShader usa um tempo, uma escala e um conjunto de parâmetros de deslocamento como uniformes e gera uma boa distribuição 2D de ruído 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) }

...

Vamos usar esse sombreador para renderizar nosso ruído de Perlin em uma textura. Isso é feito na função 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 )

O código acima configura noiseMap como um destino de renderização do Three.js, equipa-o com o NoiseShader e, em seguida, renderiza com uma câmera ortogonal para evitar distorções de perspectiva.

Como discutido, vamos usar essa textura também como a principal textura de renderização do terreno. Isso não é realmente necessário para que o efeito do vento funcione. Mas é bom ter, para que possamos entender melhor o que está acontecendo com a geração eólica.

Confira a função initTerrain retrabalhada, 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 )

Agora que temos a textura do vento, vamos conferir o WindMeshShader, que é responsável por deformar os modelos de grama de acordo com o vento.

Para criar esse sombreador, começamos com o sombreador MeshPhongMaterial padrão do Three.js e o modificamos. Essa é uma maneira rápida e fácil de começar com um sombreador que funciona, sem precisar começar do zero.

Não vamos copiar todo o código do sombreador aqui (confira no arquivo de código-fonte), porque a maior parte dele seria uma réplica do sombreador MeshPhongMaterial. Mas vamos analisar as partes modificadas relacionadas ao vento no sombreador de vértice.

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;

Esse sombreador calcula primeiro a coordenada de pesquisa de textura windUV com base na posição 2D, xz (horizontal) do vértice. Essa coordenada UV é usada para procurar a força do vento, vWindForce, na textura de vento de ruído de Perlin.

Esse valor vWindForce é composto com o windFactor específico do vértice, atributo personalizado discutido acima, para calcular a quantidade de deformação necessária. Também temos um parâmetro global, windScale, para controlar a força geral do vento, e um vetor, windDirection, que especifica em que direção a deformação do vento precisa ocorrer.

Isso cria uma deformação baseada no vento das nossas peças de grama. No entanto, ainda não terminamos. No momento, essa deformação é estática e não transmite o efeito de uma área com vento.

Como mencionamos, vamos precisar deslizar a textura de ruído ao longo do tempo, pela área de vento, para que o vidro possa ondular.

Isso é feito mudando ao longo do tempo, o uniforme vOffset que é transmitido para o NoiseShader. Esse é um parâmetro vec2, que permite especificar o deslocamento de ruído em uma determinada direção (a direção do vento).

Fazemos isso na função render, que é chamada em todos os frames:

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
...

E é isso! Acabamos de criar uma cena com "grama procedural" afetada pelo vento.

Adicionar poeira à mistura

Agora vamos apimentar um pouco a cena. Vamos adicionar um pouco de poeira para deixar a cena mais interessante.

Adicionar poeira
Adição de poeira

Afinal, a poeira é afetada pelo vento, então faz sentido ter poeira voando na cena.

O Dust é configurado na função initDust como um 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

Aqui, 130 partículas de poeira são criadas. Cada um deles é equipado com um WindParticleShader especial.

Agora, em cada frame, vamos mover um pouco as partículas, usando CoffeeScript, independentemente do vento. Confira o 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 )

Além disso, vamos compensar cada posição de partícula de acordo com o vento. Isso é feito no WindParticleShader. Especificamente no sombreador de vértice.

O código desse sombreador é uma versão modificada do ParticleMaterial do Three.js. Confira como é o núcleo dele:

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;

Esse sombreador de vértice não é muito diferente do que tínhamos para a deformação da grama baseada no vento. Ele usa a textura de ruído de Perlin como entrada e, dependendo da posição do mundo de poeira, procura um valor vWindForce na textura de ruído. Em seguida, ele usa esse valor para modificar a posição da partícula de poeira.

Riders On The Storm

A mais aventureira das nossas cenas do WebGL foi provavelmente a última, que você pode conferir se clicar no balão e entrar no olho do furacão para chegar ao fim da jornada no site e no vídeo exclusivo do lançamento.

Cena de passeio de balão

Ao criar essa cena, sabíamos que precisávamos de um recurso central para a experiência que fosse impactante. O tornado giratório seria o ponto central, e camadas de outro conteúdo moldariam esse recurso para criar um efeito dramático. Para isso, criamos o equivalente a um estúdio de cinema em torno desse sombreador estranho.

Usamos uma abordagem mista para criar o composto realista. Alguns eram truques visuais, como formas de luz para criar um efeito de reflexo de lente ou gotas de chuva que se animam como camadas sobre a cena que você está vendo. Em outros casos, desenhamos superfícies planas para parecer que elas se movem, como as camadas de nuvens voando baixo se movendo de acordo com um código do sistema de partículas. Os pedaços de detritos que orbitam o tornado são camadas de uma cena 3D classificada para se mover na frente e atrás do tornado.

O principal motivo para criarmos a cena dessa forma foi garantir que tivéssemos GPU suficiente para processar o sombreador de tornado em equilíbrio com os outros efeitos que estávamos aplicando. Inicialmente, tivemos grandes problemas de balanceamento da GPU, mas depois essa cena foi otimizada e ficou mais leve do que as principais.

Tutorial: o sombreador Storm

Para criar a sequência final da tempestade, muitas técnicas diferentes foram combinadas, mas o ponto principal desse trabalho foi um sombreador GLSL personalizado que se parece com um tornado. Testamos muitas técnicas diferentes, desde shaders de vértices para criar redemoinhos geométricos interessantes até animações baseadas em partículas e até animações 3D de formas geométricas distorcidas. Nenhum dos efeitos parecia recriar a sensação de um tornado ou exigia muito processamento.

Um projeto completamente diferente acabou nos fornecendo a resposta. Um projeto paralelo envolvendo jogos para a ciência para mapear o cérebro do rato do Instituto Max Planck (brainflight.org) gerou efeitos visuais interessantes. Conseguimos criar filmes do interior de um neurônio de rato usando um sombreador volumétrico personalizado.

Dentro de um neurônio de rato usando um sombreador volumétrico personalizado
Dentro de um neurônio de rato usando um sombreador volumétrico personalizado

Descobrimos que o interior de uma célula cerebral se parece um pouco com o funil de um tornado. Como estávamos usando uma técnica volumétrica, sabíamos que poderíamos visualizar esse sombreador de todas as direções no espaço. Podemos definir a renderização do sombreador para combinar com a cena de tempestade, principalmente se ele estiver entre camadas de nuvens e acima de um plano de fundo dramático.

A técnica de sombreador envolve um truque que basicamente usa um único sombreador GLSL para renderizar um objeto inteiro com um algoritmo de renderização simplificado chamado renderização de marcha de raios com um campo de distância. Nessa técnica, é criado um sombreador de pixel que estima a distância mais próxima de uma superfície para cada ponto na tela.

Uma boa referência do algoritmo pode ser encontrada na visão geral do iq: Rendering Worlds With Two Triangles - Iñigo Quilez (em inglês). Também há muitos exemplos dessa técnica na galeria de sombreadores em glsl.heroku.com que podem ser testados.

O ponto principal do sombreador começa com a função principal, que configura as transformações da câmera e entra em um loop que avalia repetidamente a distância de uma superfície. A chamada RaytraceFoggy( direction_vector, max_iterations, color, color_multiplier ) é onde o cálculo do núcleo de marcha de raios acontece.

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
}

A ideia é que, à medida que avançamos na forma do tornado, adicionamos regularmente contribuições de cor ao valor de cor final do pixel, bem como contribuições à opacidade ao longo do raio. Isso cria uma qualidade suave em camadas na textura do tornado.

O próximo aspecto principal do tornado é a forma real, que é criada com a composição de várias funções. Ele é um cone para começar, composto por ruído para criar uma borda orgânica áspera e, em seguida, é torcido ao longo do eixo principal e girado no tempo.

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;
}

O trabalho envolvido na criação desse tipo de sombreador é complicado. Além dos problemas envolvidos na abstração das operações que você está criando, há problemas graves de otimização e compatibilidade entre plataformas que precisam ser rastreados e resolvidos antes que você possa usar o trabalho na produção.

A primeira parte do problema: otimizar esse sombreador para nossa cena. Para lidar com isso, precisamos ter uma abordagem "segura" caso o sombreador fosse muito pesado. Para fazer isso, combinamos o sombreador de tornado com uma resolução de amostragem diferente do resto da cena. Este é o arquivo stormTest.coffee (sim, foi um teste!).

Começamos com um renderTarget que corresponde à largura e à altura da cena para que possamos ter independência da resolução do shader de tornado para a cena. Em seguida, decidimos a redução da resolução do sombreador de tempestade dinamicamente dependente da taxa de frames que estamos recebendo.

...
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 fim, renderizamos o tornado na tela usando um algoritmo simplificado sal2x (para evitar o aspecto em blocos) na linha 1107 em stormTest.coffee. Isso significa que, no pior dos casos, acabamos tendo um tornado mais desfocado, mas pelo menos ele funciona sem tirar o controle do usuário.

A próxima etapa de otimização exige que você se aprofunde no algoritmo. O fator computacional principal no sombreador é a iteração realizada em cada pixel para tentar aproximar a distância da função de superfície: o número de iterações do loop de raymarching. Usando um tamanho de etapa maior, conseguimos uma estimativa da superfície de um tornado com menos iterações enquanto estávamos fora da superfície nublada. Quando dentro, diminuímos o tamanho do passo para ter mais precisão e poder misturar valores para criar o efeito de névoa. Além disso, a criação de um cilindro de limite para estimar a profundidade do raio lançado acelerou bastante o processo.

A próxima parte do problema era garantir que esse sombreador fosse executado em diferentes placas de vídeo. Fizemos alguns testes e começamos a ter uma ideia do tipo de problema de compatibilidade que poderíamos encontrar. Não conseguimos fazer muito melhor do que a intuição porque não sempre conseguimos informações de depuração sobre os erros. Um cenário típico é apenas um erro de GPU com um pouco mais de continuidade ou até mesmo uma falha no sistema.

Os problemas de compatibilidade entre placas de vídeo tiveram soluções semelhantes: verifique se as constantes estáticas são inseridas com o tipo de dados exato, conforme definido, ou seja, 0,0 para números flutuantes e 0 para números inteiros.Tenha cuidado ao escrever funções mais longas. É preferível dividir as coisas em várias funções mais simples e variáveis temporárias, porque os compiladores pareciam não processar corretamente alguns casos. Verifique se as texturas são todas potências de 2, não muito grandes e, em qualquer caso, tenha "cuidado" ao procurar dados de textura em um loop.

Os maiores problemas de compatibilidade que tivemos foram com o efeito de iluminação da tempestade. Usamos uma textura pré-fabricada em volta do tornado para colorir as nuvens. O efeito era incrível e facilitava a mistura do tornado com as cores da cena, mas demorou muito para funcionar em outras plataformas.

tornado

O site da Web para dispositivos móveis

A experiência para dispositivos móveis não poderia ser uma tradução direta da versão para computador porque os requisitos de tecnologia e processamento eram muito pesados. Tivemos que criar algo novo, que segmentasse especificamente o usuário de dispositivos móveis.

Achamos que seria legal ter a cabine de fotos do Carnaval no computador como um aplicativo da Web para dispositivos móveis que usaria a câmera do usuário. Algo que não tínhamos visto até agora.

Para dar um toque especial, codificamos transformações 3D no CSS3. Depois de vincular o giroscópio e o acelerômetro, conseguimos adicionar mais profundidade à experiência. O site responde à forma como você segura, move e olha para o smartphone.

Ao escrever este artigo, pensamos que seria interessante dar algumas dicas sobre como executar o processo de desenvolvimento para dispositivos móveis sem problemas. Aqui estão! Confira o que você pode aprender com ela.

Dicas e truques para dispositivos móveis

O carregador prévio é necessário, não algo que precisa ser evitado. Sabemos que isso pode acontecer. Isso ocorre principalmente porque você precisa manter a lista de itens pré-carregados à medida que seu projeto cresce. Pior ainda, não fica muito claro como calcular o progresso do carregamento se você estiver extraindo recursos diferentes e muitos deles ao mesmo tempo. É aqui que nossa classe abstrata "Task" personalizada e muito genérica é útil. A ideia principal é permitir uma estrutura aninhada infinita em que uma tarefa pode ter suas próprias subtarefas, que podem ter outras, e assim por diante. Além disso, cada tarefa calcula o progresso em relação às subtarefas, mas não ao progresso da tarefa pai. Para fazer com que todas as MainPreloadTask, AssetPreloadTask e TemplatePrefetchTask derivem de Task, criamos uma estrutura assim:

Pré-carregador

Graças a essa abordagem e à classe Task, podemos saber facilmente o progresso global (MainPreloadTask), ou apenas o progresso dos recursos (AssetPreloadTask) ou o progresso do carregamento de modelos (TemplatePrefetchTask). Mesmo o progresso de um arquivo específico. Para saber como isso é feito, confira a classe Task em /m/javascripts/raw/util/Task.js e as implementações de tarefas reais em /m/javascripts/preloading/task. Como exemplo, este é um extrato de como configuramos a classe /m/javascripts/preloading/task/MainPreloadTask.js, que é nosso wrapper de pré-carregamento 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);

      }
    }
  })
]);

Na classe /m/javascripts/preloading/task/subtask/AssetPreloadTask.js, além de observar como ela se comunica com a MainPreloadTask (pela implementação de tarefas compartilhadas), também é importante observar como carregamos recursos que dependem da plataforma. Basicamente, temos quatro tipos de imagens. Padrão para dispositivos móveis (.ext, em que ext é a extensão do arquivo, normalmente .png ou .jpg), retina para dispositivos móveis (-2x.ext), padrão para tablets (-tab.ext) e retina para tablets (-tab-2x.ext). Em vez de fazer a detecção na MainPreloadTask e fixar quatro matrizes de recursos, basta informar o nome e a extensão do recurso a ser carregado previamente e se ele é dependente da plataforma (responsive = true / false). Em seguida, a AssetPreloadTask vai gerar o nome do arquivo:

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

Mais abaixo na cadeia de classes, o código que faz o pré-carregamento de recursos é o seguinte (/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);
}

Tutorial: cabine de fotos em HTML5 (iOS 6/Android)

Ao desenvolver o OZ para dispositivos móveis, descobrimos que passamos muito tempo brincando com a cabine de fotos em vez de trabalhar :D Isso simplesmente porque é divertido. Por isso, criamos uma demonstração para você brincar.

Cabine de fotos móvel
Cabine de fotos móvel

Confira uma demonstração ao vivo aqui (execute no seu iPhone ou smartphone Android):

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

Para fazer isso, você precisa de uma instância de aplicativo sem custo financeiro do Google App Engine em que seja possível executar o back-end. O código front-end não é complexo, mas há algumas possíveis armadilhas. Vamos analisar cada uma delas:

  1. Tipo de arquivo de imagem permitido Queremos que as pessoas possam fazer upload apenas de imagens (já que é uma cabine de fotos, não de vídeos). Em teoria, é possível especificar o filtro em HTML, conforme mostrado abaixo: input id="fileInput" class="fileInput" type="file" name="file" accept="image/*" No entanto, isso parece funcionar apenas no iOS. Portanto, precisamos adicionar outra verificação no RegExp depois que um arquivo for selecionado:
   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. Cancelamento de um upload ou seleção de arquivo Outra inconsistência que notamos durante o processo de desenvolvimento é como os diferentes dispositivos notificam uma seleção de arquivo cancelada. Os smartphones e tablets iOS não fazem nada, não notificam. Portanto, não precisamos de nenhuma ação especial para esse caso. No entanto, os smartphones Android acionam a função add() de qualquer maneira, mesmo que nenhum arquivo seja selecionado. Veja como fazer isso:
    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();            
    }
    }

O restante funciona bem em várias plataformas. Divirta-se.

Conclusão

Devido ao tamanho enorme do Find Your Way To Oz e à ampla variedade de tecnologias envolvidas, neste artigo abordamos apenas algumas das abordagens que usamos.

Se você quiser conferir o código-fonte completo do Find Your Way To Oz, clique aqui.

Créditos

Clique aqui para conferir a lista completa de créditos

Referências