Estudo de caso: Encontre seu caminho até Oz

Introdução

"Encontre seu caminho até Oz" é um novo experimento do Google Chrome lançado na Web pela Disney. Faça uma viagem interativa por um circo no Kansas, que leva à terra de Oz depois de uma grande tempestade.

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 possam estabelecer uma forte conexão.

O trabalho é grande demais para ser capturado por completo nessa parte. Por isso, nos aprofundamos e selecionamos alguns capítulos da história de tecnologia que consideramos interessantes. Ao longo do caminho, extraímos alguns tutoriais focados em aumentar a dificuldade.

Muitas pessoas se esforçaram para tornar essa experiência possível. São tantas para listar aqui. Acesse o site para conferir a página de créditos na seção de menu para ver a história completa.

Uma espiada nas bastidores

Encontre seu caminho até Oz no computador com um mundo rico e imersivo. Usamos 3D e várias camadas de efeitos tradicionais inspirados no cinema que se combinam para criar uma cena quase realista. As tecnologias mais importantes são o WebGL com Three.js, sombreadores personalizados e elementos animados do DOM usando recursos CSS3. Além disso, a API getUserMedia (WebRTC) para experiências interativas permite que o usuário adicione uma imagem diretamente da Webcam e do WebAudio para ter som 3D.

Mas a magia de uma experiência tecnológica como essa é a forma como tudo se une. 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 dizer em qual estágio do desenvolvimento estávamos em qualquer momento.

Para resolver o problema dos efeitos visuais interconectados e da otimização, usamos intensamente um painel de controle que capturava todas as configurações relevantes que estávamos analisando naquele momento. A cena poderia ser ajustada ao vivo no navegador para qualquer coisa, desde brilho, profundidade de campo, gama etc. Qualquer pessoa poderia tentar ajustar os valores dos parâmetros significativos da experiência e participar da descoberta do que funcionou melhor.

Antes de compartilhar nosso segredo, queremos avisá-lo de que ele pode falhar, como se você tivesse que dar uma olhada em um motor de carro. Verifique se não há nada importante, acesse o URL principal do site e anexe ?debug=on ao endereço. Aguarde o site carregar e, quando estiver dentro (pressione?) a chave Ctrl-I, será exibido um menu suspenso 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 abordaremos todas as configurações aqui, mas incentivamos você a experimentar: as teclas revelam diferentes configurações em diferentes cenários. Na sequência final da tempestade, há uma chave adicional: Ctrl-A, que permite alternar a reprodução da animação e voar. Nesse cenário, se você pressionar Esc (para sair da funcionalidade de bloqueio do mouse) e pressionar novamente Ctrl-I, será possível acessar as configurações específicas do cenário da tempestade. Dê uma olhada e capture belas imagens de cartões-postais, como a mostrada abaixo.

Cena de tempestade

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

Um pouco como a pintura fosca

Em muitos filmes e animações clássicos da Disney, criar cenas significava combinar diferentes camadas. Havia camadas de live-action, animação celular, até mesmo cenários físicos e camadas superiores criadas pela pintura em vidro: uma técnica chamada pintura fosca.

De muitas maneiras, a estrutura da experiência que criamos é semelhante, embora algumas das “camadas” sejam muito mais do que visuais estáticos. Na verdade, elas afetam a aparência das coisas de acordo com cálculos mais complexos. No entanto, ao 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: composta por diferentes componentes de cena.

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

Tutorial: suporte para Planilhas de Sprite e Retina

Uma técnica de otimização divertida que usamos para a 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 texturas 3D) carregadas antecipadamente para reduzir a latência do site. Confira a folha de sprite ativa aqui:

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

Aqui estão algumas dicas de como aproveitamos o uso das Folhas de Sprite e como usá-las para dispositivos com tela Retina e deixar a interface o mais nítida e clara possível.

Como criar Folhas de Sprite

Para criar o Sprite Sheets, usamos o TexturePacker, que gera resultados em qualquer formato que você precisar. Neste caso, exportamos como EaselJS, que é muito claro e também poderia ser usado para criar sprites animados.

Como usar a Folha de Sprite gerada

Depois de criar sua Folha de Sprite, você deverá ver 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:

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

