Skip to content

Latest commit

 

History

8 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

AETHER

A procedural VFX forge. Game spells — cast, flight, impact — built entirely from maths.

No textures. No sprite sheets. No models. No .glb, no .png, no flipbooks — zero external assets. Every particle, every colour ramp, every curve is computed at runtime.

fireball impact

fireball in flight fireball resolve ice lance in flight ice lance impact


Run it

Static page, but ES modules need to be served over HTTP:

python -m http.server 5173

Then open http://localhost:5173. Needs WebGL2.

Controls

space replay the spell
19 jump to an effect
[ ] slow down / speed up
p pause
g glow level
s save a PNG
h hide the interface

The idea

Effects are data, not code. Adding a spell means writing a spec object in src/effects.js — layers, emitters, curves, colour ramps, forces. Nothing in the engine or the shaders changes. That is the whole design constraint, because a tool that needs a shader edit per effect is not a tool.

A spell is staged as caster → flight → impact:

stage: {
  cast:   [-7.5, 1.5, 0],     // where the caster stands
  target: [ 7.0, 1.5, 0],     // what gets hit
  travel: [0.50, 1.00],       // launch and arrival, in seconds
  arc:    0.7,                // how far the projectile bows on the way over
}

A layer with emitter.follow spawns along the flight path. Because a particle's spawn point is evaluated at its own birth time, a particle born early sits further back along the path — so the trail draws itself with no history buffer anywhere.

How it works

The simulation lives in textures. Position+age and velocity+lifespan live in floating-point render targets, one texel per particle, ping-ponged each frame. Each layer owns a contiguous band of texture rows and is simulated by drawing a quad mapped onto that band, which keeps every per-layer parameter a plain uniform.

Particles are stateless where it counts. Birth time is derived from the particle's index rather than stored, so age is just effectTime - birth. Rewinding the clock replays an effect exactly — which is what makes this usable for authoring rather than just for looking at.

Curves and ramps are baked to lookup textures at load, sampled by normalised age. That is what lets every animated property be data.

Sprites stretch along velocity. Each particle is an instanced quad scaled along its own view-space velocity and shaded as a capsule, so fast particles draw streaks and slow ones stay round.

Five rules, learned the hard way

Every one of these came from something looking wrong on screen:

  1. Additive alone cannot make a silhouette. Light only ever adds, so a stack of additive layers resolves to a filled disc no matter what forces you apply. Every explosion here pairs its hot core with a dark alpha layer that occludes and bites notches out of the shape. Corollary: a ramp fading to near-black under additive blending renders as nothing — I had authored smoke that could not appear.

  2. A symmetric emitter cannot produce an asymmetric result. Uniform emission from a sphere at a uniform speed gives you a dandelion. Impacts use forward-biased cones along the travel vector, split into offset lobes with staggered births, and curl noise perturbing the launch velocity so the shell breaks into billows.

  3. White is an ignition frame, not a body colour — at both ends. Holding white for a quarter of a particle's life clips the core and throws the entire gradient away. The same applies to the tail: a ramp ending at #ffffff whitens the whole population, and does it worse than whitening at birth, because every particle arrives there at once. One colour key at the end of the ice charge's ramp was enough to turn the whole gather into a featureless white blob.

  4. Drag eats secondary forces. Heavy drag annihilates velocity long before buoyancy can mushroom a blast or gravity can make debris fall. The explosion never rose and the ice shards never sagged until drag came down.

  5. Alpha layers saturate fast. Overlapping alpha sprites compound as 1-(1-a)^n, so forty overlaps at a=0.19 is fully opaque. Smoke authored at additive-like alpha renders as solid cotton wool. Alpha layers want ~0.03–0.06, an order of magnitude below their additive siblings.

Measuring instead of eyeballing

Both spells were reviewed against AAA reference across six rounds. What made it converge was not taste but instrumentation — and the single most useful artifact of the whole process was discovering my measurement was wrong.

I was flagging over-brightness by counting pixels where max(r,g,b) >= 252. That flags saturated orange, and saturated orange is the correct colour for the hottest part of a fire — a fire effect with zero max-channel orange pixels is an underexposed fire effect. Measuring min(r,g,b) separately isolates a true white plateau from legitimate saturated hue, and the two numbers want opposite treatment: shrink the area of the saturated one, and eliminate the white one.

Leave-one-out bisection settled every attribution that reasoning could not. Hiding one layer at a time and re-measuring found that the fireball's flight plateau was the trail rather than the head, that a blown white plate on the ice impact was 84% one layer, and — usefully — that a confident diagnosis blaming the floor light pool was wrong, since pinning that uniform to zero changed the numbers not at all.

One more, on method: certifying a fix on a single frame is not certifying it. A bloom artifact declared fixed at t=0.96 was still present at t=0.72; only a sweep across the whole flight window at 60ms intervals showed the problem was flat across all of it.

Two bugs worth recording

  • renderer.setScissor() multiplies by the pixel ratio. Clipping each layer's sim pass to its band with a scissor put the bands on the wrong texture rows on any 2× display, so layers silently read each other's state — the caster's muzzle flash rendered at the target and the projectile vanished entirely. Fixed by mapping the quad onto its band in clip space instead.

  • Four random channels are not enough. Feeding one seed channel to two properties correlates them, visibly. With lifespan and ring angle both on rnd.y, half a shockwave ring died before the other half was born and the ring rendered as an arc. Every property now draws its own hashed stream.

Tinkering

VFX.loadEffect(1)                 // switch effect
VFX.seek(1.22)                    // jump to an absolute time on the effect clock
VFX.contactSheet([0.4, 0.7, 1.0]) // grid of timestamps as a data URL
VFX.applyGlow(0)                  // 0 noir · 1 low · 2 soft · 3 bright

contactSheet exists because reviewing a spell frame by frame hides timing problems. Seeing the whole beat at once does not.

Credits

three.js for the renderer and post-processing. Gradient noise with analytic derivatives is Inigo Quilez's.

About

262,144 particles of pure math. A fully procedural GPU particle field in one HTML file — no models, no textures, no assets.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages