Disclosure: I prepared this tutorial with AI assistance for a NixieFX marketing assignment. The screenshots are genuine desktop browser captures of the runtime example. The project is constructed with the package API; I am not presenting an editor-interface walkthrough.
A small shield-ready cue is a useful way to examine the boundary between an authoring project and a game scene. With the NixieFX VFX editor, the question I want to answer is concrete: can I validate an editor-compatible project, export it, load the export in Three.js, and hold repeatable frames for review?
The example below creates 48 cyan particles on a circle. They rise and fade during one short burst. A thin ground ring and grid belong to the Three.js host scene. They make movement easier to judge; they are not exported particles. There is no bloom, collision, lighting, or mesh-particle integration in this example.
The project-to-runtime contract
The flow is editor-compatible project → validation → export manifest and effect JSON → Three.js renderer. I use the package API to create the project file and the particle definition, then let the CLI validate and export them. This keeps the example reproducible without asking readers to match a sequence of editor clicks.
For the format and runtime integration, see the NixieFX VFX editor runtime documentation.
The export loader requires the three3d backend and the shield-ready effect ID. Those checks prevent an accidental change in the exported contract from silently loading the wrong effect. I also check the renderer's support report before creating the runtime instance. A successful load alone does not establish that every authored feature is supported by the chosen renderer.
1. Install the pinned packages
Use Node.js 20.19+ or 22.12+ and a browser with WebGL. Create an empty folder and add these files. The package versions match this test; pinning them makes subsequent changes easier to isolate.
npm init -y
npm pkg set type=module
npm install --save-exact nixie-fx@0.1.20 three@0.185.1
npm install --save-dev --save-exact vite@7.1.9
2. Create, validate, and export the cue
Save this as create-cue.mjs. It writes an editor-compatible project under authoring/, creates the effect, runs validation and export, and copies the exported bundle into Vite's public directory. Run the command from the folder containing the script.
The continuous emission rate is zero. One burst emits 48 particles at time zero, with a 0.9-second particle lifetime. Looping is disabled. The circle has no radial thickness, and each particle receives a small horizontal drift plus an upward velocity. These choices make the early and late frames visibly different without adding another emitter.
import { mkdir, writeFile, cp } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import { createDefaultParticleEffect, createConstantParticleScalar as value, normalizeParticleEffect } from 'nixie-fx';
import { normalizeEditorProjectFile } from 'nixie-fx/project';
const date='2026-10-13T00:00:00.000Z';
await mkdir('authoring',{recursive:true});
const project=normalizeEditorProjectFile({app:'vfx-editor',kind:'project',version:1,id:'n028-shield-ready',name:'Shield ready cue',createdAt:date,updatedAt:date,settings:{effectDataPath:'.',outputPath:'out/vfx',assetRootPath:'.',allowExternalOutput:false}});
await writeFile('authoring/vfx-editor.prj',JSON.stringify(project,null,2));
const cue=createDefaultParticleEffect('shield-ready','Shield ready');
cue.targetProfile='three-world-3d';cue.timeline.duration=1.15;cue.timeline.loop.enabled=false;
const ring=cue.emitters[0];ring.name='Rising ring sparks';ring.maxParticles=48;ring.duration=0.05;ring.loop=false;
ring.spawn.shape='circle';ring.spawn.radius=0.75;ring.spawn.radiusValue=value(0.75);ring.spawn.radiusThickness=0;
ring.spawn.rate=0;ring.spawn.rateValue=value(0);ring.spawn.bursts=[{time:0,count:48,cycles:1,interval:0,probability:1}];
ring.modules.velocity=true;ring.initializeParticle.velocity.mode='vector';ring.initializeParticle.velocity.min=[-0.18,0.7,-0.18];ring.initializeParticle.velocity.max=[0.18,1.6,0.18];
ring.initializeParticle.lifetime=value(0.9);ring.initializeParticle.size=value(0.075);ring.billboard.sizeValue=value(0.075);ring.billboard.softness=0.65;
ring.initializeParticle.color.color=[0.15,0.8,1,1];ring.initializeParticle.color.intensity=value(1);ring.initializeParticle.color.intensity.editorMin=0;ring.initializeParticle.color.intensity.editorMax=16;
ring.modules.color=true;ring.color.gradient.alphaStops=[{position:0,alpha:1},{position:0.5,alpha:0.7},{position:1,alpha:0}];
ring.render.blend='additive';ring.render.shading='unlit';ring.render.depthTest=false;ring.render.depthWrite=false;ring.render.depthInk=false;
await writeFile('authoring/shield-ready.json',JSON.stringify(normalizeParticleEffect(cue),null,2));
const cli=new URL('./node_modules/nixie-fx/dist/cli.js',import.meta.url).pathname;
execFileSync(process.execPath,[cli,'validate','./authoring'],{stdio:'inherit'});
execFileSync(process.execPath,[cli,'export','./authoring'],{stdio:'inherit'});
await mkdir('public/vfx',{recursive:true});await cp('authoring/out/vfx','public/vfx',{recursive:true});
node create-cue.mjs
In the tested project, validation returned 0 errors and 0 warnings, and export produced one effect. Keep that output visible when adapting the example: it is a check on this exact configuration, not a blanket guarantee for a different effect.
3. Consume the export in Three.js
Save this as main.js. The code reads the manifest and effect JSON rather than directly importing the authoring file. This tests the exported boundary that a runtime integration actually consumes.
The effect has no external file assets. The explicit asset check is intentional: adding a texture later also creates asset-loading work. Removing the guard without implementing that work would hide an incomplete integration.
import * as THREE from 'three';
import { loadVfxExportBundle } from 'nixie-fx/export';
import { ThreeVfxRenderer } from 'nixie-fx/three';
async function json(path){const r=await fetch(path);if(!r.ok)throw Error(`Could not load ${path}`);return r.json();}
async function start(){
const manifest=await json('./vfx/manifest.json');
const effectsByPath=Object.fromEntries(await Promise.all(manifest.effects.map(async item=>[item.path,await json(`./vfx/${item.path}`)])));
const bundle=loadVfxExportBundle({manifest,effectsByPath},{requiredBackend:'three3d',requiredEffectIds:['shield-ready']});
const effect=bundle.effectsById.get('shield-ready');if(effect.assets.length)throw Error('This cue expects procedural particles without file assets.');
const scene=new THREE.Scene();scene.background=new THREE.Color('#081522');
const camera=new THREE.PerspectiveCamera(45,640/390,0.1,30);camera.position.set(3,2.7,4);camera.lookAt(0,0.5,0);
const view=new THREE.WebGLRenderer({antialias:true});view.setPixelRatio(1);view.setSize(640,390);document.querySelector('#stage').append(view.domElement);
view.domElement.setAttribute('role','img');view.domElement.setAttribute('aria-label','A cyan shield-ready particle ring rising above a marked ground circle');
const grid=new THREE.GridHelper(5,20,0x29445e,0x142c42);scene.add(grid);
const mark=new THREE.Mesh(new THREE.TorusGeometry(0.75,0.014,8,64),new THREE.MeshBasicMaterial({color:0x69a9c1}));mark.rotation.x=Math.PI/2;mark.position.y=0.01;scene.add(mark);
const vfx=new ThreeVfxRenderer({scene,camera});const support=vfx.getSupport(effect);if(support.status!=='supported')throw Error('Review the runtime support report.');
const cue=vfx.createEffect(effect,{autoStart:false,position:[0,0,0],seed:31});
const status=document.querySelector('#status');const metrics=document.querySelector('#metrics');const reduce=document.querySelector('#reduce');const preference=matchMedia('(prefers-reduced-motion: reduce)');
let running=false,time=0.2,last=performance.now();
function draw(){view.render(scene,camera);metrics.textContent=`t=${time.toFixed(2)} s · seed 31 · active ${cue.stats.activeParticles} · visible ${cue.stats.visibleParticles} · support ${support.status}`;}
function hold(seconds){running=false;time=seconds;cue.seek(seconds);cue.pause();status.textContent='Held review frame.';draw();}
document.querySelector('#early').onclick=()=>hold(0.2);document.querySelector('#late').onclick=()=>hold(0.65);
document.querySelector('#replay').onclick=()=>{if(reduce.checked||preference.matches){hold(0.2);status.textContent='Reduced motion: static review frame.';return;}cue.restart();cue.play();time=0;last=performance.now();running=true;status.textContent='Playing the cue once.';};
function motionChanged(){if(reduce.checked||preference.matches)hold(0.2);}
reduce.onchange=motionChanged;preference.addEventListener('change',motionChanged);
view.setAnimationLoop(now=>{const dt=Math.min((now-last)/1000,0.05);last=now;if(running){time+=dt;vfx.update(dt);if(time>=1.15){running=false;cue.pause();status.textContent='Cue finished.';}draw();}});
for(const id of ['early','late','replay'])document.querySelector('#'+id).disabled=false;hold(0.2);
window.addEventListener('pagehide',()=>{view.setAnimationLoop(null);preference.removeEventListener('change',motionChanged);vfx.destroy();grid.geometry.dispose();for(const m of (Array.isArray(grid.material)?grid.material:[grid.material]))m.dispose();mark.geometry.dispose();mark.material.dispose();view.dispose();},{once:true});
}
start().catch(error=>{document.querySelector('#status').textContent=error.message;console.error(error);});
The seed is fixed at 31. seek() followed by pause() gives a held review frame, while restart() and play() replay the cue. During playback the host updates the VFX renderer and then draws the Three.js scene. A seed and a time are useful comparison inputs; they are not a claim that every renderer or future package version will produce identical pixels.
4. Add the page and run it
Save this as index.html. The initial frame is static, the controls are disabled until loading succeeds, and replay respects both the checkbox and the system reduced-motion preference. Keyboard users can reach the native buttons and checkbox.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>NixieFX editor export · shield-ready cue</title>
<style>*{box-sizing:border-box}body{margin:0;background:#06101c;color:#e3f0ff;font:16px/1.6 system-ui}main{max-width:850px;margin:26px auto;padding:24px}h1{font-size:34px;line-height:1.15}p{color:#aac3da}#stage{border:1px solid #305475;border-radius:15px;overflow:hidden}canvas{width:100%;height:auto;display:block}.controls{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-top:18px}button{border:1px solid #67e8f9;border-radius:8px;background:#67e8f9;color:#073844;font:inherit;padding:10px 17px;font-weight:650;cursor:pointer}button:focus-visible,input:focus-visible{outline:3px solid #fbbf24;outline-offset:4px}label{display:flex;align-items:center;gap:7px}input{width:19px;height:19px}#metrics{background:#102438;padding:12px;border-radius:8px;color:#99e4f3}small{color:#80a2c2}</style>
</head>
<body>
<main>
<small>N-028 · AUTHORING → EXPORT → RUNTIME</small>
<h1>Review a shield-ready cue from an editor project</h1>
<p>NixieFX VFX editor-compatible project, validated export bundle, and a Three.js consumer. The project was constructed with the package API; this is a runtime capture, not a screenshot of the editor UI.</p>
<div id="stage">
</div>
<div class="controls">
<button id="early" disabled>Hold 0.20 s</button>
<button id="late" disabled>Hold 0.65 s</button>
<button id="replay" disabled>Replay once</button>
<label>
<input id="reduce" type="checkbox">Reduce motion</label>
</div>
<p id="status" role="status" aria-live="polite">Loading…</p>
<p id="metrics">Runtime checks pending.</p>
<p>The cyan particles rise from one circular emitter. The thin ground ring and grid are host-scene geometry for orientation. There is no lighting, bloom pass, collision, or prepared mesh-particle integration in this example.</p>
<small>48 particles · 0.9 s particle lifetime · seed 31. Genuine browser verification; mobile performance not measured. AI assistance used for a NixieFX marketing assignment.</small>
</main>
<script type="module" src="./main.js">
</script>
</body>
</html>
npx vite --host 127.0.0.1
Open the local URL printed by Vite. Serve the files over HTTP: a file:// page is not the intended way to fetch this bundle. If loading fails, the status line shows the error instead of presenting an apparently working empty canvas.
Two frames from the running example
At 0.20 seconds, seed 31, the runtime reported 48 active and 48 visible particles and a supported renderer result. The particles are close enough to the ground marker to make the circular origin legible.
At 0.65 seconds, the same seed still reported 48 active and 48 visible particles. The rising particles have moved and faded. Holding both frames makes the visual difference easier to inspect than repeatedly trying to stop an animation by eye.
I also checked reduced-motion replay: with the option enabled, Replay once kept the static review frame. The screenshots above show the runtime, and the marker and grid are host geometry.
What this verifies, and what to check next
This example verifies one validated export and one Three.js consumer on the tested desktop browser. It does not measure mobile performance, frame rate, or cross-engine parity. There is no PixiJS or Babylon.js implementation in this tutorial.
If you adapt the cue, review the validation output, exported effect ID, required backend, support report, asset requirements, and the two held frames. Then test it inside the actual game camera and rendering setup. Those checks turn a visually promising authoring result into a small, inspectable runtime integration.


Top comments (0)