# NixieFX Three.js runtime — guide & API reference

> Documentation · Updated August 2026 · https://nixiefx.com/threejs-runtime/

How to load an exported NixieFX bundle into a Three.js scene and everything
the `nixie-fx/three` adapter exposes: `ThreeVfxRenderer`, effect instances,
providers, runtime controls, stats, and the instanced rendering fast path.

## TL;DR — the integration in one breath

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

```sh
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
(https://nixiefx.com/editor-manual/) or headlessly with
`npx nixie-fx export` (https://nixiefx.com/cli-reference/). Deploy the bundle
with your game's static files (below it is served at `/vfx`).

```js
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

```text
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

```ts
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.

```ts
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. |

```ts
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.

```ts
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.

```ts
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.

```js
// 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

```ts
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 `ReadonlyMap`s 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. |

**`ThreeVfxTextureStore`** —
`new ThreeVfxTextureStore({ resolveUrl, loadingManager?, configureTexture? })`
is 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. |
| `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 `maxParticles` is 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: false` in 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; use `update()`.
- 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 at
https://nixiefx.com/vfx-runtime-docs/.

## 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 (https://github.com/azakhary/nixie-fx) 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** — https://nixiefx.com/cli-reference/ (nixie-fx effect create · validate · export)
- **Editor & runtime feature reference** — https://nixiefx.com/vfx-runtime-docs/ (every module, material node, and the backend capability matrix)
- **Runtime repository** — https://github.com/azakhary/nixie-fx (runtime, CLI, and skills, MIT)
- **npm package** — https://www.npmjs.com/package/nixie-fx
- **Agent skills** — https://nixiefx.com/skills/ (nixie-fx-runtime wraps this integration for AI coding agents)

---

*This reference is maintained by the team behind NixieFX
(https://nixiefx.com/), the browser-based particle editor for PixiJS and
Three.js. The HTML version lives at https://nixiefx.com/threejs-runtime/, and
a site index for LLMs at https://nixiefx.com/llms.txt.*
