100 000 gwiazdek

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

Omówione tu tematy będą dość ogólne i wymagają pewnej wiedzy na temat THREE.js, ale mam nadzieję, że ten techniczny post-mortem będzie dla Ciebie interesujący. Możesz przejść do interesującego Cię obszaru, korzystając z przycisku spisu treści po prawej stronie. Najpierw pokażę część projektu związaną z renderowaniem, potem zarządzanie shaderami, a na koniec sposób używania etykiet tekstowych CSS w połączeniu z WebGL.

100 000 gwiazd, eksperyment w Chrome przygotowany przez zespół Data Arts
100 000 Gwiazd wykorzystuje THREE.js do wizualizacji pobliskich gwiazd w Drodze Mlecznej

Odkrywanie przestrzeni

Niedługo po ukończeniu Small Arms Globe eksperymentowałem z demonstracją cząsteczek w THREE.js z głębią ostrości. Zauważyłem, że mogę zmienić interpretowaną „skalę” sceny, dostosowując ilość zastosowanego efektu. Gdy efekt głębi ostrości był bardzo silny, odległe obiekty stawały się bardzo rozmyte, podobnie jak w przypadku fotografii z użyciem obiektywu typu tilt-shift, która daje iluzję patrzenia na mikroskopijną scenę. Zmniejszenie efektu sprawiało, że wyglądało to tak, jakbyś wpatrywał(-a) się w głęboką przestrzeń kosmiczną.

Zacząłem szukać danych, które mógłbym wykorzystać do wstrzykiwania pozycji cząstek. W ten sposób trafiłem na bazę danych HYG na stronie astronexus.com. Jest to kompilacja 3 źródeł danych (Hipparcos, Yale Bright Star Catalog i Gliese/Jahreiss Catalog) wraz z wcześniej obliczonymi współrzędnymi kartezjańskimi xyz. Zaczynamy!

Wykreślanie danych gwiazd.
Pierwszym krokiem jest wykreślenie każdej gwiazdy z katalogu jako pojedynczej cząstki.
Nazwane gwiazdy.
Niektóre gwiazdy w katalogu mają własne nazwy, które są tutaj podane.

Zajęło mi to około godziny, aby stworzyć coś, co umieszczało dane o gwiazdach w przestrzeni 3D. Zbiór danych zawiera dokładnie 119 617 gwiazd, więc przedstawienie każdej z nich za pomocą cząstki nie stanowi problemu dla nowoczesnego procesora graficznego. Jest też 87 indywidualnie 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 skończyłem serię Mass Effect. W grze gracz może eksplorować galaktykę i skanować różne planety oraz czytać o ich całkowicie fikcyjnej historii, która przypomina wpisy z Wikipedii: jakie gatunki rozwijały się na planecie, jaka jest jej historia geologiczna itp.

Znając bogactwo rzeczywistych danych o gwiazdach, można sobie wyobrazić, że w ten sam sposób można przedstawić prawdziwe informacje o galaktyce. Głównym celem tego projektu jest ożywienie tych danych, umożliwienie widzom eksplorowania galaktyki w stylu Mass Effect, poznawania gwiazd i ich rozmieszczenia oraz wzbudzanie podziwu i zachwytu nad kosmosem. Uff...

Zanim przejdę do dalszej części tego studium przypadku, muszę zaznaczyć, że nie jestem astronomem i jest to praca amatorska, która powstała przy wsparciu zewnętrznych ekspertów. Ten projekt należy zdecydowanie traktować jako artystyczną interpretację przestrzeni.

Budowanie galaktyki

Moim planem było proceduralne wygenerowanie modelu galaktyki, który umieści dane o gwiazdach w kontekście i miejmy nadzieję, że zapewni niesamowity widok naszego miejsca w Drodze Mlecznej.

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

Aby wygenerować Drogę Mleczną, wygenerowałem 100 tys. cząstek i umieściłem je w spiralnym kształcie, naśladując sposób, w jaki tworzą się ramiona galaktyczne. Nie martwiłem się zbytnio szczegółami formowania się ramion spiralnych, ponieważ miał to być model reprezentacyjny, a nie matematyczny. Starałem się jednak, aby liczba ramion spiralnych była mniej więcej prawidłowa i aby obracały się one we „właściwym kierunku”.

W późniejszych wersjach modelu Drogi Mlecznej ograniczyłem użycie cząstek na rzecz płaskiego obrazu galaktyki, który miał im towarzyszyć, aby nadać mu bardziej fotograficzny wygląd. Prawdziwe zdjęcie przedstawia galaktykę spiralną NGC 1232, która znajduje się w odległości około 70 milionów lat świetlnych od nas. Zostało ono zmodyfikowane, aby przypominało Drogę Mleczną.

