Programy do cieniowania

Wstęp

Już wcześniej omówiliśmy Three.js. Jeśli nie czytasz, może Cię to zaciekawiło.

Chcę porozmawiać o cieniowaniu. Interfejs WebGL jest niesamowity. Jak już wspominałem, Three.js (i inne biblioteki) świetnie sobie radzi z eliminacją trudności. Czasem jednak zechcesz osiągnąć konkretny efekt albo zajdzie potrzeba głębszej analizy tego, jak niesamowite rzeczy pojawiły się na ekranie. cieniowanie na pewno w pewnym stopniu będą częścią tego równania. Możecie też przejść od podstawowych informacji z ostatniego samouczka do nieco trudniejszych. Będę pracować na podstawie tego, że używasz Three.js, bo to on wykonuje za nas dużą pracę związaną z wdrażaniem cienia. Na początku wyjaśnię, co to są cienierki, a w drugiej części tego samouczka przejdziemy do nieco bardziej zaawansowanych zagadnień. Dzieje się tak dlatego, że na pierwszy rzut oka tzw. shadery są nietypowe i wymagają nieco więcej informacji.

1. Nasi cienierzy

W WebGL nie można korzystać ze stałego potoku, czyli w skrócie mówiący, że nie umożliwia on renderowania elementów prosto z pudełka. Oferuje jednak narzędzie Programmable Pipeline, które jest wydajniejsze, ale również trudniejsze do zrozumienia i wykorzystania. W skrócie „Programmable Pipeline” oznacza, że to programista odpowiada za wyświetlanie wierzchołków i innych elementów wyświetlanych na ekranie. W skład tego potoku wchodzą 2 rodzaje Shaderów:

  1. cieniowanie Vertex
  2. cieniowanie fragmentów

Z pewnością zgadzasz się, że oba te stwierdzenia nie oznaczają niczego. Pamiętaj, że działają one w całości na GPU Twojej karty graficznej. Oznacza to, że chcemy odciążyć ich w pełni, a procesor może zająć się innymi zadaniami. Nowoczesny GPU jest mocno zoptymalizowany pod kątem funkcji, których wymagają cieniowanie, więc świetnie jest z niego korzystać.

2. Programy Vertex Shader

Przyjmij standardowy podstawowy kształt, np. kulę. To składa się z wierzchołków, tak? Każdy z nich otrzymuje po kolei każdy z tych wierzchołków, więc może się z nimi łączyć. To od narzędzia do cieniowania wierzchołka zależy od działania każdego z nich, ale ponosi on jedną odpowiedzialność: w którymś momencie musi ustawić wartość o nazwie gl_Position, czyli wektorze pływającego 4D, czyli ostatecznej pozycji wierzchołka na ekranie. Sam w sobie jest to dość interesujący proces, ponieważ tak naprawdę mówimy o umieszczeniu na ekranie 2D pozycji 3D (wierzchołka z x,y i z) na ekranie 2D. Na szczęście, jeśli używamy takiego kodu, możemy użyć krótkiego sposobu ustawiania właściwości gl_Position, dzięki czemu nie będziemy musieli wykonywać zbyt ciężkich czynności.

3. Shadery fragmentów

Mamy więc obiekt z jego wierzchołkami i wyświetliliśmy je na ekranie 2D. Ale co z kolorami, których używamy? A co z teksturowaniem i oświetleniem? Właśnie do tego służy tzw. cieniowanie fragmentów. Tak jak w przypadku programu do cieniowania wierzchołków, cieniowanie fragmentów ma tylko jedno zadanie: musi ustawić lub odrzucić zmienną gl_FragColor, kolejny wektor swobodny 4D, który stanowi ostateczny kolor fragmentu. Czym jest fragment? Pomyśl o trzech wierzchołkach, z których składa się trójkąt. Każdy piksel w tym trójkącie trzeba narysować. Fragment to dane dostarczane przez te trzy wierzchołki w celu rysowania każdego piksela w trójkącie. Z tego względu fragmenty otrzymują interpolowane wartości ze swoich wierzchołków składowych. Jeśli jeden wierzchołek jest czerwony, a sąsiad jest niebieski, wartości kolorów zmieniają się z czerwonego przez fioletowy na niebieski.

4. Zmienne Shader

