sparcoon 0.7.3 → 0.9.0

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 (155) hide show
  1. package/README.md +124 -76
  2. package/dist/artifact/FXArtifact.d.ts +120 -0
  3. package/dist/artifact/FXArtifact.js +1 -0
  4. package/dist/coreLayout.d.ts +22 -0
  5. package/dist/coreLayout.js +32 -0
  6. package/dist/effect/FXEffect.d.ts +77 -0
  7. package/dist/effect/FXEffect.js +454 -0
  8. package/dist/effect/FXEffectSampling.Internal.d.ts +24 -0
  9. package/dist/effect/FXEffectSampling.Internal.js +165 -0
  10. package/dist/effect/FXEffectSpec.d.ts +115 -0
  11. package/dist/effect/FXEffectSpec.js +1 -0
  12. package/dist/emitter/FXEmitter.Internal.d.ts +23 -58
  13. package/dist/emitter/FXEmitter.Internal.js +13 -29
  14. package/dist/emitter/FXEmitter.d.ts +46 -48
  15. package/dist/emitter/FXEmitter.js +355 -186
  16. package/dist/emitter/FXFromArtifactsOptions.d.ts +13 -0
  17. package/dist/emitter/FXFromArtifactsOptions.js +1 -0
  18. package/dist/emitter/FXObjectMotion.Internal.d.ts +33 -0
  19. package/dist/emitter/FXObjectMotion.Internal.js +87 -0
  20. package/dist/emitter/FXSimulationHolder.d.ts +1 -0
  21. package/dist/emitter/FXSimulationHolder.js +43 -0
  22. package/dist/index.d.ts +10 -39
  23. package/dist/index.js +13 -36
  24. package/dist/instancedParticle/FXInstancedParticle.d.ts +1 -22
  25. package/dist/instancedParticle/FXInstancedParticle.js +145 -85
  26. package/dist/instancedParticle/glTypeInfo.d.ts +5 -0
  27. package/dist/instancedParticle/glTypeInfo.js +3 -0
  28. package/dist/instancedParticle/primitiveGeometry.d.ts +1 -0
  29. package/dist/instancedParticle/primitiveGeometry.js +30 -0
  30. package/dist/miscellaneous/asserts.d.ts +1 -18
  31. package/dist/miscellaneous/asserts.js +21 -28
  32. package/dist/miscellaneous/fxMath.d.ts +26 -0
  33. package/dist/miscellaneous/fxMath.js +261 -0
  34. package/dist/miscellaneous/math.d.ts +2 -7
  35. package/dist/miscellaneous/math.js +8 -9
  36. package/dist/miscellaneous/texture/fxDataTexture.d.ts +26 -0
  37. package/dist/miscellaneous/texture/fxDataTexture.js +20 -0
  38. package/dist/render/FXArtifactMaterial.Internal.d.ts +16 -0
  39. package/dist/render/FXArtifactMaterial.Internal.js +944 -0
  40. package/dist/render/FXArtifactMaterial.d.ts +1 -0
  41. package/dist/render/FXArtifactMaterial.js +79 -0
  42. package/dist/render/FXMaterialOptions.d.ts +27 -0
  43. package/dist/render/FXMaterialOptions.js +5 -0
  44. package/dist/render/FXMeshMaterial.d.ts +40 -0
  45. package/dist/render/FXMeshMaterial.js +77 -0
  46. package/dist/world/FXWorld.d.ts +42 -0
  47. package/dist/world/FXWorld.js +103 -0
  48. package/package.json +45 -29
  49. package/dist/_virtual/2d.js +0 -3
  50. package/dist/_virtual/3d.js +0 -3
  51. package/dist/_virtual/4d.js +0 -3
  52. package/dist/_virtual/mod.js +0 -5
  53. package/dist/_virtual/mod2.js +0 -3
  54. package/dist/emitter/behavior/FXBehavior.d.ts +0 -24
  55. package/dist/emitter/behavior/FXBehavior.js +0 -10
  56. package/dist/emitter/behavior/FXBehaviorDirectionalForce.d.ts +0 -23
  57. package/dist/emitter/behavior/FXBehaviorDirectionalForce.js +0 -72
  58. package/dist/emitter/behavior/FXBehaviorPointForce.d.ts +0 -36
  59. package/dist/emitter/behavior/FXBehaviorPointForce.js +0 -94
  60. package/dist/emitter/behavior/FXBehaviorScaleOverLife.d.ts +0 -24
  61. package/dist/emitter/behavior/FXBehaviorScaleOverLife.js +0 -55
  62. package/dist/emitter/behavior/FXBehaviorTorqueDamping.d.ts +0 -20
  63. package/dist/emitter/behavior/FXBehaviorTorqueDamping.js +0 -48
  64. package/dist/emitter/behavior/FXBehaviorTorqueNoise.d.ts +0 -25
  65. package/dist/emitter/behavior/FXBehaviorTorqueNoise.js +0 -67
  66. package/dist/emitter/behavior/FXBehaviorTorqueOverLife.d.ts +0 -19
  67. package/dist/emitter/behavior/FXBehaviorTorqueOverLife.js +0 -41
  68. package/dist/emitter/behavior/FXBehaviorVelocityDamping.d.ts +0 -20
  69. package/dist/emitter/behavior/FXBehaviorVelocityDamping.js +0 -50
  70. package/dist/emitter/behavior/FXBehaviorVelocityNoise.d.ts +0 -25
  71. package/dist/emitter/behavior/FXBehaviorVelocityNoise.js +0 -76
  72. package/dist/emitter/behavior/FXBehaviorVelocityOverLife.d.ts +0 -22
  73. package/dist/emitter/behavior/FXBehaviorVelocityOverLife.js +0 -55
  74. package/dist/emitter/spawn/FXSpawn.d.ts +0 -11
  75. package/dist/emitter/spawn/FXSpawn.js +0 -10
  76. package/dist/emitter/spawn/FXSpawnBox.d.ts +0 -18
  77. package/dist/emitter/spawn/FXSpawnBox.js +0 -50
  78. package/dist/emitter/spawn/FXSpawnLifetime.d.ts +0 -17
  79. package/dist/emitter/spawn/FXSpawnLifetime.js +0 -44
  80. package/dist/emitter/spawn/FXSpawnOffset.d.ts +0 -21
  81. package/dist/emitter/spawn/FXSpawnOffset.js +0 -49
  82. package/dist/emitter/spawn/FXSpawnPoint.d.ts +0 -18
  83. package/dist/emitter/spawn/FXSpawnPoint.js +0 -46
  84. package/dist/emitter/spawn/FXSpawnRotation.d.ts +0 -17
  85. package/dist/emitter/spawn/FXSpawnRotation.js +0 -42
  86. package/dist/emitter/spawn/FXSpawnScale.d.ts +0 -22
  87. package/dist/emitter/spawn/FXSpawnScale.js +0 -56
  88. package/dist/emitter/spawn/FXSpawnSphere.d.ts +0 -36
  89. package/dist/emitter/spawn/FXSpawnSphere.js +0 -87
  90. package/dist/emitter/spawn/FXSpawnTorque.d.ts +0 -17
  91. package/dist/emitter/spawn/FXSpawnTorque.js +0 -42
  92. package/dist/emitter/spawn/FXSpawnVelocity.d.ts +0 -29
  93. package/dist/emitter/spawn/FXSpawnVelocity.js +0 -91
  94. package/dist/instancedParticle/FXInstancedParticle.Internal.d.ts +0 -2
  95. package/dist/instancedParticle/shared.d.ts +0 -19
  96. package/dist/instancedParticle/shared.js +0 -116
  97. package/dist/materials/FXDiffuseMaterial/FXDiffuseMaterial.Internal.d.ts +0 -24
  98. package/dist/materials/FXDiffuseMaterial/FXDiffuseMaterial.Internal.js +0 -275
  99. package/dist/materials/FXDiffuseMaterial/FXDiffuseMaterial.d.ts +0 -75
  100. package/dist/materials/FXDiffuseMaterial/FXDiffuseMaterial.js +0 -63
  101. package/dist/materials/FXMaterial/FXBlending.Internal.d.ts +0 -24
  102. package/dist/materials/FXMaterial/FXBlending.Internal.js +0 -64
  103. package/dist/materials/FXMaterial/FXDepthMaterial.Internal.d.ts +0 -4
  104. package/dist/materials/FXMaterial/FXDepthMaterial.Internal.js +0 -144
  105. package/dist/materials/FXMaterial/FXDistanceMaterial.Internal.d.ts +0 -4
  106. package/dist/materials/FXMaterial/FXDistanceMaterial.Internal.js +0 -175
  107. package/dist/materials/FXMaterial/FXMaterial.d.ts +0 -70
  108. package/dist/materials/FXMaterial/FXMaterial.js +0 -51
  109. package/dist/materials/FXUnlitMaterial/FXUnlitMaterial.Internal.d.ts +0 -7
  110. package/dist/materials/FXUnlitMaterial/FXUnlitMaterial.Internal.js +0 -103
  111. package/dist/materials/FXUnlitMaterial/FXUnlitMaterial.d.ts +0 -7
  112. package/dist/materials/FXUnlitMaterial/FXUnlitMaterial.js +0 -21
  113. package/dist/miscellaneous/color/FXColor.Internal.d.ts +0 -7
  114. package/dist/miscellaneous/color/FXColor.Internal.js +0 -42
  115. package/dist/miscellaneous/color/FXColor.d.ts +0 -234
  116. package/dist/miscellaneous/color/FXColor.js +0 -636
  117. package/dist/miscellaneous/curve/FXCurve1D.Internal.d.ts +0 -20
  118. package/dist/miscellaneous/curve/FXCurve1D.d.ts +0 -17
  119. package/dist/miscellaneous/curve/FXCurve1D.js +0 -94
  120. package/dist/miscellaneous/miscellaneous.d.ts +0 -34
  121. package/dist/miscellaneous/miscellaneous.js +0 -85
  122. package/dist/miscellaneous/texture/FXTextureView.Internal.d.ts +0 -49
  123. package/dist/miscellaneous/texture/FXTextureView.Internal.js +0 -6
  124. package/dist/miscellaneous/texture/FXTextureView.d.ts +0 -72
  125. package/dist/miscellaneous/texture/FXTextureView.js +0 -234
  126. package/dist/miscellaneous/webglCapabilities.d.ts +0 -1
  127. package/dist/miscellaneous/webglCapabilities.js +0 -45
  128. package/dist/node_modules/fast-simplex-noise/lib/2d.js +0 -93
  129. package/dist/node_modules/fast-simplex-noise/lib/3d.js +0 -142
  130. package/dist/node_modules/fast-simplex-noise/lib/4d.js +0 -196
  131. package/dist/node_modules/fast-simplex-noise/lib/mod.js +0 -24
  132. package/dist/nodes/FXNode.d.ts +0 -5
  133. package/dist/nodes/FXNode.js +0 -10
  134. package/dist/nodes/blending/FXNodeBlending.d.ts +0 -6
  135. package/dist/nodes/blending/FXNodeBlending.js +0 -9
  136. package/dist/nodes/blending/FXNodeLightnessBlendingMask.d.ts +0 -19
  137. package/dist/nodes/blending/FXNodeLightnessBlendingMask.js +0 -65
  138. package/dist/nodes/color/FXNodeColor.d.ts +0 -6
  139. package/dist/nodes/color/FXNodeColor.js +0 -9
  140. package/dist/nodes/color/FXNodeColorOverLife.d.ts +0 -22
  141. package/dist/nodes/color/FXNodeColorOverLife.js +0 -64
  142. package/dist/nodes/color/FXNodeSphericalClip.d.ts +0 -21
  143. package/dist/nodes/color/FXNodeSphericalClip.js +0 -54
  144. package/dist/nodes/normal/FXNodeFlatNormal.d.ts +0 -6
  145. package/dist/nodes/normal/FXNodeFlatNormal.js +0 -16
  146. package/dist/nodes/normal/FXNodeNormal.d.ts +0 -6
  147. package/dist/nodes/normal/FXNodeNormal.js +0 -9
  148. package/dist/nodes/normal/FXNodeSphericalNormal.d.ts +0 -6
  149. package/dist/nodes/normal/FXNodeSphericalNormal.js +0 -23
  150. package/dist/nodes/texture/FXNodeAnimatedTexture.d.ts +0 -52
  151. package/dist/nodes/texture/FXNodeAnimatedTexture.js +0 -151
  152. package/dist/nodes/texture/FXNodeStaticTexture.d.ts +0 -20
  153. package/dist/nodes/texture/FXNodeStaticTexture.js +0 -67
  154. package/dist/nodes/texture/FXNodeTexture.d.ts +0 -6
  155. package/dist/nodes/texture/FXNodeTexture.js +0 -9
