Процедурні текстури: від теорії до шейдера
Кожна мармурова стільниця, потерта дошка чи інопланетний рельєф, які ви бачили у WebGL-демо, найімовірніше, ніколи не малювалися вручну. Вони народилися з кількох шумових функцій, обчислених для кожного пікселя на GPU. Цей туторіал будує цю машинерію з перших принципів — value-шум, градієнтний (у стилі Перліна) шум, фрактальний броунівський рух, викривлення простору (domain warping) і комірки Ворлі — і перетворює кожну з них на робочий фрагментний шейдер GLSL.
1. Навіщо процедурні текстури
Мальована вручну текстура мармуру у 4K коштує місця на диску, часу
завантаження і з часом починає видимо повторюватись, коли камера
наближається. Процедурна текстура натомість
обчислює математичну функцію f(x, y, z) → колір для
кожного фрагмента. Вона не займає місця на диску, має нескінченну
роздільну здатність, безшовно тайлується (якщо спроєктована так) і
анімується безкоштовно — достатньо додати доданок з часом. Плата за
це — кожен піксель коштує тактів GPU, тож функції нижче обрані за
дешевизну, детермінованість і композиційність.
Ключова ідея, спільна для майже всього процедурного текстурування — той самий трьохетапний конвеєр:
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):
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]
}
5. Фрактальний броунівський рух (FBM)
Один виклик шуму виглядає надто одноманітно — реальні матеріали мають деталізацію на кількох масштабах одразу. FBM підсумовує кілька "октав" однієї й тієї ж шумової функції зі зростаючою частотою і спадною амплітудою, імітуючи спектр потужності 1/f, властивий природним явищам:
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)
}
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. Продуктивність і безшовне тайлування
- Кількість октав — ваш головний важіль. Кожна октава FBM коштує одне повне обчислення шуму; 6 октав на повноекранному квейді 1080p — це приблизно 12 млн викликів шуму за кадр. Знижуйте до 3–4 октав для мобільних GPU, або запечіть результат один раз у render target, якщо текстура статична.
- Безшовне тайлування вимагає, щоб шум був періодичним. Семплуйте шум на поверхні 4D-тора (дві пари синус/косинус координат), а не на сирих 2D-координатах, щоб візерунок обгортався без жодного шва — використовується для небесних сфер і повторюваних текстур землі.
-
Обчислюйте наперед, де можливо. Якщо текстура не
потребує анімації, відрендерте візерунок FBM/Ворлі в
позаекранний
THREE.WebGLRenderTargetодин раз, а потім семплуйте його як звичайну текстуру у наступних кадрах — обмінюєте одноразову вартість на майже нульову вартість за кадр. -
Точність має значення при масштабі. Хеші на
основі
fract(sin(x))втрачають якість (видимі лінії сітки), коли координати перевищують приблизно ±10 000, через обмеження точностіmediump/highpfloat на мобільних GPU — обгортайте великі світові координати назад до початку координат перед хешуванням.