Programy do cieniowania

Wprowadzenie

Widziałem już wcześniej omówienie Three.js. Jeśli nie czytałeś(-aś) tego artykułu, warto się z nim zapoznać, ponieważ stanowi on podstawę dalszej części tego tekstu.

Chcę porozmawiać o shaderach. WebGL jest świetny, a jak już wcześniej wspominałem, Three.js (i inne biblioteki) świetnie radzą sobie z ukrywaniem trudności. Czasami jednak będziesz chciał uzyskać określony efekt lub dowiedzieć się więcej o tym, jak te niesamowite rzeczy pojawiają się na ekranie. W takich przypadkach shadery z pewnością będą niezbędne. Jeśli jesteś podobny do mnie, możesz przejść od podstaw z poprzedniego samouczka do czegoś nieco bardziej skomplikowanego. Zakładam, że używasz Three.js, ponieważ to ona wykonuje większość pracy związanej z uruchomieniem shadera. Na wstępie powiem też, że na początku wyjaśnię kontekst shaderów, a w części końcowej tego samouczka zagłębimy się nieco bardziej w temat. Powodem jest to, że cieniowanie są od razu nietypowe i wymagają trochę wyjaśnień.

1. Dwa shadery

WebGL nie oferuje możliwości korzystania z stałego potoku danych, co oznacza, że nie daje żadnych możliwości renderowania. Oferuje jednak Programmable Pipeline, który jest bardziej wydajny, ale też trudniejszy do zrozumienia i zastosowania. W skrócie „Programmable Pipeline” oznacza, że jako programista odpowiadasz za renderowanie wierzchołków itd. na ekranie. Cząstki te są częścią tego potoku, a ich typy to:

  1. Cienie wierzchołków
  2. Programy fragmentów

Z czym się zgadzam. Musisz wiedzieć, że oba te procesy działają całkowicie na procesorze graficznym karty graficznej. Oznacza to, że chcemy przekazać im wszystko, co tylko się da, pozostawiając procesorowi inne zadania. Nowoczesne GPU są w dużej mierze zoptymalizowane pod kątem funkcji wymaganych przez shadery, więc warto z nich korzystać.

2. Shadery wierzchołków

Weź standardowy kształt prymitywny, np. kulę. Składa się z wierzchołków, prawda? Shader wierzchołkowy otrzymuje po kolei poszczególne wierzchołki i może je modyfikować. To, co shader wierzchołka robi z każdym z nich, zależy od niego, ale ma on jedną odpowiedzialność: musi w pewnym momencie ustawić coś o nazwie gl_Position, czyli 4-wymiarowy wektor typu float, który jest ostateczną pozycją wierzchołka na ekranie. Samo w sobie jest to dość interesujący proces, ponieważ naprawdę mówimy o umieszczeniu trójwymiarowego położenia (wierzchołka z x, y, z) na lub rzutowanie na ekran 2D. Na szczęście, jeśli używamy np. Three.js, mamy skrót do ustawiania zmiennej gl_Position bez konieczności wprowadzania zbyt wielu zmian.

3. Fragmenty shaderów

Mamy obiekt z wierzchołkami i jego projekcję na ekran 2D, ale co z kolorami? A co z teksturowaniem i oświetleniem? Dokładnie do tego służy fragment shader. Mechanizm cieniowania wierzchołków, podobnie jak cieniowanie wierzchołków, ma tylko jedno zadanie: musi ustawić lub odrzucić zmienną gl_FragColor – kolejny wektor zmiennoprzecinkowy 4D, który określa ostateczny kolor naszego fragmentu. Ale czym jest fragment? Pomyśl o 3 wierzchołkach, które tworzą trójkąt. Każdy piksel w tym trójkącie trzeba narysować. Fragment to dane dostarczane przez te 3 wierzchołki na potrzeby rysowania każdego piksela w trójkącie. Z tego powodu fragmenty otrzymują interpolowane wartości ze swoich wierzchołków cząstkowych. Jeśli jeden wierzchołek jest czerwony, a jego sąsiad niebieski, zobaczymy, że wartości kolorów są interpolowane od czerwonego przez fioletowy do niebieskiego.

