Shader Lab
31.Fire (noise-based)

Fire (noise-based)

Arc 6 · Simulations · Lesson 31 of 35
coordinate signal color
0.55
0.60
1.00

Purpose

A stylized flame is four ingredients stacked — rising coordinate, domain-warped FBM, vertical fade mask, fire color ramp — and the whole thing costs a fraction of a real fluid sim while giving up almost all interactivity in exchange.

Key insight

This is the procedural tier of the trilemma in its purest form. There is no combustion, no temperature field, no advection. We scroll a noise field upward over time, warp its coordinates to fake turbulence, multiply by a mask that says "this is a flame shape, fade at the top," and run the result through a palette that goes hot-to-white at the peaks and black at the troughs.

The flame looks alive because (a) the noise is animating, (b) domain warping makes the swirls irregular in a way stacked sine waves can't fake, and (c) the color ramp is where all the heat lives — the signal is just a grayscale noise field until the ramp turns bright values into the incandescent colors the eye reads as fire.

No ping-pong here. No state across frames. Every pixel is computed from u_time and its own coordinate. By the next frame the shader has forgotten everything. That's why you can't throw a ball through it and have the flame bend around the ball. For that you go back to Lesson 28.

Break it

1. Drag warp to 0. The flame turns into vertical scrolling FBM clouds — clearly rising, but cloud-shaped and smooth, not flame-shaped. Teaches: the flame-ness is the warping. FBM alone is clouds. Domain warping twists the cloud structure into the curling, licking, vortex-heavy shapes the eye reads as combustion.

2. Pull taper to 0. The flame fills the frame — a full rectangle of fire color with no silhouette. Teaches: the flame's shape is the mask, not the noise. The upward-taper, the pointed top, the way flames thin out — that lives in the multiplier. The noise paints the interior; the mask carves the shape.

3. Compare to L28 (mentally). Move your cursor across the canvas. The flame ignores you completely — it's a function of time only. In L28's Stam fluid, the dye swirls away from the cursor. That is the trilemma made visible: procedural is cheap and unresponsive; simulate is expensive and interactive.

Direct Claude

"hotter flames / more white-hot" heat up "cooler flames / more ember-orange" heat down "more turbulent / more curling" warp up "smoother / more laminar" warp down (approaches clouds) "taller flame" taper down (mask stays opaque further up) "just the tongue at the base" taper up
Meta-phrase you gain here: the whole trilemma vocabulary. "Give me a procedural fire — domain-warped FBM, fire palette, vertical mask" means L31. "Give me a Stam-fluid smoke the cursor can push" means L28. "Give me a swarm of magic sparks following curl noise" means L30. Three different briefs, three different tiers, chosen for the job.
Combines with: L28 (use L31 as a cheap background fire with a small L28 pocket for the hero flame the player can touch — the classic optimization), L29 (the coral pattern makes a great fire-bed shape), Arc 7 bloom (where fire earns its glow), L30 sparks layered on top.
the fragment shader running above
uniform float u_heat;   // color layer — palette peak
uniform float u_warp;   // coordinate layer — domain-warp amount
uniform float u_taper;  // signal layer — vertical mask strength

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float vnoise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  f = f*f*(3.0 - 2.0*f);
  return mix(
    mix(hash(i + vec2(0,0)), hash(i + vec2(1,0)), f.x),
    mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), f.x),
    f.y);
}
float fbm(vec2 p) {
  float v = 0.0, a = 0.5;
  for (int i = 0; i < 5; i++) {
    v += a * vnoise(p);
    p *= 2.03;
    a *= 0.5;
  }
  return v;
}

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;
  float aspect = u_resolution.x / u_resolution.y;
  vec2 p = vec2((uv.x - 0.5) * aspect, uv.y);

  // COORDINATE LAYER — rising scroll + domain warp
  vec2 flow = p * 2.2 + vec2(0.0, -u_time * 0.9);
  vec2 q = vec2(
    fbm(flow + vec2(0.0, 0.0)),
    fbm(flow + vec2(5.2, 1.3))
  );
  vec2 warped = flow + u_warp * (q - 0.5) * 2.0;

  // SIGNAL LAYER — FBM at the warped coordinate, times vertical taper
  float noise = fbm(warped);
  float side = smoothstep(0.55, 0.0, abs(p.x));
  float top  = mix(1.0, smoothstep(1.1, 0.0, uv.y), u_taper);
  float mask = mix(1.0, side * top, u_taper);
  float heat = clamp(noise * mask * 1.6 - 0.12, 0.0, 1.0);

  // COLOR LAYER — fire ramp: black → red → orange → yellow → white
  float h = heat * (0.6 + 0.55 * u_heat);
  vec3 col = vec3(0.0);
  col = mix(col, vec3(0.28, 0.02, 0.0),  smoothstep(0.02, 0.18, h));
  col = mix(col, vec3(0.82, 0.12, 0.0),  smoothstep(0.15, 0.38, h));
  col = mix(col, vec3(1.00, 0.55, 0.05), smoothstep(0.34, 0.62, h));
  col = mix(col, vec3(1.00, 0.92, 0.35), smoothstep(0.58, 0.85, h));
  col = mix(col, vec3(1.00, 0.99, 0.88), smoothstep(0.82, 1.02, h));

  gl_FragColor = vec4(col, 1.0);
}