Fazendo 100.000 estrelas

Olá! Meu nome é Michael Chang, e trabalho com a equipe de artes de dados do Google. Recentemente, concluímos o 100.000 Stars, um Chrome Experiment que mostra 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 terminar com algumas ideias para melhorias futuras.

Os tópicos discutidos aqui serão bastante amplos e exigirão algum conhecimento do THREE.js, mas espero que você ainda possa aproveitar isso como uma análise pós-morte técnica. Use o botão de índice à direita para pular para uma área de interesse. Primeiro, vou mostrar 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 feito pela equipe de artes de dados
100.000 Stars usa o THREE.js para mostrar estrelas próximas na Via Láctea

Descobrir o espaço

Logo depois de terminar o Small Arms Globe, eu estava testando uma demonstração de partículas do THREE.js com profundidade de campo. Percebi que era possível mudar a "escala" interpretada da cena ajustando a quantidade do efeito aplicado. Quando o efeito de profundidade de campo era muito extremo, os objetos distantes ficavam muito desfocados, de forma semelhante à maneira como a fotografia tilt-shift funciona para dar a ilusão de olhar para uma cena microscópica. Por outro lado, diminuir o efeito faz com que pareça que você está olhando para o espaço profundo.

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

Como representar dados de estrelas.
A primeira etapa é plotar cada estrela no catálogo como uma única partícula.
As estrelas nomeadas.
Algumas estrelas no catálogo têm nomes próprios, rotulados aqui.

Levou cerca de uma hora para criar algo que colocasse os dados das estrelas no espaço 3D. Há exatamente 119.617 estrelas no conjunto de dados. Portanto, 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 marcadores CSS usando a mesma técnica descrita no Small Arms Globe.

Durante esse período, eu tinha acabado de terminar a série Mass Effect. No jogo, o jogador é convidado a explorar a galáxia e escanear vários planetas e ler sobre a história deles, que é completamente fictícia e tem um tom de Wikipedia: quais espécies prosperaram no planeta, a história geológica dele e assim por diante.

Sabendo que há muitos dados reais sobre estrelas, é possível apresentar informações reais sobre a galáxia da mesma forma. O objetivo final do projeto é dar vida a esses dados, permitir que o espectador explore a galáxia à la Mass Effect, aprenda sobre as estrelas e a distribuição delas e, com sorte, inspire um sentimento de admiração e maravilha pelo espaço. Ufa.

Antes de continuar este estudo de caso, devo dizer que não sou astrônomo e este é um trabalho de pesquisa amadora, com o apoio de alguns especialistas externos. Este projeto definitivamente precisa ser interpretado como uma interpretação artística do espaço.

Como criar uma galáxia

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

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

Para gerar a Via Láctea, criei 100.000 partículas e as coloquei em uma espiral,emulando a formação dos braços galácticos. Eu não estava muito preocupado com os detalhes da formação de braços espirais, porque esse seria um modelo representativo, e não matemático. No entanto, tentei corrigir o número de braços da espiral e girar na "direção certa".

Nas versões mais recentes do modelo da Via Láctea, desconsiderei o uso de partículas em favor de uma imagem planar de uma galáxia para acompanhar as partículas, a elas uma aparência mais fotográfica. A imagem real é da galáxia espiral NGC 1232, a cerca de 70 milhões de anos-luz de distância, manipulada para parecer a Via Láctea.

Descobrir a escala da galáxia.
Cada unidade de GL é um ano-luz. Nesse caso,a esfera tem 110.000 anos-luz de largura, englobando o sistema de partículas.

Decidi desde o início representar uma unidade do GL, basicamente um pixel em 3D, como um ano-luz, uma convenção que unificou a posição de tudo o que foi visualizado e, infelizmente, me causou sérios problemas de precisão mais tarde.

Outra convenção que decidi foi girar a cena inteira em vez de mover a câmera, algo que fiz em alguns outros projetos. Uma vantagem é que tudo é colocado em uma "mesa giratória" para que o objeto em questão gire quando o mouse é arrastado para a esquerda e para a direita, mas o zoom é apenas uma questão de mudar camera.position.z.

O campo de visão (ou FOV) da câmera também é dinâmico. À medida que se afasta, o campo de visão se amplia, capturando cada vez mais da galáxia. O oposto é verdadeiro quando se move para dentro em direção a uma estrela, o campo de visão se estreita. Isso permite que a câmera veja coisas infinitamente pequenas (em comparação com a galáxia) comprimindo o FOV para algo como uma lupa divina sem ter que lidar com problemas de recorte de plano próximo.

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