@@ -1,135 +1,191 @@
1
- import { Object3D, Vector3 } from 'three';
2
- import { FXInstancedParticle } from '../instancedParticle/FXInstancedParticle.js';
3
- import { resolveGLSLTypeInfo } from '../instancedParticle/shared.js';
4
- import { assertValidPositiveNumber, assertValidNonNegativeNumber } from '../miscellaneous/asserts.js';
5
- import { BUILTIN_OFFSET_AGE, BUILTIN_OFFSET_POSITION_X, BUILTIN_OFFSET_VELOCITY_X, BUILTIN_OFFSET_POSITION_Y, BUILTIN_OFFSET_VELOCITY_Y, BUILTIN_OFFSET_POSITION_Z, BUILTIN_OFFSET_VELOCITY_Z, BUILTIN_OFFSET_ROTATION, BUILTIN_OFFSET_TORQUE, BUILTIN_OFFSET_RANDOM_A, BUILTIN_OFFSET_RANDOM_B, BUILTIN_OFFSET_RANDOM_C } from '../miscellaneous/miscellaneous.js';
6
- import { EMITTERS, collectProperties, EMITTER_DEFAULT_EXPECTED_CAPACITY, EMITTER_DEFAULT_CAPACITY_STEP, EMITTER_DEFAULT_CAST_SHADOW, EMITTER_DEFAULT_RECEIVE_SHADOW, EMITTER_DEFAULT_SORT_FRACTION, EMITTER_DEFAULT_PREWARM_MIN_STEP_DURATION, EMITTER_DEFAULT_PREWARM_MAX_STEP_COUNT } from './FXEmitter.Internal.js';
7
-
1
+ import { Object3D, Quaternion, Vector3 } from "three";
2
+ import { FXWorld } from "../world/FXWorld.js";
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
+ import { resolveGeometrySource } from "../instancedParticle/primitiveGeometry.js";
7
+ import { assertValidNonNegativeNumber, assertValidPositiveInteger, assertValidPositiveNumber, } from "../miscellaneous/asserts.js";
8
+ import { FXArtifactMaterial } from "../render/FXArtifactMaterial.js";
9
+ import { FXSimulationHolder } from "./FXSimulationHolder.js";
10
+ 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
+ };
8
25
  /**
9
- * Particle emitter - add to a scene to make particles visible
10
- *
11
- * @remarks
12
- * Drives a pipeline of spawn modules (run once at particle birth) and behavior modules
13
- * (run every frame). Call {@link FXEmitter.onWillRender} each frame to tick the system.
26
+ * Particle emitter - one instanced billboard/mesh draw call, per-particle state driven by a
27
+ * precompiled behavior artifact and appearance by a precompiled render artifact spliced into a
28
+ * Three material. Built and ticked by {@link FXEffect} through an {@link FXWorld}. Not exported from
29
+ * the package (the runtime path is running artifacts, not hand-building emitters); reached only as
30
+ * the live handle returned by {@link FXEffect.getEmitter}.
14
31
  */
