@codexo/exojs 0.7.12 → 0.8.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 (219) hide show
  1. package/CHANGELOG.md +737 -0
  2. package/dist/esm/core/Application.d.ts +3 -1
  3. package/dist/esm/core/Application.js +7 -6
  4. package/dist/esm/core/Application.js.map +1 -1
  5. package/dist/esm/core/Scene.d.ts +30 -0
  6. package/dist/esm/core/Scene.js +56 -0
  7. package/dist/esm/core/Scene.js.map +1 -1
  8. package/dist/esm/core/SceneManager.js +2 -2
  9. package/dist/esm/core/SceneManager.js.map +1 -1
  10. package/dist/esm/debug/DebugOverlay.js +2 -2
  11. package/dist/esm/debug/DebugOverlay.js.map +1 -1
  12. package/dist/esm/debug/PointerStackLayer.js +1 -1
  13. package/dist/esm/debug/PointerStackLayer.js.map +1 -1
  14. package/dist/esm/index.js +32 -10
  15. package/dist/esm/index.js.map +1 -1
  16. package/dist/esm/input/ArcadeStickGamepadMapping.js +18 -19
  17. package/dist/esm/input/ArcadeStickGamepadMapping.js.map +1 -1
  18. package/dist/esm/input/Gamepad.d.ts +164 -62
  19. package/dist/esm/input/Gamepad.js +290 -134
  20. package/dist/esm/input/Gamepad.js.map +1 -1
  21. package/dist/esm/input/GamepadAxis.d.ts +120 -0
  22. package/dist/esm/input/GamepadAxis.js +106 -0
  23. package/dist/esm/input/GamepadAxis.js.map +1 -0
  24. package/dist/esm/input/GamepadButton.d.ts +110 -0
  25. package/dist/esm/input/GamepadButton.js +99 -0
  26. package/dist/esm/input/GamepadButton.js.map +1 -0
  27. package/dist/esm/input/GamepadDefinitions.js +4 -0
  28. package/dist/esm/input/GamepadDefinitions.js.map +1 -1
  29. package/dist/esm/input/GamepadMapping.d.ts +28 -24
  30. package/dist/esm/input/GamepadMapping.js +33 -16
  31. package/dist/esm/input/GamepadMapping.js.map +1 -1
  32. package/dist/esm/input/GamepadPromptLayouts.d.ts +10 -8
  33. package/dist/esm/input/GamepadPromptLayouts.js +21 -20
  34. package/dist/esm/input/GamepadPromptLayouts.js.map +1 -1
  35. package/dist/esm/input/GenericDualAnalogGamepadMapping.d.ts +6 -3
  36. package/dist/esm/input/GenericDualAnalogGamepadMapping.js +55 -46
  37. package/dist/esm/input/GenericDualAnalogGamepadMapping.js.map +1 -1
  38. package/dist/esm/input/InputBinding.d.ts +74 -0
  39. package/dist/esm/input/InputBinding.js +100 -0
  40. package/dist/esm/input/InputBinding.js.map +1 -0
  41. package/dist/esm/input/InputManager.d.ts +79 -33
  42. package/dist/esm/input/InputManager.js +229 -104
  43. package/dist/esm/input/InputManager.js.map +1 -1
  44. package/dist/esm/input/InteractionManager.d.ts +1 -1
  45. package/dist/esm/input/InteractionManager.js +13 -13
  46. package/dist/esm/input/InteractionManager.js.map +1 -1
  47. package/dist/esm/input/JoyConLeftGamepadMapping.d.ts +14 -9
  48. package/dist/esm/input/JoyConLeftGamepadMapping.js +39 -9
  49. package/dist/esm/input/JoyConLeftGamepadMapping.js.map +1 -1
  50. package/dist/esm/input/JoyConRightGamepadMapping.d.ts +14 -9
  51. package/dist/esm/input/JoyConRightGamepadMapping.js +35 -9
  52. package/dist/esm/input/JoyConRightGamepadMapping.js.map +1 -1
  53. package/dist/esm/input/Pointer.d.ts +84 -71
  54. package/dist/esm/input/Pointer.js +71 -71
  55. package/dist/esm/input/Pointer.js.map +1 -1
  56. package/dist/esm/input/SteamDeckGamepadMapping.d.ts +18 -0
  57. package/dist/esm/input/SteamDeckGamepadMapping.js +76 -0
  58. package/dist/esm/input/SteamDeckGamepadMapping.js.map +1 -0
  59. package/dist/esm/input/index.d.ts +7 -4
  60. package/dist/esm/input/types.d.ts +0 -76
  61. package/dist/esm/input/types.js +1 -80
  62. package/dist/esm/input/types.js.map +1 -1
  63. package/dist/esm/particles/ParticleSystem.d.ts +180 -83
  64. package/dist/esm/particles/ParticleSystem.js +446 -133
  65. package/dist/esm/particles/ParticleSystem.js.map +1 -1
  66. package/dist/esm/particles/distributions/BoxArea.d.ts +17 -0
  67. package/dist/esm/particles/distributions/BoxArea.js +48 -0
  68. package/dist/esm/particles/distributions/BoxArea.js.map +1 -0
  69. package/dist/esm/particles/distributions/CircleArea.d.ts +19 -0
  70. package/dist/esm/particles/distributions/CircleArea.js +33 -0
  71. package/dist/esm/particles/distributions/CircleArea.js.map +1 -0
  72. package/dist/esm/particles/distributions/ConeDirection.d.ts +28 -0
  73. package/dist/esm/particles/distributions/ConeDirection.js +44 -0
  74. package/dist/esm/particles/distributions/ConeDirection.js.map +1 -0
  75. package/dist/esm/particles/distributions/Constant.d.ts +17 -0
  76. package/dist/esm/particles/distributions/Constant.js +35 -0
  77. package/dist/esm/particles/distributions/Constant.js.map +1 -0
  78. package/dist/esm/particles/distributions/Curve.d.ts +30 -0
  79. package/dist/esm/particles/distributions/Curve.js +53 -0
  80. package/dist/esm/particles/distributions/Curve.js.map +1 -0
  81. package/dist/esm/particles/distributions/Distribution.d.ts +45 -0
  82. package/dist/esm/particles/distributions/Gradient.d.ts +40 -0
  83. package/dist/esm/particles/distributions/Gradient.js +72 -0
  84. package/dist/esm/particles/distributions/Gradient.js.map +1 -0
  85. package/dist/esm/particles/distributions/LineSegment.d.ts +15 -0
  86. package/dist/esm/particles/distributions/LineSegment.js +27 -0
  87. package/dist/esm/particles/distributions/LineSegment.js.map +1 -0
  88. package/dist/esm/particles/distributions/Range.d.ts +12 -0
  89. package/dist/esm/particles/distributions/Range.js +19 -0
  90. package/dist/esm/particles/distributions/Range.js.map +1 -0
  91. package/dist/esm/particles/distributions/VectorRange.d.ts +20 -0
  92. package/dist/esm/particles/distributions/VectorRange.js +31 -0
  93. package/dist/esm/particles/distributions/VectorRange.js.map +1 -0
  94. package/dist/esm/particles/distributions/index.d.ts +12 -0
  95. package/dist/esm/particles/gpu/ParticleGpuState.d.ts +57 -0
  96. package/dist/esm/particles/gpu/ParticleGpuState.js +508 -0
  97. package/dist/esm/particles/gpu/ParticleGpuState.js.map +1 -0
  98. package/dist/esm/particles/index.d.ts +2 -10
  99. package/dist/esm/particles/modules/AlphaFadeOverLifetime.d.ts +24 -0
  100. package/dist/esm/particles/modules/AlphaFadeOverLifetime.js +60 -0
  101. package/dist/esm/particles/modules/AlphaFadeOverLifetime.js.map +1 -0
  102. package/dist/esm/particles/modules/ApplyForce.d.ts +20 -0
  103. package/dist/esm/particles/modules/ApplyForce.js +48 -0
  104. package/dist/esm/particles/modules/ApplyForce.js.map +1 -0
  105. package/dist/esm/particles/modules/AttractToPoint.d.ts +27 -0
  106. package/dist/esm/particles/modules/AttractToPoint.js +73 -0
  107. package/dist/esm/particles/modules/AttractToPoint.js.map +1 -0
  108. package/dist/esm/particles/modules/BurstSpawn.d.ts +53 -0
  109. package/dist/esm/particles/modules/BurstSpawn.js +94 -0
  110. package/dist/esm/particles/modules/BurstSpawn.js.map +1 -0
  111. package/dist/esm/particles/modules/ColorOverLifetime.d.ts +22 -0
  112. package/dist/esm/particles/modules/ColorOverLifetime.js +65 -0
  113. package/dist/esm/particles/modules/ColorOverLifetime.js.map +1 -0
  114. package/dist/esm/particles/modules/ColorOverSpeed.d.ts +27 -0
  115. package/dist/esm/particles/modules/ColorOverSpeed.js +86 -0
  116. package/dist/esm/particles/modules/ColorOverSpeed.js.map +1 -0
  117. package/dist/esm/particles/modules/DeathModule.d.ts +24 -0
  118. package/dist/esm/particles/modules/DeathModule.js +25 -0
  119. package/dist/esm/particles/modules/DeathModule.js.map +1 -0
  120. package/dist/esm/particles/modules/Drag.d.ts +20 -0
  121. package/dist/esm/particles/modules/Drag.js +45 -0
  122. package/dist/esm/particles/modules/Drag.js.map +1 -0
  123. package/dist/esm/particles/modules/OrbitalForce.d.ts +28 -0
  124. package/dist/esm/particles/modules/OrbitalForce.js +65 -0
  125. package/dist/esm/particles/modules/OrbitalForce.js.map +1 -0
  126. package/dist/esm/particles/modules/RateSpawn.d.ts +41 -0
  127. package/dist/esm/particles/modules/RateSpawn.js +76 -0
  128. package/dist/esm/particles/modules/RateSpawn.js.map +1 -0
  129. package/dist/esm/particles/modules/RepelFromPoint.d.ts +24 -0
  130. package/dist/esm/particles/modules/RepelFromPoint.js +76 -0
  131. package/dist/esm/particles/modules/RepelFromPoint.js.map +1 -0
  132. package/dist/esm/particles/modules/RotateOverLifetime.d.ts +20 -0
  133. package/dist/esm/particles/modules/RotateOverLifetime.js +43 -0
  134. package/dist/esm/particles/modules/RotateOverLifetime.js.map +1 -0
  135. package/dist/esm/particles/modules/ScaleOverLifetime.d.ts +26 -0
  136. package/dist/esm/particles/modules/ScaleOverLifetime.js +59 -0
  137. package/dist/esm/particles/modules/ScaleOverLifetime.js.map +1 -0
  138. package/dist/esm/particles/modules/SpawnModule.d.ts +30 -0
  139. package/dist/esm/particles/modules/SpawnModule.js +31 -0
  140. package/dist/esm/particles/modules/SpawnModule.js.map +1 -0
  141. package/dist/esm/particles/modules/SpawnOnDeath.d.ts +24 -0
  142. package/dist/esm/particles/modules/SpawnOnDeath.js +47 -0
  143. package/dist/esm/particles/modules/SpawnOnDeath.js.map +1 -0
  144. package/dist/esm/particles/modules/Turbulence.d.ts +30 -0
  145. package/dist/esm/particles/modules/Turbulence.js +122 -0
  146. package/dist/esm/particles/modules/Turbulence.js.map +1 -0
  147. package/dist/esm/particles/modules/UpdateModule.d.ts +95 -0
  148. package/dist/esm/particles/modules/UpdateModule.js +66 -0
  149. package/dist/esm/particles/modules/UpdateModule.js.map +1 -0
  150. package/dist/esm/particles/modules/VelocityOverLifetime.d.ts +30 -0
  151. package/dist/esm/particles/modules/VelocityOverLifetime.js +84 -0
  152. package/dist/esm/particles/modules/VelocityOverLifetime.js.map +1 -0
  153. package/dist/esm/particles/modules/WgslContribution.d.ts +81 -0
  154. package/dist/esm/particles/modules/WgslContribution.js +34 -0
  155. package/dist/esm/particles/modules/WgslContribution.js.map +1 -0
  156. package/dist/esm/particles/modules/index.d.ts +22 -0
  157. package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.d.ts +9 -14
  158. package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.js +90 -61
  159. package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.js.map +1 -1
  160. package/dist/esm/rendering/webgl2/glsl/particle.vert.js +1 -1
  161. package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.d.ts +9 -0
  162. package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.js +107 -23
  163. package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.js.map +1 -1
  164. package/dist/esm/rendering/webgpu/compute/WebGpuComputePipeline.d.ts +52 -0
  165. package/dist/esm/rendering/webgpu/compute/WebGpuStorageBuffer.d.ts +29 -0
  166. package/dist/esm/rendering/webgpu/compute/index.d.ts +3 -0
  167. package/dist/esm/resources/CacheFirstStrategy.d.ts +7 -4
  168. package/dist/esm/resources/CacheFirstStrategy.js +11 -8
  169. package/dist/esm/resources/CacheFirstStrategy.js.map +1 -1
  170. package/dist/esm/resources/CacheStrategy.d.ts +14 -6
  171. package/dist/esm/resources/Loader.d.ts +8 -3
  172. package/dist/esm/resources/Loader.js +19 -37
  173. package/dist/esm/resources/Loader.js.map +1 -1
  174. package/dist/esm/resources/NetworkOnlyStrategy.d.ts +3 -0
  175. package/dist/esm/resources/NetworkOnlyStrategy.js +8 -3
  176. package/dist/esm/resources/NetworkOnlyStrategy.js.map +1 -1
  177. package/dist/esm/resources/factories/ImageFactory.d.ts +2 -2
  178. package/dist/esm/resources/factories/ImageFactory.js.map +1 -1
  179. package/dist/esm/resources/factories/TextureFactory.d.ts +2 -2
  180. package/dist/esm/resources/factories/TextureFactory.js.map +1 -1
  181. package/dist/esm/resources/factories/VttFactory.d.ts +3 -3
  182. package/dist/esm/resources/factories/VttFactory.js +83 -6
  183. package/dist/esm/resources/factories/VttFactory.js.map +1 -1
  184. package/dist/exo.esm.js +4028 -1518
  185. package/dist/exo.esm.js.map +1 -1
  186. package/package.json +2 -1
  187. package/dist/esm/input/GamepadChannels.d.ts +0 -47
  188. package/dist/esm/input/GamepadChannels.js +0 -53
  189. package/dist/esm/input/GamepadChannels.js.map +0 -1
  190. package/dist/esm/input/GamepadControl.d.ts +0 -33
  191. package/dist/esm/input/GamepadControl.js +0 -42
  192. package/dist/esm/input/GamepadControl.js.map +0 -1
  193. package/dist/esm/input/Input.d.ts +0 -52
  194. package/dist/esm/input/Input.js +0 -90
  195. package/dist/esm/input/Input.js.map +0 -1
  196. package/dist/esm/particles/Particle.d.ts +0 -77
  197. package/dist/esm/particles/Particle.js +0 -143
  198. package/dist/esm/particles/Particle.js.map +0 -1
  199. package/dist/esm/particles/ParticleProperties.d.ts +0 -29
  200. package/dist/esm/particles/affectors/ColorAffector.d.ts +0 -30
  201. package/dist/esm/particles/affectors/ColorAffector.js +0 -55
  202. package/dist/esm/particles/affectors/ColorAffector.js.map +0 -1
  203. package/dist/esm/particles/affectors/ForceAffector.d.ts +0 -24
  204. package/dist/esm/particles/affectors/ForceAffector.js +0 -39
  205. package/dist/esm/particles/affectors/ForceAffector.js.map +0 -1
  206. package/dist/esm/particles/affectors/ParticleAffector.d.ts +0 -19
  207. package/dist/esm/particles/affectors/ScaleAffector.d.ts +0 -23
  208. package/dist/esm/particles/affectors/ScaleAffector.js +0 -38
  209. package/dist/esm/particles/affectors/ScaleAffector.js.map +0 -1
  210. package/dist/esm/particles/affectors/TorqueAffector.d.ts +0 -23
  211. package/dist/esm/particles/affectors/TorqueAffector.js +0 -37
  212. package/dist/esm/particles/affectors/TorqueAffector.js.map +0 -1
  213. package/dist/esm/particles/emitters/ParticleEmitter.d.ts +0 -19
  214. package/dist/esm/particles/emitters/ParticleOptions.d.ts +0 -62
  215. package/dist/esm/particles/emitters/ParticleOptions.js +0 -120
  216. package/dist/esm/particles/emitters/ParticleOptions.js.map +0 -1
  217. package/dist/esm/particles/emitters/UniversalEmitter.d.ts +0 -40
  218. package/dist/esm/particles/emitters/UniversalEmitter.js +0 -68
  219. package/dist/esm/particles/emitters/UniversalEmitter.js.map +0 -1
