Sessões virtuais de arte

Detalhes da sessão de arte

Resumo

Seis artistas foram convidados a pintar, projetar e esculpir em RV. Este é o processo de como registramos as sessões, convertemos os dados e as apresentamos em tempo real com navegadores da Web.

https://g.co/VirtualArtSessions

Que época incrível! Com a introdução da realidade virtual como um produto de consumo, novas possibilidades estão sendo descobertas. O Tilt Brush, um produto do Google disponível no HTC Vive, permite que você desenhe em um espaço tridimensional. Quando testamos o Tilt Brush pela primeira vez, a sensação de desenhar com controles de rastreamento de movimento e a presença de estar "em um ambiente com superpoderes" permanecem com você. Não há uma experiência como a de desenhar no espaço vazio ao seu redor.

Obra de arte virtual

A equipe de artes de dados do Google recebeu o desafio de mostrar essa experiência para pessoas sem um headset de RV, na Web, onde o Tilt Brush ainda não funciona. Para isso, a equipe trouxe um escultor, um ilustrador, um designer de conceito, um artista de moda, um artista de instalação e artistas de rua para criar obras de arte no estilo deles dentro desse novo meio.

Como gravar desenhos em realidade virtual

Criado no Unity, o software Tilt Brush é um aplicativo para computador que usa a RV de sala para rastrear a posição da cabeça (dispositivo de realidade virtual para a cabeça, ou HMD, na sigla em inglês) e os controles em cada uma das mãos. A arte criada no Tilt Brush é exportada por padrão como um arquivo .tilt. Para trazer essa experiência para a Web, precisamos de mais do que apenas os dados da arte. Trabalhamos em parceria com a equipe do Tilt Brush para modificar o Tilt Brush de modo que ele exportasse ações de desfazer/excluir, bem como as posições da cabeça e da mão do artista a 90 vezes por segundo.

Ao desenhar, o Tilt Brush usa a posição e o ângulo do controle e converte vários pontos ao longo do tempo em um "traço". Confira um exemplo aqui. Criamos plug-ins que extraem esses traços e os geram como JSON bruto.

    {
      "metadata": {
        "BrushIndex": [
          "d229d335-c334-495a-a801-660ac8a87360"
        ]
      },
      "actions": [
        {
          "type": "STROKE",
          "time": 12854,
          "data": {
            "id": 0,
            "brush": 0,
            "b_size": 0.081906750798225,
            "color": [
              0.69848710298538,
              0.39136275649071,
              0.211316883564
            ],
            "points": [
              [
                {
                  "t": 12854,
                  "p": 0.25791856646538,
                  "pos": [
                    [
                      1.9832634925842,
                      17.915264129639,
                      8.6014995574951
                    ],
                    [
                      -0.32014992833138,
                      0.82291424274445,
                      -0.41208130121231,
                      -0.22473378479481
                    ]
                  ]
                }, ...many more points
              ]
            ]
          }
        }, ... many more actions
      ]
    }

O snippet acima descreve o formato do JSON do esboço.

Aqui, cada traço é salvo como uma ação, com um tipo: "STROKE". Além das ações de traço, queríamos mostrar um artista cometendo erros e mudando de ideia no meio do esboço. Por isso, era essencial salvar ações de "EXCLUSÃO", que servem como ações de apagar ou desfazer um traço inteiro.

As informações básicas de cada traço são salvas, então o tipo de pincel, o tamanho do pincel e a cor RGB são coletados.

Por fim, cada vértice do traço é salvo e inclui a posição, o ângulo, o tempo e a força de pressão do gatilho do controle (notado como p em cada ponto).

A rotação é um quaternion de quatro componentes. Isso é importante mais tarde, quando renderizamos os traços para evitar o bloqueio do gimbal.

Como reproduzir esboços com o WebGL

Para mostrar os esboços em um navegador da Web, usamos THREE.js e escrevemos um código de geração de geometria que imitava o que o Tilt Brush faz nos bastidores.

Enquanto o Tilt Brush produz tiras triangulares em tempo real com base no movimento da mão do usuário, todo o esboço já está "finalizado" quando o mostramos na Web. Isso permite que ignoremos grande parte do cálculo em tempo real e gravemos a geometria na carga.

