Olá! Meu nome é Michael Chang e trabalho com a equipe de Data Arts no Google. Recentemente, concluímos o 100.000 Stars, um Chrome Experiment que mostra as 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 temas discutidos aqui serão bastante amplos e exigirão algum conhecimento de THREE.js. No entanto, espero que você ainda possa aproveitar este artigo como uma análise técnica. Use o botão de índice à direita para acessar uma área de interesse. Primeiro, vou mostrar a parte de renderização do projeto, seguida pelo gerenciamento de shader e, por fim, como usar rótulos de texto CSS em combinação com WebGL.

Descobrindo o Space
Logo depois de terminar o Small Arms Globe, eu estava testando uma demonstração de partículas do THREE.js com profundidade de campo. Notei que posso mudar a "escala" interpretada da cena ajustando a quantidade do efeito aplicado. Quando o efeito de profundidade de campo era muito extremo, objetos distantes ficavam muito desfocados, semelhante ao funcionamento da fotografia tilt-shift, que dá a ilusão de olhar para uma cena microscópica. Por outro lado, diminuir o efeito faz parecer que você está olhando para o espaço profundo.
Comecei a procurar dados que pudessem ser usados 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) acompanhada de coordenadas cartesianas xyz pré-calculadas. Vamos começar.


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 uma delas com uma partícula não é um problema para uma GPU moderna. Também há 87 estrelas identificadas individualmente. Por isso, criei uma sobreposição de marcador CSS usando a mesma técnica descrita em Small Arms Globe.
Nessa época, 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 deles, que parece ter sido tirada da Wikipédia: quais espécies prosperaram no planeta, a história geológica dele e assim por diante.
Sabendo a quantidade de dados reais que existem sobre as estrelas, é possível apresentar informações reais sobre a galáxia da mesma forma. O objetivo final deste projeto é 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, inspire um senso de admiração e maravilha sobre o espaço. Ufa.
Antes de continuar, preciso dizer que não sou astrônomo e que este é o trabalho de uma pesquisa amadora com o apoio de alguns conselhos de especialistas externos. Este projeto deve ser interpretado como uma interpretação artística do espaço.
Como criar uma galáxia
Meu plano era gerar um modelo da galáxia que pudesse contextualizar os dados das estrelas e, com sorte, dar uma visão incrível do nosso lugar na Via Láctea.

Para gerar a Via Láctea, criei 100.000 partículas e as coloquei em uma espiral,emulando a forma como os braços galácticos são formados. Não me preocupei muito com os detalhes da formação dos braços espirais porque esse seria um modelo representacional, e não matemático. No entanto, tentei deixar o número de braços espirais mais ou menos correto e girando na "direção certa".
Em versões posteriores do modelo da Via Láctea, dei menos ênfase ao uso de partículas em favor de uma imagem plana de uma galáxia para acompanhar as partículas, na esperança de dar a ela 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.

Decidi desde o início representar uma unidade GL, basicamente um pixel em 3D, como um ano-luz. Essa convenção unificou o posicionamento de tudo o que foi visualizado e, infelizmente, me deu sérios problemas de precisão mais tarde.
Outra convenção que decidi usar 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", de modo que arrastar o mouse para a esquerda e para a direita gira o objeto em questão, mas o zoom é apenas uma questão de mudar camera.position.z.
O campo de visão (FOV) da câmera também é dinâmico. À medida que um puxa para fora, o campo de visão aumenta, abrangendo cada vez mais da galáxia. O contrário é verdadeiro ao se mover para dentro em direção a uma estrela: o campo de visão diminui. Isso permite que a câmera veja coisas infinitesimais (em comparação com a galáxia) ao reduzir o campo de visão para algo como uma lupa divina, sem ter que lidar com problemas de corte quase plano.