Mówiąc o zmiennych, możesz złożyć 3 deklaracje: Uniforms, Atrybuty i Varyings. Gdy po raz pierwszy usłyszałem o tych 3 grupach, byłem bardzo zdezorientowany, ponieważ nie pasują one do żadnego innego, z którym wcześniej pracowałem. Oto, jak możesz o nich myśleć:

  1. Jednolite są wysyłane do zarówno cieniowania wierzchołków, jak i cieniowania fragmentów. Zawierają wartości, które pozostają takie same w całej renderowanej klatce. Dobrym przykładem może być ustawienie światła.

  2. Atrybuty to wartości stosowane do poszczególnych wierzchołków. Atrybuty są dostępne tylko dla programu cieniowania wierzchołków. Może to być coś, co przypomina każdy wierzchołek innego koloru. Atrybuty są relacją jeden do jednego z wierzchołkami.

  3. Warianty to zmienne zadeklarowane w programie do cieniowania wierzchołków, które chcemy udostępnić cieniowaniu fragmentów. W tym celu deklarujemy zmienną tego samego typu i tej samej nazwy w cieniowaniu wierzchołków i fragmentach. Tego typu rozwiązanie jest normalne, ponieważ można je wykorzystać do obliczeń oświetlenia.

Później omówimy je wszystkie, żeby przekonać się, jak stosujemy je w rzeczywistości.

Omówiliśmy już cieniowanie wierzchołków i fragmentów oraz typy zmiennych, z którymi mają do czynienia. Teraz przyjrzymy się najprostszym programom do cieniowania, jakie możemy utworzyć.

5. Świat Bonjourno

Oto Hello World of vertex Sharps:

