Ефект шейдера вогню на GLSL
Процедурний вогонь — це один із класичних GPU-ефектів, який досягається
цілком у фрагментному шейдері без текстур чи частинок. Цей урок будує
його крок за кроком: функція value-шуму, нашарування фрактального
броунівського руху (fBm), палітра кольорів вогню за допомогою
mix(), висхідне зміщення UV та маскування альфа-каналу для
м'яких країв на білборд-площині.
-
Пройдений Вступ до шейдерів WebGL —
ви маєте знати varyings, uniforms та
ShaderMaterial -
Знайомство з вбудованими функціями GLSL
sin,fract,mix,smoothstep
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 + висхідне зміщення
Чистий шум, що прокручується вгору, виглядає як лава, а не вогонь. Два прийоми надають йому вигляду вогню:
-
Висхідне зміщення: відніміть
uTime * speedвід координати y у UV, щоб візерунок піднімався вгору -
Затухання за висотою: помножте на
(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>