Realizzare 100.000 stelle

Michael Chang
Michael Chang

Ciao! Mi chiamo Michael Chang e lavoro con il team di Data Arts di Google. Di recente abbiamo completato 100.000 stelle, un esperimento di Chrome che mostra le stelle vicine. Il progetto è stato creato con THREE.js e CSS3D. In questo case study illustrerò la procedura di scoperta, condividerò alcune tecniche di programmazione e terminerò con alcune idee per i miglioramenti futuri.

Gli argomenti trattati qui saranno piuttosto ampi e richiedono alcune conoscenze di THREE.js, ma spero che tu possa comunque apprezzare questo post-mortem tecnico. Non esitare a passare a un'area di interesse utilizzando il pulsante del sommario a destra. Innanzitutto, mostrerò la parte di rendering del progetto, seguita dalla gestione degli shader e infine da come utilizzare le etichette di testo CSS in combinazione con WebGL.

100.000 stelle, un esperimento di Chrome del team Data Arts
100.000 stelle utilizza THREE.js per visualizzare le stelle vicine nella Via Lattea

Discovering Space

Poco dopo aver terminato Small Arms Globe, stavo sperimentando una demo di particelle THREE.js con profondità di campo. Ho notato che potevo modificare la "scala" interpretata della scena regolando l'entità dell'effetto applicato. Quando l'effetto di profondità di campo era molto estremo, gli oggetti lontani diventavano molto sfocati, in modo simile al modo in cui la fotografia tilt-shift crea l'illusione di guardare una scena microscopica. Al contrario, abbassando l'effetto, sembrava che tu stessi guardando nello spazio profondo.

Ho iniziato a cercare dati da utilizzare per iniettare le posizioni delle particelle, un percorso che mi ha portato al database HYG di astronexus.com, una raccolta delle tre origini dati (Hipparcos, Yale Bright Star Catalog e Gliese/Jahreiss Catalog) accompagnate da coordinate cartesiane xyz precalcolate. Iniziamo.

Graficazione dei dati delle stelle.
Il primo passaggio consiste nel tracciare ogni stella del catalogo come una singola particella.
Le stelle con nome.
Alcune stelle del catalogo hanno nomi propri, contrassegnati qui.

Ci è voluta circa un'ora per mettere insieme qualcosa che posizionasse i dati delle stelle nello spazio 3D. Il set di dati contiene esattamente 119.617 stelle, quindi rappresentare ogni stella con una particella non è un problema per una GPU moderna. Ci sono anche 87 stelle identificate singolarmente, quindi ho creato un overlay di indicatori CSS utilizzando la stessa tecnica che ho descritto in Small Arms Globe.

In quel periodo avevo appena finito la serie Mass Effect. Nel gioco il giocatore è invitato a esplorare la galassia e a scansionare vari pianeti e leggere la loro storia completamente inventata, che sembra tratta da Wikipedia: quali specie prosperavano sul pianeta, la sua storia geologica e così via.

Conoscendo la ricchezza di dati reali disponibili sulle stelle, si potrebbe presentare allo stesso modo informazioni reali sulla galassia. L'obiettivo finale di questo progetto è dare vita a questi dati, consentire allo spettatore di esplorare la galassia come in Mass Effect, scoprire le stelle e la loro distribuzione e, si spera, suscitare un senso di meraviglia e stupore per lo spazio. Finalmente.

Probabilmente dovrei premettere il resto di questo caso di studio dicendo che non sono affatto un astronomo e che si tratta di una ricerca amatoriale supportata da alcuni consigli di esperti esterni. Questo progetto deve essere interpretato come un'interpretazione artistica dello spazio.

Costruire una galassia

Il mio piano era di generare in modo procedurale un modello della galassia che potesse mettere in contesto i dati delle stelle e, auspicabilmente, offrire una vista straordinaria del nostro posto nella Via Lattea.

Un primo prototipo della galassia.
Un primo prototipo del sistema di particelle Milky Way.

Per generare la Via Lattea, ho generato 100.000 particelle e le ho disposte a spirale emulando il modo in cui si formano i bracci galattici. Non ero troppo preoccupato per le specifiche della formazione dei bracci a spirale perché si tratta di un modello rappresentativo piuttosto che matematico. Tuttavia, ho cercato di ottenere il numero di bracci a spirale più o meno corretto e di farli girare nella "giusta direzione".

Nelle versioni successive del modello della Via Lattea ho ridotto l'uso delle particelle a favore di un'immagine piana di una galassia da accompagnare alle particelle, sperando di dare un aspetto più fotografico. L'immagine reale è della galassia a spirale NGC 1232, a circa 70 milioni di anni luce da noi, manipolata per assomigliare alla Via Lattea.

