Programy do cieniowania

Wcześniej wysłaliśmy Ci prezentację na temat 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ż wspominałem, Three.js (i inne biblioteki) świetnie abstrahują od 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 wejdziemy na nieco bardziej zaawansowany poziom. Wynika to z tego, że shadery są na pierwszy rzut oka nietypowe i wymagają nieco wyjaśnienia.

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. Krótko mówiąc, Programmable Pipeline oznacza, że jako programista ponosisz odpowiedzialność za renderowanie wierzchołków itp. na ekranie. Cząstki są częścią tego procesu i występują w 2 rodzajach:

  1. Błysk wierzchołkowy
  2. Programy fragmentów

Z czym się zgadzam. Musisz wiedzieć, że oba te procesy są w pełni wykonywane na procesorze graficznym karty graficznej. Oznacza to, że chcemy przekazać im wszystko, co się da, aby procesor mógł wykonywać 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 wszystkie wierzchołki i może je modyfikować. To, co shader wierzchołka robi z każdym z nich, zależy od niego, ale ma 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. To samo w sobie jest dość interesującym procesem, ponieważ chodzi o uzyskanie pozycji 3D (wierzchołka z x, y, z) na ekranie 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. Podobnie jak shader wierzchołków, shader fragmentów ma tylko jedno obowiązkowe zadanie: musi ustawić lub odrzucić zmienną gl_FragColor, która jest 4-wymiarowym wektorem zmiennoprzecinkowym i stanowi 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 musi być zaznaczony. Fragment to dane dostarczane przez te 3 wierzchołki na potrzeby rysowania każdego piksela w trójkącie. Z tego powodu fragmenty otrzymują wartości interpolowane z ich wierzchołków składowych. 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 shadera

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. kolor wierzchołka. 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żna jej używać 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?

W shaderze wierzchołka otrzymujemy kilka uniformów z Three.js. Te 2 uniformy to 4-wymiarowe macierze, zwane macierzą Model-View i macierzą 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 powyższego fragmentu kodu, 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

Mamy już skonfigurowany shader, ale jak go użyć w 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ą 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 wyglądać tak, jakby była oświetlona od góry i z boków oraz będzie pulsować. To może być trochę skomplikowane, 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 w tej chwili, 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. Współrzędna w prawym górnym rogu będzie miała wartość zbliżoną do 1, czyli będzie w pełni podświetlona, natomiast współrzędna po stronie bocznej będzie miała wartość zbliżoną do 0, a z tyłu – -1. W przypadku wartości ujemnych wartość jest ograniczana do 0, ale po podaniu liczb uzyskujemy podstawowe oświetlenie.

Co dalej? Warto spróbować zmienić pozycje niektórych wierzchołków.

9. Atrybuty

Teraz chcemy dołączyć do każdego wierzchołka losową liczbę za pomocą atrybutu. Użyjemy tej liczby, aby przesunąć wierzchołek wzdłuż jego normalnej. Otrzymasz coś w rodzaju dziwnej kuli z kolcami, która będzie się zmieniać przy 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.

Zacznijmy 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. Następnie dodamy atrybut do MeshShaderMaterial w JavaScript, a Three.js połączy te dwa elementy automatycznie.

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. Aktualizacja materiału MeshShader

Przejdźmy od razu do aktualizacji materiału MeshShaderMaterial atrybutem potrzebnym do obsługi przemieszczeń. Przypomnienie: atrybuty to wartości na wierzchołek, więc potrzebujemy jednej wartości na wierzchołek w naszej sferze. 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 zdeformowaną kulę, ale fajne jest to, że całe przekształcenie odbywa się na karcie graficznej.

11. Animacja That Sucker

Powinniśmy to animować. Jak to robimy? Musimy zrobić 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, ponieważ ich wartości mieszczą się w zakresie 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. Ale teraz wydaje się, że cofnęliśmy się o krok. 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 widzimy, że 0 zmienia się na cokolwiek innego.

W kodzie JavaScript musimy teraz umieścić wywołanie render w ramach funkcji, a potem wywołać ją za pomocą metody requestAnimationFrame. Musimy 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. Teraz możesz zobaczyć, jak animacja pulsuje w dziwny (i nieco psychodeliczny) 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.