@@ -1,35 +1,195 @@
1
- import { Particle } from './Particle.js';
2
1
  import { Rectangle } from '../math/Rectangle.js';
3
2
  import { Drawable } from '../rendering/Drawable.js';
3
+ import { Texture } from '../rendering/texture/Texture.js';
4
+ import { Spritesheet } from '../rendering/sprite/Spritesheet.js';
5
+ import { ParticleGpuState } from './gpu/ParticleGpuState.js';
4
6
 
7
+ /// <reference types="@webgpu/types" />
8
+ const defaultCapacity = 4096;
5
9
  /**
6
- * The central coordinator of the particle triad. `ParticleSystem` is a
7
- * {@link Drawable} that owns a list of {@link ParticleEmitter} spawners, a
8
- * list of {@link ParticleAffector} mutators, and the live/graveyard particle
9
- * pools. Each call to {@link ParticleSystem.update} runs all emitters to
10
- * spawn new particles, advances every live particle's position and lifetime,
11
- * retires expired ones to the graveyard for pooling, and runs all affectors
12
- * on the survivors.
10
+ * Lazily-initialised 1×1 opaque-white texture used as the default sprite
11
+ * when a {@link ParticleSystem} is constructed without one. Particles
12
+ * render as solid color quads (the per-particle `color` channel times
13
+ * white-with-alpha-1). Shared across systems to avoid wasted texture
14
+ * allocations.
15
+ */
16
+ let defaultWhiteTexture = null;
17
+ const getDefaultWhiteTexture = () => {
18
+ if (defaultWhiteTexture === null) {
19
+ const canvas = document.createElement('canvas');
20
+ canvas.width = 1;
21
+ canvas.height = 1;
22
+ const ctx = canvas.getContext('2d');
23
+ if (ctx !== null) {
24
+ ctx.fillStyle = '#ffffff';
25
+ ctx.fillRect(0, 0, 1, 1);
26
+ }
27
+ defaultWhiteTexture = new Texture(canvas);
28
+ }
29
+ return defaultWhiteTexture;
30
+ };
31
+ /**
32
+ * The central coordinator of the particle pipeline. `ParticleSystem` is a
33
+ * {@link Drawable} that owns:
34
+ *
35
+ * - **SoA particle storage** — one typed array per attribute (position,
36
+ * velocity, scale, rotation, color, lifetime, ...), sized to a fixed
37
+ * capacity at construction. User code reads/writes via
38
+ * `system.posX[slot]`, `system.velX[slot]`, etc.
39
+ * - **Spawn modules** — write new particles into freshly allocated slots.
40
+ * - **Update modules** — mutate the live range each frame (forces, color
41
+ * blends, scale curves, drag, ...). Built-in modules ship both CPU and
42
+ * WGSL implementations; custom modules can opt into GPU acceleration by
43
+ * implementing `wgsl()`.
44
+ * - **Death modules** — fire once per dying particle, before its slot is
45
+ * recycled (sub-emitters, event hooks).
46
+ *
47
+ * **Auto-routing CPU vs GPU:** at first {@link update}, the system checks:
48
+ * if a `WebGpuBackend` was supplied AND every registered update module has
49
+ * `wgsl()`, the GPU path engages — a composite compute pipeline runs
50
+ * integration plus all module bodies in one dispatch and writes directly
51
+ * into the renderer's instance buffer (no CPU readback). Otherwise the CPU
52
+ * path runs the existing per-module `apply()` loops.
13
53
  *
14
- * Rendering reads {@link ParticleSystem.vertices} and
15
- * {@link ParticleSystem.texCoords} (lazily recomputed on texture-frame
16
- * changes) plus the live {@link ParticleSystem.particles} array to draw each
17
- * sprite.
54
+ * **Per-frame order in {@link update} (CPU mode):**
55
+ * 1. Run every spawn module.
56
+ * 2. Integrate position from velocity, rotation from rotationSpeed, advance `elapsed`.
57
+ * 3. Run every update module on the live range.
58
+ * 4. Compact: scan `[0, liveCount)` forward, fire death modules on expired
59
+ * slots, copy survivors down. `liveCount` shrinks to the survivor count.
60
+ *
61
+ * **Per-frame order in {@link update} (GPU mode):**
62
+ * 1. Run every spawn module (CPU writes initial values into the spawn slot).
63
+ * 2. Detect expiries on CPU (via `elapsed >= lifetime`); fire death modules;
64
+ * set `lifetime[slot] = -1` sentinel + clear `alive[slot]` so the GPU
65
+ * shader skips them. **No compaction** — slots are recycled on next spawn.
66
+ * 3. Dispatch the composite compute pipeline. Integration + update modules
67
+ * + pack-instances run in one pass; the instance buffer is written
68
+ * directly. CPU SoA stays as-is for spawn writes.
69
+ *
70
+ * **Coordinate space:** particle positions are LOCAL to the system. The
71
+ * system's `getGlobalTransform()` is applied on top during rendering — both
72
+ * the WebGL2 and WebGPU shaders multiply `projection * translation * rotated`.
73
+ * Setting world-space positions on individual particles double-translates.
74
+ * Position the system itself via `system.setPosition(...)` and emit relative
75
+ * to `(0, 0)`.
76
+ *
77
+ * @example
78
+ * // Backend-agnostic — runs CPU on WebGL2, GPU on WebGPU automatically.
79
+ * const system = new ParticleSystem(loader.get(Texture, 'spark'), {
80
+ * capacity: 8192,
81
+ * backend: app.backend,
82
+ * });
83
+ *
84
+ * system.addSpawnModule(new RateSpawn({ rate: new Constant(60), ... }));
85
+ * system.addUpdateModule(new ApplyForce(0, 980)); // gravity, GPU-eligible
86
+ * system.addUpdateModule(new ColorOverLifetime(fireGradient));
87
+ * scene.addChild(system);
18
88
  */