Desenhos do WebGL

Cada par de vértices em um traço produz um vetor de direção (as linhas azuis que conectam cada ponto, conforme mostrado acima, moveVector no snippet de código abaixo). Cada ponto também contém uma orientação, um quaternion que representa o ângulo atual do controlador. Para produzir uma faixa de triângulos, iteramos sobre cada um desses pontos, produzindo normais perpendiculares à direção e orientação do controlador.

O processo de cálculo da faixa triangular para cada traço é quase idêntico ao código usado no Tilt Brush:

const V_UP = new THREE.Vector3( 0, 1, 0 );
const V_FORWARD = new THREE.Vector3( 0, 0, 1 );

function computeSurfaceFrame( previousRight, moveVector, orientation ){
    const pointerF = V_FORWARD.clone().applyQuaternion( orientation );

    const pointerU = V_UP.clone().applyQuaternion( orientation );

    const crossF = pointerF.clone().cross( moveVector );
    const crossU = pointerU.clone().cross( moveVector );

    const right1 = inDirectionOf( previousRight, crossF );
    const right2 = inDirectionOf( previousRight, crossU );

    right2.multiplyScalar( Math.abs( pointerF.dot( moveVector ) ) );

    const newRight = ( right1.clone().add( right2 ) ).normalize();
    const normal = moveVector.clone().cross( newRight );
    return { newRight, normal };
}

function inDirectionOf( desired, v ){
    return v.dot( desired ) >= 0 ? v.clone() : v.clone().multiplyScalar(-1);
}

A combinação da direção e da orientação do traço por si só retorna resultados matematicamente ambíguos. Pode haver várias normais derivadas e muitas vezes isso resulta em uma "torção" na geometria.

Ao iterar sobre os pontos de um traço, mantemos um vetor "direito preferido" e o transmitimos para a função computeSurfaceFrame(). Essa função oferece uma normal de onde podemos derivar um quad na faixa de quad, com base na direção do traço (do último ponto para o ponto atual) e na orientação do controlador (um quatérnion). Mais importante, ele também retorna um novo vetor "direito preferido" para o próximo conjunto de cálculos.

Traços

Depois de gerar quadriláteros com base nos pontos de controle de cada traço, fundimos os quadriláteros interpolando os cantos, de um quadrilátero para o outro.

function fuseQuads( lastVerts, nextVerts) {
    const vTopPos = lastVerts[1].clone().add( nextVerts[0] ).multiplyScalar( 0.5
);
    const vBottomPos = lastVerts[5].clone().add( nextVerts[2] ).multiplyScalar(
0.5 );

    lastVerts[1].copy( vTopPos );
    lastVerts[4].copy( vTopPos );
    lastVerts[5].copy( vBottomPos );
    nextVerts[0].copy( vTopPos );
    nextVerts[2].copy( vBottomPos );
    nextVerts[3].copy( vBottomPos );
}
Quads combinados
Quadros combinados.

Cada quad também contém UVs que são gerados como uma próxima etapa. Alguns pincéis têm uma variedade de padrões de traço para dar a impressão de que cada traço parece um traço diferente do pincel. Isso é feito usando _textura de atlas_, em que cada textura de pincel contém todas as variações possíveis. A textura correta é selecionada modificando os valores UV do traço.

function updateUVsForSegment( quadVerts, quadUVs, quadLengths, useAtlas,
atlasIndex ) {
    let fYStart = 0.0;
    let fYEnd = 1.0;

    if( useAtlas ){
    const fYWidth = 1.0 / TEXTURES_IN_ATLAS;
    fYStart = fYWidth * atlasIndex;
    fYEnd = fYWidth * (atlasIndex + 1.0);
    }

    //get length of current segment
    const totalLength = quadLengths.reduce( function( total, length ){
    return total + length;
    }, 0 );

    //then, run back through the last segment and update our UVs
    let currentLength = 0.0;
    quadUVs.forEach( function( uvs, index ){
    const segmentLength = quadLengths[ index ];
    const fXStart = currentLength / totalLength;
    const fXEnd = ( currentLength + segmentLength ) / totalLength;
    currentLength += segmentLength;

    uvs[ 0 ].set( fXStart, fYStart );
    uvs[ 1 ].set( fXEnd, fYStart );
    uvs[ 2 ].set( fXStart, fYEnd );
    uvs[ 3 ].set( fXStart, fYEnd );
    uvs[ 4 ].set( fXEnd, fYStart );
    uvs[ 5 ].set( fXEnd, fYEnd );

    });

}
Quatro texturas em um atlas de texturas para pincel de tinta a óleo
Quatro texturas em um atlas de texturas para pincéis de óleo
No Tilt Brush
No Tilt Brush
No WebGL
No WebGL

