⏱ ~55 хв🟡 Середній рівеньЧистий WebGL · GLSL ES 3.00
Фрагментні шейдери вогню та води на GLSL
Пишіть фрагментні шейдери для вогню (шум фрактального броунівського руху,
кольоровий градієнт, ерозія альфа-каналу) та води (нормалі хвиль
Герстнера, відбивна здатність за Френелем, каустичні візерунки) — без
фреймворку, мінімальний WebGL.
1
Мінімальний повноекранний квад на WebGL
Перш ніж писати будь-які ефекти шейдерів, налаштуйте чистий контекст
WebGL із повноекранним трикутником — це найдешевший спосіб запустити
фрагментний шейдер на кожному пікселі.
Один великий трикутник, що покриває простір відсікання, ефективніший за
квад (2 трикутники): він уникає діагонального шва й не потребує жодних
вершинних буферів — достатньо gl_VertexID.
2
Вогонь — градієнтний шум і fBm
Вогонь будується з фрактального броунівського руху (fBm) — кількох
октав плавного шуму, складених разом. Форма полум'я піднімається завдяки
зміщенню UV вгору з часом.
// Фрагментний шейдер — вогонь const FIRE_FS = `#version 300 es precision
highp float; uniform float uTime; uniform vec2 uResolution; out vec4
fragColor; // 2D градієнтний шум (варіант value-шуму) float hash(vec2
p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.545); }
float noise(vec2 p) { vec2 i = floor(p), f = fract(p); vec2 u =
f*f*(3.0 - 2.0*f); // smoothstep return mix(mix(hash(i),
hash(i+vec2(1,0)), u.x), mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),
u.x), u.y); } float fbm(vec2 p) { float v = 0.0, amp = 0.5; for (int i
= 0; i < 6; i++) { v += noise(p) * amp; p *= 2.1; // лакунарність amp *=
0.5; // персистентність } return v; } void main() { vec2 uv =
gl_FragCoord.xy / uResolution; uv.x = uv.x * 2.0 - 1.0; // центруємо
по горизонталі // Полум'я піднімається: віднімаємо час, щоб зсунути шум угору vec2
p = vec2(uv.x * 1.5, uv.y * 2.0 - uTime * 0.8); float f = fbm(p +
fbm(p + fbm(p))); // fBm зі спотворенням домену // ...кольоровий градієнт у кроці 3
fragColor = vec4(f, f*0.4, 0.0, 1.0); }`;
Спотворення домену (domain warping) — передавання результату fBm як
вхідних даних до іншого виклику fBm — створює характерну турбулентну,
закручену форму вогню. Зазвичай достатньо трьох рівнів спотворення.
3
Вогонь — кольоровий градієнт та ерозія альфа-каналу
Відобразіть значення fBm на кольоровий градієнт вогню та зменшіть
альфа-канал поблизу верху й країв, щоб полум'я звужувалося природно.
// Усередині main() у FIRE_FS, після обчислення f: // Вертикальна маска —
вогонь горить знизу, затухає вгорі float mask = smoothstep(1.0, 0.0,
uv.y) * // затухання вгорі smoothstep(-0.9, 0.0, uv.x) * // затухання лівого краю
smoothstep( 0.9, 0.0, uv.x); // затухання правого краю float fire = f * mask;
fire = pow(fire, 1.5); // збільшуємо контраст // Кольоровий градієнт: чорний →
червоний → помаранчевий → жовтий → білий vec3 col = vec3(0.0); col = mix(col,
vec3(0.8,0.1,0.0), smoothstep(0.0,0.2,fire)); // чорний→червоний col =
mix(col, vec3(1.0,0.4,0.0), smoothstep(0.2,0.4,fire)); // червоний→помаранчевий
col = mix(col, vec3(1.0,0.85,0.2),smoothstep(0.4,0.7,fire)); //
помаранчевий→жовтий col = mix(col,
vec3(1.0,1.0,0.95),smoothstep(0.7,1.0,fire)); // жовтий→білий float
alpha = smoothstep(0.05, 0.3, fire); // відкидаємо темні пікселі fragColor
= vec4(col, alpha);
Використовуйте gl.blendFunc(gl.SRC_ALPHA, gl.ONE)
(адитивне змішування) для вогню, відрендереного на темному фоні — воно
природно накопичує світло й уникає темних ореолів навколо країв спрайта.
4
Вода — нормалі хвиль Герстнера
Нормалі поверхні води обчислюються з суми хвиль Герстнера безпосередньо
у фрагментному шейдері. Зміщена нормаль керує відбиттями та дзеркальними
відблисками.
// Фрагментний шейдер — поверхня води const WATER_FS = `#version 300 es
precision highp float; uniform float uTime; uniform vec2 uResolution;
uniform samplerCube uEnvMap; out vec4 fragColor; // Хвиля Герстнера:
повертає (зміщення XZ, дельту нормалі хвилі XZ) struct GW { float A,
k, omega, phi; vec2 D; }; vec3 gerstnerNormal(GW w, vec2 xz) { float
theta = dot(w.k * w.D, xz) - w.omega * uTime + w.phi; float s =
sin(theta); float scale = w.A * w.k; return vec3(-scale * w.D.x * s,
0.0, // компонента y (висота) тут не використовується -scale * w.D.y * s); } void
main() { vec2 uv = gl_FragCoord.xy / uResolution; vec2 xz = (uv - 0.5)
* 20.0; // площина XZ у світових координатах // Накладаємо 4 хвилі Герстнера GW
waves[4]; waves[0] = GW(0.5, 2.0, 2.0, 0.0, normalize(vec2(1.0,
0.7))); waves[1] = GW(0.3, 3.5, 2.8, 1.2, normalize(vec2(-0.5, 1.0)));
waves[2] = GW(0.2, 5.0, 3.5, 2.4, normalize(vec2(0.8, -0.3)));
waves[3] = GW(0.15,7.0, 4.5, 3.7, normalize(vec2(-0.3, -0.9))); vec3
normal = vec3(0.0, 1.0, 0.0); for (int i = 0; i < 4; i++) { normal +=
gerstnerNormal(waves[i], xz); } normal = normalize(normal); //
Напрямок відбиття від камери, спрямованої вниз vec3 viewDir =
normalize(vec3(uv - 0.5, 1.0)); vec3 reflDir = reflect(viewDir,
normal); // ...Френель + колір у кроці 5 fragColor = vec4(normal *
0.5 + 0.5, 1.0); // налагоджувальний вигляд }`;
5
Вода — каустики та Френель
Додайте Френель за Шліком, щоб змішати заломлений колір глибини з
відбиттям неба, а потім накладіть процедурні каустичні візерунки з
високочастотної інтерференції хвиль.
// Усередині main() у WATER_FS — після обчислення normal і reflDir: //
Наближення Френеля за Шліком float fresnel(vec3 n, vec3 v, float F0)
{ return F0 + (1.0 - F0) * pow(1.0 - max(dot(n, -v), 0.0), 5.0); } //
Простий колір неба (градієнт) vec3 sky = mix(vec3(0.05, 0.1, 0.3),
vec3(0.5, 0.8, 1.0), clamp(reflDir.y * 0.5 + 0.5, 0.0, 1.0)); // Колір
глибокої води vec3 waterDeep = vec3(0.0, 0.08, 0.18); // Процедурні
каустики — інтерференція 3 наборів хвиль float cx = xz.x, cz = xz.z;
float caus = 0.0; for (float f = 1.0; f <= 4.0; f++) { float th = cx *
f * 1.1 + cz * f * 0.7 + uTime * 1.2; caus += sin(th) * 0.5 + 0.5; th
= cx * f * 0.8 - cz * f * 1.3 - uTime * 0.9; caus += sin(th) * 0.5 +
0.5; } caus = pow(caus / 8.0, 3.0) * 2.5; // загострюємо каустичні
відблиски float F = fresnel(normal, viewDir, 0.02); // F0 води ≈
0.02 vec3 col = mix(waterDeep + caus * vec3(0.2, 0.3, 0.4), //
заломлене sky, // відбите F); // Дзеркальний відблиск float spec =
pow(max(dot(reflDir, vec3(0.3, 0.9, 0.3)), 0.0), 80.0); col +=
vec3(1.0) * spec * 0.8; fragColor = vec4(col, 1.0);
Наближення Шліка: F(θ) = F₀ + (1−F₀)(1−cosθ)⁵ — вода має F₀ ≈ 0.02, тож
під ковзними кутами відбивається майже 100% світла (як у дзеркалі),
тоді як під кутами, близькими до нормалі, видно колір глибокої води
внизу.
6
Інтеграція у Three.js ShaderMaterial
Перенесіть логіку фрагментного шейдера у Three.js
ShaderMaterial, щоб її можна було застосувати до будь-якого
меша — площини для води, білборд-спрайта для вогню.
import * as THREE from
'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; //
Площина води — 10×10м, 128×128 підрозділів для деталізації const waterGeo =
new THREE.PlaneGeometry(10, 10, 128, 128); waterGeo.rotateX(-Math.PI /
2); const waterMat = new THREE.ShaderMaterial({ uniforms: { uTime: {
value: 0 }, uResolution: { value: new THREE.Vector2(innerWidth,
innerHeight) }, uEnvMap: { value: envCubeTexture }, //
CubeRenderTarget або PMREMGenerator }, vertexShader: ` varying vec2
vUv; varying vec3 vWorldPos; void main() { vUv = uv; vec4 worldPos =
modelMatrix * vec4(position, 1.0); vWorldPos = worldPos.xyz;
gl_Position = projectionMatrix * viewMatrix * worldPos; }`,
fragmentShader: WATER_FS_BODY, // вставте сюди GLSL із кроків 4+5, використовуйте
vUv/vWorldPos transparent: true, side: THREE.DoubleSide, }); const
water = new THREE.Mesh(waterGeo, waterMat); scene.add(water); // Білборд
вогню (завжди обернений до камери — використовуйте Sprite або власний білборд-шейдер)
const fireMat = new THREE.ShaderMaterial({ uniforms: { uTime:
{ value: 0 }, uResolution: { value: new THREE.Vector2(2,4) } },
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position =
projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: FIRE_FS_BODY, transparent: true, depthWrite: false,
blending: THREE.AdditiveBlending, }); // Оновлюємо час щокадру
function animate() { requestAnimationFrame(animate); const t =
performance.now() * 0.001; waterMat.uniforms.uTime.value = t;
fireMat.uniforms.uTime.value = t; renderer.render(scene, camera); }
animate();
Встановіть depthWrite:false на
ShaderMaterial вогню, щоб прозорі пікселі вогню не затуляли
геометрію позаду них. Для води залиште запис глибини увімкненим, щоб
вона правильно взаємодіяла із зануреними об'єктами.
Часті запитання
Чого я навчуся в цьому уроці?
Пишіть фрагментні шейдери WebGL для вогню (градієнтний шум, fBm, кольоровий градієнт) і води (хвилі Герстнера, Френель, каустики) — з нуля з мінімальним налаштуванням WebGL.
Які теми розглядаються в цьому уроці?
Цей урок охоплює такі теми: Мінімальний повноекранний квад на WebGL, Вогонь — градієнтний шум і fBm, Вогонь — кольоровий градієнт та ерозія альфа-каналу, Вода — нормалі хвиль Герстнера, Вода — каустики та Френель, Інтеграція у Three.js ShaderMaterial.