100 000 étoiles

Michael Chang
Michael Chang

Bonjour ! Je m'appelle Michael Chang et je fais partie de l'équipe Data Arts de Google. Nous avons récemment terminé 100 000 étoiles, un test Chrome qui permet de visualiser les étoiles proches. Le projet a été créé avec THREE.js et CSS3D. Dans cette étude de cas, je vais décrire le processus de découverte, partager quelques techniques de programmation et terminer par quelques idées d'améliorations futures.

Les sujets abordés ici seront assez larges et nécessiteront certaines connaissances de THREE.js. J'espère toutefois que vous pourrez profiter de cet article comme d'un post-mortem technique. N'hésitez pas à accéder à un sujet qui vous intéresse à l'aide du bouton du sommaire situé à droite. Tout d'abord, je vais vous montrer la partie de rendu du projet, puis la gestion des nuanceurs, et enfin comment utiliser des libellés de texte CSS en combinaison avec WebGL.

100 000 étoiles, un test Chrome par l'équipe Data Arts
100 000 Stars utilise THREE.js pour visualiser les étoiles proches de la Voie lactée

Découvrir l'espace

Peu de temps après avoir terminé Small Arms Globe, j'ai testé une démonstration de particules THREE.js avec une profondeur de champ. J'ai remarqué que je pouvais modifier l'échelle interprétée de la scène en ajustant l'intensité de l'effet appliqué. Lorsque l'effet de profondeur de champ était vraiment extrême, les objets éloignés devenaient très flous, comme dans la photographie tilt-shift, qui donne l'illusion de regarder une scène microscopique. À l'inverse, en diminuant l'effet, vous aviez l'impression de regarder dans l'espace lointain.

J'ai commencé à chercher des données que je pourrais utiliser pour injecter des positions de particules. Ce chemin m'a conduit à la base de données HYG d'astronexus.com, une compilation des trois sources de données (Hipparcos, Yale Bright Star Catalog et Gliese/Jahreiss Catalog) accompagnées de coordonnées cartésiennes xyz précalculées. C'est parti !

Représentation des données des étoiles.
La première étape consiste à représenter chaque étoile du catalogue en tant que particule unique.
Étoiles nommées.
Certaines étoiles du catalogue portent un nom propre, indiqué ici.

Il m'a fallu environ une heure pour créer quelque chose qui place les données des étoiles dans l'espace 3D. L'ensemble de données contient exactement 119 617 étoiles. Il n'est donc pas problématique pour un GPU moderne de représenter chaque étoile par une particule. Il y a également 87 étoiles identifiées individuellement. J'ai donc créé une superposition de repères CSS à l'aide de la même technique que celle que j'ai décrite dans Small Arms Globe.

À cette époque, je venais de terminer la série Mass Effect. Dans le jeu, le joueur est invité à explorer la galaxie et à scanner différentes planètes et à lire leur histoire complètement fictive, qui ressemble à celle de Wikipédia: quelles espèces ont prospéré sur la planète, son histoire géologique, etc.

Étant donné la richesse des données réelles disponibles sur les étoiles, il est concevable de présenter des informations réelles sur la galaxie de la même manière. L'objectif ultime de ce projet est de donner vie à ces données, de permettre au spectateur d'explorer la galaxie à la manière de Mass Effect, d'en apprendre davantage sur les étoiles et leur distribution, et d'inspirer, espérons-le, un sentiment d'émerveillement et de fascination pour l'espace. Ouf !

Je devrais probablement commencer le reste de cette étude de cas en disant que je ne suis en aucun cas un astronome, et que ce travail est le fruit d'une recherche amateur, étayée par les conseils d'experts externes. Ce projet doit être interprété comme une interprétation artistique de l'espace.

Créer une galaxie

Mon plan était de générer de manière procédurale un modèle de la galaxie qui puisse mettre les données sur les étoiles en contexte, et qui, espérons-le, nous offre une vue incroyable de notre place dans la Voie lactée.

Premier prototype de la galaxie.
Premier prototype du système de particules de la Voie lactée.

Pour générer la Voie lactée, j'ai généré 100 000 particules et les ai placées en spirale en imitant la formation des bras galactiques. Je ne me suis pas trop préoccupé des détails de la formation des bras spiraux, car il s'agit d'un modèle représentatif plutôt que mathématique. J'ai toutefois essayé de trouver un nombre plus ou moins correct de bras de spirale et de les faire tourner dans la "bonne direction".

Dans les versions ultérieures du modèle de la Voie lactée, j'ai réduit l'utilisation des particules au profit d'une image plane d'une galaxie pour les accompagner, ce qui lui donne une apparence plus photographique. L'image réelle représente la galaxie spirale NGC 1232, située à environ 70 millions d'années-lumière de nous. Elle a été manipulée pour ressembler à la Voie lactée.

