GLSL · WebGL · Шейдери
⏱ ~55 хв🟡 Середній рівеньЧистий WebGL · GLSL ES 3.00

Фрагментні шейдери вогню та води на GLSL

Пишіть фрагментні шейдери для вогню (шум фрактального броунівського руху, кольоровий градієнт, ерозія альфа-каналу) та води (нормалі хвиль Герстнера, відбивна здатність за Френелем, каустичні візерунки) — без фреймворку, мінімальний WebGL.

1

Мінімальний повноекранний квад на WebGL

Перш ніж писати будь-які ефекти шейдерів, налаштуйте чистий контекст WebGL із повноекранним трикутником — це найдешевший спосіб запустити фрагментний шейдер на кожному пікселі.

const canvas = document.querySelector('canvas'); const gl = canvas.getContext('webgl2'); canvas.width = innerWidth; canvas.height = innerHeight; // Вершинний шейдер — просто виводить координати простору відсікання const VS = `#version 300 es const vec2 VERTS[3] = vec2[](vec2(-1,-1), vec2(3,-1), vec2(-1,3)); void main() { gl_Position = vec4(VERTS[gl_VertexID], 0.0, 1.0); }`; function makeProgram(gl, vs, fs) { const compile = (src, type) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw gl.getShaderInfoLog(s); return s; }; const prog = gl.createProgram(); gl.attachShader(prog, compile(vs, gl.VERTEX_SHADER)); gl.attachShader(prog, compile(fs, gl.FRAGMENT_SHADER)); gl.linkProgram(prog); if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw gl.getProgramInfoLog(prog); return prog; } // Малювання: VAO не потрібен — ідентифікатори вершин вбудовані // gl.drawArrays(gl.TRIANGLES, 0, 3);
Один великий трикутник, що покриває простір відсікання, ефективніший за квад (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.

Які інструменти й технології використовуються?

Цей урок використовує: GLSL ES 3.00.

Скільки часу займає цей урок?

Цей урок займає приблизно 55 хв.