A three.js runtime that plays visual effects authored in the
Sparcoon Editor. The editor compiles a node graph
into a plain ES module of precompiled artifacts, and this package executes them: instanced particle
rendering in one draw call plus a small timeline player. No graph, no compiler and no eval in your
build.
Open the editor - Editor source - npm
Pre-release. The artifact format is not frozen yet - breaking changes are possible before the first stable release. The package is published under the
experimentaltag: npm'slatestholds an older release that the current editor does not work with.
Someone sent you a .ts file exported from the editor and you need to play it in your three.js
scene. This package is everything you have to add for that.
What is worth knowing before you install:
- No code generation. The exported module is ready GLSL plus ordinary JavaScript functions that
your bundler compiles like any other source. Neither
evalnornew Functionis called anywhere, so the effect passes where runtime code generation is forbidden by policy. - No dependencies. The only peer dependency is
three. - One draw call per emitter. Billboards and mesh particles are drawn instanced.
- Two GLSL tiers in every artifact. Standard (WebGL2) and Baseline (WebGL1-compatible); the runtime picks by the capabilities of the renderer you pass in.
- Optional GPU simulation through transform feedback, with an automatic fallback to the JavaScript kernel.
- Tree-shakeable ESM with full types, unbundled and unminified: your bundler processes the package like any other source, exactly the way three.js is consumed.
npm install sparcoon@experimental threethree is a peer dependency; the supported range is >=0.157 <0.180.
A module from the editor exports a named FXEffect subclass and a typed interface for its assets.
The runtime does not load assets: you prepare the textures and geometries and pass them to the
constructor.
import { TextureLoader } from "three";
import { FXWorld } from "sparcoon";
import { MyEffect, type MyEffectAssets } from "./effects/MyEffect"; // emitted by the editor
const loader = new TextureLoader();
const assets: MyEffectAssets = {
spark: loader.load("spark.png"),
};
// renderer picks the artifact tier (WebGL2 -> Standard) and enables GPU simulation.
// camera is needed by emitters that were given a sort interval in the editor.
const effect = new MyEffect(assets, { renderer, camera });
scene.add(effect); // FXEffect is a THREE.Group
effect.play();
function frame(deltaSeconds: number): void {
FXWorld.update(deltaSeconds); // advances the timeline and particles of every effect
renderer.render(scene, camera);
}Straight after the constructor the effect sits on frame zero, in the state the artist authored.
play() restarts the timeline from the top; stop() halts playback, clears live particles and
returns the effect to frame zero.
Without renderer the effect takes the Baseline tier unconditionally and runs on the JavaScript
kernel. That is a working mode if you have no renderer at the point of construction.
effect.dispose() unsubscribes the effect from its world, destroys its emitters and frees mesh
resources. The textures and geometries you passed in are left alone: you own their lifecycle.
Effects are built in the Sparcoon Editor: a browser-based node editor with a timeline and a three.js viewport. Each emitter there owns two graphs - behavior (per-particle simulation) and render (its material) - and exporting a project puts everything into one self-contained TypeScript module. You drop it into your source tree and import it, as in the example above.
Transform channels and timeline values can be marked by the artist as excluded from the export: the timeline does not drive those, your code supplies the values.
effect.setEmitterParam("Sparks", "intensity", 0.7);
effect.setMeshParam("Shockwave", "glow", [1, 0.4, 0.1]);
effect.getEmitter("Sparks")?.position.set(0, 1, 0);
effect.getMesh("Shockwave")?.rotateY(Math.PI);In the module the editor emitted, both setters and both getters are typed against the names the project actually declares, so a typo is caught at compile time. An unknown parameter name is always a safe no-op: a name drift never corrupts the runtime's data.
getEmitter returns a live FXEmitter (an Object3D), which gives you direct control over emission
outside the timeline:
const sparks = effect.getEmitter("Sparks");
if (sparks) {
sparks.burst(64); // one-off release, optionally delayed; returns a handle for stop()
sparks.play(120); // continuous emission in particles per second; also returns a handle
sparks.stop(); // with no argument, cancels everything scheduled on this emitter
sparks.prewarm(2); // simulate 2 seconds forward so playback does not start from an empty screen
sparks.sortCamera = camera; // depth sorting; undefined turns it off
sparks.sortFraction = 0.1; // fraction of frames that re-sort (the default)
}For diagnostics there are particleCount and particleCapacity. A GPU-driven emitter has no exact
live count, so particleCount reports an honest upper bound - the buffer's current capacity.
A world is a tick and clock domain, not a scene graph. Every effect subscribes to a world on
construction, so a single FXWorld.update(deltaSeconds) per frame drives them all.
import { FXWorld } from "sparcoon";
const slowmo = new FXWorld();
const boss = new MyEffect(assets, { renderer, world: slowmo });
scene.add(boss); // you still add the object to your scene yourself
function frame(deltaSeconds: number): void {
FXWorld.update(deltaSeconds); // the default world
slowmo.update(deltaSeconds * 0.3); // its own, slower clock
renderer.render(scene, camera);
}Within one world every effect shares the clock and one delta per frame, so effect.stop() clears the
effect rather than freezing it. To freeze one effect on its own, give it its own world.
world.dispose() disposes every effect in that world.
The editor compiles a graph into artifacts and bakes them into a module together with the project's timeline:
- Render artifact - GLSL, uniforms, texture references.
- Behavior artifact - the authored spawn/update functions, bindings and buffer layout.
- GPU kernel - a fused transform-feedback program, present only when the graph opted into GPU simulation and compiled.
The runtime executes those artifacts and compiles nothing. The editor/runtime boundary is frozen:
each particle carries two core buffers, position (vec3) and lifecycle (vec3 [age, lifetime, id]), a particle counts as dead once age >= lifetime, and everything else lives in ordinary
a_fx_<name> attributes. The package exports the constants of that layout (FX_AGE, FX_LIFETIME,
FX_ID, FX_CORE_POSITION and the rest).
The math the generated code calls comes from here too (fxMix, fxSmoothstep, fxNoise1 /
fxNoise2 / fxNoise3, fxFbm, fxFbm3, fxFract, fxMod, fxSampleLut), along with
fxDataTexture for baked curves and gradients. There is no reason to import them by hand: the effect
module does it itself.
Arguments to public methods are sanity-checked (finite numbers, positive durations, integer
capacities) and throw with the parameter's name when violated. The checks live behind
process.env.NODE_ENV !== "production", so they drop out of a production build entirely. The access
to process is guarded by a typeof check, which keeps the package safe without a bundler - under
import maps or from a CDN.
| three.js | >=0.157 <0.180, a peer dependency |
| Bundler | Vite, webpack, Rollup, esbuild - the package ships unminified ESM plus types |
| JS level | ES2020; for devices older than 2015, transpile it on your side |
| WebGL | WebGL1 is enough for the Baseline tier; WebGL2 unlocks Standard and the GPU kernel |
Dropping the package in through a <script> tag is not supported: these are unminified ES modules
that expect your bundler. The ES2020 target is deliberate, so that you can downlevel it with your own
Babel or SWC as far as you need, down to first-generation iPad Pro hardware.
- It does not load assets. You prepare the textures and geometries and pass them to the effect's constructor. Freeing them is yours as well: the runtime never disposes resources it does not own.
- It contains no compiler, graph or validator. All of that stays in the editor; only the result arrives here.
- It brings no lighting. A lit graph reads the lights of your scene, so a lit effect needs a light probe and a directional light nearby. The preview's own lighting rig is not exported.
- It does not decide your scene's draw order. Depth sorting of particles is enabled per emitter and costs frame time.
A separate, unstable entry for tools that build objects from artifacts directly:
FXEmitter.fromArtifacts and FXMesh.fromArtifact, targeted value updates, swapping the render half
without resetting the simulation, and the keyframe-sampling functions shared with the editor. The
editor's live preview uses it. An application that simply plays a finished effect has no reason to go
there: the whole path it needs runs through FXEffect.
npm ci
npm run build # tsc -> dist/, unminified ESM plus .d.ts
npm test # vitest run
npm run lintPre-release, under active development. If you hit a bug, the most useful thing is a minimal
reproduction plus the editor project .json it shows up on.
MIT - use it in commercial work, client projects and ads that ship; keep the copyright notice. See LICENSE.