Tutorial
⏱️ ~55 minutes 🎓 Intermediate–Advanced 🛠️ GLSL · Three.js · ShaderMaterial

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.

Prerequisites

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:

  1. Upward drift: subtract uTime * speed from the UV y-coordinate so the pattern rises
  2. 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>

Continue Learning

🛠

Experiment in Playground

Tweak the fire shader live — run GLSL code directly in your browser, no compilation needed.

Open Playground → View Simulation ↗