Observe que usamos as imagens de alta densidade para criar a Folha de Sprite e, em seguida, criamos a versão normal apenas redimensionando-a para a metade de seu tamanho.

Reunindo tudo

Agora que está tudo pronto, só precisamos de um snippet 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ê o 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 densidades de pixels variáveis, leia este artigo de Boris Smus.

O pipeline de conteúdo 3D

A experiência do ambiente é configurada em uma camada WebGL. Quando você pensa em uma cena 3D, uma das perguntas mais complicadas é como você vai criar conteúdo que viabilize o potencial máximo expressivo dos lados de modelagem, animação e efeitos. O pipeline de conteúdo é, de muitas formas, um processo essencial para a criação de conteúdo para a cena 3D.

Queríamos criar um mundo inspirador e, por isso, precisávamos de um processo sólido que permitisse aos artistas 3D criá-lo. Eles precisariam ter o máximo de liberdade possível no software de modelagem e animação 3D, e precisaríamos renderizá-lo na tela pelo código.

Estávamos trabalhando nesse tipo de problema há algum tempo porque, sempre que criamos um site em 3D, tínhamos limitações nas ferramentas que poderíamos usar. Por isso, criamos uma ferramenta chamada 3D Librarian: uma pesquisa interna. E ele estava prestes a ser aplicado a um emprego de verdade.

Essa ferramenta tinha um pouco de história: originalmente era para Flash, e permitia que você adicionasse uma grande cena Maya como um único arquivo compactado, otimizado para descompactar o tempo de execução. Isso foi ideal porque ele combinou a cena com basicamente a mesma estrutura de dados manipulada durante a renderização e a animação. Pouca análise precisa ser feita no arquivo quando carregado. Descompactar em Flash foi bem rápido porque o arquivo estava no formato AMF, que o Flash poderia descompactar nativamente. Usar o mesmo formato em WebGL requer um pouco mais de trabalho na CPU. Na verdade, tivemos que recriar uma camada de código JavaScript descompactada de dados, que essencialmente descompactaria esses arquivos e recriaria as estruturas de dados necessárias para o funcionamento do WebGL. Descompactar toda a cena 3D é uma operação levemente pesada com uso intenso de CPU: descompactar a cena 1 em Encontre seu caminho até Oz leva cerca de dois segundos em uma máquina de médio a alto desempenho. Portanto, isso é feito usando a tecnologia Web Workers, no momento da "configuração da cena" (antes de a cena ser realmente iniciada), para não travar a experiência para o usuário.

Essa ferramenta útil pode importar a maior parte da cena 3D: modelos, texturas, 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 nessa biblioteca e, pronto, os gera na cena.

No entanto, um problema que tínhamos era que agora estávamos lidando com a WebGL: a nova criança na área. Foi um jovem bastante complicado: era definir o padrão para experiências 3D baseadas em navegadores. Por isso, criamos uma camada JavaScript ad hoc que traduz os arquivos de cena em 3D compactados da 3D Librarian para que sejam traduzidos corretamente para um formato que o WebGL entenda.

Tutorial: Deixe haver vento

Um tema recorrente em "Encontre seu caminho até Oz" foi o vento. Um trecho do enredo está estruturado como um crescendo de vento.

A primeira cena do carnaval é relativamente calma. Além disso, ao passar pelas várias cenas, o usuário experimenta um vento progressivamente mais forte, culminando na cena final, a tempestade.

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

Para criar isso, preenchemos as três cenas do carnaval com objetos macios e, portanto, afetados pelo vento, como barracas, sinalizando a superfície da cabine de fotos e o próprio balão.

Tecido macio.

Atualmente, os jogos de computador são construídos em torno de um mecanismo de física central. Assim, quando um objeto macio precisa ser simulado no mundo 3D, uma simulação de física completa é executada para ele, criando um comportamento suave realista.

Em WebGL / JavaScript, não temos (ainda) o luxo de executar uma simulação física completa. Em Oz, tivemos que encontrar uma maneira de criar o efeito do vento, sem simular.

