Most of the cost of web-game VFX isn't the effect. It's the loop you run to see the effect.
Edit a config object. Rebuild the bundle. Reload the game. Get to the state where the thing actually fires. Squint. Change emitRate from 240 to 180. Do it again. Forty iterations later you have a fire effect that's fine, and you've spent an afternoon.
This post is about the parts of that loop you can cut, for both Three.js and PixiJS.
The eight knobs
Every particle system — Unity's, Unreal's, three.quarks, Pixi's emitter, whatever you hand-roll — exposes roughly the same eight things:
- emitter shape and rate — where particles spawn, how many per second
- lifetime — how long each one lives; the clock every curve runs against
- velocity — initial speed and direction, spread across the emission shape
- forces — gravity, drag, noise; what bends the path after spawn
- color over life — tint and alpha; the difference between fire and smoke is mostly this
- scale over life — grow on birth, shrink on death; most of the readability lives here
- texture — soft dot, spark streak, or a flipbook
- material / blend mode — additive for energy and light, alpha-blended for smoke and debris
Layered effects add multi-emitter timelines, trails and sub-emitters on top of the same eight. If you understand the list, you can read any effect file in any tool.
Hand-written configs are fine, until curves
Here's a fire burst as a plain object:
{
emitRate: 240,
lifetime: [0.25, 0.6],
speed: [4, 9],
gravity: -12,
sizeOverLife: [1, 0.1],
colorOverLife: ['#fff2b0', '#ff5a1e'],
blend: 'additive'
}
This is precise, diffable and lives in git. Nothing wrong with it. The problem starts the moment an effect needs a non-linear curve — an alpha that holds then falls off a cliff, a size that pops then eases. You cannot feel a bezier as four numbers. You can only compile it and look.
So: author the curves visually, export JSON, keep the runtime in code. The effect still ends up as a reviewable file in the repo; you just stop guessing at the numbers that go in it.
The Three.js side
The game-side code is three calls. Load, spawn, update.
const fx = await loadEffect('effects/explosion.json');
scene.add(fx.object3D);
function onHit(point) {
fx.spawnAt(point);
}
renderer.setAnimationLoop(() => {
fx.update(clock.getDelta());
renderer.render(scene, camera);
});
Three things worth getting right here:
Clamp your delta. clock.getDelta() after a tab switch can hand you two seconds. Effects then jump forward and look broken. Clamp to something like 1/20s.
Pool, don't allocate. Construct one emitter per effect type at load and reuse it, rather than building a new one per hit. Garbage collection during combat is a visible stutter.
Make sure the preview is the effect. If your editor and your runtime use different simulation code, the preview is an approximation and you're back to reload-and-check. A shared deterministic simulation is what actually kills the loop.
The PixiJS side
Same authoring, different stage. 2D world effects and UI effects use the identical JSON:
const fx = await loadEffect('effects/coin-burst.json');
uiLayer.addChild(fx.container);
button.on('pointertap', () => {
fx.spawnAt(button.position);
});
app.ticker.add((t) => fx.update(t.deltaMS / 1000));
UI particles are underrated. A button that sparkles on tap, a reward counter that bursts, a progress bar that throws off sparks as it fills — these cost almost nothing and change how finished a game feels.
Six rules for 60 FPS on a phone
The target is a mid-range Android, not your desktop.
- Cap particle counts per effect and per scene. Twenty well-timed particles beat two hundred.
- Watch overdraw. Large transparent quads stacked on each other are the most common frame killer — not particle count.
- Atlas your textures. One atlas, one draw call. Flipbooks belong in the same sheet.
- Lower emission, not lifetime. Fewer particles reads better than shorter ones. Keep the silhouette, drop the density.
- Clean up finished effects. Return emitters to the pool on completion. Leaked emitters cost frames silently.
- Profile the worst case on device. Several effects firing at once, mid-combat, on real hardware.
Letting an agent write effects
"Make me a fire effect" gets you plausible code with invented APIs and parameters nobody validated, and usually an effect you can't open in an editor afterwards.
What works better: give the agent the actual schema and runtime API as a skill, have it write effect JSON rather than bespoke code, validate the export with a CLI, then open the result in the editor and look at it. The agent drafts; you still judge it with your eyes. VFX is one of the few things where a human has to be the acceptance test.
The tool
I've been building NixieFX around this workflow: a browser particle editor with multi-emitter timelines, curves and gradients, forces and noise, flipbooks, trails, sub-emitters and a node-based material workflow. Effects export as JSON into your own project folder, the editor and runtime share one deterministic simulation, and there are open-source runtimes for both Three.js and PixiJS. There are also skills for Claude Code, Codex, Cursor, Gemini and Copilot.
Free, no install, no account: nixiefx.com · runtime on GitHub
Curious what everyone else is using for web-game VFX — three.quarks, hand-rolled shaders, something else?
Top comments (0)