npm install nixie-fx three, fetch your exported
out/vfx bundle and verify it with
loadVfxExportBundle, construct a
ThreeVfxRenderer with your scene and
camera (texture/mesh/material providers are
all optional — effects without file assets need
none), call createEffect(effect, { seed }), and
advance vfx.update(deltaSeconds) exactly once per
frame in seconds. The simulation is
deterministic: same effect, same seed, same motion, on every
machine and every backend.
Quickstart
Install nixie-fx together with Three.js.
Three.js is an optional peer dependency of the package — importing
nixie-fx/three is what actually requires it.
npm install nixie-fx three
The runtime consumes an exported out/vfx bundle — a
directory containing manifest.json, compiled effect
JSON, and byte-copies of every referenced asset. You produce that
bundle either from the
NixieFX editor (Export) or headlessly
with npx nixie-fx export. Deploy the bundle with your game's static files (below it is
served at /vfx).
import * as THREE from "three";
import { loadVfxExportBundle } from "nixie-fx/export";
import { ThreeVfxRenderer, ThreeVfxTextureStore } from "nixie-fx/three";
const BUNDLE_URL = "/vfx"; // your deployed out/vfx directory
async function json(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to load ${url}`);
return response.json();
}
// 1. Fetch the manifest and every compiled effect, then let the official
// loader verify hashes, validation status, and backend support.
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" },
);
// 2. Provide textures. Skip this entire step for effects that declare no
// file assets — procedural billboards render with no providers at all.
const textures = new ThreeVfxTextureStore({
resolveUrl: (path) => `${BUNDLE_URL}/${path}`,
});
const effect = bundle.effectsById.get("impact-burst");
if (!effect) throw new Error("Bundle is missing impact-burst");
await textures.preload(effect.assets.filter((a) => a.type === "texture"));
// 3. Mount the renderer and spawn an effect instance.
const vfx = new ThreeVfxRenderer({ scene, camera, textureProvider: textures });
const burst = vfx.createEffect(effect, { position: [0, 1, 0], seed: 42 });
// 4. Advance exactly once per host frame, in SECONDS.
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
vfx.update(clock.getDelta());
renderer.render(scene, camera);
});
// Replay the one-shot wherever you need it:
// burst.setTransform({ position: [x, y, z] });
// burst.restart();
// Teardown when the owning scene is released:
// vfx.destroy();
// textures.destroy();
update() takes seconds, not
milliseconds. If your loop hands you deltaMS, pass
deltaMS / 1000. Passing milliseconds fast-forwards
the whole effect in one frame — the most common integration bug.
Concepts
Export bundle anatomy
out/vfx/
manifest.json # effect + asset index, validation status, source hashes
effects/*.json # compiled effects: emitters, timeline, assets, support
<asset paths> # byte-copies of referenced files, preserving their
# asset-root-relative paths (spark.png,
# particles/spark.png, materials/fire.material, ...)
The manifest indexes every compiled effect (id, name, path, source
hash, support report) and every deduplicated asset reference
(texture, material, or
mesh). Asset folders are not fixed — resolve
manifest.assets[*].path and
effect.assets[*].path relative to the bundle root and
nothing else. Games load only this bundle; the authoring project
(vfx-editor.prj and its effect JSON) is never a
runtime input.
Target profiles
Every effect declares a targetProfile:
pixi-ui-2d, three-world-3d, or
portable (must satisfy both backends). The profile
drives validation and the per-backend support reports; for a
Three.js game you will normally author against
three-world-3d or portable.
Deterministic simulation & seeds
The simulation core is shared by every backend and fully
deterministic: an effect created with the same
seed produces identical motion on every run, every
machine, and both renderers. createEffect defaults the
seed to a fixed constant, so even "unseeded" instances replay
identically — pass a different seed per spawn if you
want visual variety between instances.
seek(timeSeconds) exploits the same property: it
deterministically replays the simulation to any point in time.
Support reports
Every exported effect carries a support object: an
overall status
(supported / partial /
blocked), the target profile, and per-backend reports
under support.backends.pixi2d and
support.backends.three3d, each with
warnings and blockers (diagnostic
objects with code, path, and
message), plus informational notes when a
backend renders an authored setting with different semantics.
Check the three3d report before shipping: a
blocked effect must not be treated as supported, and a
partial effect contains deliberate approximations that
the warnings enumerate. Passing
requiredBackend: "three3d" to the loader enforces the
blocked case for you.
API reference
Everything below is imported from nixie-fx/three
unless stated otherwise. The bundle loader lives in
nixie-fx/export (browser-safe; Node filesystem
helpers are isolated at nixie-fx/export/node), and
engine-level types re-export from the package root
nixie-fx.
ThreeVfxRenderer
new ThreeVfxRenderer(options: ThreeVfxRendererOptions)
The Three.js backend adapter. It owns a single
THREE.Group (renderer.root) that holds
every effect instance, and implements the shared
VfxRendererBackend contract
(backendId: "three3d"). If parent or
scene is passed, the root is mounted in the
constructor; otherwise call mount(container) yourself.
| Constructor option | Type | Default | Description |
|---|---|---|---|
camera |
THREE.Camera |
required |
Used for billboard orientation, distance sorting, and
camera-facing modes. Swap later with
setCamera().
|
scene |
THREE.Scene |
— | Container the root group is added to on construction. |
parent |
THREE.Object3D |
— |
Alternative mount container; takes precedence over
scene when both are given.
|
textureProvider |
ThreeVfxTextureProvider |
— |
Resolves texture asset refs to THREE.Texture.
Optional — see Providers.
|
meshProvider |
ThreeVfxMeshProvider |
— |
Resolves prepared mesh asset refs to
THREE.BufferGeometry for mesh particles and
mesh-surface emission. Optional.
|
materialProvider |
ThreeVfxMaterialProvider |
— | Lets the host replace the entire built-in particle material for chosen emitters. Optional. |
materialGraphProvider |
(shaderId: string) => ShaderGraph | undefined |
— |
Resolves material-instance shader IDs to parsed
.material graphs. Optional; needed only when
emitters use authored materials.
|
clockSpace |
"world" | "local" |
— | Declared in the options type but currently unused by the runtime (reserved). |
previewBloomEnabled |
boolean |
false |
Encodes HDR particle color for a preview-grade bloom pass. Leave off when your game runs its own HDR/bloom pipeline. |
previewBloomThreshold |
number |
1 |
Emissive strength above which a particle counts as a bloom
source (clamped ≥ 0). Also feeds
stats.bloomSourceParticles.
|
previewExposureStops |
number |
0 |
Preview exposure in stops, clamped to −2..2. |
captureDebugTransforms |
boolean |
true |
Records a per-particle debug transform every frame for
getParticleDebugTransforms(). Set
false in production builds.
|
| Member | Description |
|---|---|
backendId / capabilities |
"three3d" and the static capability record
(true 3D quads, mesh particles, GPU depth, instanced
material streams, runtime sampler bindings).
|
root |
THREE.Group containing every instance. |
stats |
Aggregated ThreeVfxRendererStats across all
instances, plus effectCount. See
Stats.
|
mount(container) / unmount() |
Attach or detach the root group. |
createEffect(effect, options?) |
Creates, mounts, and (by default) starts a
ThreeVfxEffectInstance. Accepts an exported
effect object (from bundle.effectsById) or an
authoring-format definition; both are normalized. Throws if
the renderer was destroyed.
|
removeEffect(instance, destroy = true) |
Detaches an instance from the renderer;
destroys it unless destroy is
false.
|
update(deltaSeconds) |
Advances every instance and refreshes stats. Call exactly
once per host frame — do not also call
instance.update() for instances owned by the
renderer.
|
getSupport(effect) |
Computes the three3d
VfxBackendSupportReport for a normalized
effect definition at runtime.
|
setCamera(camera) |
Rebinds the camera on the renderer and all instances. |
setPreviewBloomOptions({ enabled?, threshold?, exposureStops? }) |
Updates preview bloom settings on all instances. |
destroy() |
Destroys every instance, unmounts the root, and makes
further createEffect calls throw.
|
createEffect options (VfxEffectOptions)
| Option | Type | Default | Description |
|---|---|---|---|
position |
[x, y, z] |
[0, 0, 0] |
World-space effect origin. |
rotation |
[x, y, z] |
— | Euler rotation of the instance root, in radians. |
scale |
[x, y, z] |
— | Scale of the instance root. |
seed |
number |
0x7F4A7C15 |
Deterministic simulation seed. Identical seeds replay identical motion. |
timeSeconds |
number |
0 |
Initial simulation clock value (clamped ≥ 0). |
autoStart |
boolean |
true |
When false, the instance is created idle;
call play(), restart(), or
emitBurst() to run it.
|
runtimeParameters |
{ emissionRateMultiplier?, initialVelocityMultiplier? } |
identity | Effect-level multipliers applied inside the deterministic engine runner. |
ThreeVfxEffectInstance
The handle returned by createEffect. It owns a
root group (one per instance, inside the renderer's
root), a per-instance stats object, and the full
lifecycle API:
| Member | Description |
|---|---|
isActive |
true while the underlying simulation is
running (looping, or a one-shot that has not completed).
|
play() |
Starts a fresh run if the effect completed, otherwise resumes emission; also unpauses. |
pause() |
Freezes the instance; update() becomes a no-op
until play().
|
stop() |
Halts the simulation and immediately clears all rendered particles. |
allowCompletion() |
Stops further emission but lets live particles finish their lifetimes — the graceful way to end a looping effect. |
update(deltaSeconds) |
Advances this instance only. Use for standalone instances;
renderer-owned instances are advanced by
renderer.update().
|
seek(timeSeconds) |
Deterministically replays the simulation from zero to the target time in fixed 1/60 s steps. Cost is proportional to the target time — fine for scrubbing and replays, not for per-frame use. |
restart() |
seek(0) + play — one call to run a one-shot
again.
|
setTransform({ position?, rotation?, scale? }) |
Moves the effect. position also feeds the
simulation (emission follows the new origin;
world-space particles already emitted stay put).
|
setVisible(visible) |
Toggles rendering; the simulation keeps running while hidden. |
setRenderOrder(n) |
Base Three.js render order for the instance; emitter layer ranks offset from it. |
setCamera(camera) |
Rebinds the camera for this instance and redraws. |
setRuntimeParameters(patch) |
Effect-level emissionRateMultiplier /
initialVelocityMultiplier.
|
setPreviewBloomOptions({ enabled?, threshold?, exposureStops? }) |
Per-instance preview bloom settings. |
updateDefinition(effect, { preserveViews? }) |
Hot-swaps the effect definition on a live instance
(editor-style live editing). Returns the normalized
definition; rebuilds emitter views unless
preserveViews is true.
|
getParticleDebugTransforms(out?) |
Appends the last frame's per-particle debug transforms
(mode, position, normal, size, matrix, bounds) — the data
behind the editor's wireframe overlay. Empty when
captureDebugTransforms is false.
|
stats / root |
Per-instance ThreeVfxEffectStats and the
instance's THREE.Group.
|
destroy() |
Stops the effect, disposes owned GPU resources, and removes the root from its parent. Host-provided materials and provider-owned textures are never disposed. |
Runtime controls & host injection
Beyond the shared lifecycle, the Three instance exposes
host-injection APIs that drive an authored effect from game state
without touching the effect definition. Identity values
(1, [1,1,1,1], null) always
restore authored behavior.
instance.setEmitterRuntimeParameters(emitterId, patch)
Merges an emitter-scoped parameter patch. The first two fields run inside the shared deterministic engine; the rest apply at draw time on the Three backend only:
| Patch field | Type | Identity | Description |
|---|---|---|---|
emissionRateMultiplier |
number |
1 |
Scales continuous emission for this emitter (engine-side, deterministic, composes with the effect-level multiplier). |
initialVelocityMultiplier |
number |
1 |
Scales spawn velocity (engine-side). |
sizeMultiplier |
number |
1 |
Uniform draw-time size scale (clamped ≥ 0). |
sizeMultiplierValue |
ParticleScalarValue | null |
null |
Scalar-or-curve size multiplier sampled per particle at its
normalized age; compiled to a lookup table on set, so
per-frame sampling never allocates. Partial values are
accepted (e.g. { mode: "curve", curve: [...] }).
|
colorTint |
[r, g, b, a] |
[1, 1, 1, 1] |
RGBA multiplier over the sampled particle color; alpha scales opacity. |
colorOverLifetimeGradient |
ParticleColorGradientSettings | null |
null |
Replaces the authored color-over-lifetime gradient at draw time — even when the authored color module is off. Normalized on set; partial values accepted. |
instance.emitBurst(emitterId, { count?, position? }) → number
Emits an immediate burst from one emitter and returns the emitted
count (capacity-clamped; count defaults to 1,
position temporarily overrides the emitter origin for
just this burst). If the effect already completed, it restarts
first so the burst always lands — the way hosts trigger moments
like "the score changed" without authoring burst schedules around
wall-clock time.
instance.setEmissionGeometry(emitterId, geometry | null)
Injects a live THREE.BufferGeometry as the emission
source for a mesh-shaped emitter, overriding the authored
spawn.meshAsset (or supplying one where none is
authored). The geometry is copied on bind; to pick up later
mutations, call it again. null returns the emitter to
provider/asset-driven emission.
instance.setRenderGeometry(emitterId, geometry | null)
Injects live geometry for a mesh-rendered
(meshAsset) emitter — each particle renders as a copy
of it — overriding the authored mesh asset, or supplying one when
no asset is authored. Rebuilds that emitter's view;
null restores the provider/asset.
// Drive an authored ember effect from live game state
// (this exact pattern renders the nixiefx.com landing digits):
embers.setEmissionGeometry("embers", digitMesh.geometry); // spawn on the digit
embers.setTransform({ position: [0, 0, digitZ] });
embers.emitBurst("embers", { count: 24 }); // the digit changed
embers.setEmitterRuntimeParameters("embers", {
emissionRateMultiplier: 2.5,
colorOverLifetimeGradient: hotGradient, // null restores the authored one
sizeMultiplierValue: { mode: "curve", curve: sparkSizeCurve },
});
loadVfxExportBundle
loadVfxExportBundle(input, options?) → VfxExportBundle
— from nixie-fx/export
Verifies and indexes an exported bundle without doing any I/O — you fetch the JSON however your platform does (HTTP, filesystem, bundler import) and hand over the parsed values. It checks envelope kinds and format version, effect/asset uniqueness, effect↔manifest identity (id, source ids, hashes, timestamps), that every effect-referenced asset appears in the manifest, recomputes the manifest source hash, and rejects bundles whose validation was blocked.
| Input / option | Type | Default | Description |
|---|---|---|---|
input.manifest |
unknown |
required | Parsed manifest.json value. |
input.effectsByPath |
Record<string, unknown> |
required |
Parsed effect JSON keyed by each manifest entry's
path (e.g.
"effects/impact-burst.json").
|
input.assetPaths |
Iterable<string> |
— | Bundle-relative paths of deployed asset files, when the host can enumerate them. |
options.requiredBackend |
"pixi2d" | "three3d" |
— |
Throws if any effect is blocked on this
backend. Pass "three3d" for a Three.js game.
|
options.requiredEffectIds |
string[] |
— | Throws unless every named effect id is present. |
options.requireEveryAsset |
boolean |
false |
With assetPaths, throws if any manifest asset
file is missing from the deployment.
|
The returned VfxExportBundle has three fields:
manifest (the typed manifest),
effectsById, and effectsByPath — both
ReadonlyMaps of verified
VfxExportedEffect objects, which is exactly what
createEffect consumes. The lower-level validators
parseVfxExportManifest(value) and
parseVfxExportedEffect(value) are exported too.
Providers — all optional
Every provider is optional. An effect that
references no file assets — for example the CLI's default effect,
whose billboards use the built-in procedural shape — renders
with new ThreeVfxRenderer({ scene, camera }) and
nothing else. Add a provider only when the effect's
assets list needs it.
ThreeVfxTextureProvider
Resolves texture references ({ type: "texture", id, path })
to THREE.Texture. path is the raw
authored asset-root-relative path — treat it as a lookup key; hosts
that pack atlases map it to their own frames inside the provider.
getTexture must be synchronous, so preload before
spawning.
| Method | Required | Description |
|---|---|---|
getTexture(ref) |
yes |
Returns the loaded THREE.Texture for a ref, or
undefined if not (yet) available.
|
preload(refs) |
no | Loads a batch of refs ahead of first use. |
resolveUrl(ref) |
no | Maps a ref to a fetchable URL. |
release(scope?) |
no | Frees provider-owned textures on scene teardown. |
new ThreeVfxTextureStore({ resolveUrl, loadingManager?, configureTexture? })
A ready-made provider that owns loading, configuration, and
cleanup. You supply resolveUrl(path) (map the
bundle-relative path to a URL); it loads through
THREE.TextureLoader, sets
SRGBColorSpace, deduplicates in-flight requests, and
lets you tweak each texture via
configureTexture(texture, ref). Methods:
preload(refs),
preloadEffect(effect), getTexture(ref),
resolveUrl(ref), release() (dispose all,
keep usable), destroy().
ThreeVfxMeshProvider
One synchronous method:
getMeshGeometry(ref) → BufferGeometry | null. Used for
both mesh-rendered particles (mesh.asset) and
mesh-surface emission (spawn.meshAsset). Return
prepared geometry — raw FBX or other authoring formats are not
runtime inputs. The renderer re-queries every frame with cheap
identity checks, so returning null now and the loaded
geometry later works as an async load trigger; unresolved
mesh-rendered emitters simply stay hidden and are reported in
stats.missingMeshRefs.
ThreeVfxMaterialProvider
Optional escape hatch:
getParticleMaterial?(effect, emitterId) → Material | null.
Returning a material hands that emitter's entire surface pipeline
to the host — no texture frames, no material graph, no instanced
fast path; the runtime only drives transforms and disposal stays
with the host.
ThreeVfxMaterialGraphProvider
A function, (shaderId) => ShaderGraph | undefined.
When emitters use authored node materials, parse each exported
.material JSON (a graph, from the bundle's
material assets) with
normalizeShaderGraph from
nixie-fx/materials and return graphs by their id.
Unresolvable references are reported in
stats.missingMaterialRefs and the emitter renders
with a fallback texture.
Stats
instance.stats is a ThreeVfxEffectStats;
renderer.stats aggregates all instances and adds
effectCount. Values refresh on every
update().
| Field | Meaning |
|---|---|
activeParticles |
Live particles in the simulation. |
visibleParticles |
Particles actually drawn this frame. |
capacity |
Summed maxParticles across emitters. |
emittedLastFrame |
Particles spawned during the last update. |
bloomSourceParticles |
Particles whose emissive strength exceeded
previewBloomThreshold — a cheap proxy for
"how much will bloom light up".
|
drawCalls |
Estimated draw calls this frame: 1 per instanced emitter, 1 per visible particle on the legacy path, plus 1 per visible trail mesh. |
instancedDrawCalls |
Emitters rendered through the instanced fast path. |
legacyParticleDrawCalls |
Per-particle mesh draws — the number to watch when budgeting. See the performance guide. |
missingMeshRefs |
Mesh asset refs the provider could not resolve (render or emission). |
missingMaterialRefs |
Material shader ids with no graph resolved. |
unsupportedFeatures |
Human-readable strings — backend support blockers
(path: message) plus material features the
Three compiler could not honor. Non-empty means something
authored is not rendering as designed.
|
effectCount (renderer only) |
Number of live effect instances. |
Texture frames, flipbooks & HDR color
Flipbooks. Emitters with the texture-sheet animation module slice the authored texture into a uniform grid at view build time and select each particle's frame deterministically from its age, seed, and the authored frame curve. Flipbook emitters render on the per-particle path (they are excluded from the instanced fast path).
Procedural billboards. Billboard emitters with no
texture and no material render the built-in procedural shape —
circle or square with an alpha falloff controlled by
billboard.softness — generated in pure math (no
canvas), cached per shape + softness, and shared across views.
This is why assetless effects need no texture provider.
HDR color. Authored emissive intensity multiplies
particle color linearly, so colors can exceed 1.0. By default the
runtime tone-maps over-range colors (ACES-style) so they stay
plausible on an LDR canvas while sub-1.0 colors pass through
untouched. With previewBloomEnabled, over-threshold
energy is instead encoded into the output color to feed a
threshold-driven bloom pass — a preview-grade approximation.
Games with a real HDR pipeline should keep it disabled and drive
their own bloom; stats.bloomSourceParticles and the
authored intensities survive export precisely for that.
Utility functions
| Export | Description |
|---|---|
normalizeThreeVfxEffect(value) |
Normalizes an exported effect or authoring JSON into a
ParticleEffectDefinition — what
createEffect uses internally.
|
threeGeometryToEmissionInput(geometry) |
Adapts a BufferGeometry into the engine's
plain-array emission input (positions, indices, normals);
returns null without usable position data.
Reads through attribute accessors, so interleaved and
normalized attributes work.
|
reverseGeometryWinding(geometry) |
Flips triangle winding in place — the runtime uses it for
the flipWinding mesh option.
|
repairMirroredGeometryWinding(geometry) |
Reverses winding only when the geometry's transform mirrors it (negative determinant). |
updateInstancedParticleOrder(state, sortMode, out) |
Low-level helper computing the instance write order for a sort mode. Exposed for tests and custom tooling; games normally never call it. |
Performance guide
The instanced fast path
The single biggest lever. Eligible emitters render as
one InstancedMesh draw call
regardless of particle count; everything else falls back to the
legacy path of one THREE.Mesh (and one draw call)
per visible particle. An emitter is instanced when
all of these hold:
| Condition | Falls back to per-particle meshes when… |
|---|---|
| Billboard mode | The emitter renders mesh particles. |
| Unlit shading | shading: "lit" (needs real lighting materials). |
| A texture — authored or the built-in procedural shape | The texture provider cannot resolve the authored path (fix the provider key). |
| No node material assigned | The emitter uses a material graph (compiles to a bespoke shader per particle). |
| No trails, no flipbook | Trails or texture-sheet animation are enabled on the emitter. |
| Opacity from texture alpha, not inverted |
opacitySource is a channel/luminance/constant
variant, or opacityInvert is on.
|
| Alpha or additive blend |
premultiplied blend — deliberately excluded,
because the instanced shader discards near-zero-alpha
texels, which would kill exactly the low-alpha glow texels
premultiplied blending exists for.
|
| Any sort mode | Never — age sorts pre-order the instance write order, and distance sorts re-sort written instances against the camera each frame, so sorting no longer costs you the fast path. |
| No host material override |
A materialProvider supplied a custom material
for this emitter.
|
Version note: in the published nixie-fx 0.1.1, the
fast path additionally required an authored texture file (the
procedural shape did not qualify) and a sort mode of
none or oldestFirst. Current builds
lift both restrictions; with 0.1.1, keep hot billboard emitters
textured and on those two sort modes to stay instanced.
Draw-call budgeting
Watch stats.instancedDrawCalls versus
stats.legacyParticleDrawCalls. A hundred instanced
emitters are cheap; a hundred legacy particles are a
hundred draw calls. Practical rules:
- Keep high-count emitters (sparks, embers, rain) inside the fast path conditions above. Reserve trails, flipbooks, lit shading, and node materials for low-count hero emitters.
-
Per-emitter
maxParticlesis the hard cap (clamped to 1..4096 at validation); the runtime pre-allocates instanced buffers at that capacity, so set it near real peak usage. -
Set
captureDebugTransforms: falsein production — it records a per-particle transform object every frame, which is allocation pressure you only want in tooling. -
seek()replays the simulation from zero at 1/60 s steps — never call it per frame; useupdate(). - One renderer per scene is the intended shape; effect instances are cheap, renderers are not free.
Bloom and HDR
Preview bloom (previewBloomEnabled) encodes bloom
energy into vertex colors — good for editor-like previews, wrong
for a game that already runs
UnrealBloomPass/postprocessing HDR. In a real
pipeline, leave it off: authored HDR intensities arrive linearly
in particle color, over-range values tone-map gracefully, and your
own bloom threshold does the rest.
Textures
The instanced shader samples the emitter texture across the full
0..1 UV range, so one texture per emitter is the
contract — atlas sub-frames via texture.offset /
repeat are not applied on the fast path. Since every
instanced emitter is a single draw call anyway, atlasing buys you
nothing on Three (it matters on the PixiJS backend, where batching
is texture-driven). Prefer small power-of-two textures with
meaningful alpha; flipbook sheets should be uniform grids exactly
as authored.
The PixiJS runtime
The same exported bundle also runs on PixiJS —
PixiVfxRenderer from nixie-fx/pixi
follows the same lifecycle (construct with a
parent container and optional
textureProvider, createEffect,
update(seconds) once per frame,
destroy()), with 2D screen-space semantics and
batched particle containers instead of world quads. Load the
bundle with requiredBackend: "pixi2d" and read the
support.backends.pixi2d report — some features
(prepared 3D mesh particles, mesh-surface emission, lit shading,
GPU depth) are Three.js-only. The full capability matrix lives in
the feature reference.
Beyond Pixi and Three — porting
The export format is engine-neutral JSON: emitters, curves, gradients, and material graphs carry no PixiJS or Three.js types anywhere. The two shipped adapters are thin renderers over the same deterministic simulation core, and the whole runtime is MIT licensed with compact, heavily commented source — the Three adapter is a workable reference implementation for porting to another engine (Godot, Unity, bevy, a custom renderer), whether the port is written by a person or by an AI assistant reading the source. If you build one, the support-report machinery is designed to be extended with new backend ids.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| Effect plays but every particle is a flat white square |
The effect references a texture the provider did not
resolve — no textureProvider was passed, the
texture was not preloaded before spawning
(getTexture is synchronous), or the provider
keys by a different path than the authored
ref.path. Assetless procedural billboards are
not this symptom — they render their circle or
square shape with no provider.
|
| Nothing renders at all |
Check in order: is update() being called with
seconds; is the renderer mounted (pass
scene/parent or call
mount()); was the instance created with
autoStart: false and never played; is a
mesh-rendered emitter waiting on a
meshProvider (see
stats.missingMeshRefs — unresolved mesh
emitters hide instead of falling back).
|
| Particles vanish behind opaque scenery, or punch holes in it |
Depth flags. depthTest is authored per
emitter; depth writes are automatically disabled
for additive/premultiplied blends and for translucent
particles (alpha < 1), and an instanced batch disables
them when any instance is translucent. Opaque/masked
material blends force depth writes on. If particles
z-fight your geometry, author the intended flags rather
than fighting the materials at runtime.
|
| Additive glow looks dim or clipped | Over-range HDR color is tone-mapped by default. If you run your own bloom, raise authored intensity and let your pipeline pick it up; do not enable preview bloom on top of a real bloom pass. |
loadVfxExportBundle throws |
The message names the check that failed: blocked validation ("blocked by validation errors" — re-export after fixing the project), a missing effect file ("missing effects/…" — deploy the whole bundle, then re-fetch), a stale manifest hash (mixed files from two exports — redeploy atomically), or an effect "blocked on required backend three3d" (author against a Three-compatible profile). |
| Bundle requests 404 in production |
out/vfx must be deployed as static files and
every path resolved against the bundle root. Asset paths
can contain folders (particles/spark.png) —
copy the directory tree, not just
manifest.json and effects/.
|
| npm install fails on peer versions |
nixie-fx 0.1.x declares
three >=0.184.0 <0.186.0 and
pixi.js >=8.19.0 <9.0.0 as optional
peers, and requires Node ≥ 20. You only need the renderer
you import.
|
| Something authored is not showing up |
Read stats.unsupportedFeatures: each entry is
a path: message string pointing at the
authored setting the backend cannot honor (plus any
material-graph features the compiler skipped). Cross-check
the effect's exported
support.backends.three3d report — a
partial status lists every approximation in
its warnings.
|
Resources
- CLI reference nixie-fx effect create · validate · export
- Editor & runtime feature reference every module, material node, and the backend capability matrix
- Runtime repository github.com/azakhary/nixie-fx — runtime, CLI, and skills (MIT)
- npm package npmjs.com/package/nixie-fx
- Agent skills nixie-fx-runtime wraps this integration for AI coding agents
This reference is maintained by the team behind NixieFX, the browser-based particle editor for PixiJS and Three.js. A machine-readable version is available at /threejs-runtime.md, and a site index for LLMs at /llms.txt.