Fabricación de 100,000 estrellas

¡Hola! Me llamo Michael Chang y trabajo con el equipo de Artes de Datos de Google. Recientemente, completamos 100,000 estrellas, un experimento de Chrome que visualiza estrellas cercanas. El proyecto se compiló con THREE.js y CSS3D. En este caso de éxito, describiré el proceso de descubrimiento, compartiré algunas técnicas de programación y terminaré con algunas ideas para mejoras futuras.

Los temas que se analizarán aquí serán bastante amplios y requerirán algunos conocimientos de THREE.js, aunque espero que puedas disfrutar de este post mortem técnico. No dudes en saltar a un área de interés con el botón del índice de la derecha. Primero, mostraré la parte de renderización del proyecto, seguida de la administración de sombreadores y, por último, cómo usar etiquetas de texto CSS en combinación con WebGL.

100,000 estrellas, un experimento de Chrome del equipo de Data Arts
100,000 Stars usa THREE.js para visualizar estrellas cercanas en la Vía Láctea.

Descubriendo el espacio

Poco después de terminar Small Arms Globe, experimenté con una demostración de partículas de THREE.js con profundidad de campo. Noté que podía cambiar la "escala" interpretada de la escena ajustando la cantidad del efecto aplicado. Cuando el efecto de profundidad de campo era muy extremo, los objetos distantes se volvían muy desenfocados, de manera similar a como funciona la fotografía con desplazamiento de inclinación, lo que da la ilusión de mirar una escena microscópica. Por el contrario, bajar el efecto hizo que pareciera que estabas mirando el espacio profundo.

Comencé a buscar datos que pudiera usar para insertar posiciones de partículas, una ruta que me llevó a la base de datos HYG de astronexus.com, una compilación de las tres fuentes de datos (Hipparcos, Yale Bright Star Catalog y Gliese/Jahreiss Catalog) acompañadas de coordenadas cartesianas xyz calculadas previamente. Comencemos.

Cómo graficar datos de estrellas
El primer paso es trazar cada estrella del catálogo como una sola partícula.
Las estrellas con nombre
Algunas estrellas del catálogo tienen nombres propios, que se etiquetan aquí.

Tardé alrededor de una hora en crear algo que colocara los datos de las estrellas en el espacio 3D. Hay exactamente 119,617 estrellas en el conjunto de datos, por lo que representar cada estrella con una partícula no es un problema para una GPU moderna. También hay 87 estrellas identificadas de forma individual, por lo que creé una superposición de marcadores de CSS con la misma técnica que describimos en Small Arms Globe.

Durante ese tiempo, acababa de terminar la serie Mass Effect. En el juego, se invita al jugador a explorar la galaxia y explorar varios planetas y leer sobre su historia completamente ficticia que suena a Wikipedia: qué especies prosperaron en el planeta, su historia geológica, etcétera.

Si conocemos la gran cantidad de datos reales que existen sobre las estrellas, se podría presentar información real sobre la galaxia de la misma manera. El objetivo final de este proyecto sería dar vida a estos datos, permitir que el usuario explore la galaxia al estilo de Mass Effect, aprenda sobre las estrellas y su distribución, y, con suerte, inspire un sentimiento de asombro y admiración por el espacio. ¡Vaya!

Antes de continuar con el resto de este caso de éxito, debo decir que no soy astrónomo y que este es el trabajo de una investigación amateur respaldada por algunos consejos de expertos externos. Este proyecto debe interpretarse como una interpretación artística del espacio.

Cómo crear una galaxia

Mi plan era generar de forma procedural un modelo de la galaxia que pueda poner los datos de las estrellas en contexto y, con suerte, brindar una vista increíble de nuestro lugar en la Vía Láctea.

Un prototipo inicial de la galaxia.
Un prototipo inicial del sistema de partículas de Milky Way.

Para generar la Vía Láctea, creé 100,000 partículas y las coloqué en una espiral emulando la forma en que se forman los brazos galácticos. No me preocupaban demasiado los detalles de la formación de los brazos espirales, ya que este sería un modelo representativo en lugar de uno matemático. Sin embargo, intenté que la cantidad de brazos espirales fuera más o menos correcta y que giraran en la "dirección correcta".

En versiones posteriores del modelo de la Vía Láctea, quité énfasis en el uso de partículas en favor de una imagen plana de una galaxia para acompañarlas, con la esperanza de darle un aspecto más fotográfico. La imagen real es de la galaxia espiral NGC 1232, que se encuentra a unos 70 millones de años luz de distancia, y se manipuló para que se vea como la Vía Láctea.

