🎨 Програмування шейдерів GLSL: пишемо свій перший фрагментний шейдер

Шейдери — це програми, які виконуються на GPU, обробляючи кожен піксель екрана паралельно й одночасно. Саме тому множина Мандельброта може відрендеритись за мілісекунди, а симуляції рідини — досягати тисяч частинок при 60 кадрах за секунду. Розуміння шейдерів відкриває всю потужність графіки реального часу.

Конвеєр GPU: вершинні та фрагментні шейдери

Сучасні GPU обробляють графіку через програмований конвеєр. Два етапи безпосередньо програмуються за допомогою GLSL (OpenGL Shading Language): вершинний шейдер та фрагментний шейдер.

Вершинний шейдер виконується один раз для кожної вершини вашої геометрії. Він перетворює 3D-позиції на 2D-координати екрана за допомогою матриць проєкції і може передавати довільні дані (текстурні координати, нормалі, кольори) на наступні етапи. Його головний вихід — gl_Position, позиція вершини у просторі відсікання (clip space).

Фрагментний шейдер виконується один раз для кожного пікселя, покритого вашою геометрією. Він отримує інтерпольовані значення від вершинного шейдера і виводить фінальний колір для цього пікселя. Растеризатор автоматично інтерполює будь-які змінні varying між вершинами — тож якщо вершина A має UV-координату (0,0), а вершина B — UV (1,0), піксель посередині між ними отримає UV (0.5, 0).

// Мінімальний фрагментний шейдер
precision mediump float;
varying vec2 vUv;      // інтерпольований UV від вершинного шейдера
uniform float uTime;   // значення часу, передане з CPU

void main() {
  // Колір на основі UV-координат
  gl_FragColor = vec4(vUv.x, vUv.y, sin(uTime) * 0.5 + 0.5, 1.0);
}

UV-координати та одиничний квадрат

UV-координати (також звані текстурними координатами) відображають вашу геометрію на 2D-простір, зазвичай [0,1] × [0,1]. Літера U відповідає горизонтальній осі, а V — вертикальній. Коли ви семплюєте текстуру за UV (0.5, 0.5), ви отримуєте піксель у центрі текстури.

Для повноекранного квада — стандартної конфігурації для шейдерів у стилі обчислень — UV змінюється від (0,0) у нижньому лівому куті до (1,1) у верхньому правому. Нормалізація до діапазону [-1,1] з урахуванням співвідношення сторін дає vec2 p = (vUv - 0.5) * vec2(aspectRatio, 1.0) * 2.0 — систему координат, центровану на екрані, корисну для симетричних візерунків.

Функції шуму: робочий інструмент художника

Процедурні шейдери значною мірою покладаються на функції шуму — псевдовипадкові значення, що плавно змінюються в просторі й часі. Найважливіші з них:

// Класичний значеннєвий шум у 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
  );
}

Фрактальний шум (fBm — фракційний броунівський рух) накладає кілька октав шуму з різними частотами й амплітудами. Кожна октава подвоює частоту й зменшує амплітуду вдвічі: fbm += amplitude * noise(p); p *= 2.0; amplitude *= 0.5;. Це імітує природні самоподібні візерунки — хмари, рельєф місцевості, турбулентність.

Функції знакової відстані та raymarching

Raymarching — це техніка рендерингу, яка трасує промені крізь сцену, описану функціями знакової відстані (SDF, Signed Distance Functions). SDF повертає знакову відстань від будь-якої точки до найближчої поверхні — додатну зовні, від'ємну всередині. Для сфери радіуса r з центром у початку координат: float sdf = length(p) - r;.

Цикл raymarching просуває точку вздовж променя на значення SDF на кожному кроці. Оскільки SDF повідомляє мінімальний безпечний розмір кроку (можна рухатися принаймні на цю відстань, не перетнувши поверхню), алгоритм одночасно ефективний і точний:

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;  // влучання!
    t += d;                    // безпечно зробити крок на d
    if (t > 100.0) break;     // максимальна відстань
  }
  return -1.0; // промах
}

SDF елегантно комбінуються: min(sdfA, sdfB) — це об'єднання, max(sdfA, -sdfB) — віднімання, а max(sdfA, sdfB) — перетин. Плавне змішування між формами — техніка, яка називається гладким мінімумом (smooth minimum) — створює органічне злиття: 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); }.

Як mysimulator використовує GLSL

Симулятор множини Мандельброта працює повністю у фрагментному шейдері — CPU лише малює один прямокутник на весь екран і передає рівень масштабування, положення центра та кількість ітерацій як uniform-параметри. Шейдер відображає кожен піксель на комплексне число c = (uv * scale) + center, ітерує z = z² + c і забарвлює піксель залежно від часу «втечі». Саме ця паралельність дозволяє інтерактивно масштабувати ділянки з астрономічно глибокою математичною складністю.

Симуляції рідини використовують кілька проходів рендерингу: один шейдер переносить (адвектує) швидкість, інший обчислює дивергенцію, шейдер ітерації Якобі розв'язує рівняння для тиску, ще один проєктує швидкість так, щоб вона стала бездивергентною, а фінальний шейдер рендерить частинки або поля густини. Паралельність GPU перетворює те, що в JavaScript зайняло б секунди, на обчислення тривалістю менше мілісекунди.

Симуляція множини Мандельброта — це чистий фрагментний шейдер GLSL: кожен піксель обчислює власну ітерацію незалежно. Наблизьте зображення, щоб побачити, як той самий код шейдера породжує нескінченну структурну складність із жменьки арифметичних операцій.