🎨 Programowanie shaderów GLSL: piszemy pierwszy fragment shader

Shadery to programy uruchamiane na GPU, wykonywane równolegle dla każdego piksela ekranu jednocześnie. To dlatego zbiór Mandelbrota może wyrenderować się w ułamku milisekundy, a symulacje płynów potrafią osiągnąć tysiące cząstek przy 60 klatkach na sekundę. Zrozumienie shaderów odblokowuje pełną moc grafiki czasu rzeczywistego.

Potok GPU: vertex i fragment shadery

Nowoczesne GPU przetwarzają grafikę poprzez programowalny potok renderowania. Dwa etapy są bezpośrednio programowalne za pomocą GLSL (OpenGL Shading Language): vertex shader i fragment shader.

Vertex shader uruchamiany jest raz dla każdego wierzchołka geometrii. Przekształca pozycje 3D na współrzędne ekranowe 2D za pomocą macierzy projekcji i może przekazywać dowolne dane (współrzędne teksturowania, normalne, kolory) do kolejnych etapów. Jego głównym wynikiem jest gl_Position — pozycja wierzchołka w przestrzeni obcinania (clip space).

Fragment shader uruchamiany jest raz dla każdego piksela pokrytego przez geometrię. Otrzymuje zinterpolowane wartości z vertex shadera i zwraca finalny kolor tego piksela. Rasteryzator automatycznie interpoluje wszystkie zmienne varying pomiędzy wierzchołkami — jeśli więc wierzchołek A ma współrzędną UV (0,0), a wierzchołek B ma UV (1,0), piksel dokładnie pomiędzy nimi otrzyma UV (0.5, 0).

// Minimalny fragment shader
precision mediump float;
varying vec2 vUv;      // zinterpolowane UV z vertex shadera
uniform float uTime;   // wartość czasu przekazana z CPU

void main() {
  // Kolor na podstawie współrzędnych UV
  gl_FragColor = vec4(vUv.x, vUv.y, sin(uTime) * 0.5 + 0.5, 1.0);
}

Współrzędne UV i kwadrat jednostkowy

Współrzędne UV (nazywane też współrzędnymi teksturowania) odwzorowują geometrię na przestrzeń 2D, zwykle [0,1] × [0,1]. Litera U odpowiada osi poziomej, a V — osi pionowej. Gdy próbkujesz teksturę przy UV (0.5, 0.5), otrzymujesz piksel dokładnie w środku tekstury.

Dla pełnoekranowego quada — standardowej konfiguracji dla shaderów działających jak obliczenia — współrzędne UV zmieniają się od (0,0) w lewym dolnym rogu do (1,1) w prawym górnym. Normalizacja do zakresu [-1,1] z uwzględnieniem proporcji ekranu daje vec2 p = (vUv - 0.5) * vec2(aspectRatio, 1.0) * 2.0 — układ współrzędnych wyśrodkowany na ekranie, przydatny do symetrycznych wzorów.

Funkcje szumu: narzędzie pracy artysty

Proceduralne shadery w dużej mierze opierają się na funkcjach szumu — pseudolosowych wartościach, które zmieniają się płynnie w przestrzeni i czasie. Najważniejsze z nich to:

// Klasyczny szum wartości w GLSL
float hash(vec2 p) {
  p = fract(p * vec2(123.34, 456.21));
  p += dot(p, p + 19.19);
  return fract(p.x * p.y);
}

float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f); // smoothstep

  return mix(
    mix(hash(i + vec2(0,0)), hash(i + vec2(1,0)), u.x),
    mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), u.x),
    u.y
  );
}

Szum fraktalny (fBm — fractional Brownian motion) nakłada na siebie kilka oktaw szumu o różnych częstotliwościach i amplitudach. Każda oktawa podwaja częstotliwość i zmniejsza amplitudę o połowę: fbm += amplitude * noise(p); p *= 2.0; amplitude *= 0.5;. To naśladuje naturalne, samopodobne wzory — chmury, teren, turbulencje.

Funkcje odległości ze znakiem i raymarching

Raymarching to technika renderowania, która śledzi promienie przechodzące przez scenę opisaną za pomocą funkcji odległości ze znakiem (SDF, Signed Distance Functions). SDF zwraca odległość ze znakiem od dowolnego punktu do najbliższej powierzchni — dodatnią na zewnątrz, ujemną wewnątrz. Dla kuli o promieniu r wyśrodkowanej w początku układu współrzędnych: float sdf = length(p) - r;.

Pętla raymarchingu przesuwa punkt wzdłuż promienia o wartość SDF przy każdym kroku. Ponieważ SDF podaje minimalny bezpieczny rozmiar kroku (można przesunąć się przynajmniej o tę odległość bez przecięcia powierzchni), algorytm jest jednocześnie wydajny i dokładny:

float raymarch(vec3 ro, vec3 rd) {
  float t = 0.0;
  for (int i = 0; i < 100; i++) {
    vec3 p = ro + t * rd;
    float d = sceneSDF(p);
    if (d < 0.001) return t;  // trafienie!
    t += d;                    // bezpiecznie można wykonać krok o d
    if (t > 100.0) break;     // maksymalny dystans
  }
  return -1.0; // pudło
}

SDF-y elegancko się komponują: min(sdfA, sdfB) to suma, max(sdfA, -sdfB) to odejmowanie, a max(sdfA, sdfB) to przecięcie. Gładkie mieszanie kształtów — technika zwana gładkim minimum (smooth minimum) — tworzy organiczne łączenie: float smin(float a, float b, float k) { float h = clamp(0.5 + 0.5*(b-a)/k, 0.0, 1.0); return mix(b, a, h) - k*h*(1.0-h); }.

Jak mysimulator wykorzystuje GLSL

Symulator zbioru Mandelbrota działa w całości w fragment shaderze — CPU rysuje jedynie pojedynczy prostokąt pokrywający cały ekran i przekazuje poziom przybliżenia, pozycję środka oraz liczbę iteracji jako uniformy. Shader odwzorowuje każdy piksel na liczbę zespoloną c = (uv * scale) + center, iteruje z = z² + c i koloruje piksel na podstawie czasu „ucieczki”. To właśnie ta równoległość pozwala interaktywnie przybliżać obszary o astronomicznie głębokiej złożoności matematycznej.

Symulacje płynów wykorzystują wiele przebiegów renderowania: jeden shader adwekuje prędkość, kolejny oblicza dywergencję, shader iteracji Jacobiego rozwiązuje równanie ciśnienia, następny rzutuje prędkość tak, by była bezdywergencyjna, a ostatni renderuje cząstki lub pola gęstości. Równoległość GPU sprawia, że to, co w JavaScript zajęłoby sekundy, wykonuje się w mniej niż milisekundę.

Symulacja zbioru Mandelbrota to czysty fragment shader GLSL — każdy piksel oblicza własną iterację niezależnie. Przybliż obraz, by zobaczyć, jak ten sam kod shadera generuje nieskończoną złożoność strukturalną z garści operacji arytmetycznych.