Realizzare 100.000 stelle

Michael Chang
Michael Chang

Ciao! Mi chiamo Michael Chang e lavoro con il team Data Arts di Google. Di recente abbiamo completato 100.000 stelle, un esperimento di Chrome che visualizza le stelle vicine. Il progetto è stato creato con THREE.js e CSS3D. In questo case study descriverò il processo di scoperta, condividerò alcune tecniche di programmazione e concluderò con alcuni spunti per il miglioramento futuro.

Gli argomenti trattati qui saranno piuttosto ampi e richiedono una certa conoscenza di THREE.js, anche se spero che tu possa comunque apprezzare questo post tecnico. Puoi passare direttamente 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, 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

Scoperta dello 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 la quantità di effetto applicato. Quando l'effetto di profondità di campo era davvero estremo, gli oggetti distanti diventavano molto sfocati, in modo simile a come funziona la fotografia tilt-shift, che dà l'illusione di guardare una scena microscopica. Al contrario, la riduzione dell'effetto dava l'impressione di fissare lo spazio profondo.

Ho iniziato a cercare dati che potessi utilizzare per inserire 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) accompagnata da coordinate cartesiane xyz precalcolate. Iniziamo.

Tracciamento 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, etichettati qui.

Ci è voluta circa un'ora per mettere insieme qualcosa che posizionasse i dati delle stelle nello spazio 3D. Nel set di dati sono presenti 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 marcatori CSS utilizzando la stessa tecnica che ho descritto in Small Arms Globe.

In questo 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 fittizia, simile a quella di Wikipedia: quali specie hanno prosperato sul pianeta, la sua storia geologica e così via.

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

Prima di continuare con il resto di questo case study, vorrei precisare che non sono un astronomo e che questo è il frutto di una ricerca amatoriale supportata da alcuni consigli di esperti esterni. Questo progetto deve essere interpretato come l'interpretazione dello spazio da parte di un artista.

Costruire una galassia

Il mio piano era di generare proceduralmente un modello della galassia che potesse contestualizzare i dati delle stelle e, si spera, offrire una visione straordinaria del nostro posto nella Via Lattea.

Un prototipo iniziale della galassia.
Un prototipo iniziale del sistema di particelle della Via Lattea.

Per generare la Via Lattea, ho creato 100.000 particelle e le ho disposte a spirale emulando il modo in cui si formano i bracci galattici. Non mi preoccupavo troppo dei dettagli della formazione dei bracci a spirale perché questo sarebbe stato un modello rappresentativo piuttosto che matematico. Tuttavia, ho cercato di ottenere un numero di bracci a spirale più o meno corretto e che ruotassero nella "direzione giusta".

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

Determinare la scala della galassia.
Ogni unità GL è un anno luce. In questo caso, la sfera ha un diametro di 110.000 anni luce e comprende il sistema di particelle.

Ho deciso fin da subito di rappresentare un'unità GL, ovvero un pixel in 3D, come un anno luce, una convenzione che ha unificato il posizionamento di tutto ciò che è stato visualizzato e che purtroppo mi ha causato seri problemi di precisione in seguito.

Un'altra convenzione che ho deciso di adottare è stata quella di ruotare l'intera scena anziché spostare la videocamera, cosa che ho fatto in altri progetti. Un vantaggio è che tutto viene posizionato su un "giradischi" in modo che il trascinamento del mouse a sinistra e a destra ruoti l'oggetto in questione, ma lo zoom avanti è solo una questione di modifica di camera.position.z.

Anche il campo visivo della videocamera è dinamico. Man mano che si tira verso l'esterno, il campo visivo si allarga, includendo sempre più galassie. Il contrario è vero quando ci si sposta verso l'interno verso una stella: il campo visivo si restringe. In questo modo, la videocamera può visualizzare cose infinitesimali (rispetto alla galassia) comprimendo il campo visivo in qualcosa di simile a una lente d'ingrandimento divina senza dover affrontare problemi di ritaglio del piano vicino.

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

Da qui ho potuto "posizionare" il Sole a un certo numero di unità di distanza dal centro 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). All'interno di questo sistema solare modello, ho potuto anche visualizzare un'orbita semplificata della Terra e il raggio effettivo del Sole a confronto.