Com isso, consegui "colocar" o Sol a algumas unidades do núcleo galáctico. Também consegui visualizar o tamanho relativo do sistema solar mapeando o raio do Cliff de Kuiper (no final, optei por visualizar a Nuvem de Oort). Nesse modelo do sistema solar, também pude 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 representando o cinturão de Kuiper.

O Sol era difícil de renderizar. Eu tive que usar todas as técnicas de gráficos em tempo real que conhecia. A superfície do Sol é uma espuma quente de plasma e precisa pulsar e mudar com o tempo. Isso foi simulado usando uma textura bitmap de uma imagem infravermelha da superfície solar. O sombreador de superfície faz uma pesquisa de cor com base na escala de cinza dessa textura e realiza uma pesquisa em uma rampa de cores separada. Quando essa pesquisa é alterada ao longo do tempo, ela cria uma distorção semelhante à lava.

Uma técnica semelhante foi usada para a coroa do Sol, exceto que seria um cartão de sprite plano que sempre fica de frente para a câmera usando https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js.

Renderização do Sol.
Versão inicial do Sol.

Os flares solares foram criados com shaders de vértice e de fragmentos aplicados a um toro, girando apenas ao redor da borda da superfície solar. O sombreador de vértice tem uma função de ruído que faz com que ele se entrelace de forma semelhante a um blob.

Foi aqui que comecei a ter alguns problemas de z-fighting devido à precisão do GL. Todas as variáveis de precisão foram pré-definidas no THREE.js, então não foi possível aumentar a precisão sem um grande esforço. Os problemas de precisão não eram tão ruins perto da origem. No entanto, quando comecei a modelar outros sistemas estelares, isso se tornou um problema.

Modelo de estrela.
O código para renderizar o Sol foi generalizado para renderizar outras estrelas.

Usei alguns hacks para reduzir o z-fighting. O Material.polygonoffset do THREE é uma propriedade que permite que polígonos sejam renderizados em um local percebido diferente (pelo que entendi). Isso foi usado para forçar o plano da coroa a renderizar sempre na parte de cima da superfície do Sol. Abaixo disso, um "halo" do Sol foi renderizado para mostrar raios de luz nítidos se afastando da esfera.

Outro problema relacionado à precisão era que os modelos de estrela começavam a tremer quando a cena era ampliada. Para corrigir isso, tive que "zerar" a rotação da cena e girar separadamente o modelo da estrela e o mapa do ambiente para dar a ilusão de que você está orbitando a estrela.

Como criar um efeito de lente

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

As visualizações de espaço são onde eu sinto que posso usar o efeito de lenteflare em excesso. THREE.LensFlare serve para esse propósito. Tudo o que precisei fazer foi adicionar alguns hexágonos anamorfos e um pouco de JJ Abrams. O snippet abaixo mostra como criá-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 rolar a textura

Inspirado no Homeworld.
Um plano cartesiano para ajudar na orientação espacial.

Para o "plano de orientação espacial", uma THREE.CylinderGeometry() gigante foi criada e centralizada no Sol. Para criar a "onda de luz" que se espalha para fora, modifiquei o deslocamento da textura ao longo do tempo desta 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. A definição do deslocamento faz com que a textura seja "rolada" ao longo desse eixo, e o envio de spam de needsUpdate = true forçaria esse comportamento a ser repetido.

Usar rampas de cores

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

Ao renderizar as estrelas, queria dar a cada partícula uma cor com base nesses dados. A maneira de fazer isso foi com "atributos" 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, uma pessoa transmitiria um vec3 de cor, mas, neste caso, estou transmitindo um float para a eventual pesquisa de rampa de cores.

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

A rampa de cores tinha esta aparência, mas eu precisava acessar os dados de cor do bitmap usando JavaScript. Para fazer isso, primeiro carreguei a imagem no DOM, desenhei-a em um elemento de tela e acessei o bitmap da 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;
}

Esse 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.

Sombreamento

Ao longo do projeto, descobri que precisava escrever cada vez mais shaders para conseguir todos os efeitos visuais. Eu escrevi um carregador de sombreador personalizado para essa finalidade porque estava cansado de ter sombreadores ativos no 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 .fsh para fragmentos e .vsh para sombreadores de vértice), tenta carregar os dados e substitui a lista por objetos. O resultado final está nos uniformes do THREE.js. Você pode transmitir shaders para ele assim:

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

Provavelmente eu poderia ter usado o require.js, mas isso exigiria uma remontagem de código apenas para essa finalidade. Essa solução, embora muito mais fácil, poderia ser melhorada, talvez até como uma extensão do THREE.js. Se você tiver sugestões ou maneiras de melhorar isso, entre em contato comigo.

Rótulos de texto CSS sobre o THREE.js

