Fabricación de 100,000 estrellas

¡Hola! Mi nombre es Michael Chang y trabajo con el equipo de Data Arts de Google. Recientemente, completamos 100,000 estrellas, un experimento de Chrome para visualizar estrellas cercanas. El proyecto se compiló con THREE.js y CSS3D. En este caso práctico, describiré el proceso de descubrimiento, compartiré algunas técnicas de programación y terminaré con algunas ideas para mejorarlas en el futuro.

Los temas que analizamos aquí son bastante amplios y requieren cierto conocimiento de THREE.js, aunque esperamos que puedas disfrutar de este proceso en un proceso post mortem técnico. Siéntete libre de saltar a un área de interés con el botón del índice que se encuentra a la derecha. Primero, te 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 Data Arts Team
100,000 estrellas usa THREE.js para visualizar las estrellas cercanas en la Vía Láctea

Descubrimiento del espacio

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 realmente extrema, los objetos lejanos se volvieron realmente borrosos de manera similar a como funciona la fotografía de inclinación/desplazamiento al dar a las personas la ilusión de observar una escena microscópica. Por el contrario, bajar el efecto hizo que parecera como si estuvieras mirando el espacio profundo.

Comencé a buscar datos que podía usar para inyectar posiciones de partículas, una ruta que me lleva 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 precalculadas. Comencemos.

Trazado de datos de estrella.
El primer paso es modelar 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ó aproximadamente una hora en hackear algo que colocaba los datos de las estrellas en un 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, así que creé una superposición de marcadores CSS con la misma técnica que describí en el globo terráqueo de brazos pequeños.

En ese entonces, recién terminaba la serie de Mass Effect. En el juego, se invita al jugador a explorar la galaxia, explorar varios planetas y leer sobre su historia completamente ficticia, que parece Wikipedia: qué especies habían prosperado en el planeta, su historia geológica, etc.

Al conocer 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 al espectador explorar 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 asombro sobre el espacio. ¡Vaya!

Probablemente debería empezar el resto de este caso de éxito diciendo que no soy astrónomo en absoluto y que este es el trabajo de una investigación amateur con el respaldo de expertos externos. Este proyecto definitivamente debe interpretarse como una interpretación artística del espacio.

Construir una galaxia

Mi plan era generar de manera 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, generé 100,000 partículas y las coloqué en una espiral emulando la forma en que se forman los brazos galácticos. No me preocupaban los detalles de la formación de los brazos en espiral, ya que este sería un modelo representacional y no matemático. Sin embargo, intenté que el número de brazos en espiral fuera más o menos correcto y gire en la "dirección correcta".

En versiones posteriores del modelo de la Vía Láctea, restablecí el uso de partículas en favor de una imagen plana de una galaxia que acompañe a las partículas, con suerte dándole un aspecto más fotográfico. La imagen real es de la galaxia en espiral NGC 1232, que está a unos 70 millones de años luz de distancia de la nuestra, y se manipula la imagen para que se vea como la Vía Láctea.

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

Desde un principio, decidí representar una unidad de GL, básicamente, un píxel en 3D, como un año luz: una convención que unificaba la ubicación para todo lo que se podía visualizar y, lamentablemente, después me dio graves problemas de precisión.

Otra convención que decidí es rotar toda la escena en lugar de mover la cámara, algo que ya hice en otros proyectos. Una de las ventajas es que todo se coloca en un "todisco" de modo que, si arrastra el mouse hacia la izquierda y hacia la derecha, se rote el objeto en cuestión, pero para acercar la imagen solo es cuestión de cambiar Camera.position.z.

El campo visual de la cámara también es dinámico. A medida que uno se desplaza hacia afuera, el campo visual se amplía, tomando cada vez más de la galaxia. Cuando se mueve hacia adentro en dirección a una estrella, el campo visual se estrecha. De esta manera, la cámara puede ver objetos infinitesimales (en comparación con la galaxia) apretando el campo visual a una forma de una lupa divina sin tener que lidiar con problemas de recorte del plano cercano.

Diferentes formas de representar una galaxia.
(arriba) Galaxia de partículas tempranas. Partículas acompañadas de un plano de imagen (a continuación).

Desde aquí, pude "colocar" el Sol a algunas unidades del núcleo galáctico. Además, pude visualizar el tamaño relativo del sistema solar mediante el mapeo del radio del amplitud de Kuiper (con el tiempo elegí visualizar la nube de Oort). Dentro de este modelo del sistema solar, también podría 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.

