DEV Community

Sam Novak
Sam Novak

Posted on Fully Autonomous

I switched on 18 particle features one at a time to see which ones survive both PixiJS and Three.js

If you want one set of particle effects for a PixiJS UI layer and a Three.js world, the question that matters is not "does the library support both renderers?" It is "which of my effect settings mean the same thing in both?"

NixieFX is a particle editor for PixiJS and Three.js: you author an effect once, export JSON, and play it through either renderer adapter. Its exporter writes a per-backend support report into the bundle manifest. I wanted to know how much that report is worth, so I took one generated effect, switched on one feature at a time, exported each variant, and recorded what the report said. Then I rendered two of the effects in both renderers and compared the pixels.

Note: I'm involved with the NixieFX project. The runs were done in a Claude Code session, and this post was drafted with AI and reviewed by hand. Every number and image below comes from those runs.

Setup

  • nixie-fx 0.1.17 (CLI + runtime), pixi.js 8.22.0, three 0.185.0 (r185), Node 24.3.0
  • Base effect: npx nixie-fx effect create --profile portable, untouched except for the one feature under test
  • Each variant lives in its own project folder, so one feature's diagnostics can't leak into another's
  • For each variant: npx nixie-fx validate, then npx nixie-fx export, then read effects[0].support.backends from out/vfx/manifest.json

The loop, trimmed:

for name, enable in VARIANTS.items():
    effect = copy.deepcopy(BASE)
    enable(effect)                       # e.g. emitter["modules"]["trails"] = True
    write_project(work_dir, effect)
    subprocess.run(["npx", "nixie-fx", "validate", work_dir])
    subprocess.run(["npx", "nixie-fx", "export", work_dir])
    support = json.load(open(f"{work_dir}/out/vfx/manifest.json"))["effects"][0]["support"]
    print(name, {b: s["status"] for b, s in support["backends"].items()})
Enter fullscreen mode Exit fullscreen mode

"Enabled" means the module flag turned on with the editor's generated default settings. Lights also got a non-zero intensity, and collision was set to plane mode, because their defaults do nothing.

What the report said

Supported on both PixiJS and Three.js (0 warnings):

  • the generated baseline
  • additive blending
  • color over lifetime
  • size over lifetime
  • rotation over lifetime
  • velocity over lifetime
  • sphere spawn shape

Partial on both, with the same generic warning:

  • limit velocity over lifetime
  • force over lifetime
  • noise
  • external forces
  • color by speed
  • size by speed
  • texture sheet animation
  • collision (plane)
  • trails

Each of these produced one warning, identical for both backends, of the form:

<module> has partial runtime export support; validate authored subfields before shipping.
Enter fullscreen mode Exit fullscreen mode

Where the two renderers actually differ:

  • Lit shading: Three.js supported, PixiJS partial. The warning: "Lit particle shading requires the Three 3D/world backend; Pixi keeps unlit particle compositing."
  • Mesh particles: partial on both, but for different reasons. PixiJS: "Mesh mode renders as Pixi 2D shard geometry, not true 3D mesh rendering." Three.js: "Old mesh mode is Pixi shard geometry; Three renders true mesh particles only when mesh.renderMode is meshAsset."
  • Lights: PixiJS partial, Three.js blocked. The blocker: "lights is not implemented in the Three renderer MVP yet; keep it backend-gated until the Three draw path owns it."

That last one surprised me. I'd have guessed lights would be the 3D renderer's strength. On 0.1.17 it's the one feature that blocks a Three.js export outright.

Reading the report honestly

Two things to keep in mind before treating that list as a compatibility table.

First, the nine "partial on both" results are one blanket warning, not nine findings. The exporter flags those modules as partial whatever their settings are, and asks you to check the subfields yourself. It tells you where to look. It doesn't tell you that anything is broken.

Second, the warnings that name a renderer are the useful ones. They say what will actually differ: lighting falls back to unlit on Pixi, mesh particles become 2D shards on Pixi, and mesh particles on Three only become real meshes with a mesh asset.

So I checked whether "supported" and "partial" match what you see on screen.

Same frame, both renderers

I authored two one-shot bursts with the CLI (portable profile) and played both through each renderer with the same seeds:

  • spark burst: soft billboards, additive blend, color over lifetime, size over lifetime. Report: supported on both.
  • shard burst: mesh mode with the default triangle-shard template, rotation over lifetime. Report: partial on both.

The page loads one exported bundle twice, once per backend, steps both simulations in 1/60 s increments to the same time, and renders a single frame:

const load = (backend) =>
  loadVfxExportBundle({ manifest, effectsByPath }, { requiredBackend: backend });

// PixiJS: centre origin, y up, 100 px per effect unit
const pvfx = new PixiVfxRenderer({
  parent: app.stage,
  projection: createPixiVfx2dProjection({ originX: W / 2, originY: H / 2, pixelsPerUnit: 100, yAxis: "up" }),
});

// Three.js: orthographic camera framing the same 4.8 x 2.8 units
const camera = new THREE.OrthographicCamera(-2.4, 2.4, 1.4, -1.4, 0.1, 100);
camera.position.set(0, 0, 10);
const tvfx = new ThreeVfxRenderer({ scene, camera });

for (const s of shots) {
  pvfx.createEffect(load("pixi2d").effectsById.get(s.id), { position: s.pos, seed: s.seed });
  tvfx.createEffect(load("three3d").effectsById.get(s.id), { position: s.pos, seed: s.seed });
}
const step = (vfx) => { for (let s = 0; s < t; s += 1 / 60) vfx.update(Math.min(1 / 60, t - s)); };
step(pvfx); step(tvfx);
Enter fullscreen mode Exit fullscreen mode

At t = 0.15 s, both renderers report 42 live particles (24 sparks + 18 shards).

PixiJS 8 render at 0.15 s: sparks on the left, small triangle shards on the right
PixiJS 8, t = 0.15 s

Three.js r185 render at 0.15 s: the same sparks on the left, large square quads on the right
Three.js r185, t = 0.15 s

The sparks line up. The shards don't: PixiJS draws small triangles, and Three.js draws much larger square quads from the same JSON.

I compared the two 480×280 frames pixel by pixel, taking the mean absolute RGB difference per pixel, and split them into the spark half and the shard half:

  • Spark half (supported): mean difference 0.94 out of 255. 1.3% of pixels differ by more than 32.
  • Shard half (partial): mean difference 47 out of 255. 24.9% of pixels differ by more than 32.

At t = 0.35 s the gap is wider: 6.3 vs 87.5. The spark-half number also rises because Three.js's larger quads spread into the left half of the frame.

For these two effects, the report matched the screen. "Supported" rendered nearly the same. "Partial" with a mesh warning looked visibly different.

What I'd do with this

  • For effects that must look the same in UI and in the world, stay inside the supported set: billboards, blend modes, and the over-lifetime modules (color, size, rotation, velocity). In this run, those exported with zero warnings on both renderers.
  • Treat a renderer-specific warning as a design decision, not noise. Mesh particles and lit shading are effectively different effects on PixiJS and Three.js.
  • Gate lights to Pixi (or leave them off) if the effect has to export for Three.js on 0.1.17.
  • Don't read the blanket "partial" list as broken. It means "check this yourself", so render a frame in each target the way I did above.

Limits of this test: one base effect, default settings for every module, one version (0.1.17), and two hand-picked effects for the pixel comparison. A non-default trail or noise setup could behave differently. The report and a rendered frame are the checks, not this list.

The full PixiJS integration path (loading the bundle, texture providers, cleanup) is in the NixieFX guide to PixiJS particle effects.

Top comments (0)