sparcoon 0.7.2 → 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 -17
  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 -24
  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
@@ -0,0 +1,454 @@
1
+ import { Group, Mesh, Quaternion, Vector3, } from "three";
2
+ import { FXEmitter } from "../emitter/FXEmitter.js";
3
+ import { FXObjectMotionTracker } from "../emitter/FXObjectMotion.Internal.js";
4
+ import { resolveGeometrySource } from "../instancedParticle/primitiveGeometry.js";
5
+ import { FXMeshMaterial } from "../render/FXMeshMaterial.js";
6
+ import { FXWorld } from "../world/FXWorld.js";
7
+ import { frameOfTime, normalizeQuaternion, sampleTracks, sampleTransform, timeOfFrame, } from "./FXEffectSampling.Internal.js";
8
+ const VECTOR_COMPONENTS = ["x", "y", "z", "w"];
9
+ const PARAMETER_UNIFORM_PREFIX = "u_param_";
10
+ const PARAMETER_BINDING_PREFIX = "b_param_";
11
+ /**
12
+ * Writes `transform` onto `object`, skipping any channel in `liveChannels` - a "fake" channel the
13
+ * editor excludes from the exported keyframe data, left for the consumer to drive directly through
14
+ * the returned runtime object's own `position`/`quaternion`/`scale`.
15
+ */
16
+ function applyTransform(object, transform, liveChannels) {
17
+ if (liveChannels?.includes("position") !== true) {
18
+ object.position.set(transform.position[0], transform.position[1], transform.position[2]);
19
+ }
20
+ if (liveChannels?.includes("rotation") !== true) {
21
+ const quaternion = normalizeQuaternion(transform.rotation);
22
+ object.quaternion.set(quaternion[0], quaternion[1], quaternion[2], quaternion[3]);
23
+ }
24
+ if (liveChannels?.includes("scale") !== true) {
25
+ object.scale.set(transform.scale[0], transform.scale[1], transform.scale[2]);
26
+ }
27
+ }
28
+ /** Materializes a possibly-readonly keyframe value into the mutable shape the value writers expect. */
29
+ function materializeValue(value) {
30
+ return typeof value === "number" ? value : [...value];
31
+ }
32
+ // Duck-typed, not `instanceof`: the app's `three` and this package's peer-dep `three` can be two
33
+ // separate module instances (e.g. hoisted differently across a workspace), which would make
34
+ // `instanceof Texture`/`instanceof BufferGeometry` false for a perfectly good asset. Every `three`
35
+ // object of these kinds carries this own-property flag regardless of which module instance built it.
36
+ function isTexture(asset) {
37
+ return asset.isTexture === true;
38
+ }
39
+ function isBufferGeometry(asset) {
40
+ return asset.isBufferGeometry === true;
41
+ }
42
+ function texturesForSlots(slots, assets) {
43
+ const textures = {};
44
+ for (const name of slots) {
45
+ const asset = assets[name];
46
+ if (isTexture(asset)) {
47
+ textures[PARAMETER_UNIFORM_PREFIX + name] = asset;
48
+ }
49
+ }
50
+ return textures;
51
+ }
52
+ /** Every `BufferGeometry`-typed entry in `assets`, for {@link resolveGeometrySource}'s lookup - a
53
+ * custom geometry slot is a single name, not a per-shader-uniform slot list like textures. */
54
+ function geometriesFromAssets(assets) {
55
+ const geometries = {};
56
+ for (const name in assets) {
57
+ const asset = assets[name];
58
+ if (isBufferGeometry(asset)) {
59
+ geometries[name] = asset;
60
+ }
61
+ }
62
+ return geometries;
63
+ }
64
+ /** Writes one parameter's sampled value into whichever render uniform / behavior bindings it declares. */
65
+ function writeParameterSlots(render, behavior, name, value, uniforms, bindings) {
66
+ const uniformKey = PARAMETER_UNIFORM_PREFIX + name;
67
+ if (uniformKey in render.uniforms) {
68
+ uniforms[uniformKey] = value;
69
+ }
70
+ const bindingKey = PARAMETER_BINDING_PREFIX + name;
71
+ if (bindingKey in behavior.bindings) {
72
+ bindings[bindingKey] = typeof value === "number" ? value : (value[0] ?? 0);
73
+ return;
74
+ }
75
+ const vector = typeof value === "number" ? [value] : value;
76
+ for (let i = 0; i < VECTOR_COMPONENTS.length; i += 1) {
77
+ const key = bindingKey + "_" + VECTOR_COMPONENTS[i];
78
+ if (key in behavior.bindings) {
79
+ bindings[key] = vector[i] ?? 0;
80
+ }
81
+ }
82
+ }
83
+ /** Restores one parameter's baked baseline (a parameter that stopped being driven this frame). */
84
+ function writeParameterBaseline(render, behavior, name, uniforms, bindings) {
85
+ const uniformKey = PARAMETER_UNIFORM_PREFIX + name;
86
+ const uniform = render.uniforms[uniformKey];
87
+ // Types say `value` is defined once `"value" in uniform`, but guard the runtime
88
+ // `{ external, value: undefined }` case so we never bake an undefined baseline.
89
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
90
+ if (uniform !== undefined && "value" in uniform && uniform.value !== undefined) {
91
+ uniforms[uniformKey] = uniform.value;
92
+ }
93
+ const bindingKey = PARAMETER_BINDING_PREFIX + name;
94
+ for (const key in behavior.bindings) {
95
+ if (key === bindingKey || key.startsWith(bindingKey + "_")) {
96
+ bindings[key] = behavior.bindings[key].value;
97
+ }
98
+ }
99
+ }
100
+ // A VFX mesh has no behavior, so its value drive addresses render uniforms only; this stand-in
101
+ // makes the shared slot writer a no-op on the binding side.
102
+ const NO_BEHAVIOR_BINDINGS = { bindings: {} };
103
+ /**
104
+ * Runtime for a whole exported Sparcoon project: a `Group` that builds every emitter + VFX mesh
105
+ * from precompiled artifacts and drives the shared timeline. Add it to your scene, call `play()`,
106
+ * and `update(deltaSeconds)` each frame. Lit graphs read the scene's lights, so a lit effect needs
107
+ * a light probe + directional light in the host scene (studio lighting is not part of the export).
108
+ * An editor-emitted module supplies only the data (`FXEffectSpec` + a scene name) and a thin named
109
+ * subclass; this base class is identical for every export.
110
+ *
111
+ * The timeline drive mirrors the editor's own transport: burst/play events fire across the
112
+ * continuous swept interval, while value + transform tracks are frame-stepped onto the `fps` grid.
113
+ */
114
+ export class FXEffect extends Group {
115
+ constructor(specification, assets, options = {}) {
116
+ super();
117
+ this.emitters = [];
118
+ this.meshMaterials = [];
119
+ this.meshes = [];
120
+ // World-space velocity/angular-velocity tracker per mesh, parallel to `meshes`.
121
+ this.meshMotionTrackers = [];
122
+ this.emitterDriven = [];
123
+ this.meshDriven = [];
124
+ // Name -> index, for the live-update API (getEmitter/getMesh/setEmitterParam/setMeshParam). A
125
+ // name the editor duplicated across entities resolves to the last one built (Map.set overwrites).
126
+ this.emitterIndexByName = new Map();
127
+ this.meshIndexByName = new Map();
128
+ this.time = 0;
129
+ this.lastTime = 0;
130
+ this.lastFrame = -1;
131
+ this.meshClock = 0;
132
+ this.playing = false;
133
+ this.disposed = false;
134
+ // Reused decompose scratch for the per-mesh motion diff (avoids a per-tick allocation); scale
135
+ // is discarded.
136
+ this.meshPositionScratch = new Vector3();
137
+ this.meshQuaternionScratch = new Quaternion();
138
+ this.meshScaleScratch = new Vector3();
139
+ this.specification = specification;
140
+ this.world = options.world ?? FXWorld.getDefault();
141
+ applyTransform(this, specification.transform);
142
+ const geometries = geometriesFromAssets(assets);
143
+ for (const emitterSpec of specification.emitters) {
144
+ const textures = texturesForSlots(emitterSpec.externalSlots, assets);
145
+ const emitter = FXEmitter.fromArtifacts(emitterSpec.render, emitterSpec.behavior, {
146
+ textures,
147
+ geometries,
148
+ expectedCapacity: emitterSpec.expectedCapacity,
149
+ castShadow: emitterSpec.castShadow ?? false,
150
+ receiveShadow: emitterSpec.receiveShadow ?? false,
151
+ }, this.world);
152
+ if (options.camera !== undefined && emitterSpec.sortInterval > 0) {
153
+ emitter.sortCamera = options.camera;
154
+ emitter.sortFraction = 1 / emitterSpec.sortInterval;
155
+ }
156
+ applyTransform(emitter, emitterSpec.transform);
157
+ this.add(emitter);
158
+ this.emitterIndexByName.set(emitterSpec.name, this.emitters.length);
159
+ this.emitters.push(emitter);
160
+ this.emitterDriven.push(new Set());
161
+ }
162
+ for (const meshSpec of specification.meshes) {
163
+ const textures = texturesForSlots(meshSpec.externalSlots, assets);
164
+ const material = new FXMeshMaterial(meshSpec.render, textures);
165
+ const mesh = new Mesh(resolveGeometrySource(meshSpec.geometry, geometries), material.buildThreeMaterial());
166
+ if (meshSpec.receiveShadow ?? false) {
167
+ mesh.receiveShadow = true;
168
+ }
169
+ if (meshSpec.castShadow ?? false) {
170
+ mesh.castShadow = true;
171
+ mesh.customDepthMaterial = material.buildThreeDepthMaterial();
172
+ }
173
+ applyTransform(mesh, meshSpec.transform);
174
+ this.add(mesh);
175
+ this.meshIndexByName.set(meshSpec.name, this.meshes.length);
176
+ this.meshes.push(mesh);
177
+ this.meshMaterials.push(material);
178
+ this.meshMotionTrackers.push(new FXObjectMotionTracker());
179
+ this.meshDriven.push(new Set());
180
+ }
181
+ // Pose + value drive at the timeline start, so the effect shows its authored frame-0 state
182
+ // before playback begins.
183
+ this.driveFrame(0);
184
+ // Self-subscribe: from here the world drives this effect every frame; dispose() unsubscribes.
185
+ this.world.registerEffect(this);
186
+ }
187
+ /** Restarts the timeline from the top: clears live particles and begins playback. */
188
+ play() {
189
+ if (this.disposed) {
190
+ return;
191
+ }
192
+ this.resetTransport();
193
+ this.playing = true;
194
+ this.driveFrame(0);
195
+ }
196
+ /** Halts playback and clears live particles, returning to the authored frame-0 state. */
197
+ stop() {
198
+ if (this.disposed) {
199
+ return;
200
+ }
201
+ this.playing = false;
202
+ this.resetTransport();
203
+ this.driveFrame(0);
204
+ }
205
+ /**
206
+ * @internal Advances this effect one frame; driven by {@link FXWorld.update}. Drives only the
207
+ * timeline and the meshes - the world ticks the shared particle pool separately, exactly once.
208
+ * A stopped effect stays frozen.
209
+ */
210
+ advanceFrame(deltaSeconds) {
211
+ if (this.disposed) {
212
+ return;
213
+ }
214
+ // Gate the simulation on playback, exactly like the editor's transport: stopped => 0, so live
215
+ // particles and mesh clocks hold instead of drifting while the effect is not playing.
216
+ const simulationDelta = this.playing ? deltaSeconds : 0;
217
+ if (this.playing) {
218
+ this.advance(deltaSeconds);
219
+ }
220
+ this.meshClock += simulationDelta;
221
+ for (let i = 0; i < this.meshes.length; i += 1) {
222
+ const mesh = this.meshes[i];
223
+ // World-space velocity/angular velocity since the previous frame, from the mesh's own
224
+ // matrixWorld (posed by `advance()` -> `driveFrame()` above, or by a host live-driving it
225
+ // directly through `getMesh()`) - never from PARTICLE_POSITION-style local state, since a
226
+ // mesh is a single non-instanced object with no such thing.
227
+ mesh.updateWorldMatrix(true, false);
228
+ mesh.matrixWorld.decompose(this.meshPositionScratch, this.meshQuaternionScratch, this.meshScaleScratch);
229
+ const motion = this.meshMotionTrackers[i].sample({
230
+ position: [
231
+ this.meshPositionScratch.x,
232
+ this.meshPositionScratch.y,
233
+ this.meshPositionScratch.z,
234
+ ],
235
+ quaternion: [
236
+ this.meshQuaternionScratch.x,
237
+ this.meshQuaternionScratch.y,
238
+ this.meshQuaternionScratch.z,
239
+ this.meshQuaternionScratch.w,
240
+ ],
241
+ }, simulationDelta);
242
+ const material = this.meshMaterials[i];
243
+ material.setElapsedTime(this.meshClock);
244
+ material.setDeltaTime(simulationDelta);
245
+ material.setObjectVelocity(motion.velocity);
246
+ material.setObjectAngularVelocity(motion.angularVelocity);
247
+ }
248
+ }
249
+ /**
250
+ * The runtime emitter named `name` (its `FXEmitter`, an `Object3D`) - for a live channel the
251
+ * editor marked "fake" (see {@link FXEffectSpec}), pose it directly through its own
252
+ * `position`/`quaternion`/`scale`; `driveFrame` never touches that channel again. `undefined`
253
+ * for an unknown name or once disposed (matching {@link dispose}'s emptied index).
254
+ */
255
+ getEmitter(name) {
256
+ if (this.disposed) {
257
+ return undefined;
258
+ }
259
+ const index = this.emitterIndexByName.get(name);
260
+ return index === undefined ? undefined : this.emitters[index];
261
+ }
262
+ /** The runtime mesh named `name` (a `three.Mesh`), for the same live-channel posing as {@link getEmitter}. */
263
+ getMesh(name) {
264
+ if (this.disposed) {
265
+ return undefined;
266
+ }
267
+ const index = this.meshIndexByName.get(name);
268
+ return index === undefined ? undefined : this.meshes[index];
269
+ }
270
+ /**
271
+ * Pushes `value` into emitter `name`'s Timeline Value `parameter` (a uniform/binding slot the
272
+ * editor marked "fake", so it is never sampled from baked keyframes). No-op for an unknown
273
+ * emitter, or once disposed - the underlying slot writer already no-ops for an unknown parameter.
274
+ */
275
+ setEmitterParam(name, parameter, value) {
276
+ if (this.disposed) {
277
+ return;
278
+ }
279
+ const index = this.emitterIndexByName.get(name);
280
+ if (index === undefined) {
281
+ return;
282
+ }
283
+ const specification = this.specification.emitters[index];
284
+ const uniforms = {};
285
+ const bindings = {};
286
+ writeParameterSlots(specification.render, specification.behavior, parameter, materializeValue(value), uniforms, bindings);
287
+ if (Object.keys(uniforms).length > 0 || Object.keys(bindings).length > 0) {
288
+ this.emitters[index].applyValues({ uniforms, bindings });
289
+ }
290
+ }
291
+ /** Pushes `value` into mesh `name`'s Timeline Value `parameter`. See {@link setEmitterParam}. */
292
+ setMeshParam(name, parameter, value) {
293
+ if (this.disposed) {
294
+ return;
295
+ }
296
+ const index = this.meshIndexByName.get(name);
297
+ if (index === undefined) {
298
+ return;
299
+ }
300
+ const specification = this.specification.meshes[index];
301
+ const uniforms = {};
302
+ const bindings = {};
303
+ writeParameterSlots(specification.render, NO_BEHAVIOR_BINDINGS, parameter, materializeValue(value), uniforms, bindings);
304
+ if (Object.keys(uniforms).length > 0) {
305
+ this.meshMaterials[index].applyUniformValues(uniforms);
306
+ }
307
+ }
308
+ /**
309
+ * Unsubscribes from its world, destroys every emitter, and disposes mesh resources; the group
310
+ * empties itself from its parent. Idempotent.
311
+ */
312
+ dispose() {
313
+ if (this.disposed) {
314
+ return;
315
+ }
316
+ this.disposed = true;
317
+ this.playing = false;
318
+ this.world.unregisterEffect(this);
319
+ for (const emitter of this.emitters) {
320
+ emitter.destroy();
321
+ }
322
+ this.emitters.length = 0;
323
+ for (let i = 0; i < this.meshes.length; i += 1) {
324
+ const mesh = this.meshes[i];
325
+ // A "custom" geometry is the app's own `assets` object - only a primitive built
326
+ // by `resolveGeometrySource` is ours to dispose.
327
+ if (this.specification.meshes[i].geometry.type === "primitive") {
328
+ mesh.geometry.dispose();
329
+ }
330
+ mesh.material.dispose();
331
+ }
332
+ this.meshes.length = 0;
333
+ this.meshMaterials.length = 0;
334
+ this.removeFromParent();
335
+ }
336
+ resetTransport() {
337
+ this.time = 0;
338
+ this.lastTime = 0;
339
+ this.lastFrame = -1;
340
+ // meshClock is deliberately NOT reset: a mesh's time-driven shader runs on monotonic sim time,
341
+ // decoupled from the timeline playhead, so a replay does not snap its phase back. The motion
342
+ // trackers ARE reset - unlike meshClock's phase, a restarted effect should not report a
343
+ // teleport-spike velocity from wherever a mesh was last posed.
344
+ for (const emitter of this.emitters) {
345
+ emitter.stop();
346
+ emitter.reset();
347
+ }
348
+ for (const tracker of this.meshMotionTrackers) {
349
+ tracker.reset();
350
+ }
351
+ }
352
+ advance(deltaSeconds) {
353
+ const duration = this.specification.duration;
354
+ let now = this.time + deltaSeconds;
355
+ if (duration > 0 && now >= duration) {
356
+ // Loop wrap: finish this lap's tail, reset, then replay from the remainder.
357
+ this.fire(this.lastTime, duration);
358
+ for (const emitter of this.emitters) {
359
+ emitter.stop();
360
+ emitter.reset();
361
+ }
362
+ now = now % duration;
363
+ this.lastTime = 0;
364
+ this.lastFrame = -1;
365
+ }
366
+ this.fire(this.lastTime, now);
367
+ this.lastTime = now;
368
+ this.time = now;
369
+ const frame = frameOfTime(now, this.specification.fps);
370
+ if (frame !== this.lastFrame) {
371
+ this.lastFrame = frame;
372
+ this.driveFrame(timeOfFrame(frame, this.specification.fps));
373
+ }
374
+ }
375
+ /** Fires burst/play events whose time lies in `[from, to)` on their owning emitter. */
376
+ fire(from, to) {
377
+ if (to <= from) {
378
+ return;
379
+ }
380
+ for (let i = 0; i < this.specification.emitters.length; i += 1) {
381
+ const emitter = this.emitters[i];
382
+ for (const event of this.specification.emitters[i].events) {
383
+ if (event.time < from || event.time >= to) {
384
+ continue;
385
+ }
386
+ // Guard degenerate authored counts/rates: FXEmitter asserts a positive count/rate and would
387
+ // throw straight out of the host's frame loop, so a zero-count burst is a no-op here.
388
+ if (event.kind === "burst") {
389
+ if (event.count > 0) {
390
+ emitter.burst(event.count);
391
+ }
392
+ }
393
+ else if (event.rate > 0) {
394
+ emitter.play(event.rate, event.duration > 0 ? { duration: event.duration } : {});
395
+ }
396
+ }
397
+ }
398
+ }
399
+ /**
400
+ * Poses every entity and scrubs its sampled parameter values into the runtime at time `time`. The
401
+ * root itself is never re-posed here (see {@link FXEffectSpec.transformTracks}) - only
402
+ * emitters/meshes, and only their non-live channels/parameters.
403
+ */
404
+ driveFrame(time) {
405
+ for (let i = 0; i < this.specification.emitters.length; i += 1) {
406
+ const emitterSpec = this.specification.emitters[i];
407
+ applyTransform(this.emitters[i], sampleTransform(emitterSpec.transform, emitterSpec.transformTracks, time), emitterSpec.liveChannels);
408
+ this.driveEmitterValues(i, sampleTracks(emitterSpec.tracks, time));
409
+ }
410
+ for (let i = 0; i < this.specification.meshes.length; i += 1) {
411
+ const meshSpec = this.specification.meshes[i];
412
+ applyTransform(this.meshes[i], sampleTransform(meshSpec.transform, meshSpec.transformTracks, time), meshSpec.liveChannels);
413
+ this.driveMeshValues(i, sampleTracks(meshSpec.tracks, time));
414
+ }
415
+ }
416
+ driveEmitterValues(index, values) {
417
+ const specification = this.specification.emitters[index];
418
+ const driven = this.emitterDriven[index];
419
+ const uniforms = {};
420
+ const bindings = {};
421
+ for (const [name, value] of values) {
422
+ writeParameterSlots(specification.render, specification.behavior, name, value, uniforms, bindings);
423
+ driven.add(name);
424
+ }
425
+ for (const name of [...driven]) {
426
+ if (!values.has(name)) {
427
+ writeParameterBaseline(specification.render, specification.behavior, name, uniforms, bindings);
428
+ driven.delete(name);
429
+ }
430
+ }
431
+ if (Object.keys(uniforms).length > 0 || Object.keys(bindings).length > 0) {
432
+ this.emitters[index].applyValues({ uniforms, bindings });
433
+ }
434
+ }
435
+ driveMeshValues(index, values) {
436
+ const specification = this.specification.meshes[index];
437
+ const driven = this.meshDriven[index];
438
+ const uniforms = {};
439
+ const bindings = {};
440
+ for (const [name, value] of values) {
441
+ writeParameterSlots(specification.render, NO_BEHAVIOR_BINDINGS, name, value, uniforms, bindings);
442
+ driven.add(name);
443
+ }
444
+ for (const name of [...driven]) {
445
+ if (!values.has(name)) {
446
+ writeParameterBaseline(specification.render, NO_BEHAVIOR_BINDINGS, name, uniforms, bindings);
447
+ driven.delete(name);
448
+ }
449
+ }
450
+ if (Object.keys(uniforms).length > 0) {
451
+ this.meshMaterials[index].applyUniformValues(uniforms);
452
+ }
453
+ }
454
+ }
@@ -0,0 +1,24 @@
1
+ import type { FXKeyframe, FXQuat, FXTrack, FXTransform, FXTransformTrack, FXVec3 } from "./FXEffectSpec.js";
2
+ export declare function frameOfTime(time: number, fps: number): number;
3
+ export declare function timeOfFrame(frame: number, fps: number): number;
4
+ /** The bounding keys straddling `time` and the fraction between (held at the ends). */
5
+ export declare function keyframeSegment(keys: readonly FXKeyframe[], time: number): {
6
+ readonly a: FXKeyframe;
7
+ readonly b: FXKeyframe;
8
+ readonly u: number;
9
+ } | undefined;
10
+ export declare function sampleTrack(track: FXTrack, time: number): number | number[] | undefined;
11
+ export declare function sampleTracks(tracks: readonly FXTrack[], time: number): Map<string, number | number[]>;
12
+ /** A keyframe value read as a vec3 (missing components default to 0). */
13
+ export declare function vectorFromValue(value: number | readonly number[]): FXVec3;
14
+ /** A keyframe value read as a quaternion, falling back to identity for a malformed value. */
15
+ export declare function quaternionFromValue(value: number | readonly number[]): FXQuat;
16
+ /** Renormalizes a quaternion (a componentwise-lerped or hand-edited one drifts off the unit sphere). */
17
+ export declare function normalizeQuaternion(quaternion: FXQuat): FXQuat;
18
+ /** Spherical-linear blend of two quaternions at `u in [0, 1]` (shortest arc). */
19
+ export declare function slerpQuaternion(a: FXQuat, b: FXQuat, u: number): FXQuat;
20
+ /**
21
+ * The effective transform at time `time`: each channel with keyframes is sampled from its track, and
22
+ * any channel without keys holds the entity's authored `base` value.
23
+ */
24
+ export declare function sampleTransform(base: FXTransform, tracks: readonly FXTransformTrack[], time: number): FXTransform;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Pure keyframe/transform sampling math for {@link FXEffect}'s timeline drive: hold-before-first,
3
+ * hold-after-last, linear interpolation between (slerp for rotation). No side effects, no `three`
4
+ * dependency. Every export here also backs the editor's own track/transform sampling: the editor
5
+ * used to hand-duplicate this exact math under different type names, and now imports it from here
6
+ * (this stays an `.Internal.ts` module, outside the public `sparcoon` package surface) so the two
7
+ * can never drift apart.
8
+ */
9
+ const IDENTITY_QUAT = [0, 0, 0, 1];
10
+ export function frameOfTime(time, fps) {
11
+ return fps > 0 ? Math.round(time * fps) : 0;
12
+ }
13
+ export function timeOfFrame(frame, fps) {
14
+ return fps > 0 ? frame / fps : 0;
15
+ }
16
+ /** The bounding keys straddling `time` and the fraction between (held at the ends). */
17
+ export function keyframeSegment(keys, time) {
18
+ if (keys.length === 0) {
19
+ return undefined;
20
+ }
21
+ const first = keys[0];
22
+ if (time <= first.time || keys.length === 1) {
23
+ return { a: first, b: first, u: 0 };
24
+ }
25
+ const last = keys[keys.length - 1];
26
+ if (time >= last.time) {
27
+ return { a: last, b: last, u: 0 };
28
+ }
29
+ let a = first;
30
+ let b = last;
31
+ for (let i = 1; i < keys.length; i += 1) {
32
+ const key = keys[i];
33
+ if (key.time >= time) {
34
+ a = keys[i - 1];
35
+ b = key;
36
+ break;
37
+ }
38
+ }
39
+ const span = b.time - a.time;
40
+ return { a, b, u: span > 0 ? (time - a.time) / span : 0 };
41
+ }
42
+ function lerpValue(a, b, u) {
43
+ if (typeof a === "number" && typeof b === "number") {
44
+ return a + (b - a) * u;
45
+ }
46
+ const aArray = typeof a === "number" ? [a] : a;
47
+ const bArray = typeof b === "number" ? [b] : b;
48
+ const width = Math.min(aArray.length, bArray.length);
49
+ const output = [];
50
+ for (let i = 0; i < width; i += 1) {
51
+ output.push(aArray[i] + (bArray[i] - aArray[i]) * u);
52
+ }
53
+ return output;
54
+ }
55
+ export function sampleTrack(track, time) {
56
+ const segment = keyframeSegment(track.keys, time);
57
+ if (segment === undefined) {
58
+ return undefined;
59
+ }
60
+ return lerpValue(segment.a.value, segment.b.value, segment.u);
61
+ }
62
+ export function sampleTracks(tracks, time) {
63
+ const values = new Map();
64
+ for (const track of tracks) {
65
+ const value = sampleTrack(track, time);
66
+ if (value !== undefined) {
67
+ values.set(track.name, value);
68
+ }
69
+ }
70
+ return values;
71
+ }
72
+ /** A keyframe value read as a vec3 (missing components default to 0). */
73
+ export function vectorFromValue(value) {
74
+ if (typeof value === "number") {
75
+ return [value, value, value];
76
+ }
77
+ return [value[0] ?? 0, value[1] ?? 0, value[2] ?? 0];
78
+ }
79
+ /** A keyframe value read as a quaternion, falling back to identity for a malformed value. */
80
+ export function quaternionFromValue(value) {
81
+ if (typeof value === "number" || value.length < 4) {
82
+ return IDENTITY_QUAT;
83
+ }
84
+ return [value[0] ?? 0, value[1] ?? 0, value[2] ?? 0, value[3] ?? 1];
85
+ }
86
+ /** Renormalizes a quaternion (a componentwise-lerped or hand-edited one drifts off the unit sphere). */
87
+ export function normalizeQuaternion(quaternion) {
88
+ const length = Math.hypot(quaternion[0], quaternion[1], quaternion[2], quaternion[3]);
89
+ if (length === 0) {
90
+ return IDENTITY_QUAT;
91
+ }
92
+ return [
93
+ quaternion[0] / length,
94
+ quaternion[1] / length,
95
+ quaternion[2] / length,
96
+ quaternion[3] / length,
97
+ ];
98
+ }
99
+ /** Spherical-linear blend of two quaternions at `u in [0, 1]` (shortest arc). */
100
+ export function slerpQuaternion(a, b, u) {
101
+ let ax = a[0];
102
+ let ay = a[1];
103
+ let az = a[2];
104
+ let aw = a[3];
105
+ const bx = b[0];
106
+ const by = b[1];
107
+ const bz = b[2];
108
+ const bw = b[3];
109
+ let dot = ax * bx + ay * by + az * bz + aw * bw;
110
+ if (dot < 0) {
111
+ ax = -ax;
112
+ ay = -ay;
113
+ az = -az;
114
+ aw = -aw;
115
+ dot = -dot;
116
+ }
117
+ if (dot > 0.9995) {
118
+ return normalizeQuaternion([
119
+ ax + (bx - ax) * u,
120
+ ay + (by - ay) * u,
121
+ az + (bz - az) * u,
122
+ aw + (bw - aw) * u,
123
+ ]);
124
+ }
125
+ const theta0 = Math.acos(dot);
126
+ const theta = theta0 * u;
127
+ const sinTheta0 = Math.sin(theta0);
128
+ const s0 = Math.cos(theta) - (dot * Math.sin(theta)) / sinTheta0;
129
+ const s1 = Math.sin(theta) / sinTheta0;
130
+ return [ax * s0 + bx * s1, ay * s0 + by * s1, az * s0 + bz * s1, aw * s0 + bw * s1];
131
+ }
132
+ function sampleVectorChannel(track, time) {
133
+ const segment = track === undefined ? undefined : keyframeSegment(track.keys, time);
134
+ if (segment === undefined) {
135
+ return undefined;
136
+ }
137
+ const a = vectorFromValue(segment.a.value);
138
+ const b = vectorFromValue(segment.b.value);
139
+ return [
140
+ a[0] + (b[0] - a[0]) * segment.u,
141
+ a[1] + (b[1] - a[1]) * segment.u,
142
+ a[2] + (b[2] - a[2]) * segment.u,
143
+ ];
144
+ }
145
+ function sampleQuaternionChannel(track, time) {
146
+ const segment = track === undefined ? undefined : keyframeSegment(track.keys, time);
147
+ if (segment === undefined) {
148
+ return undefined;
149
+ }
150
+ return slerpQuaternion(quaternionFromValue(segment.a.value), quaternionFromValue(segment.b.value), segment.u);
151
+ }
152
+ function transformChannel(tracks, name) {
153
+ return tracks.find((track) => track.channel === name);
154
+ }
155
+ /**
156
+ * The effective transform at time `time`: each channel with keyframes is sampled from its track, and
157
+ * any channel without keys holds the entity's authored `base` value.
158
+ */
159
+ export function sampleTransform(base, tracks, time) {
160
+ return {
161
+ position: sampleVectorChannel(transformChannel(tracks, "position"), time) ?? base.position,
162
+ rotation: sampleQuaternionChannel(transformChannel(tracks, "rotation"), time) ?? base.rotation,
163
+ scale: sampleVectorChannel(transformChannel(tracks, "scale"), time) ?? base.scale,
164
+ };
165
+ }