A three.js runtime that plays effects exported from the Sparcoon Editor. The editor compiles node graphs into an ES module with ready shaders and simulation code, and the runtime draws the particles and plays the timeline.
Editor - Editor source - npm
Every effect above is an editor preset. To open one, press Ctrl+Space, pick it on the Content
screen and press Load.
0.x. The export format still changes, so an effect plays only on the runtime minor version it was exported for. After updating the runtime, export your effects again.
- Effects made of particle emitters and meshes under a shared timeline.
- Billboard and mesh particles, particle trails.
- Random points on the surface of a mesh, evenly by area.
- Collisions with planes, boxes, spheres and cones.
- Particle birth and death events for reactions between emitters and for listeners in your code, with attribute inheritance.
- Soft particles, scene lighting and shadows.
- GPU simulation.
| three.js | >=0.157 <0.188, the only peer dependency |
| Renderer | WebGLRenderer |
| WebGL | WebGL2, or WebGL1 with three 0.157-0.162 |
| Bundler | Vite, webpack, Rollup, esbuild |
| JS level | ES2020 |
- Every effect carries shaders for two tiers, Standard (WebGL2) and Baseline (WebGL1). The runtime picks the tier by the renderer. An emitter simulates on the GPU if the device supports it, and on the CPU otherwise. three dropped WebGL1 in 0.163, so a WebGL1 device needs three 0.157-0.162.
WebGPURendereris not supported. An effect given one, or drawn by one, throws an error. If the bundler mapsthreetothree/webgpu, the import of sparcoon fails, because that build has noUniformsLib.- Neither the runtime nor the export calls
evalornew Function, so effects run under a CSP that forbids code generation. - The package is unbundled, unminified ESM with types. Loading it through a
<script>tag is not supported. For older devices, transpile it together with your own code.
npm install sparcoon threeA module from the editor exports the effect class and the interface of its assets. You load the textures and geometries yourself:
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"),
};
const effect = new MyEffect(assets, { renderer, camera });
scene.add(effect); // an effect is a THREE.Group
effect.play();
function frame(deltaSeconds: number): void {
FXWorld.update(deltaSeconds); // advances every effect
renderer.render(scene, camera);
}renderer is needed to select the shader tier and for GPU simulation. Without it the effect runs on
Baseline and the CPU. camera is needed by emitters with depth sorting.
play()starts the timeline from the beginning.stop()halts it, removes the particles and returns the effect to frame zero.- A looping effect starts each new lap without removing live particles.
- A one-shot effect (set so in the editor, or started with
play({ loop: false })) holds its last frame and dispatches"finished", then"drained"once the last particle and the last trail are gone. dispose()frees the effect's emitters and meshes. The runtime does not free your textures and geometries.
effect.addEventListener("drained", () => effect.dispose());
effect.play({ loop: false });"drained" arrives only if the longest particle lifetime is known - in the editor, a Lifetime node
with its min and max typed in. If the lifetime comes from a graph or a parameter, the editor warns
about it, and you stop such an effect yourself, waiting out the particle lifetime after
"finished".
A one-shot effect can go back to a pool on "drained" and play again:
class Pool {
private readonly idle: MyEffect[] = [];
constructor(private readonly build: () => MyEffect) {}
play(scene: Scene, position: Vector3): void {
const effect = this.idle.pop() ?? this.build();
effect.position.copy(position);
scene.add(effect);
effect.addEventListener(
"drained",
() => {
effect.stop();
effect.removeFromParent();
this.idle.push(effect);
},
{ once: true },
);
effect.play({ loop: false });
}
}- A replay resets the timeline, the particles and scheduled emission. The transform, parameters and event listeners are kept.
- A pooled effect simulates no particles, but its meshes are still drawn - take it out of the scene.
Shaders compile on the first frame the effect is visible in, and that frame can stall. compile()
does it ahead of time, for example behind a loading screen:
const effect = new MyEffect(assets, { renderer, camera });
await effect.compile(renderer, camera, scene);
scene.add(effect);- If the effect draws into a post-processing buffer, set that render target on the renderer before
compile(). - On three r157
compile()compiles the wholescenesynchronously, so add the effect to the scene before the call. - Shadow pass shaders are not compiled ahead.
- Three deletes a shader together with the last material using it, so an effect built after every earlier one was disposed compiles again.
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);- Emitter, mesh and parameter names are typed in the exported module, and an unknown parameter name is ignored.
- Channels excluded from the export in the editor are left alone by the timeline - your code sets their values.
const sparks = effect.getEmitter("Sparks");
if (sparks) {
sparks.burst(64); // a burst, returns a handle for stop()
sparks.burst(16, { origin: { position: [2, 1, 0] } }); // a burst from another point
sparks.play(120); // particles per second
sparks.play(8, { over: "distance" }); // particles per unit the emitter travels
sparks.stop(); // with no argument, cancels all of the emitter's emission
sparks.prewarm(2); // simulate 2 seconds ahead
}originsets the birth point and velocity, and rotation and scale come from the emitter.
const shells = effect.getEmitter("Shells");
const stopListening = shells?.onParticleEvent("death", (death) => {
playBang(death.position); // the object is reused, so copy what you keep
});
shells?.onParticleEvent("birth", (birth) => playLaunch(birth.position));- Only a CPU emitter reports births and deaths (
reportsParticleEvents). The editor moves reaction sources to the CPU itself. - Listeners run after every emitter of the world has ticked: in one frame deaths first, then births.
- A birth is reported at the position its spawn chose, with the velocity of the emitter or of the spawn origin. A death is reported after the particle's last update, with how fast it moved over its last frame.
slotholds every attribute of the particle. At a birth only what its spawn wrote is set. To let the particles of your own burst inherit values, pass them inorigin.inherited. Without it the particles get the default values.
sparks.sortCamera = camera; // depth sorting, undefined turns it off
sparks.sortFraction = 0.1; // share of frames that re-sort- Sorting works only on the CPU and not on an emitter with a trail.
- An emitter's capacity is set in the editor and does not grow. A new particle takes the next slot even if a live one is there, so the capacity has to cover the emission rate times the lifetime.
particleCount,particleCapacityandparticleOverflowCountshow the occupancy. A nonzeroparticleOverflowCountmeans the capacity is too small. The GPU has no exact count, soparticleCountequals the capacity andparticleOverflowCountisundefined.
A world sets the clock for its effects. By default every effect is in one world, and FXWorld.update
advances them by one delta. A separate world is needed to slow down or pause some of the effects:
const slowmo = new FXWorld();
const boss = new MyEffect(assets, { renderer, world: slowmo });
scene.add(boss);
function frame(deltaSeconds: number): void {
FXWorld.update(deltaSeconds);
slowmo.update(deltaSeconds * 0.3);
renderer.render(scene, camera);
}world.dispose() disposes every effect of the world.
- Lit effects use the lights of your scene. Lambert and Half-Lit take all of three's lights, ambient takes only the ambient, hemisphere and light probe ones. The editor's preview lighting is not exported.
- Which objects of an effect cast and receive shadows is set in the editor.
Effects with a Depth Fade node fade out near the surfaces of the scene. For that the camera needs a depth texture of the opaque scene:
import { DepthTexture, Group, WebGLRenderTarget } from "three";
import { fxSetCameraDepth } from "sparcoon";
const depthPass = new WebGLRenderTarget(width, height, {
depthTexture: new DepthTexture(width, height),
});
fxSetCameraDepth(camera, depthPass.depthTexture);
const effects = new Group();
scene.add(effects);
effects.add(effect);
// each frame
effects.visible = false;
renderer.setRenderTarget(depthPass);
renderer.render(scene, camera);
effects.visible = true;
renderer.setRenderTarget(null);
renderer.render(scene, camera);- Any depth texture of the same viewport at any resolution works, for example from a separate pass, a copy of the depth buffer or post-processing. Keeping it current, resizes included, is up to you.
- Without depth, effects draw with hard edges. They draw the same way into the target that owns the camera's depth.
- Do not give a camera the depth of a target another camera draws the effects into.
- Depth Fade works only in blending mode (
blending). - Logarithmic and reversed depth buffers are not supported.
A texture slot accepts a sprite from an atlas. fxAtlasRegion builds it from a TexturePacker or
free-tex-packer frame entry:
import { fxAtlasRegion } from "sparcoon";
const atlas = loader.load("atlas.png");
const { frames, meta } = atlasJson; // the packer's JSON
const assets: MyEffectAssets = {
spark: fxAtlasRegion(atlas, frames["spark.png"], meta.size),
};- Texture wrapping repeats the sprite, not the atlas.
- An atlas sprite costs a few shader operations and a shader of its own.
- Argument checks run only in a development build and are removed with
NODE_ENV=production. - An effect exported for another minor version logs a warning naming both versions. The runtime's
version is
FX_RUNTIME_VERSION. - Given a
renderer, a trail that needs more vertex attributes, varyings, uniforms or textures than the GPU offers (a heavy trail on WebGL1) is skipped with a warning, and its particles draw without it.
An unstable API for tools that build emitters and meshes from artifacts directly. The editor's preview uses it, and it is not needed for playing effects.
npm ci
npm run build
npm test
npm run lintWhen reporting a bug, attach a minimal reproduction and the editor project .json.
MIT. See LICENSE.








