Grafika 3D: samouczek WebGL

Opublikowany: 2022-03-11

Wejście do świata grafiki 3D może być bardzo onieśmielające. Niezależnie od tego, czy chcesz po prostu stworzyć interaktywne logo 3D, czy zaprojektować pełnoprawną grę, jeśli nie znasz zasad renderowania 3D, utknąłeś w korzystaniu z biblioteki, która abstrahuje wiele rzeczy.

Korzystanie z biblioteki może być właściwym narzędziem, a JavaScript ma niesamowitą bibliotekę open source w postaci three.js. Istnieją jednak pewne wady korzystania z gotowych rozwiązań:

  • Mogą mieć wiele funkcji, których nie planujesz używać. Rozmiar zminimalizowanych podstawowych funkcji three.js wynosi około 500 kB, a wszelkie dodatkowe funkcje (ładowanie rzeczywistych plików modeli jest jedną z nich) sprawiają, że ładunek jest jeszcze większy. Przesyłanie tak dużej ilości danych tylko po to, aby pokazać wirujące logo na swojej stronie, byłoby marnotrawstwem.
  • Dodatkowa warstwa abstrakcji może utrudnić wykonanie łatwych w innym przypadku modyfikacji. Twój kreatywny sposób cieniowania obiektu na ekranie może być prosty do wdrożenia lub wymagać dziesiątek godzin pracy, aby włączyć go do abstrakcji biblioteki.
  • Chociaż biblioteka jest bardzo dobrze zoptymalizowana w większości scenariuszy, wiele dzwonków i gwizdów można wyciąć dla twojego przypadku użycia. Renderer może spowodować, że niektóre procedury będą uruchamiane miliony razy na karcie graficznej. Każda instrukcja usunięta z takiej procedury oznacza, że ​​słabsza karta graficzna bez problemu poradzi sobie z treścią.

Nawet jeśli zdecydujesz się skorzystać z biblioteki graficznej wysokiego poziomu, posiadanie podstawowej wiedzy o tym, co kryje się pod maską, pozwala na efektywniejsze jej wykorzystanie. Biblioteki mogą również mieć zaawansowane funkcje, takie jak ShaderMaterial w three.js . Znajomość zasad renderowania grafiki pozwala na korzystanie z takich funkcji.

Ilustracja przedstawiająca logo 3D Toptal na kanwie WebGL

Naszym celem jest przedstawienie krótkiego wprowadzenia do wszystkich kluczowych koncepcji związanych z renderowaniem grafiki 3D i wykorzystaniem WebGL do ich implementacji. Zobaczysz najczęściej wykonywaną czynność, czyli pokazywanie i przesuwanie obiektów 3D w pustej przestrzeni.

Ostateczny kod jest dostępny do rozwidlenia i zabawy.

Reprezentowanie modeli 3D

Pierwszą rzeczą, którą musisz zrozumieć, jest sposób przedstawiania modeli 3D. Model wykonany z siatki trójkątów. Każdy trójkąt jest reprezentowany przez trzy wierzchołki dla każdego z rogów trójkąta. Wierzchołki mają trzy najczęściej spotykane właściwości.

Pozycja wierzchołka

Pozycja to najbardziej intuicyjna właściwość wierzchołka. Jest to pozycja w przestrzeni 3D, reprezentowana przez trójwymiarowy wektor współrzędnych. Jeśli znasz dokładne współrzędne trzech punktów w przestrzeni, będziesz miał wszystkie informacje potrzebne do narysowania między nimi prostego trójkąta. Aby modele wyglądały naprawdę dobrze podczas renderowania, należy dostarczyć rendererowi jeszcze kilka rzeczy.

Normalny wierzchołek

Kule z takim samym szkieletem, które mają płaskie i gładkie cieniowanie

Rozważ dwa powyższe modele. Składają się z tych samych pozycji wierzchołków, ale podczas renderowania wyglądają zupełnie inaczej. Jak to możliwe?

Oprócz informowania renderera, gdzie chcemy umieścić wierzchołek, możemy również podpowiedzieć, w jaki sposób powierzchnia jest pochylona w tej dokładnej pozycji. Podpowiedź ma postać normalnej powierzchni w tym konkretnym punkcie modelu, reprezentowanej przez wektor 3D. Poniższy obraz powinien dać bardziej opisowy obraz tego, jak się z tym obchodzić.

Porównanie normalnych dla płaskiego i gładkiego cieniowania

Lewa i prawa powierzchnia odpowiadają odpowiednio lewej i prawej piłce na poprzednim obrazie. Czerwone strzałki reprezentują normalne określone dla wierzchołka, podczas gdy niebieskie strzałki reprezentują obliczenia renderera dotyczące tego, jak normalna powinna wyglądać dla wszystkich punktów między wierzchołkami. Obraz przedstawia demonstrację przestrzeni 2D, ale ta sama zasada obowiązuje w 3D.

Normalny jest wskazówką, jak światła oświetlają powierzchnię. Im kierunek promienia światła jest bliższy normalnemu, tym jaśniejszy jest punkt. Stopniowe zmiany w normalnym kierunku powodują gradienty światła, podczas gdy nagłe zmiany bez zmian pomiędzy nimi powodują powierzchnie ze stałym oświetleniem w poprzek i nagłe zmiany oświetlenia między nimi.

Współrzędne tekstury

Ostatnią istotną właściwością są współrzędne tekstury, potocznie nazywane mapowaniem UV. Masz model i teksturę, którą chcesz do niego nałożyć. Tekstura ma różne obszary, reprezentujące obrazy, które chcemy nałożyć na różne części modelu. Musi być sposób na zaznaczenie, który trójkąt ma być reprezentowany za pomocą której części tekstury. Tutaj wkracza mapowanie tekstur.

Dla każdego wierzchołka zaznaczamy dwie współrzędne, U i V. Współrzędne te reprezentują pozycję na teksturze, przy czym U oznacza oś poziomą, a V oś pionową. Wartości nie są wyrażone w pikselach, ale w procentach pozycji na obrazie. Lewy dolny róg obrazu jest reprezentowany przez dwa zera, a prawy górny jest reprezentowany przez dwie jedynek.

Trójkąt jest po prostu malowany, pobierając współrzędne UV każdego wierzchołka w trójkącie i nakładając na teksturę obraz przechwycony między tymi współrzędnymi.

