A skillshot VFX sandbox built with Three.js, Vite and hand-written GLSL.
Five abilities and two ways to aim them. Four 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 fifth is a far cast: 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.
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 — 938 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 installnpm run devThen open the URL Vite prints (default http://127.0.0.1:5173).
npm run buildnpm run previewSix 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.
| 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 |
| 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 |
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), IceAbility, ThunderAbility,
MeteorAbility, BeamAbility, SnareAbility, 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 — the single source of truth for every parameter
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/ IceMaterial, LightningMaterial, MeteorMaterial,
VolumetricFireMaterial, BeamMaterial, SnareMaterial
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, lil-gui editor, 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 crawlAbility 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:
- Thickness tint — a facet seen head-on has the longest path through the crystal, so it
darkens toward
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. - Internal fracture — ridged noise sampled in world space, so the crack planes stay a fixed physical size whether a spike is ankle-high or three metres tall, and neighbouring crystals look quarried from the same block.
- Feather frost and rime — fbm sampled in local space (0..1 up the crystal), so the milky veining and the frost creeping up from the base follow each spike's own axis however it is scaled or leaned.
- Glint — a hard-thresholded high-frequency field scrolling in world space, biased toward grazing angles, which is where real ice catches.
- Birth flash — a per-instance attribute the ability drives from 1 to 0 over
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:
- the axis — a straight line from the hand to the impact point, bowed by
sag. The only part that knows where the cast is pointing. - the fan — a constant per-filament offset in the plane perpendicular to the axis, opening
from
spreadNearat the hand tospreadat the target and rolling around the axis withtwist. This is what separates one filament from the next. - the kinks — octaves of linearly interpolated value noise. Linear on purpose: smoothstep would round the corners off, and the corners are the entire reason it reads as lightning rather than as a wobbly tube.
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:
- halo — widest, nothing but a rim term. The atmosphere the beam is shoving out of the way.
- sheath — rim-weighted, so it reads as hollow and its silhouette edges are its brightest part.
- core — narrow, and weighted the opposite way: brightest where the view ray runs down the barrel and its path through the tube is longest.
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:
- leash — a sagging line from the hand to the travelling tip, dropped onto the floor.
- column — a twisting climb whose radius opens from
throattocolumnSpread. - tendril — a meander running outward, its veer a per-filament constant rather than noise, so it curves the way a discharge that has committed to a direction does.
- rim — an arc travelling around the boundary, hopping over it at mid-span.
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.
- Add a settings block in
config/settings.jsand an entry inELEMENTS/ELEMENT_META. - Subclass
Abilityand implementcreateShaders,createParticles,onTravel,onImpact,onFade. - Register the class in
abilities/AbilityManager.js. - Add an editor folder in
ui/Editor.js, and a sigil inui/glyphs.js. - Bind a key in
input/InputManager.js— it emitsabilitywith the 0-based slot index, whichAppmaps throughELEMENTS.
To make it a far cast instead of a line cast, add two things and nothing else: cast: CastShape.ZONE in its ELEMENT_META entry, 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 ELEMENTS. The HUD
builds its slots from that array, so a new ability appears in the bar on its own.
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:
- Depth prepass — the opaque world into a half-res packed-depth buffer. Every VFX shader
samples it for soft intersections, so nothing cuts a hard line into the ground. The crystals sit
on
LAYER.WORLD, so mist and glitter fade softly against them. - Distortion pass — meshes on the distortion layer write screen-space UV offsets into a second half-res buffer. Nothing writes to it in the current build; the pass is kept because it is the hook a refraction effect would use.
- Composer — scene → refraction warp → bloom → tone map (ACES) → grade.
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, Frost Lance, Storm Lance, Cinder Fall, Nova Beam, Voltaic Snare, Environment, Post processing, Camera, Character. Every folder starts collapsed — there are enough controls here that one open section pushes the rest off the screen.
- 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.
- 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.frontBiasbelow 1 crowds the crystals toward the impact point.thunder.jitterandthunder.jitterScale— how violently the bolt kinks, and how often.thunder.strandsandthunder.spreadset how wide the bundle reads, andthunder.restrikehow hard it strobes. Those five carry the character of the effect.beam.radiusandbeam.flare— how heavy the column reads and how hard it opens out where it lands.beam.chargeandbeam.lifetimeare the wind-up and the hold, which are what make this ability feel unlike the other three, andbeam.coreWidth/beam.coreFilldecide 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.snapTimeandsnare.heightcarry the moment it opens, andsnare.tendrils/snare.rimArcs/snare.strandsdecide how much of that footprint is actually lit.zone.boundaryandzone.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.
- Abilities, decals, bursts and particles are pooled, per type. Twelve casts in a row build at most four instances of an ability and then stop allocating.
- The whole crystal field is three draw calls regardless of crystal count; the cap is 288.
- A whole bolt is two draw calls regardless of filament count; the cap is 24 filaments at 72
samples each. Nothing about the path touches the CPU, so
strandsis nearly free. - A whole snare — leash, pillar, tendrils and rim arcs — is two draw calls plus one for the
field, regardless of how many filaments are in it; the cap is 56 across the four roles. As with
the bolt, none of the shape touches the CPU, so raising
tendrilsorrimArcsis nearly free. Its targeting circle is two more: one quad and one ring strip. - A whole beam is six draw calls regardless of how many coils and discs are on it — three tube
passes over one shared geometry, plus one instanced draw each for the coils, the discs and the
charge orb. As with the bolt, none of the shape touches the CPU, so
coilsandringsare 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. - The six dynamic point lights are created at boot and parked at zero intensity rather than added and removed — changing the light count forces three to recompile every material.
- Shadow maps update exactly once per frame even though the scene is rendered several times.
renderer.compileAsync()runs during boot so the first cast never stutters on shader compile.- Pixel ratio is capped at 1.75; the depth and distortion buffers are half resolution.
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.
- Crystals are drawn with
transparent: trueanddepthWrite: true. That is the right trade for near-opaque ice and it keeps the field from sorting through itself, but at lowice.opacitythe sorting artefacts between overlapping spikes become visible. - The eruption front is a straight line on a flat floor. Both assumptions are baked in — the ground is a single plane at y = 0, and the aim raycast targets that plane.
- The distortion pass runs with nothing writing to it. It costs a half-res clear per frame.
- The impact cluster is placed radially around the end point, so at very short cast distances it can overlap the band behind it more than it should.
- The far cast inherits the flat-floor assumption twice over: the circle is drawn on a single quad
at
y = 0, and the snare's tendrils and rim arcs are placed against that same plane. Neither would drape over a step. - Both the targeting circle and the snare's field are additive, so the footprint brightens the floor rather than shading it. On a pale floor the boundary would need a non-additive pass under it to stay readable.
Code is provided as-is for the purposes of this project. The bundled HDR probe and the character FBX retain their original licences.