Fazendo 100.000 estrelas

Olá! Meu nome é Michael Chang e trabalho com a equipe de Data Arts, do Google. Recentemente, concluímos o programa 100.000 Estrelas, um experimento do Chrome que visualiza estrelas próximas. O projeto foi criado com THREE.js e CSS3D. Neste estudo de caso, vou descrever o processo de descoberta, compartilhar algumas técnicas de programação e concluir algumas reflexões para aperfeiçoamento futuro.

Os tópicos discutidos aqui serão bastante amplos e exigem algum conhecimento de THREE.js, mas espero que você ainda aproveite isso como uma análise técnica. Sinta-se à vontade para pular para uma área de interesse usando o botão de sumário à direita. Primeiro, mostrarei a parte de renderização do projeto, seguida pelo gerenciamento de sombreador e, por fim, como usar rótulos de texto CSS em combinação com o WebGL.

100.000 estrelas, um experimento do Chrome realizado pela equipe de Data Arts
100.000 estrelas usam THREE.js para visualizar estrelas próximas na Via Láctea

A descoberta do espaço

Logo depois de terminarmos o Small Arms Globe, eu estava testando uma demonstração de TRÊS.js partículas com profundidade de campo. Percebi que poderia mudar a "escala" interpretada da cena ajustando a quantidade do efeito aplicado. Quando a profundidade do efeito de campo era realmente extrema, objetos distantes ficavam realmente embaçados, de forma semelhante à forma como a fotografia tilt-shift funciona, proporcionando a ilusão de olhar uma cena microscópica. Por outro lado, diminuir o efeito dava a impressão de que você estava olhando para o espaço profundo.

Comecei a procurar dados que poderiam ser usados para injetar posições de partículas, um caminho que me levasse ao banco de dados HYG da astronexus.com, uma compilação das três fontes de dados (Hipparcos, Yale Bright Star Catalog e Gliese/Jahreiss Catalog) acompanhada por coordenadas cartesianas xyz pré-calculadas. Vamos começar.

Representando dados de estrelas.
O primeiro passo é representar cada estrela do catálogo como uma única partícula.
São as estrelas nomeadas.
Algumas estrelas no catálogo têm nomes próprios, rotulados aqui.

Levou cerca de uma hora para hackear algo que colocou os dados das estrelas no espaço 3D. O conjunto de dados contém exatamente 119.617 estrelas, então representar cada estrela com uma partícula não é um problema para uma GPU moderna. Há também 87 estrelas identificadas individualmente, então criei uma sobreposição de marcador CSS usando a mesma técnica descrita no Small Arms Globe.

Nesse período, eu tinha acabado de terminar a série Mass Effect. No jogo, o jogador é convidado a explorar a galáxia e analisar vários planetas e ler sobre a história completamente fictícia que lembra a Wikipédia: quais espécies prosperaram no planeta, sua história geológica e assim por diante.

Conhecendo a riqueza de dados reais que existem sobre estrelas, é possível apresentar informações reais sobre a galáxia da mesma maneira. O objetivo final deste projeto seria dar vida a esses dados, permitir que o espectador explore a galáxia à la Mass Effect, aprenda sobre as estrelas e sua distribuição, e esperamos inspirar uma sensação de adrenalina e curiosidade sobre o espaço. Ufa.

Eu provavelmente devo apresentar o restante deste estudo de caso dizendo que Não sou de forma alguma um astrônomo e que esse é o trabalho de pesquisa amadora com apoio de alguns conselhos de especialistas externos. O projeto deve ser definitivamente interpretado como uma interpretação artística do espaço.

Como construir uma galáxia

Meu plano era gerar processualmente um modelo da galáxia que pudesse contextualizar os dados das estrelas e, com sorte, fornecer uma visão incrível do nosso lugar na Via Láctea.

Um protótipo inicial da galáxia.
Um protótipo inicial do sistema de partículas da Via Láctea.

Para gerar a Via Láctea, gerei 100 mil partículas e as coloquei em uma espiral emulando a forma como os braços galácticos são formados. Não me preocupava muito com os detalhes da formação do braço espiral, porque seria um modelo representacional, e não matemático. No entanto, tentei acertar o número de braços em espiral e girar na "direção certa".

Em versões posteriores do modelo da Via Láctea, eu deixei de enfatizar o uso de partículas em favor de uma imagem plana de uma galáxia para acompanhar as partículas, esperamos que isso dê a ela uma aparência mais fotográfica. A imagem real é da galáxia espiral NGC 1232, a aproximadamente 70 milhões de anos-luz de nós, manipulada para parecer com a Via Láctea.