Demonstracja mapowania UV, z zaznaczoną jedną łatką i widocznymi szwami na modelu

Na powyższym obrazku możesz zobaczyć demonstrację mapowania UV. Model sferyczny został pobrany i pocięty na części, które są wystarczająco małe, aby można je było spłaszczyć na powierzchni 2D. Szwy, w których wykonano nacięcia, zaznaczono grubszymi liniami. Jedna z łat została podświetlona, ​​dzięki czemu można ładnie zobaczyć, jak wszystko do siebie pasuje. Możesz również zobaczyć, jak szew przechodzący przez środek uśmiechu umieszcza części ust w dwóch różnych miejscach.

Modele krawędziowe nie są częścią tekstury, ale po prostu nałożone na obraz, dzięki czemu można zobaczyć, jak rzeczy są ze sobą mapowane.

Ładowanie modelu OBJ

Wierz lub nie, ale to wszystko, co musisz wiedzieć, aby stworzyć swój własny prosty program ładujący modele. Format pliku OBJ jest wystarczająco prosty, aby zaimplementować parser w kilku linijkach kodu.

Plik zawiera listę pozycji wierzchołków w formacie v <float> <float> <float> z opcjonalną czwartą liczbą zmiennoprzecinkową, którą zignorujemy, aby wszystko było proste. Normalne wierzchołków są reprezentowane podobnie z vn <float> <float> <float> . Wreszcie współrzędne tekstury są reprezentowane przez vt <float> <float> , z opcjonalnym trzecim floatem, który zignorujemy. We wszystkich trzech przypadkach pływaki reprezentują odpowiednie współrzędne. Te trzy właściwości są gromadzone w trzech tablicach.

Twarze są reprezentowane przez grupy wierzchołków. Każdy wierzchołek jest reprezentowany przez indeks każdej z właściwości, przy czym indeksy zaczynają się od 1. Istnieje wiele sposobów przedstawiania tego, ale będziemy trzymać się f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3 , wymagające podania wszystkich trzech właściwości i ograniczające liczbę wierzchołków na powierzchnię do trzech. Wszystkie te ograniczenia są wprowadzane, aby program ładujący był jak najprostszy, ponieważ wszystkie inne opcje wymagają dodatkowego, trywialnego przetwarzania, zanim będą w formacie, który lubi WebGL.

Postawiliśmy wiele wymagań dla naszego programu do ładowania plików. To może wydawać się ograniczające, ale aplikacje do modelowania 3D zwykle dają możliwość ustawienia tych ograniczeń podczas eksportowania modelu jako pliku OBJ.

Poniższy kod analizuje ciąg znaków reprezentujący plik OBJ i tworzy model w postaci tablicy twarzy.

 function Geometry (faces) { this.faces = faces || [] } // Parses an OBJ file, passed as a string Geometry.parseOBJ = function (src) { var POSITION = /^v\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var NORMAL = /^vn\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var UV = /^vt\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var FACE = /^f\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)(?:\s+(-?\d+)\/(-?\d+)\/(-?\d+))?/ lines = src.split('\n') var positions = [] var uvs = [] var normals = [] var faces = [] lines.forEach(function (line) { // Match each line of the file against various RegEx-es var result if ((result = POSITION.exec(line)) != null) { // Add new vertex position positions.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = NORMAL.exec(line)) != null) { // Add new vertex normal normals.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = UV.exec(line)) != null) { // Add new texture mapping point uvs.push(new Vector2(parseFloat(result[1]), 1 - parseFloat(result[2]))) } else if ((result = FACE.exec(line)) != null) { // Add new face var vertices = [] // Create three vertices from the passed one-indexed indices for (var i = 1; i < 10; i += 3) { var part = result.slice(i, i + 3) var position = positions[parseInt(part[0]) - 1] var uv = uvs[parseInt(part[1]) - 1] var normal = normals[parseInt(part[2]) - 1] vertices.push(new Vertex(position, normal, uv)) } faces.push(new Face(vertices)) } }) return new Geometry(faces) } // Loads an OBJ file from the given URL, and returns it as a promise Geometry.loadOBJ = function (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(Geometry.parseOBJ(xhr.responseText)) } } xhr.open('GET', url, true) xhr.send(null) }) } function Face (vertices) { this.vertices = vertices || [] } function Vertex (position, normal, uv) { this.position = position || new Vector3() this.normal = normal || new Vector3() this.uv = uv || new Vector2() } function Vector3 (x, y, z) { this.x = Number(x) || 0 this.y = Number(y) || 0 this.z = Number(z) || 0 } function Vector2 (x, y) { this.x = Number(x) || 0 this.y = Number(y) || 0 }

Struktura Geometry zawiera dokładne dane potrzebne do wysłania modelu do karty graficznej w celu przetworzenia. Zanim jednak to zrobisz, prawdopodobnie chciałbyś mieć możliwość przesuwania modelu po ekranie.

Wykonywanie przekształceń przestrzennych

Wszystkie punkty w załadowanym modelu odnoszą się do jego układu współrzędnych. Jeśli chcemy przesunąć, obrócić i przeskalować model, wystarczy wykonać tę operację na jego układzie współrzędnych. Układ współrzędnych A, względem układu współrzędnych B, jest zdefiniowany przez położenie jego środka jako wektor p_ab , a wektor dla każdej z jego osi, x_ab , y_ab i z_ab , reprezentuje kierunek tej osi. Jeśli więc punkt przesunie się o 10 na osi x układu współrzędnych A, to — w układzie współrzędnych B — przesunie się on w kierunku x_ab , pomnożonym przez 10.

Wszystkie te informacje są przechowywane w następującej formie macierzy:

 x_ab.x y_ab.x z_ab.x p_ab.x x_ab.y y_ab.y z_ab.y p_ab.y x_ab.z y_ab.z z_ab.z p_ab.z 0 0 0 1

Jeśli chcemy przekształcić wektor 3D q , wystarczy pomnożyć macierz transformacji przez wektor:

 qx qy qz 1

Powoduje to przesunięcie punktu o qx wzdłuż nowej osi x , o qy wzdłuż nowej osi y io qz wzdłuż nowej osi z . W końcu powoduje to dodatkowe przesunięcie punktu o wektor p , dlatego używamy jedynki jako ostatniego elementu mnożenia.

