100 000 étoiles

Michael Chang
Michael Chang

Bonjour ! Je m'appelle Michael Chang et je fais partie de l'équipe Data Arts chez Google. Nous avons récemment effectué 100 000 étoiles, une expérience Chrome permettant de visualiser les étoiles à proximité. 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 réflexions sur les améliorations futures.

Les sujets abordés ici seront assez larges et nécessitent une certaine connaissance de THREE.js, même si j'espère que vous pourrez toujours apprécier ce post-mortem technique. N'hésitez pas à accéder directement à une zone qui vous intéresse à l'aide du bouton "Table des matières" à droite. Tout d'abord, je vais vous montrer le rendu du projet. Ensuite, nous verrons comment gérer les nuanceurs. Enfin, nous verrons comment utiliser des libellés de texte CSS avec WebGL.

100 000 étoiles, une expérience Chrome Experiments par l'équipe Data Arts
100 000 étoiles utilisent THREE.js pour visualiser les étoiles à proximité de la Voie lactée

À la découverte de l'espace

Peu de temps après la fin de l'atelier Small Bras Globe, j'ai expérimenté une démo de particules THREE.js avec la profondeur de champ. J'ai remarqué que je pouvais modifier l'"échelle" interprétée de la scène en ajustant la quantité d'effet appliqué. Lorsque l'effet de profondeur de champ était très extrême, les objets éloignés sont devenus très flous, tout comme la photographie en inclinaison et/ou décentrement donne l'illusion d'observer une scène microscopique. À l'inverse, la diminution de l'effet donne l'impression que vous regardez l'espace lointain.

J'ai commencé à chercher des données que je pouvais utiliser pour injecter des positions de particules, un chemin menant à 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ées de coordonnées cartésiennes xyz précalculées. C'est parti !

Représenter les données sous forme d'étoiles.
La première étape consiste à tracer chaque étoile du catalogue comme une seule particule.
Les étoiles nommées.
Certaines stars du catalogue ont des noms propres, libellés ici.

Il a fallu environ une heure pour créer un objet qui plaçait les données des étoiles dans l'espace en 3D. L'ensemble de données compte exactement 119 617 étoiles. Par conséquent, représenter chaque étoile avec une particule n'est pas un problème pour un GPU moderne. Il y a également 87 étoiles identifiées individuellement. J'ai donc créé une superposition de repère CSS en utilisant la même technique que celle que j'ai décrite dans le globe "Petits armes".

Pendant cette période, je venais de terminer la série Mass Effect. Dans ce jeu, le joueur est invité à explorer la galaxie et à explorer différentes planètes et découvrir son histoire totalement fictive aux airs de Wikipédia: quelles espèces ont prospéré sur la planète, son histoire géologique, etc.

Connaissant la richesse des données existantes sur les étoiles, il est possible de présenter de la même manière des informations réelles sur la galaxie. Le but ultime de ce projet serait de donner vie à ces données, de permettre au spectateur d'explorer la galaxie à la Mass Effect, d'en apprendre davantage sur les étoiles et leur répartition, et, nous l'espérons, de susciter un sentiment d'émerveillement et d'émerveillement sur l'espace. Ouf !

Avant le reste de cette étude de cas, je dois affirmer que je ne suis en aucun cas un astronome et qu'il s'agit d'un travail de recherche amateur qui s'appuie sur les conseils d'experts externes. Ce projet doit être interprété comme une interprétation artistique de l'espace.

Construire une galaxie

Mon plan était de générer de manière procédurale un modèle de la galaxie capable de mettre les données des étoiles en contexte et, avec un peu de chance, de donner une vue superbe de notre position dans la Voie lactée.

L'un des premiers prototypes 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 reproduisant la formation des bras galactiques. Je ne m'inquiétais pas trop des spécificités de la formation des bras en spirale, car il s'agirait d'un modèle de représentation plutôt que d'un modèle mathématique. En revanche, j'ai essayé d'obtenir un nombre plus ou moins correct de bras en spirale et de tourner dans la "bonne direction".

Dans les versions ultérieures du modèle de la Voie lactée, j'ai mis l'accent sur l'utilisation de particules au profit d'une image plane d'une galaxie pour les accompagner, en lui donnant peut-être un aspect plus photographié. L'image réelle représente la galaxie spirale NGC 1232, située à environ 70 millions d'années-lumière de nous. L'image 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 a une largeur de 110 000 années-lumière et englobe le système des particules.

Dès le départ, j'ai décidé de représenter une unité GL, en fait un pixel en 3D, comme une année-lumière. Une convention qui unifiait l'emplacement de tout ce qui était visualisé, et m'a malheureusement donné de sérieux 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, ce que j'ai fait dans d'autres projets. L'un des avantages est que tout est placé sur une "tablette" de sorte que lorsque vous faites glisser la souris vers la gauche ou vers la droite, vous faites pivoter l'objet en question, mais pour faire un zoom avant, il suffit de changer la position de la caméra.

