sparcoon 0.9.0 → 0.9.2

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 (41) hide show
  1. package/README.md +11 -6
  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 +9 -0
  8. package/dist/editor/index.js +17 -0
  9. package/dist/effect/FXEffect.d.ts +2 -0
  10. package/dist/effect/FXEffect.js +30 -10
  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 +42 -13
  15. package/dist/emitter/FXEmitter.js +113 -152
  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 +181 -0
  26. package/dist/index.d.ts +5 -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/instancedParticle/primitiveGeometry.d.ts +10 -1
  32. package/dist/instancedParticle/primitiveGeometry.js +1 -1
  33. package/dist/miscellaneous/fxMath.d.ts +18 -11
  34. package/dist/miscellaneous/fxMath.js +89 -209
  35. package/dist/render/FXArtifactMaterial.Internal.js +66 -40
  36. package/dist/render/FXArtifactMaterial.js +1 -1
  37. package/dist/render/FXMesh.d.ts +47 -0
  38. package/dist/render/FXMesh.js +97 -0
  39. package/dist/world/FXWorld.d.ts +27 -17
  40. package/dist/world/FXWorld.js +32 -31
  41. package/package.json +5 -1
package/README.md CHANGED
@@ -13,7 +13,8 @@ ships to your app.
13
13
  ## Features
14
14
 
15
15
  - Instanced billboard and 3D-mesh particles in a single draw call.
16
- - Per-particle simulation from a precompiled kernel -- no `eval`, no codegen.
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.
17
18
  - Whole-project playback: an editor module subclasses `FXEffect`.
18
19
  - One `FXWorld.update` per frame drives every effect.
19
20
  - Optional depth sorting, shadows, and custom-geometry particles.
@@ -102,10 +103,14 @@ give it its own world. `world.dispose()` disposes every effect in it.
102
103
 
103
104
  The **Sparcoon Editor** compiles a node graph into a plain ES module exporting
