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.
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.
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.
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
column mask is too sharp. Increase the falloff range, e.g. smoothstep(0.0, 0.35, vUv.x).fbm (multi-octave) gives the variation. Make sure you're calling the fbm helper, not noise directly.vec3(0.55, 0.55, 0.55) for a darker, more realistic smoke. Or modulate by noise: darker in dense areas.Exercises
- Wind. Add a horizontal noise offset that increases with vUv.y. The smoke leans in the wind. Bonus: animate the wind strength.
- Billboard the plane. The smoke is currently flat — visible from one angle. Wrap it in a parent
Object3Dthat always rotates to face the camera (look atcamera.positioneach frame, project on Y). - Multiple stacks. A row of three cups, each with its own smoke column at different phase offsets. A whole café scene.
UP NEXT
S0-09 — Particles Cursor → A particle field that reacts to the mouse. Micro-interaction polish.