Hallo! Mein Name ist Michael Chang und ich arbeite im Data Arts Team bei Google. Vor Kurzem haben wir 100.000 Sterne fertiggestellt, ein Chrome-Experiment, das nahegelegene Sterne visualisiert. Das Projekt wurde mit THREE.js und CSS3D erstellt. In dieser Fallstudie beschreibe ich den Prozess der Ideenfindung, stelle einige Programmiertechniken vor und schließe mit einigen Überlegungen zur zukünftigen Verbesserung ab.
Die hier behandelten Themen sind recht breit gefächert und erfordern einige Kenntnisse von THREE.js. Ich hoffe jedoch, dass Sie diesen technischen Post-Mortem-Bericht trotzdem interessant finden. Über die Schaltfläche „Inhaltsverzeichnis“ rechts können Sie direkt zu einem bestimmten Bereich springen. Zuerst zeige ich Ihnen den Rendering-Teil des Projekts, dann die Shader-Verwaltung und schließlich, wie Sie CSS-Textlabels in Kombination mit WebGL verwenden.

Space entdecken
Kurz nach der Fertigstellung von Small Arms Globe habe ich mit einer THREE.js-Partikel-Demo mit Tiefenschärfe experimentiert. Ich habe festgestellt, dass ich die interpretierte „Größe“ der Szene ändern kann, indem ich die Stärke des angewendeten Effekts anpasse. Wenn der Unschärfeeffekt sehr stark war, wurden entfernte Objekte sehr unscharf dargestellt, ähnlich wie bei der Tilt-Shift-Fotografie, bei der man den Eindruck hat, eine mikroskopische Szene zu betrachten. Wenn Sie den Effekt verringern, sieht es so aus, als würden Sie in den Weltraum starren.
Ich begann, nach Daten zu suchen, mit denen ich Partikelpositionen einfügen konnte. Das führte mich zur HYG-Datenbank von astronexus.com, einer Zusammenstellung der drei Datenquellen (Hipparcos, Yale Bright Star Catalog und Gliese/Jahreiss Catalog) mit vorab berechneten kartesischen xyz-Koordinaten. Fangen wir an!


Es dauerte etwa eine Stunde, bis ich etwas zusammengebastelt hatte, das die Sterndaten im 3D-Raum platzierte. Das Dataset enthält genau 119.617 Sterne. Jeder Stern kann also problemlos durch ein Partikel dargestellt werden. Es gibt auch 87 einzeln identifizierte Sterne. Daher habe ich ein CSS-Marker-Overlay mit derselben Technik erstellt, die ich im Small Arms Globe beschrieben habe.
Zu dieser Zeit hatte ich gerade die Mass Effect-Reihe beendet. Im Spiel wird der Spieler eingeladen, die Galaxie zu erkunden und verschiedene Planeten zu scannen und mehr über sie zu erfahren. Die Geschichte der Planeten ist dabei rein fiktiv und klingt wie ein Wikipedia-Artikel: Welche Spezies haben auf dem Planeten gelebt, wie ist seine geologische Geschichte usw.
Angesichts der Fülle an tatsächlichen Daten über Sterne könnte man sich vorstellen, reale Informationen über die Galaxie auf dieselbe Weise zu präsentieren. Das ultimative Ziel dieses Projekts ist es, diese Daten zum Leben zu erwecken, dem Zuschauer zu ermöglichen, die Galaxie à la Mass Effect zu erkunden, etwas über Sterne und ihre Verteilung zu erfahren und hoffentlich ein Gefühl von Ehrfurcht und Staunen über den Weltraum zu vermitteln. Geschafft!
Ich sollte den Rest dieser Fallstudie wahrscheinlich damit einleiten, dass ich keineswegs ein Astronom bin und dass dies die Arbeit von Amateurforschern ist, die von einigen Ratschlägen externer Experten unterstützt wird. Dieses Projekt sollte auf jeden Fall als künstlerische Interpretation des Weltraums betrachtet werden.
Eine Galaxie erstellen
Mein Plan war, ein Modell der Galaxie zu erstellen, das die Sterndaten in einen Kontext setzen kann und hoffentlich einen beeindruckenden Blick auf unseren Platz in der Milchstraße ermöglicht.