Le champ de vision de la caméra est également dynamique. À mesure que l'on s'éloigne vers l'extérieur, le champ de vision s'élargit, recouvrant de plus en plus la galaxie. L'inverse est vrai lorsque l'on se rapproche d'une étoile : le champ de vision se rétrécit. Ainsi, l'appareil photo peut voir les objets infinis (par rapport à la galaxie) en affinant le champ de vision d'une loupe divine sans avoir à résoudre des problèmes de bornement près du plan.

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

À partir de là, j'ai pu "placer" le Soleil à un certain nombre d'unités du cœur 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). À l'aide de ce modèle de système solaire, j'ai également pu visualiser une orbite terrestre simplifiée et le rayon réel du Soleil en comparaison.

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

Le Soleil était difficile à afficher. Je devais tricher en utilisant autant de techniques de graphisme en temps réel que je le savais. La surface du Soleil est une mousse de plasma chaude qui doit pulser et changer au fil du temps. Cette opération a été simulée à l'aide d'une texture bitmap d'une image infrarouge de la surface solaire. Le nuanceur de surface effectue une recherche des couleurs en fonction des nuances de gris de cette texture et effectue une recherche dans une rampe de couleur distincte. Lorsque cette recherche se déplace dans le temps, elle crée une distorsion semblable à la lave.

Une technique similaire a été utilisée pour le corona du Soleil, à la différence qu'il s'agissait d'une carte de lutin plat qui fait toujours face à l'appareil photo à l'aide de l'URL https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js.

Rendu du sol
Première version du Soleil.

Ces éruptions solaires ont été créées par des nuanceurs de sommets et de fragments appliqués à un tore, tournant juste au bord de la surface solaire. Le nuanceur de sommets dispose d'une fonction de bruit qui lui permet de se tisser comme un blob.

C'est là que j'ai commencé à rencontrer des problèmes de Z-fighting à cause de la précision GL. Toutes les variables de précision étant prédéfinies dans THREE.js, je ne pouvais pas augmenter la précision sans trop de travail. Les problèmes de précision étaient moins importants près de l'origine. Cependant, quand j'ai commencé à modéliser d'autres systèmes stellaires, cela est devenu un problème.

Modèle stellaire.
Le code permettant d'afficher le Soleil a été généralisé par la suite pour afficher d'autres étoiles.