Capire le dimensioni della galassia.
Ogni unità GL corrisponde a un anno luce. In questo caso la sfera ha un diametro di 110.000 anni luce e racchiude il sistema di particelle.

All'inizio ho deciso di rappresentare un'unità GL, in pratica un pixel in 3D, come un anno luce, una convenzione che unificava il posizionamento di tutto ciò che veniva visualizzato e che purtroppo mi ha causato seri problemi di precisione in un secondo momento.

Un'altra convenzione che ho deciso di adottare è stata quella di ruotare l'intera scena anziché spostare la fotocamera, cosa che ho fatto in alcuni altri progetti. Un vantaggio è che tutto è posizionato su una "piastra girevole" in modo che trascinando il mouse verso sinistra e verso destra l'oggetto in questione ruoti, ma per aumentare lo zoom è sufficiente modificare camera.position.z.

Anche il campo visivo (o FOV) della fotocamera è dinamico. Man mano che ci si allontana, il campo visivo si allarga, includendo sempre più la galassia. Il contrario avviene quando ci si sposta verso una stella, il campo visivo si restringe. In questo modo, la fotocamera può vedere oggetti infinitesimali (rispetto alla galassia) comprimendo il FOV a qualcosa di simile a una lente d'ingrandimento divina senza dover affrontare problemi di clipping vicino al piano.

Diversi modi per eseguire il rendering di una galassia.
(sopra) Galassia di particelle primordiali. (sotto) Particelle accompagnate da un piano di immagine.

Da qui ho potuto "posizionare" il Sole a una certa distanza dal nucleo galattico. Ho anche potuto visualizzare le dimensioni relative del sistema solare mappando il raggio della Scarpata di Kuiper (alla fine ho scelto di visualizzare la Nube di Oort). In questo modello del sistema solare, ho potuto anche visualizzare un'orbita semplificata della Terra e il raggio effettivo del Sole in confronto.

Il sistema solare.
Il Sole con i pianeti in orbita e una sfera che rappresenta la fascia di Kuiper.

È stato difficile eseguire il rendering del sole. Ho dovuto barare con tutte le tecniche di grafica in tempo reale che conoscevo. La superficie del Sole è una schiuma calda di plasma e deve pulsare e cambiare nel tempo. Questo è stato simulato tramite una texture bitmap di un'immagine a infrarossi della superficie solare. Lo shader di superficie esegue una ricerca del colore in base alla scala di grigi di questa texture ed esegue una ricerca in una rampa di colori separata. Quando questa ricerca viene spostata nel tempo, crea questa distorsione simile alla lava.

È stata utilizzata una tecnica simile per la corona del Sole, tranne per il fatto che si tratta di una scheda sprite piatta che è sempre rivolta verso la fotocamera utilizzando https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js.

Rendering di Sol.
Versione precedente del Sole.

Le eruzioni solari sono state create tramite shader di vertici e frammenti applicati a un toro che ruota appena oltre il bordo della superficie solare. Lo shader vertex ha una funzione di rumore che lo fa tessere in modo simile a un blob.

È stato qui che ho iniziato a riscontrare alcuni problemi di z-fighting a causa della precisione GL. Tutte le variabili per la precisione erano predefinite in THREE.js, quindi non potevo aumentare realisticamente la precisione senza un'enorme quantità di lavoro. I problemi di precisione non erano così gravi vicino all'origine. Tuttavia, quando ho iniziato a modellare altri sistemi stellari, questo è diventato un problema.

Modello di stella.
Il codice per il rendering del Sole è stato successivamente generalizzato per il rendering di altre stelle.

Ho utilizzato alcuni trucchi per ridurre al minimo il problema. Material.polygonoffset di THREE è una proprietà che consente di eseguire il rendering dei poligoni in una posizione percepita diversa (per quanto mi risulta). Questo è stato utilizzato per forzare il rendering del piano corona sempre sopra la superficie del Sole. Sotto, è stato visualizzato un "alone" del Sole per creare raggi di luce nitidi che si allontanano dalla sfera.

Un altro problema relativo alla precisione era che i modelli delle stelle iniziavano a tremolare quando si aumentava lo zoom della scena. Per risolvere il problema, ho dovuto "azzerare" la rotazione della scena e ruotare separatamente il modello della stella e la mappa dell'ambiente per dare l'illusione di orbitare attorno alla stella.

Creare il bagliore delle lenti

Da un grande potere derivano grandi responsabilità.
Da un grande potere derivano grandi responsabilità.

