fire-smoke-skill-vfx
Build the effects of a fire skill as one visual language in Three.js. Flames that read as fire, not as orange fog or cones: ray-marched flame volumes whose turbulence rises with buoyancy (slow at the root, accelerating and stretching as it climbs) instead of scrolling, a temperature ramp from a white-hot core a little above the fuel through yellow and orange to deep red tips, broad brighter licks inside, walls of fire broken into clumps of separate tongues of very different heights with notches between them and licks that detach and go out, a fire whirl wound in helical sheets with a waist and a blue root; charcoal smoke drawn premultiplied over the frame and lit only by the fire (cauliflower billows for a burst, a thin streaked curtain off a grass-fire front, fibrous wisps off small fires, ragged hot cracks inside a burst's smoke); embers and sparks that drift on a curl field, cool from white through orange to red and fall as ash; heat haze as refraction; firelight shared with the world; a world-space heat map that makes the ground's needles smoulder as hairlines and its pockets breathe; and gentle ink impact frames limited to three flashes a second. Use for fire or flame skills, fireballs, fire waves or breath, rings of fire, fire tornadoes or whirls, burning ground, wildfire fronts, smoke columns, embers, smouldering aftermath, "fire and smoke", "realistic fire", "flame thrower", "inferno", "burning field" or "skill effects only". Includes a reusable module and a five-beat demo on a scanned CC0 burned-ground floor with failure-mode switches.
Install
npx skills add https://github.com/MengTo/Skills --skill fire-smoke-skill-vfxFire and Smoke Skill VFX
Use this for the effect half of a fire skill: what the hand holds, what it throws, what burns and what the fire leaves. Reach for a sibling instead in these cases:
- Lightning, an energy orb, black afterimage smoke and air bursts:
lightning-energy-skill-vfx. This skill reuses its clock, composite, impact frames and flash limiter. - Readable, budgeted gameplay effects in general (telegraphs, status, cleanup rules):
create-game-vfx. Its readability rules still apply here. - Scoring a skill out of 10, or polishing the caster's body and timing:
game-dev-combat-skill-polish.
Source: a brief for elemental skill pages built to the Black Lightning demo's bar, with the notes its owner gave on that demo ("not as in-your-face", "more fine lines", "smooth transitions", "a more realistic floor", "no sliding textures", "more detail in the smoke"). There is no character: the flame is held by an implied hand at chest height. Then two rounds of a dream loop against GPT Image edits of the page's own frames (see references/scorecard.md [blocked]).
Verified stack: Three.js r170, WebGL2, GLSL ShaderMaterials, HalfFloat render targets, a 96³ baked noise texture, and a hand-written bloom and composite. No post-processing library. The demo's floor is Poly Haven's Burned Ground 01 by Rob Tuytel (CC0), its 1k colour, normal and AO/roughness maps in demo/assets/ground/, embedded by demo/build.mjs as data URIs so the page opens from disk.
The mechanism
Fire is a field that rises, cools and lights the world; smoke is what the fire lights.
- One flame shader, three shapes. Every flame is a ray march through a signed field: a profile (a plume from a source disc, a wall along an arc, a teardrop ball) minus the distance from its axis, pushed in and out by noise. Density is a narrow band of that field, so tongues have crisp edges and islands above the tips are detached licks.
- Buoyancy, not scrolling. The noise's height coordinate is
y0·ln(1 + h/y0)minus time, so features leave the root slowly and accelerate and stretch as they climb, and a slow warp drifting through the noise deforms them on the way. A scrolled texture (the Scrolled switch) slides over the flame. - Temperature drives everything. One temperature per sample picks the colour from a ramp, decides what hides the background (only hot gas does) and where soot absorbs (only a burst's cooling gas). The hottest gas along a ray shows through the cooler skin.
- Detail is band-limited to the buffer. Flames march into their own reduced-resolution target. Every octave fades out before it gets finer than a few pixels of that target at the sample's distance, and the target comes up with a smooth B-spline. Detail the buffer cannot hold aliases into a mosaic.
- One light set for everything. Eight firelights (one for flashes, seven claimed by flames and stage lights) light the ground, the smoke and the haze. They swell fast and settle slowly, so firelight breathes and never strobes.
- One clock and one composite, as in
lightning-energy-skill-vfx: holds freeze every layer at once, heat haze and pressure fronts write into one half-resolution distortion buffer, and the impact frame draws after tone mapping.
Reuse the working effect
Copy assets/fire-fx.mjs [blocked]. You pass in your own THREE, and the module does not import a second copy. The demo [blocked] runs this same module, inlined by demo/build.mjs because a page opened from disk cannot import modules.
Other calls:
fx.flame(opts)returns a flame you change every frame (radius,height,pos,intensity,temp,light,smoke,embers, ...) and end withkill(seconds). Options:- shape:
mode('plume','arc','ball'),pos,radius,height,lean{x, y},ragged(tongue depth, default 0.24 plume / 0.4 arc / 0.34 ball),stretch(how tall the features are, default 1.25),scale(noise feature size in metres),swirlandspin(a fire whirl: helical sheets, funnel, S-bend),wander(the axis drifts),billow(a burst: rounded lobes, cooling unevenly into hot pockets),wobbleandhvar(arcs: radius lobes and height variation); - colour:
intensity,temp(1 = nominal; above 1.05 the light runs hotter),blue(a blue root band),soot(absorbs only where a burst cools); - output:
light(claims a firelight slot),smoke,smokeKind('billow'or'wisp'),smokeSize,smokeLife,embers,heat(into the heat map),haze; - life:
fadeIn(seconds),steps(pin the march steps),seed(pin the noise; the first 121 flames take the demo's recorded seeds unless you pass one).
- shape:
fx.flameArc({ center, R, w, height, a0, a1, segments, lights, ...flame options })returns{ segs, set(props), kill(s), alive }.a1 − a0 ≥ 2πmakes a closed ring; a fan's ends taper.fx.spawnSmoke(pos, vel, opts):type(0 billow, 1 ash flake, 2 wisp, 3 curtain),size,grow,life,opacity,glow,aspect,fibre(4.5 or more marks a burst's hot-pocket sprite),buoy,drag,curl,color.fx.sparks(pos, opts)andfx.spawnSpark(pos, vel, opts):count,dir,spread,speed,life,size,heat,cool,gravity,drag,buoy,curl,flake(the chance a cooled spark falls as ash).fx.addHeat(pos, radius, amount, { burn, tau, shape, R, w, a0, a1 }): a lasting heat patch (disc, or a ring band for a front) that cools as e^(−t/τ) and leaves a burn mark.fx.flash(pos, energy, decay, hot),fx.glint(pos, { size, life, intensity }),fx.ring(center, normal, { radius, thick, life, delay, amp }).fx.impact({ hold, frame: 'center' | 'full' | 'none', flash, shake, shakeMode: 'axial' | 'radial', fisheye, at, radius }).fx.ramp(scale, seconds),fx.repeat(ticks, fn),fx.curlAt(x, y, z, t, out),fx.stats(),fx.debug(noFlames,noSmoke,noHaze,noSparks).fx.options:noise('advected'|'scrolled'),smoke('over'|'additive'),flashes('full'|'safe'),shake,fisheye,tickHz30,lightGain2.4,flameScale(the flame layer's share of the buffer, set per shot) andflameBudget260 (march work above this lowers the scale, never below 0.5).
The layers, with the numbers that shipped
Cut a brief into beats
Between beats, ease. Every size and intensity a beat sets is driven through its own curve; flames fade in over fadeIn and out over kill(seconds); lights swell and settle. Nothing pops on a beat change.
Rules, each with the failure it prevents
Each rule came from a capture of this demo that went wrong, flagged by a reviewer, a judge or the person it was built for.
Randomness and layout
fract(sin(x)·43758)is not random on Metal. Every tongue of a front came out tall, so walls read as combs. A test that gave each tongue either tall or short proved it. Use an arithmetic hash (p = fract(p·0.1031); p *= p + 33.33; p *= p + p), in the shader and wherever the CPU must agree.- Give every feature its own random stream, or layouts reshuffle. One round added particles drawn from the stream that also seeded the flames and placed the burning patches; every flame after them changed, the wave lost its right-hand fires and the ring moved away from the approved frame (overlap 0.32 → 0.12). The demo now records the approved cast's 121 flame seeds in order and gives later features a second stream. Test parity by recording the seeds through the capture protocol.
- Never change a seed to make a layout look nicer once a layout is approved. The target is the layout to match.
Flames
- Band-limit detail to the flame buffer's footprint, and upsample with a B-spline, never sharpened. Thin sheets finer than the reduced buffer could hold, brought up with a sharpened Catmull-Rom, turned every flame into a 2–4 px mosaic. Fade each octave by its size in buffer pixels; detail adapts when the buffer shrinks, and a lower
flameScalecosts detail, never blocks. - Advect the noise with buoyancy; never scroll it. A scrolled texture reads as orange cloth sliding upward.
- Saturate the light on the surface. An exponential falloff deep into the volume made blobby eggs; σ = 6/R with a bisected entry gives crisp tongues and a root that shows through the skin only partly.
- Put the brightest gas a little above the fuel. A hot band right at the base read as a glowing line on the ground.
- Walls read as curtains or combs. A uniform band reads as a curtain, equal tongues as a comb, a presence envelope as a tent. Break a front into clumps with real gaps, tongues of very different heights (tallest 2–3× the shortest) unevenly spaced with notches down near the root, outer tongues shorter and leaning out, and tips that detach as short licks.
- Weight overlapping sectors' light, not their density. Blending density across a shoulder dimmed a band at every seam.
- Let blue replace orange at the root. Added, the two read as pink.
- Soot only in a burst. Soot in every flame's cool gas stood up as black posts.
- Fade the gas before every bound. A billow that outgrew its march bound showed as a dark box.
Smoke
- Smoke puffs read as floating boulders. Round billows that leave a front as lumps read as rocks. Off a front, use a thin streaked curtain born small and faint among the tongue tips, with no underside, lit by its height above the fire as one sheet; save cauliflower billows for a burst or a column, where the mass is the point.
- Light smoke only from the fire. A built-in glow kept old puffs orange as they rose, and round shading lit every puff as a rimmed lump. Firelight per corner with a floor, warm low and dark grey-black high.
- Hot pockets are cracks in the creases, not round lamps. Soft round emission read as glowing orbs; a ridged mask confined to the gaps between billows reads as fire inside the smoke.
- Hand a burst's soot over early. The flame layer is drawn over the smoke, so a cooling fireball's soot hid the column behind it; the fireball goes by 0.7 s and the billows carry on.
- Premultiplied over, never additive. Additive black is grey haze.
Ground, light and frame
- Cell patterns read as flagstones. Worley clods with dark gaps read as orange paving; stretched noise normals at a grazing view read as a rippled lake. Use a lump height field, neutral charcoal and a matte surface.
- Keep the firelight tight, and give big sources their own falloff. One wide falloff lit the whole plain rust-orange; a tight one left the fire tornado without its ground pool until the pool got a per-light falloff and a stage light could take a flame's slot.
- Film grain inflates high-frequency energy. A 0.028 grain gave the black backdrop 4× the target's high-frequency energy and read as noise; a 0.0022 dither only debands.
- Measure regions against the target before tuning by eye. Mean colour in fixed boxes (sky, horizon, ground, smoke base, smoke top) settled what "too warm" and "too dark" meant every time.
- Warm the sky only near the fire. A warm brown horizon read as dirty haze; the target's night is slate.
Cost
The cost is fill rate: the flame marches, smoke over smoke and the full-screen ground. Measured with WebGL timer queries at 1440 × 900 and a pixel ratio of 1.5 (2160 × 1350), each sampled frame redrawn 6 times and the fastest kept, every 6th frame of each beat:
Medians in ms. The impact's peak (1.2–1.6 s after the hit, many arc sectors plus the burst) is the heaviest stretch; options.flameBudget lowers the flame scale automatically if the march work runs past it. The demo caps the pixel ratio at 1.5.
Lifecycle and accessibility
fx.update(realDt)clamps a step to 0.1 s. Reset your frame clock onvisibilitychange.- Under
prefers-reduced-motion: reduce, the demo holds a composed still (the fire tornado at its height) and plays beats only on request, with safe flashes, no shake and no fisheye. - The demo's interface is minimal: no panels, small text controls over a faint scrim. Switches are real buttons with
aria-pressed, beats are on keys 1–5 (0 for the full cast), Space pauses, H hides the controls, and changes are announced in a live region. No text renders under 11 px. window.stage(play,step(dt, n),setPaused,freeze,fx,BEATS, ...) is there for deterministic headless captures.
Verify
- With Scrolled on, the flames slide upward like printed cloth; with Rising on, features leave the root slowly and stretch as they climb.
- With Additive on, the smoke turns to grey haze; with Over it darkens the frame.
- Zoom 4× into any flame: smooth gradients, no 2–4 px cells, no grain along the edges.
- The wave's front is clumps of separate tongues with gaps and detached licks, its smoke a thin streaked curtain joined to the tips; no round puffs sit on the flame line.
- The fire tornado has a waist, an S-bend, wound sheets, a blue root band and a pool of light at its foot; its smoke leaves the top.
- The burst cools into dark billows with a few ragged hot cracks, not orange lamps; its ring is lobed and broken.
- The ground is neutral charcoal with ash flecks and pebble relief; the far plain stays near black, needles glow as hairlines only where it is hot.
- Fire five inverting hits inside a second with Full flashes: the first three invert, and the fourth and fifth dim by 0.32 instead.
- Reduced motion holds a still with safe flashes. Opened from disk, the page makes 0 network requests. The console is clean at 1440 × 900 and 390 × 844, and the portrait camera backs off by (1/aspect)^0.6.