Dużą zaletą korzystania z tych macierzy jest fakt, że jeśli mamy do wykonania wiele przekształceń na wierzchołku, możemy je scalić w jedno przekształcenie, mnożąc ich macierze, przed przekształceniem samego wierzchołka.

Istnieje wiele przekształceń, które można wykonać, a my przyjrzymy się najważniejszym.

Brak transformacji

Jeśli nie nastąpi żadna transformacja, wektor p jest wektorem zerowym, wektor x to [1, 0, 0] , y to [0, 1, 0] , a z to [0, 0, 1] . Odtąd będziemy odnosić się do tych wartości jako wartości domyślnych dla tych wektorów. Zastosowanie tych wartości daje nam macierz tożsamości:

 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1

To dobry punkt wyjścia do łączenia transformacji.

Tłumaczenie

Transformacja ramki do tłumaczenia

Kiedy wykonujemy translację, wszystkie wektory z wyjątkiem wektora p mają swoje wartości domyślne. Daje to następującą macierz:

 1 0 0 px 0 1 0 py 0 0 1 pz 0 0 0 1

skalowanie

Transformacja ramki do skalowania

Skalowanie modelu oznacza zmniejszenie udziału każdej współrzędnej w położeniu punktu. Nie ma jednolitego przesunięcia spowodowanego skalowaniem, więc wektor p zachowuje swoją domyślną wartość. Domyślne wektory osi należy pomnożyć przez odpowiadające im współczynniki skalowania, co daje następującą macierz:

 s_x 0 0 0 0 s_y 0 0 0 0 s_z 0 0 0 0 1

Tutaj s_x , s_y i s_z reprezentują skalowanie zastosowane do każdej osi.

Obrót

Transformacja ramy dla obrotu wokół osi Z

Powyższy obrazek pokazuje, co się dzieje, gdy obracamy układ współrzędnych wokół osi Z.

Obrót powoduje brak jednolitego przesunięcia, więc wektor p zachowuje swoją domyślną wartość. Teraz sprawy stają się nieco trudniejsze. Obroty powodują, że ruch wzdłuż określonej osi w pierwotnym układzie współrzędnych porusza się w innym kierunku. Jeśli więc obrócimy układ współrzędnych o 45 stopni wokół osi Z, poruszanie się wzdłuż osi x oryginalnego układu współrzędnych powoduje ruch w kierunku ukośnym między osiami x i y w nowym układzie współrzędnych.

Aby uprościć sprawę, pokażemy tylko, jak macierze transformacji wyglądają dla obrotów wokół głównych osi.

 Around X: 1 0 0 0 0 cos(phi) sin(phi) 0 0 -sin(phi) cos(phi) 0 0 0 0 1 Around Y: cos(phi) 0 sin(phi) 0 0 1 0 0 -sin(phi) 0 cos(phi) 0 0 0 0 1 Around Z: cos(phi) -sin(phi) 0 0 sin(phi) cos(phi) 0 0 0 0 1 0 0 0 0 1

Realizacja