4. Zmienne cieni

Jeśli chodzi o zmienną, możesz użyć 3 deklaracji: Uniforms, AttributesVaryings. Gdy po raz pierwszy usłyszałem o tych 3 elementach, byłem bardzo zdezorientowany, ponieważ nie pasowały do niczego, z czym pracowałem wcześniej. Oto jak możesz je wykorzystać:

  1. Uniformy są wysyłane do obu shaderów wierzchołków i fragmentów oraz zawierają wartości, które pozostają takie same w całym renderowanym ujęciu. Dobrym przykładem może być pozycja światła.

  2. Atrybuty to wartości stosowane do poszczególnych wierzchołków. Atrybuty są dostępne tylko dla shadera wierzchołkowego. Może to być np. kolorowanie wierzchołków. Atrybuty są powiązane z wierzchołkami w relacji 1 do 1.

  3. Zmienna to zmienna zadeklarowana w shaderze wierzchołka, którą chcemy udostępnić shaderowi fragmentów. Aby to zrobić, deklarujemy zmienną o tym samym typie i tej samej nazwie zarówno w shaderze wierzchołka, jak i w shaderze fragmentu. Klasycznym zastosowaniem jest normalna wartość wierzchołka, ponieważ może ona być używana w obliczeniach oświetlenia.

Później użyjemy wszystkich 3 typów, aby pokazać Ci, jak są one stosowane w praktyce.

Omówiliśmy już shadery wierzchołkowe i fragmentowe oraz typy zmiennych, z którymi się one mają do czynienia. Teraz warto przyjrzeć się najprostszym shaderom, jakie możemy utworzyć.

5. Bonjourno World

Oto „Witaj świecie” w języku shaderów wierzchołkowych:

/**
* Multiply each vertex by the model-view matrix
* and the projection matrix (both provided by
* Three.js) to get a final vertex position
*/
void main() {
gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}   

a tak wygląda shader fragmentu:

/**
* Set the colour to a lovely pink.
* Note that the color is a 4D Float
* Vector, R,G,B and A and each part
* runs from 0.0 to 1.0
*/
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}

Nie jest to zbyt skomplikowane, prawda?

Do cieniowania wierzchołków otrzymujemy od Three.js kilka mundurów. Te dwa młoty to macierze 4D, nazywane macierzami widoków modeli i macierzymi projekcji. Nie musisz dokładnie znać sposobu działania tych elementów, ale zawsze warto wiedzieć, jak działają. Krótko mówiąc, są to współrzędne 3D wierzchołka, które są przekształcane na ostateczną pozycję 2D na ekranie.

Nie dodałem ich do kodu powyżej, ponieważ Three.js dodaje je na początku kodu shadera, więc nie musisz się o to martwić. W zasadzie dodaje on znacznie więcej niż tylko te informacje, np. dane o świecie, kolory wierzchołków i normalne wierzchołki. Jeśli nie używasz Three.js, musisz samodzielnie utworzyć i ustawić wszystkie te uniformy i atrybuty. Prawdziwa historia.

6. Korzystanie z MeshShaderMaterial

Skonfigurowaliśmy program do cieniowania, ale jak go używać z Three.js? Okazuje się, że to bardzo proste. Wygląda to mniej więcej tak:

/**
* Assume we have jQuery to hand and pull out
* from the DOM the two snippets of text for
* each of our shaders
*/
var shaderMaterial = new THREE.MeshShaderMaterial({
vertexShader:   $('vertexshader').text(),
fragmentShader: $('fragmentshader').text()
});

Następnie Three.js skompiluje i uruchomi shadery dołączone do siatki, której przypisujesz ten materiał. To naprawdę proste. Prawdopodobnie tak, ale mówimy o modelach 3D w przeglądarce, więc zakładam, że oczekujesz pewnego stopnia złożoności.