Era difícil representar el Sol. Tuve que hacer trampa con tantas técnicas gráficas en tiempo real como sabía. La superficie del Sol es una espuma caliente de plasma, que se necesita para pulsar y cambiar con el tiempo. Esto se simuló mediante 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 la textura y la búsqueda en una rampa de color separada. Cuando esta búsqueda se desplaza con el tiempo, se crea una distorsión similar a la de un planeta de lava.

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

Sol. de renderización
Primera versión del Sol.

Los destellos solares se creaban a través de vértices y sombreadores de fragmentos aplicados a un toro, que giraban alrededor del borde de la superficie solar. El sombreador de vértices tiene una función de ruido que se entrelaza en forma de BLOB.

Fue aquí donde empecé a experimentar algunos problemas de lucha contra el zócalo debido a la precisión de GL. Todas las variables de precisión estaban predefinidas en THREE.js, por lo que no podría aumentar la precisión sin mucho trabajo. Los problemas de precisión no fueron tan graves cerca del origen. Sin embargo, una vez que comencé a modelar otros sistemas en estrella, esto se convirtió en un problema.

Modelo de estrella.
El código para renderizar el Sol luego se generalizó con el objetivo de renderizar otras estrellas.

Tuve algunos trucos para mitigar el efecto de la pelea Z. Material.polygonoffset de TRES es una propiedad que permite que los polígonos se rendericen en una ubicación percibida diferente (hasta donde entiendo). Esto se usaba para obligar al plano de corona a que siempre se renderizara sobre la superficie del Sol. Debajo de este, se renderizaba un "halo" solar para emitir rayos de luz nítidos que se alejaban de la esfera.

Otro problema relacionado con la precisión era que los modelos de estrellas comenzaban a moverse a medida que la escena se acercaba. Para solucionar esto, tuve que poner en cero la rotación de la escena y rotar por separado el modelo de estrella y el mapa de entorno para dar la ilusión de que estás orbitando la estrella.

Creando Lensflare

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

Las visualizaciones espaciales son donde siento que puedo dejarme llevar con el uso excesivo de lentes. THREE.LensFlare sirve para esto: todo lo que necesité hacer fue agregar algunos hexágonos anamórficos y una pizca 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 fácil de realizar el desplazamiento de texturas

Inspirada 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 gigantesco THREE.CylinderGeometry() y se centró en el Sol. Para crear la "ola de luz" en abanico hacia afuera, modifiqué el desplazamiento de la textura con el tiempo de esta 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. Establecer su desplazamiento hace que la textura se "desplace" a lo largo de ese eje, y el spam needsUpdate = true forzaría este comportamiento a repetirse indefinidamente.

Cómo usar rampas de color

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

Cuando se renderizan las estrellas, quería darle su propio color a cada partícula, según estos datos. La forma de hacerlo fue con "atributos" proporcionados 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á un color único en el sombreador. Normalmente, uno pasaría un color vec3, pero, en este caso, voy a pasar un número de punto flotante para la búsqueda final de aumento de color.

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

La rampa de colores se veía así, sin embargo, tuve que acceder a los datos de color del mapa de bits desde JavaScript. Para ello, cargamos primero la imagen en 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;
}

Este mismo método se usa para colorear estrellas individuales en la vista del modelo de estrella.

¡Mis ojos!
Se usa la misma técnica para realizar la búsqueda de colores en la clase espectral de una estrella.

Transformación 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 vivos 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 del sombreador (que esperan .fsh para los fragmentos y .vsh para los sombreadores de vértices), intenta cargar sus datos y, luego, simplemente reemplaza la lista por objetos. El resultado final será en tus uniformes de THREE.js, podrías pasarle sombreadores de la siguiente manera:

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

Probablemente podría haber usado required.js, aunque para ello habría sido necesario volver a ensamblar código solo para este propósito. Creo que esta solución, aunque es mucho más fácil, podría mejorarse incluso como una extensión THREE.js. Si tienes sugerencias o formas de mejorarlo, comunícate con nosotros.

Etiquetas de texto CSS sobre THREE.js

En nuestro último proyecto, Small Arms Globe, me dediqué a hacer que las etiquetas de texto aparecieran en la parte superior de una escena de THREE.js. El método que estaba usando calcula la posición absoluta del modelo en el que quiero que aparezca el texto, luego resuelve la posición de la pantalla con THREE.Projector() y, por último, usa CSS “arriba” e “izquierda” para colocar los elementos CSS en la posición deseada.

