Three.js From Zero · Article s0-08

Coffee Smoke

Coffee Smoke is Article s0-08 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

← Three.js From ZeroS0-08 · Quick Wins

Season 0 · Article 08 · Quick Wins

A single plane, perlin-noise distortion that rises and dissipates, soft additive blending. The cozy atmospheric loop that goes in every café-themed landing page. ~90 lines of GLSL plus the cup model.

— fps

Why a plane works better than particles

You could simulate smoke with thousands of particles. It would look like a smoke cloud. What we want is the wispy column above a coffee cup — that's better as a vertically-stretched plane with a shader sampling 3D noise over time. One draw call, soft falloff at edges, infinite detail.

Step 1 — The plane geometry

const smoke = new THREE.Mesh(
  new THREE.PlaneGeometry(1, 1.8, 16, 64),    // wider segments on Y for fluid silhouette
  smokeMaterial,
);
smoke.position.y = 1.2;
smoke.rotation.x = 0;   // facing camera (we'll billboard later if needed)
scene.add(smoke);

Resolution matters here: 16 × 64 segments. The vertex shader will displace these vertices using noise — denser segments = smoother distortion. 16×64 is enough for a single column.

Step 2 — Vertex shader: gentle sway

const vertexShader = /*glsl*/`
  uniform float uTime;
  varying vec2 vUv;
  void main() {
    vUv = uv;
    vec3 pos = position;
    // Subtle sway — taller parts move more
    float sway = sin(uTime * 0.7 + position.y * 4.0) * position.y * 0.05;
    pos.x += sway;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
  }
`;

The displacement scales with position.y — at the base (y near 0) nothing moves; at the top (y near 1) full sway. That's how real smoke behaves: anchored at the source, free at the tip.

Step 3 — Fragment shader: noise + rising mask

const fragmentShader = /*glsl*/`
  uniform float uTime;
  varying vec2 vUv;

  // Hash + value noise (simpler than simplex, fine for smoke)
  float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
  float noise(vec2 p) {
    vec2 i = floor(p), f = fract(p);
    f = f * f * (3.0 - 2.0 * f);
    return mix(mix(hash(i), 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) {
    return noise(p) * 0.5 + noise(p * 2.0) * 0.25 + noise(p * 4.0) * 0.125;
  }

  void main() {
    // Sample fbm noise, scrolling upward over time
    vec2 nuv = vUv * vec2(2.0, 1.0);
    nuv.y -= uTime * 0.15;        // scroll upward
    float n = fbm(nuv * 3.0);

    // Mask 1: rise — top is more transparent than bottom
    float rise = smoothstep(0.0, 0.3, vUv.y) * smoothstep(1.0, 0.6, vUv.y);

    // Mask 2: column shape — center bright, sides faded
    float column = smoothstep(0.0, 0.2, vUv.x) * smoothstep(1.0, 0.8, vUv.x);

    float alpha = n * rise * column * 0.8;
    gl_FragColor = vec4(vec3(0.85, 0.85, 0.85), alpha);
  }
`;

Three pieces multiplied:

  • fbm — fractal Brownian motion noise. Adds turbulent structure. The classic smoke texture.
  • rise — vertical alpha gradient. Bright at the bottom (close to source), fading at the top (dissipating).
  • column — horizontal alpha gradient. Centered column shape, fading at edges.

Without all three, smoke either fills the entire plane (no shape) or dies before rising (no animation).

Step 4 — Material settings

const smokeMaterial = new THREE.ShaderMaterial({
  uniforms: { uTime: { value: 0 } },
  vertexShader, fragmentShader,
  transparent: true,
  depthWrite: false,
  blending: THREE.NormalBlending,   // not Additive — smoke is a real material, not glow
  side: THREE.DoubleSide,
});

NormalBlending not Additive — smoke isn't a light source, it's a translucent gas. Additive would make it brighter than the background, which looks like steam in front of a torch, not coffee in front of a window.

Pitfall. depthWrite: false is mandatory for transparent materials that overlap. Without it, the back face of the plane occludes the front face and you get sorting artifacts.

Step 5 — A cup so the smoke has somewhere to come from

const cup = new THREE.Mesh(
  new THREE.CylinderGeometry(0.35, 0.28, 0.5, 24),
  new THREE.MeshStandardMaterial({ color: '#3a2616', roughness: 0.7 }),
);
cup.position.y = -0.25;
scene.add(cup);

// Coffee inside (a dark disc)
const coffee = new THREE.Mesh(
  new THREE.CircleGeometry(0.32, 32),
  new THREE.MeshBasicMaterial({ color: '#1a0f08' }),
);
coffee.rotation.x = -Math.PI / 2;
coffee.position.y = 0.0;
scene.add(coffee);

The cup grounds the smoke visually. Without it, you have a column of noise floating in space.

Common first-time pitfalls

"Smoke is square — I can see the plane edges." The column mask is too sharp. Increase the falloff range, e.g. smoothstep(0.0, 0.35, vUv.x).
"Smoke is uniform — no variation." You're using a single noise octave. fbm (multi-octave) gives the variation. Make sure you're calling the fbm helper, not noise directly.
"Smoke is white but I want gray." Color uniform is too bright. Drop to vec3(0.55, 0.55, 0.55) for a darker, more realistic smoke. Or modulate by noise: darker in dense areas.

Exercises

  1. Wind. Add a horizontal noise offset that increases with vUv.y. The smoke leans in the wind. Bonus: animate the wind strength.
  2. Billboard the plane. The smoke is currently flat — visible from one angle. Wrap it in a parent Object3D that always rotates to face the camera (look at camera.position each frame, project on Y).
  3. Multiple stacks. A row of three cups, each with its own smoke column at different phase offsets. A whole café scene.