Wszystko to można zaimplementować jako klasę przechowującą 16 liczb, przechowującą macierze w kolejności kolumnowej.

 function Transformation () { // Create an identity transformation this.fields = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } // Multiply matrices, to chain transformations Transformation.prototype.mult = function (t) { var output = new Transformation() for (var row = 0; row < 4; ++row) { for (var col = 0; col < 4; ++col) { var sum = 0 for (var k = 0; k < 4; ++k) { sum += this.fields[k * 4 + row] * t.fields[col * 4 + k] } output.fields[col * 4 + row] = sum } } return output } // Multiply by translation matrix Transformation.prototype.translate = function (x, y, z) { var mat = new Transformation() mat.fields[12] = Number(x) || 0 mat.fields[13] = Number(y) || 0 mat.fields[14] = Number(z) || 0 return this.mult(mat) } // Multiply by scaling matrix Transformation.prototype.scale = function (x, y, z) { var mat = new Transformation() mat.fields[0] = Number(x) || 0 mat.fields[5] = Number(y) || 0 mat.fields[10] = Number(z) || 0 return this.mult(mat) } // Multiply by rotation matrix around X axis Transformation.prototype.rotateX = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[5] = c mat.fields[10] = c mat.fields[9] = -s mat.fields[6] = s return this.mult(mat) } // Multiply by rotation matrix around Y axis Transformation.prototype.rotateY = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[10] = c mat.fields[2] = -s mat.fields[8] = s return this.mult(mat) } // Multiply by rotation matrix around Z axis Transformation.prototype.rotateZ = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[5] = c mat.fields[4] = -s mat.fields[1] = s return this.mult(mat) }

Patrząc przez kamerę

Oto kluczowa część prezentowania obiektów na ekranie: kamera. Aparat składa się z dwóch kluczowych elementów; a mianowicie jego położenie i sposób wyświetlania obserwowanych obiektów na ekranie.

Pozycja kamery jest obsługiwana za pomocą jednej prostej sztuczki. Nie ma wizualnej różnicy między przesunięciem aparatu o metr do przodu, a przesunięciem całego świata o metr do tyłu. Więc naturalnie robimy to drugie, stosując odwrotność macierzy jako przekształcenie.

Drugim kluczowym elementem jest sposób rzutowania obserwowanych obiektów na obiektyw. W WebGL wszystko, co jest widoczne na ekranie, znajduje się w pudełku. Pudełko rozciąga się od -1 do 1 na każdej osi. Wszystko, co widać, znajduje się w tym pudełku. Możemy użyć tego samego podejścia do macierzy transformacji, aby utworzyć macierz projekcji.

Rzut prostokątny

Przekształcenie prostokątnej przestrzeni w odpowiednie wymiary bufora ramki za pomocą rzutowania ortogonalnego

Najprostszym rzutem jest rzut prostokątny. Bierzesz pudełko w przestrzeni, oznaczając szerokość, wysokość i głębokość, zakładając, że jego środek znajduje się w pozycji zerowej. Następnie projekcja zmienia rozmiar pudełka, aby dopasować je do wcześniej opisanego pudełka, w którym WebGL obserwuje obiekty. Ponieważ chcemy zmienić rozmiar każdego wymiaru na dwa, skalujemy każdą oś o 2/size , przy czym size jest wymiarem odpowiedniej osi. Małym zastrzeżeniem jest fakt, że mnożymy oś Z przez minus. Dzieje się tak, ponieważ chcemy odwrócić kierunek tego wymiaru. Ostateczna macierz ma następującą postać:

 2/width 0 0 0 0 2/height 0 0 0 0 -2/depth 0 0 0 0 1

Projekcja perspektywiczna

Frustum zostaje przekształcone w odpowiednie wymiary bufora ramki za pomocą rzutowania perspektywicznego

Nie będziemy szczegółowo omawiać tego, jak zaprojektowano tę projekcję, ale po prostu użyjemy ostatecznej formuły, która jest obecnie w zasadzie standardowa. Możemy to uprościć, umieszczając rzut w pozycji zerowej na osi x i y, czyniąc granice prawo/lewo i góra/dół równe odpowiednio width/2 i height/2 . Parametry n i f reprezentują near i far płaszczyzny przycinania, które są najmniejszą i największą odległością, jaką punkt może przechwycić kamera. Na powyższym obrazku są one reprezentowane przez równoległe boki frustum.

Rzut perspektywiczny jest zwykle reprezentowany przez pole widzenia (będziemy używać pionowego), proporcje oraz bliską i daleką odległość w płaszczyźnie. Informacje te można wykorzystać do obliczenia width i height , a następnie utworzyć macierz z następującego szablonu:

 2*n/width 0 0 0 0 2*n/height 0 0 0 0 (f+n)/(nf) 2*f*n/(nf) 0 0 -1 0

Do obliczenia szerokości i wysokości można użyć następujących wzorów:

 height = 2 * near * Math.tan(fov * Math.PI / 360) width = aspectRatio * height

FOV (pole widzenia) reprezentuje kąt pionowy, który kamera rejestruje za pomocą obiektywu. Współczynnik proporcji reprezentuje stosunek szerokości do wysokości obrazu i jest oparty na wymiarach ekranu, na którym renderujemy.

Realizacja

Teraz możemy reprezentować kamerę jako klasę, która przechowuje pozycję kamery i macierz projekcji. Musimy również wiedzieć, jak obliczyć transformacje odwrotne. Rozwiązywanie ogólnych inwersji macierzy może być problematyczne, ale w naszym szczególnym przypadku istnieje uproszczone podejście.

 function Camera () { this.position = new Transformation() this.projection = new Transformation() } Camera.prototype.setOrthographic = function (width, height, depth) { this.projection = new Transformation() this.projection.fields[0] = 2 / width this.projection.fields[5] = 2 / height this.projection.fields[10] = -2 / depth } Camera.prototype.setPerspective = function (verticalFov, aspectRatio, near, far) { var height_div_2n = Math.tan(verticalFov * Math.PI / 360) var width_div_2n = aspectRatio * height_div_2n this.projection = new Transformation() this.projection.fields[0] = 1 / height_div_2n this.projection.fields[5] = 1 / width_div_2n this.projection.fields[10] = (far + near) / (near - far) this.projection.fields[10] = -1 this.projection.fields[14] = 2 * far * near / (near - far) this.projection.fields[15] = 0 } Camera.prototype.getInversePosition = function () { var orig = this.position.fields var dest = new Transformation() var x = orig[12] var y = orig[13] var z = orig[14] // Transpose the rotation matrix for (var i = 0; i < 3; ++i) { for (var j = 0; j < 3; ++j) { dest.fields[i * 4 + j] = orig[i + j * 4] } } // Translation by -p will apply R^T, which is equal to R^-1 return dest.translate(-x, -y, -z) }

To ostatni kawałek, którego potrzebujemy, zanim zaczniemy rysować rzeczy na ekranie.

Rysowanie obiektu za pomocą potoku graficznego WebGL

Najprostszą powierzchnią, jaką możesz narysować, jest trójkąt. W rzeczywistości większość rzeczy, które rysujesz w przestrzeni 3D, składa się z dużej liczby trójkątów.

Podstawowe spojrzenie na etapy potoku graficznego

Pierwszą rzeczą, którą musisz zrozumieć, jest sposób reprezentacji ekranu w WebGL. Jest to przestrzeń 3D rozciągająca się od -1 do 1 na osiach x , y i z . Domyślnie ta oś Z nie jest używana, ale jesteś zainteresowany grafiką 3D, więc będziesz chciał ją od razu włączyć.

Mając to na uwadze, poniżej przedstawiono trzy kroki wymagane do narysowania trójkąta na tej powierzchni.

Możesz zdefiniować trzy wierzchołki, które reprezentują trójkąt, który chcesz narysować. Serializujesz te dane i wysyłasz je do GPU (jednostki przetwarzania grafiki). Mając dostępny cały model, możesz to zrobić dla wszystkich trójkątów w modelu. Pozycje wierzchołków, które podajesz, znajdują się w lokalnej przestrzeni współrzędnych załadowanego modelu. Mówiąc prościej, pozycje, które podajesz, są dokładnie tymi z pliku, a nie tymi, które otrzymasz po wykonaniu transformacji macierzy.

Teraz, gdy już przekazałeś wierzchołki GPU, mówisz GPU, jakiej logiki użyć podczas umieszczania wierzchołków na ekranie. Ten krok zostanie wykorzystany do zastosowania naszych transformacji macierzy. GPU jest bardzo dobry w mnożeniu wielu macierzy 4x4, więc dobrze wykorzystamy tę możliwość.

W ostatnim kroku GPU rasteryzuje ten trójkąt. Rasteryzacja to proces pobierania grafiki wektorowej i określania, które piksele ekranu należy pomalować, aby dany obiekt grafiki wektorowej został wyświetlony. W naszym przypadku GPU próbuje określić, które piksele znajdują się w każdym trójkącie. Dla każdego piksela GPU zapyta, jaki kolor chcesz pomalować.

Są to cztery elementy potrzebne do narysowania czegokolwiek chcesz i są najprostszym przykładem potoku graficznego. Poniżej przedstawiamy spojrzenie na każdy z nich i prostą implementację.

Domyślny bufor ramki

Najważniejszym elementem aplikacji WebGL jest kontekst WebGL. Możesz uzyskać do niego dostęp za pomocą gl = canvas.getContext('webgl') lub użyć 'experimental-webgl' jako rozwiązania zastępczego, na wypadek gdyby aktualnie używana przeglądarka nie obsługuje jeszcze wszystkich funkcji WebGL. canvas , o którym mówiliśmy, jest elementem DOM płótna, na którym chcemy rysować. Kontekst zawiera wiele rzeczy, między innymi domyślny bufor ramki.

Możesz luźno opisać bufor ramki jako dowolny bufor (obiekt), na którym możesz narysować. Domyślnie domyślny bufor ramki przechowuje kolor każdego piksela płótna, z którym powiązany jest kontekst WebGL. Jak opisano w poprzedniej sekcji, kiedy rysujemy w buforze ramki, każdy piksel znajduje się między -1 a 1 na osiach x i y . Wspomnieliśmy również o tym, że domyślnie WebGL nie używa osi Z. Tę funkcjonalność można włączyć, uruchamiając gl.enable(gl.DEPTH_TEST) . Świetnie, ale czym jest test głębokości?

Włączenie testu głębi umożliwia pikselowi przechowywanie zarówno koloru, jak i głębi. Głębokość to współrzędna z tego piksela. Po narysowaniu do piksela na określonej głębokości z , aby zaktualizować kolor tego piksela, musisz narysować w pozycji z bliższej aparatu. W przeciwnym razie próba remisu zostanie zignorowana. Pozwala to na złudzenie trójwymiarowości, ponieważ rysowanie obiektów znajdujących się za innymi obiektami spowoduje, że obiekty te zostaną zasłonięte przez obiekty znajdujące się przed nimi.

Wszelkie remisy, które wykonujesz, pozostają na ekranie, dopóki nie powiesz im, aby zostały wyczyszczone. Aby to zrobić, musisz zadzwonić na gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) . Spowoduje to wyczyszczenie zarówno bufora koloru, jak i głębi. Aby wybrać kolor, na który są ustawione wyczyszczone piksele, użyj gl.clearColor(red, green, blue, alpha) .

Stwórzmy renderer, który używa płótna i czyści go na żądanie:

 function Renderer (canvas) { var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') gl.enable(gl.DEPTH_TEST) this.gl = gl } Renderer.prototype.setClearColor = function (red, green, blue) { gl.clearColor(red / 255, green / 255, blue / 255, 1) } Renderer.prototype.getContext = function () { return this.gl } Renderer.prototype.render = function () { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) } var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) loop() function loop () { renderer.render() requestAnimationFrame(loop) }

Dołączenie tego skryptu do następującego kodu HTML da ci jasny niebieski prostokąt na ekranie

 <!DOCTYPE html> <html> <head> </head> <body> <canvas width="800" height="500"></canvas> <script src="script.js"></script> </body> </html>

Wywołanie requestAnimationFrame powoduje ponowne wywołanie pętli zaraz po zakończeniu renderowania poprzedniej klatki i zakończeniu obsługi wszystkich zdarzeń.

Obiekty bufora wierzchołków

Pierwszą rzeczą, którą musisz zrobić, to zdefiniować wierzchołki, które chcesz narysować. Możesz to zrobić opisując je za pomocą wektorów w przestrzeni 3D. Następnie chcesz przenieść te dane do pamięci RAM GPU, tworząc nowy obiekt bufora wierzchołków (VBO).

Ogólnie obiekt bufora to obiekt, który przechowuje tablicę fragmentów pamięci na GPU. To, że jest VBO, oznacza po prostu, do czego GPU może wykorzystać pamięć. W większości przypadków obiekty buforowe, które utworzysz, będą VBO.

Możesz wypełnić VBO, biorąc wszystkie N wierzchołków, które mamy i tworząc tablicę pływaków z 3N elementami dla pozycji wierzchołka i normalnych VBO wierzchołków oraz 2N dla współrzędnych tekstury VBO. Każda grupa trzech pływaków lub dwóch pływaków dla współrzędnych UV reprezentuje indywidualne współrzędne wierzchołka. Następnie przekazujemy te tablice do GPU, a nasze wierzchołki są gotowe na resztę potoku.

Ponieważ dane znajdują się teraz w pamięci RAM GPU, można je usunąć z pamięci RAM ogólnego przeznaczenia. To znaczy, chyba że chcesz go później zmodyfikować i przesłać ponownie. Po każdej modyfikacji musi nastąpić przesłanie, ponieważ modyfikacje w naszych macierzach JS nie dotyczą VBO w rzeczywistej pamięci RAM GPU.

Poniżej znajduje się przykład kodu, który zapewnia wszystkie opisane funkcje. Ważną uwagą jest fakt, że zmienne przechowywane na GPU nie są zbieranymi śmieciami. Oznacza to, że musimy je ręcznie usunąć, gdy nie chcemy ich więcej używać. Podamy ci tylko przykład tego, jak to się robi tutaj i nie będziemy się dalej skupiać na tej koncepcji. Usuwanie zmiennych z GPU jest konieczne tylko wtedy, gdy planujesz przestać używać określonej geometrii w całym programie.

Dodaliśmy również serializację do naszej klasy Geometry i jej elementów.

 Geometry.prototype.vertexCount = function () { return this.faces.length * 3 } Geometry.prototype.positions = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.position answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.normals = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.normal answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.uvs = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.uv answer.push(vx, vy) }) }) return answer } //////////////////////////////// function VBO (gl, data, count) { // Creates buffer object in GPU RAM where we can store anything var bufferObject = gl.createBuffer() // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject) // Write the data, and set the flag to optimize // for rare changes to the data we're writing gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW) this.gl = gl this.size = data.length / count this.count = count this.data = bufferObject } VBO.prototype.destroy = function () { // Free memory that is occupied by our buffer object this.gl.deleteBuffer(this.data) }

