Sessões virtuais de arte

Detalhes da sessão de arte

Resumo

Seis artistas foram convidados a pintar, projetar e esculpir em RV. Esse é o processo de gravação das sessões, conversão dos dados e apresentação em tempo real nos navegadores da Web.

https://g.co/VirtualArtSessions

Que hora de vida! Com a introdução da realidade virtual como um produto de consumo, possibilidades novas e inexploradas estão sendo descobertas. O inclinação, um produto do Google disponível no HTC Vive, permite que você desenhe em um espaço tridimensional. Quando testamos o Like Brush pela primeira vez, a sensação de desenhar com controles de movimento e a presença de estar "em uma sala com superpoderes" perduram. Realmente não há uma experiência como conseguir desenhar o espaço vazio ao seu redor.

Obra de arte virtual

A equipe de Data Arts do Google teve o desafio de mostrar essa experiência para pessoas que não têm um headset de RV, na Web, onde o inclinado 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 seu próprio estilo nesse novo meio.

Como gravar desenhos em realidade virtual

Criado em Unity, o software inclinação é um aplicativo para computador que usa a RV em grande escala para monitorar a posição da cabeça (tela montada na cabeça ou HMD) e os controles em cada uma das mãos. Por padrão, a arte criada no inclinação é exportada como um arquivo .tilt. Para levar essa experiência para a Web, percebemos que precisávamos de mais do que apenas os dados de arte. Trabalhamos com a equipe do inclinação para modificar o recurso. Ele exportou ações de desfazer/excluir, assim como as posições da cabeça e da mão do artista 90 vezes por segundo.

Ao desenhar, ele usa a posição e o ângulo do controle e converte vários pontos ao longo do tempo em um "traço". Veja um exemplo aqui. Escrevemos plug-ins que extraíram esses traços e os geraram 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 esboço JSON.

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 mente no meio do esboço. Por isso, era fundamental salvar as ações "DELETE", que servem como apagar ou desfazer ações em um traço inteiro.

As informações básicas de cada traço são salvas. Portanto, o tipo e o tamanho do pincel, assim como o rgb de cor, são coletados.

Por fim, todos os vértices do traço são salvos, o que inclui posição, ângulo, tempo e a intensidade da pressão do acionador do controlador, indicada como p em cada ponto.

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

Como reproduzir esboços com WebGL

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

O inclinação, que produz tiras triangulares em tempo real com base no movimento da mão do usuário, já está "concluído" no momento em que o esboço é exibido na Web. Isso nos permite ignorar grande parte do cálculo em tempo real e fazer o bake da geometria no carregamento.

Sketches do WebGL

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

O processo para calcular a faixa triangular para cada traço é quase idêntico ao código usado no inclinação:

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

Combinar a direção e a orientação do traço por si só retorna resultados matematicamente ambíguos. Pode haver vários normais derivados e, muitas vezes, resultariam em uma "torção" na geometria.

Ao iterar sobre os pontos de um traço, mantemos um vetor "preferencial à direita" e o transmitimos para a função computeSurfaceFrame(). Essa função nos dá um normal do qual podemos derivar um quadrilátero na tira quad, com base na direção do traço (do último ponto até o ponto atual) e na orientação do controlador (um quatérnio). E o mais importante é que ela também retorna um novo vetor "preferencial à direita" para o próximo conjunto de cálculos.

Traços

Depois de gerar quads com base nos pontos de controle de cada traço, fundimos os quadros interpolando os cantos de um quadrático para o próximo.

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 );
}
Quadriciclos fundidos
Quadrinhos fundidos.

Cada quadrante também contém UVs, que são gerados na próxima etapa. Alguns pincéis contêm uma variedade de padrões para dar a impressão de que cada traço pareceu um traço diferente do pincel. Isso é feito usando o atlasing _texture, 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 um pincel a óleo
Quatro texturas em um atlas de texturas para pincel a óleo
No inclinador
No inclinação do pincel
Em WebGL
Em WebGL

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

Todo o esboço acima é realizado em uma chamada de desenho no WebGL
Todo o esboço acima é realizado em uma chamada de desenho no WebGL

