Programy do cieniowania

Wprowadzenie

Najpierw przeczytaj wprowadzenie do Three.js, ponieważ stanowi ono podstawę tego dokumentu.

Chcę porozmawiać o cieniowaniu. WebGL to świetne rozwiązanie, a jak już wspominałem, Three.js (i inne biblioteki) doskonale radzi sobie z upraszczaniem trudnych kwestii. Czasami jednak chcesz osiągnąć konkretny efekt lub dowiedzieć się, jak te niesamowite rzeczy pojawiły się na ekranie. W takich przypadkach shadery prawie na pewno będą częścią równania. Możesz przejść od podstawowych informacji z ostatniego samouczka do czegoś nieco bardziej skomplikowanego.

Założę, że używasz Three.js, ponieważ wykonuje on za nas wiele pracy związanej z uruchomieniem cieniowania. Najpierw poznasz kontekst cieniowania, a później w tym dokumencie znajdziesz zaawansowane instrukcje.

1. Nasze 2 shadery

WebGL nie oferuje stałego potoku, co oznacza, że nie zapewnia żadnych gotowych środków do renderowania treści. Zamiast tego oferuje programowalny potok, który jest bardziej wydajny, ale też trudniejszy do zrozumienia i użycia. Krótko mówiąc, programowalny potok oznacza, że jako programista bierzesz odpowiedzialność za renderowanie wierzchołków i innych elementów na ekranie. Cieniowanie jest częścią tego potoku i może być 2 rodzajów:

  1. shadery wierzchołków,
  2. Fragment shaders

Oba te pojęcia same w sobie nic nie znaczą. Warto wiedzieć, że oba te narzędzia działają całkowicie na GPU karty graficznej. Oznacza to, że chcemy odciążyć procesor, przenosząc jak najwięcej zadań na GPU. Nowoczesny procesor GPU jest mocno zoptymalizowany pod kątem funkcji, których wymagają shadery, więc warto go używać.

2. shadery wierzchołków,

Weź standardowy kształt podstawowy, np. kulę. Składa się z wierzchołków, prawda? Moduł cieniowania wierzchołków otrzymuje po kolei każdy z tych wierzchołków i może je modyfikować. To, co vertex shader robi z każdym z nich, zależy od niego, ale ma on jedno zadanie: w pewnym momencie musi ustawić coś, co nazywa się gl_Position, czyli 4-wymiarowy wektor zmiennoprzecinkowy, który jest ostateczną pozycją wierzchołka na ekranie. To samo w sobie jest dość interesującym procesem, ponieważ mówimy o uzyskaniu pozycji 3D (wierzchołka z współrzędnymi x, y, z) na ekranie 2D lub rzutowaniu jej na ekran 2D. Na szczęście, jeśli używamy czegoś takiego jak Three.js, mamy skrócony sposób ustawiania gl_Position bez nadmiernego obciążania.

3. Fragment shaders

Mamy więc obiekt z wierzchołkami i wyświetlamy go na ekranie 2D, ale co z kolorami, których używamy? A co z teksturami i oświetleniem? To jest dokładnie to, do czego służy fragment shader. Podobnie jak w przypadku shadera wierzchołków, shader fragmentów ma tylko jedno zadanie: musi ustawić lub odrzucić zmienną gl_FragColor, czyli kolejny 4-wymiarowy wektor zmiennoprzecinkowy, który jest ostatecznym kolorem naszego fragmentu.

Czym jednak jest fragment? Pomyśl o 3 wierzchołkach, które tworzą trójkąt. Każdy piksel w tym trójkącie musi zostać narysowany. Fragment to dane dostarczane przez te 3 wierzchołki w celu narysowania każdego piksela w tym trójkącie. Z tego powodu fragmenty otrzymują interpolowane wartości z wierzchołków składowych. Jeśli jeden wierzchołek jest czerwony, a sąsiadujący z nim niebieski, wartości kolorów będą interpolowane od czerwonego przez fioletowy do niebieskiego.

4. Zmienne cieniowania

