100 000 gwiazdek

Cześć! Nazywam się Michael Chang i pracuję w zespole Data Arts w Google. Niedawno zakończyliśmy 100 000 gwiazd, eksperyment w Chrome, który wizualizuje pobliskie gwiazdy. Projekt został utworzony za pomocą bibliotek THREE.js i CSS3D. W tym studium przypadku opiszę proces odkrywania, przedstawię kilka technik programowania i zaprezentuję kilka pomysłów na przyszłe udoskonalenia.

Tematy omówione w tym artykule będą dość szerokie i wymagają pewnej znajomości biblioteki THREE.js, ale mam nadzieję, że i tak będziesz z niego zadowolony. Możesz przejść do interesującego Cię obszaru, korzystając z przycisku spisu treści po prawej stronie. Najpierw pokażę część projektu dotyczącą renderowania, potem zarządzanie shaderami, a na koniec jak używać etykiet tekstowych CSS w połączeniu z WebGL.

100 tys. gwiazdek – eksperyment w Chrome przygotowany przez zespół Data Arts
100 000 Stars wykorzystuje bibliotekę THREE.js do wizualizacji pobliskich gwiazd w Mielcznej Drodze

Odkrywanie kosmosu

Krótko po zakończeniu projektu Small Arms Globe eksperymentowałem z demo z cząsteczkami w THREE.js z głębią ostrości. Zauważyłem, że mogę zmienić interpretowaną „skalę” sceny, dostosowując natężenie efektu. Gdy efekt głębi ostrości był naprawdę ekstremalny, obiekty znajdujące się w dalszej odległości stawały się bardzo rozmyte, podobnie jak w przypadku fotografii tilt-shift, która tworzy iluzję oglądania sceny w powiększeniu mikroskopowym. Z kolei zmniejszenie efektu sprawia, że wygląda to tak, jakbyś wpatrywał(-a) się w kosmos.

Zacząłem szukać danych, które mógłbym wykorzystać do wstrzyknięcia pozycji cząstek. W ten sposób trafiłem do bazy danych HYG na stronie astronexus.com. Jest to kompilacja trzech źródeł danych (Hipparcos, Yale Bright Star Catalog i Gliese/Jahreiss Catalog) z wstępnie obliczonymi współrzędnymi Kartezjańskimi xyz. Zaczynamy.

Wykresowanie danych dotyczących gwiazd.
Pierwszym krokiem jest narysowanie każdej gwiazdy w katalogu jako pojedynczej cząsteczki.
Nazwane gwiazdy.
Niektóre gwiazdy w katalogu mają własne nazwy, które są tu podane.

Na opracowanie czegoś, co pozwoliło umieścić dane o gwiazdach w przestrzeni 3D, potrzeba było około godziny. W zbiorze danych jest dokładnie 119 617 gwiazd, więc reprezentowanie każdej z nich za pomocą cząsteczki nie stanowi problemu dla nowoczesnej karty graficznej. Jest też 87 osobno zidentyfikowanych gwiazd, więc utworzyłem nakładkę znacznika CSS, używając tej samej techniki, którą opisałem w Small Arms Globe.

W tym czasie właśnie skończyłem serię Mass Effect. W grze gracz ma za zadanie eksplorować galaktykę i skanować różne planety oraz czytać o ich całkowicie fikcyjnej historii, która brzmi jakby była zaczerpnięta z Wikipedii: jakie gatunki żyły na danej planecie, jej historia geologiczna itp.

Wiedząc, jak wiele rzeczywistych danych o gwiazdach jest dostępnych, można w taki sam sposób przedstawić prawdziwe informacje o galaktyce. Ostatecznym celem tego projektu jest ożywienie tych danych, umożliwienie widzom eksplorowania galaktyki w stylu Mass Effect, poznawania gwiazd i ich rozmieszczenia oraz, miejmy nadzieję, zainspirowanie do podziwiania i zaciekawienia kosmosem. Uff...

Zanim przejdę do dalszej części tego opracowania, chcę zaznaczyć, że nie jestem astronomem i że to amatorska praca badawcza, która została uzupełniona o porady zewnętrznych ekspertów. Projekt należy interpretować jako interpretację przestrzeni przez artystę.