Il sistema solare.
Il Sole intorno a cui orbitano i pianeti e una sfera che rappresenta la fascia di Kuiper.

Il Sole era difficile da renderizzare. 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. È stata simulata 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, si crea questa distorsione simile a lava.

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

Rendering Sol.
Versione iniziale di Sole.

Le eruzioni solari sono state create tramite shader di vertici e frammenti applicati a un toro, che ruota appena intorno al bordo della superficie solare. Lo shader dei vertici ha una funzione di rumore che lo fa muovere in modo simile a una macchia.

È 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 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 a stella.
Il codice per il rendering del Sole è stato successivamente generalizzato per il rendering di altre stelle.

Ho utilizzato alcuni trucchi per mitigare il problema di z-fighting. Material.polygonoffset di THREE è una proprietà che consente di eseguire il rendering dei poligoni in una posizione percepita diversa (per quanto ne so). Questo valore veniva utilizzato per forzare il rendering del piano della corona sempre sopra la superficie del Sole. Sotto, è stata creata un'aureola solare per dare l'impressione di raggi di luce nitidi che si allontanano dalla sfera.

Un altro problema relativo alla precisione era che i modelli delle stelle iniziavano a tremare man mano che la scena veniva ingrandita. 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.

Creazione di bagliori

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

Le visualizzazioni dello spazio sono quelle in cui mi sembra di poter utilizzare in modo eccessivo il lens flare. THREE.LensFlare serve a questo scopo, tutto ciò che dovevo fare era aggiungere alcuni esagoni anamorfici e un pizzico di JJ Abrams. Lo snippet riportato di seguito mostra come costruirli 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 scorrere le trame

Ispirato a Homeworld.
Un piano cartesiano per orientarsi nello spazio.

Per il "piano di orientamento spaziale", è stata creata una gigantesca THREE.CylinderGeometry() centrata sul Sole. Per creare l'effetto di "onda di luce" che si espande verso l'esterno, ho modificato l'offset della texture 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 texture venga "scorri" lungo quell'asse e l'invio ripetuto di needsUpdate = true forza questo comportamento a ripetersi.

Utilizzare le rampe di colore

Ogni stella ha un colore diverso in base a un "indice di colore" assegnato dagli astronomi. In generale, le stelle rosse sono più fredde e le stelle 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 colore proprio in base a questi dati. Per farlo, si utilizzavano 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: [] },
};

Il riempimento dell'array colorIndex assegna a ogni particella il proprio colore univoco nello shader. Normalmente si passerebbe un vec3 di colore, ma in questo caso sto passando un float per la ricerca della rampa di colori finale.

Rampa 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 eseguito l'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 a stella.

I miei occhi!
La stessa tecnica viene utilizzata per la ricerca del colore per la 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 a questo scopo perché non ne potevo più di 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() accetta un elenco di nomi di file shader (previsti .fsh per gli shader di frammenti e .vsh per gli shader di vertici), tenta di caricare i dati e poi sostituisce l'elenco con gli oggetti. Il risultato finale è nelle uniformi THREE.js a cui puoi passare gli shader nel seguente modo:

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

Probabilmente avrei potuto usare require.js, anche se avrei dovuto riassemblare un po' di codice solo per questo scopo. Questa soluzione, sebbene molto più semplice, potrebbe essere migliorata, magari anche come estensione di THREE.js. Se hai suggerimenti o modi per migliorare, fammelo sapere.

Etichette di testo CSS sopra THREE.js

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

Le prime iterazioni di questo progetto utilizzavano la stessa tecnica, ma non vedevo l'ora di provare questo altro metodo descritto da Luis Cruz.

L'idea di base è abbinare la trasformazione della matrice di CSS3D alla videocamera e alla scena di THREE.js, in modo da poter "posizionare" gli elementi CSS in 3D come se fossero sopra la scena di THREE.js. Tuttavia, esistono delle limitazioni. Ad esempio, non potrai inserire testo sotto un oggetto THREE.js. È comunque molto più veloce rispetto al tentativo di eseguire il layout utilizzando gli attributi CSS "top" e "left".

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

