sparcoon 0.9.1 → 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.
Files changed (36) hide show
  1. package/README.md +176 -76
  2. package/dist/artifact/FXArtifact.d.ts +51 -3
  3. package/dist/behaviorTransformFeedbackLayout.d.ts +24 -0
  4. package/dist/behaviorTransformFeedbackLayout.js +24 -0
  5. package/dist/coreLayout.d.ts +9 -5
  6. package/dist/coreLayout.js +17 -4
  7. package/dist/editor/index.d.ts +1 -0
  8. package/dist/editor/index.js +1 -1
  9. package/dist/effect/FXEffect.d.ts +4 -0
  10. package/dist/effect/FXEffect.js +47 -14
  11. package/dist/effect/FXEffectSpec.d.ts +21 -4
  12. package/dist/emitter/FXApplyRenderArtifactOptions.d.ts +15 -0
  13. package/dist/emitter/FXApplyRenderArtifactOptions.js +1 -0
  14. package/dist/emitter/FXEmitter.d.ts +44 -14
  15. package/dist/emitter/FXEmitter.js +132 -156
  16. package/dist/emitter/FXEmitterDriver.Internal.d.ts +26 -0
  17. package/dist/emitter/FXEmitterDriver.Internal.js +70 -0
  18. package/dist/emitter/FXFromArtifactsOptions.d.ts +12 -1
  19. package/dist/emitter/FXGPUEmitterDriver.Internal.d.ts +1 -0
  20. package/dist/emitter/FXGPUEmitterDriver.Internal.js +137 -0
  21. package/dist/emitter/FXJSEmitterDriver.Internal.d.ts +1 -0
  22. package/dist/emitter/FXJSEmitterDriver.Internal.js +133 -0
  23. package/dist/emitter/FXSimulationHolder.js +7 -5
  24. package/dist/emitter/FXTransformFeedbackSimulationHolder.d.ts +1 -0
  25. package/dist/emitter/FXTransformFeedbackSimulationHolder.js +249 -0
  26. package/dist/index.d.ts +4 -3
  27. package/dist/index.js +3 -2
  28. package/dist/instancedParticle/FXInstancedParticle.js +28 -1
  29. package/dist/instancedParticle/FXTransformFeedbackParticle.d.ts +1 -0
  30. package/dist/instancedParticle/FXTransformFeedbackParticle.js +169 -0
  31. package/dist/miscellaneous/fxMath.d.ts +18 -11
  32. package/dist/miscellaneous/fxMath.js +89 -209
  33. package/dist/render/FXArtifactMaterial.Internal.js +66 -40
  34. package/dist/render/FXArtifactMaterial.js +1 -1
  35. package/dist/render/FXMesh.js +2 -4
  36. package/package.json +11 -3