Le visualizzazioni dello spazio sono quelle in cui sento di potermi permettere un uso eccessivo di effetti di luce. THREE.LensFlare è perfetto per questo scopo, non mi restava che aggiungere alcuni esagoni anamorfici e un pizzico di JJ Abrams. Lo snippet riportato di seguito mostra come crearli nella scena.

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

}
}

Un modo semplice per eseguire lo scorrimento delle texture

Ispirato a Homeworld.
Un piano cartesiano per facilitare l'orientamento spaziale nello spazio.

Per il "piano di orientamento spaziale", è stata creata una gigantesca THREE.CylinderGeometry() centrata sul Sole. Per creare l'effetto "onda di luce" che si espande verso l'esterno, ho modificato l'offset della trama nel tempo nel seguente modo:

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

map è la texture appartenente al materiale, che riceve una funzione onUpdate che puoi sovrascrivere. L'impostazione dell'offset fa sì che la trama venga "scorrita" lungo quell'asse e l'invio ripetuto di needsUpdate = true forza questo comportamento in loop.

Utilizzare le rampe di colori

Ogni stella ha un colore diverso in base a un "indice di colore" che gli astronomi le hanno assegnato. In generale, le stelle rosse sono più fredde e quelle blu/viola sono più calde. In questa sfumatura è presente una banda di colori bianchi e arancioni intermedi.

Durante il rendering delle stelle, volevo assegnare a ogni particella un proprio colore in base a questi dati. Per farlo, sono stati utilizzati gli "attributi" assegnati al materiale dello shader applicato alle particelle.

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

Se compili l'array colorIndex, a ogni particella viene assegnato un colore univoco nello shader. Normalmente si passa un colore vec3, ma in questo caso passo un valore float per la ricerca dell'eventuale rampa di colori.

Scala di colori.
Una rampa di colori utilizzata per cercare il colore visibile dall'indice di colore di una stella.

La rampa di colori aveva questo aspetto, ma dovevo accedere ai dati di colore bitmap da JavaScript. Per farlo, ho prima caricato l'immagine nel DOM, l'ho disegnata in un elemento canvas e poi ho avuto accesso alla bitmap del canvas.

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

Lo stesso metodo viene poi utilizzato per colorare le singole stelle nella visualizzazione del modello di stelle.

I miei occhi!
La stessa tecnica viene utilizzata per cercare il colore della classe spettrale di una stella.

Wrangling degli shader

Durante il progetto ho scoperto che dovevo scrivere sempre più shader per realizzare tutti gli effetti visivi. Ho scritto un caricatore di shader personalizzato per questo scopo perché non volevo più avere gli shader in 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 funzione loadShaders() prende un elenco di nomi di file shader (si prevede .fsh per gli shader di frammento e .vsh per gli shader di vertice), tenta di caricarne i dati e poi sostituisce l'elenco con gli oggetti. Il risultato finale è nelle uniformi THREE.js a cui puoi passare gli shader come segue:

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

Avrei potuto utilizzare require.js, anche se avrei dovuto riassemblare del codice solo per questo scopo. Questa soluzione, pur essendo molto più semplice, potrebbe essere migliorata, forse anche come estensione di THREE.js. Se hai suggerimenti o modi per migliorare, non esitare a contattarmi.

Etichette di testo CSS su THREE.js

Nel nostro ultimo progetto, Small Arms Globe, ho provato a visualizzare le etichette di testo sopra una scena THREE.js. Il metodo che utilizzavo calcola la posizione assoluta del modello in cui voglio visualizzare il testo, poi risolve la posizione dello schermo utilizzando THREE.Projector() e infine utilizza CSS "top" e "left" per posizionare gli elementi CSS nella posizione desiderata.

Le prime versioni di questo progetto utilizzavano la stessa tecnica, ma volevo provare questo altro metodo descritto da Luis Cruz.

L'idea di base: associa la trasformazione della matrice di CSS3D alla fotocamera e alla scena di THREE e puoi "posizionare" gli elementi CSS in 3D come se fossero sopra la scena di THREE. Tuttavia, ci sono delle limitazioni, ad esempio non potrai inserire il testo sotto un oggetto THREE.js. Questo è comunque molto più veloce che provare a eseguire il layout utilizzando gli attributi CSS "top" e "left".

Etichette di testo.
Utilizza le trasformazioni CSS3D per posizionare le etichette di testo sopra WebGL.

Puoi trovare la demo (e il codice in Visualizza sorgente) qui. Tuttavia, ho notato che l'ordine della matrice è cambiato per THREE.js. La funzione che ho aggiornato:

/_ 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(",") + ")";
}