Um die Milchstraße zu generieren, habe ich 100.000 Partikel erzeugt und sie in einer Spirale angeordnet, indem ich die Art und Weise nachgeahmt habe, wie Galaxienarme entstehen. Ich machte mir keine großen Sorgen um die Einzelheiten der Spiralarmbildung, da es sich um ein repräsentatives und nicht um ein mathematisches Modell handeln sollte. Ich habe jedoch versucht, die Anzahl der Spiralarme mehr oder weniger richtig darzustellen und die Drehrichtung zu berücksichtigen.
In späteren Versionen des Milchstraßenmodells habe ich die Verwendung von Partikeln zugunsten eines planaren Bildes einer Galaxie, das die Partikel begleitet, weniger betont, um dem Bild hoffentlich ein fotografischeres Aussehen zu verleihen. Das tatsächliche Bild zeigt die Spiralgalaxie NGC 1232, die etwa 70 Millionen Lichtjahre von uns entfernt ist. Es wurde so bearbeitet, dass es wie die Milchstraße aussieht.

Ich habe mich von Anfang an entschieden, eine GL-Einheit, also im Grunde ein Pixel in 3D, als ein Lichtjahr darzustellen. Diese Konvention hat die Platzierung für alles Visualisierte vereinheitlicht, hat mir aber später leider ernsthafte Probleme mit der Präzision bereitet.
Eine weitere Konvention, die ich beschlossen habe, war, die gesamte Szene zu drehen, anstatt die Kamera zu bewegen. Das habe ich auch in einigen anderen Projekten so gemacht. Ein Vorteil ist, dass alles auf einem „Drehteller“ platziert wird. Wenn Sie die Maus also nach links und rechts ziehen, wird das jeweilige Objekt gedreht. Zum Zoomen muss nur die camera.position.z geändert werden.
Das Sichtfeld der Kamera ist ebenfalls dynamisch. Wenn man nach außen zieht, weitet sich das Sichtfeld und es ist immer mehr von der Galaxie zu sehen. Das Gegenteil ist der Fall, wenn Sie sich nach innen in Richtung eines Sterns bewegen: Das Sichtfeld wird kleiner. So kann die Kamera Dinge sehen, die im Vergleich zur Galaxie unendlich klein sind, indem das Sichtfeld auf eine Art göttliche Lupe reduziert wird, ohne dass Probleme mit dem Clipping der nahen Ebene auftreten.

Von hier aus konnte ich die Sonne in einer bestimmten Entfernung vom galaktischen Kern „platzieren“. Ich konnte auch die relative Größe des Sonnensystems visualisieren, indem ich den Radius des Kuiper-Gürtels (ich habe mich schließlich für die Visualisierung der Oortschen Wolke entschieden) dargestellt habe. In diesem Modell des Sonnensystems konnte ich auch eine vereinfachte Umlaufbahn der Erde und den tatsächlichen Radius der Sonne im Vergleich visualisieren.

Die Sonne war schwierig zu rendern. Ich musste so viele Echtzeitgrafiktechniken wie möglich anwenden. Die Oberfläche der Sonne ist ein heißer Schaum aus Plasma, der sich im Laufe der Zeit verändert. Dies wurde durch eine Bitmap-Textur eines Infrarotbilds der Sonnenoberfläche simuliert. Der Oberflächen-Shader führt eine Farbsuche basierend auf dem Graustufenwert dieser Textur durch und sucht in einer separaten Farbstufung. Wenn diese Suche im Zeitverlauf verschoben wird, entsteht diese lavaähnliche Verzerrung.
Eine ähnliche Technik wurde für die Sonnenkorona verwendet. Dabei handelt es sich jedoch um eine flache Sprite-Karte, die immer auf die Kamera ausgerichtet ist. Dazu wird https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js verwendet.

