Hallo! Ich heiße Michael Chang und arbeite im Data Arts-Team bei Google. Vor Kurzem haben wir 100.000 Sterne abgeschlossen, einen Chrome-Test, mit dem nahe gelegene Sterne visualisiert werden. Das Projekt wurde mit THREE.js und CSS3D erstellt. In dieser Fallstudie werde ich den Discovery-Prozess skizzieren, einige Programmiertechniken vorstellen und zum Schluss einige Gedanken zu zukünftigen Verbesserungen anbringen.
Die hier behandelten Themen sind ziemlich breit gefächert und erfordern einige Kenntnisse von THREE.js. Ich hoffe jedoch, dass Sie diesen Artikel trotzdem als technische Postmortem-Analyse genießen können. Über die Schaltfläche „Inhaltsverzeichnis“ rechts können Sie direkt zu einem Thema springen. Zuerst zeige ich Ihnen den Rendering-Teil des Projekts, gefolgt von der Shaderverwaltung und schließlich der Verwendung von CSS-Textlabels in Kombination mit WebGL.
Discovering Space
Kurz nachdem wir Small Arms Globe fertiggestellt hatten, experimentierte ich mit einer THREE.js-Partikeldemo mit Tiefenschärfe. Ich habe festgestellt, dass ich den „Maßstab“ der Szene ändern konnte, indem ich die Intensität des angewendeten Effekts anpasste. Wenn der Effekt der Tiefenschärfe wirklich extrem war, wurden entfernte Objekte sehr verschwommen, ähnlich wie bei der Tilt-Shift-Fotografie, die den Anschein erweckt, als würde man sich eine mikroskopische Szene ansehen. Wenn Sie den Effekt dagegen verringerten, sah es so aus, als würden Sie in den Weltraum blicken.
Ich suchte nach Daten, mit denen ich die Partikelpositionen einschleusen konnte. Dabei stieß ich auf die HYG-Datenbank von astronexus.com, eine Zusammenstellung der drei Datenquellen Hipparcos, Yale Bright Star Catalog und Gliese/Jahreiss Catalog mit vorberechneten kartesischen xyz-Koordinaten. Legen wir los!
Es dauerte etwa eine Stunde, um etwas zusammenzustellen, das die Sterndaten in den 3D-Raum platzierte. Der Datensatz enthält genau 119.617 Sterne. Die Darstellung jedes Sterns mit einem Partikel ist für eine moderne GPU also kein Problem. Es gibt auch 87 einzeln identifizierte Sterne. Deshalb habe ich mit derselben Methode, die ich in Small Arms Globe beschrieben habe, ein CSS-Markierungs-Overlay erstellt.
Zu dieser Zeit hatte ich gerade die Mass Effect-Reihe beendet. Im Spiel werden die Spieler eingeladen, die Galaxie zu erkunden und verschiedene Planeten zu scannen und sich über ihre völlig fiktive, Wikipedia-ähnliche Geschichte zu informieren: welche Arten auf dem Planeten gediehen sind, seine geologische Geschichte usw.
Angesichts der Fülle an tatsächlichen Daten über Sterne könnte man auf ähnliche Weise echte Informationen über die Galaxie präsentieren. Das ultimative Ziel dieses Projekts wäre es, diese Daten zum Leben zu erwecken, den Zuschauern zu ermöglichen, die Galaxie à la Mass Effect zu erkunden, mehr über Sterne und ihre Verteilung zu erfahren und hoffentlich ein Gefühl der Ehrfurcht und des Staunens über den Weltraum zu wecken. Geschafft!
Ich sollte vor dem Rest dieser Fallstudie vielleicht erwähnen, dass ich kein Astronom bin und dass dies die Arbeit von Amateurforschern ist, die von externen Experten unterstützt wird. Dieses Projekt sollte definitiv als künstlerische Interpretation des Raums verstanden werden.
Eine Galaxie bauen
Mein Plan war es, ein prozedural generiertes Modell der Galaxie zu erstellen, das die Sterndaten in einen Kontext stellen und hoffentlich einen atemberaubenden Blick auf unseren Platz in der Milchstraße bieten kann.
Um die Milchstraße zu generieren, habe ich 100.000 Teilchen erstellt und in einer Spirale angeordnet,indem ich die Bildung von Galaxienarmen nachahmte. Ich habe mir nicht allzu viele Gedanken über die Details der Spiralarmbildung gemacht, da es sich hierbei um ein repräsentatives und nicht um ein mathematisches Modell handelt. Ich habe jedoch versucht, die Anzahl der Spiralarme mehr oder weniger richtig zu machen und sie in die „richtige Richtung“ zu drehen.
In späteren Versionen des Milchstraßenmodells habe ich die Verwendung von Partikeln zugunsten eines ebenen Bilds einer Galaxie reduziert, um den Partikeln ein eher fotografisches Aussehen zu verleihen. Das Originalbild zeigt die Spiralgalaxie NGC 1232, die etwa 70 Millionen Lichtjahre von uns entfernt ist. Es wurde so manipuliert, dass es wie die Milchstraße aussieht.
Ich entschied mich früh dafür, eine GL-Einheit, also im Grunde ein Pixel in 3D, als ein Lichtjahr darzustellen. Diese Konvention vereinheitlichte die Platzierung aller visualisierten Elemente, führte aber leider später zu ernsthaften Präzisionsproblemen.
Eine weitere Konvention, die ich für mich festgelegt habe, war, die gesamte Szene zu drehen, anstatt die Kamera zu bewegen. Das habe ich auch in einigen anderen Projekten getan. Ein Vorteil ist, dass sich alles auf einem „Drehteller“ befindet, sodass das betreffende Objekt durch Ziehen der Maus nach links und rechts gedreht wird. Zum Heranzoomen müssen Sie lediglich camera.position.z ändern.
Das Sichtfeld der Kamera ist ebenfalls dynamisch. Wenn man herauszoomt, wird das Sichtfeld erweitert und es wird mehr und mehr von der Galaxie erfasst. Das Gegenteil ist der Fall, wenn Sie sich einem Stern nähern. Das Sichtfeld wird dann kleiner. So kann die Kamera Dinge sehen, die im Vergleich zur Galaxie winzig sind, indem das Sichtfeld auf eine Art gottähnliche Lupe verkleinert wird, ohne dass es zu Problemen mit dem Clipping in der Nähe der Bildebene kommt.
So 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-Kliffs kartografierte. Letztendlich entschied ich mich jedoch, stattdessen die Oortsche Wolke zu visualisieren. 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 mit so vielen Echtzeit-Grafiktechniken schummeln, wie ich sie kannte. Die Oberfläche der Sonne ist ein heißer Plasmaschaum, der im Laufe der Zeit pulsieren und sich ändern muss. Dies wurde mit einer Bitmap-Textur eines Infrarotbildes der Sonnenoberfläche simuliert. Der Oberflächen-Shader führt eine Farbsuche basierend auf dem Graustufenwert dieser Textur und eine Suche in einer separaten Farbrampe durch. Wenn diese Suche im Laufe der Zeit verschoben wird, entsteht diese lavaartige Verzerrung.
Für die Korona der Sonne wurde eine ähnliche Technik verwendet, mit dem Unterschied, dass es sich dabei um eine flache Sprite-Karte handelt, die mit https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js immer zur Kamera zeigt.
Die Sonnenfackeln wurden mithilfe von Vertex- und Fragment-Shadern erstellt, die auf einen Torus angewendet wurden, der sich direkt am Rand der Sonnenoberfläche drehte. Der Vertex-Shader hat eine Rauschfunktion, die zu einer verschmolzenen Form führt.
Hier begannen aufgrund der GL-Genauigkeit einige Z-Fighting-Probleme. Alle Variablen für die Genauigkeit waren in THREE.js vordefiniert, sodass ich die Genauigkeit ohne großen Aufwand nicht realistisch erhöhen konnte. Probleme mit der Genauigkeit waren in der Nähe des Ursprungs nicht so ausgeprägt. Als ich jedoch mit dem Modellieren anderer Sternensysteme begann, wurde das zu einem Problem.
Ich habe einige Tricks angewendet, um das Z-Fighting zu minimieren. Die THREE-Eigenschaft Material.polygonoffset ermöglicht es, Polygone an einem anderen wahrgenommenen Ort zu rendern (soweit ich das verstehe). So wurde die Coronaebene immer über der Sonnenoberfläche gerendert. Darunter wurde ein Sonnenhalo gerendert, um scharfe Lichtstrahlen zu erzeugen, die sich von der Kugel entfernen.
Ein weiteres Problem im Zusammenhang mit der Genauigkeit war, dass die Sternmodelle zu zittern begannen, wenn die Szene herangezoomt wurde. Um das zu beheben, musste ich die Szenenrotation auf „Null“ setzen und das Sternenmodell und die Umgebungskarte separat drehen, um den Eindruck zu erwecken, dass man sich um den Stern dreht.
Lensflare erstellen
Bei Weltraumvisualisierungen kann ich meiner Meinung nach mit übermäßiger Verwendung von Lensflares durchkommen. THREE.LensFlare eignet sich dafür hervorragend. Ich musste nur noch einige anamorphe Sechsecke und einen Hauch JJ Abrams hinzufügen. Im folgenden Snippet wird gezeigt, 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 zum Scrollen von Texturen
Für die „Ebene der räumlichen Orientierung“ wurde eine riesige THREE.CylinderGeometry() erstellt und auf die Sonne ausgerichtet. Um die nach außen strahlende Lichtwelle zu erstellen, habe ich den Texturoffset im Zeitverlauf so geändert:
mesh.material.map.needsUpdate = true;
mesh.material.map.onUpdate = function(){
this.offset.y -= 0.001;
this.needsUpdate = true;
}
map
ist die dem Material zugewiesene Textur, die eine onUpdate-Funktion erhält, die Sie überschreiben können. Wenn Sie den Offset festlegen, wird die Textur entlang dieser Achse „gescrollt“. Wenn Sie „needsUpdate = true“ spammen, wird dieses Verhalten in einer Schleife fortgesetzt.
Farbrampen verwenden
Jeder Stern hat eine andere Farbe, die auf einem von Astronomen zugewiesenen „Farbindex“ basiert. Im Allgemeinen sind rote Sterne kühler und blau/violette Sterne heißer. In diesem Farbverlauf gibt es einen Streifen mit weißen und Zwischenorangetönen.
Beim Rendern der Sterne wollte ich jedem Partikel eine eigene Farbe basierend auf diesen Daten geben. Dazu wurden dem Shadermaterial, das auf die Partikel angewendet wurde, „Attribute“ zugewiesen.
var shaderMaterial = new THREE.ShaderMaterial( {
uniforms: datastarUniforms,
attributes: datastarAttributes,
/_ ... etc _/
});
var datastarAttributes = {
size: { type: 'f', value: [] },
colorIndex: { type: 'f', value: [] },
};
Wenn Sie das Array „colorIndex“ füllen, erhält jedes Partikel im Shader eine eindeutige Farbe. Normalerweise würde man eine Farbvektor-3 übergeben, aber in diesem Fall übergebe ich einen Float für die spätere Farbrampensuche.
Die Farbrampe 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;
}
Mit derselben Methode werden dann einzelne Sterne in der Ansicht des Sternmodells eingefärbt.
Shader-Wrangler
Im Laufe des Projekts stellte ich fest, dass ich immer mehr Shader schreiben musste, um alle visuellen Effekte zu erzielen. Ich habe einen benutzerdefinierten Shader-Lademechanismus dafür 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() nimmt eine Liste von Shaderdateinamen entgegen (erwartete Endungen sind .fsh für Fragment- und .vsh für Vertex-Shader), versucht, die Daten zu laden, und ersetzt dann einfach 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 dafür hätte ich den Code nur zu diesem Zweck neu zusammensetzen müssen. Diese Lösung ist zwar viel einfacher, könnte aber meiner Meinung nach verbessert werden, vielleicht sogar als THREE.js-Erweiterung. Wenn du Verbesserungsvorschläge hast, lass es mich gern wissen.
CSS-Textlabels über THREE.js
Bei unserem letzten Projekt, Small Arms Globe, habe ich damit experimentiert, Textlabels über einer THREE.js-Szene einzublenden. Mit der von mir verwendeten Methode wird die absolute Modellposition berechnet, an der der Text angezeigt werden soll. Anschließend wird die Bildschirmposition mit THREE.Projector() ermittelt und schließlich werden die CSS-Elemente mit „top“ und „left“ an der gewünschten Position platziert.
Bei den ersten Iterationen dieses Projekts wurde dieselbe Technik verwendet. Ich wollte jedoch schon lange diese andere Methode ausprobieren, die von Luis Cruz beschrieben wurde.
Grundidee: Wenn Sie die Matrixtransformation von CSS3D an die Kamera und Szene von THREE anpassen, können Sie CSS-Elemente in 3D so „platzieren“, als wären sie über der Szene von THREE. Es gibt jedoch Einschränkungen. So ist es beispielsweise nicht möglich, Text unter einem THREE.js-Objekt zu platzieren. Das ist immer noch viel schneller als das Layout mit den CSS-Attributen „top“ und „left“ zu erstellen.
Die Demo (und den Code in der Ansicht „Quellcode“) dazu finden Sie hier. Ich habe jedoch festgestellt, dass sich die Matrixreihenfolge für THREE.js inzwischen 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 übernommene Ausrichtung „verliert“. Diese Technik wird als „Billboarding“ bezeichnet und Gyroscope eignet sich hervorragend dafür.
Das Schöne ist, dass das normale DOM und CSS weiterhin funktioniert. So kann ich beispielsweise den Mauszeiger auf ein 3D-Textlabel bewegen und es mit Schatten zum Leuchten bringen.
Beim Heranzoomen habe ich festgestellt, dass die Skalierung der Typografie Probleme mit der Positionierung verursacht. Liegt das vielleicht am Kerning und am Abstand des Texts? Ein weiteres Problem war, dass der Text beim Heranzoomen pixelig wurde, da der DOM-Renderer den gerenderten Text als texturiertes Quad behandelt. Dies ist bei der Verwendung dieser Methode zu beachten. Rückblickend hätte ich einfach einen riesigen Text verwenden können. Vielleicht ist das etwas, das ich in Zukunft ausprobieren sollte. 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 -schleife
Das Musikstück, das während der galaktischen Karte von Mass Effect gespielt wurde, stammt von den Bioware-Komponisten Sam Hulick und Jack Wall. Es vermittelte die Emotionen, die ich den Besuchern vermitteln wollte. Wir wollten Musik in unserem Projekt haben, weil wir der Meinung waren, dass sie ein wichtiger Teil der Atmosphäre ist und dazu beiträgt, das Gefühl der Ehrfurcht und des Staunens zu erzeugen, das wir anstreben.
Unser Produzent Valdean Klump hat sich an Sam gewandt, der eine Menge Musik aus dem Schnittraum von Mass Effect hatte, die er uns sehr freundlicherweise zur Verfügung gestellt hat. Der Titel des Titels lautet „In a Strange Land“.
Ich habe das Audio-Tag für die Musikwiedergabe verwendet. Das Attribut „loop“ war jedoch selbst in Chrome nicht zuverlässig – manchmal wurde die Wiedergabe einfach nicht wiederholt. Letztendlich wurde dieser Hack mit zwei Audio-Tags verwendet, um das Ende der Wiedergabe zu prüfen und zum nächsten Tag zu wechseln. Leider war das Standbild nicht immer perfekt in der Schleife. Ich denke aber, dass ich das Beste daraus gemacht 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 schon eine Weile mit THREE.js gearbeitet habe, habe ich das Gefühl, dass meine Daten zu sehr mit meinem Code vermischt sind. Wenn ich beispielsweise Materialien, Texturen und geometrische Anweisungen inline definiert habe, habe ich im Grunde „3D-Modelle mit Code“ erstellt. Das war sehr ärgerlich und ein Bereich, in dem zukünftige Projekte mit THREE.js erheblich verbessert werden könnten, z. B. durch die Definition von Materialdaten in einer separaten Datei, die vorzugsweise in einem bestimmten Kontext sichtbar und anpassbar ist und in das Hauptprojekt zurückgeführt werden kann.
Unser Kollege Ray McClure hat auch einige tolle generative „Weltraumgeräusche“ erstellt, die jedoch aufgrund der Instabilität der Web Audio API, die Chrome immer wieder zum Absturz brachte, gekürzt werden mussten. Das ist schade, aber es hat uns dazu gebracht, bei zukünftigen Projekten mehr auf den Bereich Sound zu achten. Laut aktueller Informationen wurde die Web Audio API gepatcht. Es ist also möglich, dass sie jetzt funktioniert.
Typografische Elemente in Kombination mit WebGL sind immer noch 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. Vielleicht können zukünftige Versionen von THREE mit ihrem aufstrebenden CSS-Renderer die beiden Welten besser zusammenbringen.
Gutschriften
Vielen Dank an Aaron Koblin, der mir bei diesem Projekt freie Hand gelassen hat. Jono Brandel für das hervorragende UI-Design und die Implementierung, die Schriftart und die Implementierung der Demo. Valdean Klump für den Namen und den gesamten Text des Projekts. Sabah Ahmed für die Freigabe der Nutzungsrechte für die Daten- und Bildquellen. Clem Wright, der sich an die richtigen Personen für die Veröffentlichung gewandt hat. Doug Fritz für technische Exzellenz. George Brower, der mir JS und CSS beigebracht hat. Und natürlich Mr. Doob für THREE.js.