Fabricación de 100,000 estrellas

¡Hola! Mi nombre es Michael Chang y trabajo con el equipo de Data Arts en Google. Hace poco, completamos 100,000 Stars, un Chrome Experiment que visualiza las 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 finalizaré con algunas ideas para mejorar en el futuro.

Los temas que se abordan aquí serán bastante amplios y requerirán algunos conocimientos de THREE.js, aunque espero que puedas disfrutar de este análisis técnico posterior. Puedes ir a un área de interés con el botón de la tabla de contenido que se encuentra a la derecha. Primero, mostraré la parte de procesamiento del proyecto, luego la administración de sombreadores y, por último, cómo usar etiquetas de texto CSS en combinación con WebGL.

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

Descubrir el Space

Poco después de terminar Small Arms Globe, estaba experimentando 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 veían muy borrosos, de manera similar a la forma en que la fotografía de cambio de inclinación y desplazamiento da la ilusión de mirar una escena microscópica. Por el contrario, bajar el efecto hacía que pareciera que estabas mirando el espacio profundo.

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

Generar gráficos de datos de estrellas
El primer paso es representar cada estrella del catálogo como una sola partícula.
Las estrellas con nombre.
Algunas estrellas del catálogo tienen nombres propios, que se indican aquí.

Me llevó alrededor de una hora 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 individualmente, por lo que creé una superposición de marcadores CSS con la misma técnica que describí en Small Arms Globe.

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

Conociendo 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 público explore la galaxia al estilo de Mass Effect, aprender sobre las estrellas y su distribución, y, con suerte, inspirar una sensación de asombro y maravilla sobre el espacio. ¡Vaya!

Probablemente debería comenzar el resto de este caso de estudio diciendo que no soy astrónomo de ninguna manera 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 construir una galaxia

Mi plan era generar de forma procedimental un modelo de la galaxia que pudiera poner los datos de las estrellas en contexto y, con suerte, ofrecer 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 la Vía Láctea.

Para generar la Vía Láctea, creé 100,000 partículas y las coloqué en espiral emulando la forma en que se forman los brazos galácticos. No me preocupé demasiado por los detalles específicos 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é el énfasis en el uso de partículas y lo reemplacé por una imagen plana de una galaxia para acompañar las partículas, con la esperanza de darle una apariencia más fotográfica. La imagen real es de la galaxia espiral NGC 1232, que se encuentra a unos 70 millones de años luz de nosotros y que se manipuló para que se pareciera a la Vía Láctea.

Descubrir la escala de la galaxia
Cada unidad de GL equivale a un año luz. En este caso,la esfera tiene un diámetro de 110, 000 años luz y abarca el sistema de partículas.

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

Otra convención que decidí fue rotar toda la escena en lugar de mover la cámara, algo que ya había hecho en otros proyectos. Una ventaja es que todo se coloca en un "tocadiscos", de modo que arrastrar el mouse hacia la izquierda y la derecha rota el objeto en cuestión, pero acercar el zoom solo implica cambiar camera.position.z.

El campo visual (o FOV) de la cámara también es dinámico. A medida que se aleja, el campo visual se amplía y abarca cada vez más de la galaxia. Lo contrario ocurre cuando te acercas a una estrella, ya que el campo de visión se estrecha. Esto permite que la cámara vea cosas infinitesimales (en comparación con la galaxia) al reducir el FOV a algo parecido a una lupa divina sin tener que lidiar con problemas de recorte del plano cercano.

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

Desde aquí, 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 (finalmente, elegí 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.

El Sol era difícil de renderizar. 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 que debe palpitar y cambiar con el tiempo. Esto se simuló a través de una textura de mapa de bits de una imagen infrarroja de la superficie solar. El sombreador de superficie realiza una búsqueda de color basada en la escala de grises de esta textura y realiza una búsqueda en una rampa de color 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 mira a la cámara con https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js.

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

Las llamaradas solares se crearon con sombreadores de vértices y fragmentos aplicados a un toroide que gira justo 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 burbuja.

Aquí es donde comencé a experimentar algunos problemas de z-fighting debido a la precisión de GL. Todas las variables de precisión se definieron previamente en THREE.js, por lo que no pude aumentar la precisión de forma realista sin una gran cantidad de trabajo. Los problemas de precisión no eran tan graves cerca del origen. Sin embargo, cuando comencé a modelar otros sistemas estelares, esto se convirtió en un problema.

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

Empleé algunos trucos para mitigar el z-fighting. Material.polygonoffset de THREE es una propiedad que permite renderizar polígonos en una ubicación percibida diferente (según entiendo). Esto se usaba para forzar que el plano de la corona siempre se renderizara sobre la superficie del Sol. Debajo de esta, se renderizó un "halo" solar para generar 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 temblar a medida que se acercaba la escena. Para corregir esto, tuve que "poner en cero" la rotación de la escena y rotar por separado el modelo de la estrella y el mapa del entorno para dar la ilusión de que estás orbitando la estrella.

Cómo crear destellos de lente

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

En las visualizaciones del espacio, siento que puedo usar el efecto de destello de lente en exceso. THREE.LensFlare cumple con este propósito. Lo único que tuve que hacer fue 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 un desplazamiento de texturas

Inspirado en Homeworld.
Un plano cartesiano para ayudar con la orientación espacial en el espacio.

Para el "plano de orientación espacial", se creó un gigantesco THREE.CylinderGeometry() y se centró en el Sol. Para crear el efecto de "onda 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 anular. Establecer su desplazamiento hace que la textura se "desplace" a lo largo de ese eje, y enviar spam con needsUpdate = true forzaría este comportamiento a repetirse.

Cómo usar rampas de color

Cada estrella tiene un color diferente según un "índice de color" que los astrónomos le 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 blancos y naranjas intermedios.

Cuando renderizaba las estrellas, quería que cada partícula tuviera su propio color según estos datos. La forma de hacerlo fue con "atributos" que se le dieron 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: [] },
};