Déterminer l'échelle de la galaxie
Chaque unité GL correspond à une année-lumière. Dans ce cas, la sphère mesure 110 000 années-lumière de diamètre et englobe le système de particules.

J'ai décidé très tôt de représenter une unité GL, essentiellement un pixel en 3D, par une année-lumière. Cette convention unifiait l'emplacement de tout ce qui était visualisé, mais m'a malheureusement causé de graves problèmes de précision par la suite.

J'ai également décidé de faire pivoter l'ensemble de la scène plutôt que de déplacer la caméra, comme je l'ai fait dans d'autres projets. L'un des avantages est que tout est placé sur un "plateau tournant", de sorte que le fait de faire glisser la souris vers la gauche et vers la droite fait pivoter l'objet en question. Pour faire un zoom avant, il suffit de modifier camera.position.z.

Le champ de vision de la caméra est également dynamique. Plus on s'éloigne, plus le champ de vision s'élargit, englobant de plus en plus de la galaxie. À l'inverse, lorsque vous vous approchez d'une étoile, le champ de vision se rétrécit. Cela permet à la caméra de voir des objets infinitésimaux (par rapport à la galaxie) en réduisant le champ de vision à une sorte de loupe divine, sans avoir à gérer les problèmes de découpage près du plan.

Différentes manières de représenter une galaxie.
(ci-dessus) Galaxie de particules primitive. (ci-dessous) Particules accompagnées d'un plan d'image.

J'ai ensuite pu "placer" le Soleil à une certaine distance du noyau galactique. J'ai également pu visualiser la taille relative du système solaire en cartographiant le rayon de la falaise de Kuiper (j'ai finalement choisi de visualiser le nuage d'Oort). Dans ce modèle de système solaire, j'ai également pu visualiser une orbite simplifiée de la Terre et le rayon réel du Soleil à des fins de comparaison.

Le système solaire.
Soleil entouré de planètes et d'une sphère représentant la ceinture de Kuiper.

Le soleil était difficile à représenter. J'ai dû tricher avec autant de techniques graphiques en temps réel que je connaissais. La surface du Soleil est une écume de plasma chaud qui doit pulser et changer au fil du temps. Cette simulation a été réalisée à l'aide d'une texture bitmap d'une image infrarouge de la surface solaire. Le nuanceur de surface effectue une recherche de couleur en fonction de la couleur en niveaux de gris de cette texture et effectue une recherche dans une rampe de couleurs distincte. Lorsque cette recherche est décalée au fil du temps, elle crée cette distorsion semblable à de la lave.

Une technique similaire a été utilisée pour la couronne du Soleil, à l'exception d'une carte d'élément graphique plat qui fait toujours face à la caméra à l'aide de https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js.

Rendu de Sol.
Version antérieure du Soleil.

Les éruptions solaires ont été créées à l'aide de nuanceurs de vertex et de fragment appliqués à un tore, qui tourne juste autour du bord de la surface solaire. Le nuanceur de sommets comporte une fonction de bruit qui le fait tisser de manière semblable à une tache.

C'est là que j'ai commencé à rencontrer des problèmes de lutte z en raison de la précision GL. Toutes les variables de précision étaient prédéfinies dans THREE.js. Il était donc impossible d'augmenter la précision sans un travail considérable. Les problèmes de précision n'étaient pas aussi graves à proximité de l'origine. Cependant, une fois que j'ai commencé à modéliser d'autres systèmes stellaires, cela est devenu un problème.

Modèle en étoile.
Le code d'affichage du Soleil a ensuite été généralisé pour afficher d'autres étoiles.

J'ai utilisé quelques astuces pour atténuer le conflit de profondeur. Material.polygonoffset de THREE est une propriété qui permet d'afficher les polygones à un emplacement perçu différent (à ma connaissance). Cela permettait de forcer le plan de la couronne à toujours s'afficher au-dessus de la surface du Soleil. En dessous, un "halo" solaire a été généré pour donner des rayons lumineux nets s'éloignant de la sphère.

Un autre problème lié à la précision était que les modèles d'étoiles commençaient à trembler lorsque la scène était agrandie. Pour résoudre ce problème, j'ai dû "annuler" la rotation de la scène et faire pivoter séparément le modèle d'étoile et la carte de l'environnement pour donner l'illusion que vous orbitez autour de l'étoile.

Créer un effet de flare

Un grand pouvoir implique de grandes responsabilités.
Un grand pouvoir implique de grandes responsabilités.