Die Sonneneruptionen wurden mithilfe von Vertex- und Fragment-Shadern erstellt, die auf einen Torus angewendet wurden, der sich direkt am Rand der Sonnenoberfläche dreht. Der Vertex-Shader hat eine Rauschfunktion, die dazu führt, dass er sich wie ein Blob bewegt.
Hier traten aufgrund der GL-Präzision einige Z-Fighting-Probleme auf. Alle Variablen für die Genauigkeit waren in THREE.js vordefiniert. Daher konnte ich die Genauigkeit nicht ohne großen Aufwand erhöhen. In der Nähe des Ursprungs waren die Probleme mit der Genauigkeit nicht so schlimm. Als ich jedoch anfing, andere Sternensysteme zu modellieren, wurde dies zu einem Problem.

Ich habe einige Tricks angewendet, um Z-Fighting zu vermeiden. Material.polygonoffset von THREE ist eine Eigenschaft, mit der Polygone an einem anderen wahrgenommenen Ort gerendert werden können (soweit ich das verstehe). Damit wurde die Koronaebene immer über der Sonnenoberfläche gerendert. Darunter wurde ein Sonnenhalo gerendert, um scharfe Lichtstrahlen zu erzeugen, die sich von der Kugel wegbewegen.
Ein weiteres Problem im Zusammenhang mit der Präzision war, dass die Sternmodelle zu flimmern begannen, wenn in die Szene hineingezoomt wurde. Um das Problem zu beheben, musste ich die Szenenrotation auf null setzen und das Sternmodell und die Umgebungskarte separat drehen, um die Illusion zu erzeugen, dass man den Stern umkreist.
Lens Flare erstellen

Bei Weltraumvisualisierungen kann ich meiner Meinung nach ruhig etwas mehr Lensflare einsetzen. THREE.LensFlare erfüllt diesen Zweck. Ich musste nur noch einige anamorphische Sechsecke und einen Hauch von JJ Abrams hinzufügen. Das folgende Snippet zeigt, wie Sie sie in Ihrer Szene erstellen.
// 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;
}
}
Eine einfache Möglichkeit, Texturen zu scrollen

Für die „Ebene für die räumliche Ausrichtung“ wurde eine riesige THREE.CylinderGeometry() erstellt und auf die Sonne zentriert. Um die „Lichtwelle“ zu erzeugen, die sich nach außen ausbreitet, habe ich den Texturoffset im Laufe der Zeit so geändert:
mesh.material.map.needsUpdate = true;
mesh.material.map.onUpdate = function(){
this.offset.y -= 0.001;
this.needsUpdate = true;
}
map
ist die Textur des Materials, für die Sie eine onUpdate-Funktion überschreiben können. Wenn Sie den Offset festlegen, wird die Textur entlang dieser Achse „gescrollt“. Wenn Sie spamming needsUpdate = true verwenden, wird dieses Verhalten wiederholt.
Farbstufen verwenden
Jeder Stern hat eine andere Farbe, die auf einem „Farbindex“ basiert, den Astronomen ihm zugewiesen haben. Im Allgemeinen sind rote Sterne kühler und blaue/violette Sterne heißer. Dieser Farbverlauf enthält ein Band mit weißen und orangefarbenen Zwischenfarben.
Beim Rendern der Sterne wollte ich jedem Partikel basierend auf diesen Daten eine eigene Farbe zuweisen. Dazu wurden „Attribute“ für das Shader-Material verwendet, das auf die Partikel angewendet wurde.
var shaderMaterial = new THREE.ShaderMaterial( {
uniforms: datastarUniforms,
attributes: datastarAttributes,
/_ ... etc _/
});
var datastarAttributes = {
size: { type: 'f', value: [] },
colorIndex: { type: 'f', value: [] },
};
Wenn Sie das colorIndex-Array füllen, erhält jedes Partikel seine eigene Farbe im Shader. Normalerweise würde man einen Farb-Vec3 übergeben, aber in diesem Fall übergebe ich einen Float für die spätere Farbstufen-Suche.

