100 000 étoiles

Michael Chang
Michael Chang

Bonjour ! Je m'appelle Michael Chang et je travaille avec l'équipe Data Arts chez Google. Récemment, nous avons terminé 100 000 étoiles, une expérience Chrome qui visualise 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 pistes d'amélioration.

Les sujets abordés ici seront assez généraux et nécessiteront une certaine connaissance de THREE.js. J'espère toutefois que vous pourrez apprécier ce post-mortem technique. N'hésitez pas à accéder à la section qui vous intéresse à l'aide du bouton Sommaire à droite. Je vais d'abord vous montrer la partie rendu du projet, puis la gestion des nuanceurs et enfin comment utiliser les libellés de texte CSS en combinaison avec WebGL.

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

Découvrir YouTube Space

Peu de temps après avoir terminé Small Arms Globe, j'ai testé une démo de particules THREE.js avec 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, un peu comme en photographie tilt-shift, qui donne l'illusion de regarder une scène microscopique. À l'inverse, en diminuant l'effet, on avait 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, un chemin qui m'a conduit à la base de données HYG de astronexus.com, une compilation des trois sources de données (Hipparcos, Yale Bright Star Catalog et Gliese/Jahreiss Catalog) accompagnée de coordonnées cartésiennes xyz précalculées. C'est parti !

Tracer les données des étoiles.
La première étape consiste à représenter chaque étoile du catalogue sous la forme d'une particule unique.
Les étoiles nommées.
Certaines étoiles du catalogue ont un nom propre, indiqué ici.

Il m'a fallu environ une heure pour créer un outil qui place les données des étoiles dans l'espace 3D. L'ensemble de données contient exactement 119 617 étoiles. Représenter chaque étoile par une particule ne pose donc aucun problème pour un GPU moderne. Il y a également 87 étoiles identifiées individuellement. J'ai donc créé une superposition de repères CSS en utilisant 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 pour en savoir plus sur 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, espérons-le, d'inspirer un sentiment d'émerveillement et de fascination pour l'espace. Ouf !

Avant de poursuivre cette étude de cas, je dois préciser que je ne suis en aucun cas astronome et qu'il s'agit d'un travail de recherche amateur, soutenu par quelques conseils d'experts externes. Ce projet doit absolument être interprété comme une interprétation artistique de l'espace.

Créer une galaxie

Mon idée était de générer de manière procédurale un modèle de la galaxie qui puisse contextualiser les données des étoiles et, je l'espère, offrir une vue impressionnante de notre place dans la Voie lactée.

Un prototype précoce de la galaxie.
Prototype précoce du système de particules de la Voie lactée.

Pour générer la Voie lactée, j'ai créé 100 000 particules et les ai placées en spirale en imitant la façon dont les bras galactiques se forment. Je ne me suis pas trop inquiété des spécificités de la formation des bras spiraux, car il s'agirait d'un modèle représentatif plutôt que mathématique. Toutefois, j'ai essayé de faire en sorte que le nombre de bras spiraux soit plus ou moins correct et qu'ils tournent dans la "bonne direction".

Dans les versions ultérieures du modèle de Voie lactée, j'ai réduit l'utilisation de particules au profit d'une image plane d'une galaxie pour accompagner les particules, dans l'espoir de lui donner une apparence plus photographique. L'image réelle est celle de la galaxie spirale NGC 1232, située à environ 70 millions d'années-lumière de nous. Elle a été retouché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, soit un pixel en 3D, par une année-lumière. Cette convention a permis d'unifier le placement de tous les éléments visualisés, mais m'a malheureusement causé de graves problèmes de précision par la suite.

J'ai également décidé de faire pivoter toute 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 la droite fait pivoter l'objet en question, mais le zoom avant ne consiste qu'à modifier camera.position.z.

Le champ de vision de la caméra est également dynamique. En tirant vers l'extérieur, le champ de vision s'élargit, englobant de plus en plus de la galaxie. À l'inverse, lorsque vous vous déplacez vers l'intérieur, vers une étoile, le champ de vision se rétrécit. Cela permet à la caméra de voir des choses infinitésimales (par rapport à la galaxie) en réduisant le champ de vision à une sorte de loupe divine sans avoir à gérer les problèmes de clipping du plan proche.

Différentes façons de rendre 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 centre galactique. J'ai également pu visualiser la taille relative du système solaire en cartographiant le rayon de la ceinture de Kuiper (j'ai finalement choisi de visualiser le nuage d'Oort). Dans ce système solaire modèle, je pouvais également visualiser une orbite simplifiée de la Terre et le rayon réel du Soleil en comparaison.

Le système solaire.
Le Soleil autour duquel orbitent des planètes et une sphère représentant la ceinture de Kuiper.