J'ai utilisé quelques astuces pour réduire le risque de z-fighting. La propriété Material.polygonoffset de THREE est une propriété qui permet d'afficher des polygones à un emplacement perçu différent (d'après ce que je comprends). Cela permettait de forcer le plan corona à toujours s'afficher au-dessus de la surface du Soleil. En dessous, un "halo" solaire a été rendu pour produire des rayons lumineux s'éloignant de la sphère.

Un autre problème lié à la précision était que les modèles stellaires commençaient à trembler lorsque l'on faisait un zoom avant sur la scène. Pour résoudre ce problème, j'ai dû mettre à zéro la rotation de la scène, puis faire pivoter séparément le modèle stellaire et la carte de l'environnement pour donner l'illusion que vous tournez autour de l'étoile.

Création de Lensflare...

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

Les visualisations de l'espace me donnent l'impression de pouvoir m'en débarrasser avec une utilisation excessive du flashflare. THREE.LensFlare remplit cette fonction. Il me suffit d'ajouter quelques hexagones anamorphiques et un peu 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 des textures

Inspiré par Homeworld.
Plan cartésien qui facilite l'orientation spatiale dans l'espace.

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

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

map est la texture appartenant au Material, 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'option "spamming needUpdate = true" force la mise en boucle de ce comportement.

Utiliser des rampes de couleur

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

Pour rendre les étoiles, je voulais donner à chaque particule sa propre couleur en fonction de ces données. Pour ce faire, nous avons utilisé 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 donne à chaque particule sa couleur unique dans le nuanceur. Normalement, on transmettrait un vec3 de couleur, mais dans ce cas, je transmets un float pour la recherche de la montée en puissance des couleurs.

Dégradé de couleurs.
Ralentissement de couleur utilisé pour rechercher la couleur visible à partir de l'index de couleur d'une étoile.

La gamme de couleurs ressemblait à ceci, mais j'ai dû accéder aux données de couleur bitmap à partir de JavaScript. Pour cela, j'ai commencé par charger l'image dans le DOM, la dessiner dans un élément canevas, puis accéder au bitmap 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 étoile.

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

Préparation du nuanceur

Tout au long du projet, j'ai découvert que je devais écrire de plus en plus de nuanceurs pour exécuter tous les effets visuels. J'ai écrit un chargeur de nuanceurs personnalisé à cet effet, car j'en avais assez qu'ils figurent 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() récupère une liste de noms de fichiers de nuanceurs (au format .fsh pour les fragments et .vsh pour les nuanceurs de sommets), tente de charger leurs données, puis remplace simplement la liste par des objets. Au final, vous pouvez transmettre des nuanceurs à vos variables uniformes THREE.js comme suit:

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

J'aurais peut-être pu utiliser le fichier required.js, même si cela aurait nécessité un réassemblage du code uniquement à cet effet. Cette solution, bien qu'elle soit beaucoup plus simple, pourrait être améliorée, je pense, peut-être même sous la forme d'une extension THREE.js. Si vous avez des suggestions ou des moyens d'améliorer cette expérience, n'hésitez pas à me contacter.

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

Pour 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 de l'endroit où je souhaite afficher le texte, résout la position de l'écran à l'aide de la fonction THREE.Projector(), et enfin utilise 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: faire correspondre la transformation matricielle de CSS3D à la caméra et à la scène de THREE, et vous pouvez "placer" les éléments CSS en 3D comme s'ils se trouvaient au-dessus de la scène de THREE. Il y a toutefois des limites à cela. Par exemple, le texte ne peut pas être placé sous un objet THREE.js. Cette opération est toujours beaucoup plus rapide que d'effectuer une mise en page à l'aide des attributs CSS "top" et "left".

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

Vous trouverez la démonstration (et le code dans le code source) ici. Cependant, j'ai constaté que l'ordre de la matrice a depuis changé 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 n'est plus orienté vers l'objectif. La solution consistait à utiliser la méthode THREE.Gyroscope(), qui force un objet Object3D à "perdre" l'orientation héritée de la scène. Cette technique est appelée "billboard", et le gyroscope est idéal pour cela.

Ce qui est vraiment intéressant, c'est que tous les éléments DOM et CSS normaux continuent de fonctionner. Par exemple, vous pouvez passer la souris sur un libellé de texte 3D et le faire briller avec des ombres projetées.

Libellés textuels.
Les libellés textuels font toujours face à l'appareil photo en l'attachant à un THREE.Gyroscope().

Lorsque j'ai effectué un zoom avant, j'ai constaté que la mise à l'échelle de la typographie entraînait des problèmes de positionnement. Peut-être est-ce dû au crénage et à la marge intérieure du texte ? Un autre problème était que le texte était pixélisé lors d'un zoom avant, car le moteur de rendu DOM traite le texte rendu comme un rectangle texturé, dont il faut tenir compte lors de l'utilisation de cette méthode. Rétrospectivement, j'aurais pu utiliser un texte d'une taille de police gigantesque, et c'est peut-être quelque chose à explorer ultérieurement. Pour ce projet, j'ai également utilisé les libellés d'emplacement CSS "top/left" (haut/gauche) décrits précédemment pour les très petits éléments qui accompagnent les planètes dans le système solaire.

Lecture de musique et lecture en boucle

Le morceau de musique joué pendant la "Carte galactique" de Mass Effect a été réalisé par les compositeurs de Bioware Sam Hulick et Jack Wall, et il a eu le genre d'émotion que je voulais que le visiteur ressente. Nous voulions de la musique pour notre projet, car nous pensions qu'elle constituait une partie importante de l'atmosphère, ce qui nous a permis de créer le sentiment d'émerveillement que nous cherchions à atteindre.

Notre producteur Valdean Klump a contacté Sam, qui nous avait gracieusement autorisé l'utilisation d'une bande musicale de Mass Effect. Le titre s'intitule "In a Strange Land".

J'utilisais le tag audio pour la lecture de musique, mais même dans Chrome, l'attribut "loop" n'était pas fiable. Parfois, la lecture en boucle échoue. Au final, ce hack de double audio tag a été utilisé pour vérifier la fin de la lecture et passer à l'autre tag pour la lecture. Ce qui était décevant, c'est que cette boucle n'était pas parfaitement en boucle, hélas, j'ai l'impression que c'était 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();

Possibilité d'amélioration

Après avoir travaillé avec THREE.js pendant un certain temps, j'ai l'impression d'avoir atteint le point où mes données étaient trop mélangées avec mon code. Par exemple, lorsque je définissais des matériaux, des textures et des instructions de géométrie de manière intégrée, j'étais essentiellement "modélisation 3D avec du code". Cela semblait vraiment insatisfaisant. C'est un domaine dans lequel les futurs projets avec THREE.js pourraient s'améliorer considérablement, par exemple la définition des données matérielles dans un fichier distinct, de préférence consultable et modifiable dans certains contextes, et pouvant être ramenées 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 éliminés, car l'API Web Audio était instable, entraînant des plantages de Chrome de temps en temps. Dommage... mais cela nous a vraiment incités à réfléchir davantage sur l'univers du son pour nos futurs travaux. Au moment de la rédaction de ce message, je suis informé que l'API Web Audio a été corrigée. Il est donc possible que cela fonctionne maintenant.

L'association d'éléments typographiques à WebGL reste un défi, et je ne suis pas sûr à 100% que ce que nous faisons ici soit correct. Cela ressemble toujours à une astuce. Les futures versions de THREE, avec son moteur de rendu CSS en plein essor, pourraient être utilisées pour mieux joindre les deux mondes.

Crédits

Un grand merci à Aaron Koblin pour m'avoir permis d'aller en ville avec ce projet. Jono Brandel pour l'excellente conception de l'interface utilisateur, sa mise en œuvre, le traitement des polices et la mise en œuvre des visites. Valdean Klump pour avoir donné un nom au projet et tout le texte. Sabah Ahmed a obtenu les nombreux 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'apprendre JavaScript et CSS. Et bien sûr, M. Doob pour THREE.js.

Références