Assim, consegui "colocar" o Sol a algumas unidades de distância do centro galáctico. Também consegui visualizar o tamanho relativo do sistema solar mapeando o raio do Penhasco de Kuiper (acabei escolhendo visualizar a Nuvem de Oort). Dentro desse sistema solar em miniatura, também é possível visualizar uma órbita simplificada da Terra e o raio real do Sol em comparação.

O Sol era difícil de renderizar. Tive que usar o máximo de técnicas de gráficos em tempo real que conhecia. A superfície do Sol é uma espuma quente de plasma que precisa pulsar e mudar com o tempo. Isso foi simulado com uma textura bitmap de uma imagem infravermelha da superfície solar. O shader de superfície faz uma pesquisa de cor com base no nível de cinza dessa textura e realiza uma pesquisa em uma rampa de cores separada. Quando essa pesquisa é alterada ao longo do tempo, ela cria essa distorção semelhante a lava.
Uma técnica semelhante foi usada para a coroa do Sol, mas 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.

Os clarões solares foram criados com shaders de vértice e fragmento aplicados a um toroide, girando ao redor da borda da superfície solar. O shader de vértice tem uma função de ruído que faz com que ele se mova de maneira semelhante a uma bolha.
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 em THREE.js, então não consegui aumentar a precisão sem muito trabalho. Os problemas de precisão não eram tão graves perto da origem. No entanto, quando comecei a modelar outros sistemas estelares, isso se tornou um problema.

Usei alguns hacks para reduzir o z-fighting. O Material.polygonoffset do THREE é uma propriedade que permite renderizar polígonos em um local percebido diferente (pelo que entendi). Isso foi usado para forçar o plano da corona a sempre renderizar na parte de cima da superfície do Sol. Abaixo disso, um "halo" do Sol foi renderizado para dar 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, precisei zerar a rotação da cena e girar separadamente o modelo da estrela e o mapa de ambiente para dar a ilusão de que você está orbitando a estrela.
Como criar reflexos da lente

As visualizações de espaço são onde sinto que posso usar o efeito de lente de forma excessiva. O THREE.LensFlare serve para isso. Tudo o que eu precisava fazer era adicionar alguns hexágonos anamórficos e um toque 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 rolar texturas

Para o "plano de orientação espacial", foi criada uma THREE.CylinderGeometry() gigantesca e centralizada no Sol. Para criar o efeito de "onda de luz" que se espalha para fora, modifiquei o deslocamento da textura ao longo do tempo da seguinte maneira:
mesh.material.map.needsUpdate = true;
mesh.material.map.onUpdate = function(){
this.offset.y -= 0.001;
this.needsUpdate = true;
}
map
é a textura do material, que recebe uma função onUpdate que pode ser substituída. Definir o deslocamento faz com que a textura seja "rolada" ao longo desse eixo, e o spamming needsUpdate = true força esse comportamento a entrar em loop.
Como usar gradientes de cores
Cada estrela tem uma cor diferente com base em um "índice de cor" atribuído pelos astrônomos. Em geral, as estrelas vermelhas são mais frias e as azuis/roxas são mais quentes. Há uma faixa de cores brancas e laranja intermediárias nesse gradiente.
Ao renderizar as estrelas, queria dar a cada partícula uma cor própria com base nesses dados. Isso era feito com "atributos" atribuídos ao material do shader 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 shader. Normalmente, seria transmitido um vec3 de cor, mas, neste caso, estou transmitindo um float para a pesquisa eventual da rampa de cores.

A rampa de cores era assim, mas eu precisava acessar os dados de cores bitmap dela em JavaScript. Para fazer isso, primeiro carreguei a imagem no DOM, desenhei 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.