19
89
  class ParticleSystem extends Drawable {
20
- _emitters = [];
21
- _affectors = [];
22
- _particles = [];
23
- _graveyard = [];
90
+ /** Maximum particle count this system will store. Fixed at construction. */
91
+ capacity;
92
+ posX;
93
+ posY;
94
+ velX;
95
+ velY;
96
+ scaleX;
97
+ scaleY;
98
+ rotations;
99
+ rotationSpeeds;
100
+ color; // packed 0xAABBGGRR
101
+ elapsed; // seconds since spawn
102
+ lifetime; // total seconds before expiry; -1 sentinel for dead in GPU mode
103
+ textureIndex;
104
+ /**
105
+ * Number of currently live particles. In CPU mode this is exact: slots
106
+ * `[0, liveCount)` are all alive after each `update()`. In GPU mode
107
+ * this is a high-water mark — slots `[0, liveCount)` may contain dead
108
+ * holes (filled in by future spawns); use {@link aliveCount} for the
109
+ * actual alive count.
110
+ */
111
+ liveCount = 0;
112
+ /**
113
+ * Per-slot alive flag (1 = alive, 0 = dead). Maintained in both CPU
114
+ * and GPU mode. Custom modules iterating the live range should check
115
+ * this to skip dead slots in GPU mode.
116
+ */
117
+ alive;
118
+ _spawnModules = [];
119
+ _updateModules = [];
120
+ _deathModules = [];
121
+ _backend = null;
122
+ _device = null;
123
+ _gpuState = null;
124
+ _gpuMode = false;
125
+ _compiled = false;
126
+ _spawnHint = 0; // round-robin pointer for first-dead lookup in GPU mode
127
+ /**
128
+ * In GPU mode, slots whose CPU SoA values need re-uploading to the GPU
129
+ * (newly spawned, or just-expired with lifetime sentinel). Cleared
130
+ * after each compute dispatch. CPU never overwrites integrated GPU
131
+ * state — only dirty slots flow CPU → GPU.
132
+ */
133
+ _gpuDirtySlots = new Set();
24
134
  _texture;
135
+ _frames = [];
25
136
  _textureFrame = new Rectangle();
26
137
  _vertices = new Float32Array(4);
27
138
  _texCoords = new Uint32Array(4);
28
139
  _updateTexCoords = true;
29
140
  _updateVertices = true;
30
- constructor(texture) {
141
+ constructor(arg1, arg2, arg3) {
31
142
  super();
32
- this._texture = texture;
143
+ // Disambiguate the four valid call shapes via instanceof checks.
144
+ // The TS overloads above already prevent illegal combinations like
145
+ // `(texture, sheet)` or `(sheet, frames)` at compile time; this
146
+ // narrowing only sorts out the legal ones.
147
+ let texture = null;
148
+ let frames = null;
149
+ let options = {};
150
+ if (arg1 instanceof Texture) {
151
+ texture = arg1;
152
+ if (Array.isArray(arg2)) {
153
+ frames = arg2;
154
+ options = arg3 ?? {};
155
+ }
156
+ else {
157
+ options = arg2 ?? {};
158
+ }
159
+ }
160
+ else if (arg1 instanceof Spritesheet) {
161
+ texture = arg1.texture;
162
+ frames = [...arg1.frames.values()];
163
+ options = arg2 ?? {};
164
+ }
165
+ else {
166
+ options = arg1 ?? {};
167
+ }
168
+ const capacity = options.capacity ?? defaultCapacity;
169
+ if (capacity <= 0 || !Number.isInteger(capacity)) {
170
+ throw new Error(`ParticleSystem capacity must be a positive integer (got ${capacity}).`);
171
+ }
172
+ this.capacity = capacity;
173
+ this.posX = new Float32Array(capacity);
174
+ this.posY = new Float32Array(capacity);
175
+ this.velX = new Float32Array(capacity);
176
+ this.velY = new Float32Array(capacity);
177
+ this.scaleX = new Float32Array(capacity);
178
+ this.scaleY = new Float32Array(capacity);
179
+ this.rotations = new Float32Array(capacity);
180
+ this.rotationSpeeds = new Float32Array(capacity);
181
+ this.color = new Uint32Array(capacity);
182
+ this.elapsed = new Float32Array(capacity);
183
+ this.lifetime = new Float32Array(capacity);
184
+ this.textureIndex = new Uint16Array(capacity);
185
+ this.alive = new Uint8Array(capacity);
186
+ this._device = options.device ?? null;
187
+ this._texture = texture ?? getDefaultWhiteTexture();
188
+ if (frames !== null) {
189
+ for (const frame of frames) {
190
+ this._frames.push(frame.clone());
191
+ }
192
+ }
33
193
  this.resetTextureFrame();
34
194
  }
35
195
  get texture() {
@@ -45,11 +205,17 @@ class ParticleSystem extends Drawable {
45
205
  this.setTextureFrame(frame);
46
206
  }
47
207
  /**
48
- * Quad corner offsets for the current {@link textureFrame}, in local
49
- * space as `[minX, minY, maxX, maxY]`. Recomputed lazily whenever
50
- * `textureFrame` changes. Used by the renderer to position each particle
51
- * sprite relative to its world position.
208
+ * Atlas frames declared on this system, or empty when the texture is
209
+ * used as a single frame. Each particle's `textureIndex[i]` selects
210
+ * an entry from this list; out-of-range indices are clamped to 0.
52
211
  */
212
+ get frames() {
213
+ return this._frames;
214
+ }
215
+ /** `true` when the system declares more than one atlas frame. */
216
+ get hasAtlas() {
217
+ return this._frames.length > 1;
218
+ }
53
219
  get vertices() {
54
220
  if (this._updateVertices) {
55
221
  const { x, y, width, height } = this._textureFrame;
@@ -63,12 +229,6 @@ class ParticleSystem extends Drawable {
63
229
  }
64
230
  return this._vertices;
65
231
  }
66
- /**
67
- * Packed UV coordinates for the current {@link textureFrame} as four
68
- * `Uint32` values, each encoding a `(u, v)` pair in the upper/lower 16
69
- * bits (normalised to 0–65535). Vertex order respects
70
- * {@link Texture.flipY}. Recomputed lazily on texture or frame changes.
71
- */
72
232
  get texCoords() {
73
233
  if (this._updateTexCoords) {
74
234
  const { width, height } = this._texture;
@@ -93,27 +253,32 @@ class ParticleSystem extends Drawable {
93
253
  }
94
254
  return this._texCoords;
95
255
  }
96
- get emitters() {
97
- return this._emitters;
256
+ /** `true` when the system is running on the GPU compute pipeline. */
257
+ get gpuMode() {
258
+ return this._gpuMode;
98
259
  }
99
- get affectors() {
100
- return this._affectors;
260
+ /** GPU-side state, or `null` in CPU mode. */
261
+ get gpuState() {
262
+ return this._gpuState;
101
263
  }
102
- get particles() {
103
- return this._particles;
264
+ /** Actual count of live particles (slots with `alive[i] === 1`). May differ from `liveCount` in GPU mode. */
265
+ get aliveCount() {
266
+ let count = 0;
267
+ for (let i = 0; i < this.liveCount; i++) {
268
+ if (this.alive[i])
269
+ count++;
270
+ }
271
+ return count;
104
272
  }
105
- /**
106
- * Pool of expired {@link Particle} instances waiting to be recycled.
107
- * {@link requestParticle} pops from this array before allocating a new
108
- * instance, keeping GC pressure low during sustained emission.
109
- */
110
- get graveyard() {
111
- return this._graveyard;
273
+ get spawnModules() {
274
+ return this._spawnModules;
275
+ }
276
+ get updateModules() {
277
+ return this._updateModules;
278
+ }
279
+ get deathModules() {
280
+ return this._deathModules;
112
281
  }
113
- /**
114
- * Replaces the particle sprite texture and resets the texture frame to
115
- * cover the full new texture. No-ops if `texture` is the same instance.
116
- */
117
282
  setTexture(texture) {
118
283
  if (this._texture !== texture) {
119
284
  this._texture = texture;
@@ -121,11 +286,6 @@ class ParticleSystem extends Drawable {
121
286
  }
122
287
  return this;
123
288
  }
124
- /**
125
- * Sets the sub-rectangle of the texture used as the particle sprite,
126
- * invalidating cached vertices and UV coordinates and updating the system's
127
- * local bounds to match the frame dimensions.
128
- */
129
289
  setTextureFrame(frame) {
130
290
  this._textureFrame.copy(frame);
131
291
  this._updateTexCoords = true;
@@ -134,121 +294,274 @@ class ParticleSystem extends Drawable {
134
294
  this._invalidateBoundsCascade();
135
295
  return this;
136
296
  }
137
- /** Resets the texture frame to the full dimensions of the current texture. */
138
297
  resetTextureFrame() {
139
298
  return this.setTextureFrame(Rectangle.temp.set(0, 0, this._texture.width, this._texture.height));
140
299
  }
141
- /** Registers `emitter` to be called each tick during {@link update}. */
142
- addEmitter(emitter) {
143
- this._emitters.push(emitter);
300
+ addSpawnModule(mod) {
301
+ this._spawnModules.push(mod);
144
302
  return this;
145
303
  }
146
- /** Destroys and removes all registered emitters. */
147
- clearEmitters() {
148
- for (const emitter of this._emitters) {
149
- emitter.destroy();
304
+ addUpdateModule(mod) {
305
+ if (this._compiled) {
306
+ throw new Error('Cannot add update modules after the system has been compiled (first update). Register all modules before the first update().');
150
307
  }
151
- this._emitters.length = 0;
308
+ this._updateModules.push(mod);
152
309
  return this;
153
310
  }
154
- /** Registers `affector` to run on every live particle each tick during {@link update}. */
155
- addAffector(affector) {
156
- this._affectors.push(affector);
311
+ addDeathModule(mod) {
312
+ this._deathModules.push(mod);
157
313
  return this;
158
314
  }
159
- /** Destroys and removes all registered affectors. */
160
- clearAffectors() {
161
- for (const affector of this._affectors) {
162
- affector.destroy();
163
- }
164
- this._affectors.length = 0;
315
+ clearSpawnModules() {
316
+ for (const mod of this._spawnModules)
317
+ mod.destroy();
318
+ this._spawnModules.length = 0;
165
319
  return this;
166
320
  }
167
- /**
168
- * Returns a recycled particle from the {@link graveyard}, or allocates a
169
- * new one if the pool is empty. Call {@link Particle.applyOptions}
170
- * immediately after to reset its state before passing it to
171
- * {@link emitParticle}.
172
- */
173
- requestParticle() {
174
- return this._graveyard.pop() || new Particle();
321
+ clearUpdateModules() {
322
+ for (const mod of this._updateModules)
323
+ mod.destroy();
324
+ this._updateModules.length = 0;
325
+ return this;
175
326
  }
176
- /** Adds a fully-configured `particle` to the live pool. Typically called by emitters. */
177
- emitParticle(particle) {
178
- this._particles.push(particle);
327
+ clearDeathModules() {
328
+ for (const mod of this._deathModules)
329
+ mod.destroy();
330
+ this._deathModules.length = 0;
179
331
  return this;
180
332
  }
181
333
  /**
182
- * Advances a single particle by one `delta` step: increments
183
- * `elapsedLifetime`, integrates velocity into position, and applies
184
- * `rotationSpeed` to rotation. Called for every live particle by
185
- * {@link update} before the affector pass.
334
+ * Allocates a particle slot and returns its index. Returns `-1` when
335
+ * the system is at {@link capacity}.
336
+ *
337
+ * **CPU mode:** slots are dense in `[0, liveCount)`. `spawn()` returns
338
+ * the next sequential slot; `liveCount++`.
339
+ *
340
+ * **GPU mode:** slots may have dead holes. `spawn()` finds the first
341
+ * `alive[i] === 0` slot via a round-robin hint pointer (amortised O(1),
342
+ * worst case O(capacity) on full systems).
186
343
  */
187
- updateParticle(particle, delta) {
188
- const seconds = delta.seconds;
189
- particle.elapsedLifetime.addTime(delta);
190
- particle.position.add(seconds * particle.velocity.x, seconds * particle.velocity.y);
191
- particle.rotation += (seconds * particle.rotationSpeed);
344
+ spawn() {
345
+ if (this._gpuMode) {
346
+ return this._spawnGpu();
347
+ }
348
+ return this._spawnCpu();
349
+ }
350
+ /** Resets the system to zero live particles without destroying it. */
351
+ clearParticles() {
352
+ this.liveCount = 0;
353
+ this._spawnHint = 0;
354
+ this.alive.fill(0);
355
+ this.lifetime.fill(0);
356
+ this.elapsed.fill(0);
192
357
  return this;
193
358
  }
194
359
  /**
195
- * Destroys and removes all particles from both the live pool and the
196
- * graveyard. Use when resetting or recycling the entire system.
360
+ * Engine-side render hook. Captures the active backend on each call so
361
+ * the next `update()` can compile a GPU pipeline if the backend turned
362
+ * out to be `WebGpuBackend`. Re-captures and rebuilds when the backend
363
+ * reference changes (e.g. after device-loss recovery).
197
364
  */
198
- clearParticles() {
199
- for (const particle of this._particles) {
200
- particle.destroy();
365
+ render(backend) {
366
+ if (this._backend !== backend) {
367
+ this._backend = backend;
368
+ if (this._gpuState !== null) {
369
+ this._gpuState.destroy();
370
+ this._gpuState = null;
371
+ }
372
+ this._gpuMode = false;
373
+ this._compiled = false;
201
374
  }
202
- for (const particle of this._graveyard) {
203
- particle.destroy();
375
+ return super.render(backend);
376
+ }
377
+ /** Per-frame entry point. Routes to CPU or GPU pipeline based on auto-detection at first call. */
378
+ update(delta) {
379
+ if (!this._compiled) {
380
+ this._compile();
381
+ }
382
+ const dt = delta.seconds;
383
+ // 1. Spawn (CPU writes SoA in both modes).
384
+ for (let i = 0; i < this._spawnModules.length; i++) {
385
+ this._spawnModules[i].apply(this, dt);
386
+ }
387
+ if (this._gpuMode) {
388
+ this._updateGpu(dt);
389
+ }
390
+ else {
391
+ this._updateCpu(dt);
204
392
  }
205
- this._particles.length = 0;
206
- this._graveyard.length = 0;
207
393
  return this;
208
394
  }
209
- /**
210
- * Advances the full simulation by one `delta` step: runs all emitters,
211
- * then for each live particle calls {@link updateParticle}, moves expired
212
- * ones to the {@link graveyard}, and runs all affectors on survivors.
213
- * The particle array is iterated in reverse to allow in-place splice
214
- * without re-indexing.
215
- */
216
- update(delta) {
217
- const emitters = this._emitters;
218
- const affectors = this._affectors;
219
- const particles = this._particles;
220
- const graveyard = this._graveyard;
221
- const len = particles.length;
222
- for (const emitter of emitters) {
223
- emitter.apply(this, delta);
224
- }
225
- let expireCount = 0;
226
- for (let i = len - 1; i >= 0; i--) {
227
- this.updateParticle(particles[i], delta);
228
- if (particles[i].expired) {
229
- graveyard.push(particles[i]);
230
- expireCount++;
395
+ destroy() {
396
+ super.destroy();
397
+ this.clearSpawnModules();
398
+ this.clearUpdateModules();
399
+ this.clearDeathModules();
400
+ if (this._gpuState !== null) {
401
+ this._gpuState.destroy();
402
+ this._gpuState = null;
403
+ }
404
+ for (const frame of this._frames) {
405
+ frame.destroy();
406
+ }
407
+ this._frames.length = 0;
408
+ this._gpuMode = false;
409
+ this._compiled = false;
410
+ this.liveCount = 0;
411
+ this.alive.fill(0);
412
+ this._textureFrame.destroy();
413
+ }
414
+ _compile() {
415
+ this._compiled = true;
416
+ // Duck-typed `instanceof WebGpuBackend` — avoids importing the
417
+ // backend class (which registers a renderer for ParticleSystem
418
+ // and would create a circular dependency). WebGl2Backend has no
419
+ // `device` field, so this naturally falls back to CPU mode.
420
+ const backendDevice = this._backend?.device ?? null;
421
+ const device = this._device ?? backendDevice;
422
+ if (device === null) {
423
+ return;
424
+ }
425
+ const allEligible = this._updateModules.every((m) => typeof m.wgsl === 'function');
426
+ if (!allEligible) {
427
+ return;
428
+ }
429
+ this._gpuState = new ParticleGpuState(device, this.capacity, this._updateModules, this._frames, this._texture);
430
+ this._gpuMode = true;
431
+ // Mark every currently-alive slot dirty so the initial upload
432
+ // matches CPU state; subsequent frames only push deltas.
433
+ for (let i = 0; i < this.liveCount; i++) {
434
+ if (this.alive[i])
435
+ this._gpuDirtySlots.add(i);
436
+ }
437
+ }
438
+ _spawnCpu() {
439
+ if (this.liveCount >= this.capacity) {
440
+ return -1;
441
+ }
442
+ const slot = this.liveCount++;
443
+ this.alive[slot] = 1;
444
+ this.elapsed[slot] = 0;
445
+ return slot;
446
+ }
447
+ _spawnGpu() {
448
+ const capacity = this.capacity;
449
+ const alive = this.alive;
450
+ const start = this._spawnHint;
451
+ // Search forward from hint, then wrap.
452
+ for (let i = start; i < capacity; i++) {
453
+ if (alive[i] === 0) {
454
+ alive[i] = 1;
455
+ this.elapsed[i] = 0;
456
+ this._spawnHint = i + 1 === capacity ? 0 : i + 1;
457
+ if (i >= this.liveCount)
458
+ this.liveCount = i + 1;
459
+ this._gpuDirtySlots.add(i);
460
+ return i;
461
+ }
462
+ }
463
+ for (let i = 0; i < start; i++) {
464
+ if (alive[i] === 0) {
465
+ alive[i] = 1;
466
+ this.elapsed[i] = 0;
467
+ this._spawnHint = i + 1;
468
+ if (i >= this.liveCount)
469
+ this.liveCount = i + 1;
470
+ this._gpuDirtySlots.add(i);
471
+ return i;
472
+ }
473
+ }
474
+ return -1;
475
+ }
476
+ _updateCpu(dt) {
477
+ const { posX, posY, velX, velY, rotations, rotationSpeeds, elapsed } = this;
478
+ const liveCount = this.liveCount;
479
+ for (let i = 0; i < liveCount; i++) {
480
+ posX[i] += velX[i] * dt;
481
+ posY[i] += velY[i] * dt;
482
+ rotations[i] += rotationSpeeds[i] * dt;
483
+ elapsed[i] += dt;
484
+ }
485
+ for (let i = 0; i < this._updateModules.length; i++) {
486
+ this._updateModules[i].apply(this, dt);
487
+ }
488
+ // Compact: forward pass, fire death modules on expired, copy survivors down.
489
+ const lifetime = this.lifetime;
490
+ const alive = this.alive;
491
+ const deathModules = this._deathModules;
492
+ let writeIndex = 0;
493
+ for (let readIndex = 0; readIndex < this.liveCount; readIndex++) {
494
+ if (elapsed[readIndex] >= lifetime[readIndex]) {
495
+ for (let m = 0; m < deathModules.length; m++) {
496
+ deathModules[m].onDeath(this, readIndex);
497
+ }
498
+ alive[readIndex] = 0;
231
499
  continue;
232
500
  }
233
- if (expireCount > 0) {
234
- particles.splice(i + 1, expireCount);
235
- expireCount = 0;
501
+ if (writeIndex !== readIndex) {
502
+ this._copySlot(readIndex, writeIndex);
503
+ alive[writeIndex] = 1;
236
504
  }
237
- for (const affector of affectors) {
238
- affector.apply(particles[i], delta);
505
+ writeIndex++;
506
+ }
507
+ for (let i = writeIndex; i < this.liveCount; i++) {
508
+ alive[i] = 0;
509
+ }
510
+ this.liveCount = writeIndex;
511
+ }
512
+ _updateGpu(dt) {
513
+ // CPU advances its own copy of `elapsed` for expire detection only.
514
+ // GPU's `timing[idx].x` is advanced independently inside the compute
515
+ // shader; the two are never synced after spawn. They tick at the
516
+ // same rate (both add `dt` per frame) so they stay equivalent in
517
+ // practice (modulo numerical drift).
518
+ const elapsed = this.elapsed;
519
+ const lifetime = this.lifetime;
520
+ const alive = this.alive;
521
+ const deathModules = this._deathModules;
522
+ const liveCount = this.liveCount;
523
+ for (let i = 0; i < liveCount; i++) {
524
+ if (alive[i] === 0)
525
+ continue;
526
+ elapsed[i] += dt;
527
+ if (elapsed[i] >= lifetime[i]) {
528
+ for (let m = 0; m < deathModules.length; m++) {
529
+ deathModules[m].onDeath(this, i);
530
+ }
531
+ alive[i] = 0;
532
+ lifetime[i] = -1; // sentinel — GPU shader skips
533
+ this._gpuDirtySlots.add(i); // upload the sentinel so GPU sees the death
239
534
  }
240
535
  }
241
- if (expireCount > 0) {
242
- particles.splice(0, expireCount);
536
+ // Trim trailing dead slots.
537
+ let newLiveCount = this.liveCount;
538
+ while (newLiveCount > 0 && alive[newLiveCount - 1] === 0) {
539
+ newLiveCount--;
243
540
  }
244
- return this;
541
+ this.liveCount = newLiveCount;
542
+ // Push dirty slots (new spawns + just-expired) to GPU. CPU is NOT
543
+ // the source of truth for integrated position/velocity/etc. after
544
+ // spawn — uploading the full live range every frame would wipe
545
+ // out GPU's integrated state.
546
+ if (this._gpuDirtySlots.size > 0) {
547
+ this._gpuState.uploadDirty(this, this._gpuDirtySlots);
548
+ this._gpuDirtySlots.clear();
549
+ }
550
+ this._gpuState.dispatch(this, dt);
245
551
  }
246
- destroy() {
247
- super.destroy();
248
- this.clearEmitters();
249
- this.clearAffectors();
250
- this.clearParticles();
251
- this._textureFrame.destroy();
552
+ _copySlot(from, to) {
553
+ this.posX[to] = this.posX[from];
554
+ this.posY[to] = this.posY[from];
555
+ this.velX[to] = this.velX[from];
556
+ this.velY[to] = this.velY[from];
557
+ this.scaleX[to] = this.scaleX[from];
558
+ this.scaleY[to] = this.scaleY[from];
559
+ this.rotations[to] = this.rotations[from];
560
+ this.rotationSpeeds[to] = this.rotationSpeeds[from];
561
+ this.color[to] = this.color[from];
562
+ this.elapsed[to] = this.elapsed[from];
563
+ this.lifetime[to] = this.lifetime[from];
564
+ this.textureIndex[to] = this.textureIndex[from];
252
565
  }
253
566
  }
254
567