15
- class FXEmitter extends Object3D {
16
- /**
17
- * @param spawnSequence - Modules executed in order when particles are born
18
- * @param behaviorSequence - Modules executed in order every frame for all live particles
19
- * @param material - Particle material
20
- * @param options - Emitter configuration
21
- */
22
- constructor(_private_spawnSequence, _private_behaviorSequence, material, options = {}) {
32
+ export class FXEmitter extends Object3D {
33
+ /** @internal Use {@link FXEmitter.fromArtifacts}. */
34
+ constructor(material, simulation, options = {}, baseGeometry, world) {
23
35
  super();
24
- this._private_spawnSequence = _private_spawnSequence;
25
- this._private_behaviorSequence = _private_behaviorSequence;
26
- this._private_nextHandler = 0;
27
- this._private_pendingBursts = [];
28
- this._private_activePlays = [];
29
- this._private_sortingAccumulator = 0;
30
- this._private_sortingCameraWorldPosition = new Vector3();
31
- this._private_onRendering = (deltaTime) => {
32
- this._private_tick(deltaTime);
33
- if (this.sortCamera !== undefined && this._private_mesh.instanceCount > 0) {
34
- this._private_sortingAccumulator += this.sortFraction;
35
- if (this._private_sortingAccumulator >= 1) {
36
- this._private_sortingAccumulator -= 1;
37
- this.sortCamera.getWorldPosition(this._private_sortingCameraWorldPosition);
38
- this._private_mesh.sortByDistance(this._private_sortingCameraWorldPosition);
36
+ this.nextHandler = 0;
37
+ this.pendingBursts = [];
38
+ this.activePlays = [];
39
+ this.sortingAccumulator = 0;
40
+ this.sortingCameraWorldPosition = new Vector3();
41
+ // The emitter's `matrixWorld` as a column-major mat4, plus its world-space velocity/angular
42
+ // velocity, handed to the behavior kernel each spawn/update. Mutated in place - never
43
+ // reallocated per frame.
44
+ this.emitterTransform = {
45
+ worldMatrix: new Array(16).fill(0),
46
+ velocity: [0, 0, 0],
47
+ angularVelocity: [0, 0, 0],
48
+ };
49
+ // Reused decompose scratch (avoids a per-tick allocation); scale is discarded.
50
+ this.motionPositionScratch = new Vector3();
51
+ this.motionQuaternionScratch = new Quaternion();
52
+ this.motionScaleScratch = new Vector3();
53
+ this.motionTracker = new FXObjectMotionTracker();
54
+ this.destroyed = false;
55
+ this.kernelBuffersCacheVersion = -1;
56
+ this.onRendering = (deltaTime) => {
57
+ 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);
39
67
  }
40
68
  }
41
69
  };
70
+ this.world = world;
42
71
  if (options.expectedCapacity !== undefined) {
43
- assertValidPositiveNumber(options.expectedCapacity, "FXEmitter.constructor.options.expectedCapacity");
72
+ // Sizes the Float32Array buffers - a fractional length is a RangeError.
73
+ assertValidPositiveInteger(options.expectedCapacity, "FXEmitter.constructor.options.expectedCapacity");
44
74
  }
45
75
  if (options.capacityStep !== undefined) {
46
- assertValidPositiveNumber(options.capacityStep, "FXEmitter.constructor.options.capacityStep");
76
+ assertValidPositiveInteger(options.capacityStep, "FXEmitter.constructor.options.capacityStep");
47
77
  }
48
- if (options.sortFraction !== undefined) {
49
- assertValidNonNegativeNumber(options.sortFraction, "FXEmitter.constructor.options.sortFraction");
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);
87
+ if (options.receiveShadow ?? EMITTER_DEFAULT_RECEIVE_SHADOW) {
88
+ this.mesh.receiveShadow = true;
50
89
  }
51
- const collectedProperties = {
52
- builtin: resolveGLSLTypeInfo("Matrix4"),
53
- };
54
- collectProperties(collectedProperties, _private_spawnSequence, "FXEmitter.constructor.spawnSequence");
55
- collectProperties(collectedProperties, _private_behaviorSequence, "FXEmitter.constructor.behaviorSequence");
56
- material.prepare();
57
- const threeMaterial = material.buildThreeMaterial(collectedProperties);
58
- this._private_mesh = new FXInstancedParticle(collectedProperties, options.expectedCapacity ?? EMITTER_DEFAULT_EXPECTED_CAPACITY, options.capacityStep ?? EMITTER_DEFAULT_CAPACITY_STEP, threeMaterial);
90
+ // Pay for the second (depth) ShaderMaterial only when a cast shadow is actually requested.
91
+ // `castShadow`/`customDepthMaterial` are inherited Object3D members; `frustumCulled` is already
92
+ // false on the mesh, so the shadow pass's frustum check is a no-op just like the color pass.
59
93
  if (options.castShadow ?? EMITTER_DEFAULT_CAST_SHADOW) {
60
- this._private_mesh.castShadow = true;
61
- this._private_mesh.customDepthMaterial = material.buildDepthMaterial();
62
- this._private_mesh.customDistanceMaterial = material.buildDistanceMaterial();
63
- }
64
- if (options.receiveShadow ?? EMITTER_DEFAULT_RECEIVE_SHADOW) {
65
- this._private_mesh.receiveShadow = true;
94
+ this.mesh.castShadow = true;
95
+ this.mesh.customDepthMaterial = material.buildThreeDepthMaterial(this.collectedProperties);
66
96
  }
67
97
  this.sortCamera = options.sortCamera;
68
- this.sortFraction = options.sortFraction ?? EMITTER_DEFAULT_SORT_FRACTION;
69
- this._private_material = material;
70
- this.add(this._private_mesh);
71
- EMITTERS.push(this);
98
+ this.sortFraction = EMITTER_DEFAULT_SORT_FRACTION;
99
+ if (options.sortFraction !== undefined) {
100
+ this.setSortFraction(options.sortFraction, "FXEmitter.constructor.options.sortFraction");
101
+ }
102
+ this.assertCanSpawn("FXEmitter.constructor");
103
+ this.add(this.mesh);
104
+ this.world.registerEmitter(this);
72
105
  }
73
- /** Number of currently alive particles */
74
106
  get particleCount() {
75
- return this._private_mesh.instanceCount;
107
+ return this.mesh.instanceCount;
76
108
  }
77
- /** Maximum number of particles that fit in the currently allocated buffers */
78
109
  get particleCapacity() {
79
- return this._private_mesh.instanceCapacity;
110
+ return this.mesh.instanceCapacity;
111
+ }
112
+ /**
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.
115
+ */
116
+ static fromArtifacts(render, behavior, options = {}, world = FXWorld.getDefault()) {
117
+ const material = new FXArtifactMaterial(render, options.textures);
118
+ const holder = new FXSimulationHolder(behavior);
119
+ const baseGeometry = resolveGeometrySource(render.geometry, options.geometries ?? {});
120
+ return new FXEmitter(material, holder, options, baseGeometry, world);
121
+ }
122
+ /**
123
+ * @internal Ticks this emitter one frame. Driven by {@link FXWorld.update}, which advances the
124
+ * shared clock once and passes it in as `elapsedTime`.
125
+ */
126
+ onWorldTick(deltaTime, elapsedTime) {
127
+ this.onRendering(deltaTime);
128
+ this.material.setElapsedTime(elapsedTime);
129
+ this.material.setDeltaTime(deltaTime);
130
+ // emitterTransform.velocity/angularVelocity are always length 3 (see its declaration) -
131
+ // computed once per tick in onRendering -> tick.
132
+ this.material.setObjectVelocity(this.emitterTransform.velocity);
133
+ this.material.setObjectAngularVelocity(this.emitterTransform.angularVelocity);
80
134
  }
81
- /** Unregisters the emitter, removes it from the scene, and disposes modules and material */
135
+ /** Unregisters, removes from the scene, disposes mesh and material. Idempotent. */
82
136
  destroy() {
83
- const index = EMITTERS.indexOf(this);
84
- if (index !== -1) {
85
- EMITTERS.splice(index, 1);
86
- }
87
- for (const module of this._private_spawnSequence) {
88
- module.destroy?.();
89
- }
90
- for (const module of this._private_behaviorSequence) {
91
- module.destroy?.();
137
+ if (this.destroyed) {
138
+ return;
92
139
  }
93
- this._private_material.destroy();
94
- this._private_mesh.material?.dispose();
95
- this._private_mesh.customDepthMaterial?.dispose();
96
- this._private_mesh.customDistanceMaterial?.dispose();
140
+ 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.
143
+ this.pendingBursts.length = 0;
144
+ 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.
148
+ this.material.destroy();
149
+ this.mesh.destroy();
97
150
  this.removeFromParent();
98
151
  }
99
152
  /**
100
- * Spawns `count` particles immediately, or after a delay when `options.delay` is set
101
- *
102
- * @param count - Number of particles to spawn
103
- * @param options - Burst options
104
- * @returns Handler that can be passed to {@link stop} to cancel a pending delayed burst
153
+ * Spawns `count` particles immediately, or after `options.delay`.
154
+ * @returns Handler for {@link stop} to cancel a pending delayed burst.
105
155
  */
106
156
  burst(count, options = {}) {
107
- assertValidPositiveNumber(count, "FXEmitter.burst.count");
108
- const handler = this._private_nextHandler++;
157
+ // Whole particles only: a fractional count sets a fractional `instanceCount`.
158
+ assertValidPositiveInteger(count, "FXEmitter.burst.count");
159
+ this.assertCanSpawn("FXEmitter.burst");
160
+ if (this.destroyed) {
161
+ return DEAD_HANDLER;
162
+ }
163
+ const handler = this.nextHandler++;
109
164
  const delay = options.delay ?? 0;
110
165
  assertValidNonNegativeNumber(delay, "FXEmitter.burst.options.delay");
111
166
  if (delay <= 0) {
112
- this._private_spawnBurst(count);
167
+ this.spawnBurst(count);
113
168
  }
114
169
  else {
115
- this._private_pendingBursts.push({ handler, count, delay });
170
+ this.pendingBursts.push({ handler, count, delay });
116
171
  }
117
172
  return handler;
118
173
  }
119
174
  /**
120
- * Starts continuous emission at the given rate
121
- *
122
- * @param rate - Particles per second
123
- * @param options - Play options
124
- * @returns Handler that can be passed to {@link stop} to cancel
175
+ * Starts continuous emission at `rate` particles/second.
176
+ * @returns Handler for {@link stop} to cancel.
125
177
  */
126
178
  play(rate, options = {}) {
127
179
  assertValidPositiveNumber(rate, "FXEmitter.play.rate");
128
180
  if (options.delay !== undefined) {
129
181
  assertValidNonNegativeNumber(options.delay, "FXEmitter.play.options.delay");
130
182
  }
131
- const handler = this._private_nextHandler++;
132
- this._private_activePlays.push({
183
+ this.assertCanSpawn("FXEmitter.play");
184
+ if (this.destroyed) {
185
+ return DEAD_HANDLER;
186
+ }
187
+ const handler = this.nextHandler++;
188
+ this.activePlays.push({
133
189
  handler,
134
190
  rate,
135
191
  duration: options.duration ?? Infinity,
@@ -140,155 +196,268 @@ class FXEmitter extends Object3D {
140
196
  return handler;
141
197
  }
142
198
  /**
143
- * Stops an active play or pending burst by handler; stops all when called with no arguments
144
- *
145
- * @param handler - Handler returned by {@link play} or {@link burst}; omit to stop everything
146
- * @returns `true` if anything was stopped
199
+ * Stops an active play or pending burst by handler; stops all when called with no arguments.
200
+ * @returns `true` if anything was stopped.
147
201
  */
148
202
  stop(handler) {
203
+ if (this.destroyed) {
204
+ return false;
205
+ }
149
206
  if (handler === undefined) {
150
- const hadAnything = this._private_pendingBursts.length > 0 || this._private_activePlays.length > 0;
151
- this._private_pendingBursts.length = 0;
152
- this._private_activePlays.length = 0;
207
+ const hadAnything = this.pendingBursts.length > 0 || this.activePlays.length > 0;
208
+ this.pendingBursts.length = 0;
209
+ this.activePlays.length = 0;
153
210
  return hadAnything;
154
211
  }
155
- const burstIndex = this._private_pendingBursts.findIndex((b) => b.handler === handler);
212
+ const burstIndex = this.pendingBursts.findIndex((b) => b.handler === handler);
156
213
  if (burstIndex !== -1) {
157
- this._private_pendingBursts.splice(burstIndex, 1);
214
+ this.pendingBursts.splice(burstIndex, 1);
158
215
  return true;
159
216
  }
160
- const playIndex = this._private_activePlays.findIndex((p) => p.handler === handler);
217
+ const playIndex = this.activePlays.findIndex((play) => play.handler === handler);
161
218
  if (playIndex !== -1) {
162
- this._private_activePlays.splice(playIndex, 1);
219
+ this.activePlays.splice(playIndex, 1);
163
220
  return true;
164
221
  }
165
222
  return false;
166
223
  }
167
224
  /**
168
- * Simulates the emitter forward in time to avoid a cold-start on frame 0
169
- *
170
- * @param duration - Total time to simulate in seconds
171
- * @param stepDuration - Maximum simulation step size in seconds. Defaults to `1/60`
225
+ * Simulates forward in time to avoid a cold-start on frame 0.
226
+ * @param stepDuration - Maximum step size in seconds. Defaults to `1/60`.
172
227
  */
173
228
  prewarm(duration, stepDuration = EMITTER_DEFAULT_PREWARM_MIN_STEP_DURATION) {
174
229
  assertValidPositiveNumber(duration, "FXEmitter.prewarm.duration");
175
230
  assertValidPositiveNumber(stepDuration, "FXEmitter.prewarm.stepDuration");
231
+ if (this.destroyed) {
232
+ // Ticking a destroyed emitter would fire pending bursts and mutate disposed geometry.
233
+ return;
234
+ }
176
235
  const stepCount = Math.min(duration / Math.max(stepDuration, EMITTER_DEFAULT_PREWARM_MIN_STEP_DURATION), EMITTER_DEFAULT_PREWARM_MAX_STEP_COUNT);
177
236
  const step = duration / stepCount;
178
237
  let remaining = duration;
179
238
  while (remaining > 0) {
180
239
  const deltaTime = Math.min(remaining, step);
181
- this._private_tick(deltaTime);
240
+ this.tick(deltaTime);
182
241
  remaining -= deltaTime;
183
242
  }
184
243
  }
185
- /** Kills all live particles and cancels all active plays and pending bursts */
244
+ /** Kills all live particles and cancels all active plays and pending bursts. */
186
245
  reset() {
187
- this._private_pendingBursts.length = 0;
188
- this._private_activePlays.length = 0;
189
- this._private_mesh.drop();
246
+ if (this.destroyed) {
247
+ return;
248
+ }
249
+ this.pendingBursts.length = 0;
250
+ this.activePlays.length = 0;
251
+ this.mesh.drop();
252
+ // A restarted effect should not report a teleport spike from wherever the emitter was last
253
+ // posed - drop the velocity/angular-velocity baseline along with the particles.
254
+ this.motionTracker.reset();
255
+ }
256
+ /**
257
+ * Scrubs live parameter values into uniform/binding slots by name, picked up next frame - the
258
+ * runtime's value channel for a non-structural edit. Unknown name = safe no-op; never recompiles.
259
+ * A structural edit is instead a fresh {@link fromArtifacts} (the editor decides which).
260
+ */
261
+ applyValues(values) {
262
+ if (this.destroyed) {
263
+ return;
264
+ }
265
+ if (values.uniforms !== undefined) {
266
+ this.material.applyUniformValues(values.uniforms);
267
+ }
268
+ if (values.bindings !== undefined) {
269
+ this.simulation?.applyBindingValues(values.bindings);
270
+ }
271
+ }
272
+ /**
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.
275
+ */
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);
289
+ }
290
+ for (const read of this.material.attributeReads) {
291
+ add(read.name, read.components);
292
+ }
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
+ };
306
+ }
307
+ return properties;
308
+ }
309
+ setSortFraction(value, context) {
310
+ assertValidNonNegativeNumber(value, context);
311
+ // Clamp to [0, 1]: a value > 1 would grow `sortingAccumulator` without bound.
312
+ this.sortFraction = Math.min(value, 1);
313
+ }
314
+ // Throws when the behavior artifact cannot seed births. Checked at the scheduling points, NOT in
315
+ // the frame tick - a throw from the world tick would take the host's render loop down.
316
+ assertCanSpawn(context) {
317
+ if (this.simulation !== undefined && !this.simulation.canSpawn) {
318
+ throw new Error(`${context}: this behavior artifact cannot seed births (update-only)`);
319
+ }
320
+ }
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
+ // The emitter's world matrix as the reused snapshot the kernel reads. It is the mesh's
343
+ // `matrixWorld` (same matrix the shader and depth sort see), so a world-space graph stays
344
+ // consistent under any transform. `Matrix4.elements` is already column-major.
345
+ worldTransform() {
346
+ this.mesh.updateWorldMatrix(true, false);
347
+ const world = this.mesh.matrixWorld.elements;
348
+ const transform = this.emitterTransform;
349
+ for (let i = 0; i < 16; i += 1) {
350
+ transform.worldMatrix[i] = world[i];
351
+ }
352
+ return transform;
190
353
  }
191
- _private_tick(deltaTime) {
192
- // Age increment & cull dead particles
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
+ tick(deltaTime) {
365
+ // World-space velocity/angular velocity since the previous tick, computed once here and not
366
+ // inside worldTransform() - the burst/play loops below and the update phase can each call
367
+ // worldTransform() several times this tick, and diffing per-call instead of per-tick would
368
+ // corrupt the value on a frame with more than one of those (each call would diff against a
369
+ // baseline the previous call already advanced).
370
+ this.mesh.updateWorldMatrix(true, false);
371
+ this.mesh.matrixWorld.decompose(this.motionPositionScratch, this.motionQuaternionScratch, this.motionScaleScratch);
372
+ const motion = this.motionTracker.sample({
373
+ position: [
374
+ this.motionPositionScratch.x,
375
+ this.motionPositionScratch.y,
376
+ this.motionPositionScratch.z,
377
+ ],
378
+ quaternion: [
379
+ this.motionQuaternionScratch.x,
380
+ this.motionQuaternionScratch.y,
381
+ this.motionQuaternionScratch.z,
382
+ this.motionQuaternionScratch.w,
383
+ ],
384
+ }, deltaTime);
385
+ this.emitterTransform.velocity[0] = motion.velocity[0];
386
+ this.emitterTransform.velocity[1] = motion.velocity[1];
387
+ this.emitterTransform.velocity[2] = motion.velocity[2];
388
+ this.emitterTransform.angularVelocity[0] = motion.angularVelocity[0];
389
+ this.emitterTransform.angularVelocity[1] = motion.angularVelocity[1];
390
+ 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.
193
392
  {
194
- const { builtin } = this._private_mesh.propertyBuffers;
195
- const { array, itemSize } = builtin;
196
- for (let i = 0; i < this._private_mesh.instanceCount; i++) {
197
- array[i * itemSize + BUILTIN_OFFSET_AGE] += deltaTime;
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;
198
398
  }
199
399
  }
200
- this._private_mesh.removeDeadParticles();
201
- // Pending bursts (delayed)
202
- for (let i = this._private_pendingBursts.length - 1; i >= 0; i--) {
203
- const pending = this._private_pendingBursts[i];
400
+ this.mesh.removeDeadParticles();
401
+ for (let i = this.pendingBursts.length - 1; i >= 0; i--) {
402
+ const pending = this.pendingBursts[i];
204
403
  pending.delay -= deltaTime;
205
404
  if (pending.delay <= 0) {
206
- this._private_pendingBursts.splice(i, 1);
207
- this._private_spawnBurst(pending.count);
405
+ this.pendingBursts.splice(i, 1);
406
+ this.spawnBurst(pending.count);
208
407
  }
209
408
  }
210
- // Active plays
211
- for (let i = this._private_activePlays.length - 1; i >= 0; i--) {
212
- const play = this._private_activePlays[i];
409
+ for (let i = this.activePlays.length - 1; i >= 0; i--) {
410
+ const play = this.activePlays[i];
213
411
  let effectiveDeltaTime = deltaTime;
214
412
  if (play.delay > 0) {
215
413
  play.delay -= deltaTime;
216
414
  if (play.delay > 0) {
217
415
  continue;
218
416
  }
219
- // Delay just expired - use the overshoot as effective dt this tick
417
+ // Delay just expired - use the overshoot as effective dt this tick.
220
418
  effectiveDeltaTime = -play.delay;
221
419
  play.delay = 0;
222
420
  }
421
+ const previousElapsed = play.elapsed;
223
422
  play.elapsed += effectiveDeltaTime;
224
- if (play.elapsed >= play.duration) {
225
- this._private_activePlays.splice(i, 1);
226
- continue;
227
- }
228
- play.accumulator += play.rate * effectiveDeltaTime;
423
+ const expired = play.elapsed >= play.duration;
424
+ // On the final (partial) tick, emit only for the slice inside the play's duration, so the last
425
+ // fraction and the accumulator remainder still spawn instead of being dropped on removal.
426
+ const emitDeltaTime = expired
427
+ ? Math.max(0, play.duration - previousElapsed)
428
+ : effectiveDeltaTime;
429
+ play.accumulator += play.rate * emitDeltaTime;
229
430
  const particlesToSpawn = Math.floor(play.accumulator);
230
431
  if (particlesToSpawn > 0) {
231
- this._private_spawnBurst(particlesToSpawn);
432
+ this.spawnBurst(particlesToSpawn);
232
433
  play.accumulator -= particlesToSpawn;
233
434
  }
435
+ if (expired) {
436
+ this.activePlays.splice(i, 1);
437
+ }
234
438
  }
235
- if (this._private_mesh.instanceCount === 0) {
439
+ if (this.mesh.instanceCount === 0) {
236
440
  return;
237
441
  }
238
- {
239
- const { propertyBuffers, instanceCount } = this._private_mesh;
240
- for (const module of this._private_behaviorSequence) {
241
- module.update(propertyBuffers, instanceCount, deltaTime);
242
- }
243
- }
244
- {
245
- const { builtin } = this._private_mesh.propertyBuffers;
246
- const { array, itemSize } = builtin;
247
- for (let i = 0; i < this._private_mesh.instanceCount; i++) {
248
- const offset = i * itemSize;
249
- // Position += velocity * dt
250
- array[offset + BUILTIN_OFFSET_POSITION_X] +=
251
- array[offset + BUILTIN_OFFSET_VELOCITY_X] * deltaTime;
252
- array[offset + BUILTIN_OFFSET_POSITION_Y] +=
253
- array[offset + BUILTIN_OFFSET_VELOCITY_Y] * deltaTime;
254
- array[offset + BUILTIN_OFFSET_POSITION_Z] +=
255
- array[offset + BUILTIN_OFFSET_VELOCITY_Z] * deltaTime;
256
- // Rotation += torque * dt
257
- array[offset + BUILTIN_OFFSET_ROTATION] +=
258
- array[offset + BUILTIN_OFFSET_TORQUE] * deltaTime;
259
- }
260
- builtin.needsUpdate = true;
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);
261
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;
262
450
  }
263
- _private_spawnBurst(count) {
264
- const instanceBegin = this._private_mesh.instanceCount;
265
- this._private_mesh.createInstances(count);
266
- const instanceEnd = this._private_mesh.instanceCount;
267
- {
268
- const { builtin } = this._private_mesh.propertyBuffers;
269
- const { array, itemSize } = builtin;
270
- for (let i = instanceBegin; i < instanceEnd; i++) {
271
- const itemOffset = i * itemSize;
272
- array[itemOffset + BUILTIN_OFFSET_RANDOM_A] = Math.random();
273
- array[itemOffset + BUILTIN_OFFSET_RANDOM_B] = Math.random();
274
- array[itemOffset + BUILTIN_OFFSET_RANDOM_C] = Math.random();
275
- }
276
- builtin.needsUpdate = true;
277
- }
278
- for (const spawnModule of this._private_spawnSequence) {
279
- spawnModule.spawn(this._private_mesh.propertyBuffers, instanceBegin, instanceEnd);
451
+ 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);
280
459
  }
281
460
  }
282
461
  }
283
- /**
284
- * Ticks all active emitters; call once per frame before rendering
285
- *
286
- * @param deltaTime - Elapsed time in seconds since the last frame
287
- */
288
- FXEmitter.onWillRender = (deltaTime) => {
289
- for (const instance of EMITTERS) {
290
- instance._private_onRendering(deltaTime);
291
- }
292
- };
293
-
294
- export { FXEmitter };
462
+ // Returned by post-destroy `burst`/`play` - never matches a scheduled handler.
463
+ const DEAD_HANDLER = -1;