Para fazer um teste de estresse no sistema, criamos um esboço que levou 20 minutos para preencher o espaço com o máximo de vértices possível. O esboço 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 facilmente. Recalcular os traços por frame seria muito lento. Em vez disso, pré-computamos todo o esboço no carregamento e simplesmente revelamos cada quadrático quando isso era necessário.

Ocultar um quadrático significa apenas recolher seus vértices até o ponto 0,0,0. Quando o tempo atinge o ponto em que o quadrado precisa ser revelado, reposicionamos os vértices de volta no lugar.

Uma área que pode melhorar é manipular os vértices inteiramente na GPU com sombreadores. 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 sobrecarrega a CPU, fazendo com que o ventilador gire e desperdiçando a duração da bateria.

Obra de arte virtual

Gravando os artistas

Achamos que os esboços em si 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 gravar os dados de profundidade do corpo deles no espaço. Isso nos dá a capacidade de mostrar as figuras tridimensionais no mesmo espaço em que os desenhos aparecem.

Como o corpo da artista se ocultava nos impedindo de ver o que está por trás dele, usamos um sistema Kinect duplo, 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 a filmagem da câmera de profundidade e das câmeras coloridas. O Kinect é capaz de gravar cores, mas escolhemos usar DSLRs porque poderíamos controlar as configurações de exposição, usar belas lentes de última geração e gravar em alta definição.

Para gravar a filmagem, construímos uma sala especial para abrigar o HTC Vive, o artista e a câmera. Todas as superfícies foram cobertas com material que absorveu luz infravermelha para nos dar uma nuvem de ponto mais limpa (duvetyne nas paredes, tapete de borracha sinuosa no chão). Caso o material aparecia nas gravações da nuvem pontual, escolhemos material preto para não causar distração como algo branco.

Artista

As gravações de vídeo resultantes nos deram informações suficientes para projetar um sistema de partículas. Escrevemos algumas outras ferramentas em openFrameworks para limpar ainda mais a filmagem, especialmente removendo o piso, as paredes e o teto.

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

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

A tela, os controles e as partículas montados na cabeça estão alinhados
A tela de suporte, os controladores e as partículas estão alinhados

Isso foi feito por meio da gravação de um plug-in personalizado no inclinação, que extraiu as posições do HMD e dos controladores de cada frame. Como o inclinação é executada a 90 fps, toneladas de dados eram transmitidos e os dados de entrada de um esboço tinham mais de 20 MB descompactados. Também usamos essa técnica para capturar eventos que não são gravados no arquivo típico de salvamento do inclinação, 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 visuais/de dados. Cada vídeo de uma câmera DSLR precisa estar alinhado com o Kinect correspondente para que os pixels sejam alinhados no espaço, assim como no tempo. Em seguida, a filmagem desses dois suportes de câmera precisava ser alinhada entre si para formar um único artista. Então, precisávamos alinhar nosso artista 3D com os dados capturados no desenho dele. Ufa. Criamos ferramentas baseadas em navegador para ajudar na maioria dessas tarefas. Teste-as aqui

Artistas da gravadora
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 cortados 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 tenham no máximo três decimais de precisão. Depois, cortamos 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 o JSON simples com pares de chave-valor, uma ordem de valores seja criada para posição e rotação do HMD e dos controladores. Isso reduziu o tamanho do arquivo para apenas 3 MB, o que era aceitável para envio via cabo.
Gravadores

Como o próprio vídeo é veiculado como um elemento de vídeo HTML5 lido por uma textura do WebGL para se tornar partículas, o próprio vídeo precisava ser reproduzido escondido em segundo plano. Um sombreador converte as cores nas imagens de profundidade em posições no espaço 3D. James George compartilhou um ótimo exemplo de como você pode fazer com filmagens direto do DepthKit.

O iOS tem restrições à reprodução de vídeos inline, o que presumimos que seja para evitar que os usuários sejam afetados por anúncios em 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 infeliz de reduzir significativamente o frame rate do iOS, já que a cópia do buffer de pixels de vídeo para tela consome muita CPU. Para contornar isso, simplesmente veiculamos versões menores dos mesmos vídeos que permitem pelo menos 30 fps em um iPhone 6.

Conclusão

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

Embora navegadores da Web exibindo malhas 3D complexas não sejam empolgantes por si só, essa foi uma prova de conceito de que a polinização cruzada entre o trabalho em RV e a Web é totalmente possível.