@@ -29,6 +29,10 @@ function applyTransform(object, transform, liveChannels) {
29
29
  function materializeValue(value) {
30
30
  return typeof value === "number" ? value : [...value];
31
31
  }
32
+ /** Picks one emitter/mesh's compiled artifact by render target (see {@link FXEffectOptions.renderer}). */
33
+ function resolveRenderArtifact(render, isWebGL2) {
34
+ return render[isWebGL2 ? "standard" : "baseline"];
35
+ }
32
36
  // Duck-typed, not `instanceof`: the app's `three` and this package's peer-dep `three` can be two
33
37
  // separate module instances (e.g. hoisted differently across a workspace), which would make
34
38
  // `instanceof Texture`/`instanceof BufferGeometry` false for a perfectly good asset. Every `three`
@@ -115,6 +119,11 @@ export class FXEffect extends Group {
115
119
  constructor(specification, assets, options = {}) {
116
120
  super();
117
121
  this.emitters = [];
122
+ // The single artifact resolved (by render target) from each emitter/mesh spec's
123
+ // FXRenderArtifactsByGLSLTier - parallel to `emitters`/`meshes`, read by the parameter-drive path
124
+ // instead of re-resolving the spec's map on every call.
125
+ this.emitterRenders = [];
126
+ this.meshRenders = [];
118
127
  this.meshMaterials = [];
119
128
  this.meshes = [];
120
129
  // World-space velocity/angular-velocity tracker per mesh, parallel to `meshes`.
@@ -140,14 +149,22 @@ export class FXEffect extends Group {
140
149
  this.world = options.world ?? FXWorld.getDefault();
141
150
  applyTransform(this, specification.transform);
142
151
  const geometries = geometriesFromAssets(assets);
152
+ const isWebGL2 = options.renderer?.capabilities.isWebGL2 ?? false;
143
153
  for (const emitterSpec of specification.emitters) {
144
154
  const textures = texturesForSlots(emitterSpec.externalSlots, assets);
145
- const emitter = FXEmitter.fromArtifacts(emitterSpec.render, emitterSpec.behavior, {
155
+ const render = resolveRenderArtifact(emitterSpec.render, isWebGL2);
156
+ const emitter = FXEmitter.fromArtifacts(render, emitterSpec.behavior, {
146
157
  textures,
147
158
  geometries,
148
159
  expectedCapacity: emitterSpec.expectedCapacity,
149
160
  castShadow: emitterSpec.castShadow ?? false,
150
161
  receiveShadow: emitterSpec.receiveShadow ?? false,
162
+ // Only offered on an actual WebGL2 negotiation - the same `isWebGL2` signal already
163
+ // picking `render`'s "standard" tier above. FXEmitter itself decides whether GPU setup
164
+ // then actually succeeds, falling back to `emitterSpec.behavior` in place if not - this
165
+ // effect never needs to know which one ends up driving a given emitter.
166
+ gpuKernel: isWebGL2 ? emitterSpec.gpuBehavior : undefined,
167
+ renderer: options.renderer,
151
168
  }, this.world);
152
169
  if (options.camera !== undefined && emitterSpec.sortInterval > 0) {
153
170
  emitter.sortCamera = options.camera;
@@ -157,11 +174,13 @@ export class FXEffect extends Group {
157
174
  this.add(emitter);
158
175
  this.emitterIndexByName.set(emitterSpec.name, this.emitters.length);
159
176
  this.emitters.push(emitter);
177
+ this.emitterRenders.push(render);
160
178
  this.emitterDriven.push(new Set());
161
179
  }
162
180
  for (const meshSpec of specification.meshes) {
163
181
  const textures = texturesForSlots(meshSpec.externalSlots, assets);
164
- const material = new FXMeshMaterial(meshSpec.render, textures);
182
+ const render = resolveRenderArtifact(meshSpec.render, isWebGL2);
183
+ const material = new FXMeshMaterial(render, textures);
165
184
  const mesh = new Mesh(resolveGeometrySource(meshSpec.geometry, geometries), material.buildThreeMaterial());
166
185
  if (meshSpec.receiveShadow ?? false) {
167
186
  mesh.receiveShadow = true;
@@ -174,6 +193,7 @@ export class FXEffect extends Group {
174
193
  this.add(mesh);
175
194
  this.meshIndexByName.set(meshSpec.name, this.meshes.length);
176
195
  this.meshes.push(mesh);
196
+ this.meshRenders.push(render);
177
197
  this.meshMaterials.push(material);
178
198
  this.meshMotionTrackers.push(new FXObjectMotionTracker());
179
199
  this.meshDriven.push(new Set());
@@ -283,7 +303,7 @@ export class FXEffect extends Group {
283
303
  const specification = this.specification.emitters[index];
284
304
  const uniforms = {};
285
305
  const bindings = {};
286
- writeParameterSlots(specification.render, specification.behavior, parameter, materializeValue(value), uniforms, bindings);
306
+ writeParameterSlots(this.emitterRenders[index], specification.behavior, parameter, materializeValue(value), uniforms, bindings);
287
307
  if (Object.keys(uniforms).length > 0 || Object.keys(bindings).length > 0) {
288
308
  this.emitters[index].applyValues({ uniforms, bindings });
289
309
  }
@@ -297,10 +317,9 @@ export class FXEffect extends Group {
297
317
  if (index === undefined) {
298
318
  return;
299
319
  }
300
- const specification = this.specification.meshes[index];
301
320
  const uniforms = {};
302
321
  const bindings = {};
303
- writeParameterSlots(specification.render, NO_BEHAVIOR_BINDINGS, parameter, materializeValue(value), uniforms, bindings);
322
+ writeParameterSlots(this.meshRenders[index], NO_BEHAVIOR_BINDINGS, parameter, materializeValue(value), uniforms, bindings);
304
323
  if (Object.keys(uniforms).length > 0) {
305
324
  this.meshMaterials[index].applyUniformValues(uniforms);
306
325
  }
@@ -353,20 +372,33 @@ export class FXEffect extends Group {
353
372
  const duration = this.specification.duration;
354
373
  let now = this.time + deltaSeconds;
355
374
  if (duration > 0 && now >= duration) {
356
- // 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);
357
380
  this.fire(this.lastTime, duration);
358
381
  for (const emitter of this.emitters) {
359
- emitter.stop();
360
- emitter.reset();
382
+ emitter.loopTimeline();
383
+ }
384
+ for (const tracker of this.meshMotionTrackers) {
385
+ tracker.reset();
361
386
  }
362
387
  now = now % duration;
363
388
  this.lastTime = 0;
364
389
  this.lastFrame = -1;
365
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);
366
395
  this.fire(this.lastTime, now);
367
396
  this.lastTime = now;
368
397
  this.time = now;
369
- 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);
370
402
  if (frame !== this.lastFrame) {
371
403
  this.lastFrame = frame;
372
404
  this.driveFrame(timeOfFrame(frame, this.specification.fps));
@@ -415,16 +447,17 @@ export class FXEffect extends Group {
415
447
  }
416
448
  driveEmitterValues(index, values) {
417
449
  const specification = this.specification.emitters[index];
450
+ const render = this.emitterRenders[index];
418
451
  const driven = this.emitterDriven[index];
419
452
  const uniforms = {};
420
453
  const bindings = {};
421
454
  for (const [name, value] of values) {
422
- writeParameterSlots(specification.render, specification.behavior, name, value, uniforms, bindings);
455
+ writeParameterSlots(render, specification.behavior, name, value, uniforms, bindings);
423
456
  driven.add(name);
424
457
  }
425
458
  for (const name of [...driven]) {
426
459
  if (!values.has(name)) {
427
- writeParameterBaseline(specification.render, specification.behavior, name, uniforms, bindings);
460
+ writeParameterBaseline(render, specification.behavior, name, uniforms, bindings);
428
461
  driven.delete(name);
429
462
  }
430
463
  }
@@ -433,17 +466,17 @@ export class FXEffect extends Group {
433
466
  }
434
467
  }
435
468
  driveMeshValues(index, values) {
436
- const specification = this.specification.meshes[index];
469
+ const render = this.meshRenders[index];
437
470
  const driven = this.meshDriven[index];
438
471
  const uniforms = {};
439
472
  const bindings = {};
440
473
  for (const [name, value] of values) {
441
- writeParameterSlots(specification.render, NO_BEHAVIOR_BINDINGS, name, value, uniforms, bindings);
474
+ writeParameterSlots(render, NO_BEHAVIOR_BINDINGS, name, value, uniforms, bindings);
442
475
  driven.add(name);
443
476
  }
444
477
  for (const name of [...driven]) {
445
478
  if (!values.has(name)) {
446
- writeParameterBaseline(specification.render, NO_BEHAVIOR_BINDINGS, name, uniforms, bindings);
479
+ writeParameterBaseline(render, NO_BEHAVIOR_BINDINGS, name, uniforms, bindings);
447
480
  driven.delete(name);
448
481
  }
449
482
  }
@@ -1,5 +1,5 @@
1
- import type { Camera } from "three";
2
- import type { FXBehaviorArtifact, FXGeometrySource, FXRenderArtifact } from "../artifact/FXArtifact.js";
1
+ import type { Camera, WebGLRenderer } from "three";
2
+ import type { FXBehaviorArtifact, FXGeometrySource, FXParticleKernelArtifact, FXRenderArtifactsByGLSLTier } from "../artifact/FXArtifact.js";
3
3
  import type { FXWorld } from "../world/FXWorld.js";
4
4
  /**
5
5
  * Data shapes for {@link FXEffect}: a whole exported project (every emitter + VFX mesh, its
@@ -48,8 +48,19 @@ export type FXEffectEvent = {
48
48
  export interface FXEffectEmitterSpec {
49
49
  /** The editor-authored name, addressing this emitter through {@link FXEffect.getEmitter} etc. */
50
50
  readonly name: string;
51
- readonly render: FXRenderArtifact;
51
+ readonly render: FXRenderArtifactsByGLSLTier;
52
52
  readonly behavior: FXBehaviorArtifact;
53
+ /**
54
+ * The standard-tier (WebGL2, transform-feedback) behavior artifact - present only when this
55
+ * emitter's spawn node had "Try GPU simulation" on and the graph compiled to GLSL. `behavior`
56
+ * above is always present regardless - the mandatory JS fallback, including for the
57
+ * WebGL2->WebGL1 emergency downgrade - so a spec is never left with only this field and no JS
58
+ * twin. {@link FXEffect} passes this to
59
+ * `FXEmitter.fromArtifacts` only when the live renderer actually negotiated WebGL2 (the same
60
+ * `isWebGL2` signal that already picks `render`'s "standard" tier); the runtime decides for
61
+ * itself whether GPU setup then actually succeeds, falling back to `behavior` in place if not.
62
+ */
63
+ readonly gpuBehavior?: FXParticleKernelArtifact;
53
64
  readonly expectedCapacity: number;
54
65
  readonly sortInterval: number;
55
66
  /** Casts a shape-aware shadow; omitted (treated as false) by pre-shadow exports. */
@@ -72,7 +83,7 @@ export interface FXEffectEmitterSpec {
72
83
  export interface FXEffectMeshSpec {
73
84
  /** The editor-authored name, addressing this mesh through {@link FXEffect.getMesh} etc. */
74
85
  readonly name: string;
75
- readonly render: FXRenderArtifact;
86
+ readonly render: FXRenderArtifactsByGLSLTier;
76
87
  readonly geometry: FXGeometrySource;
77
88
  /** See {@link FXEffectEmitterSpec.castShadow}. */
78
89
  readonly castShadow?: boolean;
@@ -112,4 +123,10 @@ export interface FXEffectOptions {
112
123
  * own clock and particle pool (an independent time scale, or a group you tick separately).
113
124
  */
114
125
  readonly world?: FXWorld;
126
+ /**
127
+ * The renderer this effect will draw through: `renderer.capabilities.isWebGL2` picks each
128
+ * emitter/mesh's `"standard"` or `"baseline"` artifact (see {@link FXRenderArtifactsByGLSLTier}).
129
+ * Omit to get the `"baseline"` artifact unconditionally (the pre-split behavior).
130
+ */
131
+ readonly renderer?: WebGLRenderer;
115
132
  }
@@ -0,0 +1,15 @@
1
+ import type { BufferGeometry, Texture } from "three";
2
+ /**
3
+ * Options for {@link FXEmitter.applyRenderArtifact}: the render-only subset of
4
+ * {@link FXFromArtifactsOptions} - no `expectedCapacity`/`capacityStep`/`gpuKernel`/`renderer`,
5
+ * since none of those can change without a full {@link FXEmitter.fromArtifacts} rebuild. The app
6
+ * owns `textures`/`geometries`' lifecycle; the runtime never disposes them.
7
+ */
8
+ export interface FXApplyRenderArtifactOptions {
9
+ readonly textures?: Record<string, Texture>;
10
+ readonly geometries?: Record<string, BufferGeometry>;
11
+ /** default false */
12
+ readonly receiveShadow?: boolean;
13
+ /** Casts a shape-aware shadow (a customDepthMaterial built from the new render artifact). default false */
14
+ readonly castShadow?: boolean;
15
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,6 +1,7 @@
1
1
  import type { Camera } from "three";
2
2
  import { Object3D } from "three";
3
3
  import type { FXBehaviorArtifact, FXRenderArtifact } from "../artifact/FXArtifact.js";
4
+ import type { FXApplyRenderArtifactOptions } from "./FXApplyRenderArtifactOptions.js";
4
5
  import type { FXFromArtifactsOptions } from "./FXFromArtifactsOptions.js";
5
6
  import { FXWorld } from "../world/FXWorld.js";
6
7
  import type { FXApplyValues, FXEmitterBurstOptions, FXEmitterPlayOptions } from "./FXEmitter.Internal.js";
@@ -10,6 +11,12 @@ import type { FXApplyValues, FXEmitterBurstOptions, FXEmitterPlayOptions } from
10
11
  * Three material. Built and ticked by {@link FXEffect} through an {@link FXWorld}. Not exported from
11
12
  * the package (the runtime path is running artifacts, not hand-building emitters); reached only as
12
13
  * the live handle returned by {@link FXEffect.getEmitter}.
14
+ *
15
+ * A thin facade over one {@link FXEmitterDriver} (JS or GPU - see `FXEmitterDriver.Internal.ts`),
16
+ * chosen once by {@link fromArtifacts} and never switched mid-playback. This class itself owns only
17
+ * what is genuinely backend-independent: play/burst/stop/prewarm scheduling, sort-fraction timing,
18
+ * object motion tracking, and the render material - the material itself CAN be replaced in place,
19
+ * by {@link applyRenderArtifact}, without touching the driver or its particle state.
13
20
  */
14
21
  export declare class FXEmitter extends Object3D {
15
22
  /** Camera for back-to-front depth sorting; `undefined` disables it. Distances are world-space, so any emitter/ancestor transform is handled correctly. */
@@ -17,10 +24,8 @@ export declare class FXEmitter extends Object3D {
17
24
  /** Fraction of frames sorting runs on (`1` = every frame, `0.1` = ~every 10th); defaults to `0.1`. */
18
25
  sortFraction: number;
19
26
  private readonly world;
20
- private readonly mesh;
21
- private readonly material;
22
- private readonly simulation?;
23
- private readonly collectedProperties;
27
+ private readonly driver;
28
+ private artifactMaterial;
24
29
  private nextHandler;
25
30
  private readonly pendingBursts;
26
31
  private readonly activePlays;
@@ -32,10 +37,13 @@ export declare class FXEmitter extends Object3D {
32
37
  private readonly motionScaleScratch;
33
38
  private readonly motionTracker;
34
39
  private destroyed;
35
- private kernelBuffersCache?;
36
- private kernelBuffersCacheVersion;
40
+ /** Exact live count for a JS-driven emitter; for a GPU-driven one, there is no CPU-visible live
41
+ * count to report - `capacity` is the honest upper bound reported instead. */
37
42
  get particleCount(): number;
43
+ /** The buffer's current allocated size - grows over time for a JS-driven emitter, fixed at
44
+ * construction for a GPU-driven one (no capacityStep growth path there). */
38
45
  get particleCapacity(): number;
46
+ private get mesh();
39
47
  /**
40
48
  * Builds an emitter into `world` from a precompiled effect - the low-level building block behind
41
49
  * {@link FXEffect}. Exposed for editor tooling through the `sparcoon/editor` entry (live preview
@@ -46,7 +54,10 @@ export declare class FXEmitter extends Object3D {
46
54
  /** Unregisters, removes from the scene, disposes mesh and material. Idempotent. */
47
55
  destroy(): void;
48
56
  /**
49
- * 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.
50
61
  * @returns Handler for {@link stop} to cancel a pending delayed burst.
51
62
  */
52
63
  burst(count: number, options?: Partial<FXEmitterBurstOptions>): number;
@@ -65,8 +76,22 @@ export declare class FXEmitter extends Object3D {
65
76
  * @param stepDuration - Maximum step size in seconds. Defaults to `1/60`.
66
77
  */
67
78
  prewarm(duration: number, stepDuration?: number): void;
68
- /** Kills all live particles and cancels all active plays and pending bursts. */
79
+ /**
80
+ * Kills all live particles and cancels all active plays and pending bursts. For a GPU-driven
81
+ * emitter this is a known-narrower reset (see the mesh's own `drop()` doc comment): there is no
82
+ * CPU-visible per-particle state to instantly zero, so already-alive particles keep aging out
83
+ * naturally rather than vanishing on this call's own frame - only the spawn cursor and pending
84
+ * schedule reset immediately.
85
+ */
69
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;
70
95
  /**
71
96
  * Scrubs live parameter values into uniform/binding slots by name, picked up next frame - the
72
97
  * runtime's value channel for a non-structural edit. Unknown name = safe no-op; never recompiles.
@@ -74,16 +99,21 @@ export declare class FXEmitter extends Object3D {
74
99
  */
75
100
  applyValues(values: FXApplyValues): void;
76
101
  /**
77
- * Merged attribute set (name -> component count): union of the behavior's writes and the render's
78
- * reads. Throws on a cross-artifact width disagreement rather than sizing a buffer wrong.
102
+ * Swaps only this emitter's render half in place - a new shader, geometry primitive/mesh, or
103
+ * shadow flags - leaving the behavior driver, particle buffers, instance count, and playback
104
+ * schedule completely untouched (unlike a structural edit that reaches {@link fromArtifacts},
105
+ * this never resets a running effect). The editor calls this instead of rebuilding whenever a
106
+ * graph edit only changed the render half.
107
+ *
108
+ * @throws If the new render artifact's attribute reads disagree with this emitter's already-
109
+ * allocated buffers (a genuine layout change - see {@link FXEmitterDriver.applyRenderArtifact}).
110
+ * This emitter is left exactly as it was before the call in that case; the caller must fall back
111
+ * to a fresh {@link fromArtifacts} instead, since only a new driver can safely resize buffers.
79
112
  */
80
- private attributeComponents;
81
- private buildCollectedProperties;
113
+ applyRenderArtifact(render: FXRenderArtifact, options?: FXApplyRenderArtifactOptions): void;
82
114
  private setSortFraction;
83
115
  private assertCanSpawn;
84
- private kernelBuffers;
85
116
  private worldTransform;
86
- private markBuffersNeedUpdate;
87
117
  private tick;
88
118
  private readonly onRendering;
89
119
  private spawnBurst;