Descobrindo a escala da galáxia.
Toda unidade GL é um ano-luz. Neste caso,a esfera tem 110.000 anos-luz de largura, abrangendo o sistema de partículas.

No início, decidi representar uma unidade GL, basicamente um pixel em 3D, como um ano-luz - uma convenção que unificou o posicionamento para tudo visualizado e, infelizmente, me deu sérios problemas de precisão posteriormente.

Outra convenção que decidi foi girar a cena toda em vez de mover a câmera, algo que já fiz em outros projetos. Uma vantagem é que tudo é colocado em uma "mesa giratória", de modo que arrastar o mouse para a esquerda e para a direita gire o objeto em questão, mas aumentar o zoom é apenas uma questão de alterar a câmera.position.z.

O campo de visão (ou campo de visão) da câmera também é dinâmico. À medida que alguém se afasta, o campo de visão aumenta, absorvendo cada vez mais da galáxia. O oposto também é verdadeiro ao se mover para dentro em direção a uma estrela: o campo de visão é estreito. Isso permite que a câmera visualize coisas que são infinitesimal (em comparação com a galáxia) ao apertar o campo de visão para algo como uma lupa divina, sem ter que lidar com problemas de recorte quase plano.

Diferentes formas de renderizar uma galáxia.
(acima) Galáxia de partículas antigas. (abaixo) Partículas acompanhadas por um plano de imagem.

A partir daqui, pude "colocar" o Sol em algumas unidades de distância do núcleo da galáxia. Também pude visualizar o tamanho relativo do sistema solar mapeando o raio do Penhasco Kiper (por fim, escolhi visualizar a Nuvem de Oort). Dentro desse modelo de sistema solar, também posso visualizar uma órbita simplificada da Terra e o raio real do Sol em comparação.

O Sistema Solar.
O Sol orbitado por planetas e uma esfera que representa o Cinturão de Kuiper.

Foi difícil renderizar o Sol. Eu precisava usar quantas técnicas de gráficos em tempo real conhecia. A superfície do Sol é uma espuma quente de plasma, que ela pulsa e muda ao longo do tempo. Isso foi simulado por meio de uma textura em bitmap de uma imagem infravermelha da superfície solar. O sombreador de superfície faz uma busca de cor com base na escala de cinza dessa textura e realiza uma busca em uma rampa de cores separada. Quando esse ponto é modificado ao longo do tempo, ele cria uma distorção semelhante à lava.

Uma técnica semelhante foi usada para a coroa do sol, exceto pelo fato de ser um cartão de sprite plano que sempre fica voltado para a câmera usando https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js (link em inglês).

Sol. de renderização
Versão antiga do Sol.

As luzes solares foram criadas com sombreadores de vértice e fragmento aplicados a um toro, que girava ao redor da superfície solar. O sombreador de vértice tem uma função de ruído que faz com que ele entrelaça de maneira semelhante a um blob.

Foi aqui que comecei a ter alguns problemas de Z-definição devido à precisão de GL. Todas as variáveis para precisão foram predefinidas em THREE.js, então eu não poderia aumentar a precisão de maneira realista sem uma enorme quantidade de trabalho. Os problemas de precisão não eram tão ruins perto da origem. No entanto, depois que comecei a modelar outros sistemas estelares, isso se tornou um problema.

É o modelo estelar.
O código para renderizar o Sol foi generalizado posteriormente para renderizar outras estrelas.

Fiz alguns truques para mitigar o Z-racing. O Material.polygonoffset de TRÊS é uma propriedade que permite que os polígonos sejam renderizados em um local percebido diferente (até onde eu entendo). Isso foi usado para forçar o plano da coroa a sempre renderizar sobre a superfície do Sol. Abaixo disso, um "halo" de sol foi renderizado para fornecer raios de luz agudos que se afastam da esfera.

Um problema diferente relacionado à precisão era que os modelos de estrelas começavam a tremer à medida que o zoom da cena aumentava. Para corrigir isso, tive que "zerar" a rotação da cena e girar separadamente o modelo estelar e o mapa ambiental para dar a ilusão de que você está orbitando a estrela.

Criando Lensflare

Com grandes poderes vêm grandes responsabilidades.
Com grandes poderes vêm grandes responsabilidades.

É nas visualizações do espaço que sinto que consigo sair da frente com o uso excessivo da claridade de lente. THREE.LensFlare serve para esse propósito, só preciso colocar alguns hexágonos anamórficos e um traço de JJ Abrams. O snippet abaixo mostra como construí-los na sua cena.