Tworzenie Galaxy

Mój plan zakładał wygenerowanie proceduralnego modelu galaktyki, który pozwoliłby umieścić dane o gwiazdach w odpowiednim kontekście. Mam nadzieję, że udało mi się uzyskać niesamowity widok naszego miejsca w drodze Mlecznej.

Wczesny prototyp galaktyki.
Wczesny prototyp systemu cząstek Drogi Mlecznej.

Aby wygenerować Drogę Mleczną, wygenerowałam 100 tys. cząstek i umieściłam je w spirali,naśladując sposób tworzenia się ramion galaktycznych. Nie martwiłem się zbytnio szczegółami dotyczącymi tworzenia spiralnych ramion, ponieważ chodziło raczej o model poglądowy niż matematyczny. Starałam się jednak, aby liczba ramion spirali była mniej więcej prawidłowa, a ich ruch był zgodny z „właściwym kierunkiem”.

W późniejszych wersjach modelu Drogi Mlecznej zrezygnowałem z użycia cząsteczek na rzecz płaskiego obrazu galaktyki, który towarzyszy cząstkom. Mam nadzieję, że dzięki temu model wygląda bardziej jak zdjęcie. Na zdjęciu galaktyki spiralnej NGC 1232, która znajduje się około 70 mln lat świetlnych od nas, zostały wprowadzone zmiany, aby wyglądała jak Droga Mleczna.

Określanie skali galaktyki.
Każda jednostka GL to rok świetlny. W tym przypadku kula ma 110 tys. lat świetlnych średnicy i obejmuje system cząstek.

Na samym początku zdecydowałem się na reprezentowanie jednej jednostki GL, czyli piksela w 3D, jako jednego roku świetlnego. Ta konwencja umożliwiała ujednolicenie umieszczania wszystkich wizualizowanych elementów. Niestety później wiązała się z poważnymi problemami z dokładnością.

Inną przyjętą przeze mnie konwencją było obracanie całej sceny zamiast przemieszczania kamery. Zastosowałem to w kilku innych projektach. Jedną z zalet jest to, że wszystko jest umieszczone na „talerzu”, więc przeciąganie myszką w lewo i w prawo obraca dany obiekt, a przybliżanie jest tylko kwestią zmiany wartości camera.position.z.

Pole widzenia kamery (FOV) jest również dynamiczne. Gdy się odsuwa, pole widzenia się poszerza, obejmując coraz większą część galaktyki. Gdy natomiast przesuwasz się w kierunku gwiazdy, pole widzenia się zawęża. Dzięki temu kamera może rejestrować obiekty, które są nieskończenie małe (w porównaniu z galaktyką), przez zmniejszenie pola widzenia do poziomu niczym boska lupa, bez konieczności rozwiązywania problemów z przycinaniem obiektów na pierwszym planie.

Różne sposoby renderowania galaktyki.
(powyżej) Galaktyka we wczesnej fazie tworzenia. (poniżej) Cząstki z dodatkiem płaszczyzny obrazu.

Dzięki temu mogłem „umieścić” Słońce w pewnej odległości od centrum galaktyki. Udało mi się też zwizualizować względną wielkość Układu Słonecznego, mapując promień progi Kuipera (ostatecznie zdecydowałem się zwizualizować chmurę Oorta). W tym modelu Układu Słonecznego można też zobaczyć uproszczoną orbitę Ziemi i porównać ją z rzeczywistym promieniem Słońca.

Układ słoneczny.
Słońce z planetami i sferą reprezentującą pas Kuipera.

Słońce było trudne do renderowania. Musiałem oszukać, stosując jak najwięcej znanych mi technik graficznych w czasie rzeczywistym. Powierzchnia Słońca to gorąca piana plazmy, która musi pulsować i zmieniać się z upływem czasu. Zostało to zasymulowane za pomocą bitmapowej tekstury obrazu w podczerwieni powierzchni Słońca. Shader powierzchni wykonuje wyszukiwanie koloru na podstawie skali szarości tej tekstury i wyszukiwanie w osobnej rampie kolorów. Gdy to wyszukiwanie jest przesuwane w czasie, powoduje to zniekształcenie podobne do lawy.