Completar el array colorIndex le daría a cada partícula su color único en el sombreador. Normalmente, se pasaría un vec3 de color, pero, en este caso, paso un valor de coma flotante para la búsqueda eventual de la rampa de color.

Es una rampa de color.
Una rampa de color que se usa para buscar el color visible a partir del índice de color de una estrella.

La rampa de color se veía así, pero necesitaba acceder a sus datos de color de mapa de bits desde JavaScript. Para hacerlo, 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 el mismo método para colorear las 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.

Tratamiento 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 sombreadores de fragmentos y .vsh para sombreadores de vértices), intenta cargar sus datos y, luego, reemplaza la lista por objetos. El resultado final se encuentra en tus variables uniformes de THREE.js, a las que podrías pasar 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 poco de reensamblaje de código solo para este propósito. Creo que esta solución, aunque es mucho más sencilla, podría mejorarse, tal vez incluso como una extensión de Three.js. Si tienes sugerencias o formas de mejorar este proceso, no dudes en comunicarte con nosotros.

Etiquetas de texto CSS sobre THREE.js

En nuestro último proyecto, Small Arms Globe, experimenté con la posibilidad 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 en la que quiero que aparezca el texto, luego resuelve la posición de la pantalla con THREE.Projector() y, finalmente, usa "top" y "left" de CSS para colocar los elementos de CSS en la posición deseada.

Las primeras iteraciones de este proyecto usaron la misma técnica, pero me moría por 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 sobre la escena de THREE. Sin embargo, esto tiene limitaciones. Por ejemplo, no podrás hacer que el texto quede debajo de un objeto de THREE.js. Esto sigue siendo mucho más rápido que intentar realizar el diseño con los atributos "top" y "left" de CSS.

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

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

/_ 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 apunta 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 "billboarding", y el giroscopio es perfecto para realizarla.

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

Etiquetas de texto
Haz que las etiquetas de texto siempre apunten hacia la cámara uniéndolas a un THREE.Gyroscope().

Cuando acerqué la imagen, descubrí que el escalamiento de la tipografía causaba problemas de posicionamiento. ¿Quizás se deba al kerning y al padding del texto? Otro problema era que el texto se pixelaba cuando se acercaba, ya que el renderizador del DOM trata el texto renderizado como un cuadrilátero texturizado, algo que se debe tener en cuenta cuando se usa este método. En retrospectiva, podría haber usado texto con un tamaño de fuente gigantesco, y tal vez esto sea algo para explorar en el futuro. En este proyecto, también usé las etiquetas de texto de ubicación CSS "top/left", 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, compositores 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, ya que ayudaba a crear esa sensación de asombro y maravilla que queríamos lograr.

Nuestro productor Valdean Klump se comunicó con Sam, quien tenía mucha música de "sala de edición" de Mass Effect que nos permitió usar con mucha amabilidad. La pista se titula "In a Strange Land".

Usé la etiqueta de audio para la reproducción de música, pero incluso en Chrome el atributo "loop" no era confiable; a veces, simplemente no se repetía. Al final, este hack de etiquetas de audio dual se usó para verificar el final de la reproducción y cambiar a la otra etiqueta para la reproducción. Lo decepcionante fue que esta imagen no se repetía perfectamente todo el tiempo, pero creo que hice lo mejor 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();

Puede mejorar

Después de trabajar con THREE.js durante un tiempo, siento que llegué a un punto en el que mis datos se mezclaban demasiado con mi código. Por ejemplo, cuando definía materiales, texturas y geometría en línea, básicamente estaba "modelando en 3D con código". Esto se sintió muy mal y es un área en la que los futuros esfuerzos con THREE.js podrían mejorar mucho, por ejemplo, definiendo datos de materiales en un archivo separado, preferiblemente visible y modificable en algún contexto, y que se pueda volver a incorporar al proyecto principal.

Nuestro compañero Ray McClure también dedicó tiempo a crear algunos ruidos espaciales generativos increíbles, que debieron cortarse debido a que la API de audio web era inestable y fallaba en Chrome con frecuencia. Es una pena, pero sin duda nos hizo pensar más en el espacio del sonido para trabajos futuros. Al momento de escribir este artículo, se me informó que se corrigió la API de Web Audio, por lo que es posible que ahora funcione. Esto es algo que se debe 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. Aún se siente como un truco. Quizás las versiones futuras de THREE, con su próximo CSS Renderer, se puedan usar para unir mejor los dos mundos.

Créditos

Gracias a Aaron Koblin por permitirme hacer lo que quisiera con este proyecto. Jono Brandel por el excelente diseño e implementación de la IU, el tratamiento de texto y la implementación del recorrido. Valdean Klump por darle un nombre al proyecto y por todo el texto Sabah Ahmed por obtener una tonelada métrica de derechos de uso para las fuentes de datos y de 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