Descubrimiento de la escala de la galaxia
Cada unidad de GL es un año luz. En este caso,la esfera tiene un ancho de 110, 000 años luz y abarca el sistema de partículas.

Al principio, decidí representar una unidad de GL, básicamente un píxel en 3D, como un año luz, una convención que unificó la ubicación de todo lo visualizado y, lamentablemente, me generó problemas de precisión graves más adelante.

Otra convención que decidí fue rotar toda la escena en lugar de mover la cámara, algo que hice en algunos otros proyectos. Una ventaja es que todo se coloca en una "mesa giratoria" para que, cuando se arrastre el mouse hacia la izquierda y la derecha, se rote el objeto en cuestión, pero acercar la imagen es solo cuestión de cambiar camera.position.z.

El campo visual (o FOV) de la cámara también es dinámico. A medida que uno se aleja, el campo visual se amplía y abarca cada vez más de la galaxia. Lo contrario ocurre cuando te mueves hacia una estrella, el campo de visión se estrecha. Esto permite que la cámara vea objetos infinitesimales (en comparación con la galaxia) comprimiendo el FOV hasta convertirlo en una especie de lupa divina sin tener que lidiar con problemas de recorte de plano cercano.

Diferentes formas de renderizar una galaxia.
(arriba) Galaxia de partículas temprana. (abajo) Partículas acompañadas de un plano de imagen.

Desde allí, pude “colocar” el Sol a cierta cantidad de unidades del núcleo galáctico. También pude visualizar el tamaño relativo del sistema solar trazando el radio del acantilado de Kuiper (al final, decidí visualizar la nube de Oort). Dentro de este modelo del sistema solar, también pude visualizar una órbita simplificada de la Tierra y el radio real del Sol en comparación.

El sistema solar.
El Sol orbitado por planetas y una esfera que representa el cinturón de Kuiper.

Fue difícil renderizar el Sol. Tuve que hacer trampa con todas las técnicas de gráficos en tiempo real que conocía. La superficie del Sol es una espuma caliente de plasma y necesita pulsar y cambiar con el tiempo. Esto se simuló con una textura de mapa de bits de una imagen infrarroja de la superficie solar. El sombreador de superficie realiza una búsqueda de color en función de la escala de grises de esta textura y realiza una búsqueda en una rampa de colores independiente. Cuando esta búsqueda se desplaza con el tiempo, se crea esta distorsión similar a la lava.

Se usó una técnica similar para la corona del Sol, excepto que sería una tarjeta de sprite plana que siempre se orienta a la cámara con https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js.

Renderización de Sol.
Versión anterior del Sol.

Las llamaradas solares se crearon con sombreadores de vértices y fragmentos aplicados a un toro que gira alrededor del borde de la superficie solar. El sombreador de vértices tiene una función de ruido que hace que se entrelace de forma similar a una mancha.

Fue aquí donde comencé a experimentar algunos problemas de z-fighting debido a la precisión de GL. Todas las variables de precisión estaban predefinidas en THREE.js, por lo que no podía aumentar la precisión de forma realista sin una gran cantidad de trabajo. Los problemas de precisión no fueron tan graves cerca del origen. Sin embargo, una vez que comencé a modelar otros sistemas estelares, esto se convirtió en un problema.

Modelo de estrella.
Más tarde, el código para renderizar el Sol se generalizó para renderizar otras estrellas.

Usé algunos trucos para mitigar la lucha contra Z. Material.polygonoffset de THREE es una propiedad que permite que los polígonos se rendericen en una ubicación percibida diferente (según entiendo). Esto se usó para forzar que el plano de la corona siempre se renderizara sobre la superficie del Sol. Debajo de esto, se renderizó un “halo” solar para dar rayos de luz nítidos que se alejan de la esfera.

Otro problema relacionado con la precisión era que los modelos de estrellas comenzaban a parpadear a medida que se acercaba la escena. Para solucionar este problema, tuve que “anular” la rotación de la escena y rotar por separado el modelo de la estrella y el mapa de entorno para dar la ilusión de que orbitas la estrella.

Cómo crear un destello de lente

Un gran poder conlleva una gran responsabilidad.
Un gran poder conlleva una gran responsabilidad.