Typ danych VBO generuje VBO w przekazanym kontekście WebGL, na podstawie tablicy przekazanej jako drugi parametr.

Możesz zobaczyć trzy wywołania kontekstu gl . createBuffer() tworzy bufor. bindBuffer() mówi maszynie stanów WebGL, aby używała tej konkretnej pamięci jako bieżącego VBO ( ARRAY_BUFFER ) dla wszystkich przyszłych operacji, dopóki nie zostanie poinformowane inaczej. Następnie ustawiamy wartość bieżącego VBO na podane dane za pomocą bufferData() .

Udostępniamy również metodę destroy, która usuwa nasz obiekt bufora z pamięci RAM GPU, używając deleteBuffer() .

Możesz użyć trzech VBO i transformacji, aby opisać wszystkie właściwości siatki, wraz z jej położeniem.

 function Mesh (gl, geometry) { var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() }

Jako przykład, oto jak możemy załadować model, zapisać jego właściwości w siatce, a następnie go zniszczyć:

 Geometry.loadOBJ('/assets/model.obj').then(function (geometry) { var mesh = new Mesh(gl, geometry) console.log(mesh) mesh.destroy() })

Shadery

Poniżej znajduje się opisany wcześniej dwuetapowy proces przesuwania punktów w żądane pozycje i malowania poszczególnych pikseli. W tym celu piszemy program, który jest wielokrotnie uruchamiany na karcie graficznej. Ten program zazwyczaj składa się z co najmniej dwóch części. Pierwsza część to Vertex Shader , który jest uruchamiany dla każdego wierzchołka i wyjścia, w których powinniśmy umieścić wierzchołek na ekranie, między innymi. Druga część to Fragment Shader , który jest uruchamiany dla każdego piksela zakrywanego przez trójkąt na ekranie i wyświetla kolor, na który piksel powinien być pomalowany.