Como cada sketch tem um número ilimitado de traços e eles não precisam ser modificados no tempo de execução, nós pré-calculamos a geometria do traço com antecedência e os mesclamos em uma única malha. Embora cada novo tipo de pincel precise ser um material, isso ainda reduz nossas chamadas de exibição para uma por pincel.

O esboço acima é realizado em uma chamada de renderização no WebGL.
Todo o esboço acima é realizado em uma chamada de exibição no WebGL

Para testar o sistema, criamos um esboço que levou 20 minutos para preencher o espaço com o maior número possível de vértices. O sketch resultante ainda era reproduzido a 60 fps no WebGL.

Como cada um dos vértices originais de um traço também continha tempo, podemos reproduzir os dados com facilidade. Recalcular os traços por frame seria muito lento. Por isso, pré-calculamos todo o esboço no carregamento e simplesmente revelamos cada quad quando chegou a hora.

Ocultar um quad significava simplesmente colapsar os vértices para o ponto 0,0,0. Quando o tempo atinge o ponto em que o quad precisa ser revelado, reposicionamos os vértices.

Uma área para melhoria é manipular os vértices totalmente na GPU com shaders. A implementação atual os coloca percorrendo a matriz de vértices do carimbo de data/hora atual, verificando quais vértices precisam ser revelados e atualizando a geometria. Isso coloca muita carga na CPU, o que faz o ventilador girar e desperdiçar a vida útil da bateria.

Obra de arte virtual

Como gravar os artistas

Sentimos que os esboços não seriam suficientes. Queríamos mostrar os artistas dentro dos esboços, pintando cada pincelada.

Para capturar os artistas, usamos câmeras Microsoft Kinect para registrar os dados de profundidade do corpo dos artistas no espaço. Isso nos permite mostrar as figuras tridimensionais no mesmo espaço em que os desenhos aparecem.

Como o corpo do artista se ocultava, impedindo que víssemos o que estava atrás dele, usamos um sistema duplo de Kinect, ambos em lados opostos da sala apontando para o centro.

Além das informações de profundidade, também capturamos as informações de cor da cena com câmeras DSLR padrão. Usamos o excelente software DepthKit para calibrar e mesclar as filmagens da câmera de profundidade e das câmeras coloridas. O Kinect pode gravar cores, mas optamos por usar DSLRs porque podíamos controlar as configurações de exposição, usar lentes de alta qualidade e gravar em alta definição.

Para gravar as imagens, criamos um espaço especial para abrigar o HTC Vive, o artista e a câmera. Todas as superfícies foram cobertas com material que absorve luz infravermelha para fornecer uma nuvem de pontos mais limpa (duvetyne nas paredes, tapete de borracha com nervuras no chão). Caso o material apareça na filmagem da nuvem de pontos, escolhemos o material preto para que ele não distraia tanto quanto algo branco.

Artista de gravação

As gravações de vídeo resultantes nos forneceram informações suficientes para projetar um sistema de partículas. Criamos algumas ferramentas adicionais no openFrameworks para limpar ainda mais a filmagem, em particular removendo os pisos, paredes e teto.

Todos os quatro canais de uma sessão de vídeo gravada (dois canais de cor acima e dois
de profundidade abaixo)
Todos os quatro canais de uma sessão de vídeo gravada (dois canais de cor acima e dois de profundidade abaixo)

Além de mostrar os artistas, também queríamos renderizar o HMD e os controladores em 3D. Isso foi importante não apenas para mostrar o HMD na saída final de forma clara (as lentes reflexivas do HTC Vive estavam atrapalhando as leituras de IR do Kinect), mas também nos deu pontos de contato para depurar a saída de partículas e alinhar os vídeos com o esboço.