Preparação de sombreadores
Ao longo do projeto, descobri que precisava escrever cada vez mais shaders para realizar todos os efeitos visuais. Escrevi um carregador de sombreador personalizado para essa finalidade porque estava cansado de ter sombreadores 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 shader (esperando .fsh para fragmentos e .vsh para shaders 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,
/_..._/
});
Eu provavelmente poderia ter usado o require.js, mas isso teria exigido alguma remontagem de código apenas para essa finalidade. Essa solução, embora muito mais fácil, pode ser melhorada, talvez até como uma extensão do THREE.js. Se tiver sugestões ou maneiras de fazer isso melhor, entre em contato.
Rótulos de texto CSS em cima do THREE.js
No nosso último projeto, Small Arms Globe, eu tentei fazer com que os rótulos de texto aparecessem em cima de uma cena THREE.js. O método que eu estava usando calcula a posição absoluta do modelo de onde quero que o texto apareça, resolve a posição da tela usando THREE.Projector() e, por fim, usa "top" e "left" do CSS para colocar os elementos CSS na posição desejada.
As primeiras iterações desse projeto usaram a mesma técnica, mas eu estava ansioso para testar este outro método descrito por Luis Cruz.
A ideia básica é corresponder a transformação de matriz do CSS3D à câmera e à cena do THREE. Assim, é possível "colocar" elementos CSS em 3D como se estivessem em 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".

Confira a demonstração (e o código em "Exibir origem") 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 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 giroscópio é perfeito para isso.
O que é muito legal é que todo o DOM e CSS normais ainda funcionam, como passar o cursor do mouse sobre um rótulo de texto 3D e fazer com que ele brilhe com sombras projetadas.

Ao aumentar o zoom, descobri que o escalonamento da tipografia estava causando problemas de posicionamento. Talvez isso aconteça devido ao kerning e ao padding do texto? Outro problema é que o texto fica pixelado quando ampliado, já que o renderizador do DOM trata o texto renderizado como um quad texturizado. É importante saber disso ao usar esse método. Em retrospecto, eu poderia ter usado apenas texto com 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 "top/left", descritos anteriormente, para elementos muito pequenos que acompanham os planetas do sistema solar.
Reprodução e repetição de músicas
A música tocada 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 aquela sensação de admiração e encantamento que estávamos tentando alcançar.
Nosso produtor, Valdean Klump, entrou em contato com Sam, que tinha várias músicas "cortadas" de Mass Effect e que ele nos deixou usar. A música se chama "In a Strange Land".
Usei a tag de áudio para reprodução de música, mas mesmo no Chrome o atributo "loop" não era confiável. Às vezes, ele simplesmente não fazia o loop. 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 é que essa imagem não ficava em loop perfeito o tempo todo, mas acho que fiz o melhor que pude.
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();
Oportunidade de melhoria
Depois de trabalhar com THREE.js por um tempo, senti 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 essencialmente "modelando em 3D com código". Isso foi muito ruim e é uma área em que os futuros projetos com THREE.js podem melhorar muito. Por exemplo, definindo dados de materiais em um arquivo separado, de preferência visualizável e ajustável em algum contexto, e que pode ser trazido de volta ao projeto principal.
Nosso colega Ray McClure também passou algum tempo criando "ruídos espaciais" generativos incríveis, que precisaram ser cortados porque a API Web Audio estava instável e travava o Chrome de vez em quando. É uma pena, mas isso nos fez pensar mais no espaço sonoro para trabalhos futuros. No momento em que escrevo este artigo, fui informado de que a API Web Audio recebeu um patch. Portanto, é possível que ela esteja funcionando agora. Fique de olho nisso no futuro.
Elementos tipográficos combinados com WebGL ainda são um desafio, e não tenho 100% de certeza de que o que estamos fazendo aqui é a maneira correta. Ainda parece uma gambiarra. Talvez versões futuras do THREE, com o CSS Renderer em ascensão, possam ser usadas para unir melhor os dois mundos.
Créditos
Agradeço a Aaron Koblin por me deixar trabalhar nesse projeto. Jono Brandel pelo excelente design e implementação da interface, tratamento de tipos e implementação do tour. Valdean Klump por dar um nome ao projeto e todo o texto. Sabah Ahmed por limpar a tonelada métrica de direitos de uso das fontes de dados e 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, o Mr. Doob pelo THREE.js.