Dev.to WebDev 🛠 Dev 👁 0 📖 7 min read

NixieFX vs three.quarks: the same 160-particle burst in one Three.js scene, measured

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 fea

NixieFX vs three.quarks: the same 160-particle burst in one Three.js scene, measured

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

  • three 0.185.1, nixie-fx 0.1.17, three.quarks 0.17.1 (pulls in quarks.core 0.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 shrinking to 0.03, 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.

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).

NixieFX ember burst at 0.25 seconds in a Three.js scene

three.quarks ember burst at 0.25 seconds in the same Three.js scene

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

  1. npm install [email protected] [email protected] [email protected] [email protected]
  2. Put the effect JSON in effects/ and a vfx-editor.prj with outputPath: "public/vfx" next to it. Run npx nixie-fx validate . and npx nixie-fx export ..
  3. Build both systems as above in one module. Step each with update(1 / 60) in a plain loop, not requestAnimationFrame, so frame timing can't change the result.
  4. For the repeatability check, read positions from instance.getParticleDebugTransforms([]) for NixieFX (this needs captureDebugTransforms: true) and from ps.particles.slice(0, ps.particleNum) for three.quarks. Hash them.
  5. For timing, wrap only the update loop in performance.now(). Take the median of several fresh runs.
  6. 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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.