Shadery wierzchołków

Załóżmy, że chcesz mieć model, który porusza się w lewo i w prawo na ekranie. W naiwny sposób można zaktualizować położenie każdego wierzchołka i ponownie wysłać go do GPU. Ten proces jest kosztowny i powolny. Alternatywnie możesz dać program dla GPU do uruchamiania dla każdego wierzchołka i wykonywać wszystkie te operacje równolegle z procesorem, który jest zbudowany do wykonywania dokładnie tego zadania. To jest rola Vertex Shadera .

Vertex Shader to część potoku renderowania, która przetwarza poszczególne wierzchołki. Wywołanie Vertex Shader odbiera pojedynczy wierzchołek i wysyła jeden wierzchołek po zastosowaniu wszystkich możliwych transformacji wierzchołka.

Shadery są napisane w GLSL. Istnieje wiele unikalnych elementów tego języka, ale większość składni jest bardzo podobna do C, więc powinna być zrozumiała dla większości ludzi.

Istnieją trzy typy zmiennych, które wchodzą i wychodzą z Vertex Shadera, a wszystkie służą do określonego zastosowania:

  • attribute — Są to dane wejściowe, które przechowują określone właściwości wierzchołka. Poprzednio opisaliśmy pozycję wierzchołka jako atrybut w postaci trzyelementowego wektora. Atrybuty można traktować jako wartości opisujące jeden wierzchołek.
  • uniform — Są to dane wejściowe, które są takie same dla każdego wierzchołka w ramach tego samego wywołania renderowania. Powiedzmy, że chcemy móc poruszać naszym modelem, definiując macierz transformacji. Możesz to opisać za pomocą zmiennej uniform . Możesz również wskazać zasoby na GPU, takie jak tekstury. Możesz spojrzeć na mundury jako wartości opisujące model lub część modelu.
  • varying — są to dane wyjściowe, które przekazujemy do Fragment Shadera. Ponieważ potencjalnie są tysiące pikseli dla trójkąta wierzchołków, każdy piksel otrzyma interpolowaną wartość tej zmiennej, w zależności od pozycji. Jeśli więc jeden wierzchołek wyśle ​​500 jako dane wyjściowe, a drugi 100, piksel znajdujący się pomiędzy nimi otrzyma 300 jako dane wejściowe dla tej zmiennej. Różnice można traktować jako wartości opisujące powierzchnie między wierzchołkami.

So, let's say you want to create a vertex shader that receives a position, normal, and uv coordinates for each vertex, and a position, view (inverse camera position), and projection matrix for each rendered object. Let's say you also want to paint individual pixels based on their uv coordinates and their normals. “How would that code look?” możesz zapytać.

 attribute vec3 position; attribute vec3 normal; attribute vec2 uv; uniform mat4 model; uniform mat4 view; uniform mat4 projection; varying vec3 vNormal; varying vec2 vUv; void main() { vUv = uv; vNormal = (model * vec4(normal, 0.)).xyz; gl_Position = projection * view * model * vec4(position, 1.); }

Most of the elements here should be self-explanatory. The key thing to notice is the fact that there are no return values in the main function. All values that we would want to return are assigned, either to varying variables, or to special variables. Here we assign to gl_Position , which is a four-dimensional vector, whereby the last dimension should always be set to one. Another strange thing you might notice is the way we construct a vec4 out of the position vector. You can construct a vec4 by using four float s, two vec2 s, or any other combination that results in four elements. There are a lot of seemingly strange type castings which make perfect sense once you're familiar with transformation matrices.

You can also see that here we can perform matrix transformations extremely easily. GLSL is specifically made for this kind of work. The output position is calculated by multiplying the projection, view, and model matrix and applying it onto the position. The output normal is just transformed to the world space. We'll explain later why we've stopped there with the normal transformations.

For now, we will keep it simple, and move on to painting individual pixels.

Fragment Shaders

A fragment shader is the step after rasterization in the graphics pipeline. It generates color, depth, and other data for every pixel of the object that is being painted.

The principles behind implementing fragment shaders are very similar to vertex shaders. There are three major differences, though:

  • There are no more varying outputs, and attribute inputs have been replaced with varying inputs. We have just moved on in our pipeline, and things that are the output in the vertex shader are now inputs in the fragment shader.
  • Our only output now is gl_FragColor , which is a vec4 . The elements represent red, green, blue, and alpha (RGBA), respectively, with variables in the 0 to 1 range. You should keep alpha at 1, unless you're doing transparency. Transparency is a fairly advanced concept though, so we'll stick to opaque objects.
  • At the beginning of the fragment shader, you need to set the float precision, which is important for interpolations. In almost all cases, just stick to the lines from the following shader.

With that in mind, you can easily write a shader that paints the red channel based on the U position, green channel based on the V position, and sets the blue channel to maximum.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec2 clampedUv = clamp(vUv, 0., 1.); gl_FragColor = vec4(clampedUv, 1., 1.); }