Les visualisations spatiales sont les cas où je peux utiliser un halo lumineux excessif. THREE.LensFlare est là pour ça. Il me suffisait d'ajouter quelques hexagones anamorphiques et une touche de JJ Abrams. L'extrait de code ci-dessous montre comment les créer dans votre scène.

// 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 moyen simple de faire défiler les textures

Inspiré par Homeworld.
Plan cartésien pour faciliter l'orientation spatiale dans l'espace.

Pour le "plan d'orientation spatiale", un gigantesque THREE.CylinderGeometry() a été créé et centré sur le Soleil. Pour créer la "vague de lumière" qui s'étend vers l'extérieur, j'ai modifié son décalage de texture au fil du temps comme suit:

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

map est la texture appartenant au matériau, qui reçoit une fonction onUpdate que vous pouvez écraser. Définir son décalage entraîne le "défilement" de la texture le long de cette axe, et le spam de needsUpdate = true forcerait ce comportement en boucle.

Utiliser des rampes de couleurs

Chaque étoile a une couleur différente en fonction d'un "indice de couleur" que les astronomes lui ont attribué. En général, les étoiles rouges sont plus froides, tandis que les étoiles bleues/violettes sont plus chaudes. Ce dégradé comporte une bande de blanc et d'orange intermédiaire.

Lors du rendu des étoiles, je voulais attribuer à chaque particule sa propre couleur en fonction de ces données. Pour ce faire, nous avons attribué des "attributs" au matériau de nuanceur appliqué aux particules.

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

Remplir le tableau colorIndex attribuerait à chaque particule sa couleur unique dans le nuanceur. Normalement, on transmet un vec3 de couleur, mais dans cet exemple, je transmets un float pour la recherche éventuelle de la rampe de couleurs.

Échelle de couleurs.
Échelle de couleurs utilisée pour rechercher la couleur visible à partir de l'indice de couleur d'une étoile.

La rampe de couleurs se présentait comme suit, mais je devais accéder à ses données de couleur bitmap à partir de JavaScript. Pour ce faire, j'ai d'abord chargé l'image dans le DOM, puis l'ai dessinée dans un élément de canevas, puis j'ai accédé au bitmap du canevas.

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

Cette même méthode est ensuite utilisée pour colorer des étoiles individuelles dans la vue du modèle d'étoiles.

Mes yeux !
La même technique est utilisée pour effectuer une recherche de couleur pour la classe spectrale d'une étoile.

Gestion des nuanceurs

Au cours du projet, j'ai découvert que j'avais besoin d'écrire de plus en plus de nuanceurs pour obtenir tous les effets visuels. J'ai écrit un chargeur de nuanceurs personnalisé à cette fin, car j'en avais assez que les nuanceurs soient en direct dans 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 fonction loadShaders() prend une liste de noms de fichiers de nuanceurs (.fsh pour les nuanceurs de fragment et .vsh pour les nuanceurs de sommets), tente de charger leurs données, puis remplace simplement la liste par des objets. Le résultat final se trouve dans vos uniformes THREE.js. Vous pouvez y transmettre des nuanceurs comme suit:

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

J'aurais probablement pu utiliser require.js, mais cela aurait nécessité un réassemblage du code à cette fin. Cette solution, bien que beaucoup plus simple, pourrait être améliorée, peut-être même en tant qu'extension THREE.js. Si vous avez des suggestions ou des idées pour améliorer ce processus, n'hésitez pas à me contacter.

Libellés textuels CSS au-dessus de THREE.js

Dans notre dernier projet, Small Arms Globe, j'ai essayé d'afficher des libellés de texte au-dessus d'une scène THREE.js. La méthode que j'utilisais calcule la position absolue du modèle où je souhaite que le texte apparaisse, puis résout la position de l'écran à l'aide de THREE.Projector(), puis utilise les propriétés CSS "top" et "left" pour placer les éléments CSS à la position souhaitée.

Les premières itérations de ce projet ont utilisé cette même technique. Cependant, j'avais hâte d'essayer cette autre méthode décrite par Luis Cruz.

L'idée de base est la suivante: faites correspondre la transformation de matrice CSS3D à la caméra et à la scène de THREE, et vous pouvez "placer" des éléments CSS en 3D comme s'ils se trouvaient au-dessus de la scène de THREE. Cependant, cette méthode présente des limites. Par exemple, vous ne pouvez pas placer de texte sous un objet THREE.js. Cette méthode est toujours beaucoup plus rapide que d'essayer de réaliser la mise en page à l'aide des attributs CSS "top" et "left".

Libellés de texte.
Utilisation de transformations CSS3D pour placer des libellés de texte au-dessus de WebGL.