Incorporamos as informações de "sensibilidade ao vento" para cada objeto no próprio modelo 3D. Cada vértice do modelo 3D tinha um "Atributo de vento" que especificava quanto aquele vértice seria afetado pelo vento. Essa foi a sensibilidade ao vento de objetos 3D. Depois, precisamos criar o próprio vento.

Para isso, geramos uma imagem contendo Perlin Noise. O objetivo desta imagem é cobrir uma determinada "área de vento". Uma boa maneira de pensar nisso é imaginar a imagem de uma nuvem, como um ruído, sendo posicionada sobre uma certa área retangular da cena em 3D. Cada pixel, valor de nível cinza, dessa imagem especifica a intensidade do vento em um determinado momento na área 3D "ao seu redor".

Para produzir o efeito do vento, a imagem se move, 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 no cenário, envolvemos a imagem do vento nas bordas, confinada à área de efeito.

Tutorial simples em 3D sobre vento

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

Vamos criar vento em um simples "campo gramado processual".

Primeiro, vamos criar a cena. Teremos um terreno plano simples e texturizado. E cada pedaço de grama será simplesmente representado por um cone 3D de cabeça para baixo.

Terreno repleto de grama
Terreno cheio de vegetação

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

Primeiro, vamos configurar o Three.js e conectá-lo à câmera, ao controle do mouse e à iluminação, das seguintes formas:

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 por 15 bits de grama. Acrescentamos um pouco de randomização para cada posição na grama, para que elas não se alinhem como soldados, o que ficaria estranho.

Este terreno é apenas um plano horizontal, colocado na base de pedaços 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 )

O que fizemos até agora foi simplesmente criar uma cena Three.js e adicionar alguns pedaços de grama, feitos de cones invertidos gerados processualmente e um terreno simples.

Nada sofisticado até agora.

Agora, é hora de começar a adicionar 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 do Grass. E vamos usar a regra que: a extremidade inferior do modelo de grama (ponta do cone) tem sensibilidade zero, porque está presa ao solo. A parte de cima do modelo de grama (base do cone) tem sensibilidade máxima ao vento, porque é a parte mais distante do solo.

Veja como a função instanceGrass é recodificada para adicionar a sensibilidade ao vento como um atributo personalizado do modelo 3D da 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 usado anteriormente. WindMaterial une o WindMeshShader que vamos ver em breve.

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

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

Para maior clareza, vamos atribuir essa textura ao próprio terreno, em vez da textura verde anterior que ele tinha. Assim, será mais fácil ter uma noção do que está acontecendo com o vento.

Portanto, essa textura de ruído de Perlin cobrirá espacialmente a extensão do nosso terreno, e cada pixel da textura especificará a intensidade do vento na área do terreno onde o pixel cai. O retângulo de terreno será nossa “área de vento”.

O ruído Perlin é gerado processualmente por um sombreador, chamado NoiseShader. Esse sombreador usa algoritmos de ruído simples 3D de: https://github.com/ashima/webgl-noise (link em inglês). A versão 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 (em inglês).

O NoiseShader usa um tempo, uma escala e um conjunto de parâmetros de deslocamento, como uniformes, e gera uma boa distribuição 2D do 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 o 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 que o código acima faz é configurar o noiseMap como um destino de renderização do Three.js, equipá-lo com o NoiseShader e renderizá-lo com uma câmera ortográfica para evitar distorções de perspectiva.

Conforme discutido, agora vamos usar essa textura também como a principal textura de renderização do terreno. Isso não é necessário para que o efeito do vento em si funcione. Mas isso é bom, para que possamos entender melhor visualmente o que está acontecendo com a geração de vento.

Confira a função initTerrain reformulada usando ruídoMap 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 já definimos a textura do vento, vamos analisar o WindMeshShader, responsável por deformar os modelos de grama conforme o vento.

Para criar esse sombreador, começamos a usar o sombreador MeshPhongMaterial padrão Three.js e o modificamos. Essa é uma boa maneira rápida e suja de começar a usar um sombreador que funciona, sem ter que 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 dar uma olhada nas peças modificadas e relacionadas ao vento no 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;

O que esse sombreador faz é primeiro calcular a coordenada de pesquisa da textura windUV, com base na posição 2D, xz (horizontal) do vértice. Essa coordenada UV é usada para encontrar a força do vento, vWindForce, na textura de vento com ruído de Perlin.

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

