ice-frost-skill-vfx
Build the effects of an ice and frost skill as one visual language in Three.js. Frost that grows like real frost, not a painted fern: a dendritic tree grown on the CPU from a seed point, stems racing outward with barbs and sub-barbs that compete for space in one time-ordered queue, side branches leaned toward their tips, baked into a distance field so the shader draws hairline feathers growing out from the seed with a bright tip, rime, crystal glints and a contact shadow. Faceted ice spikes built from half-spaces and ray-traced against their own planes (Snell, Fresnel, total internal reflection, Beer-Lambert blue, trapped bubbles, a fracture network, frosted edges) that erupt along the frost line, crack along visible planes and shatter into convex pieces that tumble, land on a face and rest, with snow lumps and ice chips settled into the crust. A low freezing mist marched as wind-stretched banks that churn in place and part around the ice, a blizzard column of powder on helical strands with a fibrous veil, glinting diamond dust, and a scanned snow floor with dunes, broken crust, a raking moon with self-shadow and two populations of glints. Use for ice or frost skills, frost novas, ice spikes or walls, glacial eruptions, shattering ice, blizzards, snow devils, freezing mist, frozen ground, "ice and frost", "frost creep", "ice shards", "realistic ice" or "skill effects only". Includes two reusable modules and a five-beat demo on a scanned CC0 snow floor with a failure-mode switch.
Install
npx skills add https://github.com/MengTo/Skills --skill ice-frost-skill-vfxIce and Frost Skill VFX
Use this for the effect half of an ice or frost skill: what forms in the hand, what spreads across the ground, what erupts, what the wind does and what breaks. 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. - Flames, smoke, embers and burning ground:
fire-smoke-skill-vfx. - 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 the user 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 shadows"). There is no character: the frost blooms in an implied hand at chest height. Then eight self-critique rounds, two rounds of review on the blizzard, and a three-round 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 (one in GLSL3 for a two-target bake), HalfFloat render targets with a depth texture, a 96³ baked noise texture, and a hand-written bloom, composite and FXAA. No post-processing library. The demo's floor is Poly Haven's Snow 02 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
Frost is grown once and drawn by its arrival time; ice is traced against its own planes; the air is marched where the cold pools.
- Grow the frost, then let the shader reveal it.
growFrostruns a small diffusion-limited growth on the CPU in about 50 ms: stems, barbs and sub-barbs advance as events in one time-ordered heap, so whichever tip reaches a patch first claims it and later tips stop against it. Every segment carries the time the front reached it. The tree is baked once into a 2048-wide HalfFloat distance field, and the ground shader draws hairlines from it by comparing that arrival time with the frost clock. Nothing is re-rolled or scrolled: the pattern grows. - Ice is a convex hull you can trace exactly. Each spike is a set of planes (sides, tip facets, chips, a base) clipped from a cube. The fragment shader refracts into the hull, finds the exit plane in closed form, reflects internally while total internal reflection holds, and samples a mip-mapped copy of the world pass for what is seen through it. Cracks are planes too: the spike shatters along exactly the planes the viewer watched crack.
- Pieces are rigid bodies with one contact. Every fragment and chunk keeps its hull vertices, so the lowest corner is known each substep. An impulse with friction lands it, and when it slows it tips onto the face nearest the snow, so nothing ends balanced on a point, floating or sunk.
- Mist is a ray march in banks, not cards. Each bank is a wind-stretched ellipsoid marched over its own span at a third of the resolution, with domain-warped fibres advected in two crossfaded phases so it churns in place. A mist-only debug view (
fx.debug = 'mist') is how it was calibrated. - One wind field moves everything. A column vortex (foot inflow, core lift, top outflow, an S-shaped axis) drives the powder, the flakes, the hairline streamers and the mist's rotation.
- One clock and one composite, as in
lightning-energy-skill-vfx: holds freeze every layer, a time ramp slows the crack, refraction rings go into a half-resolution distortion buffer, bloom has a high threshold, and the impact frame draws after tone mapping. FXAA runs only on pixels the ice pass marks, because MSAA on a HalfFloat target resolves to garbage.
Reuse the working effect
Copy assets/frost-energy.mjs [blocked] and assets/frost-growth.mjs [blocked] side by side (frost-energy imports ./frost-growth.mjs). You pass in your own THREE, and neither module imports a second copy. The demo [blocked] runs these same modules, inlined by demo/build.mjs because a page opened from disk cannot import modules.
Other calls:
growFrost(opts)(infrost-growth.mjs) returns{ seg, count, spine, paths, tips, duration, branches }:segis 12 floats a segment (x0, z0, x1, z1, t0, t1, half-width 0 and 1, level, rand, arc length 0 and 1);spineandpathsare[x, z, t]points of the forward stem and of each seed stem (for lights that ride the front);tipsare branch ends for glints. Options:seed,origin,dir,radius,area,speed(2.6 m/s),stems(11),density,symmetric(a stellar frost of n stems),fan,maxSegments.fx.growFrostField(opts)runsgrowFrost, bakes the distance field (2048 wide by default,res), builds the tip stars and the hub crystal clumps, and fillsfx.frost.uniforms.fx.frost.t,.fade,.glowdrive it.fx.stemTip(path, t, out)andfx.spineAt(s)read the grown tree.fx.createSpike({ base, axis, height, radius, seed, erupt, rise })returns a spike;fx.crackSpike(S, k, delay, duration)starts crackk(0–2 across the shaft, 3 down its length);fx.shatterSpike(S, center, strength);fx.resetIce(keepFrags),fx.clearFrags();fx.buildObstacles()after placing spikes.fx.spawnChunks(pos, { count, speed, size, chips, dir, life, glow }): snow lumps, or translucent ice chips withchips: true.fx.spawnMote(kind, pos, vel, opts): kind 0 snowflake, 1 diamond dust (a tumbling facet that glints), 2 cold spark, 3 chipped flake, 4 long straight streak;life,size,gravity,drag,wind,bright,column(fades once it leaves the vortex body),soft(never under 2.2 px).fx.spawnWisp(type, pos, vel, opts): type 0 vapour, 1 powder, 2 ground mist;size,grow,life,opacity,drag,buoy,gravity,wind,stretch(aligns to its screen motion).fx.spawnStreamer(pos, { life, width, opacity }): a hairline ribbon carried by the wind.fx.setBanks([{ id, c, ra, rc, hb, ang, dens }]): up to 8 mist banks (centre, half-length along the wind, half-width, height, wind angle, density); banks not in the list ease out, new ones ease in at 1.1/s.fx.wind.vortex(on,center,radius,height,swirl,inflow,lift,outflow,sDir,sAmp,veil,phase),fx.windAt(x, y, z, out),fx.vortexAxis(y, out),fx.coreAt(y).fx.mist(amount,height,origin,flowSpeed),fx.bloom(the hand's frost flower:group,t,fade),fx.setLight(i, pos, energy, { rate, color, snap }),fx.flashLight(i, pos, energy, decay, color),fx.lightAt(x, y, z, out).fx.ring(center, normal, { radius, thick, life, delay, amp })(refraction only),fx.impact({ hold, frame: 'center' | 'full' | 'none', flash, shake, shakeMode, fisheye, at }),fx.ramp(scale, seconds).fx.options:flashes('full'|'safe'),shake,fisheye,ice('traced'|'flat', the demo's Shaded failure).fx.debug = 'mist'renders the mist alone over black.fx.stats().
The layers, with the numbers that shipped
Cut a brief into beats
Between beats, ease. Every size and intensity a beat sets runs through its own curve; banks ease in and out; replaying the spikes sinks the standing ice back into the snow before it rises again; a new cast melts the last frost and pieces away instead of popping them.
Rules, each with the failure it prevents
Each rule came from a capture of this demo that went wrong, flagged by the user, a judge or a measurement.
Measuring
- Build a region-stats harness before tuning. For each shot, split frame and target into regions (sky, treeline, far, mid and near snow, the spike band, each mist zone, the column) and print mean RGB, luminance std, p99/p99.9, mean |Laplacian| and glint blob counts side by side. A judge naming the same four gaps two rounds running ("mist missing", "rope dim", "ice not clear", "ground flat") only moved once every change was driven by closing those numbers.
- Score glitter on two axes: high-frequency energy and glint count. One round was "salt noise, too many glints"; after the fix the next was "about 10× too few". The target is dense fine granular sparkle plus a few bright glints. Match the fine energy with a sugar population and the bright count with a separate strong population, and the flip-flop stops.
- Render the mist alone to calibrate it. Twice the banks were "still missing" in the frame while existing in the code: too thin, too dim, or undersampled. A debug flag that draws only the mist over black showed each problem at once.
- The final judge still read surface glints as airborne dots. Soft 3–5 px round glints evenly spread over the snow read as dust on the lens. Keep strong glints to 1.5–3 px, on lit crests only, and fade them with distance.
Rendering
- No MSAA on a HalfFloat target; use FXAA in the composite. A multisampled HDR target resolves to blocky garbage. Mark the ice in the 8-bit target's alpha and run FXAA only there, so the frost hairlines and glints stay sharp.
- March each mist bank over its own span, with white jitter and a small blur. One march over the union of all banks gave a 1 m plume two samples, so it vanished; a patterned (interleaved-gradient) jitter printed vertical bars and a screen door on the thin mist. Per-bank spans, a hashed jitter, a 5-tap blur and a tent upsample cured both.
- Band-limit relief to the pixel footprint. Small dune and ripple terms aliased into horizontal water ripples at grazing distances; fade them as the footprint grows from 12 to 50 mm.
- Polygon cells read as fish scales. Voronoi crust domes with flat normals read as cobblestones; the scan's own normal at a small mip-mapped tile reads as snow.
- Tilt chip planes only upward. A downward chip plane sliced the whole lower shaft off and left tips floating.
- Copy shared hull vertices before moving them. The clipper shares corner objects between faces; recentring a fragment shifted them twice and drew black and white garbage.
- Glints need a pixel-space spot. A spot sized in world units stretched into streaks at grazing angles; map the cell centre through the inverse screen Jacobian and draw a Gaussian in pixels.
Layout and randomness
- Never change a shared random stream to restyle a feature. Changing the barb angles inside the growth reshuffled the generator, moved the spine and moved every spike placed on it. Leaning the barbs as a post-transform about their own roots changed the look without moving anything.
- Keep the framing the targets were cut from. Removing the vortex's lean also moved the camera aimed at it and shifted every spike 30 px; pin the camera to the old framing when you change the subject.
Looks
- Frost is a grown tree, not a texture. Barbs at a fixed angle and spacing read as a fishbone comb; competition in a time-ordered queue packs them into feathers, and the fine needles, beads, rime and glints carry the realism.
- Traced ice reads as ice; shaded ice reads as plastic. The Shaded switch shows the same facets lit from outside only.
- Ice glow is white-blue, low, and mostly in the core. A cyan body glow read as acrylic; keep it in a milky core near the base and let the moonlit edges and refraction carry the rest.
- No twinkle sprites on tips, no cotton-ball puffs. Four-point stars on every crystal tip and isolated round mist puffs were called out every time.
- A blizzard is a column of fine powder, not scribbles. A dozen fat, opaque streamers crossing at random read as scribbles on the lens. Many hairlines, thousands of soft powder points on a few helical strands, a translucent fibrous veil, and a crown kept within 1.3× the body's width.
- Pieces rest on a face, buried a little. Rocks balanced on a corner, chunks floating a few millimetres up and mist cards cutting the ground were all called. Contact is checked numerically for every piece.
Cost
The cost is fill rate: the ground (relief, self-shadow, two glint loops), the traced ice, and the mist and column marches. Measured with WebGL timer queries at 1440 × 900 and a pixel ratio of 1.5 (2160 × 1350), at a load average of 3.1–4.8:
Milliseconds. The second row includes contention from other GPU work on the shared machine; the first is the better estimate of the page's own cost. On the CPU a frame costs about 2–4.5 ms at the blizzard's peak (spawning, 4,000+ particles and their buffers). The demo caps the pixel ratio at 1.5. Growing and baking the frost costs about 50 ms once, at load.
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 spikes standing in the frost and the mist) 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,setOption,fx,BEATS,clusters, ...) is there for deterministic headless captures. The portrait camera backs off by (1/aspect)^0.55.
Verify
- The frost grows out from where the bloom lands: stems race ahead, barbs open behind them leaning toward the tips, the growth tip blazes and the fresh frost glows, then settles; nothing slides.
- With Shaded on, the spikes are flat tinted plastic; with Traced, the snow and sky bend through them, back facets and cracks show inside, and edges catch the moon.
- Spikes erupt along the frost line in a wave, with lumps and chips settling round the bases; the mist hugs the bases and trails downwind.
- The blizzard is a column of fine powder on helical strands with a translucent veil, ramping in and out; nothing crosses the frame as a fat streak.
- Cracks run near to far in slow motion, then every spike bursts along those planes; every piece lands on a face within 1 mm of the snow, none overlap, stumps stay.
- 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 requests only its own files. The console is clean at 1440 × 900 and 390 × 844.