The function clamp just limits all floats in an object to be within the given limits. The rest of the code should be pretty straightforward.

With all of this in mind, all that is left is to implement this in WebGL.

Combining Shaders into a Program

The next step is to combine the shaders into a program:

 function ShaderProgram (gl, vertSrc, fragSrc) { var vert = gl.createShader(gl.VERTEX_SHADER) gl.shaderSource(vert, vertSrc) gl.compileShader(vert) if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(vert)) throw new Error('Failed to compile shader') } var frag = gl.createShader(gl.FRAGMENT_SHADER) gl.shaderSource(frag, fragSrc) gl.compileShader(frag) if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(frag)) throw new Error('Failed to compile shader') } var program = gl.createProgram() gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) throw new Error('Failed to link program') } this.gl = gl this.position = gl.getAttribLocation(program, 'position') this.normal = gl.getAttribLocation(program, 'normal') this.uv = gl.getAttribLocation(program, 'uv') this.model = gl.getUniformLocation(program, 'model') this.view = gl.getUniformLocation(program, 'view') this.projection = gl.getUniformLocation(program, 'projection') this.vert = vert this.frag = frag this.program = program } // Loads shader files from the given URLs, and returns a program as a promise ShaderProgram.load = function (gl, vertUrl, fragUrl) { return Promise.all([loadFile(vertUrl), loadFile(fragUrl)]).then(function (files) { return new ShaderProgram(gl, files[0], files[1]) }) function loadFile (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(xhr.responseText) } } xhr.open('GET', url, true) xhr.send(null) }) } }

There isn't much to say about what's happening here. Each shader gets assigned a string as a source and compiled, after which we check to see if there were compilation errors. Then, we create a program by linking these two shaders. Finally, we store pointers to all relevant attributes and uniforms for posterity.

Actually Drawing the Model

Last, but not least, you draw the model.

First you pick the shader program you want to use.

 ShaderProgram.prototype.use = function () { this.gl.useProgram(this.program) }

Then you send all the camera related uniforms to the GPU. These uniforms change only once per camera change or movement.

 Transformation.prototype.sendToGpu = function (gl, uniform, transpose) { gl.uniformMatrix4fv(uniform, transpose || false, new Float32Array(this.fields)) } Camera.prototype.use = function (shaderProgram) { this.projection.sendToGpu(shaderProgram.gl, shaderProgram.projection) this.getInversePosition().sendToGpu(shaderProgram.gl, shaderProgram.view) }

Finally, you take the transformations and VBOs and assign them to uniforms and attributes, respectively. Since this has to be done to each VBO, you can create its data binding as a method.

 VBO.prototype.bindToAttribute = function (attribute) { var gl = this.gl // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, this.data) // Enable this attribute in the shader gl.enableVertexAttribArray(attribute) // Define format of the attribute array. Must match parameters in shader gl.vertexAttribPointer(attribute, this.size, gl.FLOAT, false, 0, 0) }

Then you assign an array of three floats to the uniform. Each uniform type has a different signature, so documentation and more documentation are your friends here. Finally, you draw the triangle array on the screen. You tell the drawing call drawArrays() from which vertex to start, and how many vertices to draw. The first parameter passed tells WebGL how it shall interpret the array of vertices. Using TRIANGLES takes three by three vertices and draws a triangle for each triplet. Using POINTS would just draw a point for each passed vertex. There are many more options, but there is no need to discover everything at once. Below is the code for drawing an object:

 Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) }

The renderer needs to be extended a bit to accommodate all the extra elements that need to be handled. It should be possible to attach a shader program, and to render an array of objects based on the current camera position.

 Renderer.prototype.setShader = function (shader) { this.shader = shader } Renderer.prototype.render = function (camera, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }

We can combine all the elements that we have to finally draw something on the screen:

 var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Geometry.loadOBJ('/assets/sphere.obj').then(function (data) { objects.push(new Mesh(gl, data)) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) loop() function loop () { renderer.render(camera, objects) requestAnimationFrame(loop) } 

Object drawn on the canvas, with colors depending on UV coordinates

This looks a bit random, but you can see the different patches of the sphere, based on where they are on the UV map. You can change the shader to paint the object brown. Just set the color for each pixel to be the RGBA for brown:

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); gl_FragColor = vec4(brown, 1.); } 

Brown object drawn on the canvas

It doesn't look very convincing. It looks like the scene needs some shading effects.

Adding Light

Lights and shadows are the tools that allow us to perceive the shape of objects. Lights come in many shapes and sizes: spotlights that shine in one cone, light bulbs that spread light in all directions, and most interestingly, the sun, which is so far away that all the light it shines on us radiates, for all intents and purposes, in the same direction.

Sunlight sounds like it's the simplest to implement, since all you need to provide is the direction in which all rays spread. For each pixel that you draw on the screen, you check the angle under which the light hits the object. This is where the surface normals come in.

Demonstration of angles between light rays and surface normals, for both flat and smooth shading

You can see all the light rays flowing in the same direction, and hitting the surface under different angles, which are based on the angle between the light ray and the surface normal. The more they coincide, the stronger the light is.

If you perform a dot product between the normalized vectors for the light ray and the surface normal, you will get -1 if the ray hits the surface perfectly perpendicularly, 0 if the ray is parallel to the surface, and 1 if it illuminates it from the opposite side. So anything between 0 and 1 should add no light, while numbers between 0 and -1 should gradually increase the amount of light hitting the object. You can test this by adding a fixed light in the shader code.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); gl_FragColor = vec4(brown * lightness, 1.); } 

Brown object with sunlight

Ustawiamy słońce, by świeciło w kierunku przód-lewo-dół. Widać, jak płynne jest cieniowanie, mimo że model jest bardzo postrzępiony. Możesz również zauważyć, jak ciemna jest lewa dolna strona. Możemy dodać poziom światła otoczenia, które rozjaśni obszar w cieniu.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); float ambientLight = 0.3; lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); } 

Brązowy obiekt ze światłem słonecznym i światłem otoczenia

Ten sam efekt można osiągnąć, wprowadzając klasę światła, która przechowuje kierunek światła i intensywność światła otoczenia. Następnie możesz zmienić Fragment Shader, aby uwzględnić ten dodatek.

Teraz shader staje się:

 #ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }

