Fiquei muito feliz quando a equipe de artes de dados do Google entrou em contato com Moniker e comigo para trabalharmos juntos e explorar as possibilidades apresentadas pelo WebVR. Acompanhei o trabalho da equipe ao longo dos anos, e os projetos dela sempre me chamaram a atenção. Nossa colaboração resultou no Dance Tonite, uma experiência de dança em RV em constante mudança com LCD Soundsystem e seus fãs. Confira como fizemos isso.
O conceito
Começamos desenvolvendo uma série de protótipos usando a WebVR, um padrão aberto que possibilita entrar na RV visitando um site usando o navegador. O objetivo é facilitar a entrada de todos nas experiências de RV, independente do dispositivo.
Levamos isso a sério. O que temos criado deve funcionar em todos os tipos de RV, desde os headsets de RV que funcionam com smartphones, como o Daydream View do Google, o Cardboard e o Gear VR da Samsung, até os sistemas de escala ambiente, como o HTC VIVE e o Oculus Rift, que refletem seus movimentos físicos no ambiente virtual. Talvez o mais importante seja que acreditamos que seria no espírito da Web criar algo que também funcionasse para todos que não têm um dispositivo de RV.
1. Captura de movimento "aprenda a fazer"
Como queríamos envolver os usuários de forma criativa, começamos a analisar as possibilidades de participação e autoexpressão usando a RV. Ficamos impressionados com a precisão dos movimentos e da visão em RV e com a fidelidade da experiência. Isso nos deu uma ideia. Em vez de fazer com que os usuários olhem ou criem algo, que tal registrar os movimentos deles?
Preparamos um protótipo em que registramos a posição dos nossos óculos e controladores de RV enquanto dançamos. Substituímos as posições gravadas por formas abstratas e ficamos maravilhados com os resultados. Os resultados foram muito humanos e contiveram muita personalidade. Rapidamente percebemos que poderíamos usar o WebVR para fazer capturas de movimento baratas em casa.
Com a WebVR, o desenvolvedor tem acesso à posição da cabeça e à orientação do usuário pelo objeto VRPose. Esse valor é atualizado a cada frame pelo hardware de RV para que o código possa renderizar novos frames do ponto de vista correto. Com a API GamePad com a WebVR, também é possível acessar a posição/orientação dos controles dos usuários pelo objeto GamepadPose. Simplesmente armazenamos todos esses valores de posição e orientação em cada frame, criando uma "gravação" dos movimentos do usuário.
2. Minimalismo e fantasias
Com o equipamento atual de RV da escala da sala, podemos rastrear três pontos do corpo do usuário: a cabeça e as mãos. Em Dance Tonite, queríamos manter o foco na humanidade no movimento desses três pontos no espaço. Para conseguir isso, enxergamos o mínimo possível na estética e nos concentramos no movimento. Gostamos da ideia de colocar os cérebros das pessoas para trabalhar.
Este vídeo que demonstra o trabalho do psicólogo sueco Gunnar Johansson foi um dos exemplos que usamos ao considerar a simplificação o máximo possível. Ele mostra como pontos brancos flutuantes são instantaneamente reconhecidos como corpos quando vistos em movimento.
Visualmente, nos inspiramos nas salas coloridas e nos figurinos geométricos desta gravação de Margarete Hastings de 1970, que recriou o Ballet Triádico de Oskar Schlemmer.
Enquanto a razão de Schlemmer para escolher fantasias geométricas abstratas era limitar os movimentos dos dançarinos aos de marionetes, nós tínhamos o objetivo oposto para o Dance Tonite.
A escolha das formas foi baseada na quantidade de informações que elas transmitiam por rotação. Uma esfera tem a mesma aparência, não importa como é girada, mas um cone realmente aponta na direção que está olhando e tem um visual diferente na frente e na parte de trás.
3. Pedal loop para movimento
Queríamos mostrar grandes grupos de pessoas gravadas dançando e se movendo juntos. Fazer isso ao vivo não seria viável, já que os dispositivos de RV não estão disponíveis em números suficientes. Mas ainda queríamos ter grupos de pessoas reagindo um ao outro por meio do movimento. Nossas mentes foram para a performance recursiva de Norman McClaren na peça de vídeo de 1964 "Canon".
A apresentação de McClaren apresenta uma série de movimentos altamente coreografados que começam a interagir entre si após cada loop. Assim como um pedal de loop na música, em que os músicos improvisam com diferentes camadas de música ao vivo, queríamos saber se seria possível criar um ambiente em que os usuários pudessem improvisar versões mais livres de apresentações.
4. Salas interligadas
Como muitas músicas, as faixas do LCD Soundsystem são criadas usando medidas precisas. A música deles, "Tonite", que está no nosso projeto, tem medidas de exatamente 8 segundos. Queríamos que os usuários fizessem uma performance para cada loop de 8 segundos na faixa. Embora o ritmo desses compassos não mude, o conteúdo musical deles muda. À medida que a música avança, há momentos com instrumentos e vocais diferentes aos quais os artistas podem reagir de maneiras diferentes. Cada uma dessas medidas é expressa como uma sala, em que as pessoas podem fazer um desempenho adequado a ela.
Otimizações de desempenho: não perca frames
Criar uma experiência de RV multiplataforma que seja executada em uma única base de código com desempenho ideal para cada dispositivo ou plataforma não é uma tarefa simples.
Uma das coisas mais enjoativas que você pode experimentar na RV é quando a taxa de frames não acompanha seu movimento. Se você virar a cabeça, mas as imagens que seus olhos veem não corresponderem ao movimento que seu ouvido interno sente, isso causa uma sensação de enjoo instantânea. Por isso, precisamos evitar qualquer atraso de taxa de frames. Confira algumas otimizações que implementamos.
1. Geometria de buffer instanciada
Como todo o projeto usa apenas alguns objetos 3D, conseguimos
um grande aumento de desempenho usando a geometria de buffer instanciado. Basicamente, ele
permite fazer upload do objeto para a GPU uma vez e desenhar quantas "instâncias"
desse objeto quiser em uma única chamada de renderização. No Dance Tonite, temos apenas três
objetos diferentes (um cone, um cilindro e uma sala com um buraco), mas potencialmente
centenas de cópias desses objetos. A geometria do buffer de instância faz parte do ThreeJS, mas usamos a bifurcação experimental e em andamento de Dusan Bosnjak que implementa THREE.InstanceMesh
, o que facilita muito o trabalho com o Instanced Buffer Geometry.
2. Evitando o coletor de lixo
Como muitas outras linguagens de script, o JavaScript libera memória automaticamente encontrando quais objetos alocados não estão mais em uso. Esse processo é chamado de coleta de lixo.
Os desenvolvedores não têm controle sobre quando isso acontece. O coletor de lixo pode aparecer na nossa porta a qualquer momento e começar a esvaziar o lixo, resultando em frames descartados à medida que eles levam o tempo que quiserem.
A solução para isso é produzir o mínimo de lixo possível reciclando nossos objetos. Em vez de criar um novo objeto vetorial para cada cálculo, marcamos objetos de rascunho para reutilização. Como os mantivemos movendo nossa referência para fora do nosso escopo, eles não foram marcados para remoção.
Por exemplo, este é nosso código para converter a matriz de localização da cabeça
e das mãos do usuário na matriz de valores de posição/rotação que armazenamos em cada frame. Ao
reutilizar SERIALIZE_POSITION
, SERIALIZE_ROTATION
e SERIALIZE_SCALE
,
evitamos a alocação de memória e a coleta de lixo que ocorreria se
criássemos novos objetos sempre que a função fosse chamada.
const SERIALIZE_POSITION = new THREE.Vector3();
const SERIALIZE_ROTATION = new THREE.Quaternion();
const SERIALIZE_SCALE = new THREE.Vector3();
export const serializeMatrix = (matrix) => {
matrix.decompose(SERIALIZE_POSITION, SERIALIZE_ROTATION, SERIALIZE_SCALE);
return SERIALIZE_POSITION.toArray()
.concat(SERIALIZE_ROTATION.toArray())
.map(compressNumber);
};
3. Serialização de movimento e reprodução progressiva
Para capturar os movimentos dos usuários em RV, precisávamos serializar a posição e a rotação dos fones de ouvido e dos controles e fazer upload desses dados para nossos servidores. Começamos capturando as matrizes de transformação completas para cada frame. Isso teve um bom desempenho, mas com 16 números multiplicados por três posições cada a 90 quadros por segundo, o que gerava arquivos muito grandes e, portanto, longas esperas durante o upload e o download dos dados. Ao extrair apenas os dados de posição e rotação das matrizes de transformação, foi possível reduzir esses valores de 16 para 7.
Como os visitantes da Web geralmente clicam em um link sem saber exatamente o que esperar, precisamos mostrar o conteúdo visual rapidamente, ou eles vão sair em segundos.
Por esse motivo, queríamos ter certeza de que nosso projeto pudesse começar a funcionar o mais rápido possível. Inicialmente, usávamos JSON como um formato para carregar nossos dados de movimento. O problema é que temos que carregar o arquivo JSON completo antes de analisá-lo. Não é muito progressiva.
Para manter um projeto como o Dance Tonite sendo exibido na maior taxa de frames possível, o navegador tem apenas uma pequena quantidade de tempo em cada frame para cálculos do JavaScript. Se você demorar muito, as animações vão começar a travar. No início, o navegador travava ao decodificar esses arquivos JSON enormes.
Encontramos um formato de dados de streaming conveniente chamado NDJSON ou JSON delimitado por nova linha. O truque aqui é criar um arquivo com uma série de strings JSON válidas, cada uma na própria linha. Isso permite analisar o arquivo enquanto ele está sendo carregado, permitindo que mostremos as performances antes que elas sejam totalmente carregadas.
Confira como é uma seção de uma das nossas gravações:
{"fps":15,"count":1,"loopIndex":"1","hideHead":false}
[-464,17111,-6568,-235,-315,-44,9992,-3509,7823,-7074, ... ]
[-583,17146,-6574,-215,-361,-38,9991,-3743,7821,-7092, ... ]
[-693,17158,-6580,-117,-341,64,9993,-3977,7874,-7171, ... ]
[-772,17134,-6591,-93,-273,205,9994,-4125,7889,-7319, ... ]
[-814,17135,-6620,-123,-248,408,9988,-4196,7882,-7376, ... ]
[-840,17125,-6644,-173,-227,530,9982,-4174,7815,-7356, ... ]
[-868,17120,-6670,-148,-183,564,9981,-4069,7732,-7366, ... ]
...
O uso de NDJSON permite manter a representação de dados dos frames individuais das performances como strings. Poderíamos esperar até chegarmos ao tempo necessário antes de decodificar os dados posicionais, distribuindo o processamento necessário ao longo do tempo.
4. Movimento de interpolação
Como queríamos exibir entre 30 e 60 performances ao mesmo tempo, precisávamos reduzir ainda mais a taxa de dados. A equipe de artes de dados resolveu o mesmo problema no projeto Virtual Art Sessions, em que eles tocam gravações de artistas pintando em RV usando o Tilt Brush. Eles resolveram o problema criando versões intermediárias dos dados do usuário com taxas de frames mais baixas e interpolando entre os frames durante a reprodução. Ficamos surpresos ao descobrir que mal conseguimos notar a diferença entre uma gravação interpolada executada a 15 QPS em comparação com a gravação original de 90 QPS.
Para conferir, você pode forçar o Dance Tonite a reproduzir os dados em várias
taxas usando a string de consulta ?dataRate=
. Você pode usar isso para comparar o
movimento gravado a 90 quadros por segundo, 45
quadros por segundo ou 15 quadros por
segundo.
Para a posição, fazemos uma interpolação linear entre o frame-chave anterior e o seguinte, com base na proximidade entre os frames-chave (proporção):
const { x: x1, y: y1, z: z1 } = getPosition(previous, performanceIndex, limbIndex);
const { x: x2, y: y2, z: z2 } = getPosition(next, performanceIndex, limbIndex);
interpolatedPosition = new THREE.Vector3();
interpolatedPosition.set(
x1 + (x2 - x1) * ratio,
y1 + (y2 - y1) * ratio,
z1 + (z2 - z1) * ratio
);
Para orientação, fazemos uma interpolação linear esférica (slerp, na sigla em inglês) entre os frames-chave. A orientação é armazenada como quaterniões.
const quaternion = getQuaternion(previous, performanceIndex, limbIndex);
quaternion.slerp(
getQuaternion(next, performanceIndex, limbIndex),
ratio
);
5. Sincronizando movimentos com a música
Para saber qual frame das animações gravadas será reproduzido, precisamos saber o tempo atual da música até o milissegundo. Embora o elemento de áudio HTML seja perfeito para carregar e reproduzir sons progressivamente, a propriedade de tempo fornecida não muda de acordo com o loop de frames do navegador. Ele sempre está um pouco errado. Às vezes, uma fração de um ms antes, às vezes uma fração depois.
Isso leva a falhas nas nossas gravações de dança, o que queremos evitar a todo custo. Para corrigir isso, implementamos nosso próprio timer em JavaScript. Dessa forma, podemos ter certeza de que a quantidade de tempo que muda entre os frames é exatamente a quantidade de tempo que passou desde o último frame. Sempre que o cronômetro fica mais de 10 ms fora de sincronia com a música, ele é sincronizado novamente.
6. Descascamento e neblina
Toda história precisa de um bom final, e queríamos fazer algo surpreendente para os usuários que chegaram ao fim da experiência. Ao sair do último cômodo, você entra em uma paisagem tranquila de cones e cilindros. "Este é o fim?", você se pergunta. À medida que você se afasta do campo, os tons da música fazem com que diferentes grupos de cones e cilindros se transformem em dançarinos. Você está no meio de uma festa enorme! Quando a música para abruptamente, tudo cai no chão.
Embora isso fosse ótimo para os espectadores, ele apresentava alguns obstáculos de performance para resolver. Os dispositivos de RV de sala e os equipamentos de jogos de última geração funcionaram perfeitamente com as 40 apresentações extras necessárias para o novo final. No entanto, as taxas de frames em alguns dispositivos móveis foram reduzidas pela metade.
Para neutralizar isso, introduzimos a neblina. Depois de uma certa distância, tudo lentamente fica preto. Como não precisamos calcular ou desenhar o que não está visível, eliminamos as performances em salas que não estão visíveis. Isso nos permite salvar trabalho para a CPU e a GPU. Mas como decidir a distância certa?
Alguns dispositivos podem lidar com qualquer coisa que você jogar neles, e outros são mais restritos. Escolhemos implementar uma escala deslizante. Ao medir continuamente a quantidade de frames por segundo, podemos ajustar a distância da névoa. Enquanto a taxa de frames estiver funcionando sem problemas, vamos tentar fazer mais renderizações removendo a névoa. Se a taxa de frames não estiver funcionando de maneira suave, vamos aproximar a neblina, permitindo que você pule a renderização em ambientes escuros.
// this is called every frame
// the FPS calculation is based on stats.js by @mrdoob
tick: (interval = 3000) => {
frames++;
const time = (performance || Date).now();
if (prevTime == null) prevTime = time;
if (time > prevTime + interval) {
fps = Math.round((frames * 1000) / (time - prevTime));
frames = 0;
prevTime = time;
const lastCullDistance = settings.cullDistance;
// if the fps is lower than 52 reduce the cull distance
if (fps <= 52) {
settings.cullDistance = Math.max(
settings.minCullDistance,
settings.cullDistance - settings.roomDepth
);
}
// if the FPS is higher than 56, increase the cull distance
else if (fps > 56) {
settings.cullDistance = Math.min(
settings.maxCullDistance,
settings.cullDistance + settings.roomDepth
);
}
}
// gradually increase the cull distance to the new setting
cullDistance = cullDistance * 0.95 + settings.cullDistance * 0.05;
// mask the edge of the cull distance with fog
viewer.fog.near = cullDistance - settings.roomDepth;
viewer.fog.far = cullDistance;
}
Algo para todos: criar RV para a Web
Projetar e desenvolver experiências multiplataforma e assimétricas significa considerar as necessidades de cada usuário, dependendo do dispositivo. A cada decisão de design, precisávamos ver como isso poderia impactar outros usuários. Como você garante que o que você vê em RV seja tão emocionante quanto sem RV e vice-versa?
1. A esfera amarela
Os usuários de RV de sala inteira fariam as apresentações, mas como os usuários de dispositivos de RV para dispositivos móveis (como Cardboard, Daydream View ou Samsung Gear) iriam interagir com o projeto? Para isso, introduzimos um novo elemento em nosso ambiente: a esfera amarela.
Ao assistir o projeto em RV, você está fazendo isso do ponto de vista do globo amarelo. Conforme você flutua de uma sala para outra, os dançarinos reagem à sua presença. Ele faz gestos para você, dança ao seu redor, faz movimentos engraçados pelas suas costas e se afasta rapidamente para não esbarrar em você. O orbe amarelo é sempre o centro das atenções.
O motivo é que, ao gravar uma performance, o orbe amarelo se move pelo centro da sala em sincronia com a música e faz um loop. A posição do orbe dá ao artista uma ideia de onde ele está no tempo e de quanto tempo ele tem no loop. Isso fornece um foco natural para a criação de um desempenho.
2. Outro ponto de vista
Não queríamos deixar de fora os usuários sem RV, principalmente porque eles provavelmente seriam nosso maior público. Em vez de criar uma experiência de RV falsa, queríamos oferecer aos dispositivos com tela uma experiência própria. Tivemos a ideia de mostrar os desempenhos acima de uma perspectiva isométrica. Essa perspectiva tem uma história rica em jogos de computador. Ele foi usado pela primeira vez em Zaxxon, um jogo de tiro espacial de 1982. Enquanto os usuários de RV estão no meio da ação, a perspectiva isométrica oferece uma visão divina da ação. Escolhemos aumentar um pouco os modelos, um toque de estética de casa de bonecas.
3. Sombras: finja até conseguir
Descobrimos que alguns usuários estavam com dificuldade para perceber a profundidade no ponto de vista isométrico. Tenho certeza de que é por esse motivo que o Zaxxon também foi um dos primeiros jogos de computador da história a projetar uma sombra dinâmica sob seus objetos voadores.
Fazer sombras em 3D é difícil. Especialmente para dispositivos restritos, como smartphones. Inicialmente, tivemos que tomar a difícil decisão de retirá-los da equação, mas depois de pedir conselhos ao autor do Three.js e hacker de demonstração experiente Mr doob, ele teve a ideia inovadora de... simular.
Em vez de ter que calcular como cada um dos nossos objetos flutuantes oculta nossas luzes e, portanto, projetando sombras de diferentes formas, desenhamos a mesma imagem circular de textura desfocada sob cada um deles. Como nossos recursos visuais não tentam imitar a realidade, descobrimos que podemos fazer isso com facilidade com apenas algumas mudanças. Quando os objetos se aproximam do solo, as texturas ficam mais escuras e menores. Quando elas se movem para cima, as texturas ficam mais transparentes e maiores.
Para criá-los, usamos esta textura com um gradiente suave de branco para preto (sem transparência Alfa). Definimos o material como transparente e usamos a mistura subtrativa. Isso ajuda a misturar bem quando eles se sobrepõem:
function createShadow() {
const texture = new THREE.TextureLoader().load(shadowTextureUrl);
const material = new THREE.MeshLambertMaterial({
map: texture,
transparent: true,
side: THREE.BackSide,
depthWrite: false,
blending: THREE.SubtractiveBlending,
});
const geometry = new THREE.PlaneBufferGeometry(0.5, 0.5, 1, 1);
const plane = new THREE.Mesh(geometry, material);
return plane;
}
4. Estar lá
Ao clicar nas cabeças de um artista, os visitantes sem RV podem assistir as coisas do ponto de vista do dançarino. Nesse ângulo, muitos pequenos detalhes ficam aparentes. Para tentar manter a sincronia, os dançarinos olham rapidamente uns para os outros. Quando a esfera entra na sala, você vê os personagens olhando nervosamente na direção dela. Embora o espectador não possa influenciar esses movimentos, eles transmitem a sensação de imersão de maneira surpreendente. Mais uma vez, preferimos fazer isso em vez de apresentar aos nossos usuários uma versão falsa de RV controlada por mouse.
5. Compartilhando gravações
Sabemos que você pode ter orgulho de gravar uma gravação complexa e coreografada de 20 camadas de artistas reagindo uns aos outros. Sabíamos que nossos usuários provavelmente iriam querer mostrar o recurso aos amigos. Mas uma imagem estática desse feito não comunica o suficiente. Em vez disso, queríamos permitir que os usuários compartilhassem vídeos das apresentações. Na verdade, por que não um GIF? Nossas animações são sombreadas, perfeitas para as paletas de cores limitadas do formato.
Usamos a GIF.js, uma biblioteca JavaScript que permite codificar GIFs animados a partir do navegador. Ele reduz a codificação de frames para workers da Web que podem ser executados em segundo plano como processos separados, podendo aproveitar vários processadores trabalhando lado a lado.
Infelizmente, com a quantidade de frames necessários para as animações, o processo de codificação ainda era muito lento. O GIF pode criar arquivos pequenos usando uma paleta de cores limitada. Descobrimos que a maior parte do tempo era gasto encontrando a cor mais próxima para cada pixel. Conseguimos otimizar esse processo em dez vezes usando um pequeno atalho: se a cor do pixel for a mesma do último pixel, use a mesma cor da paleta de antes.
Agora temos codificações rápidas, mas os arquivos GIF resultantes são muito grandes. O formato GIF permite indicar como cada frame será exibido por cima do último, definindo o método de descarte. Para acessar arquivos menores, em vez de atualizar cada pixel em cada frame, atualizamos apenas os pixels que mudaram. Ao diminuir a velocidade do processo de codificação novamente, isso reduziu muito bem o tamanho dos arquivos.
6. Base sólida: Google Cloud e Firebase
O back-end de um site de "conteúdo gerado pelo usuário" geralmente pode ser complicado e frágil, mas criamos um sistema simples e robusto graças ao Google Cloud e ao Firebase. Quando um artista faz upload de uma nova dança no sistema, ele é autenticado anonimamente pelo Firebase Authentication. Eles recebem permissão para fazer upload da gravação em um espaço temporário usando o Cloud Storage para Firebase. Quando o upload é concluído, a máquina cliente chama um gatilho HTTP do Cloud Functions para Firebase usando o token do Firebase. Isso aciona um processo de servidor que valida o envio, cria um registro de banco de dados e move a gravação para um diretório público no Google Cloud Storage.
Todo o nosso conteúdo público é armazenado em uma série de arquivos simples em um bucket do Cloud Storage. Isso significa que nossos dados são acessados rapidamente em todo o mundo, e não precisamos nos preocupar com cargas de tráfego altas que afetam a disponibilidade dos dados.
Usamos o Firebase Realtime Database e os endpoints da função do Cloud para criar uma ferramenta simples de moderação/curadoria que permite assistir a cada novo envio em RV e publicar novas playlists em qualquer dispositivo.
7. Service workers
Os service workers são uma inovação bastante recente que ajuda a gerenciar o armazenamento em cache de recursos do site. No nosso caso, os service workers carregam nosso conteúdo muito rápido para visitantes recorrentes e até permitem que o site funcione off-line. Esses são recursos importantes, já que muitos dos visitantes vão estar em conexões móveis de qualidade variável.
Adicionar service workers ao projeto foi fácil graças a um plug-in webpack que faz a maior parte do trabalho pesado para você. Na configuração abaixo, geramos um worker de serviço que armazena em cache automaticamente todos os arquivos estáticos. Ele vai extrair o arquivo de playlist mais recente da rede, se disponível, já que a playlist será atualizada o tempo todo. Todos os arquivos JSON de gravação precisam ser extraídos do cache, se disponível, porque eles nunca mudam.
const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin');
config.plugins.push(
new SWPrecacheWebpackPlugin({
dontCacheBustUrlsMatching: /\.\w{8}\./,
filename: 'service-worker.js',
minify: true,
navigateFallback: 'index.html',
staticFileGlobsIgnorePatterns: [/\.map$/, /asset-manifest\.json$/],
runtimeCaching: [{
urlPattern: /playlist\.json$/,
handler: 'networkFirst',
}, {
urlPattern: /\/recordings\//,
handler: 'cacheFirst',
options: {
cache: {
maxEntries: 120,
name: 'recordings',
},
},
}],
})
);
Atualmente, o plug-in não processa recursos de mídia carregados progressivamente, como nossos
arquivos de música. Então, resolvemos isso definindo o cabeçalho
Cache-Control
do Cloud Storage nesses arquivos como public, max-age=31536000
. Assim,
o navegador armazena o arquivo em cache por até um ano.
Conclusão
Estamos ansiosos para ver como os artistas vão contribuir para essa experiência e usá-la como uma ferramenta para expressão criativa usando a animação. Lançamos todo o código aberto, que pode ser encontrado em https://github.com/puckey/dance-tonite. Nos primórdios da RV e, principalmente, da WebVR, estamos ansiosos para ver que novos rumos criativos e inesperados essa nova mídia vai tomar. Dance muito.