104
105
  a render artifact (GLSL, uniforms, textures) and a behavior artifact (authored
105
- spawn/update functions). This runtime executes them -- your bundler compiles
106
- the authored functions like any other source, so nothing is evaluated at
107
- runtime. The editor/runtime boundary is a frozen ABI: two core per-particle
108
- buffers, `position` and `lifecycle` (`[age, lifetime]`).
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]`).
109
114
 
110
115
  ## Requirements
111
116
 
@@ -126,7 +131,7 @@ buffers, `position` and `lifecycle` (`[age, lifetime]`).
126
131
  - `FXWorld` -- the tick domain. `FXWorld.update(deltaTime)` per frame drives the
127
132
  default world; `new FXWorld()` is an isolated clock and pool.
128
133
  - Artifact contract types, the `FX_*` core-layout constants, CPU math helpers
129
- (`fxMix`, `fxSnoise3`, ...), and `fxDataTexture`.
134
+ (`fxMix`, `fxNoise3`, ...), and `fxDataTexture`.
130
135
 
131
136
  ## License
132
137
 
@@ -3,9 +3,11 @@ import type { FXGraphLambertMaterialOptions, FXGraphUnlitMaterialOptions } from
3
3
  /**
4
4
  * Frozen editor<->runtime boundary. The editor compiles a graph into a plain ESM module that
5
5
  * exports an {@link FXRenderArtifact} (GLSL + uniforms + textures) and an {@link FXBehaviorArtifact}
6
- * (authored spawn/update functions + bindings + buffer layout); the runtime only executes them.
7
- * Runtime-owned and exported; no code generation, no eval (the app's bundler compiles the authored
8
- * functions). Every value scrub must no-op on an unknown name so a name drift never corrupts values.
6
+ * (authored spawn/update functions + bindings + buffer layout), plus an optional
7
+ * {@link FXParticleKernelArtifact} (a fused GPU transform-feedback program) when the graph opts into
8
+ * GPU simulation; the runtime only executes them. Runtime-owned and exported; no code generation, no
9
+ * eval (the app's bundler compiles the authored JS functions, the editor precompiles the GLSL).
10
+ * Every value scrub must no-op on an unknown name so a name drift never corrupts values.
9
11
  */
10
12
  /** One state buffer: name + per-particle stride in floats. */
11
13
  export interface FXBufferLayout {
@@ -98,6 +100,23 @@ export interface FXRenderArtifact {
98
100
  /** Attributes the shader reads (`p_fx_<name>`); the emitter merges these with the sim's write set. */
99
101
  readonly attributeReads: readonly FXAttributeDecl[];
100
102
  }
103
+ /**
104
+ * Which GLSL-family compiler tier produced a compiled {@link FXRenderArtifact}: `"baseline"` is
105
+ * the WebGL1/GLSL-ES-1.00-style compiler; `"standard"` is the WebGL2/GLSL-ES-3.00-only compiler
106
+ * (may use int/ivec and other standard-only capabilities a `"baseline"` graph cannot). Both
107
+ * compile to the same GLSL-text artifact shape and splice through the same material builders
108
+ * unchanged - Three already upgrades any material to `#version 300 es` (with full
109
+ * `attribute`/`varying`/`gl_FragColor` backward-compat macros) on an actual WebGL2 context,
110
+ * regardless of tier, so the runtime needs no declaration-style distinction between the two.
111
+ *
112
+ * Deliberately scoped to the GLSL family in its name: a structurally different family (the future
113
+ * advanced tier - a WGSL/WebGPU compiler, whose artifact has no vertex/fragment GLSL text at all)
114
+ * would need its own sibling type and its own field on the emitter/mesh spec, never a third member
115
+ * of this union or a third key in {@link FXRenderArtifactsByGLSLTier}.
116
+ */
117
+ export type FXGLSLRenderTier = "baseline" | "standard";
118
+ /** One emitter/mesh's render artifact, precompiled once per GLSL tier the editor supports. */
119
+ export type FXRenderArtifactsByGLSLTier = Readonly<Record<FXGLSLRenderTier, FXRenderArtifact>>;
101
120
  /**
102
121
  * Behavior half of an emitted effect: authored spawn/update functions mutating the packed state
103
122
  * buffers, plus buffer layout and live bindings. Math helpers are inlined, so the artifact carries
@@ -118,3 +137,32 @@ export interface FXBehaviorArtifact {
118
137
  /** Advances all live particles `[0, count)`. */
119
138
  update(buffers: FXKernelBuffers, count: number, deltaTime: number, bindings: Record<string, FXValueSlot<number | Float32Array>>, emitter?: FXEmitterTransform): void;
120
139
  }
