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.
// 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;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.
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>
}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.
// 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;0.34. Multiplied by pow(uv.y, 2.0), so it is exactly zero at the cup0.0 and the quad is a flat hazeDefines, 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.
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,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.