Isso cria deformação baseada no vento dos pedaços de grama. No entanto, ainda não terminamos. Como acontece agora, essa deformação é estática e não transmite o efeito de uma área com vento.

Como mencionamos, precisamos deslizar a textura do ruído ao longo do tempo, na área do vento, para que nosso vidro possa ondular.

Isso é feito mudando ao longo do tempo, o uniforme vOffset que é transmitido para o NoiseShader. Este é um parâmetro vec2, que nos permitirá especificar o deslocamento de ruído ao longo de uma determinada direção (nossa direção do vento).

Fazemos isso na função render, que é chamada a cada frame:

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

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

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

Adicionar poeira à mistura

Agora, vamos incrementar um pouco nossa cena. Vamos adicionar um pouco de poeira voadora para tornar a cena mais interessante.

Adicionando poeira
Adicionar poeira

Afinal, a poeira deve ser afetada pelo vento, então faz sentido ter poeira voando em nosso cenário de vento.

A poeira é configurada 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. Observe que cada um deles é equipado com um WindParticleShader especial.

Agora, em cada frame, vamos nos mover um pouco em torno das partículas, usando o CoffeeScript, independentemente do vento. Este é 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 deslocar cada posição de cada 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, com a aparência do núcleo:

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

mvPosition = modelViewMatrix *  pos;

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

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

gl_Position = projectionMatrix * mvPosition;

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 Perlin como entrada e, dependendo da posição do mundo da poeira, procura um valor vWindForce, na textura do ruído. Em seguida, ele usa esse valor para modificar a posição da partícula de poeira.

Cavaleiros na Tempestade

A mais aventureira das nossas cenas WebGL foi provavelmente a última, que você pode ver se clicar no balão dentro do tornado para chegar ao final da sua jornada no site, além de um vídeo exclusivo do próximo lançamento.

Cenário de passeio de balão

Quando criamos essa cena, sabíamos que precisávamos de uma característica central para a experiência que fosse impactante. O tornado giratório funcionava como o elemento central, e as camadas de outros conteúdos moldariam esse recurso para criar um efeito dramático. Para isso, construímos o que seria o equivalente a um estúdio de cinema em torno desse sombreador estranho.

Usamos uma abordagem mista para criar o composto realista. Alguns são truques visuais, como formas de luz, para criar um efeito de alargamento de lente, ou gotas de chuva que são animadas como camadas sobre a cena que você está vendo. Em outros casos, tínhamos superfícies planas desenhadas para parecerem se mover, como as camadas de nuvens baixas se movendo de acordo com um código do sistema de partículas. Enquanto os restos de detritos que orbitavam o tornado eram camadas de uma cena em 3D ordenadas para se mover na frente e atrás do tornado.

A principal razão pela qual tivemos que criar a cena dessa forma foi garantir que tivéssemos GPU suficiente para lidar com o sombreador de tornado em equilíbrio com os outros efeitos que estávamos aplicando. No início, tínhamos grandes problemas de equilíbrio de GPU, mas depois esse cenário foi otimizado e ficou mais leve do que as cenas principais.

Tutorial: O sombreador de tempestade

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

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

Dentro de um neurônio do mouse usando um sombreador volumétrico personalizado
Dentro de um neurônio do mouse 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. E, como estávamos usando uma técnica volumétrica, sabíamos que era possível visualizar esse sombreador de todas as direções no espaço. Poderíamos configurar a renderização do sombreador para combinar com a cena de tempestade, especialmente se preso sob camadas de nuvens e sobre um fundo dramático.

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

Uma boa referência ao algoritmo pode ser encontrada na visão geral do iq: Rendering Worlds With Two Triangles - Iñigo Quilez. Além disso, confira a galeria de sombreadores em glsl.heroku.com. É possível encontrar vários exemplos dessa técnica que podem ser testados.

O centro do sombreador começa com a função principal. Ele configura as transformações da câmera e entra em um loop que avalia repetidamente a distância até uma superfície. O cálculo da marcha do raio do núcleo é feito na chamada RaytraceFoggy( transmissão_vetor, max_iterations, cor, color_multiplier ).

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 para a forma do tornado, adicionamos regularmente contribuições de cor ao valor final da cor do pixel, bem como contribuições para a opacidade ao longo do raio. Isso cria uma qualidade suave em camadas para a textura do tornado.