En las visualizaciones espaciales, siento que puedo usar el destello de lente en exceso. THREE.LensFlare sirve para este propósito. Solo tuve que agregar algunos hexágonos anamórficos y un toque de JJ Abrams. En el siguiente fragmento, se muestra cómo construirlos en tu escena.

// 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;

}
}

Una forma sencilla de hacer el desplazamiento de texturas

Se inspira en Homeworld.
Un plano cartesiano que ayuda con la orientación espacial en el espacio.

Para el "plano de orientación espacial", se creó un THREE.CylinderGeometry() gigante centrado en el Sol. Para crear la "ola de luz" que se extiende hacia afuera, modifiqué su desplazamiento de textura con el tiempo de la siguiente manera:

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

map es la textura que pertenece al material, que obtiene una función onUpdate que puedes reemplazar. Si estableces su desplazamiento, la textura se “desplazará” a lo largo de ese eje, y si envías spam con needsUpdate = true, se forzar el bucle de este comportamiento.

Cómo usar las escalas de colores

Cada estrella tiene un color diferente según un “índice de color” que los astrónomos les asignaron. En general, las estrellas rojas son más frías y las estrellas azules o púrpuras son más calientes. En este gradiente, hay una banda de colores blanco y naranja intermedio.

Cuando rendericé las estrellas, quería darle a cada partícula su propio color según estos datos. La forma de hacerlo era con los "atributos" que se le asignaban al material del sombreador aplicado a las partículas.

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

Si se completa el array colorIndex, cada partícula tendrá su color único en el sombreador. Por lo general, se pasa un vec3 de color, pero en este caso paso un número de punto flotante para la búsqueda de rampa de colores final.

Rampa de colores.
Es una rampa de colores que se usa para buscar el color visible del índice de color de una estrella.

La rampa de colores se veía así, pero necesitaba acceder a sus datos de color de mapa de bits desde JavaScript. Para ello, primero cargué la imagen en el DOM, la dibujé en un elemento de lienzo y, luego, accedí al mapa de bits del lienzo.

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

Luego, se usa este mismo método para colorear estrellas individuales en la vista del modelo de estrellas.

¡Mis ojos!
Se usa la misma técnica para buscar el color de la clase espectral de una estrella.

Control de sombreadores

A lo largo del proyecto, descubrí que necesitaba escribir cada vez más sombreadores para lograr todos los efectos visuales. Escribí un cargador de sombreadores personalizado para este propósito porque estaba cansado de tener sombreadores en 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') );

}
}

La función loadShaders() toma una lista de nombres de archivos de sombreadores (se espera .fsh para los sombreadores de fragmentos y .vsh para los sombreadores de vértices), intenta cargar sus datos y, luego, reemplaza la lista por objetos. El resultado final está en tus uniformes de THREE.js. Puedes pasarle sombreadores de la siguiente manera:

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

Probablemente podría haber usado require.js, aunque eso habría requerido un reensamblado de código solo para este propósito. Si bien esta solución es mucho más fácil, creo que se podría mejorar, incluso como una extensión de THREE.js. Si tienes sugerencias o formas de hacerlo mejor, avísame.

Etiquetas de texto CSS sobre THREE.js

En nuestro último proyecto, Small Arms Globe, jugué con la idea de hacer que las etiquetas de texto aparecieran sobre una escena de THREE.js. El método que usaba calcula la posición absoluta del modelo donde quiero que aparezca el texto y, luego, resuelve la posición de la pantalla con THREE.Projector() y, por último, usa CSS "top" y "left" para colocar los elementos de CSS en la posición deseada.

En las primeras iteraciones de este proyecto, se usó esta misma técnica. Sin embargo, me muero de ganas de probar este otro método que describió Luis Cruz.

La idea básica es hacer coincidir la transformación de matriz de CSS3D con la cámara y la escena de THREE, y puedes "colocar" elementos CSS en 3D como si estuvieran en la parte superior de la escena de THREE. Sin embargo, esto tiene limitaciones. Por ejemplo, no podrás colocar texto debajo de un objeto THREE.js. Esto sigue siendo mucho más rápido que intentar realizar el diseño con los atributos CSS "top" y "left".

Etiquetas de texto
Usa transformaciones CSS3D para colocar etiquetas de texto sobre WebGL.

Puedes encontrar la demostración (y el código en Ver código fuente) de esto aquí. Sin embargo, descubrí que el orden de la matriz cambió para THREE.js. La función que actualicé:

/_ 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 todo se transforma, el texto ya no está orientado hacia la cámara. La solución fue usar THREE.Gyroscope(), que obliga a un Object3D a “perder” su orientación heredada de la escena. Esta técnica se denomina "publicación de anuncios", y el giroscopio es perfecto para hacerlo.

Lo que es realmente bueno es que todo el DOM y CSS normales siguen funcionando, como poder colocar el cursor sobre una etiqueta de texto en 3D y hacer que brille con sombras.

Etiquetas de texto
Hacer que las etiquetas de texto siempre estén orientadas a la cámara conectándolas a un THREE.Gyroscope().

Cuando acerqué la imagen, descubrí que el escalamiento de la tipografía causaba problemas de posicionamiento. ¿Es posible que se deba al espaciado entre caracteres y el padding del texto? Otro problema era que el texto se pixelaba cuando se acercaba, ya que el renderizador de DOM trata el texto renderizado como un cuádruple con textura, algo que debes tener en cuenta cuando uses este método. En retrospectiva, podría haber usado texto de tamaño de fuente gigante, y quizás esto sea algo para explorar en el futuro. En este proyecto, también usé las etiquetas de texto de posición "superior/izquierda" de CSS, que se describieron anteriormente, para elementos muy pequeños que acompañan a los planetas del sistema solar.

Reproducción y repetición de música

La pieza musical que se reproduce durante el “Mapa galáctico” de Mass Effect fue compuesta por Sam Hulick y Jack Wall de Bioware, y tenía el tipo de emoción que quería que experimentara el visitante. Queríamos incluir música en nuestro proyecto porque sentíamos que era una parte importante de la atmósfera y ayudaba a crear esa sensación de asombro y admiración que intentábamos lograr.

Nuestro productor Valdean Klump se comunicó con Sam, quien tenía un montón de música de Mass Effect que nos permitió usar con mucho gusto. El título de la pista es "In a Strange Land".

Usé la etiqueta de audio para la reproducción de música. Sin embargo, incluso en Chrome, el atributo "loop" no era confiable; a veces, simplemente no se repetía. Al final, se usó este hack de etiqueta de audio dual para verificar el final de la reproducción y pasar al otro segmento para reproducirlo. Lo que me decepcionó fue que este fotograma no se repetía perfectamente todo el tiempo. Lamentablemente, creo que esto fue lo mejor que pude hacer.

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

Posibilidades de mejora

Después de trabajar con THREE.js durante un tiempo, creo que llegué al punto en el que mis datos se mezclaban demasiado con mi código. Por ejemplo, cuando definía materiales, texturas y las instrucciones de geometría intercaladas, en esencia, estaba “modelando en 3D con código”. Esto fue muy malo y es un área en la que los esfuerzos futuros con THREE.js podrían mejorar mucho, por ejemplo, definir los datos de material en un archivo separado, preferiblemente visible y modificable en algún contexto, y se puede volver a incorporar al proyecto principal.

Nuestro colega Ray McClure también dedicó tiempo a crear algunos "ruidos espaciales" generativos increíbles que se tuvieron que cortar debido a que la API de audio web era inestable y causaba fallas en Chrome de vez en cuando. Es una lástima, pero definitivamente nos hizo pensar más en el espacio de sonido para trabajos futuros. En el momento de escribir este mensaje, me informaron que se corrigió la API de Web Audio, por lo que es posible que ahora funcione. Esto es algo que debes tener en cuenta en el futuro.

Los elementos tipográficos combinados con WebGL siguen siendo un desafío, y no estoy 100% seguro de que lo que estamos haciendo aquí sea la forma correcta. Todavía parece un hack. Quizás las versiones futuras de THREE, con su Renderer de CSS en desarrollo, se puedan usar para unir mejor los dos mundos.

Créditos

Gracias a Aaron Koblin por dejarme hacer lo que quisiera con este proyecto. Jono Brandel por el excelente diseño y la implementación de la IU, el tratamiento de tipos y la implementación de la visita. Valdean Klump por darle un nombre al proyecto y toda la copia. A Sabah Ahmed por aclarar los derechos de uso de las fuentes de datos e imágenes. Clem Wright por comunicarse con las personas adecuadas para la publicación. Doug Fritz por su excelencia técnica. George Brower por enseñarme JS y CSS. Y, por supuesto, a Mr. Doob por THREE.js.

Referencias