En las primeras iteraciones de este proyecto, se utilizó esta misma técnica, pero he estado ansioso por probar este otro método descrito por Luis Cruz.

La idea básica es hacer coincidir la transformación de matriz de CSS3D con la cámara y la escena de TRES, y puedes “colocar” elementos CSS en 3D como si estuvieran encima de TRES escenas. Sin embargo, esto tiene limitaciones; por ejemplo, no podrás que haya texto debajo de un objeto THREE.js. Este proceso sigue siendo mucho más rápido que intentar realizar un diseño con los atributos CSS "izquierda" y "superior".

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

Puedes encontrar la demostración (y el código en la fuente de vista) aquí. Sin embargo, encontré 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(",") + ")";
}

Dado que todo se transformó, el texto ya no mira a 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 llama "billboard", y el giroscopio es perfecto para esta tarea.

Lo que es realmente bueno es que todos los DOM y CSS normales siguen funcionando, como poder desplazar el mouse sobre una etiqueta de texto 3D y hacer que brille con sombras paralelas.

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

Cuando acerqué la tarjeta, descubrí que el ajuste de la tipografía causaba problemas con el posicionamiento. Quizás se deba al interletraje y el padding del texto. Otro problema era que el texto se pixelaba al acercar la imagen, ya que el procesador del DOM trata el texto renderizado como un cuadrante con textura, algo que se debe tener en cuenta al usar este método. En retrospectiva, podría haber usado simplemente texto con un 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 colocación CSS “superior/izquierda”, como se describió antes, para elementos realmente pequeños que acompañan a los planetas del sistema solar.

Reproducción de música y repetición indefinida

La pieza musical que se tocó durante "Mapa galáctico" de Mass Effect perteneció a los compositores de Bioware Sam Hulick y Jack Wall, y tenía el tipo de emoción que yo quería que experimentara el visitante. Queríamos un poco de música para nuestro proyecto porque sentimos que era una parte importante de la atmósfera, que ayuda a crear la sensación de asombro y asombro a la que estábamos tratando de alcanzar.

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

Usé la etiqueta de audio para reproducir música; sin embargo, incluso en Chrome, el atributo "loop" no era confiable. A veces, no se reproduce. Al final, se usó este hackeo de dos etiquetas de audio para verificar el final de la reproducción y para pasar a la otra etiqueta de reproducción. Lo decepcionante fue que esto de todos modos no se repite a la perfección todo el tiempo. Por desgracia, creo que 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();

Posibilidad de mejorar

Después de trabajar con THREE.js por un tiempo, siento que llegué al punto en que mis datos se mezclaban demasiado con mi código. Por ejemplo, cuando definía instrucciones de geometría, materiales y texturas en línea, yo era, en esencia, "modelado 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, la definición de 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ó un tiempo a crear algunos "ruidos espaciales" generativos increíbles, que tuvieron que cortarse debido a que la API de audio web era inestable y provocaba fallas en Chrome de vez en cuando. Es lamentable, pero definitivamente nos hizo pensar más en el espacio sonoro para futuros trabajos. Hasta el momento de la redacción de este documento, se me informó que se aplicó un parche a la API de Web Audio, por lo que es posible que esté funcionando en este momento, algo que se debe tener en cuenta en el futuro.

Los elementos tipográficos combinados con WebGL sigue siendo un desafío, y no estoy seguro al 100% de que lo que haremos aquí sea la manera correcta. Todavía se siente como un hackeo. Es posible que las próximas versiones de THREE, con su próximo procesador CSS, se puedan usar para unir mejor los dos mundos.

Créditos

Gracias a Aaron Koblin por dejarme ir a la ciudad con este proyecto. Jono Brandel por su excelente diseño e implementación de IU, el tratamiento de tipos y la implementación de recorridos. Valdean Klump por asignarle un nombre al proyecto y todo el texto. Sabah Ahmed por borrar la gran cantidad de derechos de uso métricos para las fuentes de imágenes y datos. Clem Wright por comunicarse con las personas indicadas para la publicación. Doug Fritz por la excelencia técnica. George Brower por enseñarme JS y CSS. Y, por supuesto, Sr. Doob para THREE.js.

Referencias