/**
* 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);
}   

i tak samo w przypadku cieniowania fragmentów:

/**
* 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);
}

To nie jest zbyt skomplikowane, prawda?

W cieniu wierzchołków wysyłamy dwa uniformy przez Three.js. Te 2 formy to macierze 4D, zwane macierz widoku modelu i macierzy projekcji. Nie musisz dokładnie wiedzieć, jak one działają, ale najlepiej zrozumieć, jak działają. W skrócie: widać w nim, jak trójwymiarowe położenie wierzchołków jest faktycznie wyświetlane w końcowej pozycji 2D na ekranie.

Pominęliśmy je w powyższym fragmencie, ponieważ Three.js dodaje je na początku kodu do cieniowania, więc nie trzeba się tym przejmować. W rzeczywistości ta technologia wnosi znacznie więcej, na przykład dane świetlne, kolory wierzchołków i normalne wartości wierzchołkowe. Gdyby nie było to Three.js, trzeba byłoby utworzyć i ustawić wszystkie uniformy i cechy samodzielnie. Prawdziwa historia.

6. Używanie obiektu MeshShaderMaterial

Mamy już skonfigurowany program do cieniowania, ale jak użyć go z Three.js? Okazuje się, że to strasznie 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 program do cieniowania połączony z siatką, do której przekazujesz materiał. To bardzo proste. Pewnie tak, ale mówimy o 3D uruchomionej w przeglądarce, więc spodziewamy się takiej złożoności.

Do MeshShaderMaterial można dodać jeszcze 2 właściwości: uniformy i atrybuty. Oba mogą przyjmować wektory, liczby całkowite lub liczby zmiennoprzecinkowe, ale jak wspomniałem, uniformy są takie same dla całej klatki, tj. dla wszystkich wierzchołków, więc mają zwykle pojedyncze wartości. Atrybuty są zmiennymi zależnymi od wierzchołków, więc mają być tablicami. Między liczbą wartości w tablicy atrybutów a liczbą wierzchołków w siatce powinna istnieć relacja jeden do jednego.

7. Dalsze kroki

Poświęcimy teraz trochę czasu na dodanie pętli animacji, atrybutów wierzchołków i uniformu. Dodajemy też zmienną zmienną, aby cieniowanie wierzchołków mogło wysyłać dane do cieniowania fragmentów. Efektem jest to, że różowa sfera wydaje się oświetlona z góry i z boku i będzie pulsować. Jest trochę trudna, ale mam nadzieję, że pomoże Ci lepiej zrozumieć te 3 typy zmiennych oraz ich wzajemne powiązania z podstawową geometrią.

8. Sztuczne światło

Zmieńmy kolorystykę, aby nie był to jednolity obiekt. Możemy przyjrzeć się temu, jak Three.js obsługuje oświetlenie, ale z pewnością już wiecie, że to bardziej skomplikowane, niż to konieczne, więc będziemy to fałszować. Należy dokładnie przyjrzeć się fantastycznym cieniowaniu, które są częścią Three.js, a także tych z niedawnego projektu WebGL połączonego z Chrisem Milkiem i z Google Rzym. Wróćmy do cieniowania. Aktualizujemy Vertex Shader, dodając do niego wszystkie wierzchołki typowe dla cienia fragmentów. 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);
}

a w funkcji cieniowania fragmentów ustawimy tę samą nazwę zmiennej, a następnie użyjemy iloczynu skalarnego wierzchołka normalnego z wektorem, który reprezentuje światło poświaty z góry i z prawej strony kuli. W efekcie otrzymujemy efekt podobny do światła kierunkowego w trójwymiarze.

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

}

Iloczyn skalarny działa, ponieważ dla 2 wektorów otrzymuje on liczbę, która mówi, jak „podobne” są te dwa wektory. W przypadku znormalizowanych wektorów, które wskazują dokładnie w tym samym kierunku, otrzymujemy wartość 1. Jeśli będą wskazywać przeciwne kierunki, otrzymasz -1. Teraz pobieramy go i stosujemy do oświetlenia. Zatem wierzchołek w prawym górnym rogu będzie mieć wartość zbliżoną lub równą 1, tj. w pełni oświetlony, natomiast wierzchołek z boku będzie miał wartość w pobliżu 0, a zaokrąglenie do tyłu – -1. W przypadku wartości ujemnej ograniczamy wartość do 0, ale po podaniu liczby otrzymujemy podstawowe oświetlenie, które widzimy.

Co dalej? Dobrze by było pobawić się kilkoma pozycjami wierzchołków.

9. Atrybuty

Teraz za pomocą atrybutu dołącz liczbę losową do każdego wierzchołka. Użyjemy tej liczby, aby wypchnąć wierzchołek wzdłuż normalnej pozycji. W rezultacie pojawią się dziwne kolce, które będą się zmieniać po każdym odświeżeniu strony. Nie będzie ona jeszcze animowana (co nastąpi później), ale wystarczy kilka odświeżeń, aby pokazać, że jest ona losowo.

Zacznijmy od dodania atrybutu do cieniowania wierzchołków:

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?

Tak naprawdę niewiele się zmieniło! Wynika to z faktu, że atrybut nie został skonfigurowany w elemencie MeshShaderMaterial, więc funkcja Shader stosuje w zamian wartość 0. To jakby obiekt zastępczy. W ciągu sekundy dodamy atrybut do obiektu MeshShaderMaterial w JavaScripcie, a Three.js automatycznie połączy te dwa elementy.

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

10. Aktualizowanie obiektu MeshShaderMaterial

Przejdźmy do aktualizacji materiału MeshShaderMaterial z atrybutem niezbędnym do obsługi przemieszczeń. Pamiętaj: atrybuty to wartości poszczególnych wierzchołków, więc potrzebujemy po jednej wartości na każdy wierzchołek w 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);
}

Widzimy zniekształconą kulę, ale najlepsze jest to, że całe przemieszczenie odbywa się przez GPU.

11. Animowane ssanie

Powinniśmy w pełni stworzyć animację. Jak to robimy? Musimy przygotować 2 rzeczy:

  1. Jednolita wielkość animacji, która ma zostać zastosowana 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 kodzie JS.

Dodamy uniform zarówno do obiektów 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 nasze cieniowanie są gotowe. Ale po prawej stronie wygląda na to, że zrobiliśmy krok w tył. Wynika to głównie z faktu, że wartość amplitudy wynosi 0 i po pomnożeniu tej wartości przez przesunięcie, nie zaobserwujemy żadnej zmiany. Nie skonfigurowaliśmy pętli animacji, więc nigdy nie widzimy tej zmiany.

W języku JavaScript musimy teraz spakować wywołanie renderowania do funkcji, a następnie użyć metody requestAnimationFrame do jej wywołania. Tutaj też trzeba 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

Gotowe! Widać, że przesuwa się w dziwny (i nieco niepewny) ruch pulsujący.

Możesz zająć się jeszcze innymi tematami na temat cieniowania, ale mam nadzieję, że to wstęp będzie dla Ciebie przydatny. Teraz już wiesz, jak działają cieniowanie, i nie możesz się już martwić o tworzenie własnych.