Урок
⏱️ ~55 хвилин 🎓 Середній–просунутий рівень 🛠️ GLSL · Three.js · ShaderMaterial

Ефект шейдера вогню на GLSL

Процедурний вогонь — це один із класичних GPU-ефектів, який досягається цілком у фрагментному шейдері без текстур чи частинок. Цей урок будує його крок за кроком: функція value-шуму, нашарування фрактального броунівського руху (fBm), палітра кольорів вогню за допомогою mix(), висхідне зміщення UV та маскування альфа-каналу для м'яких країв на білборд-площині.

Передумови

Value-шум у GLSL

Шум — це основа ефектів з органічним виглядом. Ми не можемо використати Math.random() у GLSL; натомість ми будуємо детермінований хеш на основі позиції:

// --- Вставте на початку фрагментного шейдера ---

// Hash: відображає 2D-координату у псевдовипадкове число float [0,1]
float hash(vec2 p) {
  p = fract(p * vec2(234.34, 435.345));
  p += dot(p, p + 34.23);
  return fract(p.x * p.y);
}

// Білінійний value-шум
float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  // Згладжуємо інтерполяцію (квінтична функція Кена Перліна: 6t^5-15t^4+10t^3)
  vec2 u = f * f * (3.0 - 2.0 * f);

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

  return mix(mix(a, b, u.x),
             mix(c, d, u.x), u.y);
}

Це value-шум — швидкий, але з помітними ґратковими артефактами на низькій частоті. Для вищої якості використовуйте градієнтний шум (Перліна) або сімплекс-шум. Для вогню value-шум підходить і коштує дешевше.

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

Один шар шуму виглядає пласким. fBm накладає кілька октав із дедалі вищими частотами та нижчими амплітудами — імітуючи багатомасштабну структуру природних явищ:

// fBm: 5 октав value-шуму
float fbm(vec2 p) {
  float value  = 0.0;
  float amp    = 0.5;   // амплітуда (зменшується вдвічі щооктави)
  float freq   = 1.0;   // частота (подвоюється щооктави)
  for (int i = 0; i < 5; i++) {
    value += amp * noise(p * freq);
    amp  *= 0.5;
    freq *= 2.0;
  }
  return value;
}

Після 5 октав сумарна амплітуда дорівнює ≈1 (0.5+0.25+0.125+0.0625+0.03125 = 0.97). Викличте fbm(vUv * 3.0 + vec2(0.0, uTime)) — і ви побачите анімовану органічну турбулентність.

Палітра кольорів вогню

Вогонь переходить від темно-червоного біля основи → яскраво-помаранчевого → жовтого → білого в ядрі. Відобразіть значення шуму через три виклики mix():

vec3 fireColor(float t) {
  // t у [0,1]: 0 = темний/холодний, 1 = гарячий/яскравий
  vec3 black  = vec3(0.0, 0.0, 0.0);
  vec3 red    = vec3(0.8, 0.1, 0.0);
  vec3 orange = vec3(1.0, 0.5, 0.0);
  vec3 yellow = vec3(1.0, 0.95, 0.1);
  vec3 white  = vec3(1.0, 1.0, 0.9);

  vec3 col = black;
  col = mix(col, red,    smoothstep(0.0, 0.25, t));
  col = mix(col, orange, smoothstep(0.2, 0.5,  t));
  col = mix(col, yellow, smoothstep(0.4, 0.75, t));
  col = mix(col, white,  smoothstep(0.7, 1.0,  t));
  return col;
}

Спотворення UV + висхідне зміщення

Чистий шум, що прокручується вгору, виглядає як лава, а не вогонь. Два прийоми надають йому вигляду вогню:

  1. Висхідне зміщення: відніміть uTime * speed від координати y у UV, щоб візерунок піднімався вгору
  2. Затухання за висотою: помножте на (1 - vUv.y), щоб верх площини був прозорим — вогонь звужується
void main() {
  vec2 uv = vUv;

  // Висхідне зміщення — вогонь піднімається
  float speed = 0.8;
  uv.y -= uTime * speed;

  // Турбулентне спотворення по x для хвилястості
  uv.x += 0.15 * sin(uv.y * 4.0 + uTime * 2.0);

  float n = fbm(uv * 2.5);

  // Маска висоти: вогонь найсильніший біля основи, затухає вгорі
  float heightMask = 1.0 - vUv.y;              // vUv.y=0 — це низ
  heightMask = pow(heightMask, 1.5);            // загострюємо спад

  float intensity = n * heightMask;

  vec3 col = fireColor(intensity * 1.8);       // множимо, щоб зсунути в гарячий діапазон
  float alpha = smoothstep(0.0, 0.3, intensity);

  gl_FragColor = vec4(col, alpha);
}

Маска альфа-каналу та прозорість

Матеріалам Three.js потрібні додаткові прапорці, щоб коректно відображати прозорість:

const mat = new THREE.ShaderMaterial({
  uniforms: { uTime: { value: 0 } },
  vertexShader: /* ... */,
  fragmentShader: /* ... */,
  transparent: true,       // вмикаємо альфа-змішування
  depthWrite: false,       // не записуємо в буфер глибини (уникаємо артефактів сортування)
  side: THREE.DoubleSide,  // видимий з обох боків
  blending: THREE.AdditiveBlending, // необов'язково: адитивне = світіння вогню на темному фоні
});

AdditiveBlending додає колір вогню поверх того, що знаходиться позаду — ідеально для випромінювальних ефектів і світіння. Використовуйте NormalBlending, якщо вогонь має затуляти фонові об'єкти.

Налаштування білборд-площини

Білборд завжди обернений до камери. Використайте PlaneGeometry і обертайте меш у циклі анімації:

const firePlane = new THREE.Mesh(
  new THREE.PlaneGeometry(2, 3), // ширина, висота
  mat
);
scene.add(firePlane);

// У animate():
// Простий білборд: копіюємо кватерніон камери в меш
firePlane.quaternion.copy(camera.quaternion);

Або скористайтеся Sprite із Three.js для автоматичного обертання до камери — але Sprite не підтримує ShaderMaterial, тож тут краще підходить підхід із мешем.

Повний шейдер вогню

<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.168/build/three.module.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080808);
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.01, 100);
camera.position.set(0, 1.5, 5);

const vertGLSL = /* glsl */`
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragGLSL = /* glsl */`
  uniform float uTime;
  varying vec2 vUv;

  float hash(vec2 p) {
    p = fract(p * vec2(234.34, 435.345));
    p += dot(p, p + 34.23);
    return fract(p.x * p.y);
  }
  float noise(vec2 p) {
    vec2 i = floor(p), f = fract(p);
    vec2 u = f * f * (3.0 - 2.0 * f);
    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, a = 0.5, fr = 1.0;
    for (int i = 0; i < 5; i++) { v += a*noise(p*fr); a*=.5; fr*=2.; }
    return v;
  }
  vec3 fireColor(float t) {
    vec3 col = vec3(0);
    col = mix(col, vec3(.8,.1,0), smoothstep(0.,.25, t));
    col = mix(col, vec3(1.,.5,0), smoothstep(.2,.5,  t));
    col = mix(col, vec3(1.,.95,.1), smoothstep(.4,.75, t));
    col = mix(col, vec3(1.,1.,.9), smoothstep(.7,1.,  t));
    return col;
  }
  void main() {
    vec2 uv = vUv;
    uv.y -= uTime * 0.8;
    uv.x += 0.15 * sin(uv.y * 4.0 + uTime * 2.0);
    float n = fbm(uv * 2.5);
    float h = pow(1.0 - vUv.y, 1.5);
    float intensity = n * h;
    vec3 col = fireColor(intensity * 1.8);
    float alpha = smoothstep(0.0, 0.3, intensity);
    gl_FragColor = vec4(col, alpha);
  }
`;

const mat = new THREE.ShaderMaterial({
  uniforms: { uTime: { value: 0 } },
  vertexShader: vertGLSL,
  fragmentShader: fragGLSL,
  transparent: true,
  depthWrite: false,
  side: THREE.DoubleSide,
  blending: THREE.AdditiveBlending,
});

const flame = new THREE.Mesh(new THREE.PlaneGeometry(2, 3), mat);
scene.add(flame);

// Точкове джерело світла, щоб освітлювати оточення
const light = new THREE.PointLight(0xff6600, 3, 15);
light.position.set(0, 1.5, 1);
scene.add(light);

window.addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});

(function loop(t) {
  requestAnimationFrame(loop);
  mat.uniforms.uTime.value = t * 0.001;
  flame.quaternion.copy(camera.quaternion);
  light.intensity = 2.5 + 0.8 * Math.sin(t * 0.009); // мерехтіння
  renderer.render(scene, camera);
})(performance.now());
</script>

Продовжуйте навчання

🛠

Експериментуйте в пісочниці

Налаштовуйте шейдер вогню наживо — запускайте код GLSL просто у браузері, без компіляції.

Відкрити пісочницю → Переглянути симуляцію ↗