Vous trouverez la démonstration (et le code en mode "Afficher le code source") ici. Toutefois, j'ai constaté que l'ordre des matrices a changé depuis pour THREE.js. La fonction que j'ai mise à jour:

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

Comme tout est transformé, le texte ne fait plus face à la caméra. La solution a consisté à utiliser THREE.Gyroscope(), qui force un objet Object3D à "perdre" son orientation héritée de la scène. Cette technique s'appelle "billboarding", et le gyroscope est parfait pour cela.

Le plus intéressant est que le DOM et le CSS normaux continuent de fonctionner, par exemple, vous pouvez pointer sur un libellé de texte 3D pour qu'il s'illumine avec des ombres portées.

Libellés de texte.
Les libellés textuels sont toujours orientés vers la caméra en les associant à un THREE.Gyroscope().

Lorsque j'ai fait un zoom avant, j'ai constaté que la mise à l'échelle de la typographie causait des problèmes de positionnement. Cela est peut-être dû à l'espacement et à la marge intérieure du texte. Un autre problème était que le texte devenait pixélisé lorsque vous zoomiez dessus, car le moteur de rendu DOM traite le texte rendu comme un quad texturé. Tenez-en compte lorsque vous utilisez cette méthode. Rétrospectivement, j'aurais pu utiliser une taille de police gigantesque. C'est peut-être une idée à explorer à l'avenir. Dans ce projet, j'ai également utilisé les libellés de texte d'emplacement CSS "top/left", décrits précédemment, pour les éléments très petits qui accompagnent les planètes du système solaire.

Lecture et lecture en boucle de la musique

La musique jouée pendant la "carte galactique" de Mass Effect a été composée par les compositeurs de Bioware, Sam Hulick et Jack Wall. Elle dégageait le type d'émotion que je voulais que le visiteur ressente. Nous voulions inclure de la musique dans notre projet, car nous estimions qu'elle était un élément important de l'atmosphère, et qu'elle nous aiderait à créer l'émerveillement que nous recherchions.

Notre producteur Valdean Klump a contacté Sam, qui possédait un tas de musiques de Mass Effect qu'il nous a très gentiment autorisés à utiliser. Le titre de la piste est "In a Strange Land".

J'ai utilisé la balise audio pour la lecture de musique. Cependant, même dans Chrome, l'attribut "loop" n'était pas fiable. Parfois, la lecture en boucle ne fonctionnait pas. Au final, ce piratage de balise audio double a été utilisé pour vérifier la fin de la lecture et passer à l'autre balise pour la lecture. Ce qui est décevant, c'est que cette image fixe ne boucle pas parfaitement tout le temps. Hélas, je pense que c'est le mieux que j'ai pu faire.

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

À améliorer

Après avoir travaillé avec THREE.js pendant un certain temps, j'ai l'impression que mes données se mélangeaient trop avec mon code. Par exemple, lorsque je définissais des matériaux, des textures et des instructions de géométrie en ligne, je réalisais essentiellement de la "modélisation 3D avec du code". C'était vraiment dommage, et c'est un domaine dans lequel les futures initiatives avec THREE.js pourraient grandement s'améliorer. Par exemple, en définissant les données de matériau dans un fichier distinct, de préférence visible et modifiable dans un contexte donné, et qui peut être réintroduit dans le projet principal.

Notre collègue Ray McClure a également passé du temps à créer d'incroyables "bruits spatiaux" génératifs, qui ont dû être coupés en raison de l'instabilité de l'API Web Audio, qui plantait Chrome de temps en temps. C'est dommage, mais cela nous a définitivement incités à réfléchir davantage à l'espace sonore pour nos futurs projets. À l'heure actuelle, je suis informé que l'API Web Audio a été corrigée. Il est donc possible que cela fonctionne maintenant. À surveiller à l'avenir.

Les éléments typographiques associés à WebGL restent un défi, et je ne suis pas sûr à 100% que ce que nous faisons ici soit la bonne méthode. Cela ressemble toujours à un bidouillage. Il est possible que les futures versions de THREE, avec son nouveau moteur de rendu CSS, permettent de mieux associer les deux mondes.

Crédits

Merci à Aaron Koblin de m'avoir laissé m'amuser avec ce projet. Jono Brandel pour l'excellente conception et implémentation de l'UI, le traitement des typographies et l'implémentation de la visite guidée. Valdean Klump pour avoir donné un nom au projet et rédigé l'ensemble du texte. Sabah Ahmed pour avoir obtenu les droits d'utilisation de la tonne métrique de données et d'images. Clem Wright pour avoir contacté les bonnes personnes pour la publication. Doug Fritz pour l'excellence technique George Brower pour m'avoir appris le JS et le CSS. Et bien sûr, M. Doob pour THREE.js.

Références