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 +176 -81
- package/dist/effect/FXEffect.d.ts +2 -0
- package/dist/effect/FXEffect.js +17 -4
- package/dist/emitter/FXEmitter.d.ts +13 -2
- package/dist/emitter/FXEmitter.js +26 -9
- package/dist/emitter/FXGPUEmitterDriver.Internal.js +25 -25
- package/dist/emitter/FXTransformFeedbackSimulationHolder.js +85 -17
- package/package.json +11 -3
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
|
-
|
|
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
|
-
|
|
6
|
-
editor
|
|
7
|
-
|
|
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
|
-
>
|
|
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
|
-
##
|
|
17
|
+
## Who this is for
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
-
|
|
21
|
-
|
|
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`
|
|
42
|
+
`three` is a peer dependency; the supported range is `>=0.157 <0.180`.
|
|
30
43
|
|
|
31
44
|
## Quick start
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 {
|
|
53
|
+
import { MyEffect, type MyEffectAssets } from "./effects/MyEffect"; // emitted by the editor
|
|
41
54
|
|
|
42
55
|
const loader = new TextureLoader();
|
|
43
|
-
const assets = {
|
|
44
|
-
|
|
45
|
-
fire: loader.load("fire.png"),
|
|
56
|
+
const assets: MyEffectAssets = {
|
|
57
|
+
spark: loader.load("spark.png"),
|
|
46
58
|
};
|
|
47
|
-
|
|
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(
|
|
53
|
-
FXWorld.update(
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
68
|
-
effect.setMeshParam("
|
|
98
|
+
effect.setEmitterParam("Sparks", "intensity", 0.7);
|
|
99
|
+
effect.setMeshParam("Shockwave", "glow", [1, 0.4, 0.1]);
|
|
69
100
|
|
|
70
|
-
effect.getEmitter("
|
|
71
|
-
effect.getMesh("
|
|
101
|
+
effect.getEmitter("Sparks")?.position.set(0, 1, 0);
|
|
102
|
+
effect.getMesh("Shockwave")?.rotateY(Math.PI);
|
|
72
103
|
```
|
|
73
104
|
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
89
|
-
scene.add(boss); // still
|
|
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(
|
|
92
|
-
FXWorld.update(
|
|
93
|
-
slowmo.update(
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
The editor/runtime boundary is
|
|
113
|
-
`position` and `lifecycle` (`[age, lifetime,
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
-
|
|
134
|
-
|
|
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
|
|
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
|
/**
|
package/dist/effect/FXEffect.js
CHANGED
|
@@ -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
|
|
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.
|
|
379
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
139
|
-
this.
|
|
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.
|
|
143
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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).
|
|
69
|
-
|
|
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.
|
|
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
|
-
|
|
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 -
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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.
|
|
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
|
},
|