O próximo aspecto central do tornado é a própria forma, que é criada pela composição de várias funções. Para começar, é um cone que é composto por ruído para criar uma borda orgânica áspera, sendo posteriormente 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 com a abstração das operações que você está criando, existem problemas sérios de otimização e compatibilidade entre plataformas que você precisa rastrear e resolver antes de usar o trabalho na produção.

A primeira parte do problema: otimizar esse sombreador para nossa cena. Para lidar com isso, precisávamos de uma abordagem "segura" para o caso de o sombreador ficar muito pesado. Para fazer isso, combinamos o sombreador de tornado com uma resolução de amostra diferente do restante da cena. Isso é do arquivo stormTest.coffee (sim, isso foi um teste!).

Começamos com um renderTarget que corresponda à largura e altura da cena para que possamos ter independência da resolução do sombreador de tornado para a cena. Depois, decidimos que a redução da resolução da resolução do sombreador de tempestade depende dinamicamente do frame rate que estamos obtendo.

...
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 para a tela usando um algoritmo sal2x simplificado (para evitar a aparência em blocos) @line 1107 em stormTest.coffee. Na pior das hipóteses, temos um tornado mais desfocado, mas pelo menos ele funciona sem tirar o controle do usuário.

A próxima etapa da otimização exige que você se aprofunde no algoritmo. O fator computacional determinante no sombreador é a iteração executada em cada pixel para tentar aproximar a distância da função da superfície: o número de iterações do loop de raio. Usando uma taxa de aprendizado maior, podemos obter uma estimativa da superfície de tornado com menos iterações enquanto estávamos fora da superfície nublada. Já no interior, o tamanho da taxa de aprendizado diminui para maior precisão e para poder misturar valores e criar o efeito de névoa. Além disso, a criação de um cilindro delimitador para estimar a profundidade do raio lançado proporcionou uma boa velocidade.

A próxima parte do problema era garantir que esse sombreador fosse executado em diferentes placas de vídeo. Fizemos alguns testes todas as vezes e começamos a criar uma intuição sobre os tipos de problemas de compatibilidade que poderíamos encontrar. O motivo pelo qual não podemos fazer muito melhor do que a intuição é que nem sempre conseguimos obter boas informações de depuração sobre os erros. Um cenário típico é apenas um erro de GPU com pouco mais para acontecer ou até mesmo uma falha do sistema.

Problemas de compatibilidade de placas de vídeo tinham soluções semelhantes: verifique se as constantes estáticas estavam inseridas com o tipo de dados preciso, conforme definido, ou seja: 0.0 para flutuação e 0 para int. Tenha cuidado ao escrever funções mais longas. É preferível dividir tudo em várias funções mais simples e variáveis provisórias, porque os compiladores parecem não lidar com determinados casos corretamente. Certifique-se de que as texturas tenham uma potência de 2, não muito grandes e, em qualquer caso, exerça “cautela” ao procurar dados de textura em loop.

Os maiores problemas que tivemos em relação à compatibilidade foram relacionados ao efeito de iluminação da tempestade. Usamos uma textura pronta em volta do tornado para colorir os fios. Foi um efeito maravilhoso e facilitou a mistura do tornado com as cores da cena, mas levou muito tempo para tentar correr em outras plataformas.

tornado

O site móvel

A experiência móvel não poderia ser uma tradução direta da versão desktop porque os requisitos de tecnologia e processamento eram muito pesados. Tivemos que criar algo novo, que fosse direcionado especificamente ao usuário de celular.

Pensamos que seria legal ter o Photo-Booth do Carnival do computador como um aplicativo da Web para dispositivos móveis que usaria a câmera do celular do usuário. Algo que não vimos até agora.

Para dar mais sabor, codificamos as transformações 3D no CSS3. Depois de vincular o modelo ao giroscópio e acelerômetro, conseguimos adicionar mais profundidade à experiência. O site responde à maneira como você segura, se move e olha para seu celular.