Le Soleil était difficile à afficher. J'ai dû tricher en utilisant autant de techniques de graphisme en temps réel que possible. La surface du Soleil est une mousse 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 nuance de gris de cette texture et effectue une recherche dans une rampe de couleur distincte. Lorsque cette recherche est décalée dans le temps, elle crée cette distorsion semblable à de la lave.

Une technique similaire a été utilisée pour la couronne solaire, à la différence qu'il s'agissait d'une carte de sprite plate qui fait toujours face à la caméra à l'aide de https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js.

Rendu Sol.
Version préliminaire de Sun.

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 vertex comporte une fonction de bruit qui le fait onduler de manière blobulaire.

C'est à ce moment-là que j'ai commencé à rencontrer des problèmes de z-fighting en raison de la précision GL. Toutes les variables de précision étaient prédéfinies dans THREE.js. Il était donc irréaliste d'augmenter la précision sans un travail considérable. Les problèmes de précision étaient moins importants près de l'origine. Cependant, cela est devenu un problème lorsque j'ai commencé à modéliser d'autres systèmes stellaires.

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

J'ai utilisé quelques astuces pour atténuer le z-fighting. Material.polygonoffset de THREE est une propriété qui permet aux polygones d'être rendus à un emplacement perçu différent (si j'ai bien compris). Cela permettait de forcer le plan de la couronne à toujours s'afficher au-dessus de la surface du Soleil. En dessous, un "halo" de soleil a été rendu 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û "mettre à zéro" 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 d'orbiter autour de l'étoile.

Créer un flare

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

Les visualisations spatiales sont celles où je pense pouvoir me permettre d'utiliser excessivement les reflets de l'objectif. THREE.LensFlare sert cet objectif. Il me suffisait d'ajouter quelques hexagones anamorphiques et une touche de JJ Abrams. L'extrait ci-dessous montre comment les construire 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é de Homeworld.
Un plan cartésien pour vous aider à vous orienter dans l'espace.

Pour le "plan d'orientation spatiale", une gigantesque THREE.CylinderGeometry() a été créée et centrée sur le Soleil. Pour créer l'effet de "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 obtient une fonction onUpdate que vous pouvez écraser. La définition de son décalage entraîne le "défilement" de la texture le long de cet axe, et l'envoi de spam needsUpdate = true forcerait ce comportement à se répéter.

Utiliser des gammes 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 et les étoiles bleues/violettes sont plus chaudes. Une bande de couleurs orange intermédiaires et blanches existe dans ce dégradé.

Lors du rendu des étoiles, je voulais donner à chaque particule sa propre couleur en fonction de ces données. Pour ce faire, il fallait utiliser des "attributs" attribués au matériau du 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 donnerait à chaque particule sa propre couleur dans le nuanceur. Normalement, on transmettrait un vec3 de couleur, mais dans ce cas, je transmets un float pour la recherche éventuelle de la dégradé de couleurs.

Dégradé de couleurs.
Rampe de couleurs utilisée pour rechercher la couleur visible à partir de l'indice de couleur d'une étoile.

La palette de couleurs ressemblait à ceci, mais j'avais besoin d'accéder à ses données de couleur bitmap depuis JavaScript. Pour ce faire, j'ai d'abord chargé l'image dans le DOM, l'ai dessinée dans un élément canvas, puis ai accédé au bitmap du 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;
}

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

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

Gestion des nuanceurs