No nosso último projeto, o Small Arms Globe, eu brinquei com a ideia de fazer rótulos de texto aparecerem na parte de cima de 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, depois resolve a posição da tela usando THREE.Projector() e, por fim, usa "top" e "left" do CSS para colocar os elementos do CSS na posição desejada.

As primeiras iterações desse projeto usaram essa mesma técnica, mas eu estava com vontade de tentar este outro método descrito por Luis Cruz.

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

Rótulos de texto.
Usar transformações CSS3D para colocar rótulos de texto sobre a WebGL.

Confira a demonstração (e o código no código-fonte). No entanto, descobri que a ordem da matriz mudou para o 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 está mais de frente para a câmera. A solução foi usar THREE.Gyroscope(), que força um Object3D a "perder" a orientação herdada da cena. Essa técnica é chamada de "billboarding", e o Gyroscope é perfeito para isso.

O legal é que todo o DOM e o CSS normais ainda funcionam, como passar o mouse sobre um rótulo de texto 3D e fazer com que ele brilhe com sombras.

Rótulos de texto.
Ao anexar os rótulos de texto a um THREE.Gyroscope(), eles sempre ficam de frente para a câmera.

Ao fazer zoom, descobri que o escalonamento da tipografia estava causando problemas de posicionamento. Talvez isso se deva ao espaçamento entre letras e ao padding do texto? Outro problema era que o texto ficava pixelado quando o zoom era aplicado, já que o renderizador DOM tratava o texto renderizado como um quad com textura. Isso é algo a ser observado ao usar esse método. Em retrospectiva, eu poderia ter usado um texto gigante, e talvez isso seja algo para uma análise futura. Neste projeto, também usei os rótulos de texto de posicionamento CSS "top/left", descritos anteriormente, para elementos muito pequenos que acompanham os planetas no sistema solar.

Reprodução e looping de músicas

A música que tocou durante o "Mapa Galáctico" de Mass Effect foi composta por Sam Hulick e Jack Wall, da Bioware, e tinha o tipo de emoção que eu queria que o visitante sentisse. Queríamos música no nosso projeto porque achamos que ela era uma parte importante da atmosfera, ajudando a criar a sensação de admiração e maravilha que queríamos.

Nosso produtor, Valdean Klump, entrou em contato com Sam, que tinha várias músicas de "cutting floor" de Mass Effect que ele gentilmente nos permitiu usar. A faixa se chama "In a Strange Land".

Usei a tag de áudio para a reprodução de música, mas, mesmo no Chrome, o atributo "loop" não era confiável. Às vezes, ele simplesmente não funcionava. No final, esse hack de tag de áudio dupla foi usado para verificar o fim da reprodução e alternar para a outra tag para reprodução. O que foi decepcionante foi que o still não estava em loop perfeito o tempo todo. Infelizmente, acho que isso foi o melhor que pude 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();

Espaço para melhorias

Depois de trabalhar com o THREE.js por um tempo, sinto que cheguei ao ponto em que meus dados estavam se misturando demais com meu código. Por exemplo, ao definir materiais, texturas e instruções de geometria inline, eu estava basicamente "modelando em 3D com código". Isso foi muito ruim e é uma área em que as iniciativas futuras com o THREE.js podem melhorar muito, por exemplo, definindo dados de material em um arquivo separado, de preferência visualizável e ajustável em algum contexto, e pode ser trazido de volta para o projeto principal.

Nosso colega Ray McClure também passou um tempo criando alguns "sons espaciais" generativos incríveis, que tiveram que ser cortados devido à instabilidade da API de áudio da Web, que fazia o Chrome travar com frequência. É uma pena, mas isso nos fez pensar mais no espaço do som para trabalhos futuros. No momento em que escrevo este artigo, recebi a informação de que a API Web Audio foi corrigida. Portanto, é possível que ela esteja funcionando agora. Fique de olho no futuro.

Os elementos tipográficos combinados com o WebGL ainda são um desafio, e não tenho 100% de certeza de que o que estamos fazendo é a maneira correta. Ainda parece um hack. Talvez versões futuras do THREE, com o CSS Renderer, possam ser usadas para unir melhor os dois mundos.

Créditos

Agradeço a Aaron Koblin por me deixar fazer esse projeto. Jono Brandel pelo excelente design e implementação da interface, tratamento de tipo e implementação do tour. Valdean Klump por dar um nome ao projeto e toda a cópia. Sabah Ahmed por esclarecer os direitos de uso de dados e fontes de imagens. Clem Wright por entrar em contato com as pessoas certas para publicação. Doug Fritz, pela excelência técnica. George Brower por me ensinar JS e CSS. E, claro, Mr. Doob para o THREE.js.

Referências