water-crystal-skill-vfx
Build the effects of a water and crystal skill as one visual language in Three.js. Water that reads as clear water, never a teal fill or a textured ball. A ray-marched water orb whose surface swells, rings and deforms in place: it refracts the frame behind it with an inverted horizon, has a silver rim and sharp highlights, and shows a faint caustic web on its far wall. Streams wound up from tide pools as uneven beaded ropes with silver edges, drips and shed drops. A lash that unspools from the orb, whips and pours into a pool under a torn, lopsided splash crown of aerated white water with spikes breaking into drops. Analytic ripple rings and rain on the pools. Crystals traced against their own half-space planes (Snell, Fresnel, total internal reflection, exit dispersion, Beer–Lambert, fracture discs, a white-hot root glow and a resonance band that climbs the stone), growing out of the water in a draining veil, then shattering into Voronoi shards that fall, tumble and rest with real contact on a heightfield. Caustics on the pool floor, and a moonlit tidal rock flat: rock banks traced from target images onto a world grid, flat mirror pools, a moon-glitter column of micro-facet glints, and a moon disc with maria. Use for water or ice-crystal skills, water orbs or bubbles, water whips, jets or tentacles, splash impacts, tide pools, rain, crystal growth or eruption, crystal resonance, shattering glass or quartz, caustics, wet-stone night scenes, moon reflections on water, "water and crystal", "hydro", "clear water VFX", "crystal shatter" or "skill effects only". Includes a reusable module and a five-beat demo on a scanned CC0 low-tide floor with failure-mode switches.
Install
npx skills add https://github.com/MengTo/Skills --skill water-crystal-skill-vfxWater and Crystal Skill VFX
Use this for the effect half of a water skill: what gathers above the hand, what it throws, what grows where it lands 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. - Fire, smoke, embers and heat haze:
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 frames"). There is no character: water gathers over an implied open palm. After its self-critique rounds and a review round, the page went through three rounds of a dream loop against GPT Image edits of its 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, glint and composite chain. No post-processing library. The demo's floor is Poly Haven's Low Tide Rocks by Dimitrios Savva (CC0). Its 1k colour, normal and ARM maps are in demo/assets/ground/, re-encoded at quality 85 and embedded by demo/build.mjs as data URIs, so the page opens from disk.
The mechanism
Water is what it bends and what it mirrors; crystal is light caught between planes.
- Clear water has no colour of its own. Every water surface (orb, streams, splash) refracts a copy of the frame drawn behind it, reflects the night and the lights with Fresnel, and is tinted only by its path length (Beer–Lambert). Its look comes from silver rims, a dark edge on the unlit side and sharp highlights of small bright sources, which are never scaled down by the 2% reflectance.
- Surfaces evolve in place. The orb is a sphere-traced field: its free-drop oscillation modes ride on springs, capillary rings run over it, and a warp field drifts through its noise. Nothing turns rigidly or scrolls (the Sliding texture switch shows that failure). Streams carry ripples that move with the flow.
- Crystals are their own planes. Each crystal is a convex hull clipped from half-spaces. The fragment shader traces the ray inside against those same planes in closed form, so back facets, total internal reflection and edges show inside the stone (the Painted glass switch shows a tinted body with only a reflection).
- The ground is a place, not a texture. A heightfield of wet rock banks stands out of one flat water plane at y = 0. Where the rock is below the plane, it is the pool floor seen through water. A half-resolution mirror pass (an oblique-clipped reflected camera) puts the rock, the orb, the streams, the crystals and the drops into the pools, and the moon arrives as a glitter column of micro-facet glints. The same terrain runs on the CPU, so shards and drops land on the surface that is drawn.
- One clock and one composite, as in
lightning-energy-skill-vfx: holds freeze every layer at once, refraction-only pressure fronts write into one half-resolution distortion buffer, and the impact frame draws after tone mapping. Here the vignette sits before the tone curve, so highlights still clip.
Reuse the working effect
Copy assets/tide-crystal.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.createOrb({ radius })returns an orb you change every frame:position,radius,glow,visible,settle(0–1: calmer, more damped),neck{ dir, len, r }(a capsule smooth-unioned onto the orb where a stream leaves it),ring(dir, amp, speed, width),kick(mode, v)andcurrentRadius.fx.createTube({ maxPoints, radial, flow, foam, glow })returns a tube:points(Vector3s),radii,fades,along0(where the ripples sit on the water, so they ride with it),visible,flow,opacity,glowandwobble.fx.createCrystal({ base, axis, length, radius, seed, tint, pieces, spin, groundY })returns a crystal:visible,grow(0–1, rising out of the floor),glow,band(the resonance band's height, −1 for none),veilandveilT(the water film and its draining line),crack(0–1, fractures climbing from the root),frostandshattered. Its shards come pre-cut (C.pieces).fx.shatter(C, { center, strength, up })hands the crystal to its shards (rigid bodies with contact).fx.crown(pos, { radius, height, life, droplets }),fx.drop(pos, vel, { life, size, drag, gravity, ripple, kind })andfx.spray(pos, { count, dir, spread, speed, size, life, ripple, up }).fx.ripple(pos, { slope, speed, wavelength, decay, width, life, delay, small })(slots 0–11 are for hero rings;small: trueuses 12–23, so drops never steal a splash's ring),fx.groundUniforms.uRain.value(0–1).fx.caus[i](x, z, radius, gain) andfx.causCol[i](r, g, b, dispersion) for six caustic spots,fx.addFrost(pos, radius, { grow, life }),fx.addFoam(pos, radius, life),fx.wet[i].fx.setLight(slot, pos, energy, color)(held, eased),fx.flashLight(slot, pos, energy, decay, color)(decays),fx.front(center, normal, { radius, thick, life, amp, delay })(refraction only).fx.vortexField{ on, center, swirl, pull, lift, radius }winds drops ofkind: 1round a centre and swallows them.fx.impact({ hold, frame: 'center' | 'full' | 'none', flash, shake, shakeMode, fisheye, at }),fx.ramp(scale, seconds),fx.repeat(ticks, fn),fx.stats(),fx.dropList()(the live drops, for debugging),fx.debug(noMirror,noGrab2,noRip,noCaus).fx.options:water('living'|'sliding'),crystal('traced'|'flat'),flashes('full'|'safe'),shake,fisheye.fx.grabTwice = truetakes a second frame copy after the crystals when water overlaps them on screen.- Layers: 0 the world, 1 distortion, 2 seen in the mirror (
L_MIRROR), 3 refracting solids, 4 water bodies, 5 drops.
The layers, with the numbers that shipped
Cut a brief into beats
Between beats, ease. The orb's radius (3.2/s), glow (4/s) and place (5/s) follow what a beat asks; a beat's camera eases in over 1.4 s, its pitch tilt (a few pixels at 720 lines) at 2.5/s; crystals grow, glow and fade through curves. 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 user.
Water
- Clear water is not a teal fill. Streams shaded with an inner glow and the orb with a caustic net over its whole disc read as "teal neon tubes" and a "teal net ball", and every frame was one hue. Water takes its colour only from path length; refraction, silver rims, a dark unlit edge and sharp highlights carry it.
- Never scale bright small sources by 2% reflectance. The moon and the lights in a stream at F ≈ 0.02 all but vanished. Multiply only the dim sky by Fresnel; give point sources (0.5F + 0.03) × 22.
- Never scroll or spin a noise on a water surface. A rigidly turning noise reads as a printed ball (the Sliding texture switch). Drift the warp through the field and let modes and rings move it.
- Put the light at the splash foot, not inside the stream. A point light inside a tube glinted all over its own skin.
- Show the inverted horizon sharp. Reading every refracted ray from the frame copy made the orb murky. Read the sky analytically for rays leaving upward and the frame copy only at the ground hit for rays leaving downward. (The judge still calls the orb murky; a sharp capture from the orb's centre is the next step, see the scorecard.)
- Kill drops that leave a vortex. Drops flung out at the orb's height hung in the sky and were counted as stars (37 in one round-2 frame). Killing them when they leave, and capping their speed, cut that to a handful.
Moon, sky and grade
- The moon's reflection read as a "≡" glyph. A smooth mirror with a few swells drew a compact stack of four or five white dashes. Real glitter is many small facets, each flashing when its own wandering slope mirrors the moon: a long, thin, broken column that brightens toward the horizon. Keep the slope spread narrow (σ 0.04) or big swells turn it to chrome.
- A vignette after the tone curve capped every highlight at about 225. The moon never clipped, and the brightest 0.1% stayed at 211–226 against the target's 247–255. Apply the vignette in linear light before ACES.
- Cap what one pixel gives the bloom. An uncapped moon swelled into a soft ball with 1.2–2.2× the target's bright area; a per-pixel cap of 0.55 above the threshold keeps it a crisp disc.
- A disc far above the clip shows no maria. Scale the disc so it sits just above 1.0 after the curve, and let the maria multiply it below.
- The sky brightens toward the moon at the horizon. In round 2 the sky darkened toward the horizon where the target's brightened. A forward-scattering haze lobe over the lowest few degrees fixed the moon side, but the final round lifted the side away from the moon about 40% too far, and the gather shot's pixel error rose from 13.9 to 15.6. Lift only a thin band toward the moon.
- Clouds are the hardest texture to fake. Stretched noise read as brush-stroke cirrus, billowed noise as a "mackerel" ripple, and a thresholded deck as camouflage. Soft, dim wisps at about 30% coverage, silver only near the moon, is still open (see the scorecard).
Ground
- A scan alone reads as a flat gravel plane. The Low Tide Rocks scan at 2.17 m a tile read as "one flat teal gravel plane". A tidal flat needs macro structure: rock banks standing proud of truly flat mirror pools.
- Trace the pool and rock layout from the targets. Each target image was classified into water (the blue sky reflected, B − R high, or the moon's glitter) and rock (neutral and dark), every pixel was traced through its shot's real camera onto y = 0, and the result was splatted into a 5 cm world grid weighted toward the nearer camera, with the skill's own elements masked out. A few screen rectangles force water or rock where the colour test is ambiguous. One map serves all three shots, because they share one world.
- Test contact per corner on lumpy ground. A plane fitted at a shard's centre let its corners sink into the next lump (62 of 120 pieces sank, one by 23 cm); testing every corner against the height under it brought that to 3 pieces within 3.4 mm, and 0 in the shipped build.
- Keep the terrain identical on the CPU and the GPU. Use an integer hash (not
fract(sin)) and the same maths, or shards rest above or inside the drawn rock. - Anti-alias the specular on scanned normals. Pebble glints became salt-and-pepper that crawled with the camera, and the preview video came out at 10.5 MB. Widening roughness by the normals' pixel variance fixed the crawl and, with lossless capture frames, brought the video to 6.5 MB.
- Caustics belong under the water. Projected on steep rock they read as "gold claw streaks"; weight them by the depth below the waterline.
- Re-encode the scan, don't ship quality 97. The three 1k maps were 3.9 MB (5.2 MB as base64, 84% of the page). Decoded with
djpeg -nosmoothand re-encoded at quality 85 with the original 4:2:0 chroma (cjpeg -sample 2x2), they are 1.26 MB, and an in-page A/B of the same paused frames differed by 0.9–1.7 levels in single glint pixels. The page went from 6.2 to 2.6 MB.
Crystals
- Milky glass is scattered light everywhere. A glow term along every ray lifted every face, and the judge read the crystals as frosted or milky plastic. Show the inner light only where something scatters it (veils, fractures) and at the root; let reflection, refraction and edges carry the rest. (The demo still reads as frosted to the judge; see the scorecard.)
- An outline band reads as wireframe. A 3–4 px pale edge on every facet read as SVG strokes; a 1-pixel catch-light from fwidth reads as a cut edge.
- Dark tips are dropped light. Ending the trace after three legs left the terminations black; add the light still trapped inside (night env + a little glow) and let the point carry what leaves through it.
- Three dispersion taps of a bright point are three coloured discs. Clamp each channel to ±30% of the mean, so the moon through a crystal gets a fringe.
- Never overwrite a swell's start time. Each resonance pulse replaced the last hit time before the swell played out, so every swell was cut short; keep a list of hits and sum their swells.
- Squeeze the Voronoi axis for splinters. Plain cells broke the prisms into cubes; measuring with the axis at 0.45 makes long shards.
Frame and tools
- Per-pass timer queries lie on ANGLE/Metal. Every pass read 7–12 ms. Time whole frames, best of several redraws, and attribute cost with interleaved on/off A/B runs.
- A
//comment on a uniform's line swallowed the next uniform. The shatter crashed on an undefined uniform; check that every declared uniform exists on its material after each shader edit. - Hide the controls without a fade in captures. A 0.3 s fade left ghosted controls in a stepped capture; capture mode turns transitions off.
Cost
The cost is fill rate: the orb's march, the crystals' traces, the full-screen terrain and pools, and two frame copies. Measured with WebGL timer queries at 1440 × 900 and a pixel ratio of 1.5 (2160 × 1350): every 6th frame of each beat redrawn 6 times and the fastest kept.
Medians in ms. Timing every frame once, back to back, gave 11–18 ms medians on the same machine at the same load: the shared GPU queues other work between submissions, and the best-of-6 figure is the one to compare. CPU per step is 0.2 ms, 1.6 ms in the shatter (shard contacts). 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 crystals ringing with light) 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,fx,BEATS,ORDER,camera,orb,heightAt, ...) is there for deterministic headless captures;?capturekeeps the drawing buffer and turns the interface's fades off,?idlestarts without the cast,?stillholds the reduced-motion still, and?dpr=lowers the pixel-ratio cap.
Verify
- With Sliding texture on, the orb's surface turns like a printed ball; with Living surface its swells deform in place and rings run over it.
- With Painted glass on, the crystals are tinted bodies with a reflection; with Traced their back facets, internal reflections and fractures show.
- The streams and the orb are clear: the frame behind them bends through them, their edges are silver on the lit side, and there is no teal fill.
- The moon's reflection is a long, broken column of small glints, not a stack of dashes; the moon is a crisp near-white disc with faint grey maria, not a soft ball.
- The pools are flat mirrors between rock banks; the rock, the orb, the streams and the crystals reflect in them, broken by rings.
- Shards come to rest on the rock with their lowest corner within about 1 mm of it and none sunk; any that sit higher lie on other shards.
- 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, and the portrait camera backs off by (1/aspect)^0.55 and turns toward the orb.