Podobna technika została zastosowana w przypadku korony słonecznej, z tą różnicą, że jest to płaska karta sprite, która zawsze jest skierowana w kierunku kamery. Aby ją utworzyć, wykorzystano bibliotekę https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js.

Renderowanie Sol.
Wczesna wersja Słońca.

Błyski słoneczne zostały utworzone za pomocą shaderów wierzchołkowych i fragmentowych zastosowanych do torusa, który obraca się wokół krawędzi powierzchni słonecznej. Shader wierzchołkowy ma funkcję szumu, która powoduje, że linie są niczym plamy.

Właśnie wtedy zaczęły się pojawiać problemy z z-fighting z powodu precyzji GL. Wszystkie zmienne dotyczące dokładności zostały zdefiniowane w bibliotece THREE.js, więc nie da się zwiększyć dokładności bez ogromnego nakładu pracy. W pobliżu punktu początkowego problemy z dokładnością nie były tak duże. Jednak gdy zaczęłam modelować inne układy gwiezdne, pojawił się problem.

Model gwiazdy.
Kod do renderowania Słońca został później uogólniony, aby renderować inne gwiazdy.

Aby ograniczyć z-fighting, zastosowałem kilka sztuczek. W bibliotece THREE Material.polygonoffset jest właściwością, która umożliwia renderowanie wielokątów w innej postrzeganej lokalizacji (o ile dobrze rozumiem). Umożliwiło to wymuszenie renderowania płaszczyzny korony zawsze na powierzchni Słońca. Poniżej „halo” słońca zostało wyrenderowane, aby uzyskać ostre promienie światła odchodzące od sfery.

Innym problemem związanym z dokładnością było to, że modele gwiazd zaczynały się trząść, gdy przybliżano scenę. Aby to naprawić, musiałem „zerować” rotację sceny i oddzielnie obracać modelem gwiazdy i mapą środowiska, aby stworzyć iluzję orbitowania wokół gwiazdy.

Tworzenie efektu Lensflare

Z wielką mocą wiąże się wielka odpowiedzialność.
Z wielką mocą wiąże się wielka odpowiedzialność.

Wizualizacje kosmosu to miejsce, w którym mogę sobie pozwolić na nadmierne używanie efektu flary. Do tego celu służy THREE.LensFlare. Wystarczyło dodać kilka anamorficznych heksagonów i odrobinę JJ Abramsa. Poniżej znajdziesz fragment kodu, który pokazuje, jak utworzyć je w scenie.

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

}
}

Łatwy sposób na przewijanie tekstur

Zainspirowany grą Homeworld.
Płaszczyzna Kartezjańska ułatwiająca orientację przestrzenną w przestrzeni.

W przypadku „płaszczyzny orientacji przestrzennej” została utworzona ogromna geometria THREE.CylinderGeometry(), która została umieszczona w centrum Słońca. Aby stworzyć „falę światła” rozchodzącą się na zewnątrz, zmodyfikowałem przesunięcie tekstury w czasie w ten sposób:

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

map to tekstura należąca do materiału, która ma funkcję onUpdate, którą można zastąpić. Ustawienie przesunięcia powoduje, że tekstura jest „przewijana” wzdłuż tej osi, a spamowanie needsUpdate = true powoduje, że to zachowanie będzie się powtarzać.

Używanie ramp kolorów

Każda gwiazda ma inny kolor, który zależy od „kolorowego indeksu” przypisanego przez astronomów. Ogólnie rzecz biorąc, czerwone gwiazdy są chłodniejsze, a niebieskie/fioletowe – cieplejsze. W tym gradientem jest pasek białego i pomarańczowego koloru.

Podczas renderowania gwiazd chciałem, aby każda cząstka miała swój własny kolor na podstawie tych danych. Aby to zrobić, należy nadać „atrybuty” materiałowi cieniowania zastosowanemu do cząsteczek.

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

