A skillshot VFX sandbox with 100 procedural spell effects across 15 schools. Three.js and hand-written GLSL, nothing is a texture. Expanded from achrefelouafi's original six-ability sandbox, which is where the techniques come from.
55
stars
27
commits
JavaScript
primary language
Aug 14, 2026
updated
A skillshot VFX sandbox built with Three.js, Vite and hand-written GLSL.
One hundred abilities across fifteen schools, and two ways to aim them. Fifty are line casts: press the key to arm, a League-of-Legends style arrow appears on the ground and swings with the mouse, click to fire. The other fifty are far casts: the arrow is replaced by a circle with a deliberately thick boundary that follows the cursor and answers the only question a ground-targeted AoE has to answer before you commit — how much space is this going to take.
Eight of the hundred are on the bar at a time. Tab opens the spellbook; drag a card onto a slot
to bind it. The six below are the ones the sandbox shipped with and the ones the rest were built
to the standard of — see docs/ROSTER.md and docs/ROSTER-II.md for the other ninety-four, each
with the one technique that makes it read.
Q — Frost Lance. A fracture front races out along the line while a field of ice crystals tears up out of the floor behind it — small and dense at your feet, opening into a wall of blades at the far end, with a cluster thrown up around the impact point.
E — Storm Lance. A bolt leaves the caster's hand and a bundle of lightning filaments is drawn out behind the strike front, holds while it gutters and re-strikes, then blows out. Sparks come off it the whole way, the floor underneath takes a branching electric burn and a dark scorch, and the far end gets a shell of ionised air.
R — Cinder Fall. A burning rock is lobbed downrange on an arc, trailing a raymarched wake of burning gas and heating up the whole way: the lava seams splitting its surface prise wider and brighter as it comes in. It detonates on arrival, throws its own shattered chunks across the floor, and tears the ground open into a network of molten cracks that keep glowing while the crater burns out.
F — Nova Beam. The caster winds a ball of light up in both hands, pulling motes in out of the air, then lets a column of it out along the line — white-hot core, cyan sheath, gold ribbons spiralling around it and shock discs racing down it. It holds there, burning into the floor and throwing spray back up the beam, before collapsing to a thread and blinking out. The only cast in the sandbox that is still happening a second after it landed.
V — Voltaic Snare. The far cast. A leash of current is whipped out across the floor, and where it lands the ring snaps open past its own radius and pulls back onto it: a violet column tears up out of the middle, tendrils crawl outward to the boundary, arcs run around the rim and the whole disc burns. It holds there re-striking and hauling the air up into the pillar, then collapses to a thread. The circle you measured out before the click is exactly the circle you get.
Everything you can see is generated. There are no textures, no sprite sheets and no meshes on disk except the character: the crystals are procedural geometry, the bolt is a strip of ribbon placed entirely by a vertex shader, the meteor is an icosphere cratered and sliced by fracture planes on the CPU, the beam is a parametric tube drawn three times at three radii, the snare's whole cage is that same ribbon strip threaded along four different parametric paths, the arrow, the targeting circle, the rime, the burns and the molten cracks are signed-distance and noise shaders, and the mist, sparks, chips and glitter are GPU particles.
Every parameter is a live slider — a little over 16,000 of them — and they stay live while the simulation is paused. That is the point of the project: freeze a frame mid-eruption, mid-strike or mid-burn with P, then reshape the silhouette, the palette and the timing against a still image.
References for the look: icecast.jpg, thundercast.jpg, superbeam.jpg and
electricalboost.jpg.
npm install
npm run dev
Then open the URL Vite prints (default http://127.0.0.1:5173).
npm run build
npm run preview
Six binary assets are served from public/ and loaded automatically at boot:
| File | Purpose |
|---|---|
public/models/Idle.fbx | Rigged character and its idle animation clip |
public/models/diffuse.png | The character's colour map |
public/models/cast1.fbx | Cast animation |
public/models/cast2.fbx | Cast animation |
public/models/cast3.fbx | Cast animation — the default for Frost Lance, Root Snare and Glacier Crown |
public/hdri/spruit_sunrise.hdr | HDR probe used for image-based lighting and crystal reflections |
All four FBX files are Mixamo exports of the same rig, each carrying a skinned mesh plus one
animation stack. The character comes from the idle file; the cast files are loaded for their clip
alone, and the duplicate rig that arrives with each one is released the moment its AnimationClip
has been taken. Clips bind to the skeleton by bone name, which is the whole reason an animation
authored in another file plays here without retargeting.
The rig ships no material, so diffuse.png is loaded beside it and assigned as the colour map when
the imported materials are converted to PBR — an FBX that does carry an embedded texture keeps its
own, since that map is authored against its own UVs.
Every ability picks the clip it throws — castAnim in its settings block, a dropdown under The
cast in its editor folder. Out of the box slots 1, 5 and 6 — Frost Lance, Root Snare and Glacier
Crown — throw cast3, and the other three throw cast1. The clip is a one-shot laid over
the looping idle, with character.castBlendIn / castBlendOut as the two edges of that overlap.
The HDR is loaded as image-based lighting and as the reflection source for the ice — it is never shown as a visible sky. The stage keeps its flat dark backdrop.
The bar is a loadout: eight slots over the whole roster, bound to Q E R F V X Z T with the
digits 1..8 mirroring them. The default is the six that shipped in slots 1–6 and two empty
slots; the binding is saved to localStorage and rebound from the spellbook. An empty slot's key
opens the spellbook ready to fill it.
| Input | Action |
|---|---|
| Q (or 1) | Arm Frost Lance — press again to put it away |
| E (or 2) | Arm Storm Lance — press again to put it away |
| R (or 3) | Arm Cinder Fall — press again to put it away |
| F (or 4) | Arm Nova Beam — press again to put it away |
| V (or 5) | Arm Voltaic Snare — the far cast, aimed with a circle |
| X (or 6) | Arm Glacial Crown — the other far cast |
| Z, T (or 7, 8) | The two open slots — empty until you bind something |
| Tab or B | Open the spellbook: the whole roster, by school |
| Move the mouse | Swing the aim arrow, or move the far-cast circle |
| Left click | Cast along the arrow, or drop the circle where it is |
| Esc / right click | Cancel an armed cast |
| Right mouse + drag | Orbit the camera |
| Scroll | Zoom |
| G | Show/hide the VFX editor |
| P | Pause / resume — the editor keeps applying |
| C | Clear all active effects |
| H | Hide the controls panel |
| Click a slot's name | Open that ability's folder in the editor |
In the spellbook: type to filter by name, school or description; arrow keys and Enter to browse and cast; shift-click a card, or drag it onto a slot, to bind it; Esc to close. The loadout bar stays live underneath the book so it can be dropped on.
range and minRange are per ability, so the indicator's reach changes with the slot you have
selected. Aiming closer than the selected ability's minRange tints it red and refuses the cast;
set minRange to 0 if you would rather cast at your own feet, which is what the Snare ships with —
a trap you cannot drop on yourself is missing half its uses. Cooldowns are per ability too, so
spending one slot never locks the other out.
src/
abilities/ Ability base class (the travelling front), registry.js (the one
declaration site), one directory per school, pooling manager
animation/ FBX character loading, AnimationMixer, the per-ability cast clips,
the procedural cast lunge
assets/ Procedural crystal and asteroid geometry, the bolt ribbon strip,
the beam tube and its shock discs
config/ settings.js (globals, aim, post, camera) and abilities/<id>.js —
one settings block and editor schema per ability
core/ App, Renderer, CameraRig, Time, Layers, shared frame uniforms
effects/ Aim arrow, far-cast circle, ground decals, fissures, bursts,
light pool, shake, flash
input/ InputManager (events) and AimController (both targeting shapes)
loaders/ AssetLoader with a shared LoadingManager
materials/ Per-ability bespoke materials — ice, lightning, meteor, fire,
beam, snare, and the ones the new schools brought with them
vfx/ The shared tech library: 26 modules an ability is configured
against rather than re-implementing. See docs/VFX_API.md.
particles/ GPU particle system + engine and rate emitters
postprocessing/ Composer pipeline, grade shader, distortion shader
shaders/lib/ Shared GLSL: noise library, common helpers
ui/ HUD + loadout bar, spellbook overlay, lil-gui editor, sigils,
preset manager, styles
utils/ Maths, colour cache, pooling, disposal, shader patching
world/ Environment (stage lighting), floor, dust, contact shadows
archive/ The retired four-element sandbox — see archive/README.md
src/config/settings.js holds every tweakable value. Nothing else owns that state: shaders,
particle systems, lights and post passes read those objects every frame. That is what makes the
editor work with no rebuild — moving a slider changes the ice field that is already standing, the
next cast, the environment and the post stack at once. Preset loading deep-merges into the same
objects so every live binding stays valid.
import { settings } from './config/settings.js';
settings.ice.height = 7; // visible on the next frame, even mid-cast
settings.thunder.jitter = 1.2; // re-kinks a bolt that is already in the air
settings.global.timeScale = 0.1; // slow the whole cast to a crawl
Ability blocks are keyed by their id in ELEMENTS, and the shared systems that need to know
"which ability is the player holding" — the aim controller, the cooldowns, the HUD — look it up as
settings[element]. The four fields they rely on being present are range, minRange, speed
and cooldown; a far cast adds a fifth, zoneRadius. Everything else in a block is that ability's
own business.
A spike record in IceAbility stores only what the dice decided: a position fraction along
the line, a signed lateral fraction, and a handful of unitless jitters. Not one metre, radian or
second is captured when the cast starts. Every dimension is resolved against settings.ice inside
the update loop, which runs on a zero-length frame too.
So dragging height re-grows a field that is already standing; dragging lean re-tilts it;
dragging clumping re-packs it toward the centre line. The only values a record does capture
are timestamps — the moment its own eruption was triggered. Those are events, not dimensions.
The four shape controls (facets, taper, roughness, bend) cannot be expressed as a
per-instance transform, so they are baked into the geometry instead — and a six-sided crystal is
just 60 triangles, cheap enough to regenerate outright rather than approximate in a vertex shader.
IceAbility#_syncGeometry hashes those four values and rebuilds the three crystal meshes when the
hash changes, which is what keeps them live sliders rather than restart-required constants.
AimController raycasts the pointer onto the ground plane every frame, not only on mouse
move, so orbiting the camera with a cast armed swings the indicator under a stationary cursor. It
clamps the distance into [minRange, range], tracks a 0..1 reveal envelope, and emits a single
cast event carrying an origin, a unit direction and a distance — which is exactly the signature
Ability#spawn takes. It decides nothing about what the cast does.
It runs on real time rather than the scaled simulation delta, so the indicator keeps animating while the sandbox is paused.
There are two indicators and one controller. Which one is drawn comes from
ELEMENT_META[element].cast — CastShape.LINE or CastShape.ZONE — and that is the only thing
the two shapes disagree about. Arming, clamping, validating, revealing and firing are shared, and
both end in the same three-argument cast event, because from the targeting side a far cast is a
line cast you only care about the far end of. That is why zone targeting needed no change in
Ability, AbilityManager or App: SnareAbility reads its centre as pointAt(1) and works
outward from there.
ZoneIndicator is the arrow's opposite number, and it is built out of the same two ideas: metres,
and no textures.
The footprint is one quad whose fragment shader remaps UV into metres from the target, so the
boundary stays 0.34 m thick whether the circle is 2 m or 8 m across. The band is deliberately the
heaviest mark on screen — it is the whole message — and it is split about the nominal radius by
boundaryBias rather than centred on it, so its outer lip stays honest about where the effect
ends. Inside there is a rim-weighted wash, contour rings travelling outward, warped filaments and a
reticle whose downrange arm is longer, because the quad carries the caster's yaw and that arm is
therefore the heading.
The reach ring at the caster is the bolt's ribbon strip bent into a circle: (t, side) in,
world position out. A quad big enough to hold a 20 m range would be 40 m across and shade a
screenful of discarded fragments for one thin line.
The circle snaps out past its radius and settles back when the cast is armed, and the trap does the same thing when it lands. A circle that grows linearly reads as a UI element; one that overshoots reads as something the caster did.
AimIndicator is a single ground quad. Its fragment shader remaps UV into metres measured from
the caster, so every control in settings.aim is a real measurement — the shaft stays 0.42 m
wide whether the cast is 3 m or 15 m long.
The silhouette is a rounded union of a box (the shaft) and iq's exact triangle SDF (the head);
the cheap half-plane intersection leaves visible corner artefacts on a wedge this shallow. From
that one distance field the shader derives the outline, the rim-weighted interior wash, the
chevrons (a phase skewed by |x|, which turns flat bands into arrowheads pointing the way the
cast does), the frost noise and voronoi plates, the ring at the caster's feet, the range cap arc,
a six-fold frost rosette pinned to the impact point, and the sweep-out when the ability is armed.
materials/IceMaterial.js patches a MeshStandardMaterial rather than replacing it, so the
crystals cast and receive the stage's real shadows and pick up the HDR probe. The stylisation is
injected on top:
colorDeep; grazing edges stay pale. This is the term that makes the field read
as a solid you can see into rather than as blue plastic.birthFade, so a
crystal is lit from within for the moment it erupts.Three InstancedMeshes share one material. Three rather than one because the facets differ, not
just the proportions — per-instance scaling alone cannot buy that silhouette variety, and three
draw calls is a cheap price.
ThunderAbility takes the "no dimensions on the CPU" rule further than the ice does: there is no
path object at all. The bolt is one InstancedBufferGeometry — a flat ladder of quads in
parameter space, where each vertex carries only (t, side): how far along the bolt it is, and
which edge of the ribbon it is on. One instance is one filament. materials/LightningMaterial.js
turns that pair into a world position every frame, so a single strip serves a bolt of any length,
any shape and any width.
Three things stack to make the shape:
sag. The only part
that knows where the cast is pointing.spreadNear at the hand to spread at the target and rolling around the axis with
twist. This is what separates one filament from the next.The ribbon is turned to face the camera by crossing the local tangent with the view vector, which is why the bolt keeps its apparent thickness from any angle without ever being a screen-space line. It is drawn twice — a wide soft halo underneath and the hot core on top — because drawing the glow as real ribbon rather than leaving it to bloom is what keeps it attached to every kink.
Two clocks run the flicker. restrike snaps every filament onto a new shape N times a second,
and crawl slides the kinks continuously in between; together they stop a held bolt from looking
like a static ribbon. A cast captures exactly one number — a seed, so two casts do not draw the
identical bolt — and resolves every metre, radian and second against settings.thunder each
frame. That is why dragging jitter re-kinks a bolt that is already in the air.
The ground burns are worth a note as a thing not to do. The first version sampled the filament
field on atan(y, x), which hands every radius along a given bearing the same value and draws
dead-straight spokes out of the centre — a firework, not a burn. Sampling the same noise in the
plane and warping the lookup is what lets the filaments meander and fork.
The Nova Beam shares the bolt's rule — no dimensions on the CPU — and reaches the opposite look with it. Where the bolt's whole charm is that its noise is piecewise-linear and keeps its corners, every noise term in the beam is smooth, stretched hard along the flow and crawling downrange. A beam that kinks is a bolt.
It is a real tube rather than a camera-facing ribbon, because a column this thick has to have a
cross-section: the silhouette must bow correctly when you orbit it, the far wall must add through
the near one, and the shock discs have to hug it. createBeamTubeGeometry is the ribbon strip one
dimension richer — every vertex carries (t, a), how far along the barrel it is and how far around
— and materials/BeamMaterial.js turns that pair into a world position each frame.
That one tube is drawn three times, and the trick is in how the three are weighted:
Rim-weighted outside, axis-weighted inside, both faces adding: that is a volume integral, cheaply,
and the inversion is the entire reason the middle reads as a solid rod of light instead of as a lit
pipe. Widen coreWidth or push coreFill up and the three layers collapse into one white tube —
the cyan sheath and the gold coils are only legible because the core leaves them room.
Two more instanced passes put structure on it. The coils are the bolt's ribbon strip bent into a
helix, camera-facing and warm on purpose — the colour split is what stops them dissolving into the
sheath. The shock discs are an instanced annulus whose phase is fract(index / count + time × speed), so the train is a pure function of the clock and there is no queue on the CPU. Both place
themselves against the same beamRadius() the tube uses, which is why all five stay welded together
when the profile is dragged.
The beam is also the one ability with a fourth beat. The other three run travel → impact →
fade; this one puts a wind-up in front of that, and it needed nothing from the base class:
advance() simply refuses to let the front leave the hand until the orb is up to power, so IMPACT
becomes the burn and the phase machine is untouched. The far end therefore has an impact that keeps
happening — spray thrown back up the line, pressure shells shed off the burning point, dust and
shockwave rings pushed across the floor, all rate-throttled through the same fractional-rate emitter
the particles use so every rate is a live slider.
The Voltaic Snare is the first ability built around a point instead of a line, and the thing that
holds it together is that zoneRadius is read in exactly one place per consumer and nowhere is it
copied: the indicator measures it out, the tendrils end on it, the rim arcs run along it, the field
burns it and the column's throat and flare are fractions of it. Drag it and all five move together,
mid-cast, with the clock stopped.
The whole cage — the whip that plants it, the pillar, the tendrils and the rim arcs — is one instanced ribbon strip, the same one the bolt and the beam's coils are drawn on. A filament's role is decided in the vertex shader by testing its instance index against four live counts, and the role picks which parametric path it is threaded along:
throat to columnSpread.Every offset then lives in a frame taken by finite difference off that path, which is what lets one
kink function serve a vertical pillar and a filament crawling flat across the floor. The two
ground-hugging roles damp the vertical component of that offset and clamp above the floor — a kink
with a free y buries half of every tendril and the effect reads as a broken dotted line. Setting
a count to zero retires the role outright, which is how the leash disappears on the frame the ring
takes over. Two draw calls cover all four roles, however many filaments are in the air.
The field is a quad rather than a pooled decal for one reason: a decal captures its radius when
it spawns, and this circle has to re-scale under zoneRadius while it is standing. Its veins are
sampled in the plane and domain warped — the same lesson the bolt's ground burns taught, and for
the same reason.
The one thing worth stealing for the next far cast is the snap: the ring opens on
Easing.outCubic multiplied by a bump that peaks late and dies at exactly 1, so it overshoots its
radius and pulls back onto it, and the pillar climbs on the same clock 1.7× slower. The ground goes
first, then the air breaks down over it.
config/abilities/<id>.js exporting its settings block and its editor schema, and import
it in config/abilities/index.js. The schema is the editor folder — there is no ui/Editor.js
edit any more, and no ability list in config/settings.js.Ability in abilities/<school>/<Name>Ability.js and implement createShaders,
createParticles, onTravel, onImpact, onFade.abilities/registry.js. That is the only registration: the manager, the
HUD, the spellbook, the presets and the aim controller all derive from it.ui/glyphs/<school>.js.Q E R F V X Z T (digits 1..8 mirror
them), rebound by dragging a card out of the spellbook and saved to localStorage. Only the
two keyless default slots are named in code, in DEFAULT_FILL in ui/Loadout.js.To make it a far cast instead of a line cast, add two things and nothing else: cast: CastShape.ZONE on its registry descriptor, and a zoneRadius in its settings block. The circle
indicator, the reach ring, the snap-out and the whole targeting loop come for free, and the ability
reads its centre as pointAt(1).
Everything else — pooling, the travelling front, the local frame, lights, phases, per-ability cooldowns, the aim reach and camera framing — is inherited or driven off the registry.
Then run npm run check. It parses your ability for every settings key it reads and fails on one
that does not exist, simulates the whole cast headlessly, and mutates your sliders on a
zero-length frame and fails if nothing observable changes — the invariant that the whole project
is built on, enforced rather than trusted. It does not compile GLSL, so put a new material on
screen once before you believe it.
particles/ParticleSystem.js is a GPU-simulated, instanced-quad system. Motion (velocity, gravity,
analytic drag, curl turbulence, vortex swirl), size-over-lifetime, the colour gradient and alpha
fade are all evaluated in the shader from per-instance attributes; the CPU only ever writes spawn
data, and only the slots that changed are uploaded. Particles live in a ring buffer, so spamming
the ability recycles slots instead of allocating. Silhouettes (soft, smoke, streak, leaf, chip,
ring) are procedural — there are no sprite textures anywhere in the project.
Frost Lance uses three systems: mist (non-additive, so the fog genuinely occludes and gives the field depth), shards (lit chips under gravity) and glitter (additive, negative gravity — the rising plume that is the signature of the reference frame).
Storm Lance uses four: sparks (velocity-stretched streaks under gravity), motes (the slow ionised drift around the bolt), smoke (non-additive haze off the scorched floor) and debris (lit chips). Its sparks are emitted from several points along the bolt each frame rather than one: a beam sheds along its whole length, and a single origin makes every batch read as a starburst.
Nova Beam uses four as well, and works one of them twice: its motes are the intake spiralling
into the orb while it charges and the drift shed off the column once it is firing — the same glow,
thrown the other way. Its sparks are thrown radially off the barrel and then dragged downrange
by sparkForward, which is the read that says "pressure"; the bolt's fall instead, and that one
difference does a lot of the work of keeping the two abilities apart.
Per frame:
LAYER.WORLD, so mist and glitter fade softly against them.vfx/Distortion.js is what writes
there: heat shimmer, 1/r² gravity lensing, travelling shock fronts, vacuum blades and refracting
hulls. The layer is counted, so the clear, the draw and the resample are all skipped on frames
where nothing is refracting anything — post.distortionEnabled turns it off outright and
post.distortionScale sets the offset buffer's resolution. See src/vfx/README.md.The grade pass folds chromatic aberration, lift/gain/contrast/saturation/temperature, vignette, film grain and the impact flash into one resample.
Shadows come from a single directional light whose orthographic shadow camera is re-centred on the
character each frame and fitted to a 52 m box at 4096² (~1.3 cm/texel). The three/addons CSM
module was tried first and removed: it replaces three's lights_fragment_begin chunk globally,
so any material not explicitly registered with it silently loses all directional lighting.
Contact shadows are a real render: the character's depth is captured from below into a 256² target, blurred twice and projected onto the ground.
Press G for the panel. Folders: Presets, Global, Aim indicator, Far-cast circle, then one per school — Frost, Flame, Storm, Stone, Verdant, Void, Arcane, Blood, Aether, Tide, Forge, Lumen, Ink, Chrono, Hive — then Environment, Post processing, Camera, Character. Every folder starts collapsed, so the panel opens as a list of nineteen rows rather than sixteen thousand controls.
An ability's folder is built the first time it is opened. Constructing all hundred eagerly cost 597 ms and 107,000 DOM nodes at boot, which is a stalled start; on demand it is 7.7 ms and 1,400 nodes. The search box at the top still finds controls in folders that have never been opened, because it indexes the schemas rather than the built controllers, and typing filters the whole tree at once.
Every ability's folder is generated from the <id>Schema export beside its settings block, so a
new slider appears in the panel the moment it exists — filed if the schema mentions it, and in a
trailing More folder if it does not. A schema is never wrong, only incomplete.
Global multipliers scale everything at once (speed, glow, noise, particles, lights, impact intensity, camera shake, time scale…).
Aim indicator — the arrow's silhouette in metres, its outline and fill, the chevrons and frost, and the rings and rosette.
Far-cast circle (40 controls) — the boundary band, the interior, the ticks, sweep and reticle, the reach ring, and the snap-out. Shared by every far cast, so it is filed with the targeting rather than with any one ability. The four shipped abilities below are a sample of the shape every one of the hundred takes:
Frost Lance (113 controls, 25 of them colours) — the cast, the footprint, the silhouette, the crystal itself, the eruption timing, the ice material, the frost on the ground, mist/chips/glitter, the impact and the dynamic light.
Storm Lance (123 controls, 34 of them colours) — the cast, where the bolt leaves the hand, the bundle, one filament, the ribbon, flicker and restrike, the bolt's colour, the burns on the ground, sparks/motes/smoke/debris, the muzzle and impact, and the dynamic light.
Nova Beam (176 controls) — the cast, where it leaves the hands, the column, the core/sheath/ halo stack, the surface and its flow, the beam's colour, the coils, the shock discs, the charge and its intake, what the floor does, sparks/motes/steam/debris, release/impact/burn, and the two dynamic lights.
Voltaic Snare (174 controls, 33 of them colours) — the cast and its footprint, the leash, the column, the tendrils, the rim arcs, the shared filament shape and flicker, the ribbon and its colour, the field on the floor, the burns, sparks/updraft/smoke/debris, throw/snap/hold, and the dynamic light.
Presets save to localStorage, and can be duplicated, deleted, exported to JSON, imported
from JSON, or reset to the shipped defaults.
Every ability exposes every colour it draws with, and none is derived from another: the crystal
palette, the bolt palette, the beam's four layers and its coils and discs, the ground marks, the
impact shells, the shockwave rings, the screen flashes, and a four-stop lifetime gradient
(birth → early → late → death) for each particle system. Tinting the fog without touching the ice,
or cooling the sparks to orange while the filaments stay blue, is a picker away.
Presets are plain snapshots of the settings tree, so an exported file is readable and editable by hand.
Knobs worth knowing about, because they reshape their ability the most:
ice.heightCurve — how late the ramp climbs; raise it and the field stays low until it explodes
at the target. ice.frontBias below 1 crowds the crystals toward the impact point.thunder.jitter and thunder.jitterScale — how violently the bolt kinks, and how often.
thunder.strands and thunder.spread set how wide the bundle reads, and thunder.restrike
how hard it strobes. Those five carry the character of the effect.beam.radius and beam.flare — how heavy the column reads and how hard it opens out where it
lands. beam.charge and beam.lifetime are the wind-up and the hold, which are what make this
ability feel unlike the other three, and beam.coreWidth / beam.coreFill decide whether the
layers stay separable or blow out to white.snare.zoneRadius — the one number the whole far cast is built on. It resizes the targeting
circle, the tendrils, the rim arcs, the burnt field and the pillar's throat together, live.
After that, snare.snapTime and snare.height carry the moment it opens, and snare.tendrils /
snare.rimArcs / snare.strands decide how much of that footprint is actually lit.zone.boundary and zone.snap — how thick the far-cast circle's edge reads, and how hard it
overshoots on the way out. Between them they decide whether the indicator feels like a UI overlay
or like something the caster is doing.strands is nearly free.tendrils or rimArcs is nearly free.
Its targeting circle is two more: one quad and one ring strip.coils and rings are
nearly free. It takes two of the six dynamic lights (the column and the caster's hands), so four
concurrent beams would exhaust the pool; LightPool.acquire() returns null and every use of the
handle is guarded.App calls warm(id) on selection
so the class is in memory long before the click. renderer.compileAsync() at boot therefore
only sees what is in the scene at boot — an ability's shaders compile the first time it is
warmed, which is why selection warms rather than the cast.Mirror (Black Ice, Refraction Cascade) renders the world a second time from a mirrored camera
and is the most expensive thing here; it is capped, skipped when nothing is visible, and its
target resolution is a slider. VolumeHull is fill-rate bound and reads
settings.global.volumeQuality.The figures below are for the six original abilities and still hold; the new ninety-four were built to the same budget of roughly a dozen draw calls for a whole cast.
Measured on a default cast: 32 draw calls idle, ~69 with a full ice field standing and ~49 with a bolt in the air, ~1150 live particles. A snare standing with its cage, field and rim burns is ~45 draw calls and ~480 live particles, and arming its circle costs two. Four concurrent casts — the pool's ceiling, whichever slots they came from — peaks at ~186 draw calls and five of the six dynamic lights.
Live counters (FPS, live particles, instances, draw calls) are in the top-right of the HUD.
src/archive/ holds the previous incarnation of this project: a four-element bending sandbox
(fire, water, earth, air) cast along a freehand-drawn spline, plus a walk mode that let the avatar
ride the same stroke. None of it is imported by the live app, so Vite never bundles it.
It was retired because this build replaced path drawing with a linear skillshot, which removed the
input every one of those systems was built on. The raymarched flame and water surfaces in
particular are worth mining. See src/archive/README.md for what is in there and how to restore a
piece of it.
transparent: true and depthWrite: true. That is the right trade for
near-opaque ice and it keeps the field from sorting through itself, but at low ice.opacity the
sorting artefacts between overlapping spikes become visible.vfx/Distortion.js writes to it
(heat, gravity lensing, shock fronts, vacuum blades, refracting hulls), and the pass now skips its
clear, its draw and its resample entirely on frames where nothing on the layer is visible. What
remains is that overlapping distorters resolve by coverage rather than by depth: the offset buffer
has no depth attachment, so occlusion is a soft rejection against the opaque prepass and other
transparents cannot hide a warp.y = 0, and the snare's tendrils and rim arcs are placed against that same plane. Neither
would drape over a step.MIT, © mohamedachrefelouafi — see LICENSE for the full text. The original
sandbox, and every technique this project is built on top of, is his work.
The bundled HDR probe and the character FBX are third-party assets and retain their own licences; they are not covered by the MIT grant above.
18 commits
9 commits
Hacker News (1)
JavaScript
99.6%
A skillshot VFX sandbox with 100 procedural spell effects across 15 schools. Three.js and hand-written GLSL, nothing is a texture. Expanded from achrefelouafi's original six-ability sandbox, which is where the techniques come from.
55
stars
27
commits
JavaScript
primary language
Aug 14, 2026
updated
A skillshot VFX sandbox built with Three.js, Vite and hand-written GLSL.
One hundred abilities across fifteen schools, and two ways to aim them. Fifty are line casts: press the key to arm, a League-of-Legends style arrow appears on the ground and swings with the mouse, click to fire. The other fifty are far casts: the arrow is replaced by a circle with a deliberately thick boundary that follows the cursor and answers the only question a ground-targeted AoE has to answer before you commit — how much space is this going to take.
Eight of the hundred are on the bar at a time. Tab opens the spellbook; drag a card onto a slot
to bind it. The six below are the ones the sandbox shipped with and the ones the rest were built
to the standard of — see docs/ROSTER.md and docs/ROSTER-II.md for the other ninety-four, each
with the one technique that makes it read.
Q — Frost Lance. A fracture front races out along the line while a field of ice crystals tears up out of the floor behind it — small and dense at your feet, opening into a wall of blades at the far end, with a cluster thrown up around the impact point.
E — Storm Lance. A bolt leaves the caster's hand and a bundle of lightning filaments is drawn out behind the strike front, holds while it gutters and re-strikes, then blows out. Sparks come off it the whole way, the floor underneath takes a branching electric burn and a dark scorch, and the far end gets a shell of ionised air.
R — Cinder Fall. A burning rock is lobbed downrange on an arc, trailing a raymarched wake of burning gas and heating up the whole way: the lava seams splitting its surface prise wider and brighter as it comes in. It detonates on arrival, throws its own shattered chunks across the floor, and tears the ground open into a network of molten cracks that keep glowing while the crater burns out.
F — Nova Beam. The caster winds a ball of light up in both hands, pulling motes in out of the air, then lets a column of it out along the line — white-hot core, cyan sheath, gold ribbons spiralling around it and shock discs racing down it. It holds there, burning into the floor and throwing spray back up the beam, before collapsing to a thread and blinking out. The only cast in the sandbox that is still happening a second after it landed.
V — Voltaic Snare. The far cast. A leash of current is whipped out across the floor, and where it lands the ring snaps open past its own radius and pulls back onto it: a violet column tears up out of the middle, tendrils crawl outward to the boundary, arcs run around the rim and the whole disc burns. It holds there re-striking and hauling the air up into the pillar, then collapses to a thread. The circle you measured out before the click is exactly the circle you get.
Everything you can see is generated. There are no textures, no sprite sheets and no meshes on disk except the character: the crystals are procedural geometry, the bolt is a strip of ribbon placed entirely by a vertex shader, the meteor is an icosphere cratered and sliced by fracture planes on the CPU, the beam is a parametric tube drawn three times at three radii, the snare's whole cage is that same ribbon strip threaded along four different parametric paths, the arrow, the targeting circle, the rime, the burns and the molten cracks are signed-distance and noise shaders, and the mist, sparks, chips and glitter are GPU particles.
Every parameter is a live slider — a little over 16,000 of them — and they stay live while the simulation is paused. That is the point of the project: freeze a frame mid-eruption, mid-strike or mid-burn with P, then reshape the silhouette, the palette and the timing against a still image.
References for the look: icecast.jpg, thundercast.jpg, superbeam.jpg and
electricalboost.jpg.
npm install
npm run dev
Then open the URL Vite prints (default http://127.0.0.1:5173).
npm run build
npm run preview
Six binary assets are served from public/ and loaded automatically at boot:
| File | Purpose |
|---|---|
public/models/Idle.fbx | Rigged character and its idle animation clip |
public/models/diffuse.png | The character's colour map |
public/models/cast1.fbx | Cast animation |
public/models/cast2.fbx | Cast animation |
public/models/cast3.fbx | Cast animation — the default for Frost Lance, Root Snare and Glacier Crown |
public/hdri/spruit_sunrise.hdr | HDR probe used for image-based lighting and crystal reflections |
All four FBX files are Mixamo exports of the same rig, each carrying a skinned mesh plus one
animation stack. The character comes from the idle file; the cast files are loaded for their clip
alone, and the duplicate rig that arrives with each one is released the moment its AnimationClip
has been taken. Clips bind to the skeleton by bone name, which is the whole reason an animation
authored in another file plays here without retargeting.
The rig ships no material, so diffuse.png is loaded beside it and assigned as the colour map when
the imported materials are converted to PBR — an FBX that does carry an embedded texture keeps its
own, since that map is authored against its own UVs.
Every ability picks the clip it throws — castAnim in its settings block, a dropdown under The
cast in its editor folder. Out of the box slots 1, 5 and 6 — Frost Lance, Root Snare and Glacier
Crown — throw cast3, and the other three throw cast1. The clip is a one-shot laid over
the looping idle, with character.castBlendIn / castBlendOut as the two edges of that overlap.
The HDR is loaded as image-based lighting and as the reflection source for the ice — it is never shown as a visible sky. The stage keeps its flat dark backdrop.
The bar is a loadout: eight slots over the whole roster, bound to Q E R F V X Z T with the
digits 1..8 mirroring them. The default is the six that shipped in slots 1–6 and two empty
slots; the binding is saved to localStorage and rebound from the spellbook. An empty slot's key
opens the spellbook ready to fill it.
| Input | Action |
|---|---|
| Q (or 1) | Arm Frost Lance — press again to put it away |
| E (or 2) | Arm Storm Lance — press again to put it away |
| R (or 3) | Arm Cinder Fall — press again to put it away |
| F (or 4) | Arm Nova Beam — press again to put it away |
| V (or 5) | Arm Voltaic Snare — the far cast, aimed with a circle |
| X (or 6) | Arm Glacial Crown — the other far cast |
| Z, T (or 7, 8) | The two open slots — empty until you bind something |
| Tab or B | Open the spellbook: the whole roster, by school |
| Move the mouse | Swing the aim arrow, or move the far-cast circle |
| Left click | Cast along the arrow, or drop the circle where it is |
| Esc / right click | Cancel an armed cast |
| Right mouse + drag | Orbit the camera |
| Scroll | Zoom |
| G | Show/hide the VFX editor |
| P | Pause / resume — the editor keeps applying |
| C | Clear all active effects |
| H | Hide the controls panel |
| Click a slot's name | Open that ability's folder in the editor |
In the spellbook: type to filter by name, school or description; arrow keys and Enter to browse and cast; shift-click a card, or drag it onto a slot, to bind it; Esc to close. The loadout bar stays live underneath the book so it can be dropped on.
range and minRange are per ability, so the indicator's reach changes with the slot you have
selected. Aiming closer than the selected ability's minRange tints it red and refuses the cast;
set minRange to 0 if you would rather cast at your own feet, which is what the Snare ships with —
a trap you cannot drop on yourself is missing half its uses. Cooldowns are per ability too, so
spending one slot never locks the other out.
src/
abilities/ Ability base class (the travelling front), registry.js (the one
declaration site), one directory per school, pooling manager
animation/ FBX character loading, AnimationMixer, the per-ability cast clips,
the procedural cast lunge
assets/ Procedural crystal and asteroid geometry, the bolt ribbon strip,
the beam tube and its shock discs
config/ settings.js (globals, aim, post, camera) and abilities/<id>.js —
one settings block and editor schema per ability
core/ App, Renderer, CameraRig, Time, Layers, shared frame uniforms
effects/ Aim arrow, far-cast circle, ground decals, fissures, bursts,
light pool, shake, flash
input/ InputManager (events) and AimController (both targeting shapes)
loaders/ AssetLoader with a shared LoadingManager
materials/ Per-ability bespoke materials — ice, lightning, meteor, fire,
beam, snare, and the ones the new schools brought with them
vfx/ The shared tech library: 26 modules an ability is configured
against rather than re-implementing. See docs/VFX_API.md.
particles/ GPU particle system + engine and rate emitters
postprocessing/ Composer pipeline, grade shader, distortion shader
shaders/lib/ Shared GLSL: noise library, common helpers
ui/ HUD + loadout bar, spellbook overlay, lil-gui editor, sigils,
preset manager, styles
utils/ Maths, colour cache, pooling, disposal, shader patching
world/ Environment (stage lighting), floor, dust, contact shadows
archive/ The retired four-element sandbox — see archive/README.md
src/config/settings.js holds every tweakable value. Nothing else owns that state: shaders,
particle systems, lights and post passes read those objects every frame. That is what makes the
editor work with no rebuild — moving a slider changes the ice field that is already standing, the
next cast, the environment and the post stack at once. Preset loading deep-merges into the same
objects so every live binding stays valid.
import { settings } from './config/settings.js';
settings.ice.height = 7; // visible on the next frame, even mid-cast
settings.thunder.jitter = 1.2; // re-kinks a bolt that is already in the air
settings.global.timeScale = 0.1; // slow the whole cast to a crawl
Ability blocks are keyed by their id in ELEMENTS, and the shared systems that need to know
"which ability is the player holding" — the aim controller, the cooldowns, the HUD — look it up as
settings[element]. The four fields they rely on being present are range, minRange, speed
and cooldown; a far cast adds a fifth, zoneRadius. Everything else in a block is that ability's
own business.
A spike record in IceAbility stores only what the dice decided: a position fraction along
the line, a signed lateral fraction, and a handful of unitless jitters. Not one metre, radian or
second is captured when the cast starts. Every dimension is resolved against settings.ice inside
the update loop, which runs on a zero-length frame too.
So dragging height re-grows a field that is already standing; dragging lean re-tilts it;
dragging clumping re-packs it toward the centre line. The only values a record does capture
are timestamps — the moment its own eruption was triggered. Those are events, not dimensions.
The four shape controls (facets, taper, roughness, bend) cannot be expressed as a
per-instance transform, so they are baked into the geometry instead — and a six-sided crystal is
just 60 triangles, cheap enough to regenerate outright rather than approximate in a vertex shader.
IceAbility#_syncGeometry hashes those four values and rebuilds the three crystal meshes when the
hash changes, which is what keeps them live sliders rather than restart-required constants.
AimController raycasts the pointer onto the ground plane every frame, not only on mouse
move, so orbiting the camera with a cast armed swings the indicator under a stationary cursor. It
clamps the distance into [minRange, range], tracks a 0..1 reveal envelope, and emits a single
cast event carrying an origin, a unit direction and a distance — which is exactly the signature
Ability#spawn takes. It decides nothing about what the cast does.
It runs on real time rather than the scaled simulation delta, so the indicator keeps animating while the sandbox is paused.
There are two indicators and one controller. Which one is drawn comes from
ELEMENT_META[element].cast — CastShape.LINE or CastShape.ZONE — and that is the only thing
the two shapes disagree about. Arming, clamping, validating, revealing and firing are shared, and
both end in the same three-argument cast event, because from the targeting side a far cast is a
line cast you only care about the far end of. That is why zone targeting needed no change in
Ability, AbilityManager or App: SnareAbility reads its centre as pointAt(1) and works
outward from there.
ZoneIndicator is the arrow's opposite number, and it is built out of the same two ideas: metres,
and no textures.
The footprint is one quad whose fragment shader remaps UV into metres from the target, so the
boundary stays 0.34 m thick whether the circle is 2 m or 8 m across. The band is deliberately the
heaviest mark on screen — it is the whole message — and it is split about the nominal radius by
boundaryBias rather than centred on it, so its outer lip stays honest about where the effect
ends. Inside there is a rim-weighted wash, contour rings travelling outward, warped filaments and a
reticle whose downrange arm is longer, because the quad carries the caster's yaw and that arm is
therefore the heading.
The reach ring at the caster is the bolt's ribbon strip bent into a circle: (t, side) in,
world position out. A quad big enough to hold a 20 m range would be 40 m across and shade a
screenful of discarded fragments for one thin line.
The circle snaps out past its radius and settles back when the cast is armed, and the trap does the same thing when it lands. A circle that grows linearly reads as a UI element; one that overshoots reads as something the caster did.
AimIndicator is a single ground quad. Its fragment shader remaps UV into metres measured from
the caster, so every control in settings.aim is a real measurement — the shaft stays 0.42 m
wide whether the cast is 3 m or 15 m long.
The silhouette is a rounded union of a box (the shaft) and iq's exact triangle SDF (the head);
the cheap half-plane intersection leaves visible corner artefacts on a wedge this shallow. From
that one distance field the shader derives the outline, the rim-weighted interior wash, the
chevrons (a phase skewed by |x|, which turns flat bands into arrowheads pointing the way the
cast does), the frost noise and voronoi plates, the ring at the caster's feet, the range cap arc,
a six-fold frost rosette pinned to the impact point, and the sweep-out when the ability is armed.
materials/IceMaterial.js patches a MeshStandardMaterial rather than replacing it, so the
crystals cast and receive the stage's real shadows and pick up the HDR probe. The stylisation is
injected on top:
colorDeep; grazing edges stay pale. This is the term that makes the field read
as a solid you can see into rather than as blue plastic.birthFade, so a
crystal is lit from within for the moment it erupts.Three InstancedMeshes share one material. Three rather than one because the facets differ, not
just the proportions — per-instance scaling alone cannot buy that silhouette variety, and three
draw calls is a cheap price.
ThunderAbility takes the "no dimensions on the CPU" rule further than the ice does: there is no
path object at all. The bolt is one InstancedBufferGeometry — a flat ladder of quads in
parameter space, where each vertex carries only (t, side): how far along the bolt it is, and
which edge of the ribbon it is on. One instance is one filament. materials/LightningMaterial.js
turns that pair into a world position every frame, so a single strip serves a bolt of any length,
any shape and any width.
Three things stack to make the shape:
sag. The only part
that knows where the cast is pointing.spreadNear at the hand to spread at the target and rolling around the axis with
twist. This is what separates one filament from the next.The ribbon is turned to face the camera by crossing the local tangent with the view vector, which is why the bolt keeps its apparent thickness from any angle without ever being a screen-space line. It is drawn twice — a wide soft halo underneath and the hot core on top — because drawing the glow as real ribbon rather than leaving it to bloom is what keeps it attached to every kink.
Two clocks run the flicker. restrike snaps every filament onto a new shape N times a second,
and crawl slides the kinks continuously in between; together they stop a held bolt from looking
like a static ribbon. A cast captures exactly one number — a seed, so two casts do not draw the
identical bolt — and resolves every metre, radian and second against settings.thunder each
frame. That is why dragging jitter re-kinks a bolt that is already in the air.
The ground burns are worth a note as a thing not to do. The first version sampled the filament
field on atan(y, x), which hands every radius along a given bearing the same value and draws
dead-straight spokes out of the centre — a firework, not a burn. Sampling the same noise in the
plane and warping the lookup is what lets the filaments meander and fork.
The Nova Beam shares the bolt's rule — no dimensions on the CPU — and reaches the opposite look with it. Where the bolt's whole charm is that its noise is piecewise-linear and keeps its corners, every noise term in the beam is smooth, stretched hard along the flow and crawling downrange. A beam that kinks is a bolt.
It is a real tube rather than a camera-facing ribbon, because a column this thick has to have a
cross-section: the silhouette must bow correctly when you orbit it, the far wall must add through
the near one, and the shock discs have to hug it. createBeamTubeGeometry is the ribbon strip one
dimension richer — every vertex carries (t, a), how far along the barrel it is and how far around
— and materials/BeamMaterial.js turns that pair into a world position each frame.
That one tube is drawn three times, and the trick is in how the three are weighted:
Rim-weighted outside, axis-weighted inside, both faces adding: that is a volume integral, cheaply,
and the inversion is the entire reason the middle reads as a solid rod of light instead of as a lit
pipe. Widen coreWidth or push coreFill up and the three layers collapse into one white tube —
the cyan sheath and the gold coils are only legible because the core leaves them room.
Two more instanced passes put structure on it. The coils are the bolt's ribbon strip bent into a
helix, camera-facing and warm on purpose — the colour split is what stops them dissolving into the
sheath. The shock discs are an instanced annulus whose phase is fract(index / count + time × speed), so the train is a pure function of the clock and there is no queue on the CPU. Both place
themselves against the same beamRadius() the tube uses, which is why all five stay welded together
when the profile is dragged.
The beam is also the one ability with a fourth beat. The other three run travel → impact →
fade; this one puts a wind-up in front of that, and it needed nothing from the base class:
advance() simply refuses to let the front leave the hand until the orb is up to power, so IMPACT
becomes the burn and the phase machine is untouched. The far end therefore has an impact that keeps
happening — spray thrown back up the line, pressure shells shed off the burning point, dust and
shockwave rings pushed across the floor, all rate-throttled through the same fractional-rate emitter
the particles use so every rate is a live slider.
The Voltaic Snare is the first ability built around a point instead of a line, and the thing that
holds it together is that zoneRadius is read in exactly one place per consumer and nowhere is it
copied: the indicator measures it out, the tendrils end on it, the rim arcs run along it, the field
burns it and the column's throat and flare are fractions of it. Drag it and all five move together,
mid-cast, with the clock stopped.
The whole cage — the whip that plants it, the pillar, the tendrils and the rim arcs — is one instanced ribbon strip, the same one the bolt and the beam's coils are drawn on. A filament's role is decided in the vertex shader by testing its instance index against four live counts, and the role picks which parametric path it is threaded along:
throat to columnSpread.Every offset then lives in a frame taken by finite difference off that path, which is what lets one
kink function serve a vertical pillar and a filament crawling flat across the floor. The two
ground-hugging roles damp the vertical component of that offset and clamp above the floor — a kink
with a free y buries half of every tendril and the effect reads as a broken dotted line. Setting
a count to zero retires the role outright, which is how the leash disappears on the frame the ring
takes over. Two draw calls cover all four roles, however many filaments are in the air.
The field is a quad rather than a pooled decal for one reason: a decal captures its radius when
it spawns, and this circle has to re-scale under zoneRadius while it is standing. Its veins are
sampled in the plane and domain warped — the same lesson the bolt's ground burns taught, and for
the same reason.
The one thing worth stealing for the next far cast is the snap: the ring opens on
Easing.outCubic multiplied by a bump that peaks late and dies at exactly 1, so it overshoots its
radius and pulls back onto it, and the pillar climbs on the same clock 1.7× slower. The ground goes
first, then the air breaks down over it.
config/abilities/<id>.js exporting its settings block and its editor schema, and import
it in config/abilities/index.js. The schema is the editor folder — there is no ui/Editor.js
edit any more, and no ability list in config/settings.js.Ability in abilities/<school>/<Name>Ability.js and implement createShaders,
createParticles, onTravel, onImpact, onFade.abilities/registry.js. That is the only registration: the manager, the
HUD, the spellbook, the presets and the aim controller all derive from it.ui/glyphs/<school>.js.Q E R F V X Z T (digits 1..8 mirror
them), rebound by dragging a card out of the spellbook and saved to localStorage. Only the
two keyless default slots are named in code, in DEFAULT_FILL in ui/Loadout.js.To make it a far cast instead of a line cast, add two things and nothing else: cast: CastShape.ZONE on its registry descriptor, and a zoneRadius in its settings block. The circle
indicator, the reach ring, the snap-out and the whole targeting loop come for free, and the ability
reads its centre as pointAt(1).
Everything else — pooling, the travelling front, the local frame, lights, phases, per-ability cooldowns, the aim reach and camera framing — is inherited or driven off the registry.
Then run npm run check. It parses your ability for every settings key it reads and fails on one
that does not exist, simulates the whole cast headlessly, and mutates your sliders on a
zero-length frame and fails if nothing observable changes — the invariant that the whole project
is built on, enforced rather than trusted. It does not compile GLSL, so put a new material on
screen once before you believe it.
particles/ParticleSystem.js is a GPU-simulated, instanced-quad system. Motion (velocity, gravity,
analytic drag, curl turbulence, vortex swirl), size-over-lifetime, the colour gradient and alpha
fade are all evaluated in the shader from per-instance attributes; the CPU only ever writes spawn
data, and only the slots that changed are uploaded. Particles live in a ring buffer, so spamming
the ability recycles slots instead of allocating. Silhouettes (soft, smoke, streak, leaf, chip,
ring) are procedural — there are no sprite textures anywhere in the project.
Frost Lance uses three systems: mist (non-additive, so the fog genuinely occludes and gives the field depth), shards (lit chips under gravity) and glitter (additive, negative gravity — the rising plume that is the signature of the reference frame).
Storm Lance uses four: sparks (velocity-stretched streaks under gravity), motes (the slow ionised drift around the bolt), smoke (non-additive haze off the scorched floor) and debris (lit chips). Its sparks are emitted from several points along the bolt each frame rather than one: a beam sheds along its whole length, and a single origin makes every batch read as a starburst.
Nova Beam uses four as well, and works one of them twice: its motes are the intake spiralling
into the orb while it charges and the drift shed off the column once it is firing — the same glow,
thrown the other way. Its sparks are thrown radially off the barrel and then dragged downrange
by sparkForward, which is the read that says "pressure"; the bolt's fall instead, and that one
difference does a lot of the work of keeping the two abilities apart.
Per frame:
LAYER.WORLD, so mist and glitter fade softly against them.vfx/Distortion.js is what writes
there: heat shimmer, 1/r² gravity lensing, travelling shock fronts, vacuum blades and refracting
hulls. The layer is counted, so the clear, the draw and the resample are all skipped on frames
where nothing is refracting anything — post.distortionEnabled turns it off outright and
post.distortionScale sets the offset buffer's resolution. See src/vfx/README.md.The grade pass folds chromatic aberration, lift/gain/contrast/saturation/temperature, vignette, film grain and the impact flash into one resample.
Shadows come from a single directional light whose orthographic shadow camera is re-centred on the
character each frame and fitted to a 52 m box at 4096² (~1.3 cm/texel). The three/addons CSM
module was tried first and removed: it replaces three's lights_fragment_begin chunk globally,
so any material not explicitly registered with it silently loses all directional lighting.
Contact shadows are a real render: the character's depth is captured from below into a 256² target, blurred twice and projected onto the ground.
Press G for the panel. Folders: Presets, Global, Aim indicator, Far-cast circle, then one per school — Frost, Flame, Storm, Stone, Verdant, Void, Arcane, Blood, Aether, Tide, Forge, Lumen, Ink, Chrono, Hive — then Environment, Post processing, Camera, Character. Every folder starts collapsed, so the panel opens as a list of nineteen rows rather than sixteen thousand controls.
An ability's folder is built the first time it is opened. Constructing all hundred eagerly cost 597 ms and 107,000 DOM nodes at boot, which is a stalled start; on demand it is 7.7 ms and 1,400 nodes. The search box at the top still finds controls in folders that have never been opened, because it indexes the schemas rather than the built controllers, and typing filters the whole tree at once.
Every ability's folder is generated from the <id>Schema export beside its settings block, so a
new slider appears in the panel the moment it exists — filed if the schema mentions it, and in a
trailing More folder if it does not. A schema is never wrong, only incomplete.
Global multipliers scale everything at once (speed, glow, noise, particles, lights, impact intensity, camera shake, time scale…).
Aim indicator — the arrow's silhouette in metres, its outline and fill, the chevrons and frost, and the rings and rosette.
Far-cast circle (40 controls) — the boundary band, the interior, the ticks, sweep and reticle, the reach ring, and the snap-out. Shared by every far cast, so it is filed with the targeting rather than with any one ability. The four shipped abilities below are a sample of the shape every one of the hundred takes:
Frost Lance (113 controls, 25 of them colours) — the cast, the footprint, the silhouette, the crystal itself, the eruption timing, the ice material, the frost on the ground, mist/chips/glitter, the impact and the dynamic light.
Storm Lance (123 controls, 34 of them colours) — the cast, where the bolt leaves the hand, the bundle, one filament, the ribbon, flicker and restrike, the bolt's colour, the burns on the ground, sparks/motes/smoke/debris, the muzzle and impact, and the dynamic light.
Nova Beam (176 controls) — the cast, where it leaves the hands, the column, the core/sheath/ halo stack, the surface and its flow, the beam's colour, the coils, the shock discs, the charge and its intake, what the floor does, sparks/motes/steam/debris, release/impact/burn, and the two dynamic lights.
Voltaic Snare (174 controls, 33 of them colours) — the cast and its footprint, the leash, the column, the tendrils, the rim arcs, the shared filament shape and flicker, the ribbon and its colour, the field on the floor, the burns, sparks/updraft/smoke/debris, throw/snap/hold, and the dynamic light.
Presets save to localStorage, and can be duplicated, deleted, exported to JSON, imported
from JSON, or reset to the shipped defaults.
Every ability exposes every colour it draws with, and none is derived from another: the crystal
palette, the bolt palette, the beam's four layers and its coils and discs, the ground marks, the
impact shells, the shockwave rings, the screen flashes, and a four-stop lifetime gradient
(birth → early → late → death) for each particle system. Tinting the fog without touching the ice,
or cooling the sparks to orange while the filaments stay blue, is a picker away.
Presets are plain snapshots of the settings tree, so an exported file is readable and editable by hand.
Knobs worth knowing about, because they reshape their ability the most:
ice.heightCurve — how late the ramp climbs; raise it and the field stays low until it explodes
at the target. ice.frontBias below 1 crowds the crystals toward the impact point.thunder.jitter and thunder.jitterScale — how violently the bolt kinks, and how often.
thunder.strands and thunder.spread set how wide the bundle reads, and thunder.restrike
how hard it strobes. Those five carry the character of the effect.beam.radius and beam.flare — how heavy the column reads and how hard it opens out where it
lands. beam.charge and beam.lifetime are the wind-up and the hold, which are what make this
ability feel unlike the other three, and beam.coreWidth / beam.coreFill decide whether the
layers stay separable or blow out to white.snare.zoneRadius — the one number the whole far cast is built on. It resizes the targeting
circle, the tendrils, the rim arcs, the burnt field and the pillar's throat together, live.
After that, snare.snapTime and snare.height carry the moment it opens, and snare.tendrils /
snare.rimArcs / snare.strands decide how much of that footprint is actually lit.zone.boundary and zone.snap — how thick the far-cast circle's edge reads, and how hard it
overshoots on the way out. Between them they decide whether the indicator feels like a UI overlay
or like something the caster is doing.strands is nearly free.tendrils or rimArcs is nearly free.
Its targeting circle is two more: one quad and one ring strip.coils and rings are
nearly free. It takes two of the six dynamic lights (the column and the caster's hands), so four
concurrent beams would exhaust the pool; LightPool.acquire() returns null and every use of the
handle is guarded.App calls warm(id) on selection
so the class is in memory long before the click. renderer.compileAsync() at boot therefore
only sees what is in the scene at boot — an ability's shaders compile the first time it is
warmed, which is why selection warms rather than the cast.Mirror (Black Ice, Refraction Cascade) renders the world a second time from a mirrored camera
and is the most expensive thing here; it is capped, skipped when nothing is visible, and its
target resolution is a slider. VolumeHull is fill-rate bound and reads
settings.global.volumeQuality.The figures below are for the six original abilities and still hold; the new ninety-four were built to the same budget of roughly a dozen draw calls for a whole cast.
Measured on a default cast: 32 draw calls idle, ~69 with a full ice field standing and ~49 with a bolt in the air, ~1150 live particles. A snare standing with its cage, field and rim burns is ~45 draw calls and ~480 live particles, and arming its circle costs two. Four concurrent casts — the pool's ceiling, whichever slots they came from — peaks at ~186 draw calls and five of the six dynamic lights.
Live counters (FPS, live particles, instances, draw calls) are in the top-right of the HUD.
src/archive/ holds the previous incarnation of this project: a four-element bending sandbox
(fire, water, earth, air) cast along a freehand-drawn spline, plus a walk mode that let the avatar
ride the same stroke. None of it is imported by the live app, so Vite never bundles it.
It was retired because this build replaced path drawing with a linear skillshot, which removed the
input every one of those systems was built on. The raymarched flame and water surfaces in
particular are worth mining. See src/archive/README.md for what is in there and how to restore a
piece of it.
transparent: true and depthWrite: true. That is the right trade for
near-opaque ice and it keeps the field from sorting through itself, but at low ice.opacity the
sorting artefacts between overlapping spikes become visible.vfx/Distortion.js writes to it
(heat, gravity lensing, shock fronts, vacuum blades, refracting hulls), and the pass now skips its
clear, its draw and its resample entirely on frames where nothing on the layer is visible. What
remains is that overlapping distorters resolve by coverage rather than by depth: the offset buffer
has no depth attachment, so occlusion is a soft rejection against the opaque prepass and other
transparents cannot hide a warp.y = 0, and the snare's tendrils and rim arcs are placed against that same plane. Neither
would drape over a step.MIT, © mohamedachrefelouafi — see LICENSE for the full text. The original
sandbox, and every technique this project is built on top of, is his work.
The bundled HDR probe and the character FBX are third-party assets and retain their own licences; they are not covered by the MIT grant above.
Hacker News (1)
18 commits
9 commits
JavaScript
99.6%