Туторіал · Рендеринг · GLSL · Шейдери
📅 Липень 2026 ⏱ ≈ 22 хв 🎯 Середній рівень

Процедурні текстури: від теорії до шейдера

Кожна мармурова стільниця, потерта дошка чи інопланетний рельєф, які ви бачили у WebGL-демо, найімовірніше, ніколи не малювалися вручну. Вони народилися з кількох шумових функцій, обчислених для кожного пікселя на GPU. Цей туторіал будує цю машинерію з перших принципів — value-шум, градієнтний (у стилі Перліна) шум, фрактальний броунівський рух, викривлення простору (domain warping) і комірки Ворлі — і перетворює кожну з них на робочий фрагментний шейдер GLSL.

1. Навіщо процедурні текстури

Мальована вручну текстура мармуру у 4K коштує місця на диску, часу завантаження і з часом починає видимо повторюватись, коли камера наближається. Процедурна текстура натомість обчислює математичну функцію f(x, y, z) → колір для кожного фрагмента. Вона не займає місця на диску, має нескінченну роздільну здатність, безшовно тайлується (якщо спроєктована так) і анімується безкоштовно — достатньо додати доданок з часом. Плата за це — кожен піксель коштує тактів GPU, тож функції нижче обрані за дешевизну, детермінованість і композиційність.

Ключова ідея, спільна для майже всього процедурного текстурування — той самий трьохетапний конвеєр:

1. Хеш — детерміноване псевдовипадкове значення з координати 2. Шум — плавна інтерполяція хешованих значень → неперервне поле 3. Форма — комбінування/ремапінг октав шуму у впізнаваний матеріал (мармурові прожилки, кільця дерева, камінь, хмари, стінки клітин...)

2. Хешування: псевдовипадкові числа на GPU

У GLSL немає функції random(), тож будь-яка реалізація шуму починається з хешу: функції, яка бере вхідну координату і повертає число з діапазону [0, 1), що виглядає випадковим, але повністю детерміноване (той самий вхід → той самий вихід, кожен кадр, кожна GPU). Класичний однорядковий варіант використовує скалярний добуток з великим ірраціональним вектором, пропущений через fract(sin(x)):

// Класичний "sine hash" — швидкий, але на деяких GPU дає помітне смугування
float hash21(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}

// Альтернатива на цілочисельному хеші — рівномірніша, без проблем з точністю sin()
vec2 hash22(vec2 p) {
  vec3 p3 = fract(p.xyx * vec3(0.1031, 0.1030, 0.0973));
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.xx + p3.yz) * p3.zy);
}
Чому це важливо: кожен алгоритм шуму нижче — це "просто" хеш плюс схема інтерполяції. Якщо ваш шум виглядає "сітчастим" або надто швидко повторюється, першим підозрюваним майже завжди є хеш — спробуйте замінити hash21 на варіанти на основі hash22, якщо бачите періодичні артефакти.

3. Value-шум

Value-шум хешує чотири кути комірки сітки, що містить точку семплування, а потім плавно інтерполює між ними білінійною інтерполяцією з кривою згладжування (quintic fade Кена Перліна, 6t⁵ − 15t⁴ + 10t³, дозволяє уникнути видимих зламів, які дає звичайна лінійна чи кубічна smoothstep):

Для кутів комірки (0,0),(1,0),(0,1),(1,1) з хешованими значеннями a,b,c,d і локальною дробовою частиною f = fract(p): u = fade(f.x), v = fade(f.y) top = mix(a, b, u) bottom = mix(c, d, u) value = mix(top, bottom, v)
float fade(float t) {
  return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}

float valueNoise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);

  float a = hash21(i);
  float b = hash21(i + vec2(1.0, 0.0));
  float c = hash21(i + vec2(0.0, 1.0));
  float d = hash21(i + vec2(1.0, 1.0));

  vec2 u = vec2(fade(f.x), fade(f.y));
  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

4. Градієнтний шум (у стилі Перліна)

Value-шум інтерполює значення в кутах; градієнтний шум інтерполює скалярні добутки з псевдовипадковими векторами градієнта в кожному куті. Це дає візуально багатший, менш "плямистий" візерунок з більш стабільним локальним контрастом — техніка, яку Кен Перлін представив 1985 року і вдосконалив 2002-го (Simplex-шум). Ключова відмінність:

vec2 gradient(vec2 i) {
  float angle = hash21(i) * 6.2831853; // хеш → [0, 2π)
  return vec2(cos(angle), sin(angle));
}

float gradientNoise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = vec2(fade(f.x), fade(f.y));

  // Скалярний добуток градієнта в кожному куті з вектором до точки семплування
  float a = dot(gradient(i), f - vec2(0.0, 0.0));
  float b = dot(gradient(i + vec2(1.0,0.0)), f - vec2(1.0, 0.0));
  float c = dot(gradient(i + vec2(0.0,1.0)), f - vec2(0.0, 1.0));
  float d = dot(gradient(i + vec2(1.0,1.0)), f - vec2(1.0, 1.0));

  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) * 0.5 + 0.5; // ремапінг [-1,1] → [0,1]
}
Value vs. градієнтний: value-шум дешевший (один виклик хешу на кут) і добре підходить для м'яких хмароподібних базових шарів. Градієнтний шум коштує один хеш + один скалярний добуток на кут, але дає той органічний, неповторюваний вигляд, який асоціюється з рельєфом і мармуром. Використовуйте value-шум для грубого базового шару, градієнтний — для деталей.

