lightning-energy-skill-vfx
Build the effects of a close-combat energy skill as one visual language in Three.js. Lightning that reads as lightning, dynamic but never in your face: trees of strips (trunk, forks of forks, hair-fine branchlets) with a fine white core in a violet-blue glow, each channel reaching out as a stepped leader, flaring as it connects, holding its shape and fading through an afterglow, one discharge at a time; a storm-cloud orb (a lit billow surface that churns in place, 10-15 plasma-globe arcs from a small white core to its inner wall, crawlers on the rim, a hairline fresnel); ground strikes that flash when the leader arrives, crawl along the stone's own cracks and reflect in wet stone (the demo's floor is a scanned CC0 texture); a torn black shadow with dry-brush edges that hangs off the orb and streams behind it, air bursts felt as refraction, orange sparks, fractured rocks that land or float in a storm domain, and impact frames that hold, invert in two-tone ink with tapered speed lines, shake and bend the lens, limited to three flashes a second. Use for anime or dark-fantasy skill effects, lightning or thunder fists, plasma or energy balls, charge-ups, dashes with shadow trails, ground strikes, storm or domain rings, ultimates, hit-stop, impact frames, negative flashes, screen shake, "black lightning", "thunderstorm fist", "realistic lightning", "afterimage" or "skill effects only". Includes a reusable module and a five-beat demo with failure-mode switches.
Install
npx skills add https://github.com/MengTo/Skills --skill lightning-energy-skill-vfxLightning Energy Skill VFX
Use this for the effect half of a skill: what the fist throws, what the air does, what the hit leaves. Reach for a sibling instead in these cases:
- 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-polishandgame-dev-combat-animation.
Source: a 30-second skill-showcase brief for a dark-fantasy ability user. White-blue lightning, a semi-transparent thunderstorm orb ahead of the fist, black smoke afterimages like a shattered cloak, air bursts, orange sparks and impact frames. Only the effect language is kept here. The character, story and shot list are left out.
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 Dry 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 still opens from disk.
The mechanism
One effect clock, one composite, and a shadow that belongs to the orb.
- One clock. Lightning re-rolls on it, a hit freezes it (a hold), and a time ramp slows it. A 0.1 s hold therefore stops sparks, smoke, rings and lightning together.
- One composite. Every layer that bends the air writes a signed screen offset into one shared distortion buffer. The composite reads colour through that offset, adds bloom and tone maps once. Only then does it draw the impact frame on top, so a negative flash inverts the finished image rather than one layer.
- The orb carries its own shadow. A black teardrop is drawn behind the orb, and ribbons and billows leave from the orb's back. Wherever the fist goes, the black follows it; nothing is emitted from a body you cannot see.
- Lightning is a tree of strips in one point ring. A bolt is a trunk, its forks, their forks and twigs. All their points live in one ring buffer and the GPU buffers are sized by a point budget, so thousands of twigs fit. Points of bolts anchored to the orb are stored relative to it and the anchor is a uniform, so the buffers change only when the lightning re-rolls (30 Hz), not every frame the orb moves.
- Lightning has a life, drawn in the shader. Every vertex carries when the leader reaches it, so a channel grows out from its origin (forks from their junctions) in 30–90 ms on the lightning's own clock, flares as the return stroke connects, holds its shape for a few ticks, and fades through an afterglow. The ground flashes when the leader arrives.
Reuse the working effect
Copy assets/storm-energy.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.bolt(a, b, opts):levels,jag,width,minPx,intensity,life(ticks),hold(keep the shape at full strength for its life, then an afterglow),leader(seconds to reach out; default by length, 0 = at once),anchor,branches(first forks),twigK(twigs per fork),twigs(fork depth, up to 3),fringe(hair-fine branchlets along trunk and first forks),forkLen,core(the white core's share),flat(pin to a height: ground arcs),shell/inside(on or within a sphere round the anchor),inner(inside the orb: dimmed behind its core),path(follow a polyline),hit(false: no ground strike).fx.sparks,fx.debris,fx.puffandfx.shards.fx.ringfor a single pressure front.fx.createTrail({ chain, chainSize })for any other smoke stream, with billows laid along its path.fx.vortex(center, opts)for a storm domain.fx.addHeat(pos, radius, amount)to make the ground glow.fx.ramp(scale, seconds)for a time ramp.fx.repeat(ticks, fn)to re-throw something on the next lightning ticks.fx.attractorto pull everything into a charge;attractor.lift(0–1) makes debris float in it.
The layers, with the numbers that shipped
Light and heat. Six point lights in (0.30, 0.42, 1.0): bluer than white, paler than the glow. Diffuse falls off as 1/(1 + 2.6d²); the specular reaches further, 1/(1 + 0.45d²), and grows with Fresnel at grazing angles, pow(N·H, mix(140, 8, rough)) × (1 − rough) × (1.5 + 10F), so wet stone catches a strike's light across the ground and not only in a pool under it.
- Orb: (1.6 + 5.5 × pressure) × radius/0.45.
- Impact: 14 × strength, warm cooling to blue.
- Bolt flash: min(length × brightness × 2.2, 26).
- Blast: 30 × strength.
- Two strike slots, taken in turn: 15 k at 0.14 m over the contact.
Heat spots work the same way for ground bursts and strikes. Ground bursts leave radius 1.5 × strength, decaying as e^(−1.3t). The ground's own cracks glow with the heat as hairlines, only where the heat passes 0.12–0.5.
Wet reflections. The same bolt ribbons are drawn a second time mirrored under y = 0 and stretched down 1.5×, with an inverted depth test (GreaterDepth), so they appear only where something nearer was drawn (the ground) and never over the sky. A wetness mask shared with the ground (WET_GLSL, evaluated per vertex) keeps them to the puddles, at 0.55 × (0.15 + 0.85 × wet), fading with height and dropping out for arcs lying on the stone.
Impact frames (composite, after ACES):
Composite:
- Dual-Kawase bloom with 5 levels from half resolution, threshold 1.3, knee 0.6, × 0.27, tinted (0.38, 0.42, 1.0): a white halo read as grey haze, and lightning light scatters blue.
- A half-resolution HalfFloat distortion buffer. The xy channels hold fronts and the funnel; zw holds the orb's lens.
- No chromatic split.
- One ACES pass, then sRGB, grain 0.03 and vignette 0.85. ACES clips each channel, so a bright glow in (0.42, 0.45, 1.0) came out near-white: keep halo colours deeply saturated and their intensity moderate.
- Scene: a slate horizon of (0.017, 0.024, 0.036) and sky of (0.0055, 0.0095, 0.017), measured off the targets, with dim torn cloud that heavy lightning lifts; the sky follows the lightning at 4/s, so it swells and settles instead of flickering.
- The floor (demo): the scan at 6 m a tile, so its plates are 0.2–0.5 m. Its light-brown mud becomes dark wet slate,
(0.036, 0.039, 0.048) × (lum / 0.2)^1.3 × (0.72 + 0.56·macro noise), its normals lifted 1.7× (dry mud is shallow), roughness 0.8 × its own map down to 0.16 in the wet hollows, and anisotropy 8 for the grazing views. Its AO channel marks the cracks:1 − smoothstep(0.27, 0.42, AO)darkens and roughens them, and the heat lights only the deepest,1 − smoothstep(0.2, 0.3, AO). Fine grit (simplex at 97/m) stays a screen-space bump for the wet glints.
Cut a brief into beats
Between beats, ease. The demo passes the orb's radius, pressure and instability through a short ease (radius at 9/s, the others at 4/s) after each beat sets them, and snaps only when the orb re-forms from nothing. Before that, the charge's first frame snapped the orb from 0.36 m to 0.1 m.
On a rigged character, the fist socket and the aim drive orb.position and orb.axis, and shoulder, elbow and head go into orb.reach. For a cloak on the body as well, add fx.createTrail({ chain }) streams from the shoulder bones. The demo has no body.
Rules, each with the failure it prevents
Each rule came from a capture of this demo that went wrong, flagged by blind judges or by the person it was built for.
Shadow and smoke
- Hang the shadow off the energy. Black cloak trails from an invisible body read as detached planks and blobs. The teardrop and smoke must leave from the orb itself.
- Give the shadow a direction and a torn edge. A round dark halo read as "a blurred static backdrop". A teardrop with tongues, a crisp edge, holes and a shredding tip reads as a cloak in motion.
- Black needs a lighter backdrop, premultiplied "over" blending and a high bloom threshold. Smoke at 0.003 reads against a slate horizon of 0.017–0.036; at 0.010 against a 0.016 sky it vanished, and a 0.046 sky read as washed-out grey. With a bloom threshold of 0.9, the orb's halo washed the black smoke into "blue haze". Additive black adds nothing (the Additive switch shows this).
- Keep the shadow's head inside the orb's silhouette. A teardrop head 1.25 radii wide showed round the rim as a dark ring and, below the orb, as a pedestal.
- Let the noise own every smoke outline. Alpha must reach zero at a ribbon's side whatever the noise, or the quad edge shows as a plank. A lengthwise streak term drew striations. Jitter must vary along the length: random per-point drift folded ribbons into glass shards. Keep ribbons narrower than their bends, with the tangent from i ± 2, or they fold like paper.
- Fray the black with dry-brush fibres, and never fill it flat. A crisp, smooth outline over pure black read as a cut-out silhouette with no detail. Fibres stretched along the motion that eat into the edge and go partly see-through at the rim, flecks tearing off, and faint charcoal folds inside read as smoke, as long as nothing lights its edge.
- Billows laid along a path need detailed edges and must stretch along that path. Round, soft billows read as "stamped blotches" or "an out-of-focus smear". Give them crisp three-octave erosion, stretch them 1–1.7× along the path, and fade them into the ground plane so no quad cuts it in a straight line.
The orb
- Draw the storm inside as a lit billow surface, and the lightning as real ribbons. Filaments where two noise fields cross zero read as grain, "not lightning at all". A soft ray-marched cloud blurred every billow into blue fog, even after its levels matched the target. March to the first solid hit, refine it, take a normal from the field's gradient and light it, and the lobes and creases read as cumulus.
- Light the faces you can see. The core is inside the cloud, so it lights only the far sides of the front billows. A key from above and in front, the arcs' own light along their segments, and cavities darkened by hit depth make the near-black creases and lit lobes the target shows.
- Measure before tuning by eye. The orb's interior percentiles (10/25/50/75/90%) against the target's showed a 10× gap in the darks and a too-saturated hue, where two judges only said "too bright" and then "too dark".
- Spread the arcs evenly and keep each one a single strand. Random wall ends left a quadrant empty and bunched the rest into a "hairy fan". Ends on a turning Fibonacci sphere, one held shape per channel, and few, splayed twigs read as 10–15 distinct veins.
- Size inner arcs by the orb's size on screen. At a fixed minimum width a small orb (storm ring) turned into a white knot.
- Keep the membrane a hairline and the lens outside it. A wide rim read as a neon ring. A lens bending inside the silhouette warped the orb's own arcs, and the sky it pulled over the edge read as a grey glass band. A bright opaque sphere reads as a light bulb (the Glow ball switch shows this).
- Let the cloud churn in place; never slide it. Spinning the cloud at 0.5–1.2 rad/s and scrolling its noise read as a texture sliding over the ball. Turn it very slowly and drift the warp field through it instead, so the billows deform where they are.
spin·t·(1.6 − r)shears without limit and winds the noise into rings; more than about 0.5 of radial shear smeared the billows into streaks.
Noise
- Bake it, but set the 3D texture yourself.
WebGL3DRenderTargetswaps in a texture that defaults to NEAREST and 8-bit, which showed as mosaic edges for two rounds. Use at least 16 texels per noise cell, or thresholds show trilinear facets. - Never take zero-crossing filaments from baked classic Perlin. It is zero on every lattice point, so the orb lit up in a regular grid of cloud that read as a planet. Billowed (abs) octaves of it are fine once the coordinates are warped.
Lightning
- Re-roll lightning; never tween it. Morphing bolts read as wriggling noodles (the Tweened switch).
- Hold shapes, then fade; never strobe. Every arc, strike and X arm re-rolled on every 30 Hz tick was too much animation, in your face. Hold a shape for 3–9 ticks at a steady strength, then fade it through an afterglow (0.55, 0.2, 0.07) while the next one grows.
- One discharge at a time. Two leaps a tick, crackles and a strike together, and five storm channels flashing in unison read as a firework. Outside the orb, fire a strike, a leap or a crackle, hold it, then the next; take the storm's channels in turn.
- Grow it out, then flare. A bolt that appears whole in one frame reads as a picture of lightning. Reaching out from its origin over 30–90 ms, forks from their junctions, with a bright tip, then the return stroke flaring the whole channel as it connects (and the ground flashing only then) reads as lightning arriving.
- A fine core, many hairlines. A 4–6 px white core with a glow at 2.2 read as a bold neon tube. A core capped at 1.25 px × dpr at ×3.8, a glow at 1.45, and dozens of hair-fine branchlets of mostly glow make the line subtle and the detail rich.
- Build a hierarchy, all the way down. Even-width lines with equal segments read as "lines drawn point to point", and at a fixed minimum width every bolt became an equal strand in a tangle. Give trunks weight and a wider core, taper them to a point (a blunt end read as a neon tube), split segments unevenly, keep fine crackle (offset ×0.58 a level, not 0.5), and fork to depth 3 with a power law on length.
- Avoid parallel bundles and bows. A spear of near-parallel bolts read as combed hair; so did a channel shown twice (its last two shapes), long first forks running beside their trunk, and twigs pulled toward the trunk's direction. Chords bowed upward read as bridges and arches.
- Root discharge at the orb, leaving from its membrane. Bolts between points out in the air read as loose noodles, and bolts starting at the orb's centre turned it into a white knot. Strike from the orb's surface down to the ground.
- Aim the main channels; randomise the rest. Random strikes landed somewhere new on every capture, and judges marked the layout wrong each time: a diagonal across the frame instead of a trunk under the orb, a tangle instead of a fan. Re-strike a few channels to chosen spots (the charge's one trunk, the storm's fan, the ultimate's X) and let forks, twigs and crawlers vary.
- Keep the glow saturated and moderate. ACES clips per channel, so a bright (0.42, 0.45, 1.0) halo came out white and a judge called it "pale cyan". The glow's colour was measured off the target as linear (0.10–0.15, 0.17–0.19, 1.0); tint the bloom the same way.
- Wind ribbons counter-clockwise on screen, and test them with a solid colour first. Clockwise ribbons were silently culled; the empty frame looked like a tuning problem.
Impacts and the world
- Use the air for refraction, not colour. Coloured air rings read as hoops. The punch funnel's surface read as grey slabs from inside. Keep refraction under about 1.5% of the screen and drop the chromatic split: a quarter-screen offset turned the orb into a rainbow, and smaller splits fringed every spark.
- Make the impact frame a starburst with tapered speed lines, and skip the white after a normal hit. A soft disc read as a spotlight cutout, and the white flash after it as a grey wash. Taper the strokes toward the centre and break one only now and then: a gap every few pixels read as Morse code.
- Keep flakes few and dark. In numbers they read as confetti, lobed ones as leaves, and blue-rimmed ones as worms.
- Put heat in the world's own cracks as hairlines, only where it is hot. A radial decal read as a sticker, a wide lit web as neon tiling, and wide soft cracks as worms. Lifting the dust read as flat grey cartoon puffs, and dust darker than the lit stone read as ghost shadows.
- Make the ground a scan. Five judges in a row read the procedural Voronoi plain as "clay tiles" with lines drawn on it, whatever the tuning. A CC0 scan of cracked ground, re-tinted and wetted in the shader, reads as ground at once.
- Make strike arcs follow the cracks the ground draws. Arcs thrown flat in random directions read as a "broom" fanned across the screen. The demo keeps the scan's AO channel on the CPU, and
crackPathwalks it: at each 3 cm step it tries seven headings 0.35 rad apart and takes the darkest AO (the deepest crack), with a small cost for turning. For a procedural ground, mirror its hash in JavaScript (integer PCG reproduces exactly) and walk the same distance field. - Give arcs lying on the ground a depth bias. At a grazing view the stone ate the lower half of each ribbon, which read as a dashed line.
- Take sharp relief from a normal map or an exact gradient, not screen derivatives.
dFdxbumps step in 2×2 pixel blocks; along every crack bevel the wet sheen turned them into dashes. Keep screen-space bumps for the fine grit only. - Light wet stone from the lightning. A constant ambient made the quiet charge's ground twice the target's brightness, while the ultimate's stayed too dark. Reflect the horizon and the strikes' light (not the orb's steady one) with Fresnel off a gritty normal, and mirror the bolts: down-stretched streaks, not a sideways blur.
- Ease what a beat sets. A beat's first frame snapped the orb's size; the camera springs, but the orb did not. Pass the orb's radius, pressure and instability through a short ease after each beat sets them.
- Feather every glow to zero inside its quad. A falloff cut at the quad's radius showed as boxes at the contacts, and the flat contact decal as an ellipse.
- Warm up shaders against the colour pass's render target. First-use compiles cost 44–71 ms hitches.
Cost
The cost is fill rate: the orb's march, the full-screen ground, and smoke over smoke. Two measurements:
The last two rows are like-for-like A/Bs on a shared, busy GPU; the rebuild measured at or below the old build in every beat but the storm ring (14.9 against 13.5, within the noise). Sampling the scan is cheaper than the procedural ground it replaced. CPU per frame fell from about 8 ms to under 1 ms in the ultimate, because bolts rebuild only on lightning ticks. The orb marches its 28 steps on a two-octave field and spends the full field only on the hit; marching the full field everywhere doubled the dash's GPU time. The demo caps the pixel ratio at 1.5. Only the first frame after load is slow.
Everything is pooled: 480 bolt trees in a 131,072-point ring (60,000 drawn), 1,600 smoke sprites, 1,800 sparks, 240 rocks, 64 glints, 40 rings, 8 funnels and 14 scorch marks.
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 charge at full pressure) 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.
Verify
- Play the dash: the black shadow streams behind the orb along its path, and nothing dark hangs where there is no orb.
- With Tweened on, the arcs turn into wriggling wire. With Additive on, the black is gone and the dust greys the ground. With Glow ball on, the orb is an opaque bulb.
- The orb up close is a dark blue storm cloud with lit lobes and dark creases, a small white core, 10–15 single jagged arcs out to the inner wall, crawlers on the rim and a hairline blue edge. There is no grey band, no dark ring round it and no white knot.
- A strike drops as one trunk to a small white contact, arcs crawl away along the scan's visible cracks, the stone round it lights and the wet patches reflect the bolt as a streak. No dashed lines, no boxes, no ellipse.
- Watch the full cast: outside the orb there is one discharge at a time, each reaching out from its origin, flaring as it lands, holding, then fading. Nothing strobes. The orb's cloud churns in place without sliding, and the charge's orb eases down and grows instead of snapping.
- The black shadow's edges fray into fibres with flecks tearing off, and its inside shows faint folds.
- The barrage's hits hold for 0.1 s as a full-frame ink negative with speed lines, with no grey wash after. The ultimate's negative is followed by a white flash.
- Fire five inverting hits inside a second with Full flashes: the first three invert, and the fourth and fifth dim by 0.32 instead.
- Rocks come to rest with their lowest corner within 1 mm of y = 0, none sunk; in the storm ring they float low and stay clear of the camera.
- 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 so the orb keeps its frame.