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
@@ -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
- }
60
+ this.driver = driver;
78
61
  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);
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;
@@ -101,23 +76,58 @@ export class FXEmitter extends Object3D {
101
76
  }
102
77
  this.assertCanSpawn("FXEmitter.constructor");
103
78
  this.add(this.mesh);
104
- this.world.registerEmitter(this);
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
- * @internal Builds an emitter into `world` from a precompiled effect. Called by {@link FXEffect};
114
- * the editor emits the two artifacts as an ESM module and the effect hands them here.
97
+ * Builds an emitter into `world` from a precompiled effect - the low-level building block behind
98
+ * {@link FXEffect}. Exposed for editor tooling through the `sparcoon/editor` entry (live preview
99
+ * rebuilds a single emitter per graph edit); not part of the main package surface, where the
100
+ * runtime path is running whole projects through `FXEffect`.
115
101
  */
116
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
+ }
117
111
  const material = new FXArtifactMaterial(render, options.textures);
118
- const holder = new FXSimulationHolder(behavior);
119
112
  const baseGeometry = resolveGeometrySource(render.geometry, options.geometries ?? {});
120
- 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);
121
131
  }
122
132
  /**
123
133
  * @internal Ticks this emitter one frame. Driven by {@link FXWorld.update}, which advances the
@@ -138,15 +148,14 @@ export class FXEmitter extends Object3D {
138
148
  return;
139
149
  }
140
150
  this.destroyed = true;
141
- this.world.unregisterEmitter(this);
142
- // Drop scheduled work and the buffer cache so nothing references the mesh's buffers after teardown.
151
+ this.world.unregisterObject(this);
152
+ // Drop scheduled work so nothing references the driver's buffers after teardown.
143
153
  this.pendingBursts.length = 0;
144
154
  this.activePlays.length = 0;
145
- this.kernelBuffersCache = undefined;
146
- // The material owns no per-emitter GPU resources; the mesh teardown disposes the instanced
147
- // geometry and the mounted material.
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).
148
157
  this.material.destroy();
149
- this.mesh.destroy();
158
+ this.driver.destroy();
150
159
  this.removeFromParent();
151
160
  }
152
161
  /**
@@ -241,14 +250,20 @@ export class FXEmitter extends Object3D {
241
250
  remaining -= deltaTime;
242
251
  }
243
252
  }
244
- /** Kills all live particles and cancels all active plays and pending bursts. */
253
+ /**
254
+ * Kills all live particles and cancels all active plays and pending bursts. For a GPU-driven
255
+ * emitter this is a known-narrower reset (see the mesh's own `drop()` doc comment): there is no
256
+ * CPU-visible per-particle state to instantly zero, so already-alive particles keep aging out
257
+ * naturally rather than vanishing on this call's own frame - only the spawn cursor and pending
258
+ * schedule reset immediately.
259
+ */
245
260
  reset() {
246
261
  if (this.destroyed) {
247
262
  return;
248
263
  }
249
264
  this.pendingBursts.length = 0;
250
265
  this.activePlays.length = 0;
251
- this.mesh.drop();
266
+ this.driver.reset();
252
267
  // A restarted effect should not report a teleport spike from wherever the emitter was last
253
268
  // posed - drop the velocity/angular-velocity baseline along with the particles.
254
269
  this.motionTracker.reset();
@@ -266,45 +281,42 @@ export class FXEmitter extends Object3D {
266
281
  this.material.applyUniformValues(values.uniforms);
267
282
  }
268
283
  if (values.bindings !== undefined) {
269
- this.simulation?.applyBindingValues(values.bindings);
284
+ this.driver.applyBindingValues(values.bindings);
270
285
  }
271
286
  }
272
287
  /**
273
- * Merged attribute set (name -> component count): union of the behavior's writes and the render's
274
- * reads. Throws on a cross-artifact width disagreement rather than sizing a buffer wrong.
288
+ * Swaps only this emitter's render half in place - a new shader, geometry primitive/mesh, or
289
+ * shadow flags - leaving the behavior driver, particle buffers, instance count, and playback
290
+ * schedule completely untouched (unlike a structural edit that reaches {@link fromArtifacts},
291
+ * this never resets a running effect). The editor calls this instead of rebuilding whenever a
292
+ * graph edit only changed the render half.
293
+ *
294
+ * @throws If the new render artifact's attribute reads disagree with this emitter's already-
295
+ * allocated buffers (a genuine layout change - see {@link FXEmitterDriver.applyRenderArtifact}).
296
+ * This emitter is left exactly as it was before the call in that case; the caller must fall back
297
+ * to a fresh {@link fromArtifacts} instead, since only a new driver can safely resize buffers.
275
298
  */