5. Фрактальний броунівський рух (FBM)

Один виклик шуму виглядає надто одноманітно — реальні матеріали мають деталізацію на кількох масштабах одразу. FBM підсумовує кілька "октав" однієї й тієї ж шумової функції зі зростаючою частотою і спадною амплітудою, імітуючи спектр потужності 1/f, властивий природним явищам:

FBM(p) = Σᵢ₌₀ⁿ⁻¹ amplitude·persistence^i · noise(p · lacunarity^i) Типові значення за замовчуванням: persistence ≈ 0.5 (кожна октава вносить удвічі менше) lacunarity ≈ 2.0 (кожна октава подвоює частоту) octaves = 4–6 (більше октав = більше дрібних деталей, вища вартість)
float fbm(vec2 p) {
  float value = 0.0;
  float amplitude = 0.5;
  float frequency = 1.0;

  for (int i = 0; i < 6; i++) {
    value += amplitude * gradientNoise(p * frequency);
    frequency *= 2.0;  // lacunarity
    amplitude *= 0.5;   // persistence
  }
  return value;
}

6. Викривлення простору (domain warping)

Domain warping (техніка, популяризована Iñigo Quílez) подає шум на вхідні координати іншого шуму перед обчисленням фінального поля. Замість того щоб семплувати fbm(p), ви семплуєте fbm(p + fbm(p)), або вкладаєте це на два рівні глибше. Це викривлює ізолінії шуму у вихороподібні форми, схожі на прожилки мармуру, замість ізотропних "плям", які дає звичайний FBM:

float warpedFbm(vec2 p) {
  vec2 q = vec2(
    fbm(p + vec2(0.0, 0.0)),
    fbm(p + vec2(5.2, 1.3))
  );

  vec2 r = vec2(
    fbm(p + 4.0 * q + vec2(1.7, 9.2)),
    fbm(p + 4.0 * q + vec2(8.3, 2.8))
  );

  return fbm(p + 4.0 * r);
}
Як читати вкладеність: q — це 2D-поле зміщення, побудоване з шуму; r — друге, більше поле зміщення, побудоване семплуванням шуму в p зі зсувом на q; фінальний return знову викривлює p через r. Кожен рівень вкладеності приблизно геометрично множить візуальну складність, лише потроюючи вартість.

7. Шум Ворлі / клітинний шум

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

float worleyNoise(vec2 p) {
  vec2 cell = floor(p);
  float minDist = 1.0;

  for (int y = -1; y <= 1; y++) {
    for (int x = -1; x <= 1; x++) {
      vec2 neighbor = vec2(float(x), float(y));
      vec2 point = hash22(cell + neighbor); // випадкова точка всередині цієї комірки
      vec2 diff = neighbor + point - fract(p);
      float dist = length(diff);
      minDist = min(minDist, dist);
    }
  }
  return minDist; // 0 у центрах комірок, ~0.7 на межах комірок (метрика F1)
}
F1 проти F2: функція вище повертає відстань до найближчої точки (F1), даючи заповнені комірки. Відстежуйте дві найменші відстані й поверніть замість цього F2 − F1, щоб отримати тонкі тріщинуваті лінії вздовж меж комірок — ідеально для висохлого бруду, розтрісканого каменю чи жилок листя.

8. Створюємо шейдери мармуру й дерева

Маючи хеш → шум → FBM → викривлення, матеріали стають питанням ремапінгу скалярного шумового поля через функцію і кольорову рампу.

Мармур

Класичний мармур (з оригінального демо Перліна 1985 року) збурює синусоїду турбулентністю, а потім розфарбовує результат:

vec3 marble(vec2 p) {
  float n = fbm(p * 3.0);
  float stripes = sin((p.x + n * 4.0) * 6.2831853) * 0.5 + 0.5;

  vec3 veinColor = vec3(0.15, 0.15, 0.17);
  vec3 baseColor = vec3(0.92, 0.90, 0.88);
  return mix(veinColor, baseColor, smoothstep(0.15, 0.85, stripes));
}

Дерево

Кільця дерева отримуються з дробової частини радіальної відстані від "центру стовбура", збуреної шумом низької амплітуди, щоб кільця не були ідеально круглими:

vec3 wood(vec2 p) {
  vec2 centered = p - vec2(0.5);
  float radius = length(centered) + fbm(p * 4.0) * 0.1;
  float rings = fract(radius * 10.0);

  vec3 darkGrain  = vec3(0.30, 0.17, 0.09);
  vec3 lightGrain = vec3(0.55, 0.35, 0.20);
  return mix(darkGrain, lightGrain, smoothstep(0.0, 0.6, rings));
}

Обидві функції вставляються прямо у фрагментний шейдер ShaderMaterial у Three.js як gl_FragColor = vec4(marble(vUv), 1.0) — без текстур, без часу завантаження, з нескінченним масштабуванням без пікселізації.

9. Продуктивність і безшовне тайлування

Наступний крок: поєднайте все вищенаведене з трипланарним мапінгом (triplanar mapping), щоб текстурувати довільні 3D-меші (рельєф, каміння) без UV-швів — семплуйте те саме шумове поле на площинах XY, YZ і XZ та змішуйте за нормаллю поверхні.