Die Farbskala sah so aus, aber ich musste über JavaScript auf die Bitmap-Farbdaten zugreifen. Dazu habe ich das Bild zuerst in das DOM geladen, es in ein Canvas-Element gezeichnet und dann auf die Canvas-Bitmap zugegriffen.
// 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;
}
Dieselbe Methode wird dann zum Einfärben einzelner Sterne in der Sternmodellansicht verwendet.

Shader-Wrangling
Im Laufe des Projekts stellte ich fest, dass ich immer mehr Shader schreiben musste, um alle visuellen Effekte zu erzielen. Ich habe dafür einen benutzerdefinierten Shader-Loader geschrieben, weil ich es leid war, Shader in index.html zu haben.
// 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') );
}
}
Die Funktion „loadShaders()“ verwendet eine Liste von Shader-Dateinamen (erwartet .fsh für Fragment- und .vsh für Vertex-Shader), versucht, ihre Daten zu laden, und ersetzt dann die Liste durch Objekte. Das Endergebnis ist in Ihren THREE.js-Uniformen. Sie können Shader so übergeben:
var galacticShaderMaterial = new THREE.ShaderMaterial( {
vertexShader: shaderList.galacticstars.vertex,
fragmentShader: shaderList.galacticstars.fragment,
/_..._/
});
Ich hätte wahrscheinlich require.js verwenden können, aber das hätte für diesen Zweck eine Neuzusammenstellung des Codes erfordert. Diese Lösung ist zwar viel einfacher, könnte aber meiner Meinung nach verbessert werden, vielleicht sogar als THREE.js-Erweiterung. Wenn Sie Vorschläge haben, wie wir das besser machen können, lassen Sie es mich bitte wissen.
CSS-Textlabels über THREE.js
Bei unserem letzten Projekt, Small Arms Globe, habe ich damit experimentiert, Textlabels über einer THREE.js-Szene zu platzieren. Bei der von mir verwendeten Methode wird die absolute Modellposition berechnet, an der der Text erscheinen soll. Anschließend wird die Bildschirmposition mit THREE.Projector() aufgelöst und schließlich werden die CSS-Elemente mit „top“ und „left“ an der gewünschten Position platziert.
In den ersten Iterationen dieses Projekts wurde dieselbe Technik verwendet. Ich wollte aber unbedingt diese andere Methode von Luis Cruz ausprobieren.
Die Grundidee: Die Matrix-Transformation von CSS3D wird an die Kamera und Szene von THREE angepasst. So können Sie CSS-Elemente in 3D „platzieren“, als ob sie über der Szene von THREE lägen. Es gibt jedoch Einschränkungen. So kann Text beispielsweise nicht unter einem THREE.js-Objekt platziert werden. Das ist immer noch viel schneller als das Layout mit den CSS-Attributen „top“ und „left“ zu erstellen.

Hier finden Sie die Demo und den Code (im Quellcode). Ich habe jedoch festgestellt, dass sich die Reihenfolge der Matrix für THREE.js geändert hat. Die Funktion, die ich aktualisiert habe:
/_ 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(",") + ")";
}
Da alles transformiert wird, ist der Text nicht mehr auf die Kamera ausgerichtet. Die Lösung bestand darin, THREE.Gyroscope() zu verwenden, wodurch ein Object3D seine von der Szene geerbte Ausrichtung „verliert“. Diese Technik wird als „Billboarding“ bezeichnet und Gyroscope ist dafür ideal geeignet.
Besonders schön ist, dass alle normalen DOM- und CSS-Elemente weiterhin funktionieren, z. B. das Hovern mit der Maus über ein 3D-Textlabel, wodurch es mit Schlagschatten leuchtet.