Możesz utworzyć 3 deklaracje zmiennych: Uniforms, Attributes i Varyings. Może to być mylące, ponieważ nie pasują do żadnych innych popularnych języków. Oto jak możesz o nich myśleć:

  1. Zmienne uniform są wysyłane zarówno do programów do cieniowania wierzchołków, jak i do programów do cieniowania fragmentów. Zawierają one wartości, które pozostają takie same w całej renderowanej klatce. 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 vertex shadera. Może to być na przykład inny kolor każdego wierzchołka. Atrybuty mają relację jeden do jednego z wierzchołkami.

  3. Zmienne to zmienne zadeklarowane w programie do cieniowania wierzchołków, które chcemy udostępnić programowi do cieniowania fragmentów. Aby to zrobić, musimy zadeklarować zmienną varying tego samego typu i o tej samej nazwie zarówno w programie do cieniowania wierzchołków, jak i w programie do cieniowania fragmentów. Klasycznym zastosowaniem jest wektor normalny wierzchołka, ponieważ można go użyć w obliczeniach oświetlenia.

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

Nie, spójrz na jeden z najprostszych shaderów, jakie można utworzyć.

5. Bonjourno World

Oto przykład „Hello World” dla obiektów cieniujących wierzchołki:

/**
* 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 oto ten sam kod dla fragmentu kodu cieniowania:

/**
* Set the color 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 jednak zbyt skomplikowane, prawda?

W programie do cieniowania wierzchołków Three.js wysyła nam kilka zmiennych uniform. Te 2 zmienne uniform to macierze 4D, nazywane macierzą widoku modelu i macierzą projekcji. Nie musisz dokładnie wiedzieć, jak działają te elementy, chociaż zawsze warto rozumieć, jak coś działa, jeśli jest to możliwe. W skrócie: to dzięki nim pozycja 3D wierzchołka jest rzutowana na ostateczną pozycję 2D na ekranie.

Pominąłem je w przykładowym fragmencie kodu, ponieważ Three.js dodaje je na początku kodu cieniowania, więc nie musisz się tym przejmować. Prawdę mówiąc, dodaje on znacznie więcej, np. dane o oświetleniu, kolory i wektory normalne wierzchołków. Jeśli nie używasz Three.js, musisz samodzielnie utworzyć i ustawić wszystkie te zmienne i atrybuty. To stuprocentowa prawda.

6. Używanie MeshShaderMaterial

OK, mamy skonfigurowany obiekt cieniujący, ale jak go używać z Three.js?

/**
* 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, do której przypisano dany materiał. To naprawdę proste. Prawdopodobnie tak, ale mówimy o grafice 3D działającej w przeglądarce, więc pewnie spodziewasz się pewnego poziomu złożoności.

Do MeshShaderMaterial możemy dodać jeszcze 2 właściwości: uniforms i attributes. Oba mogą przyjmować wektory, liczby całkowite lub zmiennoprzecinkowe, ale jak wspomniałem wcześniej, zmienne uniform są takie same dla całej klatki lub wszystkich wierzchołków, więc zwykle są to pojedyncze wartości. Atrybuty są jednak zmiennymi dla poszczególnych wierzchołków, więc powinny być tablicą. Powinna istnieć relacja jeden do jednego między liczbą wartości w tablicy atrybutów a liczbą wierzchołków w siatce.

7. Dalsze kroki

Teraz poświęcimy trochę czasu na dodanie pętli animacji, atrybutów wierzchołków i uniformu. Dodamy też zmienną varying, aby program do cieniowania wierzchołków mógł wysyłać dane do programu do cieniowania fragmentów. W efekcie nasza różowa kula będzie wyglądać, jakby była oświetlona z góry i z boku, i będzie pulsować. Może to być nieco skomplikowane, ale mam nadzieję, że pomoże Ci zrozumieć te 3 typy zmiennych oraz ich wzajemne relacje i podstawową geometrię.

8. Fałszywy jasny

Teraz zmień kolorystykę, aby obiekt nie był jednokolorowy. Możemy przyjrzeć się, jak Three.js obsługuje oświetlenie, ale jak się domyślasz, jest to bardziej skomplikowane, niż potrzebujemy, więc będziemy udawać. Koniecznie przejrzyj fantastyczne shadery, które są częścią Three.js, a także te z niesamowitego projektu WebGL autorstwa Chrisa Milka i Google, Rome. Wróćmy do naszych cieniowania. Zaktualizujemy nasz Vertex Shader, aby przekazywał wektor normalny każdego wierzchołka do Fragment Shadera. Robimy to za pomocą różnych:

// 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 Fragment Shaderze ustawimy tę samą nazwę zmiennej, a następnie użyjemy iloczynu skalarnego wektora normalnego wierzchołka z wektorem, który reprezentuje światło padające z góry i z prawej strony kuli. W rezultacie uzyskujemy 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 color
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);

}

Iloczyn skalarny działa, ponieważ dla 2 wektorów daje liczbę, która wskazuje, jak bardzo są one „podobne”. W przypadku wektorów znormalizowanych, jeśli wskazują dokładnie ten sam kierunek, otrzymasz wartość 1. Jeśli wskazują przeciwne kierunki, otrzymujesz -1. Następnie stosujemy tę liczbę do oświetlenia. Wierzchołek w prawym górnym rogu będzie miał wartość zbliżoną do 1 lub równą 1 (pełne oświetlenie), natomiast wierzchołek z boku będzie miał wartość zbliżoną do 0, a z tyłu – -1. Wartości ujemne są zaokrąglane do 0, ale po podstawieniu liczb otrzymujemy podstawowe oświetlenie, które widzimy.

Co dalej? Można by spróbować zmienić pozycje niektórych wierzchołków.

9. Atrybuty

Teraz chcę, abyśmy dołączyli losową liczbę do każdego wierzchołka za pomocą atrybutu. Użyjemy tej liczby, aby wypchnąć wierzchołek wzdłuż jego normalnej. W efekcie powstanie dziwna kolczasta kula, 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 losowy.

Zacznij od dodania atrybutu do vertex shadera:

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 wygląda?

Nie ma dużej różnicy. Dzieje się tak, ponieważ atrybut nie został skonfigurowany w MeshShaderMaterial, więc moduł cieniujący używa wartości 0. Na razie jest to coś w rodzaju obiektu zastępczego. Za chwilę dodamy atrybut do MeshShaderMaterial w kodzie JavaScript, a Three.js automatycznie połączy te 2 elementy.

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

10. Aktualizowanie MeshShaderMaterial

Przejdźmy od razu do aktualizacji MeshShaderMaterial za pomocą atrybutu potrzebnego do obsługi przemieszczenia. Przypomnienie: atrybuty to wartości dla poszczególnych wierzchołków, więc potrzebujemy po jednej wartości dla każdego wierzchołka 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 zniekształconą kulę, ale fajne jest to, że całe przemieszczenie odbywa się na GPU.

11. Zanimuj

Powinniśmy dodać do tego animację. Jak to robimy? Musimy zrobić 2 rzeczy:

  1. Zmienna uniform do animowania wielkości przesunięcia, które powinno być stosowane w każdej klatce. Możemy użyć sinusa lub cosinusa, ponieważ ich wartości mieszczą się w zakresie od -1 do 1.
  2. pętla animacji w JS,

Dodamy zmienną uniform zarówno do MeshShaderMaterial, jak i Vertex Shader. Najpierw Vertex Shader:

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 to wszystko, jeśli chodzi o shadery. Ale wygląda na to, że zrobiliśmy krok wstecz. Dzieje się tak głównie dlatego, że wartość amplitudy wynosi 0, a ponieważ mnożymy ją przez przemieszczenie, nie widzimy żadnych zmian. Nie skonfigurowaliśmy też pętli animacji, więc nie widzimy, żeby wartość 0 się zmieniała.

W kodzie JavaScript musimy teraz opakować wywołanie renderowania w funkcję, a następnie użyć requestAnimationFrame, aby je wywołać. W tym miejscu musimy też zaktualizować wartość zmiennej uniform.

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

Podsumowanie

To wszystko. Teraz możesz zobaczyć, że animacja pulsuje w dziwny (i nieco psychodeliczny) sposób.

Temat cieniowania jest bardzo szeroki, ale mam nadzieję, że to wprowadzenie było przydatne. Teraz powinieneś już rozumieć, czym są shadery, i mieć pewność, że możesz tworzyć własne niesamowite shadery.