// This function returns a lesnflare THREE object to be .add()ed to the scene graph
function addLensFlare(x,y,z, size, overrideImage){
var flareColor = new THREE.Color( 0xffffff );

lensFlare = new THREE.LensFlare( overrideImage, 700, 0.0, THREE.AdditiveBlending, flareColor );

// we're going to be using multiple sub-lens-flare artifacts, each with a different size
lensFlare.add( textureFlare1, 4096, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );

// and run each through a function below
lensFlare.customUpdateCallback = lensFlareUpdateCallback;

lensFlare.position = new THREE.Vector3(x,y,z);
lensFlare.size = size ? size : 16000 ;
return lensFlare;
}

// this function will operate over each lensflare artifact, moving them around the screen
function lensFlareUpdateCallback( object ) {
var f, fl = this.lensFlares.length;
var flare;
var vecX = -this.positionScreen.x _ 2;
var vecY = -this.positionScreen.y _ 2;
var size = object.size ? object.size : 16000;

var camDistance = camera.position.length();

for( f = 0; f < fl; f ++ ) {
flare = this.lensFlares[ f ];

flare.x = this.positionScreen.x + vecX * flare.distance;
flare.y = this.positionScreen.y + vecY * flare.distance;

flare.scale = size / camDistance;
flare.rotation = 0;

}
}

Uma maneira fácil de fazer rolagem de textura

Inspirado no Homeworld.
Um plano cartesiano para ajudar com a orientação espacial no espaço.

Para o "plano de orientação espacial", um TRÊE.CylinderGeometry() gigantesco foi criado e centralizado no Sol. Para criar a "onda de luz" se espalhando para fora, modifiquei o deslocamento da textura ao longo do tempo da seguinte forma:

mesh.material.map.needsUpdate = true;
mesh.material.map.onUpdate = function(){
this.offset.y -= 0.001;
this.needsUpdate = true;
}

map é a textura pertencente ao material, que recebe uma função onUpdate que pode ser substituída. Definir o deslocamento faz com que a textura seja "rolada" ao longo do eixo, e o envio de spam necessidadesUpdate = true forçaria esse comportamento a se repetir.

Como usar rampas de cores

Cada estrela tem uma cor diferente, com base no "índice de cores" atribuído pelos astrônomos. Em geral, estrelas vermelhas são mais frias e estrelas azuis/roxas são mais quentes. Uma faixa de cores brancas e laranjas intermediárias existem nesse gradiente.

Ao renderizar as estrelas, eu queria dar a cada partícula sua própria cor com base nesses dados. Para fazer isso, "atributos" foram dados ao material do sombreador aplicado às partículas.

var shaderMaterial = new THREE.ShaderMaterial( {
uniforms: datastarUniforms,
attributes: datastarAttributes,
/_ ... etc _/
});
var datastarAttributes = {
size: { type: 'f', value: [] },
colorIndex: { type: 'f', value: [] },
};

Preencher a matriz colorIndex daria a cada partícula uma cor exclusiva no sombreador. Normalmente, passaria uma cor vec3, mas neste caso estou passando um ponto flutuante para a eventual busca de incremento de cor.

Rampa de cores.
Uma rampa de cores usada para procurar a cor visível no índice de cores de uma estrela.

A rampa de cores era parecida com o exemplo, mas eu precisava acessar os dados de cor de bitmap no JavaScript. Fiz isso primeiro carregar a imagem no DOM, desenhá-la em um elemento de tela e acessar o bitmap de tela.

// make a blank canvas, sized to the image, in this case gradientImage is a dom image element
gradientCanvas = document.createElement('canvas');
gradientCanvas.width = gradientImage.width;
gradientCanvas.height = gradientImage.height;

// draw the image
gradientCanvas.getContext('2d').drawImage( gradientImage, 0, 0, gradientImage.width, gradientImage.height );

// a function to grab the pixel color based on a normalized percentage value
gradientCanvas.getColor = function( percentage ){
return this.getContext('2d').getImageData(percentage \* gradientImage.width,0, 1, 1).data;
}

O mesmo método é usado para colorir estrelas individuais na visualização do modelo de estrela.

Meus olhos!
A mesma técnica é usada para fazer a pesquisa de cores da classe espectral de uma estrela.

Contorno de sombreador

Ao longo do projeto, descobri que precisava escrever cada vez mais sombreadores para realizar todos os efeitos visuais. Escrevi um carregador de sombreador personalizado com essa finalidade porque não tinha tantos sombreadores disponíveis em index.html.

