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
@@ -1,37 +1,28 @@
1
1
  import { Object3D, Quaternion, Vector3 } from "three";
2
2
  import { FXWorld } from "../world/FXWorld.js";
3
3
  import { FXObjectMotionTracker } from "./FXObjectMotion.Internal.js";
4
- import { FX_AGE, FX_CORE_LIFECYCLE, FX_CORE_LIFECYCLE_STRIDE, FX_CORE_POSITION, FX_CORE_POSITION_STRIDE, } from "../coreLayout.js";
5
- import { FXInstancedParticle } from "../instancedParticle/FXInstancedParticle.js";
6
4
  import { resolveGeometrySource } from "../instancedParticle/primitiveGeometry.js";
7
5
  import { assertValidNonNegativeNumber, assertValidPositiveInteger, assertValidPositiveNumber, } from "../miscellaneous/asserts.js";
8
6
  import { FXArtifactMaterial } from "../render/FXArtifactMaterial.js";
9
- import { FXSimulationHolder } from "./FXSimulationHolder.js";
7
+ import { FXGPUEmitterDriver } from "./FXGPUEmitterDriver.Internal.js";
8
+ import { FXJSEmitterDriver } from "./FXJSEmitterDriver.Internal.js";
10
9
  import { EMITTER_DEFAULT_CAPACITY_STEP, EMITTER_DEFAULT_CAST_SHADOW, EMITTER_DEFAULT_EXPECTED_CAPACITY, EMITTER_DEFAULT_PREWARM_MAX_STEP_COUNT, EMITTER_DEFAULT_PREWARM_MIN_STEP_DURATION, EMITTER_DEFAULT_RECEIVE_SHADOW, EMITTER_DEFAULT_SORT_FRACTION, } from "./FXEmitter.Internal.js";
11
- // User attribute `name` -> mesh property-buffer key `fx_<name>`.
12
- const ATTRIBUTE_BUFFER_PREFIX = "fx_";
13
- // GLSL type of a float attribute, indexed by component count.
14
- const GLSL_TYPE_BY_COMPONENTS = ["", "float", "vec2", "vec3", "vec4"];
15
- const CORE_POSITION_TYPE = {
16
- glslTypeName: "vec3",
17
- bufferSize: FX_CORE_POSITION_STRIDE,
18
- instantiable: true,
19
- };
20
- const CORE_LIFECYCLE_TYPE = {
21
- glslTypeName: "vec2",
22
- bufferSize: FX_CORE_LIFECYCLE_STRIDE,
23
- instantiable: true,
24
- };
25
10
  /**
26
11
  * Particle emitter - one instanced billboard/mesh draw call, per-particle state driven by a
27
12
  * precompiled behavior artifact and appearance by a precompiled render artifact spliced into a
28
13
  * Three material. Built and ticked by {@link FXEffect} through an {@link FXWorld}. Not exported from
29
14
  * the package (the runtime path is running artifacts, not hand-building emitters); reached only as
30
15
  * the live handle returned by {@link FXEffect.getEmitter}.
16
+ *
17
+ * A thin facade over one {@link FXEmitterDriver} (JS or GPU - see `FXEmitterDriver.Internal.ts`),
18
+ * chosen once by {@link fromArtifacts} and never switched mid-playback. This class itself owns only
19
+ * what is genuinely backend-independent: play/burst/stop/prewarm scheduling, sort-fraction timing,
20
+ * object motion tracking, and the render material - the material itself CAN be replaced in place,
21
+ * by {@link applyRenderArtifact}, without touching the driver or its particle state.
31
22
  */