O óculos de realidade virtual, os controles e as partículas alinhados
O óculos de realidade virtual, os controles e as partículas alinhados

Isso foi feito escrevendo um plug-in personalizado no Tilt Brush que extraía as posições do HMD e dos controladores em cada frame. Como o Tilt Brush é executado a 90 fps, muita informação foi transmitida e os dados de entrada de um esboço eram mais de 20 MB sem compactação. Também usamos essa técnica para capturar eventos que não são registrados no arquivo de salvamento típico do Tilt Brush, como quando o artista seleciona uma opção no painel de ferramentas e a posição do widget de espelho.

Ao processar os 4 TB de dados que capturamos, um dos maiores desafios foi alinhar todas as diferentes fontes de dados/visuais. Cada vídeo de uma câmera DSLR precisa ser alinhado com o Kinect correspondente para que os pixels sejam alinhados no espaço e no tempo. Em seguida, as filmagens dessas duas câmeras precisavam ser alinhadas para formar um único artista. Depois, precisamos alinhar nosso artista 3D com os dados capturados do desenho. Ufa. Criamos ferramentas baseadas em navegador para ajudar na maioria dessas tarefas. Você pode testá-las aqui.

Artistas de gravação

Depois que os dados foram alinhados, usamos alguns scripts escritos em NodeJS para processar tudo e gerar um arquivo de vídeo e uma série de arquivos JSON, todos aparados e sincronizados. Para reduzir o tamanho do arquivo, fizemos três coisas. Primeiro, reduzimos a precisão de cada número de ponto flutuante para que eles tivessem no máximo 3 casas decimais de precisão. Em segundo lugar, reduzimos o número de pontos em um terço para 30 fps e interpolamos as posições no lado do cliente. Por fim, serializamos os dados para que, em vez de usar JSON simples com pares de chave/valor, uma ordem de valores seja criada para a posição e a rotação do HMD e dos controladores. Isso reduziu o tamanho do arquivo para pouco menos de 3 MB, o que era aceitável para a transferência.

Artistas de gravação

Como o vídeo é veiculado como um elemento de vídeo HTML5 que é lido por uma textura do WebGL para se tornar partículas, ele precisa ser reproduzido oculto em segundo plano. Um sombreador converte as cores das imagens de profundidade em posições no espaço 3D. James George compartilhou um ótimo exemplo de como usar o DepthKit.

O iOS tem restrições à reprodução de vídeo inline, o que presumimos que é para evitar que os usuários sejam incomodados por anúncios de vídeo da Web que são reproduzidos automaticamente. Usamos uma técnica semelhante a outras soluções alternativas na Web, que é copiar o frame do vídeo em uma tela e atualizar manualmente o tempo de busca do vídeo, a cada 1/30 de segundo.

videoElement.addEventListener( 'timeupdate', function(){
    videoCanvas.paintFrame( videoElement );
});

function loopCanvas(){

    if( videoElement.readyState === videoElement.HAVE\_ENOUGH\_DATA ){

    const time = Date.now();
    const elapsed = ( time - lastTime ) / 1000;

    if( videoState.playing && elapsed >= ( 1 / 30 ) ){
        videoElement.currentTime = videoElement.currentTime + elapsed;
        lastTime = time;
    }

    }

}

frameLoop.add( loopCanvas );

Nossa abordagem teve o efeito colateral indesejável de reduzir significativamente a taxa de frames do iOS, já que a cópia do buffer de pixels do vídeo para a tela exige muita CPU. Para contornar esse problema, simplesmente veiculamos versões menores dos mesmos vídeos que permitem pelo menos 30 qps em um iPhone 6.

Conclusão

O consenso geral para o desenvolvimento de software de RV desde 2016 é manter geometrias e shaders simples para que você possa executar a mais de 90 fps em um HMD. Isso acabou sendo um ótimo alvo para demonstrações do WebGL, já que as técnicas usadas no Tilt Brush são muito bem mapeadas para o WebGL.

Embora os navegadores da Web que mostram malhas 3D complexas não sejam tão interessantes, essa foi uma prova de conceito de que a transferência cruzada de VR e da Web é totalmente possível.