Beim Heranzoomen habe ich festgestellt, dass die Skalierung der Typografie Probleme mit der Positionierung verursacht hat. Vielleicht liegt es am Kerning und Padding des Texts? Ein weiteres Problem war, dass der Text beim Zoomen verpixelt wurde, da der DOM-Renderer den gerenderten Text als strukturiertes Quad behandelt. Das sollten Sie bei der Verwendung dieser Methode beachten. Rückblickend hätte ich einfach riesige Schriftgrößen verwenden können. Vielleicht ist das etwas für die Zukunft. In diesem Projekt habe ich auch die oben beschriebenen CSS-Platzierungstextlabels „top/left“ für sehr kleine Elemente verwendet, die Planeten im Sonnensystem begleiten.
Musikwiedergabe und Wiederholung
Das Musikstück, das während der „Galactic Map“ von Mass Effect gespielt wurde, stammte von den Bioware-Komponisten Sam Hulick und Jack Wall und hatte die Art von Emotionen, die ich dem Besucher vermitteln wollte. Wir wollten Musik in unserem Projekt, weil wir sie für einen wichtigen Teil der Atmosphäre hielten, um das Gefühl von Ehrfurcht und Staunen zu erzeugen, das wir anstrebten.
Unser Producer Valdean Klump kontaktierte Sam, der eine Reihe von Musikstücken aus Mass Effect hatte, die es nicht in das Spiel geschafft hatten. Er war so freundlich, uns die Nutzung zu erlauben. Der Titel des Tracks lautet „In a Strange Land“.
Ich habe das Audio-Tag für die Musikwiedergabe verwendet, aber selbst in Chrome war das Attribut „loop“ unzuverlässig – manchmal wurde die Musik einfach nicht wiederholt. Letztendlich wurde dieser Dual-Audio-Tag-Hack verwendet, um das Ende der Wiedergabe zu prüfen und zum anderen Tag für die Wiedergabe zu wechseln. Enttäuschend war, dass sich das Standbild nicht immer perfekt wiederholte. Ich glaube aber, dass ich mein Bestes gegeben habe.
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();
Verbesserungspotenzial
Nachdem ich eine Weile mit THREE.js gearbeitet habe, hatte ich das Gefühl, dass sich meine Daten zu sehr mit meinem Code vermischten. Als ich beispielsweise Materialien, Texturen und Geometrieanweisungen inline definiert habe, habe ich im Grunde „3D-Modellierung mit Code“ betrieben. Das war wirklich schlecht und ist ein Bereich, in dem zukünftige Bemühungen mit THREE.js deutlich verbessert werden könnten, z. B. durch die Definition von Materialdaten in einer separaten Datei, die vorzugsweise in einem bestimmten Kontext angezeigt und angepasst werden kann und in das Hauptprojekt zurückgebracht werden kann.
Unser Kollege Ray McClure hat auch einige Zeit damit verbracht, tolle generative „Weltraumgeräusche“ zu erstellen, die jedoch aufgrund der instabilen Web Audio API, die Chrome immer wieder zum Absturz brachte, nicht verwendet werden konnten. Das ist bedauerlich, aber es hat uns dazu angeregt, für zukünftige Arbeiten mehr über Sound nachzudenken. Nach meinem aktuellen Kenntnisstand wurde die Web Audio API gepatcht. Es ist also möglich, dass sie jetzt funktioniert. Das solltest du im Blick behalten.
Typografische Elemente in Kombination mit WebGL sind nach wie vor eine Herausforderung und ich bin mir nicht sicher, ob wir hier den richtigen Weg gehen. Es fühlt sich immer noch wie ein Hack an. Möglicherweise können zukünftige Versionen von THREE mit dem bevorstehenden CSS-Renderer verwendet werden, um die beiden Welten besser zu verbinden.
Gutschriften
Vielen Dank an Aaron Koblin, dass er mir bei diesem Projekt freie Hand gelassen hat. Jono Brandel für das hervorragende UI-Design und die Implementierung, die Typografie und die Implementierung der Tour. Valdean Klump für die Namensgebung des Projekts und den gesamten Text. Sabah Ahmed für die Klärung der Nutzungsrechte für die Daten- und Bildquellen. Clem Wright für die Kontaktaufnahme mit den richtigen Personen für die Veröffentlichung. Doug Fritz für technische Exzellenz. George Brower, der mir JS und CSS beigebracht hat. Und natürlich Herrn Doob für THREE.js.