If you're adding particle effects to a Three.js game, three.quarks is the library most people find first. NixieFX is a newer option that ships a Three.js runtime. "NixieFX vs three.quarks" comparisons usually stop at feature lists, so I built the same burst in both, put them in the same scene, and measured four things: whether a run repeats exactly, draw calls, CPU time per update(), and how much each adds to a production bundle.
The results go both ways, and one of them isn't flattering to NixieFX.
Note: I'm involved with the NixieFX project. The measurements were run by an AI agent in a Claude Code session, and this post was drafted with AI and reviewed by hand. Every number below comes from those runs, and the method is in the post so you can rerun it.
Setup
-
three0.185.1,nixie-fx0.1.17,three.quarks0.17.1 (pulls inquarks.core0.17.1), Vite 8.3.1 - Chromium 152 on macOS, Intel Core i9-9880H, 8 threads. One machine.
- Same
WebGLRenderer, camera, grid and 480 × 320 canvas for both libraries - Fixed timestep of 1/60 s for every simulation step
The effect: a one-shot burst of 160 additive, camera-facing particles from a 0.05-unit sphere. Lifetime 0.5–0.9 s, start speed 2.6–4.2, size 0.12, warm white fading to orange and transparent, gravity 6 downward.
I matched the parameters by hand. They aren't identical, because the two libraries don't define drag the same way. NixieFX has a drag value; for three.quarks I approximated it with a SpeedOverLife curve. The bursts look alike but don't overlap exactly.
Correction (October 5): an earlier version said both bursts shrink from 0.12 to 0.03 over their life. Only the three.quarks one does. In the NixieFX effect I left the size constant at 0.12. The measurements below are from that version and are unchanged.
How each one is built
NixieFX effects are data. I wrote the effect as JSON (in a real project you'd author it in the NixieFX editor), then validated and exported it with the CLI:
npx nixie-fx validate .
npx nixie-fx export .
Validated 1 effect: 0 errors, 0 warnings.
Exported 1 effect to public/vfx.
The export includes a support report for each backend. For this effect it said three3d: supported. The game loads the bundle and asks the loader to refuse anything Three.js can't render:
import { loadVfxExportBundle } from "nixie-fx/export";
import { ThreeVfxRenderer } from "nixie-fx/three";
const bundle = loadVfxExportBundle({ manifest, effectsByPath }, { requiredBackend: "three3d" });
const effect = bundle.effectsById.get("ember-burst");
const vfx = new ThreeVfxRenderer({ scene, camera, captureDebugTransforms: false });
vfx.createEffect(effect, { position: [0, 0.4, 0], seed: 42 });
// every frame, in seconds:
vfx.update(1 / 60);
three.quarks effects are objects you build in code (or load from JSON made in its own editor). Every system is registered with a BatchedRenderer:
import {
BatchedRenderer, ParticleSystem, SphereEmitter, IntervalValue, ConstantValue,
ConstantColor, ColorOverLife, Gradient, SizeOverLife, SpeedOverLife,
PiecewiseBezier, Bezier, ApplyForce, RenderMode, Vector3, Vector4,
} from "three.quarks";
const ps = new ParticleSystem({
duration: 1,
looping: false,
worldSpace: true,
shape: new SphereEmitter({ radius: 0.05, thickness: 1, arc: Math.PI * 2 }),
startLife: new IntervalValue(0.5, 0.9),
startSpeed: new IntervalValue(2.6, 4.2),
startSize: new ConstantValue(0.12),
startColor: new ConstantColor(new Vector4(1, 1, 1, 1)),
emissionOverTime: new ConstantValue(0),
emissionBursts: [{ time: 0, count: new ConstantValue(160), cycle: 1, interval: 0, probability: 1 }],
material: new THREE.MeshBasicMaterial({
map: dotTexture, blending: THREE.AdditiveBlending, transparent: true, depthWrite: false,
}),
renderMode: RenderMode.BillBoard,
behaviors: [
new ColorOverLife(new Gradient(
[[new Vector3(1, 0.97, 0.8), 0], [new Vector3(1, 0.5, 0.15), 1]],
[[1, 0], [0, 1]],
)),
new SizeOverLife(new PiecewiseBezier([[new Bezier(1, 0.75, 0.5, 0.25), 0]])),
new SpeedOverLife(new PiecewiseBezier([[new Bezier(1, 0.66, 0.4, 0.25), 0]])),
new ApplyForce(new Vector3(0, -1, 0), new ConstantValue(6)),
],
});
const batch = new BatchedRenderer();
scene.add(batch);
ps.emitter.position.set(0, 0.4, 0);
scene.add(ps.emitter);
batch.addSystem(ps);
// every frame, in seconds:
batch.update(1 / 60);
Both frames below were captured at t = 0.25 s (15 fixed steps).
The NixieFX burst is tighter at this moment. The likely cause is that its drag slows particles sooner than my SpeedOverLife curve does. I didn't tune the two to match exactly.
1. Does the same run come out the same twice?
Method: create the effect, step it 24 times (0.4 s), round every particle position to 5 decimals and hash them (FNV-1a). Do it twice from scratch.
| Run 1 | Run 2 | Same? | |
|---|---|---|---|
| NixieFX, seed 42 | d43231ea |
d43231ea |
yes |
| three.quarks | 5fa8d3fe |
b7aeb6e5 |
no |
three.quarks, Math.random replaced with a seeded PRNG during the run |
058191a6 |
058191a6 |
yes |
NixieFX takes a seed per instance, and the same seed gave the same positions. three.quarks 0.17.1 draws its randomness from Math.random (the quarks.core ESM build has 52 call sites), so it can't be seeded directly. Swapping Math.random for a seeded generator around the update did make it repeatable in my test. But that replaces a global function, and the result depends on nothing else calling Math.random during the frame. Fine for a test harness, fragile in a game.
This matters if you snapshot-test effects, record replays, or want two clients to show the same burst from the same event. It doesn't matter if you only need a burst that looks good.
2. Draw calls
Measured with renderer.info.render.calls, minus the grid-only baseline:
| 1 burst | 20 bursts | |
|---|---|---|
| NixieFX | 1 | 20 |
| three.quarks | 1 | 1 |
three.quarks batched all 20 systems into one draw call, because they share a material and its BatchedRenderer is built for that. NixieFX drew each instance separately in this setup. I didn't find a way to batch separate NixieFX instances, so I'm not claiming there's one. If you spawn dozens of identical bursts at once, this is the number to watch.
3. CPU time per update()
Method: N bursts, step 30 frames from t = 0 (spawn and simulation), time only the update() call with no rendering. Median of 9 runs. NixieFX ran with captureDebugTransforms: false, as its docs recommend for production.
| Bursts | Particles | NixieFX (ms/frame) | three.quarks (ms/frame) |
|---|---|---|---|
| 1 | 160 | 0.74 | 0.13 |
| 5 | 800 | 3.08 | 0.40 |
| 25 | 4,000 | 15.41 | 2.63 |
three.quarks was roughly 6 to 8 times faster at every size I tried. At 4,000 live particles, NixieFX used nearly all of a 16.7 ms frame on simulation alone on this machine. three.quarks stayed under 3 ms.
The NixieFX debug default makes it worse. captureDebugTransforms is true unless you turn it off. In an earlier, longer run at 4,000 particles, the steady-state cost was about 18.6 ms with it on and 15.0 ms with it off. Turn it off in production builds.
Caveats: this is one machine. The page ran in a browser tab that wasn't on screen, so some of the earlier runs were noisy (I report the clean scaling run above). I measured CPU only, not GPU time.
4. Bundle size
Three separate Vite production builds of a tiny page: Three.js plus the scene only, then the same page plus each library. Gzip level 9.
| Build | Minified | Gzipped | Added over Three.js alone |
|---|---|---|---|
| Three.js + scene | 503.5 KB | 123.9 KB | (baseline) |
+ NixieFX (nixie-fx/three + nixie-fx/export) |
692.4 KB | 179.0 KB | 55.1 KB |
| + three.quarks | 655.6 KB | 158.1 KB | 34.2 KB |
NixieFX's number includes its bundle loader and validator. That's the code that rejected nothing here, but would refuse a three3d: blocked effect before rendering.
What I'd take from this
three.quarks was faster to simulate, batched identical systems into one draw call, and added less to the bundle. For a game that fires lots of bursts and doesn't care whether two runs match, those are the numbers that matter. (Small thing: it also logs a "powered by three.quarks" banner to the console on load.)
NixieFX gave repeatable runs from a per-instance seed with no global patching, and treated the effect as an exported, validated file with a per-backend support report. The same export also reported pixi2d: supported for this effect, so the file isn't tied to Three.js. Its CPU cost per particle was clearly higher in this test. Whether that's acceptable depends on how many particles you keep alive at once.
Not everything is supported on the Three.js side either. In nixie-fx 0.1.17, the lights module exports as blocked for three3d, and mesh-mode particles render as camera-facing quads unless you supply a mesh asset. The NixieFX Three.js runtime guide lists the supported modules and the support.backends.three3d report. Check your effect's report before you commit to it.
Limits of this comparison
- One effect, one machine, one browser. Different effects (trails, textures, sub-emitters) could shift every number here.
- I built the three.quarks system by hand. Someone who knows the library well might configure it more efficiently. The NixieFX effect was written by hand too, not tuned in its editor.
- The two bursts are close but not identical, so neither the frames nor the timings are a pixel-for-pixel match.
- No GPU timings and no mobile devices.
Rerun it
npm install three@0.185.1 nixie-fx@0.1.17 three.quarks@0.17.1 vite@8.3.1- Put the effect JSON in
effects/and avfx-editor.prjwithoutputPath: "public/vfx"next to it. Runnpx nixie-fx validate .andnpx nixie-fx export .. - Build both systems as above in one module. Step each with
update(1 / 60)in a plain loop, notrequestAnimationFrame, so frame timing can't change the result. - For the repeatability check, read positions from
instance.getParticleDebugTransforms([])for NixieFX (this needscaptureDebugTransforms: true) and fromps.particles.slice(0, ps.particleNum)for three.quarks. Hash them. - For timing, wrap only the update loop in
performance.now(). Take the median of several fresh runs. - For size, build three single-entry pages separately so Three.js isn't shared between them, then gzip the output.
If you get different numbers on your hardware, I'd like to see them in the comments.


Top comments (0)