GLSL Fire Shader Effect
Procedural fire is one of the classic GPU effects — achieved entirely
in the fragment shader with no textures or particles. This tutorial
builds it step by step: a value noise function, fractional Brownian
motion (fBm) layering, a fire colour palette using mix(),
upward UV drift, and alpha masking for soft edges on a billboard
plane.
-
Completed WebGL Shaders Intro —
you must know varyings, uniforms and
ShaderMaterial -
Familiarity with
sin,fract,mix,smoothstepGLSL built-ins
Value Noise in GLSL
Noise is the foundation of organic-looking effects. We can't use
Math.random() in GLSL; instead we build a deterministic
hash from position:
// --- Paste at top of fragment shader ---
// Hash: maps a 2D coordinate to a pseudo-random 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);
}
// Bilinear value noise
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
// Smooth the interpolation (Ken Perlin's quintic: 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);
}
This is value noise — fast but visible grid artefacts at low frequency. For higher quality use gradient noise (Perlin) or simplex noise. For fire, value noise is fine and cheaper.
Fractional Brownian Motion (fBm)
A single noise layer looks flat. fBm stacks multiple octaves at progressively higher frequencies and lower amplitudes — mimicking the multi-scale structure of natural phenomena:
// fBm: 5 octaves of value noise
float fbm(vec2 p) {
float value = 0.0;
float amp = 0.5; // amplitude (halves each octave)
float freq = 1.0; // frequency (doubles each octave)
for (int i = 0; i < 5; i++) {
value += amp * noise(p * freq);
amp *= 0.5;
freq *= 2.0;
}
return value;
}
After 5 octaves the total amplitude sums to ≈1
(0.5+0.25+0.125+0.0625+0.03125 = 0.97). Call
fbm(vUv * 3.0 + vec2(0.0, uTime)) and you'll see animated
organic turbulence.
Fire Colour Palette
Fire transitions from dark red at the base → bright orange → yellow →
white at the core. Map the noise value through three
mix() calls:
vec3 fireColor(float t) {
// t in [0,1]: 0 = dark/cold, 1 = hot/bright
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 Distortion + Upward Drift
Raw noise scrolled upward looks like lava, not fire. Two tricks make it look like fire:
-
Upward drift: subtract
uTime * speedfrom the UV y-coordinate so the pattern rises -
Height fadeout: multiply by
(1 - vUv.y)so the top of the plane is transparent — fire tapers
void main() {
vec2 uv = vUv;
// Upward drift — fire rises
float speed = 0.8;
uv.y -= uTime * speed;
// Turbulent x-distortion for waviness
uv.x += 0.15 * sin(uv.y * 4.0 + uTime * 2.0);
float n = fbm(uv * 2.5);
// Height mask: fire is strongest at base, fades at top
float heightMask = 1.0 - vUv.y; // vUv.y=0 is bottom
heightMask = pow(heightMask, 1.5); // sharpen falloff
float intensity = n * heightMask;
vec3 col = fireColor(intensity * 1.8); // multiply to push into hot range
float alpha = smoothstep(0.0, 0.3, intensity);
gl_FragColor = vec4(col, alpha);
}
Alpha Mask and Transparency
Three.js materials need extra flags to render transparency correctly:
const mat = new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0 } },
vertexShader: /* ... */,
fragmentShader: /* ... */,
transparent: true, // enable alpha blending
depthWrite: false, // don't write to depth buffer (avoids sorting artefacts)
side: THREE.DoubleSide, // visible from both sides
blending: THREE.AdditiveBlending, // optional: additive = fire glow over dark bg
});
AdditiveBlending adds the fire colour on top of
whatever is behind it — perfect for emissive/glow effects. Use
NormalBlending if the fire should occlude background
objects.
Billboard Plane Setup
A billboard always faces the camera. Use
PlaneGeometry and rotate the mesh in the animate loop:
const firePlane = new THREE.Mesh(
new THREE.PlaneGeometry(2, 3), // width, height
mat
);
scene.add(firePlane);
// In animate():
// Simple billboard: copy camera quaternion to mesh
firePlane.quaternion.copy(camera.quaternion);
Or use Three.js Sprite for automatic camera-facing — but
Sprite doesn't support ShaderMaterial, so
the mesh approach is better here.
Complete Fire Shader
<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);
// Point light to illuminate surroundings
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); // flicker
renderer.render(scene, camera);
})(performance.now());
</script>