Do materiału MeshShaderMaterial możemy dodać jeszcze 2 właściwości: uniformy i atrybuty. Mogą one przyjmować wektory, liczby całkowite lub zmiennoprzecinkowe, ale jak już wspomniałem, uniformy są takie same dla całego kadru, czyli dla wszystkich wierzchołków, więc zazwyczaj są to pojedyncze wartości. Atrybuty są jednak zmiennymi na wierzchołek, więc powinny być tablicami. Liczba wartości w tablicy atrybutów powinna być zgodna z liczbą wierzchołków w siatce.

7. Następne kroki

Teraz poświęcimy trochę czasu na dodanie pętli animacji, atrybutów wierzchołka i jednolitej tkanki. Dodamy też zmienną, która będzie się zmieniać, aby shader wierzchołkowy mógł przesyłać pewne dane do shadera fragmentów. W efekcie nasza kula, która była różowa, będzie oświetlona od góry i z boków oraz będzie pulsować. To może być trochę dziwne, ale mam nadzieję, że dzięki temu lepiej zrozumiesz 3 typy zmiennych oraz ich wzajemne zależności i zależności od geometrii.

8. Fałszywe światło

Zmieńmy kolorystykę, aby nie była płaska. Możemy sprawdzić, jak Three.js obsługuje oświetlenie, ale jak zapewne się domyślasz, jest to bardziej skomplikowane niż to, czego potrzebujemy teraz, więc zamiast tego użyjemy fałszywego oświetlenia. Koniecznie zapoznaj się z fantastycznymi shaderami, które są częścią Three.js, a także z tymi z niesamowitego projektu WebGL autorstwa Chrisa Milka i Google, Rome. Wracając do tematu shaderów. Zaktualizujemy nasz shader wierzchołkowy, aby przekazywał do shadera fragmentowego wierzchołkowe wartości normalne każdego wierzchołka. Robimy to na różne sposoby:

// create a shared variable for the
// VS and FS containing the normal
varying vec3 vNormal;

void main() {

// set the vNormal value with
// the attribute value passed
// in by Three.js
vNormal = normal;

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(position,1.0);
}

W shaderze fragmentu użyjemy tej samej nazwy zmiennej, a potem zastosujemy iloczyn skalarny normalnej wierzchołka z wektorem, który reprezentuje światło padające z góry na prawą stronę kuli. Ostatecznie daje to efekt podobny do światła kierunkowego w pakiecie 3D.

// same name and type as VS
varying vec3 vNormal;

void main() {

// calc the dot product and clamp
// 0 -> 1 rather than -1 -> 1
vec3 light = vec3(0.5,0.2,1.0);
    
// ensure it's normalized
light = normalize(light);

// calculate the dot product of
// the light to the vertex normal
float dProd = max(0.0, dot(vNormal, light));

// feed into our frag colour
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);

}

Wynika to z tego, że iloczyn skalarny 2 wektorów daje liczbę, która mówi, jak „podobne” są te 2 wektory. W przypadku wektorów znormalizowanych, jeśli wskazują dokładnie w ten sam kierunek, otrzymujesz wartość 1. Jeśli wskazują w przeciwnych kierunkach, otrzymasz wartość -1. Używamy tej liczby do oświetlenia. Zatem wierzchołek w prawym górnym rogu będzie miał wartość 1 lub równa 1, tj. wierzchołek z boku będzie miał wartość bliską 0, a zaokrąglenie z tyłu – -1. W przypadku wartości ujemnych wartość jest ograniczana do 0, ale po podaniu liczb uzyskujemy podstawowe oświetlenie.

Co dalej? Dobrze byłoby poeksperymentować z położeniem wierzchołków.

9. Atrybuty

Teraz należy dołączyć liczbę losową do każdego wierzchołka za pomocą atrybutu. Użyjemy tej liczby, aby przesunąć wierzchołek wzdłuż jego normalnej. Efektem będzie dziwna piłka, która będzie się zmieniać po każdym odświeżeniu strony. Nie będzie jeszcze animowany (to nastąpi później), ale po kilku odświeżeniach strony zobaczysz, że jest losowany.

Zacznij od dodania atrybutu do shadera wierzchołka:

attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// push the displacement into the three
// slots of a 3D vector so it can be
// used in operations with other 3D
// vectors like positions and normals
vec3 newPosition = position + 
                    normal * 
                    vec3(displacement);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