Określanie skali galaktyki.
Każda jednostka GL to rok świetlny. W tym przypadku sfera ma średnicę 110 tys. lat świetlnych i obejmuje system cząsteczek.

Od początku postanowiłem, że jedna jednostka GL, czyli piksel w 3D, będzie reprezentować jeden rok świetlny. To uprościło umieszczanie wszystkich wizualizowanych elementów, ale niestety później spowodowało poważne problemy z precyzją.

Kolejną zasadą, którą postanowiłem zastosować, było obracanie całej sceny zamiast przesuwania kamery. Zrobiłem to już w kilku innych projektach. Jedną z zalet jest to, że wszystko jest umieszczone na „gramofonie”, więc przeciąganie myszą w lewo i w prawo obraca dany obiekt, a powiększanie polega tylko na zmianie wartości camera.position.z.

Pole widzenia kamery jest również dynamiczne. Gdy oddalasz się od galaktyki, pole widzenia się poszerza i obejmuje coraz większą jej część. Gdy zbliżasz się do gwiazdy, pole widzenia się zawęża. Dzięki temu kamera może obserwować obiekty, które są nieskończenie małe (w porównaniu z galaktyką), poprzez zmniejszenie pola widzenia do poziomu przypominającego powiększenie, bez konieczności radzenia sobie z problemami z przycinaniem w pobliżu płaszczyzny.

Różne sposoby renderowania galaktyki.
(powyżej) Wczesna galaktyka cząsteczkowa. (poniżej) Cząstki z płaszczyzną obrazu.

Dzięki temu mogłem „umieścić” Słońce w odległości określonej liczby jednostek od jądra galaktyki. Udało mi się też zwizualizować względną wielkość Układu Słonecznego, wyznaczając promień Klifu Kuipera (ostatecznie zdecydowałem się na wizualizację Obłoku Oorta). W tym modelu Układu Słonecznego mogłem też zobaczyć uproszczoną orbitę Ziemi i rzeczywisty promień Słońca.

Układ Słoneczny.
Słońce, wokół którego krążą planety, oraz sfera reprezentująca pas Kuipera.

Słońce było trudne do wyrenderowania. Musiałem użyć tylu technik grafiki w czasie rzeczywistym, ile znałem. Powierzchnia Słońca to gorąca piana plazmy, która musi pulsować i zmieniać się z upływem czasu. Symulacja została przeprowadzona za pomocą tekstury bitmapowej obrazu w podczerwieni powierzchni Słońca. Cieniowanie powierzchniowe wykonuje wyszukiwanie koloru na podstawie skali szarości tej tekstury i wyszukuje go na oddzielnej rampie kolorów. Gdy ta tabela jest przesuwana w czasie, powstaje zniekształcenie przypominające lawę.

Podobną technikę zastosowano w przypadku korony słonecznej, z tym że była to płaska karta sprite, która zawsze była skierowana w stronę kamery, przy użyciu https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js.

Renderowanie Sol.
Wczesna wersja Słońca.

Rozbłyski słoneczne zostały utworzone za pomocą shaderów wierzchołków i fragmentów zastosowanych do torusa, który obraca się tuż przy krawędzi powierzchni Słońca. Shaders wierzchołków ma funkcję szumu, która powoduje, że porusza się on w sposób przypominający plamę.

W tym miejscu zaczęły się problemy z z-fightingiem spowodowane precyzją GL. Wszystkie zmienne precyzji były wstępnie zdefiniowane w THREE.js, więc nie mogłem zwiększyć precyzji bez ogromnego nakładu pracy. Problemy z dokładnością nie były tak poważne w pobliżu źródła. Jednak gdy zacząłem modelować inne układy gwiezdne, stało się to problemem.

Model gwiazdy.
Kod renderujący Słońce został później uogólniony, aby można było renderować inne gwiazdy.

Zastosowałem kilka trików, aby złagodzić z-fighting. Material.polygonoffset w bibliotece THREE to właściwość, która umożliwia renderowanie wielokątów w innej postrzeganej lokalizacji (o ile dobrze rozumiem). Użyto go, aby wymusić renderowanie płaszczyzny korony zawsze na powierzchni Słońca. Poniżej tego obiektu wyrenderowano „halo” słoneczne, aby uzyskać ostre promienie świetlne oddalające się od kuli.

Innym problemem związanym z precyzją było to, że modele gwiazd zaczynały drgać, gdy scena była powiększana. Aby to naprawić, musiałem „wyzerować” obrót sceny i osobno obrócić model gwiazdy oraz mapę środowiska, aby stworzyć iluzję, że orbitujesz wokół gwiazdy.

