If you have added particle effects to a Three.js scene, you have probably done it the hard way: a Points geometry, a custom shader, and a tweak-recompile-refresh cycle every time someone says "more sparks, less smoke."
The recompile cycle is the real problem. Particle work is iterative by nature — you have to see a change to judge it. When the effect lives in code, every iteration costs a rebuild, so in practice you stop iterating long before the effect is actually good.
Game engines solved this years ago by splitting authoring from runtime: design the effect in a visual tool, export it as data, load the data at runtime. That split works fine on the web too, and the runtime half is the part worth writing about.
I have been using NixieFX for this lately (disclosure: I work at Rockbite Games, which builds it — it is MIT-licensed and free). The code below is Three.js specific, but the pattern is not.
An exported effect is just data
A manifest plus some JSON per effect plus texture files. Nothing you cannot inspect by hand:
import * as THREE from "three";
import { loadVfxExportBundle } from "nixie-fx/export";
import { ThreeVfxRenderer, ThreeVfxTextureStore } from "nixie-fx/three";
const BUNDLE_URL = "/vfx";
async function json(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to load ${url}`);
return response.json();
}
const manifest = await json(`${BUNDLE_URL}/manifest.json`);
const effectsByPath = Object.fromEntries(
await Promise.all(
manifest.effects.map(async (entry) => [
entry.path,
await json(`${BUNDLE_URL}/${entry.path}`),
]),
),
);
const bundle = loadVfxExportBundle(
{ manifest, effectsByPath },
{ requiredBackend: "three3d" },
);
Wire up the renderer
const textures = new ThreeVfxTextureStore({
resolveUrl: (path) => `${BUNDLE_URL}/${path}`,
});
const effect = bundle.effectsById.get("impact-burst");
await textures.preload(effect.assets.filter((a) => a.type === "texture"));
const vfx = new ThreeVfxRenderer({ scene, camera, textureProvider: textures });
That preload line matters more than it looks. Skip it and the first spawn of an effect pops in a frame or two late while its texture decodes — which is exactly the moment the player is looking at it.
Spawn it
const burst = vfx.createEffect(effect, {
position: [0, 1, 0],
seed: 42,
});
// later, on a hit:
burst.setTransform({ position: [x, y, z] });
burst.restart();
The seed is worth knowing about: same seed, same randomisation. Useful for deterministic replays, for screenshot tests that do not flake, and for making sure a reviewer sees the same thing you did.
The gotcha: delta seconds, not milliseconds
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
vfx.update(clock.getDelta()); // seconds, not ms
renderer.render(scene, camera);
});
update() wants seconds. If you are used to doing your own performance.now() bookkeeping you will reach for milliseconds out of habit, pass 16.7 where 0.0167 was expected, and every effect will fast-forward through its entire lifetime inside a single frame.
The symptom is genuinely confusing, because nothing throws. You get a one-frame flash and then nothing, so it looks like the effect never played, and you go hunting in the wrong place — checking whether the bundle loaded, whether the texture resolved, whether the position is behind the camera. It is none of those. It already played, 400 times faster than you wanted.
THREE.Clock().getDelta() returns seconds already, so use it and move on. Driving your own loop? Divide by 1000.
While you are on that line, clamp it:
vfx.update(Math.min(clock.getDelta(), 0.05));
Otherwise the first frame after a backgrounded tab or a long GC pause arrives with a delta of several seconds, and every live effect in the scene jumps to its end state at once. Same bug, different trigger.
Clean up
vfx.destroy();
textures.destroy();
Is the split worth it
Honest trade-off: you take on a dependency and a build step, and you get back not writing shader code plus the ability to iterate on look-and-feel in seconds. If you need exactly one simple puff of smoke, hand-rolling Points is still less total work — genuinely, do that. Past two or three effects, or the moment someone who does not write code needs to adjust them, the data-driven split wins clearly.
If you want to dig further:
- Three.js runtime API reference
- Full mobile 3D + particle VFX walkthrough
- The editor — runs in the browser, nothing to install
- Source on GitHub
Happy to answer questions about the runtime side in the comments.
Top comments (0)