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. The version of this that shipped first was five billboarded blobs on a loop, and it looked like five blobs on a loop — you could count them, and the cycle repeated on a timer you could feel.
What replaced it has no pool and no lifetime. There is 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, and 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 is not 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.
A plane’s z is zero at every vertex. Rotating newPosition.xz therefore does not 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 is the whole of the fake volume. There is no thickness anywhere, but the silhouette changes as it climbs, which is the part of a real column your eye is actually reading.
// Twist: rotate each ring of the column about the y axis by an amount that
// varies with height. Sampled from a single vertical line of the noise
// (x = 0.5) so neighbouring rings turn together — a constant angle would
// just rotate the whole plane, and a regular function would read as a screw.
float twistPerlin = texture2D(uPerlin, vec2(0.5, uv.y * 0.2 - uTime * 0.005)).r;
newPosition.xz = rotate2D(newPosition.xz, twistPerlin * U_TWIST);
// Wind: a slow lateral drift, read off two DIFFERENT vertical lines so x and
// z don't get identical values and collapse the movement onto a diagonal.
// Centred to +/-0.5 so it pushes both ways, and weighted by the square of
// the height so the base stays anchored on the cup and only the top wanders.
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 asymmetric. 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 at all, 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 that 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, which is the sort of sign error you only make once.
void main()
{
// squash the sample so one noise tile covers the whole column, and scroll it
// 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;
// raw noise never reaches 0, so the plane would be a solid haze. Collapsing
// everything under 0.4 to nothing is what carves the gaps between wisps.
smoke = smoothstep(0.4, 1.0, smoke);
// fade the plane's own edges out, against the ORIGINAL uv rather than the
// scrolled one, or the fade would travel with the noise. The top fades from
// much further down (1.0 -> 0.4) so the plume thins out as it climbs instead
// of ending on a line.
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 are 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 it 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, and neighbouring rings therefore turn together. The fragment stage is the exception, and deliberately so: alpha is meant to vary across the surface, so it samples the full squashed uv.
The wind needs two different lines because it drives two axes. Read x and z off the same line and they receive identical values every frame, which means 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: a slow lateral drift read off a single line of the noise field, so
// the whole column moves as one body rather than boiling in place. Weighted
// by the square of the height — the base stays anchored on the cup and only
// the top wanders.
float wind = (noise(vec2(0.25, uTime * 0.06)) - 0.5) * uWind;
p.x -= wind * uv.y * uv.y;
// TWIST: rotate the sample about the column's axis by an amount that varies
// with height. A constant angle would just spin the whole plane; a regular
// function of height reads as a screw thread. Sampling the noise along one
// vertical line is what makes neighbouring heights turn together.
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.4 collapses to nothing. Set it to 0.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. The measurable saving is negligible — three fewer uniform uploads a draw is nothing, and claiming otherwise would be dishonest. The distinction is real for a different reason: a uniform is a promise that a value can change, and these three cannot. Left as uniforms, the next person to open the file has to read the whole render loop to discover that nobody ever writes to them.
const mat = useMemo(
() =>
new THREE.ShaderMaterial({
vertexShader: SMOKE_VERTEX,
fragmentShader: SMOKE_FRAGMENT,
// Only the two values that actually change are uniforms. The plume's
// opacity, wind and twist are fixed for the life of the scene, so they
// are #defines in the shader source instead — see shaders/smoke.ts.
uniforms: {
uTime: new THREE.Uniform(0),
uPerlin: new THREE.Uniform(perlin),
uColor: new THREE.Uniform(DAY.clone()),
},
// soft alpha haze over a small area: mediump is invisible here and is a
// real saving over the highp the renderer would otherwise hand it
precision: "mediump",
transparent: true,
// the plane folds through itself as it twists; without this it would
// occlude its own far side and show the seam
depthWrite: false,
side: THREE.DoubleSide,
}),
[perlin],
);What survives as a uniform is the clock, the handle on the baked noise — a texture cannot be a define — and a colour lerped from #f6f3ec toward #dde6f2 as the scene runs to night. Only two of the three are 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 volume.