// list of shaders we'll load
var shaderList = ['shaders/starsurface', 'shaders/starhalo', 'shaders/starflare', 'shaders/galacticstars', /*...etc...*/];

// a small util to pre-fetch all shaders and put them in a data structure (replacing the list above)
function loadShaders( list, callback ){
var shaders = {};

var expectedFiles = list.length \* 2;
var loadedFiles = 0;

function makeCallback( name, type ){
return function(data){
if( shaders[name] === undefined ){
shaders[name] = {};
}

    shaders[name][type] = data;

    //  check if done
    loadedFiles++;
    if( loadedFiles == expectedFiles ){
    callback( shaders );
    }

};

}

for( var i=0; i<list.length; i++ ){
var vertexShaderFile = list[i] + '.vsh';
var fragmentShaderFile = list[i] + '.fsh';

//  find the filename, use it as the identifier
var splitted = list[i].split('/');
var shaderName = splitted[splitted.length-1];
$(document).load( vertexShaderFile, makeCallback(shaderName, 'vertex') );
$(document).load( fragmentShaderFile,  makeCallback(shaderName, 'fragment') );

}
}

A função loadShaders() recebe uma lista de nomes de arquivos de sombreador (esperando-se .fsh para os sombreadores de fragmento e .vsh para os sombreadores de vértice), tenta carregar os dados e, em seguida, apenas substitui a lista por objetos. O resultado final é que, nos seus TRÊS uniformes, é possível transmitir sombreadores da seguinte forma:

var galacticShaderMaterial = new THREE.ShaderMaterial( {
vertexShader: shaderList.galacticstars.vertex,
fragmentShader: shaderList.galacticstars.fragment,
/_..._/
});

Eu provavelmente poderia ter usado o requirements.js, mas isso precisaria da recriação do código apenas para essa finalidade. Essa solução, embora muito mais fácil, poderia ser aprimorada, talvez até mesmo como uma extensão THREE.js. Se você tiver sugestões ou maneiras de melhorar, entre em contato.

Rótulos de texto CSS sobre THREE.js

Em nosso último projeto, o Small Arms Globe, tentei fazer com que rótulos de texto aparecessem em uma cena THREE.js. O método que eu estava usando calcula a posição absoluta do modelo de onde eu quero que o texto apareça e, em seguida, resolve a posição da tela usando THREE.Projector() e, por fim, usa CSS "top" e "left" para colocar os elementos CSS na posição desejada.

As primeiras iterações desse projeto usaram essa mesma técnica, mas estou com vontade de experimentar esse outro método descrito por Luis Cruz.

A ideia básica: combinar a transformação de matriz do CSS3D com a câmera e a cena de TRÊS e você pode "colocar" elementos CSS em 3D como se estivessem em cima da cena de TRÊS. No entanto, há limitações para isso, por exemplo, não é possível que o texto fique abaixo de um objeto THREE.js. Isso ainda é muito mais rápido do que tentar realizar o layout usando atributos CSS "top" e "left".

Marcadores de texto.
Usar transformações CSS3D para colocar rótulos de texto na WebGL.

Acesse a demonstração (e o código na origem da visualização) disso aqui. No entanto, descobri que a ordem da matriz mudou para THREE.js. A função que atualizei:

/_ Fixes the difference between WebGL coordinates to CSS coordinates _/
function toCSSMatrix(threeMat4, b) {
var a = threeMat4, f;
if (b) {
f = [
a.elements[0], -a.elements[1], a.elements[2], a.elements[3],
a.elements[4], -a.elements[5], a.elements[6], a.elements[7],
a.elements[8], -a.elements[9], a.elements[10], a.elements[11],
a.elements[12], -a.elements[13], a.elements[14], a.elements[15]
];
} else {
f = [
a.elements[0], a.elements[1], a.elements[2], a.elements[3],
a.elements[4], a.elements[5], a.elements[6], a.elements[7],
a.elements[8], a.elements[9], a.elements[10], a.elements[11],
a.elements[12], a.elements[13], a.elements[14], a.elements[15]
];
}
for (var e in f) {
f[e] = epsilon(f[e]);
}
return "matrix3d(" + f.join(",") + ")";
}

Como tudo é transformado, o texto não fica mais voltado para a câmera. A solução era usar THREE.Gyroscope(), que força um Object3D a "perder" a orientação herdada da cena. Essa técnica é chamada de "billboard", e o giroscópio é perfeito para isso.

