Skip to content
03 / 103D7 min read

Plume

The steam off the coffee mug in the hero is one tall quad. The vertex shader folds it, the fragment shader carves its alpha out of scrolling noise, and nothing in it is a particle.

Used on this site

This is the plume rising off the mug on the desk in the hero diorama on the home page. One draw call, no simulation, and a per-frame cost of two uniform updates and an atan2.

Paused
Drag Threshold to zero. That flat grey rectangle is what the quad actually is. Everything else is one smoothstep.

The steam off the coffee mug is a single quad, and at no point does it stop being a single quad.

The default answer to smoke is particles: a pool of billboards, a buffer to update, a spawn rate, a lifetime, and per-frame CPU work proportional to how convincing you want it to be. I know because that's what I built first. Five billboarded blobs on a loop, and it looked like exactly that. You could count them, and the cycle repeated on a timer you could feel.

What replaced it has no pool and no lifetime. There's one PlaneGeometry standing on the mug's lip, and two shader stages doing quite different jobs to it. The vertex program folds the plane into something with a silhouette. The fragment program decides which parts of it exist.

One plane, folded

The geometry is PlaneGeometry(1, 1, 12, 48), translated so its base sits at the origin and scaled to 0.34 × 0.8 × 0.34 before it ever reaches the mesh. The subdivision isn't decoration. 48 rows give the twist somewhere to vary along, and 12 columns give each row something to curl into. Sizing happens in the geometry rather than on the mesh because the vertex shader displaces raw position, so the numbers it works with have to already be the numbers you see.

Here's the part I find genuinely pleasing. A plane's z is zero at every vertex, so rotating newPosition.xz doesn't spin a flat card. It sweeps each vertex's x out into z, and because the angle varies with height, the quad curls into a ribbon that faces a different direction at every level. That's the whole of the fake volume. There is no thickness anywhere, but the silhouette changes as it climbs, and the silhouette is the part your eye actually reads.

src/components/scene/shaders/smoke.ts — SMOKE_VERTEX
// twist: angle varies with height, sampled along one vertical line
// (x = 0.5) so neighbouring rings turn together
float twistPerlin = texture2D(uPerlin, vec2(0.5, uv.y * 0.2 - uTime * 0.005)).r;
newPosition.xz = rotate2D(newPosition.xz, twistPerlin * U_TWIST);

// wind: two different vertical lines, so x and z don't collapse onto a
// diagonal. weighted by height squared so the base stays on the cup
vec2 windOffset = vec2(
    texture2D(uPerlin, vec2(0.25, uTime * 0.01)).r - 0.5,
    texture2D(uPerlin, vec2(0.75, uTime * 0.01)).r - 0.5
);
windOffset *= pow(uv.y, 2.0) * U_WIND;
newPosition.xz += windOffset;
Seventeen lines, and the plume's entire motion.

The two displacements are deliberately different from each other. twistPerlin is a raw texture read in the range 0 to 1 and is never centred, so the rotation only ever turns one way, spanning 0 to 5.5 radians, just short of a full turn. The wind is centred, by subtracting 0.5, because a drift that only pushed one direction would walk the plume off the cup. And the wind is weighted by pow(uv.y, 2.0): at the base that factor is zero, so the bottom row cannot move no matter what the noise says. The plume stays welded to the mug and only the top wanders.

One smoothstep from a flat haze

The fragment stage has one job: decide alpha. It squashes the uv so less than one noise tile covers the whole column, scrolls that sample downward through the field, and reads a single channel back out. Scrolling down is what reads as rising. That's the kind of sign error you make exactly once.

src/components/scene/shaders/smoke.ts — SMOKE_FRAGMENT
void main()
{
    // scrolled downward through the uv, which reads as the smoke rising
    vec2 smokeUv = vUv;
    smokeUv.x *= 0.5;
    smokeUv.y *= 0.3;
    smokeUv.y -= uTime * 0.03;

    float smoke = texture2D(uPerlin, smokeUv).r;

    // collapsing everything under 0.4 carves the gaps between wisps
    smoke = smoothstep(0.4, 1.0, smoke);

    // edge fades read the original uv, not the scrolled one, or the fade
    // travels with the noise. the top fade starts low so the plume thins out
    smoke *= smoothstep(0.0, 0.1, vUv.x);
    smoke *= smoothstep(1.0, 0.9, vUv.x);
    smoke *= smoothstep(0.0, 0.08, vUv.y);
    smoke *= smoothstep(1.0, 0.4, vUv.y);

    gl_FragColor = vec4(uColor, smoke * U_OPACITY);

    #include <tonemapping_fragment>
    #include <colorspace_fragment>
}
The threshold on line 72 is the one that matters. Everything else is bookkeeping.

smoothstep(0.4, 1.0, smoke) is the single most important line in the file. Gradient noise never reaches zero, it clusters hard around its own midpoint, so without a threshold every pixel of the quad carries some alpha and you're looking at a rectangle of faintly mottled haze. Collapsing everything under 0.4 to nothing is what cuts the gaps between the wisps, and the gaps are the only reason this reads as smoke rather than as fog on a card.

Sampling a line, not a surface

The obvious way to drive the motion is to read the noise at each vertex's own uv. Do that and the column boils. Every point gets its own independent value, the surface seethes, and nothing about it suggests a body of gas moving through a room. Both of the shipped displacements instead read the noise along a fixed vertical line: x = 0.5 for the twist, x = 0.25 and x = 0.75 for the wind. One wandering value per height, so neighbouring rings turn together. The fragment stage is the exception, deliberately, because alpha is meant to vary across the surface. It samples the full squashed uv.

Why two different lines for the wind? Because it drives two axes. Read x and z off the same line and they receive identical values every frame, the offset is always (n, n), and the entire drift collapses onto a 45-degree diagonal. Two lines, two uncorrelated walks, and the top of the plume wanders in a plane instead of along a wire.

plume.frag.glsl
// wind: one line of the noise field, so the column moves as one body.
// weighted by uv.y squared, so the base stays anchored on the cup
float wind = (noise(vec2(0.25, uTime * 0.06)) - 0.5) * uWind;
p.x -= wind * uv.y * uv.y;

// twist: angle varies with height, sampled along one vertical line
// so neighbouring heights turn together instead of shredding
float twist = (noise(vec2(0.5, uv.y * 0.9 - uTime * 0.05)) - 0.5) * uTwist;
float s = sin(twist),
  c = cos(twist);
p.x = p.x * c - (uv.y - 0.5) * s * 0.06;
The demo above, which has no rings to turn: it applies the same twist and wind to the sample coordinate rather than to a vertex, and needs only one wind line because it drifts on one axis.
ConstantDefault
U_TWIST5.5radians at the top of the noise's range. The sample is never centred, so the column only ever turns one way
U_WIND0.17lateral drift, in the plane's own units against a width of 0.34. Multiplied by pow(uv.y, 2.0), so it is exactly zero at the cup
threshold0.4everything under it collapses to nothing. Set it to 0.0 and the quad is a flat haze
scroll rate0.03how fast the field runs down through the uv, which is what reads as the smoke rising
tile squash0.5 × 0.3how much of one noise tile the column sees at once. The tile is baked to wrap without a seam, so the squash is about feature size, not hiding one
U_OPACITY0.55the alpha ceiling. This composites on normal blending, so it is a haze and not a light

Defines, not uniforms

U_WIND, U_TWIST and U_OPACITY are #defines in the shader source rather than uniforms on the material. A uniform is a value the renderer hands the program on every draw. A define is a number baked in at compile time, where the compiler can fold it into the expressions around it. Is the saving measurable? Not really. Three fewer uniform uploads per draw is nothing, and I'd be making it up if I claimed otherwise. The real reason is different: a uniform is a promise that a value can change, and these three can't. Leave them as uniforms and the next person to open the file has to read the whole render loop to discover nobody ever writes to them.

Steam.tsx
  g.translate(0, 0.5, 0);
  g.scale(0.34, 0.8, 0.34);
  return g;
}, []);
useEffect(() => () => geo.dispose(), [geo]);

const mat = useMemo(
  () =>
    new THREE.ShaderMaterial({
      vertexShader: SMOKE_VERTEX,
      fragmentShader: SMOKE_FRAGMENT,
      // only the values that change are uniforms. opacity, wind and twist
      // are fixed for life, so they're #defines in shaders/smoke.ts
      uniforms: {
        uTime: new THREE.Uniform(0),
        uPerlin: new THREE.Uniform(perlin),
        uColor: new THREE.Uniform(DAY.clone()),
      },
      // mediump is invisible on a soft haze, and cheaper
      precision: "mediump",
      transparent: true,
Three uniforms left. Depth writing off and DoubleSide are both consequences of the folding.

What survives as a uniform is the clock, the handle on the baked noise (a texture can't be a define), and a colour lerped from #f6f3ec toward #dde6f2 as the scene runs to night. Only two of the three get written per frame. depthWrite is off and the material is DoubleSide because the twist folds the plane through itself. Leave depth writing on and the ribbon occludes its own far side, and the seam where it does is a hard straight line across the middle of your soft volume.

P.S. This one is running on the site right now.

WebGL · Shader ·
← Back to the lab
Creative Web Designer & Developer25.2048°N · 55.2708°E

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E