140
+ /**
141
+ * Standard-tier (WebGL2, transform-feedback) behavior half of an emitted effect - a sibling of
142
+ * {@link FXBehaviorArtifact}, not a variant of it: a compiled GLSL program has no JS function
143
+ * shape to reuse. One fused GLSL ES 3.00 vertex-shader program handles both spawn and update in a
144
+ * single draw, branching per-invocation on the runtime-supplied spawn range (see
145
+ * `behaviorTransformFeedbackLayout.ts`'s uniform-name contract) - never two separate programs,
146
+ * since transform feedback cannot read and write the same buffer in one pass, and splitting the
147
+ * update range around a wrapping spawn cursor compounds badly with GPU-side buffer offsets.
148
+ *
149
+ * Optional per emitter/mesh spec: present only when the graph's spawn node had "Try GPU
150
+ * simulation" on and the graph actually compiled to GLSL; every emitter always has a matching
151
+ * {@link FXBehaviorArtifact} too (the mandatory JS fallback, including for the WebGL2->WebGL1
152
+ * emergency downgrade), so the runtime is never left with only this artifact and no JS twin.
153
+ */
154
+ export interface FXParticleKernelArtifact {
155
+ readonly vertexSource: string;
156
+ readonly fragmentSource: string;
157
+ /** State buffers this program's `in`/`out` pairs cover, in declaration order - a buffer's index
158
+ * here IS its `layout(location = N)` attribute index in {@link vertexSource}; the runtime binds
159
+ * by that index, never by looking an attribute up by name. */
160
+ readonly buffers: readonly FXBufferLayout[];
161
+ /** Varying names for `gl.transformFeedbackVaryings`, one per {@link buffers} entry, same order. */
162
+ readonly transformFeedbackVaryings: readonly string[];
163
+ /** Live-tunable uniform values (graph-authored params only - the fixed contract uniforms in
164
+ * `behaviorTransformFeedbackLayout.ts` are always declared and are the runtime's own
165
+ * responsibility to set every tick, not part of this map). Float32Array is reserved for a
166
+ * future sampler2D-backed (LUT) uniform; no standard-tier node produces one yet. */
167
+ readonly bindings: Record<string, FXValueSlot<number | Float32Array>>;
168
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Frozen transform-feedback (WebGL2, standard-tier behavior) uniform-name contract, shared by the
3
+ * editor's standard-tier behavior assembler (`FXKernelBuildStandard.Internal.ts`) and the
4
+ * runtime's transform-feedback driver. Every fused spawn+update program declares all of these
5
+ * uniforms unconditionally, regardless of whether a given graph actually reads them - fixed,
6
+ * literal names both sides import instead of independently deriving, the same reasoning as
7
+ * `coreLayout.ts`'s `PARTICLE_AGE`-style macros. Dependency-free (the editor imports this too), so
8
+ * keep it three-free and portable.
9
+ *
10
+ * Buffer-backed `in` attributes and `out` (transform-feedback) varyings are NOT listed here: a
11
+ * compiled program binds attributes by explicit `layout(location = N)` index (N = the buffer's
12
+ * position in the artifact's own `buffers` array), and transform-feedback varying names travel
13
+ * with the artifact itself (`FXParticleKernelArtifact.transformFeedbackVaryings`) - neither needs
14
+ * a shared naming scheme, only these truly fixed, graph-independent uniforms do.
15
+ */
16
+ export declare const FX_TRANSFORM_FEEDBACK_SPAWN_RANGE_START_UNIFORM = "u_fxSpawnRangeStart";
17
+ export declare const FX_TRANSFORM_FEEDBACK_SPAWN_RANGE_COUNT_UNIFORM = "u_fxSpawnRangeCount";
18
+ export declare const FX_TRANSFORM_FEEDBACK_SPAWN_ID_BASE_UNIFORM = "u_fxSpawnIdBase";
19
+ export declare const FX_TRANSFORM_FEEDBACK_CAPACITY_UNIFORM = "u_fxCapacity";
20
+ export declare const FX_TRANSFORM_FEEDBACK_RAND_SEED_UNIFORM = "u_fxRandSeed";
21
+ export declare const FX_TRANSFORM_FEEDBACK_DELTA_TIME_UNIFORM = "u_fxDt";
22
+ export declare const FX_TRANSFORM_FEEDBACK_MODEL_MATRIX_UNIFORM = "u_fxModelMatrix";
23
+ export declare const FX_TRANSFORM_FEEDBACK_OBJECT_VELOCITY_UNIFORM = "u_fxObjectVelocity";
24
+ export declare const FX_TRANSFORM_FEEDBACK_OBJECT_ANGULAR_VELOCITY_UNIFORM = "u_fxObjectAngularVelocity";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Frozen transform-feedback (WebGL2, standard-tier behavior) uniform-name contract, shared by the
3
+ * editor's standard-tier behavior assembler (`FXKernelBuildStandard.Internal.ts`) and the
4
+ * runtime's transform-feedback driver. Every fused spawn+update program declares all of these
5
+ * uniforms unconditionally, regardless of whether a given graph actually reads them - fixed,
6
+ * literal names both sides import instead of independently deriving, the same reasoning as
7
+ * `coreLayout.ts`'s `PARTICLE_AGE`-style macros. Dependency-free (the editor imports this too), so
8
+ * keep it three-free and portable.
9
+ *
10
+ * Buffer-backed `in` attributes and `out` (transform-feedback) varyings are NOT listed here: a
11
+ * compiled program binds attributes by explicit `layout(location = N)` index (N = the buffer's
12
+ * position in the artifact's own `buffers` array), and transform-feedback varying names travel
13
+ * with the artifact itself (`FXParticleKernelArtifact.transformFeedbackVaryings`) - neither needs
14
+ * a shared naming scheme, only these truly fixed, graph-independent uniforms do.
15
+ */
16
+ export const FX_TRANSFORM_FEEDBACK_SPAWN_RANGE_START_UNIFORM = "u_fxSpawnRangeStart";
17
+ export const FX_TRANSFORM_FEEDBACK_SPAWN_RANGE_COUNT_UNIFORM = "u_fxSpawnRangeCount";
18
+ export const FX_TRANSFORM_FEEDBACK_SPAWN_ID_BASE_UNIFORM = "u_fxSpawnIdBase";
19
+ export const FX_TRANSFORM_FEEDBACK_CAPACITY_UNIFORM = "u_fxCapacity";
20
+ export const FX_TRANSFORM_FEEDBACK_RAND_SEED_UNIFORM = "u_fxRandSeed";
21
+ export const FX_TRANSFORM_FEEDBACK_DELTA_TIME_UNIFORM = "u_fxDt";
22
+ export const FX_TRANSFORM_FEEDBACK_MODEL_MATRIX_UNIFORM = "u_fxModelMatrix";
23
+ export const FX_TRANSFORM_FEEDBACK_OBJECT_VELOCITY_UNIFORM = "u_fxObjectVelocity";
24
+ export const FX_TRANSFORM_FEEDBACK_OBJECT_ANGULAR_VELOCITY_UNIFORM = "u_fxObjectAngularVelocity";
@@ -1,22 +1,26 @@
1
1
  /**
2
2
  * Frozen per-particle ABI shared by the runtime loop and every editor-emitted module.
3
- * Two GPU-mirrored core buffers only: `position` (vec3) and `lifecycle` (vec2 [age, lifetime]);
3
+ * Two GPU-mirrored core buffers only: `position` (vec3) and `lifecycle` (vec3 [age, lifetime, id]);
4
4
  * a particle is dead once `age >= lifetime`. Everything else (velocity, scale, ...) is an ordinary
5
5
  * node-declared `a_fx_<name>` attribute, not core.
6
6
  *
7
- * Split into two buffers because a Three instanced attribute is at most a vec4 - a 5-float core
8
- * cannot be one attribute, whereas vec3 + vec2 are two clean varyings. Dependency-free (the editor's
7
+ * Split into two buffers because a Three instanced attribute is at most a vec4 - a 6-float core
8
+ * cannot be one attribute, whereas vec3 + vec3 are two clean varyings. Dependency-free (the editor's
9
9
  * module emitter imports this too), so keep it three-free and portable.
10
10
  */
11
11
  export declare const FX_CORE_POSITION = "position";
12
12
  export declare const FX_CORE_LIFECYCLE = "lifecycle";
13
13
  export declare const FX_CORE_POSITION_STRIDE = 3;
14
- export declare const FX_CORE_LIFECYCLE_STRIDE = 2;
14
+ export declare const FX_CORE_LIFECYCLE_STRIDE = 3;
15
15
  export declare const FX_POSITION_X = 0;
16
16
  export declare const FX_POSITION_Y = 1;
17
17
  export declare const FX_POSITION_Z = 2;
18
18
  export declare const FX_AGE = 0;
19
19
  export declare const FX_LIFETIME = 1;
20
+ export declare const FX_ID = 2;
20
21
  export declare const FX_CORE_POSITION_VARYING = "p_position";
21
22
  export declare const FX_CORE_LIFECYCLE_VARYING = "p_lifecycle";
22
- export declare const FX_CORE_PARTICLE_DEFINES = "\n #define PARTICLE_POSITION_X p_position.x\n #define PARTICLE_POSITION_Y p_position.y\n #define PARTICLE_POSITION_Z p_position.z\n #define PARTICLE_POSITION p_position\n\n #define PARTICLE_AGE p_lifecycle.x\n #define PARTICLE_LIFETIME p_lifecycle.y\n";
23
+ export declare const ATTRIBUTE_BUFFER_PREFIX = "fx_";
24
+ export declare function isCoreBufferName(bufferName: string): boolean;
25
+ export declare function meshPropertyKeyFor(bufferName: string): string;
26
+ export declare const FX_CORE_PARTICLE_DEFINES = "\n #define PARTICLE_POSITION_X p_position.x\n #define PARTICLE_POSITION_Y p_position.y\n #define PARTICLE_POSITION_Z p_position.z\n #define PARTICLE_POSITION p_position\n\n #define PARTICLE_AGE p_lifecycle.x\n #define PARTICLE_LIFETIME p_lifecycle.y\n #define PARTICLE_ID p_lifecycle.z\n";
@@ -1,24 +1,36 @@
1
1
  /**
2
2
  * Frozen per-particle ABI shared by the runtime loop and every editor-emitted module.
3
- * Two GPU-mirrored core buffers only: `position` (vec3) and `lifecycle` (vec2 [age, lifetime]);
3
+ * Two GPU-mirrored core buffers only: `position` (vec3) and `lifecycle` (vec3 [age, lifetime, id]);
4
4
  * a particle is dead once `age >= lifetime`. Everything else (velocity, scale, ...) is an ordinary
5
5
  * node-declared `a_fx_<name>` attribute, not core.
6
6
  *
7
- * Split into two buffers because a Three instanced attribute is at most a vec4 - a 5-float core
8
- * cannot be one attribute, whereas vec3 + vec2 are two clean varyings. Dependency-free (the editor's
7
+ * Split into two buffers because a Three instanced attribute is at most a vec4 - a 6-float core
8
+ * cannot be one attribute, whereas vec3 + vec3 are two clean varyings. Dependency-free (the editor's
9
9
  * module emitter imports this too), so keep it three-free and portable.
10
10
  */
11
11
  export const FX_CORE_POSITION = "position";
12
12
  export const FX_CORE_LIFECYCLE = "lifecycle";
13
13
  export const FX_CORE_POSITION_STRIDE = 3;
14
- export const FX_CORE_LIFECYCLE_STRIDE = 2;
14
+ export const FX_CORE_LIFECYCLE_STRIDE = 3;
15
15
  export const FX_POSITION_X = 0;
16
16
  export const FX_POSITION_Y = 1;
17
17
  export const FX_POSITION_Z = 2;
18
18
  export const FX_AGE = 0;
19
19
  export const FX_LIFETIME = 1;
20
+ export const FX_ID = 2;
20
21
  export const FX_CORE_POSITION_VARYING = "p_position";
21
22
  export const FX_CORE_LIFECYCLE_VARYING = "p_lifecycle";
23
+ // A kernel-compiled buffer name (e.g. "velocity", as authored/compiled - always unprefixed) to its
24
+ // mesh property-buffer key: unchanged for the two core buffers, `fx_<name>` for every attribute.
25
+ // The one place this mapping is defined - every mesh/simulation-holder pairing (JS or GPU) must
26
+ // go through this, not re-derive the prefix locally, or the two sides can silently drift apart.
27
+ export const ATTRIBUTE_BUFFER_PREFIX = "fx_";
28
+ export function isCoreBufferName(bufferName) {
29
+ return bufferName === FX_CORE_POSITION || bufferName === FX_CORE_LIFECYCLE;
30
+ }
31
+ export function meshPropertyKeyFor(bufferName) {
32
+ return isCoreBufferName(bufferName) ? bufferName : `${ATTRIBUTE_BUFFER_PREFIX}${bufferName}`;
33
+ }
22
34
  // Stable macro names so emitted render GLSL reads PARTICLE_POSITION_X / PARTICLE_AGE etc. instead
23
35
  // of hardcoding the varying names this module owns.
24
36
  export const FX_CORE_PARTICLE_DEFINES = `
@@ -29,4 +41,5 @@ export const FX_CORE_PARTICLE_DEFINES = `
29
41
 
30
42
  #define PARTICLE_AGE ${FX_CORE_LIFECYCLE_VARYING}.x
31
43
  #define PARTICLE_LIFETIME ${FX_CORE_LIFECYCLE_VARYING}.y
44
+ #define PARTICLE_ID ${FX_CORE_LIFECYCLE_VARYING}.z
32
45
  `;
@@ -0,0 +1,9 @@
1
+ export { FXEmitter } from "../emitter/FXEmitter.js";
2
+ export type { FXFromArtifactsOptions } from "../emitter/FXFromArtifactsOptions.js";
3
+ export type { FXApplyRenderArtifactOptions } from "../emitter/FXApplyRenderArtifactOptions.js";
4
+ export type { FXApplyValues, FXEmitterOptions, FXEmitterBurstOptions, FXEmitterPlayOptions, } from "../emitter/FXEmitter.Internal.js";
5
+ export { FXMesh } from "../render/FXMesh.js";
6
+ export type { FXFromMeshArtifactOptions } from "../render/FXMesh.js";
7
+ export { FXMeshMaterial } from "../render/FXMeshMaterial.js";
8
+ export { resolveGeometrySource } from "../instancedParticle/primitiveGeometry.js";
9
+ export { frameOfTime, keyframeSegment, normalizeQuaternion, quaternionFromValue, sampleTrack, sampleTracks, sampleTransform, slerpQuaternion, timeOfFrame, vectorFromValue, } from "../effect/FXEffectSampling.Internal.js";
@@ -0,0 +1,17 @@
1
+ // Editor-tooling surface: `import ... from "sparcoon/editor"`.
2
+ //
3
+ // UNSTABLE - no semver guarantees. These are the low-level building blocks the Sparcoon Editor's
4
+ // live preview needs (rebuild a single emitter/mesh from freshly compiled artifacts on every graph
5
+ // edit), plus the keyframe-sampling math the editor and runtime must share so a baked preview and
6
+ // runtime playback never drift. Regular game/scene hosts use the main "sparcoon" entry
7
+ // (FXEffect + FXWorld); they should not reach for anything here.
8
+ export { FXEmitter } from "../emitter/FXEmitter.js";
9
+ // Self-contained VFX mesh (the mesh twin of FXEmitter): build one from a render artifact and let
10
+ // FXWorld.update drive it, including the object velocity/angular velocity a mesh render graph reads.
11
+ export { FXMesh } from "../render/FXMesh.js";
12
+ // Lower-level pieces FXMesh is built from - kept for tooling that assembles a mesh by hand.
13
+ export { FXMeshMaterial } from "../render/FXMeshMaterial.js";
14
+ export { resolveGeometrySource } from "../instancedParticle/primitiveGeometry.js";
15
+ // Shared keyframe/transform sampling: the editor bakes keyframes and the runtime samples them, so
16
+ // both sides must sample identically - a frozen contract, never a one-sided edit.
17
+ export { frameOfTime, keyframeSegment, normalizeQuaternion, quaternionFromValue, sampleTrack, sampleTracks, sampleTransform, slerpQuaternion, timeOfFrame, vectorFromValue, } from "../effect/FXEffectSampling.Internal.js";
@@ -18,6 +18,8 @@ export declare abstract class FXEffect extends Group {
18
18
  private readonly specification;
19
19
  private readonly world;
20
20
  private readonly emitters;
21
+ private readonly emitterRenders;
22
+ private readonly meshRenders;
21
23
  private readonly meshMaterials;
22
24
  private readonly meshes;
23
25
  private readonly meshMotionTrackers;
@@ -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
  }
@@ -415,16 +434,17 @@ export class FXEffect extends Group {
415
434
  }
416
435
  driveEmitterValues(index, values) {
417
436
  const specification = this.specification.emitters[index];
437
+ const render = this.emitterRenders[index];
418
438
  const driven = this.emitterDriven[index];
419
439
  const uniforms = {};
420
440
  const bindings = {};
421
441
  for (const [name, value] of values) {
422
- writeParameterSlots(specification.render, specification.behavior, name, value, uniforms, bindings);
442
+ writeParameterSlots(render, specification.behavior, name, value, uniforms, bindings);
423
443
  driven.add(name);
424
444
  }
425
445
  for (const name of [...driven]) {
426
446
  if (!values.has(name)) {
427
- writeParameterBaseline(specification.render, specification.behavior, name, uniforms, bindings);
447
+ writeParameterBaseline(render, specification.behavior, name, uniforms, bindings);
428
448
  driven.delete(name);
429
449
  }
430
450
  }
@@ -433,17 +453,17 @@ export class FXEffect extends Group {
433
453
  }
434
454
  }
435
455
  driveMeshValues(index, values) {
436
- const specification = this.specification.meshes[index];
456
+ const render = this.meshRenders[index];
437
457
  const driven = this.meshDriven[index];
438
458
  const uniforms = {};
439
459
  const bindings = {};
440
460
  for (const [name, value] of values) {
441
- writeParameterSlots(specification.render, NO_BEHAVIOR_BINDINGS, name, value, uniforms, bindings);
461
+ writeParameterSlots(render, NO_BEHAVIOR_BINDINGS, name, value, uniforms, bindings);
442
462
  driven.add(name);
443
463
  }
444
464
  for (const name of [...driven]) {
445
465
  if (!values.has(name)) {
446
- writeParameterBaseline(specification.render, NO_BEHAVIOR_BINDINGS, name, uniforms, bindings);
466
+ writeParameterBaseline(render, NO_BEHAVIOR_BINDINGS, name, uniforms, bindings);
447
467
  driven.delete(name);
448
468
  }
449
469
  }
@@ -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,5 +1,9 @@
1
1
  import type { Camera } from "three";
2
2
  import { Object3D } from "three";
3
+ import type { FXBehaviorArtifact, FXRenderArtifact } from "../artifact/FXArtifact.js";
4
+ import type { FXApplyRenderArtifactOptions } from "./FXApplyRenderArtifactOptions.js";
5
+ import type { FXFromArtifactsOptions } from "./FXFromArtifactsOptions.js";
6
+ import { FXWorld } from "../world/FXWorld.js";
3
7
  import type { FXApplyValues, FXEmitterBurstOptions, FXEmitterPlayOptions } from "./FXEmitter.Internal.js";
4
8
  /**
5
9
  * Particle emitter - one instanced billboard/mesh draw call, per-particle state driven by a
@@ -7,6 +11,12 @@ import type { FXApplyValues, FXEmitterBurstOptions, FXEmitterPlayOptions } from
7
11
  * Three material. Built and ticked by {@link FXEffect} through an {@link FXWorld}. Not exported from
8
12
  * the package (the runtime path is running artifacts, not hand-building emitters); reached only as
9
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.
10
20
  */
11
21
  export declare class FXEmitter extends Object3D {
12
22
  /** Camera for back-to-front depth sorting; `undefined` disables it. Distances are world-space, so any emitter/ancestor transform is handled correctly. */
@@ -14,10 +24,8 @@ export declare class FXEmitter extends Object3D {
14
24
  /** Fraction of frames sorting runs on (`1` = every frame, `0.1` = ~every 10th); defaults to `0.1`. */
15
25
  sortFraction: number;
16
26
  private readonly world;
17
- private readonly mesh;
18
- private readonly material;
19
- private readonly simulation?;
20
- private readonly collectedProperties;
27
+ private readonly driver;
28
+ private material;
21
29
  private nextHandler;
22
30
  private readonly pendingBursts;
23
31
  private readonly activePlays;
@@ -29,10 +37,20 @@ export declare class FXEmitter extends Object3D {
29
37
  private readonly motionScaleScratch;
30
38
  private readonly motionTracker;
31
39
  private destroyed;
32
- private kernelBuffersCache?;
33
- 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. */
34
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). */
35
45
  get particleCapacity(): number;
46
+ private get mesh();
47
+ /**
48
+ * Builds an emitter into `world` from a precompiled effect - the low-level building block behind
49
+ * {@link FXEffect}. Exposed for editor tooling through the `sparcoon/editor` entry (live preview
50
+ * rebuilds a single emitter per graph edit); not part of the main package surface, where the
51
+ * runtime path is running whole projects through `FXEffect`.
52
+ */
53
+ static fromArtifacts(render: FXRenderArtifact, behavior: FXBehaviorArtifact, options?: FXFromArtifactsOptions, world?: FXWorld): FXEmitter;
36
54
  /** Unregisters, removes from the scene, disposes mesh and material. Idempotent. */
37
55
  destroy(): void;
38
56
  /**
@@ -55,7 +73,13 @@ export declare class FXEmitter extends Object3D {
55
73
  * @param stepDuration - Maximum step size in seconds. Defaults to `1/60`.
56
74
  */
57
75
  prewarm(duration: number, stepDuration?: number): void;
58
- /** Kills all live particles and cancels all active plays and pending bursts. */
76
+ /**
77
+ * Kills all live particles and cancels all active plays and pending bursts. For a GPU-driven
78
+ * emitter this is a known-narrower reset (see the mesh's own `drop()` doc comment): there is no
79
+ * CPU-visible per-particle state to instantly zero, so already-alive particles keep aging out
80
+ * naturally rather than vanishing on this call's own frame - only the spawn cursor and pending
81
+ * schedule reset immediately.
82
+ */
59
83
  reset(): void;
60
84
  /**
61
85
  * Scrubs live parameter values into uniform/binding slots by name, picked up next frame - the
@@ -64,16 +88,21 @@ export declare class FXEmitter extends Object3D {
64
88
  */
65
89
  applyValues(values: FXApplyValues): void;
66
90
  /**
67
- * Merged attribute set (name -> component count): union of the behavior's writes and the render's
68
- * reads. Throws on a cross-artifact width disagreement rather than sizing a buffer wrong.
91
+ * Swaps only this emitter's render half in place - a new shader, geometry primitive/mesh, or
92
+ * shadow flags - leaving the behavior driver, particle buffers, instance count, and playback
93
+ * schedule completely untouched (unlike a structural edit that reaches {@link fromArtifacts},
94
+ * this never resets a running effect). The editor calls this instead of rebuilding whenever a
95
+ * graph edit only changed the render half.
96
+ *
97
+ * @throws If the new render artifact's attribute reads disagree with this emitter's already-
98
+ * allocated buffers (a genuine layout change - see {@link FXEmitterDriver.applyRenderArtifact}).
99
+ * This emitter is left exactly as it was before the call in that case; the caller must fall back
100
+ * to a fresh {@link fromArtifacts} instead, since only a new driver can safely resize buffers.
69
101
  */
70
- private attributeComponents;
71
- private buildCollectedProperties;
102
+ applyRenderArtifact(render: FXRenderArtifact, options?: FXApplyRenderArtifactOptions): void;
72
103
  private setSortFraction;
73
104
  private assertCanSpawn;
74
- private kernelBuffers;
75
105
  private worldTransform;
76
- private markBuffersNeedUpdate;
77
106
  private tick;
78
107
  private readonly onRendering;
79
108
  private spawnBurst;