Poiché tutto viene trasformato, il testo non è più rivolto alla fotocamera. La soluzione è stata utilizzare THREE.Gyroscope(), che forza un Object3D a "perdere" l'orientamento ereditato dalla scena. Questa tecnica si chiama "billboarding" e Gyroscope è perfetta per questo scopo.

La cosa davvero bella è che tutti i normali DOM e CSS continuano a funzionare, ad esempio puoi passare il mouse sopra un'etichetta di testo 3D e farla brillare con le ombreggiature interne.

Etichette di testo.
Fai in modo che le etichette di testo siano sempre rivolte verso la fotocamera collegandole a THREE.Gyroscope().

Quando ho aumentato lo zoom, ho notato che il ridimensionamento della tipografia causava problemi di posizionamento. Forse è dovuto al kerning e allo spazio del testo? Un altro problema è che il testo diventava pixelato quando si aumentava lo zoom, poiché il motore di rendering DOM tratta il testo visualizzato come un quad con texture, un aspetto da tenere presente quando si utilizza questo metodo. A posteriori, avrei potuto utilizzare un testo con caratteri giganti e forse è qualcosa da esplorare in futuro. In questo progetto ho utilizzato anche le etichette di testo per il posizionamento CSS "top/left", descritte in precedenza, per elementi molto piccoli che accompagnano i pianeti nel sistema solare.

Riproduzione e riproduzione in loop della musica

Il brano musicale riprodotto durante la "mappa galattica" di Mass Effect è stato composto dai compositori di Bioware Sam Hulick e Jack Wall ed era in grado di suscitare le emozioni che volevo far provare al visitatore. Volevamo inserire della musica nel nostro progetto perché era una parte importante dell'atmosfera e ci aiutava a creare quel senso di meraviglia e stupore che volevamo ottenere.

Il nostro produttore Valdean Klump ha contattato Sam, che aveva un sacco di tracce musicali "non utilizzate" di Mass Effect che ci ha gentilmente concesso di usare. Il titolo della traccia è "In a Strange Land".

Ho utilizzato il tag audio per la riproduzione di musica, ma anche in Chrome l'attributo "loop" non era affidabile: a volte non riusciva a ripetersi. Alla fine, questo hack del tag audio doppio è stato utilizzato per verificare il termine della riproduzione e passare all'altro tag per la riproduzione. È stato deludente che questa immagine fissa non fosse in loop perfetto tutto il tempo, ma credo che sia stato il meglio che potessi fare.

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

Margini di miglioramento

Dopo aver lavorato con THREE.js per un po' di tempo, ho raggiunto il punto in cui i miei dati si mescolavano troppo con il mio codice. Ad esempio, quando definivo i materiali, le texture e le istruzioni di geometria in linea, in pratica "facevo la modellazione 3D con il codice". È stato un peccato e si tratta di un'area in cui i progetti futuri con THREE.js potrebbero migliorare notevolmente, ad esempio definendo i dati dei materiali in un file separato, preferibilmente visualizzabili e modificabili in un determinato contesto, e possono essere riportati nel progetto principale.

Il nostro collega Ray McClure ha anche trascorso un po' di tempo a creare fantastici "rumori spaziali" generativi che hanno dovuto essere tagliati a causa dell'instabilità dell'API web audio, che arrestava in modo anomalo Chrome di tanto in tanto. È un peccato, ma ci ha fatto riflettere di più sull'aspetto audio per i lavori futuri. Al momento della stesura di questo articolo, mi risulta che l'API Web Audio è stata oggetto di patch, quindi è possibile che ora funzioni. Tienilo d'occhio in futuro.

Gli elementi tipografici abbinati a WebGL continuano a rappresentare una sfida e non sono sicuro al 100% che quello che stiamo facendo sia la strada giusta. Sembra ancora un hack. Forse le versioni future di THREE, con il suo renderer CSS emergente, possono essere utilizzate per unire meglio i due mondi.

Crediti

Grazie ad Aaron Koblin per avermi permesso di dare sfogo alla mia creatività con questo progetto. Jono Brandel per l'eccellente design e l'implementazione dell'interfaccia utente, il trattamento dei caratteri e l'implementazione del tour. Valdean Klump per aver dato un nome al progetto e per aver scritto tutto il testo. Sabah Ahmed per aver chiarito la tonnellata di diritti di utilizzo per le origini dati e delle immagini. Clem Wright per aver contattato le persone giuste per la pubblicazione. Doug Fritz per l'eccellenza tecnica. George Brower per avermi insegnato JS e CSS. E, ovviamente, il signor Doob per THREE.js.

Riferimenti