Wypełnienie tablicy colorIndex spowoduje, że każda cząstka będzie miała unikalny kolor w shaderze. Zwykle przekazuje się kolor za pomocą typu vec3, ale w tym przypadku przekazuję typ float na potrzeby wyszukiwania rampy kolorów.

Rampa kolorów.
Półka kolorów używana do wyszukiwania widocznego koloru w indeksie kolorów gwiazdy.

Rampa kolorów wyglądała tak, ale musiałem uzyskać dostęp do danych o kolorach bitmapy z JavaScriptu. Aby to zrobić, najpierw wczytałem obraz do DOM, narysowałem go w elemencie canvas, a potem uzyskałem dostęp do bitmapy 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;
}

Ta sama metoda jest następnie używana do kolorowania poszczególnych gwiazdek w widoku modelu gwiazd.

Oczy!
Ta sama technika jest używana do wyszukiwania koloru w przypadku klasy widmowej gwiazdy.

Przetwarzanie shaderów

W trakcie pracy nad projektem odkryłem, że muszę pisać coraz więcej shaderów, aby uzyskać wszystkie efekty wizualne. Z tego powodu napisałem niestandardowy ładujący shader, ponieważ zmęczyło mnie umieszczanie shaderów w pliku 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') );

}
}

Funkcja loadShaders() przyjmuje listę nazw plików shaderów (oczekując .fsh dla shaderów fragmentów i .vsh dla shaderów wierzchołków), próbuje załadować ich dane, a potem zastępuje listę obiektami. W efekcie w uniformach THREE.js możesz przekazywać do niego shadery w ten sposób:

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

Prawdopodobnie można by użyć require.js, ale wymagałoby to złożenia kodu tylko w tym celu. To rozwiązanie, choć znacznie łatwiejsze, można ulepszyć, być może nawet jako rozszerzenie THREE.js. Jeśli masz sugestie lub wiesz, jak to zrobić lepiej, daj nam znać.

Etykiety tekstowe CSS na podstawie biblioteki THREE.js

W naszym ostatnim projekcie, Small Arms Globe, eksperymentowałam z wyświetlaniem etykiet tekstowych na wierzchu sceny THREE.js. Metoda, której używałem, oblicza bezwzględną pozycję modelu, w której ma się wyświetlać tekst, a potem rozwiązuje pozycję ekranu za pomocą funkcji THREE.Projector(), a na koniec używa wartości „top” i „left” w CSS, aby umieścić elementy CSS w pożądanej pozycji.

W pierwszych iteracjach tego projektu używano tej samej techniki, ale bardzo chciałem wypróbować inną metodę opisaną przez Luisa Cruza.

Podstawowy pomysł: dopasuj transformację macierzy CSS3D do kamery i sceny w THREE, a potem możesz „umieścić” elementy CSS w 3D tak, jakby znajdowały się one na wierzchu sceny w THREE. Istnieją jednak pewne ograniczenia. Na przykład nie można umieścić tekstu pod obiektem THREE.js. Jest to znacznie szybsze niż próba wykonania układu za pomocą atrybutów CSS „top” i „left”.

etykiety tekstowe,
Używanie transformatorów CSS3D do umieszczania etykiet tekstowych na wierzchu WebGL.

Demo (i kod w źródle widoku) możesz znaleźć tutaj. Od tego czasu kolejność w matricy została jednak zmieniona w przypadku biblioteki THREE.js. Funkcja, którą zaktualizowaliśmy:

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

Ponieważ wszystko zostało przekształcone, tekst nie jest już skierowany w stronę aparatu. Rozwiązaniem było użycie funkcji THREE.Gyroscope(), która powoduje, że obiekt 3D „traci” dziedziczoną orientację ze sceny. Ta technika nazywa się „billboardingiem”, a Gyroscope jest do tego idealny.

Bardzo fajne jest to, że cały normalny DOM i CSS nadal działają, np. można najechać kursorem na etykietę tekstu 3D, aby zaczęła świecić z cieniowaniem.

etykiety tekstowe,
Aby etykiety tekstowe zawsze były skierowane w kierunku kamery, dodaj je do funkcji THREE.Gyroscope().