Ao escrever este artigo, pensamos que valeria a pena dar a você algumas dicas sobre como executar o processo de desenvolvimento para dispositivos móveis sem problemas. Vamos lá! Vá em frente e veja o que você pode aprender com isso.

Dicas e truques para dispositivos móveis

O pré-carregador é algo que é necessário, não algo que deve ser evitado. Sabemos que, às vezes, isso acontece. Isso ocorre principalmente porque você precisa manter a lista de itens pré-carregados à medida que seu projeto cresce. Para piorar, não está muito claro como calcular o progresso do carregamento se você estiver usando recursos diferentes, e muitos deles ao mesmo tempo. É aqui que nossa classe abstrata personalizada e muito genérica "Task" é útil. A ideia principal é permitir uma estrutura infinitamente aninhada em que uma tarefa pode ter suas próprias subtarefas, que podem ter suas outras tarefas. Além disso, cada tarefa calcula o progresso em relação ao progresso das subtarefas (mas não para o progresso do pai). Ao fazer com que MainPreloadTask, AssetPreloadTask e TemplatePreFetchTask sejam derivados de Task, criamos uma estrutura parecida com esta:

Pré-carregador

Graças a essa abordagem e à classe Task, podemos saber facilmente o progresso global (MainPreloadTask), ou apenas o progresso de recursos (AssetPreloadTask) ou o progresso de carregamento de modelos (TemplatePreFetchTask). até o progresso de um arquivo específico; Para ver como isso é feito, confira a classe Task em /m/javascripts/raw/util/Task.js e as implementações de tarefa reais em /m/javascripts/preloading/task. Como exemplo, esta é uma extração de como configuramos a classe /m/javascripts/preloading/task/MainPreloadTask.js, que é o 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 MainPreloadTask (através da implementação de tarefa compartilhada), também vale a pena observar como carregamos recursos que dependem da plataforma. Basicamente, há quatro tipos de imagens. Padrão para dispositivos móveis (.ext, onde ext é a extensão de arquivo, normalmente .png ou .jpg), para tela retina para dispositivos móveis (-2x.ext), padrão para tablet (-tab.ext) e tela de retina para tablets (-tab-2x.ext). Em vez de fazer a detecção em MainPreloadTask e fixar no código quatro matrizes de ativos, apenas dizemos qual é o nome e a extensão do ativo a ser pré-carregado e se o ativo depende da plataforma (responsivo = verdadeiro / falso). Então, AssetPreloadTask gerará o nome do arquivo para nós:

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 real que faz o pré-carregamento de recursos é semelhante a este (/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 HTML5 (iOS6/Android)

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

Cabine de fotos para dispositivos móveis
Cabine de fotos para dispositivos móveis

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

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

Para configurá-lo, você precisa de uma instância sem custo financeiro do aplicativo Google App Engine onde possa executar o back-end. O código do front-end não é complexo, mas há alguns possíveis problemas. Vamos conferi-las agora:

  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 uma cabine de vídeo. Em teoria, é possível simplesmente especificar o filtro em HTML, da seguinte maneira: input id="fileInput" class="fileInput" type="file" name="file" accept="image/*". No entanto, isso parece funcionar apenas no iOS. Por isso, precisamos adicionar outra verificação de RegExp assim 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. Cancelar um upload ou a seleção de arquivos Outra inconsistência durante o processo de desenvolvimento é a forma como diferentes dispositivos notificam uma seleção de arquivo cancelada. Smartphones e tablets iOS não fazem nada e não notificam nada. Portanto, não precisamos de nenhuma ação especial para esse caso. No entanto, os smartphones Android acionam a função add(), mesmo que nenhum arquivo seja selecionado. Veja como resolver 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 resto funciona bem em todas as plataformas. Divirta-se.

Conclusão

Considerando o tamanho enorme do recurso "Encontre seu caminho até Oz" e a ampla variedade de tecnologias envolvidas, neste artigo abordamos apenas algumas das abordagens que usamos.

Se você quiser explorar toda a enchilada, fique à vontade para dar uma olhada no código-fonte completo de Encontre seu caminho até Oz neste link.

Créditos

Clique aqui para consultar a lista completa de créditos

Referências