Tout au long du projet, j'ai découvert que je devais écrire de plus en plus de nuanceurs pour réaliser tous les effets visuels. J'ai écrit un chargeur de nuanceurs personnalisé à cet effet, car j'en avais assez d'avoir des nuanceurs 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 nuanceur (en s'attendant à .fsh pour les nuanceurs de fragment et .vsh pour les nuanceurs de vertex), tente de charger leurs données, puis remplace simplement la liste par des objets. Le résultat final se trouve dans vos uniforms 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 juste pour cela. 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 moyens de faire mieux, n'hésitez pas à me le faire savoir.

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

Lors de notre dernier projet, Small Arms Globe, j'ai essayé de faire apparaître des libellés textuels au-dessus d'une scène THREE.js. La méthode que j'utilisais calcule la position absolue du modèle à l'endroit où je souhaite que le texte apparaisse, puis résout la position de l'écran à l'aide de THREE.Projector(), et utilise enfin 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 utilisaient cette même technique, mais j'avais hâte d'essayer cette autre méthode décrite par Luis Cruz.

L'idée de base est d'adapter la transformation matricielle de CSS3D à la caméra et à la scène de THREE. Vous pouvez ainsi "placer" des éléments CSS en 3D comme s'ils se trouvaient au-dessus de la scène de THREE. Il existe toutefois des limites. Par exemple, vous ne pourrez pas placer de texte sous un objet THREE.js. C'est toujours beaucoup plus rapide que d'essayer d'effectuer la mise en page à l'aide des attributs CSS "top" et "left".

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

Vous trouverez la démonstration (et le code dans "Afficher la source") ici. Cependant, j'ai constaté que l'ordre de la matrice a changé pour THREE.js. La fonction que j'ai modifiée :

/_ 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 consistait à utiliser THREE.Gyroscope(), qui force un Object3D à "perdre" son orientation héritée de la scène. Cette technique s'appelle le "billboarding", et Gyroscope est parfait pour cela.

Ce qui est vraiment intéressant, c'est que tous les DOM et CSS normaux ont continué à fonctionner, comme la possibilité de pointer sur un libellé de texte 3D et de le faire briller avec des ombres portées.

Libellés de texte.
Faire en sorte que les libellés textuels soient toujours orientés vers la caméra en les associant à un THREE.Gyroscope().

En zoomant, j'ai constaté que la mise à l'échelle de la typographie posait des problèmes de positionnement. Peut-être est-ce dû à l'approche et à la marge intérieure du texte ? Un autre problème était que le texte devenait pixélisé lorsqu'il était agrandi, car le moteur de rendu DOM traite le texte rendu comme un quad texturé. Il faut en tenir compte lorsque vous utilisez cette méthode. Rétrospectivement, j'aurais pu simplement utiliser du texte avec une taille de police gigantesque, et c'est peut-être quelque chose à explorer à l'avenir. Dans ce projet, j'ai également utilisé les libellés de texte de placement CSS "top/left" (en haut/à gauche), décrits précédemment, pour les très petits éléments qui accompagnent les planètes du système solaire.

Lecture et boucle de musique

Le morceau de musique joué pendant la "carte galactique" de Mass Effect était de Sam Hulick et Jack Wall, compositeurs de Bioware. Il suscitait le type d'émotion que je souhaitais faire ressentir aux visiteurs. Nous voulions ajouter de la musique à notre projet, car nous avons estimé qu'elle était importante pour l'atmosphère, afin de créer ce sentiment d'émerveillement que nous essayions d'obtenir.

Notre producteur Valdean Klump a contacté Sam, qui avait beaucoup de musique "coupée au montage" de Mass Effect et qui nous a très gentiment permis de l'utiliser. La piste s'intitule "In a Strange Land".

J'ai utilisé la balise audio pour la lecture de musique, mais même dans Chrome, l'attribut "loop" n'était pas fiable. Parfois, la boucle ne fonctionnait tout simplement pas. En fin de compte, cette astuce de double balise audio a été utilisée pour vérifier la fin de la lecture et passer à l'autre balise pour la lecture. Ce qui était décevant, c'est que cette image fixe ne bouclait pas parfaitement tout le temps. Malheureusement, j'ai l'impression que c'est le mieux que je pouvais 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 d'être arrivé à un point où mes données se mélangeaient trop avec mon code. Par exemple, lorsque je définissais des instructions de matériaux, de textures et de géométrie en ligne, je faisais essentiellement de la "modélisation 3D avec du code". C'était vraiment désagréable. C'est un domaine dans lequel les futurs projets avec THREE.js pourraient s'améliorer considérablement, par exemple en définissant les données matérielles dans un fichier distinct, de préférence visible et modifiable dans un contexte donné, et qui peut être réintégré dans le projet principal.

Notre collègue Ray McClure a également passé du temps à créer des "bruits spatiaux" génératifs impressionnants, qui ont dû être coupés en raison de l'instabilité de l'API Web Audio, qui plante Chrome de temps en temps. C'est dommage, mais cela nous a certainement fait réfléchir davantage à l'espace sonore pour nos futurs projets. Au moment où j'écris ces lignes, j'ai été informé que l'API Web Audio avait été corrigée. Il est donc possible que cela fonctionne désormais. C'est un point à 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 hack. Les futures versions de THREE, avec son CSS Renderer à venir, pourront peut-être être utilisées pour mieux relier les deux mondes.

Crédits

Merci à Aaron Koblin de m'avoir permis de m'éclater sur ce projet. Jono Brandel pour l'excellente conception et implémentation de l'UI, le traitement typographique et l'implémentation de la visite guidée. Valdean Klump pour avoir donné un nom au projet et rédigé tous les textes. Sabah Ahmed pour avoir clarifié la tonne de droits d'utilisation pour les sources de données et d'images. Clem Wright pour avoir contacté les bonnes personnes pour la publication. Doug Fritz pour son excellence technique. George Brower pour m'avoir appris JS et CSS. Et bien sûr, M. Doob pour THREE.js.

Références