Wprowadzenie
Najpierw przeczytaj wprowadzenie do Three.js, ponieważ stanowi ono podstawę tego dokumentu.
Chcę omówić shadery. WebGL jest świetny, a Three.js (i inne biblioteki) doskonale radzą sobie z ukrywaniem trudności. Czasami jednak będziesz chciał uzyskać określony efekt lub dowiedzieć się, jak te niesamowite rzeczy pojawiły się na ekranie, a shadery prawie na pewno będą częścią tego procesu. Możesz chcieć przejść od podstawowych rzeczy z ostatniego samouczka do czegoś nieco bardziej skomplikowanego.
Będę zakładać, że używasz Three.js, ponieważ wykonuje ona za nas wiele pracy związanej z uruchomieniem shadera. Najpierw poznasz kontekst shaderów, a następnie bardziej zaawansowane kroki opisane w tym dokumencie.
1. Nasze 2 shadery
WebGL nie oferuje użycia stałego potoku, co oznacza, że nie daje żadnych środków do renderowania rzeczy od razu. Oferuje natomiast 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 tak dalej na ekranie. Shadery są częścią tego potoku i występują w 2 typach:
- shadery wierzchołków,
- shadery fragmentów.
Oba te typy, jak na pewno się zgodzisz, same w sobie nic nie znaczą. Powinieneś wiedzieć, że oba działają w całości na procesorze graficznym karty graficznej. Oznacza to, że chcemy odciążyć je w jak największym stopniu, aby procesor mógł wykonywać inne zadania. Współczesny procesor graficzny jest zoptymalizowany pod kątem funkcji wymaganych przez shadery, więc warto go używać.
2. Shadery wierzchołków
Weźmy standardowy kształt pierwotny, np. kulę. Składa się ona z wierzchołków, prawda? Shader wierzchołków otrzymuje każdy z tych wierzchołków po kolei i może je modyfikować. To, co shader wierzchołków robi z każdym z nich, zależy od niego, ale ma on jeden obowiązek: 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. Sam w sobie jest to dość interesujący proces, ponieważ mówimy o przeniesieniu pozycji 3D (wierzchołka z współrzędnymi x, y, z) na ekran 2D. Na szczęście, jeśli używamy czegoś takiego jak Three.js, mamy skrótowy sposób na ustawienie gl_Position bez zbytniego obciążania systemu.
3. Shadery fragmentów
Mamy więc obiekt z jego wierzchołkami i rzutowaliśmy je na ekran 2D, ale co z kolorami, których używamy? A co z teksturowaniem i oświetleniem? Właśnie do tego służy shader fragmentów. Podobnie jak shader wierzchołków, shader fragmentów ma tylko jedno zadanie: musi ustawić lub odrzucić zmienną gl_FragColor, kolejny 4-wymiarowy wektor zmiennoprzecinkowy, który jest ostatecznym kolorem 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 zostać narysowany. Fragment to dane dostarczane przez te 3 wierzchołki w celu narysowania każdego piksela w tym trójkącie. Dlatego fragmenty otrzymują interpolowane wartości z wierzchołków, z których się składają. Jeśli jeden wierzchołek jest czerwony, a jego sąsiad niebieski, zobaczymy, jak wartości kolorów interpolują się od czerwonego przez fioletowy do niebieskiego.
4. Zmienne shadera
Możesz zadeklarować 3 typy zmiennych: Uniforms, Attributes i Varyings. Mogą one być mylące, ponieważ nie pasują do żadnych innych popularnych języków. Oto jak możesz je sobie wyobrazić:
Uniforms są wysyłane do obu shaderów – wierzchołków i fragmentów – i zawierają wartości, które pozostają takie same w całym renderowanym klatce. Dobrym przykładem może być pozycja światła.
Attributes to wartości, które są stosowane do poszczególnych wierzchołków. Atrybuty są dostępne tylko dla shadera wierzchołków. Może to być np. każdy wierzchołek mający inny kolor. Atrybuty mają relację 1 do 1 z wierzchołkami.
Varying to zmienne zadeklarowane w shaderze wierzchołków, które chcemy udostępnić shaderowi fragmentów. Aby to zrobić, deklarujemy zmienną varying o tym samym typie i nazwie zarówno w shaderze wierzchołków, jak i w shaderze fragmentów. Klasycznym przykładem użycia jest normalna wierzchołka, ponieważ można jej użyć w obliczeniach oświetlenia.
Później użyjemy wszystkich 3 typów, abyś mógł się przekonać, jak są one stosowane w praktyce.
Nie, spójrz na jeden z najprostszych shaderów, jakie możesz utworzyć.
5. Bonjourno World
Oto „Hello World” shaderów wierzchołków:
/**
* 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 to samo w przypadku shadera fragmentów:
/**
* 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 zbyt skomplikowane, prawda?
W shaderze wierzchołków Three.js wysyła do nas kilka uniformów. Te 2 uniformy to macierze 4D, zwane macierzą modelu i widoku oraz macierzą projekcji. Nie musisz dokładnie wiedzieć, jak one działają, chociaż zawsze warto zrozumieć, jak coś działa. W skrócie: 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 shadera, więc nie musisz się tym martwić. Prawdę mówiąc, dodaje on znacznie więcej, np. dane oświetlenia, kolory wierzchołków i normalne wierzchołków. Gdybyś robił to bez Three.js, musiałbyś samodzielnie utworzyć i ustawić wszystkie te uniformy i atrybuty. Prawda.
6. Używanie MeshShaderMaterial
OK, mamy skonfigurowany shader, ale jak go używać w 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()
});
Three.js skompiluje i uruchomi shadery dołączone do siatki, do której przypiszesz ten materiał. Nie może być łatwiej. Pewnie może, ale mówimy o 3D działającym w przeglądarce, więc pewnie spodziewasz się pewnej złożoności.
Do MeshShaderMaterial możemy dodać 2 kolejne właściwości: uniforms i attributes. Obie mogą przyjmować wektory, liczby całkowite lub zmiennoprzecinkowe, ale jak wspomniałem wcześniej, uniformy są takie same dla całej klatki lub dla wszystkich wierzchołków, więc zwykle są to pojedyncze wartości. Atrybuty są jednak zmiennymi na wierzchołek, więc powinny być tablicą. Liczba wartości w tablicy atrybutów powinna być równa liczbie wierzchołków w siatce.
7. Następne kroki
Teraz poświęcimy trochę czasu na dodanie pętli animacji, atrybutów wierzchołków i uniformu. Dodamy też zmienną varying, aby shader wierzchołków mógł wysyłać dane do shadera fragmentów. W efekcie nasza różowa kula będzie wyglądać, jakby była oświetlona od góry i z boku, i będzie pulsować. To trochę dziwne, ale mam nadzieję, że pomoże Ci dobrze zrozumieć 3 typy zmiennych oraz ich wzajemne relacje i geometrię.
8. Fałszywe światło
Teraz zaktualizujmy kolorowanie, aby nie był to obiekt o jednolitym kolorze. Moglibyśmy przyjrzeć się, jak Three.js obsługuje oświetlenie, ale jak na pewno się zgodzisz, jest to bardziej skomplikowane niż potrzebujemy, więc będziemy je symulować. Powinieneś przejrzeć fantastyczne shadery, które są częścią Three.js, a także te z niesamowitego projektu WebGL Chrisa Milka i Google, Rome. Wróćmy do naszych shaderów. Zaktualizujemy shader wierzchołków, aby przekazywał do shadera fragmentów normalną każdego wierzchołka. Zrobimy to za pomocą zmiennej varying:
// 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 fragmentów ustawimy tę samą nazwę zmiennej, a następnie użyjemy iloczynu skalarnego normalnej wierzchołka z wektorem, który reprezentuje światło świecące z góry i z prawej strony kuli. W efekcie uzyskamy 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 informuje, jak „podobne” są te 2 wektory. W przypadku znormalizowanych wektorów, jeśli wskazują dokładnie ten sam kierunek, otrzymasz wartość 1. Jeśli wskazują przeciwne kierunki, otrzymasz -1. Bierzemy tę liczbę i stosujemy ją do oświetlenia. Wierzchołek w prawym górnym rogu będzie miał wartość bliską lub równą 1 (w pełni oświetlony), natomiast wierzchołek z boku będzie miał wartość bliską 0, a z tyłu -1. Wszystkie wartości ujemne ograniczamy do 0, ale gdy wpiszesz liczby, uzyskasz podstawowe oświetlenie, które widzimy.
Co dalej? Może warto spróbować zmodyfikować pozycje wierzchołków.
9. Atrybuty
Teraz chcemy przypisać do każdego wierzchołka losową liczbę za pomocą atrybutu. Użyjemy tej liczby, aby przesunąć wierzchołek wzdłuż jego normalnej. W efekcie otrzymamy dziwną kulę z kolcami, która będzie się zmieniać przy każdym odświeżeniu strony. Nie będzie jeszcze animowana (to nastąpi później), ale kilka odświeżeń strony pokaże, że jest losowa.
Zacznij od dodania atrybutu do shadera 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?
Niewiele się zmieniło. Dzieje się tak, ponieważ atrybut nie został skonfigurowany w MeshShaderMaterial, więc shader używa wartości zero. Na razie jest to rodzaj symbolu zastępczego. Za chwilę dodamy atrybut do MeshShaderMaterial w 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
Od razu zaktualizujmy MeshShaderMaterial o atrybut potrzebny do obsługi przesunięcia. Przypominamy, że atrybuty to wartości na wierzchołek, więc potrzebujemy 1 wartości na wierzchołek w naszej kuli. 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 przesunięcie odbywa się na procesorze graficznym.
11. Zanimuj
Powinniśmy to animować. Jak to zrobić? Musimy wprowadzić 2 zmiany:
- Uniform, który będzie animować, o ile przesunięcie ma być stosowane w każdej klatce. Możemy do tego użyć sinusa lub cosinusa, ponieważ przyjmują wartości od -1 do 1.
- Pętla animacji w JS.
Dodamy uniform do MeshShaderMaterial i shadera wierzchołków. Najpierw shader wierzchołków:
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 zaktualizujemy 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 skończyliśmy z shaderami. Wydaje się jednak, że cofnęliśmy się o krok. Dzieje się tak głównie dlatego, że wartość amplitudy wynosi 0, a ponieważ mnożymy ją przez przesunięcie, nie widzimy żadnych zmian. Nie skonfigurowaliśmy też pętli animacji, więc nigdy nie zobaczymy, jak 0 zmienia się na coś innego.
W JavaScript musimy teraz opakować wywołanie renderowania w funkcję, a następnie użyć requestAnimationFrame, aby ją wywołać. 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);
Podsumowanie
Gotowe! Teraz widzisz, że animacja jest dziwna (i trochę psychodeliczna) i pulsacyjna.
Na temat shaderów można powiedzieć o wiele więcej, ale mam nadzieję, że to wprowadzenie było pomocne. Powinieneś teraz rozumieć shadery, gdy je widzisz, i mieć pewność, że możesz tworzyć własne niesamowite shadery.