Tworzenie efektu flary

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

Wizualizacje przestrzeni kosmicznej to miejsce, w którym mogę sobie pozwolić na nadmierne użycie efektu flary. THREE.LensFlare spełnia te wymagania. Wystarczyło dodać kilka anamorficznych sześciokątów i odrobinę JJ Abramsa. Fragment kodu poniżej pokazuje, jak je skonstruować 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

Zainspirowana grą Homeworld.
Płaszczyzna kartezjańska ułatwiająca orientację przestrzenną.

W przypadku „płaszczyzny orientacji przestrzennej” utworzono gigantyczny obiekt THREE.CylinderGeometry() i wyśrodkowano go na Słońcu. Aby utworzyć rozchodzącą się „falę światła”, 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żesz zastąpić. Ustawienie przesunięcia powoduje „przewijanie” tekstury wzdłuż tej osi, a częste ustawianie wartości needsUpdate = true wymusza zapętlenie tego zachowania.

Korzystanie z palet kolorów

Każda gwiazda ma inny kolor w zależności od „indeksu barwy” przypisanego jej przez astronomów. Ogólnie rzecz biorąc, czerwone gwiazdy są chłodniejsze, a niebieskie i fioletowe – gorętsze. W tym gradiencie występuje pas białych i pośrednich pomarańczowych kolorów.

Podczas renderowania gwiazd chciałem, aby każda cząstka miała swój własny kolor na podstawie tych danych. Można to było zrobić za pomocą „atrybutów” przypisanych do materiału shadera zastosowanego 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ąsteczka będzie miała w shaderze swój własny kolor. Zwykle przekazuje się wektor koloru vec3, ale w tym przypadku przekazuję liczbę zmiennoprzecinkową do późniejszego wyszukiwania w rampie kolorów.

Rampa kolorów.
Rampa kolorów używana do wyszukiwania widocznego koloru na podstawie indeksu koloru gwiazdy.

Rampa kolorów wyglądała tak, ale musiałem uzyskać dostęp do danych kolorów bitmapy z JavaScriptu. Najpierw wczytałem obraz do DOM, narysowałem go w elemencie canvas, a potem uzyskałem dostęp do mapy bitowej 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 gwiazd w widoku modelu gwiazd.

Moje oczy!
Ta sama technika jest używana do wyszukiwania koloru klasy widmowej gwiazdy.

Transformacje shaderów

W trakcie projektu odkryłem, że aby uzyskać wszystkie efekty wizualne, muszę napisać coraz więcej shaderów. W tym celu napisałem własny program do wczytywania shaderów, ponieważ miałem już dość przechowywania 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 cieniowania (oczekując plików .fsh w przypadku cieniowania fragmentów i plików .vsh w przypadku cieniowania wierzchołków), próbuje wczytać ich dane, a następnie zastępuje listę obiektami. Wynik końcowy znajduje się w jednolitych zmiennych THREE.js, do których możesz przekazywać shadery w ten sposób:

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

Prawdopodobnie mogłem użyć require.js, ale wymagałoby to ponownego złożenia kodu tylko na potrzeby tego celu. To rozwiązanie jest znacznie prostsze, ale moim zdaniem można je ulepszyć, być może nawet jako rozszerzenie THREE.js. Jeśli masz sugestie lub pomysły na ulepszenie tego procesu, daj mi znać.

Etykiety tekstowe CSS na platformie THREE.js

W naszym ostatnim projekcie, Small Arms Globe, eksperymentowałem z wyświetlaniem etykiet tekstowych na scenie THREE.js. Metoda, której używam, oblicza bezwzględną pozycję modelu, w której ma się pojawić tekst, a następnie określa pozycję na ekranie za pomocą funkcji THREE.Projector() i w końcu używa właściwości CSS „top” i „left”, aby umieścić elementy CSS w odpowiednim miejscu.

W pierwszych wersjach tego projektu używałem tej samej techniki, ale od dawna chciałem wypróbować inną metodę opisaną przez Luisa Cruza.

Podstawowa idea: dopasuj transformację macierzową CSS3D do kamery i sceny THREE.js, a będziesz mieć możliwość „umieszczania” elementów CSS w 3D tak, jakby znajdowały się na scenie THREE.js. Istnieją jednak pewne ograniczenia. Na przykład nie można umieścić tekstu pod obiektem THREE.js. To nadal znacznie szybsze niż próba wykonania układu za pomocą atrybutów CSS „top” i „left”.

etykiety tekstowe,
Używanie przekształceń CSS3D do umieszczania etykiet tekstowych na WebGL.

