sparcoon 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,138 +1,233 @@
1
- # Sparcoon
1
+ # <img src="https://raw.githubusercontent.com/jango-git/sparcoon/experimental/assets/logo.svg" width="30" height="30" alt="" align="top" /> Sparcoon
2
2
 
3
- Three.js runtime for VFX authored in the Sparcoon Editor.
3
+ A three.js runtime that plays visual effects authored in the
4
+ [Sparcoon Editor](https://github.com/jango-git/sparcoon-editor). The editor compiles a node graph
5
+ into a plain ES module of precompiled artifacts, and this package executes them: instanced particle
6
+ rendering in one draw call plus a small timeline player. No graph, no compiler and no `eval` in your
7
+ build.
4
8
 
5
- `sparcoon` plays back particle effects designed in the Sparcoon Editor: the
6
- editor compiles a node graph into a plain ES module of precompiled artifacts,
7
- and this thin runtime loads and runs it -- instanced-particle rendering in one
8
- draw call plus a small timeline player. No graph, compiler, or live protocol
9
- ships to your app.
9
+ **[Open the editor](https://jango-git.github.io/sparcoon-editor/)** -
10
+ [Editor source](https://github.com/jango-git/sparcoon-editor) -
11
+ [npm](https://www.npmjs.com/package/sparcoon/v/0.9.3)
10
12
 
11
- > Editor: link coming soon.
13
+ > **Pre-release.** The artifact format is not frozen yet - breaking changes are possible before the
14
+ > first stable release. The package is published under the `experimental` tag: npm's `latest` holds
15
+ > an older release that the current editor does not work with.
12
16
 
13
- ## Features
17
+ ## Who this is for
14
18
 
15
- - Instanced billboard and 3D-mesh particles in a single draw call.
16
- - Per-particle simulation from a precompiled kernel, on the CPU or -- on a
17
- WebGL2 renderer -- the GPU (transform feedback); no `eval`, no codegen either way.
18
- - Whole-project playback: an editor module subclasses `FXEffect`.
19
- - One `FXWorld.update` per frame drives every effect.
20
- - Optional depth sorting, shadows, and custom-geometry particles.
21
- - Tree-shakeable ESM with full TypeScript types.
19
+ Someone sent you a `.ts` file exported from the editor and you need to play it in your three.js
20
+ scene. This package is everything you have to add for that.
21
+
22
+ What is worth knowing before you install:
23
+
24
+ - **No code generation.** The exported module is ready GLSL plus ordinary JavaScript functions that
25
+ your bundler compiles like any other source. Neither `eval` nor `new Function` is called anywhere,
26
+ so the effect passes where runtime code generation is forbidden by policy.
27
+ - **No dependencies.** The only peer dependency is `three`.
28
+ - **One draw call per emitter.** Billboards and mesh particles are drawn instanced.
29
+ - **Two GLSL tiers in every artifact.** Standard (WebGL2) and Baseline (WebGL1-compatible); the
30
+ runtime picks by the capabilities of the renderer you pass in.
31
+ - **Optional GPU simulation** through transform feedback, with an automatic fallback to the
32
+ JavaScript kernel.
33
+ - **Tree-shakeable ESM with full types**, unbundled and unminified: your bundler processes the
34
+ package like any other source, exactly the way three.js is consumed.
22
35
 
23
36
  ## Install
24
37
 
25
38
  ```sh
26
- npm install sparcoon three
39
+ npm install sparcoon@experimental three
27
40
  ```
28
41
 
29
- `three` (>= 0.157) is a peer dependency.
42
+ `three` is a peer dependency; the supported range is `>=0.157 <0.180`.
30
43
 
31
44
  ## Quick start
32
45
 
33
- An editor project exports an `FXEffect` subclass plus a typed map of the
34
- textures it needs. Construct it (it joins the default world automatically), add
35
- it to your scene, and call `FXWorld.update(deltaTime)` once per frame.
46
+ A module from the editor exports a named `FXEffect` subclass and a typed interface for its assets.
47
+ The runtime does not load assets: you prepare the textures and geometries and pass them to the
48
+ constructor.
36
49
 
37
50
  ```ts
38
51
  import { TextureLoader } from "three";
39
52
  import { FXWorld } from "sparcoon";
40
- import { VFXDemo0 } from "./effects/VFXDemo_0"; // emitted by the editor
53
+ import { MyEffect, type MyEffectAssets } from "./effects/MyEffect"; // emitted by the editor
41
54
 
42
55
  const loader = new TextureLoader();
43
- const assets = {
44
- heart: loader.load("heart.png"),
45
- fire: loader.load("fire.png"),
56
+ const assets: MyEffectAssets = {
57
+ spark: loader.load("spark.png"),
46
58
  };
47
- const effect = new VFXDemo0(assets);
59
+
60
+ // renderer picks the artifact tier (WebGL2 -> Standard) and enables GPU simulation.
61
+ // camera is needed by emitters that were given a sort interval in the editor.
62
+ const effect = new MyEffect(assets, { renderer, camera });
48
63
 
49
64
  scene.add(effect); // FXEffect is a THREE.Group
50
65
  effect.play();
51
66
 
52
- function frame(deltaTime: number): void {
53
- FXWorld.update(deltaTime); // advances every effect's timeline and its particles
67
+ function frame(deltaSeconds: number): void {
68
+ FXWorld.update(deltaSeconds); // advances the timeline and particles of every effect
54
69
  renderer.render(scene, camera);
55
70
  }
56
71
  ```
57
72
 
58
- The effect is an `Object3D` -- position, rotate, and scale it like any other.
59
- Call `effect.dispose()` to remove it, free its GPU resources, and unsubscribe it
60
- from the world.
73
+ Straight after the constructor the effect sits on frame zero, in the state the artist authored.
74
+ `play()` restarts the timeline from the top; `stop()` halts playback, clears live particles and
75
+ returns the effect to frame zero. The timeline loops, and seamlessly: particles still alive at the
76
+ end of a lap finish their lives in the next one.
77
+
78
+ Without `renderer` the effect takes the Baseline tier unconditionally and runs on the JavaScript
79
+ kernel. That is a working mode if you have no renderer at the point of construction.
61
80
 
62
- ### Live parameters
81
+ `effect.dispose()` unsubscribes the effect from its world, destroys its emitters and frees mesh
82
+ resources. The textures and geometries you passed in are left alone: you own their lifecycle.
63
83
 
64
- Channels the editor marked "live" are driven from your code, not the timeline:
84
+ ## Where the module comes from
85
+
86
+ Effects are built in the [Sparcoon Editor](https://jango-git.github.io/sparcoon-editor/): a
87
+ browser-based node editor with a timeline and a three.js viewport. Each emitter there owns two
88
+ graphs - behavior (per-particle simulation) and render (its material) - and exporting a project puts
89
+ everything into one self-contained TypeScript module. You drop it into your source tree and import
90
+ it, as in the example above.
91
+
92
+ Transform channels and timeline values can be marked by the artist as excluded from the export: the
93
+ timeline does not drive those, your code supplies the values.
94
+
95
+ ## Driving it from code
65
96
 
66
97
  ```ts
67
- effect.setEmitterParam("Hearts", "spawnRate", 64);
68
- effect.setMeshParam("Mesh", "glow", [1, 0.4, 0.1]);
98
+ effect.setEmitterParam("Sparks", "intensity", 0.7);
99
+ effect.setMeshParam("Shockwave", "glow", [1, 0.4, 0.1]);
69
100
 
70
- effect.getEmitter("Hearts"); // the underlying emitter (Object3D)
71
- effect.getMesh("Mesh"); // the underlying THREE.Mesh
101
+ effect.getEmitter("Sparks")?.position.set(0, 1, 0);
102
+ effect.getMesh("Shockwave")?.rotateY(Math.PI);
72
103
  ```
73
104
 
74
- ### Many effects, and separate worlds
105
+ In the module the editor emitted, both setters and both getters are typed against the names the
106
+ project actually declares, so a typo is caught at compile time. An unknown parameter name is always a
107
+ safe no-op: a name drift never corrupts the runtime's data.
75
108
 
76
- Every effect joins the default world on construction, so any number of them are
77
- driven by the same single `FXWorld.update(deltaTime)` -- there is nothing extra
78
- to wire up per effect.
109
+ `getEmitter` returns a live `FXEmitter` (an `Object3D`), which gives you direct control over emission
110
+ outside the timeline:
79
111
 
80
- A world is a tick and clock domain (not a scene graph). Create your own
81
- `FXWorld` when you want an independent clock -- an isolated time scale, or a
82
- group you pause on its own -- and pass it to the effect:
112
+ ```ts
113
+ const sparks = effect.getEmitter("Sparks");
114
+ if (sparks) {
115
+ sparks.burst(64); // one-off release, optionally delayed; returns a handle for stop()
116
+ sparks.play(120); // continuous emission in particles per second; also returns a handle
117
+ sparks.stop(); // with no argument, cancels everything scheduled on this emitter
118
+ sparks.prewarm(2); // simulate 2 seconds forward so playback does not start from an empty screen
119
+
120
+ sparks.sortCamera = camera; // depth sorting; undefined turns it off
121
+ sparks.sortFraction = 0.1; // fraction of frames that re-sort (the default)
122
+ }
123
+ ```
124
+
125
+ An immediate `burst()` spawns from the emitter's pose and parameter values at the moment of the
126
+ call, on the GPU path too - so moving the emitter between two bursts of one frame spawns them in two
127
+ places.
128
+
129
+ For diagnostics there are `particleCount` and `particleCapacity`. A GPU-driven emitter has no exact
130
+ live count, so `particleCount` reports an honest upper bound - the buffer's current capacity.
131
+
132
+ ## Multiple effects and worlds
133
+
134
+ A world is a tick and clock domain, not a scene graph. Every effect subscribes to a world on
135
+ construction, so a single `FXWorld.update(deltaSeconds)` per frame drives them all.
83
136
 
84
137
  ```ts
85
138
  import { FXWorld } from "sparcoon";
86
139
 
87
140
  const slowmo = new FXWorld();
88
- const boss = new VFXDemo0(assets, { world: slowmo });
89
- scene.add(boss); // still added to your scene yourself
141
+ const boss = new MyEffect(assets, { renderer, world: slowmo });
142
+ scene.add(boss); // you still add the object to your scene yourself
90
143
 
91
- function frame(deltaTime: number): void {
92
- FXWorld.update(deltaTime); // the default world
93
- slowmo.update(deltaTime * 0.3); // its own, slower clock
144
+ function frame(deltaSeconds: number): void {
145
+ FXWorld.update(deltaSeconds); // the default world
146
+ slowmo.update(deltaSeconds * 0.3); // its own, slower clock
94
147
  renderer.render(scene, camera);
95
148
  }
96
149
  ```
97
150
 
98
- Within one world every effect shares the clock and one delta per frame, so
99
- `effect.stop()` clears rather than freezes -- to freeze one effect on its own,
100
- give it its own world. `world.dispose()` disposes every effect in it.
151
+ Within one world every effect shares the clock and one delta per frame, so `effect.stop()` clears the
152
+ effect rather than freezing it. To freeze one effect on its own, give it its own world.
153
+ `world.dispose()` disposes every effect in that world.
101
154
 
102
155
  ## How it works
103
156
 
104
- The **Sparcoon Editor** compiles a node graph into a plain ES module exporting
105
- a render artifact (GLSL, uniforms, textures) and a behavior artifact (authored
106
- spawn/update functions), plus -- when the graph opts into GPU simulation -- a
107
- fused WebGL2 transform-feedback kernel. This runtime executes them: your bundler
108
- compiles the authored functions like any other source and the behavior runs on
109
- the CPU, or, when you pass a WebGL2 `renderer` in the effect options, the
110
- precompiled kernel runs the simulation on the GPU instead (falling back to the
111
- CPU behavior if GPU setup fails). Nothing is evaluated at runtime either way.
112
- The editor/runtime boundary is a frozen ABI: two core per-particle buffers,
113
- `position` and `lifecycle` (`[age, lifetime, id]`).
157
+ The editor compiles a graph into artifacts and bakes them into a module together with the project's
158
+ timeline:
159
+
160
+ - **Render artifact** - GLSL, uniforms, texture references.
161
+ - **Behavior artifact** - the authored spawn/update functions, bindings and buffer layout.
162
+ - **GPU kernel** - a fused transform-feedback program, present only when the graph opted into GPU
163
+ simulation and compiled.
164
+
165
+ The runtime executes those artifacts and compiles nothing. The editor/runtime boundary is frozen:
166
+ each particle carries two core buffers, `position` (vec3) and `lifecycle` (vec3 `[age, lifetime,
167
+ id]`), a particle counts as dead once `age >= lifetime`, and everything else lives in ordinary
168
+ `a_fx_<name>` attributes. The package exports the constants of that layout (`FX_AGE`, `FX_LIFETIME`,
169
+ `FX_ID`, `FX_CORE_POSITION` and the rest).
170
+
171
+ The math the generated code calls comes from here too (`fxMix`, `fxSmoothstep`, `fxNoise1` /
172
+ `fxNoise2` / `fxNoise3`, `fxFbm`, `fxFbm3`, `fxFract`, `fxMod`, `fxSampleLut`), along with
173
+ `fxDataTexture` for baked curves and gradients. There is no reason to import them by hand: the effect
174
+ module does it itself.
175
+
176
+ ## Development-build checks
177
+
178
+ Arguments to public methods are sanity-checked (finite numbers, positive durations, integer
179
+ capacities) and throw with the parameter's name when violated. The checks live behind
180
+ `process.env.NODE_ENV !== "production"`, so they drop out of a production build entirely. The access
181
+ to `process` is guarded by a `typeof` check, which keeps the package safe without a bundler - under
182
+ import maps or from a CDN.
114
183
 
115
184
  ## Requirements
116
185
 
117
- - **A bundler** (Vite, webpack, Rollup, esbuild). `sparcoon` ships as
118
- unbundled, unminified ES2020 modules plus types; your bundler tree-shakes and
119
- minifies them, the same way three.js is consumed. Not a drop-in `<script>`.
120
- - **`three` >= 0.157**, as a peer dependency.
121
- - **Old devices.** Output targets ES2020; to run on pre-2015 hardware (for
122
- example a first-generation iPad Pro), downlevel it in your own build
123
- (Babel / SWC / your bundler's `target`). `sparcoon` leaves that final step
124
- to you.
125
-
126
- ## API overview
127
-
128
- - `FXEffect` -- whole-project timeline player (a `Group`); emitted modules
129
- subclass it. `play` / `stop`, `setEmitterParam` / `setMeshParam`,
130
- `getEmitter` / `getMesh`, `dispose`.
131
- - `FXWorld` -- the tick domain. `FXWorld.update(deltaTime)` per frame drives the
132
- default world; `new FXWorld()` is an isolated clock and pool.
133
- - Artifact contract types, the `FX_*` core-layout constants, CPU math helpers
134
- (`fxMix`, `fxNoise3`, ...), and `fxDataTexture`.
186
+ | | |
187
+ | -------- | ---------------------------------------------------------------------------------- |
188
+ | three.js | `>=0.157 <0.180`, a peer dependency |
189
+ | Bundler | Vite, webpack, Rollup, esbuild - the package ships unminified ESM plus types |
190
+ | JS level | ES2020; for devices older than 2015, transpile it on your side |
191
+ | WebGL | WebGL1 is enough for the Baseline tier; WebGL2 unlocks Standard and the GPU kernel |
192
+
193
+ Dropping the package in through a `<script>` tag is not supported: these are unminified ES modules
194
+ that expect your bundler. The ES2020 target is deliberate, so that you can downlevel it with your own
195
+ Babel or SWC as far as you need, down to first-generation iPad Pro hardware.
196
+
197
+ ## What the runtime does not do
198
+
199
+ - **It does not load assets.** You prepare the textures and geometries and pass them to the effect's
200
+ constructor. Freeing them is yours as well: the runtime never disposes resources it does not own.
201
+ - **It contains no compiler, graph or validator.** All of that stays in the
202
+ [editor](https://github.com/jango-git/sparcoon-editor); only the result arrives here.
203
+ - **It brings no lighting.** A lit graph reads the lights of your scene, so a lit effect needs a
204
+ light probe and a directional light nearby. The preview's own lighting rig is not exported.
205
+ - **It does not decide your scene's draw order.** Depth sorting of particles is enabled per emitter
206
+ and costs frame time.
207
+
208
+ ## The `sparcoon/editor` entry point
209
+
210
+ A separate, unstable entry for tools that build objects from artifacts directly:
211
+ `FXEmitter.fromArtifacts` and `FXMesh.fromArtifact`, targeted value updates, swapping the render half
212
+ without resetting the simulation, and the keyframe-sampling functions shared with the editor. The
213
+ editor's live preview uses it. An application that simply plays a finished effect has no reason to go
214
+ there: the whole path it needs runs through `FXEffect`.
215
+
216
+ ## Local development
217
+
218
+ ```sh
219
+ npm ci
220
+ npm run build # tsc -> dist/, unminified ESM plus .d.ts
221
+ npm test # vitest run
222
+ npm run lint
223
+ ```
224
+
225
+ ## Status
226
+
227
+ Pre-release, under active development. If you hit a bug, the most useful thing is a minimal
228
+ reproduction plus the editor project `.json` it shows up on.
135
229
 
136
230
  ## License
137
231
 
138
- MIT (c) jango
232
+ MIT - use it in commercial work, client projects and ads that ship; keep the copyright notice. See
233
+ [LICENSE](LICENSE).
@@ -65,6 +65,8 @@ export declare abstract class FXEffect extends Group {
65
65
  dispose(): void;
66
66
  private resetTransport;
67
67
  private advance;
68
+ /** Poses every entity for the frame `time` falls in, unless it already is. */
69
+ private poseAt;
68
70
  /** Fires burst/play events whose time lies in `[from, to)` on their owning emitter. */
69
71
  private fire;
70
72
  /**
@@ -372,20 +372,33 @@ export class FXEffect extends Group {
372
372
  const duration = this.specification.duration;
373
373
  let now = this.time + deltaSeconds;
374
374
  if (duration > 0 && now >= duration) {
375
- // Loop wrap: finish this lap's tail, reset, then replay from the remainder.
375
+ // Loop wrap: finish this lap's tail from the lap's last pose, then run on into the next lap
376
+ // without clearing what is alive - a looping effect never pops at the seam. The pose jumps
377
+ // back to the start, which is no motion: every motion baseline is dropped, or the jump would
378
+ // read as a one-frame velocity spike.
379
+ this.poseAt(duration);
376
380
  this.fire(this.lastTime, duration);
377
381
  for (const emitter of this.emitters) {
378
- emitter.stop();
379
- emitter.reset();
382
+ emitter.loopTimeline();
383
+ }
384
+ for (const tracker of this.meshMotionTrackers) {
385
+ tracker.reset();
380
386
  }
381
387
  now = now % duration;
382
388
  this.lastTime = 0;
383
389
  this.lastFrame = -1;
384
390
  }
391
+ // Pose and values first, events second: a burst spawns from the emitter's pose and parameters
392
+ // at the moment it is called, so this frame's bursts must see this frame's pose - not the one
393
+ // the previous frame left behind.
394
+ this.poseAt(now);
385
395
  this.fire(this.lastTime, now);
386
396
  this.lastTime = now;
387
397
  this.time = now;
388
- const frame = frameOfTime(now, this.specification.fps);
398
+ }
399
+ /** Poses every entity for the frame `time` falls in, unless it already is. */
400
+ poseAt(time) {
401
+ const frame = frameOfTime(time, this.specification.fps);
389
402
  if (frame !== this.lastFrame) {
390
403
  this.lastFrame = frame;
391
404
  this.driveFrame(timeOfFrame(frame, this.specification.fps));
@@ -25,7 +25,7 @@ export declare class FXEmitter extends Object3D {
25
25
  sortFraction: number;
26
26
  private readonly world;
27
27
  private readonly driver;
28
- private material;
28
+ private artifactMaterial;
29
29
  private nextHandler;
30
30
  private readonly pendingBursts;
31
31
  private readonly activePlays;
@@ -54,7 +54,10 @@ export declare class FXEmitter extends Object3D {
54
54
  /** Unregisters, removes from the scene, disposes mesh and material. Idempotent. */
55
55
  destroy(): void;
56
56
  /**
57
- * Spawns `count` particles immediately, or after `options.delay`.
57
+ * Spawns `count` particles immediately, or after `options.delay`. An immediate burst seeds its
58
+ * particles from this emitter's pose and parameter values at the moment of the call, on either
59
+ * backend - so moving the emitter between two bursts of one frame spawns them in two places.
60
+ * A delayed burst spawns from the pose and values of the tick it expires on.
58
61
  * @returns Handler for {@link stop} to cancel a pending delayed burst.
59
62
  */
60
63
  burst(count: number, options?: Partial<FXEmitterBurstOptions>): number;
@@ -81,6 +84,14 @@ export declare class FXEmitter extends Object3D {
81
84
  * schedule reset immediately.
82
85
  */
83
86
  reset(): void;
87
+ /**
88
+ * For a timeline that loops back to its start - {@link FXEffect}'s own, or a tool's that drives
89
+ * the emitter itself: cancels the active plays and pending bursts, as {@link stop} does, and
90
+ * forgets the motion baseline - the pose jumps from the lap's end to its start, which is no
91
+ * motion - but leaves live particles to finish their lives, so the loop runs on seamlessly.
92
+ * Unlike {@link reset}, which clears them.
93
+ */
94
+ loopTimeline(): void;
84
95
  /**
85
96
  * Scrubs live parameter values into uniform/binding slots by name, picked up next frame - the
86
97
  * runtime's value channel for a non-structural edit. Unknown name = safe no-op; never recompiles.
@@ -58,7 +58,7 @@ export class FXEmitter extends Object3D {
58
58
  };
59
59
  this.world = world;
60
60
  this.driver = driver;
61
- this.material = material;
61
+ this.artifactMaterial = material;
62
62
  if (options.receiveShadow ?? EMITTER_DEFAULT_RECEIVE_SHADOW) {
63
63
  this.mesh.receiveShadow = true;
64
64
  }
@@ -135,12 +135,12 @@ export class FXEmitter extends Object3D {
135
135
  */
136
136
  onWorldTick(deltaTime, elapsedTime) {
137
137
  this.onRendering(deltaTime);
138
- this.material.setElapsedTime(elapsedTime);
139
- this.material.setDeltaTime(deltaTime);
138
+ this.artifactMaterial.setElapsedTime(elapsedTime);
139
+ this.artifactMaterial.setDeltaTime(deltaTime);
140
140
  // emitterTransform.velocity/angularVelocity are always length 3 (see its declaration) -
141
141
  // computed once per tick in onRendering -> tick.
142
- this.material.setObjectVelocity(this.emitterTransform.velocity);
143
- this.material.setObjectAngularVelocity(this.emitterTransform.angularVelocity);
142
+ this.artifactMaterial.setObjectVelocity(this.emitterTransform.velocity);
143
+ this.artifactMaterial.setObjectAngularVelocity(this.emitterTransform.angularVelocity);
144
144
  }
145
145
  /** Unregisters, removes from the scene, disposes mesh and material. Idempotent. */
146
146
  destroy() {
@@ -154,12 +154,15 @@ export class FXEmitter extends Object3D {
154
154
  this.activePlays.length = 0;
155
155
  // The material owns no per-emitter GPU resources; the driver teardown disposes the instanced
156
156
  // geometry and the mounted material (plus, for a GPU-driven emitter, the compiled program+VAO).
157
- this.material.destroy();
157
+ this.artifactMaterial.destroy();
158
158
  this.driver.destroy();
159
159
  this.removeFromParent();
160
160
  }
161
161
  /**
162
- * Spawns `count` particles immediately, or after `options.delay`.
162
+ * Spawns `count` particles immediately, or after `options.delay`. An immediate burst seeds its
163
+ * particles from this emitter's pose and parameter values at the moment of the call, on either
164
+ * backend - so moving the emitter between two bursts of one frame spawns them in two places.
165
+ * A delayed burst spawns from the pose and values of the tick it expires on.
163
166
  * @returns Handler for {@link stop} to cancel a pending delayed burst.
164
167
  */
165
168
  burst(count, options = {}) {
@@ -268,6 +271,20 @@ export class FXEmitter extends Object3D {
268
271
  // posed - drop the velocity/angular-velocity baseline along with the particles.
269
272
  this.motionTracker.reset();
270
273
  }
274
+ /**
275
+ * For a timeline that loops back to its start - {@link FXEffect}'s own, or a tool's that drives
276
+ * the emitter itself: cancels the active plays and pending bursts, as {@link stop} does, and
277
+ * forgets the motion baseline - the pose jumps from the lap's end to its start, which is no
278
+ * motion - but leaves live particles to finish their lives, so the loop runs on seamlessly.
279
+ * Unlike {@link reset}, which clears them.
280
+ */
281
+ loopTimeline() {
282
+ if (this.destroyed) {
283
+ return;
284
+ }
285
+ this.stop();
286
+ this.motionTracker.reset();
287
+ }
271
288
  /**
272
289
  * Scrubs live parameter values into uniform/binding slots by name, picked up next frame - the
273
290
  * runtime's value channel for a non-structural edit. Unknown name = safe no-op; never recompiles.
@@ -278,7 +295,7 @@ export class FXEmitter extends Object3D {
278
295
  return;
279
296
  }
280
297
  if (values.uniforms !== undefined) {
281
- this.material.applyUniformValues(values.uniforms);
298
+ this.artifactMaterial.applyUniformValues(values.uniforms);
282
299
  }
283
300
  if (values.bindings !== undefined) {
284
301
  this.driver.applyBindingValues(values.bindings);
@@ -305,7 +322,7 @@ export class FXEmitter extends Object3D {
305
322
  // Throws (leaving this emitter untouched) before anything below mutates it - see this
306
323
  // method's own @throws doc.
307
324
  this.driver.applyRenderArtifact(material, baseGeometry);
308
- this.material = material;
325
+ this.artifactMaterial = material;
309
326
  this.mesh.receiveShadow = options.receiveShadow ?? EMITTER_DEFAULT_RECEIVE_SHADOW;
310
327
  const previousDepthMaterial = this.mesh.customDepthMaterial;
311
328
  if (options.castShadow ?? EMITTER_DEFAULT_CAST_SHADOW) {
@@ -15,15 +15,17 @@ import { FXTransformFeedbackSimulationHolder } from "./FXTransformFeedbackSimula
15
15
  */
16
16
  export class FXGPUEmitterDriver {
17
17
  constructor(kernel, renderer, material, capacity, baseGeometry) {
18
- // Cursor/overwrite bookkeeping: `gpuSpawnCursor` is the next write position (mod
19
- // capacity); `gpuSpawnRangeStart`/`gpuPendingSpawnCount` accumulate every spawn() call since the
20
- // last endTick() into one contiguous range, since the GPU can only run one transform-feedback pass
21
- // per tick (unlike the JS backend, which can grow its array once per call with no such constraint) -
22
- // "since the last endTick()", not "since the start of this tick": an immediate `burst()` between
23
- // ticks must not lose its contribution (see endTick()'s own doc comment).
18
+ // Cursor/overwrite bookkeeping: `gpuSpawnCursor` is the next write position (mod capacity).
19
+ // Spawns inside a tick (a delayed burst expiring, a play's rate accumulator) all share that
20
+ // tick's pose and parameters, so `gpuSpawnRangeStart`/`gpuPendingSpawnCount` gather them into
21
+ // one contiguous range the tick's own pass seeds. A spawn outside a tick - an immediate
22
+ // `burst()` from host code or from an effect's timeline - is seeded right away by a pass of its
23
+ // own instead (see spawn()), so the range is always empty between ticks.
24
24
  this.gpuSpawnCursor = 0;
25
25
  this.gpuSpawnRangeStart = 0;
26
26
  this.gpuPendingSpawnCount = 0;
27
+ // Between beginTick() and endTick(): spawns join the tick's pass instead of running their own.
28
+ this.ticking = false;
27
29
  // Depth sorting has no GPU-driven form - warned once, not every tick, if a host sets
28
30
  // sortCamera on one anyway.
29
31
  this.warnedAboutSortCamera = false;
@@ -62,32 +64,33 @@ export class FXGPUEmitterDriver {
62
64
  get canSpawn() {
63
65
  return true;
64
66
  }
65
- spawn(count) {
67
+ spawn(count, transform) {
66
68
  // Cursor advances by `count`, wrapping at capacity - the buffer slots this claims may already
67
69
  // be occupied by still-alive particles, which are simply overwritten (the scheme's cheapness is
68
- // not checking occupancy at all). Accumulates into
69
- // `gpuPendingSpawnCount` regardless of whether this call happens during a tick's own scheduling
70
- // (a delayed burst/play firing) or entirely between ticks (an immediate `burst()` call from host
71
- // code) - endTick() is the only place that range is ever consumed and reset (see its own doc
72
- // comment for why beginTick() must NOT also reset it).
70
+ // not checking occupancy at all).
71
+ const start = this.gpuSpawnCursor;
73
72
  this.gpuSpawnCursor = (this.gpuSpawnCursor + count) % this.mesh.capacity;
74
- this.gpuPendingSpawnCount += count;
73
+ if (this.ticking) {
74
+ this.gpuPendingSpawnCount += count;
75
+ return;
76
+ }
77
+ // Outside a tick, the emitter may be moved or re-parametrized before the next one (two
78
+ // bursts from two poses in one frame is an ordinary way to author an effect), so this spawn
79
+ // is seeded now, from the pose and bindings of this call. More than `capacity` at once
80
+ // overwrites the whole ring; the extra births would only overwrite each other.
81
+ this.simulation.spawn(this.mesh, { start, count: Math.min(count, this.mesh.capacity) }, transform);
82
+ this.gpuSpawnRangeStart = this.gpuSpawnCursor;
75
83
  }
76
84
  beginTick() {
77
- // No begin-of-tick bookkeeping needed: unlike the range/count fields (reset only once consumed,
78
- // in endTick()), there is nothing here that must happen before this tick's scheduled bursts run.
85
+ this.ticking = true;
79
86
  }
80
87
  endTick(deltaTime, transform) {
88
+ this.ticking = false;
81
89
  // Every particle slot is always processed - there is no CPU-visible live count to skip on;
82
90
  // the render shader hides dead slots (FXArtifactMaterial.Internal.ts's
83
91
  // projectFromModelPos degenerate-position injection).
84
92
  this.simulation.tick(this.mesh, deltaTime, { start: this.gpuSpawnRangeStart, count: this.gpuPendingSpawnCount }, transform);
85
- // Consumed - reset for the NEXT accumulation window, starting from wherever the cursor now sits.
86
- // Must NOT happen in beginTick() instead: an immediate `burst()` call between two ticks (the
87
- // ordinary way to spawn a one-shot effect, never touching pendingBursts/activePlays at all)
88
- // already advances the cursor and this count the moment it is called - resetting at the START of
89
- // the NEXT tick would silently discard that count before it was ever reported as a birth range,
90
- // leaving those slots' data one full transform-feedback pass behind their real cursor claim.
93
+ // Consumed - the next tick's range starts wherever the cursor now sits.
91
94
  this.gpuSpawnRangeStart = this.gpuSpawnCursor;
92
95
  this.gpuPendingSpawnCount = 0;
93
96
  }
@@ -108,10 +111,7 @@ export class FXGPUEmitterDriver {
108
111
  reset() {
109
112
  this.mesh.drop();
110
113
  this.gpuSpawnCursor = 0;
111
- // Must also clear any not-yet-consumed spawn accumulation (endTick() only resets it AFTER
112
- // consuming it - see that method's own doc comment), or a burst() that ran earlier this same
113
- // accumulation window would still spawn on the next tick despite reset() having just cancelled
114
- // it - a stale range left over from before the cursor was zeroed here.
114
+ // Also drops a range gathered but not yet seeded, so nothing cancelled here spawns later.
115
115
  this.gpuSpawnRangeStart = 0;
116
116
  this.gpuPendingSpawnCount = 0;
117
117
  }
@@ -78,18 +78,7 @@ export class FXTransformFeedbackSimulationHolder {
78
78
  tick(mesh, deltaTime, spawnRange, emitter) {
79
79
  const gl = this.gl;
80
80
  this.randSeedCounter += 1;
81
- // Routed through the renderer's own cache-aware setter (not raw gl.useProgram()) so three's
82
- // `WebGLState.currentProgram` cache stays truthful - its own next draw then correctly detects
83
- // the mismatch and re-binds, with no reset needed for this half of the state-hygiene problem.
84
- // The other half (the VAO-identity cache) has no such setter - see the raw
85
- // gl.bindVertexArray() call below and resetState() at the end of this method.
86
- this.renderer.state.useProgram(this.program);
87
- gl.bindVertexArray(this.vao);
88
- this.bufferNames.forEach((name, index) => {
89
- gl.bindBuffer(gl.ARRAY_BUFFER, mesh.readBuffer(name));
90
- gl.vertexAttribPointer(index, this.strides[index], gl.FLOAT, false, 0, 0);
91
- gl.enableVertexAttribArray(index);
92
- });
81
+ this.bindProgramAndInputs(mesh);
93
82
  this.bufferNames.forEach((name, index) => {
94
83
  gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, index, mesh.writeBuffer(name));
95
84
  });
@@ -99,11 +88,7 @@ export class FXTransformFeedbackSimulationHolder {
99
88
  gl.drawArrays(gl.POINTS, 0, mesh.capacity);
100
89
  gl.endTransformFeedback();
101
90
  gl.disable(gl.RASTERIZER_DISCARD);
102
- // A bound TRANSFORM_FEEDBACK_BUFFER base is otherwise still "active" for this binding point;
103
- // release it rather than leave a stale reference around between ticks.
104
- for (let index = 0; index < this.bufferNames.length; index += 1) {
105
- gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, index, null);
106
- }
91
+ this.releaseFeedbackBuffers();
107
92
  for (const name of this.bufferNames) {
108
93
  mesh.swapBuffers(name);
109
94
  }
@@ -116,6 +101,64 @@ export class FXTransformFeedbackSimulationHolder {
116
101
  // optimization, not required for correctness.
117
102
  this.renderer.resetState();
118
103
  }
104
+ /**
105
+ * Seeds the slots of `spawnRange` right now, from `emitter` and the current binding values, and
106
+ * touches no other slot: the spawn half of {@link tick}, run on its own. Lets every `burst()`
107
+ * outside the emitter's tick spawn from the pose and parameters of the moment it was called,
108
+ * instead of sharing one pass - and so one pose - with everything else spawned that frame.
109
+ *
110
+ * Transform feedback cannot write the buffer it reads, so the seeded range is drawn into the
111
+ * write half of each pair (`drawArrays(first, count)`: `gl_VertexID` is the slot index, which
112
+ * makes every drawn vertex a birth under the kernel's own range test) and then copied into the
113
+ * read half - the side the renderer draws and the next {@link tick} reads. No swap: every slot
114
+ * outside the range keeps its state untouched. A range that wraps past the end of the ring is
115
+ * drawn as two segments. `spawnRange.count` must not exceed the mesh's capacity.
116
+ */
117
+ spawn(mesh, spawnRange, emitter) {
118
+ if (spawnRange.count <= 0) {
119
+ return;
120
+ }
121
+ const gl = this.gl;
122
+ this.randSeedCounter += 1;
123
+ const capacity = mesh.capacity;
124
+ const head = Math.min(spawnRange.count, capacity - spawnRange.start);
125
+ const segments = head < spawnRange.count
126
+ ? [
127
+ [spawnRange.start, head],
128
+ [0, spawnRange.count - head],
129
+ ]
130
+ : [[spawnRange.start, spawnRange.count]];
131
+ this.bindProgramAndInputs(mesh);
132
+ // dt 0: the spawn branch has no use for it, and nothing else runs in this pass.
133
+ this.setUniforms(0, spawnRange, emitter, capacity);
134
+ gl.enable(gl.RASTERIZER_DISCARD);
135
+ for (const [first, count] of segments) {
136
+ this.bufferNames.forEach((name, index) => {
137
+ const bytesPerSlot = this.strides[index] * Float32Array.BYTES_PER_ELEMENT;
138
+ gl.bindBufferRange(gl.TRANSFORM_FEEDBACK_BUFFER, index, mesh.writeBuffer(name), first * bytesPerSlot, count * bytesPerSlot);
139
+ });
140
+ gl.beginTransformFeedback(gl.POINTS);
141
+ gl.drawArrays(gl.POINTS, first, count);
142
+ gl.endTransformFeedback();
143
+ }
144
+ gl.disable(gl.RASTERIZER_DISCARD);
145
+ // Released before the copy: a buffer still bound for transform feedback cannot be a copy
146
+ // source.
147
+ this.releaseFeedbackBuffers();
148
+ this.bufferNames.forEach((name, index) => {
149
+ const bytesPerSlot = this.strides[index] * Float32Array.BYTES_PER_ELEMENT;
150
+ gl.bindBuffer(gl.COPY_READ_BUFFER, mesh.writeBuffer(name));
151
+ gl.bindBuffer(gl.COPY_WRITE_BUFFER, mesh.readBuffer(name));
152
+ for (const [first, count] of segments) {
153
+ gl.copyBufferSubData(gl.COPY_READ_BUFFER, gl.COPY_WRITE_BUFFER, first * bytesPerSlot, first * bytesPerSlot, count * bytesPerSlot);
154
+ }
155
+ });
156
+ gl.bindBuffer(gl.COPY_READ_BUFFER, null);
157
+ gl.bindBuffer(gl.COPY_WRITE_BUFFER, null);
158
+ this.spawnIdBase += spawnRange.count;
159
+ // Same reason as in tick(): the raw VAO bind is invisible to three's caches.
160
+ this.renderer.resetState();
161
+ }
119
162
  destroy() {
120
163
  this.gl.deleteProgram(this.program);
121
164
  this.gl.deleteVertexArray(this.vao);
@@ -130,6 +173,31 @@ export class FXTransformFeedbackSimulationHolder {
130
173
  }
131
174
  }
132
175
  }
176
+ /** Binds the program and wires every state buffer's read half as its vertex input. */
177
+ bindProgramAndInputs(mesh) {
178
+ const gl = this.gl;
179
+ // Routed through the renderer's own cache-aware setter (not raw gl.useProgram()) so three's
180
+ // `WebGLState.currentProgram` cache stays truthful - its own next draw then correctly detects
181
+ // the mismatch and re-binds, with no reset needed for this half of the state-hygiene problem.
182
+ // The other half (the VAO-identity cache) has no such setter - see the raw
183
+ // gl.bindVertexArray() call below and the resetState() that ends every pass.
184
+ this.renderer.state.useProgram(this.program);
185
+ gl.bindVertexArray(this.vao);
186
+ this.bufferNames.forEach((name, index) => {
187
+ gl.bindBuffer(gl.ARRAY_BUFFER, mesh.readBuffer(name));
188
+ gl.vertexAttribPointer(index, this.strides[index], gl.FLOAT, false, 0, 0);
189
+ gl.enableVertexAttribArray(index);
190
+ });
191
+ }
192
+ /**
193
+ * Unbinds every transform-feedback binding point. A bound base stays "active" for its point
194
+ * otherwise; released rather than left as a stale reference between passes.
195
+ */
196
+ releaseFeedbackBuffers() {
197
+ for (let index = 0; index < this.bufferNames.length; index += 1) {
198
+ this.gl.bindBufferBase(this.gl.TRANSFORM_FEEDBACK_BUFFER, index, null);
199
+ }
200
+ }
133
201
  setUniforms(deltaTime, spawnRange, emitter, capacity) {
134
202
  const gl = this.gl;
135
203
  gl.uniform1i(this.uniformLocations[FX_TRANSFORM_FEEDBACK_RAND_SEED_UNIFORM], this.randSeedCounter);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sparcoon",
3
- "version": "0.9.2",
4
- "description": "Runtime for VFX artifacts authored in the Sparcoon Editor.",
3
+ "version": "0.9.3",
4
+ "description": "Three.js runtime for visual effects authored in the Sparcoon Editor - precompiled artifacts, one draw call per emitter, no eval.",
5
5
  "keywords": [
6
6
  "three",
7
7
  "threejs",
@@ -14,13 +14,18 @@
14
14
  "instanced",
15
15
  "instancing",
16
16
  "billboard",
17
+ "transform-feedback",
17
18
  "webgl",
19
+ "webgl2",
20
+ "glsl",
18
21
  "3d",
19
22
  "animation",
23
+ "playable-ads",
24
+ "esm",
20
25
  "typescript",
21
26
  "sparcoon"
22
27
  ],
23
- "homepage": "https://github.com/jango-git/sparcoon#readme",
28
+ "homepage": "https://jango-git.github.io/sparcoon-editor/",
24
29
  "bugs": {
25
30
  "url": "https://github.com/jango-git/sparcoon/issues"
26
31
  },
@@ -50,6 +55,9 @@
50
55
  "LICENSE"
51
56
  ],
52
57
  "sideEffects": false,
58
+ "publishConfig": {
59
+ "tag": "experimental"
60
+ },
53
61
  "engines": {
54
62
  "node": ">=18.18.0"
55
63
  },