O que é realmente bom é que todos os elementos DOM e CSS normais ainda funcionam, como poder passar o mouse sobre um rótulo de texto 3D e fazê-lo brilhar com sombras projetadas.

Marcadores de texto.
Os rótulos de texto sempre ficam voltados para a câmera, conectando-os a THREE.Gyroscope().

Ao aumentar o zoom, descobri que o dimensionamento da tipografia estava causando problemas com o posicionamento. Isso pode ter acontecido devido ao kerning e ao padding do texto. Outro problema era que o texto ficava pixelado quando o zoom era ampliado, já que o renderizador DOM trata o texto renderizado como um quadriculado texturizado, algo que deve ser considerado ao usar esse método. Ao olhar para trás, eu poderia ter apenas usado um texto de tamanho de fonte gigante, e talvez isso seja algo para exploração futura. Neste projeto, também usei os rótulos de texto de posicionamento CSS "superior/esquerda", descritos anteriormente, para elementos muito pequenos que acompanham os planetas no sistema solar.

Reprodução de música e repetição

A peça musical tocada durante o "Mapa Galáctico" do Mass Effect foi dos compositores de Bioware Sam Hulick e Jack Wall e tinha a emoção que eu queria que o visitante vivesse. Queríamos um pouco de música em nosso projeto porque sentimos que era uma parte importante da atmosfera, ajudando a criar aquela sensação de admiração e maravilha que estávamos almejando.

Nosso produtor Valdean Klump entrou em contato com Sam, que tinha um monte de músicas cortadas no Mass Effect que ele nos permitiu usar com toda a gentileza. O título da música é "In a Strange Land".

Usei a tag de áudio para reproduzir músicas. No entanto, mesmo no Google Chrome, o atributo "loop" não era confiável. Às vezes, ele falhava. No final, essa invasão da tag de áudio duplo foi usada para verificar o fim da reprodução e alternar para a outra tag para reprodução. O que foi decepcionante foi que ainda não funcionava perfeitamente o tempo todo, mas acho que isso foi o melhor que eu podia fazer.

var musicA = document.getElementById('bgmusicA');
var musicB = document.getElementById('bgmusicB');
musicA.addEventListener('ended', function(){
this.currentTime = 0;
this.pause();
var playB = function(){
musicB.play();
}
// make it wait 15 seconds before playing again
setTimeout( playB, 15000 );
}, false);

musicB.addEventListener('ended', function(){
this.currentTime = 0;
this.pause();
var playA = function(){
musicA.play();
}
// otherwise the music will drive you insane
setTimeout( playA, 15000 );
}, false);

// okay so there's a bit of code redundancy, I admit it
musicA.play();

Possibilidade de melhorias

Depois de trabalhar com o THREE.js por um tempo, sinto que cheguei ao ponto em que meus dados estavam misturando-se demais com meu código. Por exemplo, ao definir as instruções de materiais, texturas e geometria, eu estava essencialmente com "modelagem 3D com código". Isso me parece muito ruim e é uma área na qual os futuros esforços com o THREE.js poderiam melhorar muito, por exemplo, definir dados do material em um arquivo separado, preferencialmente visualizável e ajustável em algum contexto, e pode ser trazido de volta ao projeto principal.

Nosso colega Ray McClure também passou algum tempo criando "barulhos espaciais" generativos incríveis, que tiveram que ser cortados devido à instabilidade da API de áudio da Web que travava o Chrome de vez em quando. É uma sorte, mas isso definitivamente nos fez pensar mais no espaço de som para trabalhos futuros. No momento em que este artigo foi escrito, sou informado de que a API de áudio da Web recebeu um patch, então é possível que isso esteja funcionando agora, algo que deve ser analisado no futuro.

Os elementos tipográficos combinados com a WebGL ainda são um desafio, e não tenho 100% de certeza do que estamos fazendo aqui da maneira correta. Ainda parece um hack. Talvez as versões futuras de TRÊS, com seu novo renderizador de CSS, possam ser usadas para maior integração entre os dois mundos.

Créditos

Obrigada a Aaron Koblin por me deixar visitar a cidade com este projeto. Jono Brandel pelo excelente design da interface do usuário + implementação, tratamento do tipo e implementação dos tours. Valdean Klump por dar um nome ao projeto e todo o texto. Sabah Ahmed por limpar a enorme quantidade de direitos de uso das fontes de dados e imagens. Clem Wright por entrar em contato com as pessoas certas para a publicação. Doug Fritz pela excelência técnica. George Brower, por me ensinar a usar JS e CSS. E, claro, o Sr. Doob para THREE.js.

Referências