276
- attributeComponents() {
277
- const components = new Map();
278
- const add = (name, count) => {
279
- const existing = components.get(name);
280
- if (existing !== undefined && existing !== count) {
281
- throw new Error(`FXEmitter: attribute "${name}" is written and read with conflicting widths ` +
282
- `(${existing.toString()} vs ${count.toString()}); the behavior attributeWrites and ` +
283
- `the render attributeReads must agree`);
284
- }
285
- components.set(name, count);
286
- };
287
- for (const write of this.simulation?.attributeWrites ?? []) {
288
- add(write.name, write.components);
299
+ applyRenderArtifact(render, options = {}) {
300
+ if (this.destroyed) {
301
+ return;
289
302
  }
290
- for (const read of this.material.attributeReads) {
291
- add(read.name, read.components);
303
+ const material = new FXArtifactMaterial(render, options.textures);
304
+ const baseGeometry = resolveGeometrySource(render.geometry, options.geometries ?? {});
305
+ // Throws (leaving this emitter untouched) before anything below mutates it - see this
306
+ // method's own @throws doc.
307
+ this.driver.applyRenderArtifact(material, baseGeometry);
308
+ this.material = material;
309
+ this.mesh.receiveShadow = options.receiveShadow ?? EMITTER_DEFAULT_RECEIVE_SHADOW;
310
+ const previousDepthMaterial = this.mesh.customDepthMaterial;
311
+ if (options.castShadow ?? EMITTER_DEFAULT_CAST_SHADOW) {
312
+ this.mesh.castShadow = true;
313
+ this.mesh.customDepthMaterial = material.buildThreeDepthMaterial(this.driver.varyings);
292
314
  }
293
- return components;
294
- }
295
- buildCollectedProperties(components) {
296
- const properties = {
297
- [FX_CORE_POSITION]: CORE_POSITION_TYPE,
298
- [FX_CORE_LIFECYCLE]: CORE_LIFECYCLE_TYPE,
299
- };
300
- for (const [name, count] of components) {
301
- properties[`${ATTRIBUTE_BUFFER_PREFIX}${name}`] = {
302
- glslTypeName: GLSL_TYPE_BY_COMPONENTS[count],
303
- bufferSize: count,
304
- instantiable: true,
305
- };
315
+ else {
316
+ this.mesh.castShadow = false;
317
+ this.mesh.customDepthMaterial = undefined;
306
318
  }
307
- return properties;
319
+ previousDepthMaterial?.dispose();
308
320
  }
309
321
  setSortFraction(value, context) {
310
322
  assertValidNonNegativeNumber(value, context);
@@ -314,31 +326,10 @@ export class FXEmitter extends Object3D {
314
326
  // Throws when the behavior artifact cannot seed births. Checked at the scheduling points, NOT in
315
327
  // the frame tick - a throw from the world tick would take the host's render loop down.
316
328
  assertCanSpawn(context) {
317
- if (this.simulation !== undefined && !this.simulation.canSpawn) {
329
+ if (!this.driver.canSpawn) {
318
330
  throw new Error(`${context}: this behavior artifact cannot seed births (update-only)`);
319
331
  }
320
332
  }
321
- // Maps each kernel state-buffer name to its backing Float32Array. Cached across ticks/bursts and
322
- // rebuilt only when the mesh's buffer set changes (`bufferVersion` - capacity growth).
323
- kernelBuffers() {
324
- const version = this.mesh.bufferVersion;
325
- if (this.kernelBuffersCache !== undefined && this.kernelBuffersCacheVersion === version) {
326
- return this.kernelBuffersCache;
327
- }
328
- const buffers = {
329
- [FX_CORE_POSITION]: this.mesh.propertyBuffers[FX_CORE_POSITION].array,
330
- [FX_CORE_LIFECYCLE]: this.mesh.propertyBuffers[FX_CORE_LIFECYCLE].array,
331
- };
332
- for (const buffer of this.simulation?.attributeBuffers ?? []) {
333
- const attribute = this.mesh.propertyBuffers[`${ATTRIBUTE_BUFFER_PREFIX}${buffer.name}`];
334
- if (attribute !== undefined) {
335
- buffers[buffer.name] = attribute.array;
336
- }
337
- }
338
- this.kernelBuffersCache = buffers;
339
- this.kernelBuffersCacheVersion = version;
340
- return buffers;
341
- }
342
333
  // The emitter's world matrix as the reused snapshot the kernel reads. It is the mesh's
343
334
  // `matrixWorld` (same matrix the shader and depth sort see), so a world-space graph stays
344
335
  // consistent under any transform. `Matrix4.elements` is already column-major.
@@ -351,16 +342,6 @@ export class FXEmitter extends Object3D {
351
342
  }
352
343
  return transform;
353
344
  }
354
- markBuffersNeedUpdate(names) {
355
- for (const name of names) {
356
- const isCore = name === FX_CORE_POSITION || name === FX_CORE_LIFECYCLE;
357
- const key = isCore ? name : `${ATTRIBUTE_BUFFER_PREFIX}${name}`;
358
- const attribute = this.mesh.propertyBuffers[key];
359
- if (attribute !== undefined) {
360
- attribute.needsUpdate = true;
361
- }
362
- }
363
- }
364
345
  tick(deltaTime) {
365
346
  // World-space velocity/angular velocity since the previous tick, computed once here and not
366
347
  // inside worldTransform() - the burst/play loops below and the update phase can each call
@@ -388,16 +369,13 @@ export class FXEmitter extends Object3D {
388
369
  this.emitterTransform.angularVelocity[0] = motion.angularVelocity[0];
389
370
  this.emitterTransform.angularVelocity[1] = motion.angularVelocity[1];
390
371
  this.emitterTransform.angularVelocity[2] = motion.angularVelocity[2];
391
- // Age increment + cull. Age is owned by this fixed loop, not the graph - same for every effect.
392
- {
393
- const lifecycle = this.mesh.propertyBuffers[FX_CORE_LIFECYCLE];
394
- const { array, itemSize } = lifecycle;
395
- const instanceCount = this.mesh.instanceCount;
396
- for (let i = 0; i < instanceCount; i++) {
397
- array[i * itemSize + FX_AGE] += deltaTime;
398
- }
372
+ // Paused (dt == 0): skip the simulation step entirely - no JS age/kernel update, no GPU
373
+ // transform-feedback dispatch, no schedule advance - the emitter keeps rendering whatever
374
+ // particle state the last active tick left behind.
375
+ if (deltaTime <= 0) {
376
+ return;
399
377
  }
400
- this.mesh.removeDeadParticles();
378
+ this.driver.beginTick(deltaTime);
401
379
  for (let i = this.pendingBursts.length - 1; i >= 0; i--) {
402
380
  const pending = this.pendingBursts[i];
403
381
  pending.delay -= deltaTime;
@@ -436,27 +414,10 @@ export class FXEmitter extends Object3D {
436
414
  this.activePlays.splice(i, 1);
437
415
  }
438
416
  }
439
- if (this.mesh.instanceCount === 0) {
440
- return;
441
- }
442
- if (this.simulation !== undefined) {
443
- // Motion integration lives in the graph (an `integrate-motion` node writes
444
- // `position += velocity * dt`), not in this host loop.
445
- this.simulation.update(this.kernelBuffers(), this.mesh.instanceCount, deltaTime, this.worldTransform());
446
- this.markBuffersNeedUpdate(this.simulation.updateWrittenBuffers);
447
- }
448
- // Age (written above) must reach the GPU this frame; the update phase's buffers were flagged already.
449
- this.mesh.propertyBuffers[FX_CORE_LIFECYCLE].needsUpdate = true;
417
+ this.driver.endTick(deltaTime, this.worldTransform());
450
418
  }
451
419
  spawnBurst(count) {
452
- const instanceBegin = this.mesh.instanceCount;
453
- this.mesh.createInstances(count);
454
- const instanceEnd = this.mesh.instanceCount;
455
- // Re-checked (defensive): no spawn function would leave the freshly zeroed rows born dead.
456
- if (this.simulation?.canSpawn === true) {
457
- this.simulation.spawn(this.kernelBuffers(), instanceBegin, instanceEnd - instanceBegin, this.worldTransform());
458
- this.markBuffersNeedUpdate(this.simulation.spawnWrittenBuffers);
459
- }
420
+ this.driver.spawn(count, this.worldTransform());
460
421
  }
461
422
  }
462
423
  // 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 {};
@@ -0,0 +1,137 @@
1
+ import { isCoreBufferName } from "../coreLayout.js";
2
+ import { FXTransformFeedbackParticle } from "../instancedParticle/FXTransformFeedbackParticle.js";
3
+ import { mergeAttributeWidths, sameVaryings, varyingsFromComponents, } from "./FXEmitterDriver.Internal.js";
4
+ import { FXTransformFeedbackSimulationHolder } from "./FXTransformFeedbackSimulationHolder.js";
5
+ /**
6
+ * @internal The GPU (WebGL2 transform-feedback) particle-simulation backend: a fixed-capacity
7
+ * `FXTransformFeedbackParticle` mesh driven by a precompiled `FXParticleKernelArtifact` through
8
+ * `FXTransformFeedbackSimulationHolder`. Self-sufficient from its own artifact - its varyings come
9
+ * from `gpu.kernel.buffers` (+ the render artifact's `attributeReads`), never from a JS behavior
10
+ * artifact's `attributeWrites`.
11
+ *
12
+ * No sorting or dead-particle compaction: every particle slot is always drawn at the fixed
13
+ * `capacity`, and `particleCount`/`particleCapacity` both report that capacity rather than an
14
+ * exact live count, which there is no cheap way to know.
15
+ */
16
+ export class FXGPUEmitterDriver {
17
+ constructor(kernel, renderer, material, capacity, baseGeometry) {
18
+ // Cursor/overwrite bookkeeping: `gpuSpawnCursor` is the next write position (mod
19
+ // capacity); `gpuSpawnRangeStart`/`gpuPendingSpawnCount` accumulate every spawn() call since the
20
+ // last endTick() into one contiguous range, since the GPU can only run one transform-feedback pass
21
+ // per tick (unlike the JS backend, which can grow its array once per call with no such constraint) -
22
+ // "since the last endTick()", not "since the start of this tick": an immediate `burst()` between
23
+ // ticks must not lose its contribution (see endTick()'s own doc comment).
24
+ this.gpuSpawnCursor = 0;
25
+ this.gpuSpawnRangeStart = 0;
26
+ this.gpuPendingSpawnCount = 0;
27
+ // Depth sorting has no GPU-driven form - warned once, not every tick, if a host sets
28
+ // sortCamera on one anyway.
29
+ this.warnedAboutSortCamera = false;
30
+ const attributeBuffers = kernel.buffers.filter((buffer) => !isCoreBufferName(buffer.name));
31
+ this.behaviorAttributeWidths = attributeBuffers.map((buffer) => ({
32
+ name: buffer.name,
33
+ components: buffer.stride,
34
+ }));
35
+ const components = mergeAttributeWidths(this.behaviorAttributeWidths, material.attributeReads);
36
+ this.varyings = varyingsFromComponents(components);
37
+ const threeMaterial = material.buildThreeMaterial(this.varyings);
38
+ let mesh;
39
+ try {
40
+ mesh = new FXTransformFeedbackParticle(renderer.getContext(), this.varyings, capacity, threeMaterial, baseGeometry);
41
+ this.simulation = new FXTransformFeedbackSimulationHolder(renderer, kernel);
42
+ }
43
+ catch (error) {
44
+ // `mesh` can have already succeeded (real WebGL buffers/VAO-backing state allocated) when the
45
+ // simulation holder construction right after it is what actually throws - release those
46
+ // before re-throwing, or they leak for the GL context's lifetime. The caller (FXEmitter.
47
+ // fromArtifacts) decides what happens next - falling back to FXJSEmitterDriver.
48
+ mesh?.destroy();
49
+ throw error;
50
+ }
51
+ this.mesh = mesh;
52
+ }
53
+ get particleCount() {
54
+ return this.mesh.capacity;
55
+ }
56
+ get particleCapacity() {
57
+ return this.mesh.capacity;
58
+ }
59
+ // A fused transform-feedback program always has a spawn branch - assembleTransformFeedbackProgram
60
+ // (the editor's assembler) refuses to build one without it, so a constructed FXParticleKernelArtifact
61
+ // is this guarantee already checked, not something to re-derive here.
62
+ get canSpawn() {
63
+ return true;
64
+ }
65
+ spawn(count) {
66
+ // Cursor advances by `count`, wrapping at capacity - the buffer slots this claims may already
67
+ // be occupied by still-alive particles, which are simply overwritten (the scheme's cheapness is
68
+ // not checking occupancy at all). Accumulates into
69
+ // `gpuPendingSpawnCount` regardless of whether this call happens during a tick's own scheduling
70
+ // (a delayed burst/play firing) or entirely between ticks (an immediate `burst()` call from host
71
+ // code) - endTick() is the only place that range is ever consumed and reset (see its own doc
72
+ // comment for why beginTick() must NOT also reset it).
73
+ this.gpuSpawnCursor = (this.gpuSpawnCursor + count) % this.mesh.capacity;
74
+ this.gpuPendingSpawnCount += count;
75
+ }
76
+ beginTick() {
77
+ // No begin-of-tick bookkeeping needed: unlike the range/count fields (reset only once consumed,
78
+ // in endTick()), there is nothing here that must happen before this tick's scheduled bursts run.
79
+ }
80
+ endTick(deltaTime, transform) {
81
+ // Every particle slot is always processed - there is no CPU-visible live count to skip on;
82
+ // the render shader hides dead slots (FXArtifactMaterial.Internal.ts's
83
+ // projectFromModelPos degenerate-position injection).
84
+ this.simulation.tick(this.mesh, deltaTime, { start: this.gpuSpawnRangeStart, count: this.gpuPendingSpawnCount }, transform);
85
+ // Consumed - reset for the NEXT accumulation window, starting from wherever the cursor now sits.
86
+ // Must NOT happen in beginTick() instead: an immediate `burst()` call between two ticks (the
87
+ // ordinary way to spawn a one-shot effect, never touching pendingBursts/activePlays at all)
88
+ // already advances the cursor and this count the moment it is called - resetting at the START of
89
+ // the NEXT tick would silently discard that count before it was ever reported as a birth range,
90
+ // leaving those slots' data one full transform-feedback pass behind their real cursor claim.
91
+ this.gpuSpawnRangeStart = this.gpuSpawnCursor;
92
+ this.gpuPendingSpawnCount = 0;
93
+ }
94
+ sortByDistance() {
95
+ // No CPU-visible particle positions to sort, and no cheap way to sort GPU-resident data without
96
+ // a readback that would defeat the point of running on the GPU at all (an accepted loss, not a
97
+ // bug to work around). Never thrown - a host that sets sortCamera on a GPU
98
+ // emitter out of habit should not crash the world tick over it.
99
+ if (!this.warnedAboutSortCamera) {
100
+ this.warnedAboutSortCamera = true;
101
+ console.warn("FXEmitter: sortCamera has no effect on a GPU-driven (transform-feedback) emitter - " +
102
+ "back-to-front depth sorting is not supported for that backend.");
103
+ }
104
+ }
105
+ applyBindingValues(values) {
106
+ this.simulation.applyBindingValues(values);
107
+ }
108
+ reset() {
109
+ this.mesh.drop();
110
+ this.gpuSpawnCursor = 0;
111
+ // Must also clear any not-yet-consumed spawn accumulation (endTick() only resets it AFTER
112
+ // consuming it - see that method's own doc comment), or a burst() that ran earlier this same
113
+ // accumulation window would still spawn on the next tick despite reset() having just cancelled
114
+ // it - a stale range left over from before the cursor was zeroed here.
115
+ this.gpuSpawnRangeStart = 0;
116
+ this.gpuPendingSpawnCount = 0;
117
+ }
118
+ applyRenderArtifact(material, baseGeometry) {
119
+ // Recomputed exactly as the constructor does, against this driver's OWN kernel buffers (never
120
+ // a JS behavior artifact's attributeWrites) - a stale merge here would wrongly pass a real
121
+ // layout change.
122
+ const components = mergeAttributeWidths(this.behaviorAttributeWidths, material.attributeReads);
123
+ const nextVaryings = varyingsFromComponents(components);
124
+ if (!sameVaryings(this.varyings, nextVaryings)) {
125
+ throw new Error("FXGPUEmitterDriver.applyRenderArtifact: the new render artifact's attribute reads no " +
126
+ "longer match this emitter's allocated buffers - a full rebuild (fromArtifacts) is required");
127
+ }
128
+ // this.varyings is value-equal to nextVaryings (just proved by sameVaryings above) - built
129
+ // from nextVaryings so a future reader never has to re-derive that equivalence to trust this.
130
+ const threeMaterial = material.buildThreeMaterial(nextVaryings);
131
+ this.mesh.replaceMaterialAndBaseGeometry(threeMaterial, baseGeometry);
132
+ }
133
+ destroy() {
134
+ this.mesh.destroy();
135
+ this.simulation.destroy();
136
+ }
137
+ }