Fire is one of the most rewarding effects to build from scratch in a fragment shader. Unlike particle-based approaches, a purely procedural flame lives entirely in math — no textures, no external assets, just noise functions sculpted into something that flickers and breathes. In this post we will build a complete fire shader step by step, exploring value noise, fractal Brownian motion (FBM), domain distortion, and color mapping techniques that together produce convincing, animated flames.
The key insight behind procedural fire is that flames are essentially shaped noise. We generate layered noise to simulate turbulence, warp it with time to create upward motion, and then multiply by a vertical gradient so the effect tapers off at the top like a real flame.
Below is the full, standalone WebGL1 fragment shader. Copy it directly into any GLSL sandbox that provides iResolution and iTime uniforms and you will see animated procedural flames.
precision mediump float;
uniform vec2 iResolution;
uniform float iTime;
float hash(vec2 p) {
p = fract(p * vec2(443.897, 441.423));
p += dot(p, p + 19.19);
return fract(p.x * p.y);
}
float valueNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
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));
vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < 6; i++) {
value += amplitude * valueNoise(p * frequency);
frequency *= 2.0;
amplitude *= 0.5;
p = mat2(0.8, 0.6, -0.6, 0.8) * p;
}
return value;
}
float turbulentFBM(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < 6; i++) {
float n = valueNoise(p * frequency);
value += amplitude * abs(n * 2.0 - 1.0);
frequency *= 2.0;
amplitude *= 0.5;
p = mat2(0.8, 0.6, -0.6, 0.8) * p;
}
return value;
}
vec3 fireColorRamp(float t) {
t = clamp(t, 0.0, 1.0);
vec3 black = vec3(0.0, 0.0, 0.0);
vec3 red = vec3(0.7, 0.1, 0.0);
vec3 orange = vec3(1.0, 0.45, 0.0);
vec3 yellow = vec3(1.0, 0.85, 0.2);
vec3 white = vec3(1.0, 1.0, 0.9);
vec3 color = black;
color = mix(color, red, smoothstep(0.0, 0.25, t));
color = mix(color, orange, smoothstep(0.2, 0.45, t));
color = mix(color, yellow, smoothstep(0.4, 0.65, t));
color = mix(color, white, smoothstep(0.7, 1.0, t));
return color;
}
void main() {
vec2 uv = gl_FragCoord.xy / iResolution.xy;
uv.x = (uv.x - 0.5) * (iResolution.x / iResolution.y);
float verticalMask = 1.0 - uv.y;
verticalMask = pow(verticalMask, 1.3);
float width = mix(0.55, 0.12, pow(uv.y, 0.8));
float horizontalMask = 1.0 - smoothstep(0.0, width, abs(uv.x));
float shapeMask = verticalMask * horizontalMask;
float speed = iTime * 1.8;
vec2 noiseCoord = vec2(uv.x * 4.0, uv.y * 3.5 - speed);
float noise1 = fbm(noiseCoord);
vec2 warpOffset = vec2(
fbm(noiseCoord + vec2(1.7, 9.2) + 0.15 * iTime),
fbm(noiseCoord + vec2(8.3, 2.8) + 0.12 * iTime)
);
float noise2 = turbulentFBM(noiseCoord + warpOffset * 2.0);
float fireNoise = mix(noise1, noise2, 0.55);
float flicker = 0.95 + 0.05 * sin(iTime * 7.3 + uv.x * 3.0)
+ 0.03 * sin(iTime * 13.1);
float intensity = shapeMask * fireNoise * flicker;
intensity = pow(intensity, 1.1);
intensity = smoothstep(0.05, 0.95, intensity * 1.8);
vec3 color = fireColorRamp(intensity);
float glow = exp(-3.0 * length(vec2(uv.x, uv.y - 0.1)));
color += vec3(0.4, 0.1, 0.0) * glow * 0.25;
color = color / (1.0 + color * 0.15);
gl_FragColor = vec4(color, 1.0);
}
Everything begins with a single hash function — a cheap pseudo-random number generator that takes a 2D coordinate and returns a seemingly random float between 0 and 1. By itself the hash gives us white noise. To turn this into something useful we need value noise, which evaluates the hash at integer lattice points and smoothly interpolates between them.
float hash(vec2 p) {
p = fract(p * vec2(443.897, 441.423));
p += dot(p, p + 19.19);
return fract(p.x * p.y);
}
A single layer of value noise looks like smooth, blobby hills. Real turbulence has detail at every scale. Fractal Brownian Motion (FBM) achieves this by summing multiple octaves of noise, each at double the frequency and half the amplitude.
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < 6; i++) {
value += amplitude * valueNoise(p * frequency);
frequency *= 2.0;
amplitude *= 0.5;
p = mat2(0.8, 0.6, -0.6, 0.8) * p; // rotate per octave
}
return value;
}
The rotation matrix applied between octaves prevents axis-aligned banding artifacts. Six octaves provides a good balance between visual richness and performance.
One of the most powerful techniques in procedural generation is domain warping — using one noise function to distort the input coordinates of another. This creates complex, swirling patterns that feel organic.
vec2 warpOffset = vec2(
fbm(noiseCoord + vec2(1.7, 9.2) + 0.15 * iTime),
fbm(noiseCoord + vec2(8.3, 2.8) + 0.12 * iTime)
);
float noise2 = turbulentFBM(noiseCoord + warpOffset * 2.0);
The final step maps our scalar intensity value to color. Real fire follows a predictable thermal progression: cooler regions are deep red, mid-temperature zones are orange and yellow, and the hottest core approaches white.
vec3 color = black; color = mix(color, red, smoothstep(0.0, 0.25, t)); color = mix(color, orange, smoothstep(0.2, 0.45, t)); color = mix(color, yellow, smoothstep(0.4, 0.65, t)); color = mix(color, white, smoothstep(0.7, 1.0, t));
Notice that the smoothstep ranges overlap to prevent hard bands between colors and produce a smooth, continuous gradient you see in real flames.
This shader runs comfortably on most hardware. If performance is a concern on mobile, reducing to four octaves or removing the domain warp pass will help significantly while still looking good. Try adding a second color layer with blue tones for a gas flame, or introduce horizontal wind by adding a time-varying x-offset to the noise coordinates.