Po zbliżeniu okazało się, że skalowanie czcionki powoduje problemy z pozycjonowaniem. Być może jest to spowodowane interliniacją i odstępami między znakami? Kolejnym problemem było to, że po powiększeniu tekst stawał się pikselowany, ponieważ render DOM traktuje renderowany tekst jako kwadrat z teksturą. Należy to wziąć pod uwagę podczas korzystania z tej metody. W retrospekcji mogę użyć gigantycznej czcionki, ale może to coś na przyszłość. W tym projekcie użyłem również opisanych wcześniej etykiet tekstowych w miejscu docelowe w CSS „góra/lewo” dla bardzo małych elementów towarzyszących planetom w układzie słonecznym.

odtwarzanie i odtwarzanie w pętli muzyki;

Utwór odtwarzany podczas prezentacji „Galactic Map” w grze Mass Effect został skomponowany przez kompozytorów z Bioware, Sama Hulicka i Jacka Walla. Zawierał emocje, które chciałem przekazać odwiedzającym. Chcieliśmy użyć muzyki w naszym projekcie, ponieważ uważaliśmy, że jest ona ważnym elementem atmosfery, która pomaga stworzyć poczucie zachwytu i zaskoczenia, na którym nam zależało.

Nasz producent Valdean Klump skontaktował się z Samem, który miał mnóstwo muzyki z Mass Effect, która nie została wykorzystana w filmie. Sam użyczył nam jej z wielką życzliwością. Utwór nosi tytuł „In a Strange Land”.

Użyłem tagu audio do odtwarzania muzyki, ale nawet w Chrome atrybut „loop” był zawodny – czasami nie działał. Ostatecznie ten podwójny tag audio został użyty do sprawdzenia zakończenia odtwarzania i przejścia do odtwarzania innego tagu. Rozczarowujące było to, że still nie był idealnie zapętlony przez cały czas, ale wydaje mi się, że to było najlepsze, co mogłem zrobić.

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

Potencjał w zakresie ulepszenia

Po pewnym czasie pracy z biblioteką THREE.js doszłam do wniosku, że moje dane zbytnio mieszają się z kodem. Na przykład podczas definiowania materiałów, tekstur i instrukcji geometrii w wierszu chodziło mi o „modelowanie 3D za pomocą kodu”. To naprawdę zły pomysł i jest to obszar, w którym przyszłe działania z THREE.js mogą znacznie się poprawić, na przykład poprzez zdefiniowanie danych materiału w osobnym pliku, który można wyświetlić i zmodyfikować w określonym kontekście, a następnie zaimportować do projektu głównego.

Nasz współpracownik Ray McClure poświęcił też trochę czasu na stworzenie niesamowitych generowanych „kosmicznych dźwięków”, które musiały zostać wycięte z filmu z powodu niestabilnego interfejsu Web Audio API, który co jakiś czas powodował zamykanie się Chrome. To przykre, ale na pewno skłoniło nas do bardziej wnikliwego przyjrzenia się kwestiom dźwięku w przyszłej pracy. W momencie pisania tej odpowiedzi otrzymaliśmy informację, że interfejs Web Audio API został poprawiony, więc może teraz działać prawidłowo. Warto to sprawdzić w przyszłości.

Elementy typograficzne w połączeniu z WebGL nadal stanowią wyzwanie i nie jestem w 100% pewien, czy to, co robimy, jest właściwe. Nadal wygląda to na oszustwo. Być może przyszłe wersje THREE z nowym silnikiem renderowania CSS pozwolą na lepsze połączenie tych dwóch światów.

Środki

Dziękuję Aaronowi Koblinowi za pozwolenie mi na realizację tego projektu. Jono Brandel za świetny interfejs użytkownika, wdrożenie, typowanie i prezentację. Valdean Klump za nazwę projektu i cały tekst. Sabah Ahmed za wyjaśnienie mnóstwa kwestii związanych z uprawnieniami do korzystania z źródeł danych i obrazów. Clem Wright za skontaktowanie się z odpowiednimi osobami w sprawie publikacji. Doug Fritz za techniczne osiągnięcia. George’owi Browerowi za nauczenie mnie JS i CSS. I oczywiście pan Doob z THREE.js.

Pliki referencyjne