32
23
  export class FXEmitter extends Object3D {
33
24
  /** @internal Use {@link FXEmitter.fromArtifacts}. */
34
- constructor(material, simulation, options = {}, baseGeometry, world) {
25
+ constructor(driver, material, options, world) {
35
26
  super();
36
27
  this.nextHandler = 0;
37
28
  this.pendingBursts = [];
@@ -52,38 +43,22 @@ export class FXEmitter extends Object3D {
52
43
  this.motionScaleScratch = new Vector3();
53
44
  this.motionTracker = new FXObjectMotionTracker();
54
45
  this.destroyed = false;
55
- this.kernelBuffersCacheVersion = -1;
56
46
  this.onRendering = (deltaTime) => {
57
47
  this.tick(deltaTime);
58
- if (this.sortCamera !== undefined && this.mesh.instanceCount > 0) {
59
- // `sortFraction` is public, so clamp here too: a direct assignment above 1 would grow the accumulator unbounded.
60
- this.sortingAccumulator += Math.min(Math.max(this.sortFraction, 0), 1);
61
- if (this.sortingAccumulator >= 1) {
62
- this.sortingAccumulator -= 1;
63
- // Sort in world space to match the shader's `p_cameraDistance`, correct under any transform.
64
- this.sortCamera.getWorldPosition(this.sortingCameraWorldPosition);
65
- this.mesh.updateWorldMatrix(true, false);
66
- this.mesh.sortByDistance(this.sortingCameraWorldPosition, this.mesh.matrixWorld);
67
- }
48
+ if (this.sortCamera === undefined) {
49
+ return;
50
+ }
51
+ // `sortFraction` is public, so clamp here too: a direct assignment above 1 would grow the accumulator unbounded.
52
+ this.sortingAccumulator += Math.min(Math.max(this.sortFraction, 0), 1);
53
+ if (this.sortingAccumulator >= 1) {
54
+ this.sortingAccumulator -= 1;
55
+ this.sortCamera.getWorldPosition(this.sortingCameraWorldPosition);
56
+ this.driver.sortByDistance(this.sortingCameraWorldPosition);
68
57
  }
69
58
  };
70
59
  this.world = world;
71
- if (options.expectedCapacity !== undefined) {
72
- // Sizes the Float32Array buffers - a fractional length is a RangeError.
73
- assertValidPositiveInteger(options.expectedCapacity, "FXEmitter.constructor.options.expectedCapacity");
74
- }
75
- if (options.capacityStep !== undefined) {
76
- assertValidPositiveInteger(options.capacityStep, "FXEmitter.constructor.options.capacityStep");
77
- }
78
- this.material = material;
79
- this.simulation = simulation;
80
- // Property set = the two core buffers plus one `fx_<name>` per attribute, known before the mesh
81
- // and Three material are built so `a_<name>`/`p_<name>` are declared up front. A cross-artifact
82
- // width disagreement fails fast here.
83
- const components = this.attributeComponents();
84
- this.collectedProperties = this.buildCollectedProperties(components);
85
- const threeMaterial = material.buildThreeMaterial(this.collectedProperties);
86
- this.mesh = new FXInstancedParticle(this.collectedProperties, options.expectedCapacity ?? EMITTER_DEFAULT_EXPECTED_CAPACITY, options.capacityStep ?? EMITTER_DEFAULT_CAPACITY_STEP, threeMaterial, baseGeometry);
60
+ this.driver = driver;
61
+ this.artifactMaterial = material;
87
62
  if (options.receiveShadow ?? EMITTER_DEFAULT_RECEIVE_SHADOW) {
88
63
  this.mesh.receiveShadow = true;
89
64
  }
@@ -92,7 +67,7 @@ export class FXEmitter extends Object3D {
92
67
  // false on the mesh, so the shadow pass's frustum check is a no-op just like the color pass.
93
68
  if (options.castShadow ?? EMITTER_DEFAULT_CAST_SHADOW) {
94
69
  this.mesh.castShadow = true;
95
- this.mesh.customDepthMaterial = material.buildThreeDepthMaterial(this.collectedProperties);
70
+ this.mesh.customDepthMaterial = material.buildThreeDepthMaterial(this.driver.varyings);
96
71
  }
97
72
  this.sortCamera = options.sortCamera;
98
73
  this.sortFraction = EMITTER_DEFAULT_SORT_FRACTION;
@@ -103,11 +78,20 @@ export class FXEmitter extends Object3D {
103
78
  this.add(this.mesh);
104
79
  this.world.registerObject(this);
105
80
  }
81
+ /** Exact live count for a JS-driven emitter; for a GPU-driven one, there is no CPU-visible live
82
+ * count to report - `capacity` is the honest upper bound reported instead. */
106
83
  get particleCount() {
107
- return this.mesh.instanceCount;
84
+ return this.driver.particleCount;
108
85
  }
86
+ /** The buffer's current allocated size - grows over time for a JS-driven emitter, fixed at
87
+ * construction for a GPU-driven one (no capacityStep growth path there). */
109
88
  get particleCapacity() {
110
- return this.mesh.instanceCapacity;
89
+ return this.driver.particleCapacity;
90
+ }
91
+ // The active driver's own mesh - a thin passthrough, not a second source of truth (this.driver.mesh
92
+ // is authoritative; nothing caches or duplicates it here).
93
+ get mesh() {
94
+ return this.driver.mesh;
111
95
  }
112
96
  /**
113
97
  * Builds an emitter into `world` from a precompiled effect - the low-level building block behind
@@ -116,10 +100,34 @@ export class FXEmitter extends Object3D {
116
100
  * runtime path is running whole projects through `FXEffect`.
117
101
  */
118
102
  static fromArtifacts(render, behavior, options = {}, world = FXWorld.getDefault()) {
103
+ if (options.expectedCapacity !== undefined) {
104
+ // Sizes the Float32Array buffers - a fractional length is a RangeError. Doubles as the
105
+ // fixed GPU capacity when a GPU kernel is present (no capacityStep growth for that path).
106
+ assertValidPositiveInteger(options.expectedCapacity, "FXEmitter.constructor.options.expectedCapacity");
107
+ }
108
+ if (options.capacityStep !== undefined) {
109
+ assertValidPositiveInteger(options.capacityStep, "FXEmitter.constructor.options.capacityStep");
110
+ }
119
111
  const material = new FXArtifactMaterial(render, options.textures);
120
- const holder = new FXSimulationHolder(behavior);
121
112
  const baseGeometry = resolveGeometrySource(render.geometry, options.geometries ?? {});
122
- return new FXEmitter(material, holder, options, baseGeometry, world);
113
+ const capacity = options.expectedCapacity ?? EMITTER_DEFAULT_EXPECTED_CAPACITY;
114
+ // The one place this emitter's backend is chosen, ever (never switched mid-playback). A real
115
+ // driver-level failure (program link, buffer/VAO setup) despite "Try GPU simulation" having
116
+ // compiled and the live render tier already being "standard" falls back to the mandatory JS
117
+ // artifact in place, no reload loop, matching the editor's sceneCoordinator.ts own forced-
118
+ // WebGL1-creation-failure fallback shape on the render side.
119
+ let driver;
120
+ if (options.gpuKernel !== undefined && options.renderer !== undefined) {
121
+ try {
122
+ driver = new FXGPUEmitterDriver(options.gpuKernel, options.renderer, material, capacity, baseGeometry);
123
+ }
124
+ catch (error) {
125
+ console.warn("FXEmitter: GPU (transform-feedback) behavior setup failed at runtime; falling back " +
126
+ "to the JS behavior artifact for this emitter.", error);
127
+ }
128
+ }
129
+ driver ?? (driver = new FXJSEmitterDriver(behavior, material, capacity, options.capacityStep ?? EMITTER_DEFAULT_CAPACITY_STEP, baseGeometry));
130
+ return new FXEmitter(driver, material, options, world);
123
131
  }
124
132
  /**
125
133
  * @internal Ticks this emitter one frame. Driven by {@link FXWorld.update}, which advances the
@@ -127,12 +135,12 @@ export class FXEmitter extends Object3D {
127
135
  */
128
136
  onWorldTick(deltaTime, elapsedTime) {
129
137
  this.onRendering(deltaTime);
130
- this.material.setElapsedTime(elapsedTime);
131
- this.material.setDeltaTime(deltaTime);
138
+ this.artifactMaterial.setElapsedTime(elapsedTime);
139
+ this.artifactMaterial.setDeltaTime(deltaTime);
132
140
  // emitterTransform.velocity/angularVelocity are always length 3 (see its declaration) -
133
141
  // computed once per tick in onRendering -> tick.
134
- this.material.setObjectVelocity(this.emitterTransform.velocity);
135
- this.material.setObjectAngularVelocity(this.emitterTransform.angularVelocity);
142
+ this.artifactMaterial.setObjectVelocity(this.emitterTransform.velocity);
143
+ this.artifactMaterial.setObjectAngularVelocity(this.emitterTransform.angularVelocity);
136
144
  }
137
145
  /** Unregisters, removes from the scene, disposes mesh and material. Idempotent. */
138
146
  destroy() {
@@ -141,18 +149,20 @@ export class FXEmitter extends Object3D {
141
149
  }
142
150
  this.destroyed = true;
143
151
  this.world.unregisterObject(this);
144
- // Drop scheduled work and the buffer cache so nothing references the mesh's buffers after teardown.
152
+ // Drop scheduled work so nothing references the driver's buffers after teardown.
145
153
  this.pendingBursts.length = 0;
146
154
  this.activePlays.length = 0;
147
- this.kernelBuffersCache = undefined;
148
- // The material owns no per-emitter GPU resources; the mesh teardown disposes the instanced
149
- // geometry and the mounted material.
150
- this.material.destroy();
151
- this.mesh.destroy();
155
+ // The material owns no per-emitter GPU resources; the driver teardown disposes the instanced
156
+ // geometry and the mounted material (plus, for a GPU-driven emitter, the compiled program+VAO).
157
+ this.artifactMaterial.destroy();
158
+ this.driver.destroy();
152
159
  this.removeFromParent();
153
160
  }
154
161
  /**
155
- * 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.
156
166
  * @returns Handler for {@link stop} to cancel a pending delayed burst.
157
167
  */
158
168
  burst(count, options = {}) {
@@ -243,18 +253,38 @@ export class FXEmitter extends Object3D {
243
253
  remaining -= deltaTime;
244
254
  }
245
255
  }
246
- /** Kills all live particles and cancels all active plays and pending bursts. */
256
+ /**
257
+ * Kills all live particles and cancels all active plays and pending bursts. For a GPU-driven
258
+ * emitter this is a known-narrower reset (see the mesh's own `drop()` doc comment): there is no
259
+ * CPU-visible per-particle state to instantly zero, so already-alive particles keep aging out
260
+ * naturally rather than vanishing on this call's own frame - only the spawn cursor and pending
261
+ * schedule reset immediately.
262
+ */
247
263
  reset() {
248
264
  if (this.destroyed) {
249
265
  return;
250
266
  }
251
267
  this.pendingBursts.length = 0;
252
268
  this.activePlays.length = 0;
253
- this.mesh.drop();
269
+ this.driver.reset();
254
270
  // A restarted effect should not report a teleport spike from wherever the emitter was last
255
271
  // posed - drop the velocity/angular-velocity baseline along with the particles.
256
272
  this.motionTracker.reset();
257
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
+ }
258
288
  /**
259
289
  * Scrubs live parameter values into uniform/binding slots by name, picked up next frame - the
260
290
  * runtime's value channel for a non-structural edit. Unknown name = safe no-op; never recompiles.
@@ -265,48 +295,45 @@ export class FXEmitter extends Object3D {
265
295
  return;
266
296
  }
267
297
  if (values.uniforms !== undefined) {
268
- this.material.applyUniformValues(values.uniforms);
298
+ this.artifactMaterial.applyUniformValues(values.uniforms);
269
299
  }
270
300
  if (values.bindings !== undefined) {
271
- this.simulation?.applyBindingValues(values.bindings);
301
+ this.driver.applyBindingValues(values.bindings);
272
302
  }
273
303
  }
274
304
  /**
275
- * Merged attribute set (name -> component count): union of the behavior's writes and the render's
276
- * reads. Throws on a cross-artifact width disagreement rather than sizing a buffer wrong.
305
+ * Swaps only this emitter's render half in place - a new shader, geometry primitive/mesh, or
306
+ * shadow flags - leaving the behavior driver, particle buffers, instance count, and playback
307
+ * schedule completely untouched (unlike a structural edit that reaches {@link fromArtifacts},
308
+ * this never resets a running effect). The editor calls this instead of rebuilding whenever a
309
+ * graph edit only changed the render half.
310
+ *
311
+ * @throws If the new render artifact's attribute reads disagree with this emitter's already-
312
+ * allocated buffers (a genuine layout change - see {@link FXEmitterDriver.applyRenderArtifact}).
313
+ * This emitter is left exactly as it was before the call in that case; the caller must fall back
314
+ * to a fresh {@link fromArtifacts} instead, since only a new driver can safely resize buffers.
277
315
  */
278
- attributeComponents() {
279
- const components = new Map();
280
- const add = (name, count) => {
281
- const existing = components.get(name);
282
- if (existing !== undefined && existing !== count) {
283
- throw new Error(`FXEmitter: attribute "${name}" is written and read with conflicting widths ` +
284
- `(${existing.toString()} vs ${count.toString()}); the behavior attributeWrites and ` +
285
- `the render attributeReads must agree`);
286
- }
287
- components.set(name, count);
288
- };
289
- for (const write of this.simulation?.attributeWrites ?? []) {
290
- add(write.name, write.components);
316
+ applyRenderArtifact(render, options = {}) {
317
+ if (this.destroyed) {
318
+ return;
291
319
  }
292
- for (const read of this.material.attributeReads) {
293
- add(read.name, read.components);
320
+ const material = new FXArtifactMaterial(render, options.textures);
321
+ const baseGeometry = resolveGeometrySource(render.geometry, options.geometries ?? {});
322
+ // Throws (leaving this emitter untouched) before anything below mutates it - see this
323
+ // method's own @throws doc.
324
+ this.driver.applyRenderArtifact(material, baseGeometry);
325
+ this.artifactMaterial = material;
326
+ this.mesh.receiveShadow = options.receiveShadow ?? EMITTER_DEFAULT_RECEIVE_SHADOW;
327
+ const previousDepthMaterial = this.mesh.customDepthMaterial;
328
+ if (options.castShadow ?? EMITTER_DEFAULT_CAST_SHADOW) {
329
+ this.mesh.castShadow = true;
330
+ this.mesh.customDepthMaterial = material.buildThreeDepthMaterial(this.driver.varyings);
294
331
  }
295
- return components;
296
- }
297
- buildCollectedProperties(components) {
298
- const properties = {
299
- [FX_CORE_POSITION]: CORE_POSITION_TYPE,
300
- [FX_CORE_LIFECYCLE]: CORE_LIFECYCLE_TYPE,
301
- };
302
- for (const [name, count] of components) {
303
- properties[`${ATTRIBUTE_BUFFER_PREFIX}${name}`] = {
304
- glslTypeName: GLSL_TYPE_BY_COMPONENTS[count],
305
- bufferSize: count,
306
- instantiable: true,
307
- };
332
+ else {
333
+ this.mesh.castShadow = false;
334
+ this.mesh.customDepthMaterial = undefined;
308
335
  }
309
- return properties;
336
+ previousDepthMaterial?.dispose();
310
337
  }
311
338
  setSortFraction(value, context) {
312
339
  assertValidNonNegativeNumber(value, context);
@@ -316,31 +343,10 @@ export class FXEmitter extends Object3D {
316
343
  // Throws when the behavior artifact cannot seed births. Checked at the scheduling points, NOT in
317
344
  // the frame tick - a throw from the world tick would take the host's render loop down.
318
345
  assertCanSpawn(context) {
319
- if (this.simulation !== undefined && !this.simulation.canSpawn) {
346
+ if (!this.driver.canSpawn) {
320
347
  throw new Error(`${context}: this behavior artifact cannot seed births (update-only)`);
321
348
  }
322
349
  }
323
- // Maps each kernel state-buffer name to its backing Float32Array. Cached across ticks/bursts and
324
- // rebuilt only when the mesh's buffer set changes (`bufferVersion` - capacity growth).
325
- kernelBuffers() {
326
- const version = this.mesh.bufferVersion;
327
- if (this.kernelBuffersCache !== undefined && this.kernelBuffersCacheVersion === version) {
328
- return this.kernelBuffersCache;
329
- }
330
- const buffers = {
331
- [FX_CORE_POSITION]: this.mesh.propertyBuffers[FX_CORE_POSITION].array,
332
- [FX_CORE_LIFECYCLE]: this.mesh.propertyBuffers[FX_CORE_LIFECYCLE].array,
333
- };
334
- for (const buffer of this.simulation?.attributeBuffers ?? []) {
335
- const attribute = this.mesh.propertyBuffers[`${ATTRIBUTE_BUFFER_PREFIX}${buffer.name}`];
336
- if (attribute !== undefined) {
337
- buffers[buffer.name] = attribute.array;
338
- }
339
- }
340
- this.kernelBuffersCache = buffers;
341
- this.kernelBuffersCacheVersion = version;
342
- return buffers;
343
- }
344
350
  // The emitter's world matrix as the reused snapshot the kernel reads. It is the mesh's
345
351
  // `matrixWorld` (same matrix the shader and depth sort see), so a world-space graph stays
346
352
  // consistent under any transform. `Matrix4.elements` is already column-major.
@@ -353,16 +359,6 @@ export class FXEmitter extends Object3D {
353
359
  }
354
360
  return transform;
355
361
  }
356
- markBuffersNeedUpdate(names) {
357
- for (const name of names) {
358
- const isCore = name === FX_CORE_POSITION || name === FX_CORE_LIFECYCLE;
359
- const key = isCore ? name : `${ATTRIBUTE_BUFFER_PREFIX}${name}`;
360
- const attribute = this.mesh.propertyBuffers[key];
361
- if (attribute !== undefined) {
362
- attribute.needsUpdate = true;
363
- }
364
- }
365
- }
366
362
  tick(deltaTime) {
367
363
  // World-space velocity/angular velocity since the previous tick, computed once here and not
368
364
  // inside worldTransform() - the burst/play loops below and the update phase can each call
@@ -390,16 +386,13 @@ export class FXEmitter extends Object3D {
390
386
  this.emitterTransform.angularVelocity[0] = motion.angularVelocity[0];
391
387
  this.emitterTransform.angularVelocity[1] = motion.angularVelocity[1];
392
388
  this.emitterTransform.angularVelocity[2] = motion.angularVelocity[2];
393
- // Age increment + cull. Age is owned by this fixed loop, not the graph - same for every effect.
394
- {
395
- const lifecycle = this.mesh.propertyBuffers[FX_CORE_LIFECYCLE];
396
- const { array, itemSize } = lifecycle;
397
- const instanceCount = this.mesh.instanceCount;
398
- for (let i = 0; i < instanceCount; i++) {
399
- array[i * itemSize + FX_AGE] += deltaTime;
400
- }
389
+ // Paused (dt == 0): skip the simulation step entirely - no JS age/kernel update, no GPU
390
+ // transform-feedback dispatch, no schedule advance - the emitter keeps rendering whatever
391
+ // particle state the last active tick left behind.
392
+ if (deltaTime <= 0) {
393
+ return;
401
394
  }
402
- this.mesh.removeDeadParticles();
395
+ this.driver.beginTick(deltaTime);
403
396
  for (let i = this.pendingBursts.length - 1; i >= 0; i--) {
404
397
  const pending = this.pendingBursts[i];
405
398
  pending.delay -= deltaTime;
@@ -438,27 +431,10 @@ export class FXEmitter extends Object3D {
438
431
  this.activePlays.splice(i, 1);
439
432
  }
440
433
  }
441
- if (this.mesh.instanceCount === 0) {
442
- return;
443
- }
444
- if (this.simulation !== undefined) {
445
- // Motion integration lives in the graph (an `integrate-motion` node writes
446
- // `position += velocity * dt`), not in this host loop.
447
- this.simulation.update(this.kernelBuffers(), this.mesh.instanceCount, deltaTime, this.worldTransform());
448
- this.markBuffersNeedUpdate(this.simulation.updateWrittenBuffers);
449
- }
450
- // Age (written above) must reach the GPU this frame; the update phase's buffers were flagged already.
451
- this.mesh.propertyBuffers[FX_CORE_LIFECYCLE].needsUpdate = true;
434
+ this.driver.endTick(deltaTime, this.worldTransform());
452
435
  }
453
436
  spawnBurst(count) {
454
- const instanceBegin = this.mesh.instanceCount;
455
- this.mesh.createInstances(count);
456
- const instanceEnd = this.mesh.instanceCount;
457
- // Re-checked (defensive): no spawn function would leave the freshly zeroed rows born dead.
458
- if (this.simulation?.canSpawn === true) {
459
- this.simulation.spawn(this.kernelBuffers(), instanceBegin, instanceEnd - instanceBegin, this.worldTransform());
460
- this.markBuffersNeedUpdate(this.simulation.spawnWrittenBuffers);
461
- }
437
+ this.driver.spawn(count, this.worldTransform());
462
438
  }
463
439
  }
464
440
  // Returned by post-destroy `burst`/`play` - never matches a scheduled handler.
@@ -0,0 +1,26 @@
1
+ import type { GLTypeInfo } from "../instancedParticle/glTypeInfo.js";
2
+ /** A named width declaration - shape shared by `FXAttributeDecl` (`components`) and a mapped
3
+ * `FXBufferLayout` (`stride`), the two concrete lists {@link mergeAttributeWidths} merges. */
4
+ interface FXNamedWidth {
5
+ readonly name: string;
6
+ readonly components: number;
7
+ }
8
+ /**
9
+ * Merges width-declaring attribute lists (a driver's own state buffers, the render artifact's
10
+ * `attributeReads`) into one name -> component-count map, throwing on a genuine cross-source
11
+ * disagreement rather than sizing a buffer wrong. Shared by both drivers' constructors so the
12
+ * validation itself never drifts between them, even though each driver's own input list does not.
13
+ */
14
+ export declare function mergeAttributeWidths(...sources: readonly (readonly FXNamedWidth[])[]): ReadonlyMap<string, number>;
15
+ /** Builds a driver's full varyings map (the two core buffers plus one `fx_<name>` per attribute)
16
+ * from a merged width map - the input to both `FXArtifactMaterial.buildThreeMaterial` and either
17
+ * mesh class's constructor. */
18
+ export declare function varyingsFromComponents(components: ReadonlyMap<string, number>): Record<string, GLTypeInfo>;
19
+ /**
20
+ * Whether two varyings maps declare the exact same names at the exact same width - the guard
21
+ * `applyRenderArtifact` needs before reusing already-allocated buffers in place: a new render
22
+ * artifact whose attribute reads disagree with what is already allocated (a new name, or an
23
+ * existing name at a different width) cannot be applied without a full rebuild.
24
+ */
25
+ export declare function sameVaryings(current: Record<string, GLTypeInfo>, next: Record<string, GLTypeInfo>): boolean;
26
+ export {};
@@ -0,0 +1,70 @@
1
+ import { FX_CORE_LIFECYCLE, FX_CORE_LIFECYCLE_STRIDE, FX_CORE_POSITION, FX_CORE_POSITION_STRIDE, meshPropertyKeyFor, } from "../coreLayout.js";
2
+ const CORE_POSITION_TYPE = {
3
+ glslTypeName: "vec3",
4
+ bufferSize: FX_CORE_POSITION_STRIDE,
5
+ instantiable: true,
6
+ };
7
+ const CORE_LIFECYCLE_TYPE = {
8
+ glslTypeName: "vec3",
9
+ bufferSize: FX_CORE_LIFECYCLE_STRIDE,
10
+ instantiable: true,
11
+ };
12
+ // GLSL type of a float attribute, indexed by component count.
13
+ const GLSL_TYPE_BY_COMPONENTS = ["", "float", "vec2", "vec3", "vec4"];
14
+ /**
15
+ * Merges width-declaring attribute lists (a driver's own state buffers, the render artifact's
16
+ * `attributeReads`) into one name -> component-count map, throwing on a genuine cross-source
17
+ * disagreement rather than sizing a buffer wrong. Shared by both drivers' constructors so the
18
+ * validation itself never drifts between them, even though each driver's own input list does not.
19
+ */
20
+ export function mergeAttributeWidths(...sources) {
21
+ const components = new Map();
22
+ for (const source of sources) {
23
+ for (const decl of source) {
24
+ const existing = components.get(decl.name);
25
+ if (existing !== undefined && existing !== decl.components) {
26
+ throw new Error(`FXEmitter: attribute "${decl.name}" is declared with conflicting widths ` +
27
+ `(${existing.toString()} vs ${decl.components.toString()}) across its sources`);
28
+ }
29
+ components.set(decl.name, decl.components);
30
+ }
31
+ }
32
+ return components;
33
+ }
34
+ /** Builds a driver's full varyings map (the two core buffers plus one `fx_<name>` per attribute)
35
+ * from a merged width map - the input to both `FXArtifactMaterial.buildThreeMaterial` and either
36
+ * mesh class's constructor. */
37
+ export function varyingsFromComponents(components) {
38
+ const varyings = {
39
+ [FX_CORE_POSITION]: CORE_POSITION_TYPE,
40
+ [FX_CORE_LIFECYCLE]: CORE_LIFECYCLE_TYPE,
41
+ };
42
+ for (const [name, count] of components) {
43
+ varyings[meshPropertyKeyFor(name)] = {
44
+ glslTypeName: GLSL_TYPE_BY_COMPONENTS[count],
45
+ bufferSize: count,
46
+ instantiable: true,
47
+ };
48
+ }
49
+ return varyings;
50
+ }
51
+ /**
52
+ * Whether two varyings maps declare the exact same names at the exact same width - the guard
53
+ * `applyRenderArtifact` needs before reusing already-allocated buffers in place: a new render
54
+ * artifact whose attribute reads disagree with what is already allocated (a new name, or an
55
+ * existing name at a different width) cannot be applied without a full rebuild.
56
+ */
57
+ export function sameVaryings(current, next) {
58
+ const currentNames = Object.keys(current);
59
+ if (currentNames.length !== Object.keys(next).length) {
60
+ return false;
61
+ }
62
+ return currentNames.every((name) => {
63
+ if (!Object.prototype.hasOwnProperty.call(next, name)) {
64
+ return false;
65
+ }
66
+ const nextEntry = next[name];
67
+ return (nextEntry.glslTypeName === current[name].glslTypeName &&
68
+ nextEntry.bufferSize === current[name].bufferSize);
69
+ });
70
+ }
@@ -1,4 +1,5 @@
1
- import type { BufferGeometry, Texture } from "three";
1
+ import type { BufferGeometry, Texture, WebGLRenderer } from "three";
2
+ import type { FXParticleKernelArtifact } from "../artifact/FXArtifact.js";
2
3
  import type { FXEmitterOptions } from "./FXEmitter.Internal.js";
3
4
  /**
4
5
  * Options for {@link FXEmitter.fromArtifacts}: the emitter options plus external textures by slot -
@@ -10,4 +11,14 @@ import type { FXEmitterOptions } from "./FXEmitter.Internal.js";
10
11
  export interface FXFromArtifactsOptions extends Partial<FXEmitterOptions> {
11
12
  readonly textures?: Record<string, Texture>;
12
13
  readonly geometries?: Record<string, BufferGeometry>;
14
+ /**
15
+ * The standard-tier (WebGL2, transform-feedback) behavior artifact, present only when the
16
+ * graph's spawn node had "Try GPU simulation" on and the graph compiled to GLSL. `renderer` is
17
+ * required alongside it - without one, or if GPU program setup itself throws (a real
18
+ * driver-level failure), the emitter falls back to the mandatory JS `behavior` artifact in
19
+ * place, never a hard error. `expectedCapacity` becomes this emitter's fixed GPU particle
20
+ * capacity (no growth); `capacityStep` is meaningless for a GPU-driven emitter and is ignored.
21
+ */
22
+ readonly gpuKernel?: FXParticleKernelArtifact;
23
+ readonly renderer?: WebGLRenderer;
13
24
  }
@@ -0,0 +1 @@
1
+ export {};