Puoi trovare la demo (e il codice in Visualizza sorgente) qui. Tuttavia, ho scoperto 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 verso la videocamera. La soluzione è stata utilizzare THREE.Gyroscope(), che costringe un Object3D a "perdere" il suo orientamento ereditato dalla scena. Questa tecnica è chiamata "billboarding" e Gyroscope è perfetta per questo scopo.

La cosa davvero bella è che tutto il normale DOM e CSS ha continuato a funzionare, ad esempio è possibile passare il mouse sopra un'etichetta di testo 3D e farla illuminare con ombreggiature.

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

Quando ho ingrandito la visualizzazione, ho notato che il ridimensionamento della tipografia causava problemi di posizionamento. Forse è dovuto alla crenatura e al padding del testo? Un altro problema era che il testo diventava pixelato quando si ingrandiva, poiché il renderer DOM tratta il testo sottoposto a rendering come un quadrilatero con texture, un aspetto da tenere presente quando si utilizza questo metodo. A posteriori, avrei potuto usare un testo con caratteri di dimensioni gigantesche e forse è qualcosa da esplorare in futuro. In questo progetto ho utilizzato anche le etichette di testo di posizionamento CSS "top/left", descritte in precedenza, per elementi molto piccoli che accompagnano i pianeti del sistema solare.

Riproduzione e loop della musica

Il brano musicale riprodotto durante la "Mappa galattica" di Mass Effect è stato composto da Sam Hulick e Jack Wall di Bioware e trasmetteva il tipo di emozione che volevo che il visitatore provasse. Volevamo inserire della musica nel nostro progetto perché ritenevamo che fosse una parte importante dell'atmosfera, contribuendo a creare quel senso di stupore e meraviglia che stavamo cercando di ottenere.

Il nostro produttore Valdean Klump ha contattato Sam, che aveva una serie di brani musicali "tagliati" da Mass Effect che ci ha gentilmente concesso di utilizzare. La traccia si intitola "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 veniva eseguito il loop. Alla fine, questo hack del tag audio doppio è stato utilizzato per verificare la fine della riproduzione e il passaggio all'altro tag per la riproduzione. La cosa deludente è che questa foto non era sempre in loop perfetto, ma credo che questo sia 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, mi sono reso conto che i miei dati si stavano mescolando troppo con il mio codice. Ad esempio, quando definivo materiali, texture e istruzioni di geometria in linea, stavo essenzialmente "modellando in 3D con il codice". Questa cosa è stata davvero brutta ed è un'area in cui i futuri progetti con THREE.js potrebbero migliorare notevolmente, ad esempio definendo i dati dei materiali in un file separato, preferibilmente visualizzabile e modificabile in un determinato contesto, e che può essere riportato nel progetto principale.

Il nostro collega Ray McClure ha anche dedicato del tempo alla creazione di fantastici "rumori spaziali" generativi, che hanno dovuto essere tagliati a causa dell'instabilità dell'API Web Audio, che causava l'arresto anomalo di Chrome di tanto in tanto. È un peccato… ma ci ha sicuramente fatto riflettere di più sul suono per i lavori futuri. Al momento della stesura di questo messaggio, mi è stato comunicato che l'API Web Audio è stata patchata, quindi è possibile che ora funzioni. Si tratta di un aspetto da tenere d'occhio in futuro.

Gli elementi tipografici abbinati a WebGL rimangono ancora una sfida e non sono sicuro al 100% che quello che stiamo facendo qui sia il modo corretto. Sembra ancora un trucco. Forse le versioni future di THREE, con il suo CSS Renderer in arrivo, potranno essere utilizzate per unire meglio i due mondi.

Crediti

Grazie ad Aaron Koblin per avermi dato carta bianca per questo progetto. Jono Brandel per l'eccellente progettazione e implementazione dell'UI, il trattamento dei caratteri e l'implementazione del tour. Valdean Klump per aver dato un nome al progetto e per tutti i testi. Sabah Ahmed per aver ottenuto una tonnellata di diritti di utilizzo per le origini dati e 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 a Mr. Doob per THREE.js.

Riferimenti