Demo (i kod w widoku źródła) znajdziesz tutaj. Zauważyłem jednak, że kolejność macierzy w przypadku THREE.js uległa zmianie. Zaktualizowana funkcja:

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

Wszystkie elementy są przekształcone, więc tekst nie jest już skierowany w stronę kamery. Rozwiązaniem było użycie funkcji THREE.Gyroscope(), która powoduje, że obiekt Object3D „traci” odziedziczoną orientację ze sceny. Ta technika nazywa się „billboarding”, a żyroskop doskonale się do niej nadaje.

Wszystkie standardowe elementy DOM i CSS nadal działały, np. można było najechać kursorem na etykietę tekstową 3D i sprawić, że będzie się świecić z cieniami.

etykiety tekstowe,
Spraw, aby etykiety tekstowe były zawsze skierowane w stronę kamery, dołączając je do THREE.Gyroscope().

Podczas powiększania okazało się, że skalowanie typografii powoduje problemy z pozycjonowaniem. Czy jest to spowodowane kerningiem i dopełnieniem tekstu? Kolejnym problemem było to, że po powiększeniu tekst stawał się rozpikselowany, ponieważ moduł renderowania DOM traktuje renderowany tekst jako teksturowany czworokąt. Warto o tym pamiętać, gdy używasz tej metody. Z perspektywy czasu widzę, że mogłem po prostu użyć tekstu o gigantycznym rozmiarze czcionki. Być może warto to sprawdzić w przyszłości. W tym projekcie użyłem też opisanych wcześniej etykiet tekstowych CSS „top/left” w przypadku bardzo małych elementów towarzyszących planetom w układzie słonecznym.

odtwarzanie muzyki i odtwarzanie w pętli,

Utwór muzyczny odtwarzany w „Mapie galaktyki” w Mass Effect został skomponowany przez Sama Hulicka i Jacka Walla z Bioware i wywoływał emocje, które chciałem, aby odczuwał zwiedzający. Chcieliśmy, aby w naszym projekcie pojawiła się muzyka, ponieważ uważaliśmy, że jest ona ważnym elementem atmosfery, który pomaga wywołać poczucie zachwytu i zdumienia, do którego dążyliśmy.

Nasz producent Valdean Klump skontaktował się z Samem, który miał wiele „odrzuconych” utworów muzycznych z Mass Effect. Umożliwił nam ich wykorzystanie. 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 po prostu nie działał. Ostatecznie ten sposób z 2 tagami audio został użyty do sprawdzania końca odtwarzania i przełączania się na drugi tag w celu odtwarzania. Rozczarowujące było to, że ten obraz nie zawsze był idealnie zapętlony, ale myślę, że to 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();

Możliwość poprawy

Po pewnym czasie pracy z THREE.js doszedłem do wniosku, że moje dane za bardzo mieszają się z kodem. Na przykład podczas definiowania materiałów, tekstur i instrukcji geometrii w tekście w zasadzie „modelowałem w 3D za pomocą kodu”. To było bardzo nieprzyjemne i jest to obszar, w którym przyszłe projekty z użyciem THREE.js można znacznie ulepszyć, na przykład definiując dane materiału w osobnym pliku, który można wyświetlać i modyfikować w określonym kontekście, a następnie przywrócić do głównego projektu.

Nasz kolega Ray McClure poświęcił też trochę czasu na stworzenie niesamowitych generatywnych „dźwięków kosmicznych”, które musieliśmy wyciąć, ponieważ interfejs Web Audio API był niestabilny i co jakiś czas powodował awarię Chrome. To przykre, ale dzięki temu zaczęliśmy bardziej myśleć o dźwięku w kontekście przyszłych projektów. W momencie pisania tego artykułu interfejs Web Audio API został już poprawiony, więc być może działa teraz prawidłowo. Warto jednak mieć to na uwadze 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ściwym rozwiązaniem. Nadal wydaje się to obejściem. Być może przyszłe wersje THREE, z nadchodzącym modułem renderowania CSS, pozwolą lepiej połączyć te dwa światy.

Środki

Dziękuję Aaronowi Koblinowi za możliwość realizacji tego projektu. Jono Brandel za doskonały projekt i wdrożenie interfejsu, typografię i wdrożenie przewodnika. Valdean Klump za nadanie nazwy projektowi i wszystkie teksty. Sabah Ahmed za wyjaśnienie kwestii dotyczących praw do wykorzystania danych i źródeł obrazów. Clem Wright za kontaktowanie się z odpowiednimi osobami w celu publikacji. Doug Fritz za doskonałość techniczną. George Brower za nauczenie mnie JS i CSS. I oczywiście pan Doob za THREE.js.

Odniesienia