Jak to wygląda?

Niewiele się różni. Dzieje się tak, ponieważ atrybut nie został skonfigurowany w MeshShaderMaterial, więc shader używa wartości zero. To jest teraz rodzaj zastępnika. Za chwilę dodamy ten atrybut do MeshShaderMaterial w JavaScript, a Three.js automatycznie połączy je ze sobą.

Należy też pamiętać, że musiałem przypisać zaktualizowaną pozycję do nowej zmiennej vec3, ponieważ oryginalny atrybut, jak wszystkie atrybuty, jest tylko do odczytu.

10. Aktualizowanie materiału MeshShader

Przejdźmy od razu do aktualizacji materiału MeshShaderMaterial atrybutem potrzebnym do obsługi przemieszczeń. Przypomnienie: atrybuty to wartości odwzorowań wierzchołkowych, dlatego potrzebujemy po jednej wartości na wierzchołek sfery. W ten sposób:

var attributes = {
displacement: {
    type: 'f', // a float
    value: [] // an empty array
}
};

// create the material and now
// include the attributes property
var shaderMaterial = new THREE.MeshShaderMaterial({
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

// now populate the array of attributes
var vertices = sphere.geometry.vertices;
var values = attributes.displacement.value
for(var v = 0; v < vertices.length; v++) {
values.push(Math.random() * 30);
}

Teraz widzimy zniekształconą kulę, ale całe przemieszczenie odbywa się na GPU.

11. Animacja tego seksa

Powinniśmy stworzyć animację. Jak to robimy? Trzeba dopracować 2 rzeczy:

  1. Uniform do animowania, jak duże przesunięcie powinno być stosowane w każdej klatce. Możemy do tego użyć sinusa lub cosinusa, bo mają one zakres od -1 do 1
  2. pętla animacji w JS.

Dodamy uniform do materiału MeshShaderMaterial i do shadera wierzchołkowego. Najpierw shader wierzchołkowy:

uniform float amplitude;
attribute float displacement;
varying vec3 vNormal;

void main() {

vNormal = normal;

// multiply our displacement by the
// amplitude. The amp will get animated
// so we'll have animated displacement
vec3 newPosition = position + 
                    normal * 
                    vec3(displacement *
                        amplitude);

gl_Position = projectionMatrix *
                modelViewMatrix *
                vec4(newPosition,1.0);
}

Następnie aktualizujemy MeshShaderMaterial:

// add a uniform for the amplitude
var uniforms = {
amplitude: {
    type: 'f', // a float
    value: 0
}
};

// create the final material
var shaderMaterial = new THREE.MeshShaderMaterial({
uniforms:       uniforms,
attributes:     attributes,
vertexShader:   $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});

Na razie nasze shadery są gotowe. Wydaje się jednak, że zrobiliśmy krok w tył. Dzieje się tak głównie dlatego, że wartość amplitudy wynosi 0, a ponieważ mnożymy ją przez przemieszczenie, nie widzimy żadnej zmiany. Nie skonfigurowaliśmy też pętli animacji, więc nigdy nie zobaczymy, że wartość 0 zmieni się na inną.

W kodzie JavaScript musimy teraz umieścić wywołanie render w ramach funkcji, a potem wywołać ją za pomocą metody requestAnimationFrame. W tym miejscu trzeba też zaktualizować wartość uniformu.

var frame = 0;
function update() {

// update the amplitude based on
// the frame value
uniforms.amplitude.value = Math.sin(frame);
frame += 0.1;

renderer.render(scene, camera);

// set up the next call
requestAnimFrame(update);
}
requestAnimFrame(update);

12. Podsumowanie

To wszystko. Widać, że wiruje w dziwny (i nieco drżący) sposób.

Mamy jeszcze wiele do powiedzenia na temat shaderów, ale mam nadzieję, że ten wstęp był dla Ciebie przydatny. Teraz powinieneś/powinnaś rozumieć shadery, gdy je widzisz, a także mieć pewność, że potrafisz tworzyć własne niesamowite shadery.