Następnie możesz zdefiniować światło:

 function Light () { this.lightDirection = new Vector3(-1, -1, -1) this.ambientLight = 0.3 } Light.prototype.use = function (shaderProgram) { var dir = this.lightDirection var gl = shaderProgram.gl gl.uniform3f(shaderProgram.lightDirection, dir.x, dir.y, dir.z) gl.uniform1f(shaderProgram.ambientLight, this.ambientLight) }

W klasie programu shader dodaj potrzebne uniformy:

 this.ambientLight = gl.getUniformLocation(program, 'ambientLight') this.lightDirection = gl.getUniformLocation(program, 'lightDirection')

W programie dodaj wywołanie nowego światła w rendererze:

 Renderer.prototype.render = function (camera, light, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() light.use(shader) camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }

Pętla zmieni się wtedy nieznacznie:

 var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }

Jeśli zrobiłeś wszystko dobrze, wyrenderowany obraz powinien być taki sam, jak na poprzednim obrazie.

Ostatnim krokiem do rozważenia byłoby dodanie rzeczywistej tekstury do naszego modelu. Zróbmy to teraz.

Dodawanie tekstur

HTML5 ma świetne wsparcie dla ładowania obrazów, więc nie ma potrzeby robienia szalonego parsowania obrazów. Obrazy są przesyłane do GLSL jako sampler2D , informując shader, która z powiązanych tekstur ma być próbkowana. Istnieje ograniczona liczba tekstur, które można powiązać, a limit zależy od używanego sprzętu. sampler2D może być zapytany o kolory w określonych pozycjach. W tym miejscu pojawiają się współrzędne UV. Oto przykład, w którym zastąpiliśmy brąz próbkowanymi kolorami.

 #ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; uniform sampler2D diffuse; varying vec3 vNormal; varying vec2 vUv; void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }

Nowy uniform musi zostać dodany do listy w programie do cieniowania:

 this.diffuse = gl.getUniformLocation(program, 'diffuse')

Na koniec zaimplementujemy ładowanie tekstur. Jak wcześniej wspomniano, HTML5 zapewnia możliwość ładowania obrazów. Wystarczy, że wyślemy obraz do GPU:

 function Texture (gl, image) { var texture = gl.createTexture() // Set the newly created texture context as active texture gl.bindTexture(gl.TEXTURE_2D, texture) // Set texture parameters, and pass the image that the texture is based on gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) // Set filtering methods // Very often shaders will query the texture value between pixels, // and this is instructing how that value shall be calculated gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) this.data = texture this.gl = gl } Texture.prototype.use = function (uniform, binding) { binding = Number(binding) || 0 var gl = this.gl // We can bind multiple textures, and here we pick which of the bindings // we're setting right now gl.activeTexture(gl['TEXTURE' + binding]) // After picking the binding, we set the texture gl.bindTexture(gl.TEXTURE_2D, this.data) // Finally, we pass to the uniform the binding ID we've used gl.uniform1i(uniform, binding) // The previous 3 lines are equivalent to: // texture[i] = this.data // uniform = i } Texture.load = function (gl, url) { return new Promise(function (resolve) { var image = new Image() image.onload = function () { resolve(new Texture(gl, image)) } image.src = url }) }

Proces ten nie różni się zbytnio od procesu używanego do ładowania i wiązania VBO. Główna różnica polega na tym, że nie wiążemy już z atrybutem, ale raczej wiążemy indeks tekstury z uniformem liczb całkowitych. Typ sampler2D to nic innego jak przesunięcie wskaźnika do tekstury.

Teraz wystarczy tylko rozszerzyć klasę Mesh , aby obsługiwała również tekstury:

 function Mesh (gl, geometry, texture) { // added texture var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.texture = texture // new this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() } Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.texture.use(shaderProgram.diffuse, 0) // new this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) } Mesh.load = function (gl, modelUrl, textureUrl) { // new var geometry = Geometry.loadOBJ(modelUrl) var texture = Texture.load(gl, textureUrl) return Promise.all([geometry, texture]).then(function (params) { return new Mesh(gl, params[0], params[1]) }) }

A ostateczny scenariusz główny wyglądałby następująco:

 var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Mesh.load(gl, '/assets/sphere.obj', '/assets/diffuse.png') .then(function (mesh) { objects.push(mesh) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) } 

Teksturowany obiekt z efektami świetlnymi

Nawet animowanie jest teraz łatwe. Jeśli chciałbyś, aby kamera obracała się wokół naszego obiektu, możesz to zrobić, dodając po prostu jedną linijkę kodu:

 function loop () { renderer.render(camera, light, objects) camera.position = camera.position.rotateY(Math.PI / 120) requestAnimationFrame(loop) } 

Obrócona głowa podczas animacji kamery

Zapraszam do zabawy z shaderami. Dodanie jednej linii kodu zmieni to realistyczne oświetlenie w coś kreskówkowego.

 void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = lightness > 0.1 ? 1. : 0.; // new lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }

To tak proste, jak przekazanie oświetleniu skrajności w oparciu o to, czy przekroczyło ustalony próg.

Głowa z zastosowanym oświetleniem kreskówkowym

Gdzie iść dalej

Istnieje wiele źródeł informacji umożliwiających poznanie wszystkich sztuczek i zawiłości WebGL. A najlepsze jest to, że jeśli nie możesz znaleźć odpowiedzi, która odnosi się do WebGL, możesz poszukać jej w OpenGL, ponieważ WebGL jest w dużej mierze oparty na podzbiorze OpenGL, a niektóre nazwy są zmieniane.

W dowolnej kolejności, oto kilka świetnych źródeł bardziej szczegółowych informacji, zarówno dla WebGL, jak i OpenGL.

  • Podstawy WebGL
  • Nauka WebGL
  • Bardzo szczegółowy samouczek OpenGL, który przeprowadzi Cię przez wszystkie opisane tutaj podstawowe zasady w bardzo powolny i szczegółowy sposób.
  • I jest wiele, wiele innych stron poświęconych nauce zasad grafiki komputerowej.
  • Dokumentacja MDN dla WebGL
  • Specyfikacja Khronos WebGL 1.0 dla tych, którzy chcą poznać bardziej techniczne szczegóły dotyczące działania interfejsu API WebGL we wszystkich skrajnych przypadkach.