@codexo/exojs-particles 0.15.2 → 0.16.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 (211) hide show
  1. package/README.md +21 -43
  2. package/dist/esm/ParticleStorage.d.ts +188 -0
  3. package/dist/esm/ParticleStorage.d.ts.map +1 -0
  4. package/dist/esm/ParticleStorage.js +208 -0
  5. package/dist/esm/ParticleStorage.js.map +1 -0
  6. package/dist/esm/ParticleSystem.d.ts +189 -60
  7. package/dist/esm/ParticleSystem.d.ts.map +1 -0
  8. package/dist/esm/ParticleSystem.js +660 -560
  9. package/dist/esm/ParticleSystem.js.map +1 -1
  10. package/dist/esm/distributions/BoxArea.d.ts +1 -0
  11. package/dist/esm/distributions/BoxArea.d.ts.map +1 -0
  12. package/dist/esm/distributions/BoxArea.js +38 -44
  13. package/dist/esm/distributions/BoxArea.js.map +1 -1
  14. package/dist/esm/distributions/CircleArea.d.ts +1 -0
  15. package/dist/esm/distributions/CircleArea.d.ts.map +1 -0
  16. package/dist/esm/distributions/CircleArea.js +30 -28
  17. package/dist/esm/distributions/CircleArea.js.map +1 -1
  18. package/dist/esm/distributions/ColorGradient.d.ts +3 -2
  19. package/dist/esm/distributions/ColorGradient.d.ts.map +1 -0
  20. package/dist/esm/distributions/ColorGradient.js +70 -75
  21. package/dist/esm/distributions/ColorGradient.js.map +1 -1
  22. package/dist/esm/distributions/ConeDirection.d.ts +2 -1
  23. package/dist/esm/distributions/ConeDirection.d.ts.map +1 -0
  24. package/dist/esm/distributions/ConeDirection.js +41 -39
  25. package/dist/esm/distributions/ConeDirection.js.map +1 -1
  26. package/dist/esm/distributions/Constant.d.ts +1 -0
  27. package/dist/esm/distributions/Constant.d.ts.map +1 -0
  28. package/dist/esm/distributions/Constant.js +32 -32
  29. package/dist/esm/distributions/Constant.js.map +1 -1
  30. package/dist/esm/distributions/Curve.d.ts +2 -1
  31. package/dist/esm/distributions/Curve.d.ts.map +1 -0
  32. package/dist/esm/distributions/Curve.js +46 -55
  33. package/dist/esm/distributions/Curve.js.map +1 -1
  34. package/dist/esm/distributions/Distribution.d.ts +4 -3
  35. package/dist/esm/distributions/Distribution.d.ts.map +1 -0
  36. package/dist/esm/distributions/LineSegment.d.ts +1 -0
  37. package/dist/esm/distributions/LineSegment.d.ts.map +1 -0
  38. package/dist/esm/distributions/LineSegment.js +25 -23
  39. package/dist/esm/distributions/LineSegment.js.map +1 -1
  40. package/dist/esm/distributions/Range.d.ts +1 -0
  41. package/dist/esm/distributions/Range.d.ts.map +1 -0
  42. package/dist/esm/distributions/Range.js +18 -16
  43. package/dist/esm/distributions/Range.js.map +1 -1
  44. package/dist/esm/distributions/VectorRange.d.ts +1 -0
  45. package/dist/esm/distributions/VectorRange.d.ts.map +1 -0
  46. package/dist/esm/distributions/VectorRange.js +29 -27
  47. package/dist/esm/distributions/VectorRange.js.map +1 -1
  48. package/dist/esm/distributions/index.d.ts +1 -0
  49. package/dist/esm/distributions/index.d.ts.map +1 -0
  50. package/dist/esm/gpu/ParticleGpuState.d.ts +143 -18
  51. package/dist/esm/gpu/ParticleGpuState.d.ts.map +1 -0
  52. package/dist/esm/gpu/ParticleGpuState.js +655 -497
  53. package/dist/esm/gpu/ParticleGpuState.js.map +1 -1
  54. package/dist/esm/gpu/shaders/particle-simulate.wgsl.js +6 -0
  55. package/dist/esm/gpu/shaders/particle-simulate.wgsl.js.map +1 -0
  56. package/dist/esm/index.d.ts +1 -0
  57. package/dist/esm/index.d.ts.map +1 -0
  58. package/dist/esm/index.js +39 -32
  59. package/dist/esm/modules/AlphaFadeOverLifetime.d.ts +4 -3
  60. package/dist/esm/modules/AlphaFadeOverLifetime.d.ts.map +1 -0
  61. package/dist/esm/modules/AlphaFadeOverLifetime.js +68 -55
  62. package/dist/esm/modules/AlphaFadeOverLifetime.js.map +1 -1
  63. package/dist/esm/modules/ApplyForce.d.ts +4 -3
  64. package/dist/esm/modules/ApplyForce.d.ts.map +1 -0
  65. package/dist/esm/modules/ApplyForce.js +49 -43
  66. package/dist/esm/modules/ApplyForce.js.map +1 -1
  67. package/dist/esm/modules/AttractToPoint.d.ts +4 -3
  68. package/dist/esm/modules/AttractToPoint.d.ts.map +1 -0
  69. package/dist/esm/modules/AttractToPoint.js +73 -62
  70. package/dist/esm/modules/AttractToPoint.js.map +1 -1
  71. package/dist/esm/modules/BurstSpawn.d.ts +6 -18
  72. package/dist/esm/modules/BurstSpawn.d.ts.map +1 -0
  73. package/dist/esm/modules/BurstSpawn.js +56 -91
  74. package/dist/esm/modules/BurstSpawn.js.map +1 -1
  75. package/dist/esm/modules/ColorOverLifetime.d.ts +4 -3
  76. package/dist/esm/modules/ColorOverLifetime.d.ts.map +1 -0
  77. package/dist/esm/modules/ColorOverLifetime.js +65 -53
  78. package/dist/esm/modules/ColorOverLifetime.js.map +1 -1
  79. package/dist/esm/modules/ColorOverSpeed.d.ts +5 -4
  80. package/dist/esm/modules/ColorOverSpeed.d.ts.map +1 -0
  81. package/dist/esm/modules/ColorOverSpeed.js +90 -75
  82. package/dist/esm/modules/ColorOverSpeed.js.map +1 -1
  83. package/dist/esm/modules/DeathModule.d.ts +26 -10
  84. package/dist/esm/modules/DeathModule.d.ts.map +1 -0
  85. package/dist/esm/modules/DeathModule.js +38 -22
  86. package/dist/esm/modules/DeathModule.js.map +1 -1
  87. package/dist/esm/modules/Drag.d.ts +3 -2
  88. package/dist/esm/modules/Drag.d.ts.map +1 -0
  89. package/dist/esm/modules/Drag.js +43 -37
  90. package/dist/esm/modules/Drag.js.map +1 -1
  91. package/dist/esm/modules/OrbitalForce.d.ts +4 -3
  92. package/dist/esm/modules/OrbitalForce.d.ts.map +1 -0
  93. package/dist/esm/modules/OrbitalForce.js +70 -58
  94. package/dist/esm/modules/OrbitalForce.js.map +1 -1
  95. package/dist/esm/modules/RateSpawn.d.ts +7 -22
  96. package/dist/esm/modules/RateSpawn.d.ts.map +1 -0
  97. package/dist/esm/modules/RateSpawn.js +39 -71
  98. package/dist/esm/modules/RateSpawn.js.map +1 -1
  99. package/dist/esm/modules/RepelFromPoint.d.ts +4 -3
  100. package/dist/esm/modules/RepelFromPoint.d.ts.map +1 -0
  101. package/dist/esm/modules/RepelFromPoint.js +72 -62
  102. package/dist/esm/modules/RepelFromPoint.js.map +1 -1
  103. package/dist/esm/modules/RotateOverLifetime.d.ts +3 -2
  104. package/dist/esm/modules/RotateOverLifetime.d.ts.map +1 -0
  105. package/dist/esm/modules/RotateOverLifetime.js +40 -36
  106. package/dist/esm/modules/RotateOverLifetime.js.map +1 -1
  107. package/dist/esm/modules/ScaleOverLifetime.d.ts +5 -4
  108. package/dist/esm/modules/ScaleOverLifetime.d.ts.map +1 -0
  109. package/dist/esm/modules/ScaleOverLifetime.js +60 -50
  110. package/dist/esm/modules/ScaleOverLifetime.js.map +1 -1
  111. package/dist/esm/modules/SpawnModule.d.ts +15 -12
  112. package/dist/esm/modules/SpawnModule.d.ts.map +1 -0
  113. package/dist/esm/modules/SpawnModule.js +32 -28
  114. package/dist/esm/modules/SpawnModule.js.map +1 -1
  115. package/dist/esm/modules/SpawnOnDeath.d.ts +13 -9
  116. package/dist/esm/modules/SpawnOnDeath.d.ts.map +1 -0
  117. package/dist/esm/modules/SpawnOnDeath.js +41 -43
  118. package/dist/esm/modules/SpawnOnDeath.js.map +1 -1
  119. package/dist/esm/modules/Turbulence.d.ts +4 -3
  120. package/dist/esm/modules/Turbulence.d.ts.map +1 -0
  121. package/dist/esm/modules/Turbulence.js +101 -89
  122. package/dist/esm/modules/Turbulence.js.map +1 -1
  123. package/dist/esm/modules/UpdateModule.d.ts +21 -13
  124. package/dist/esm/modules/UpdateModule.d.ts.map +1 -0
  125. package/dist/esm/modules/UpdateModule.js +65 -63
  126. package/dist/esm/modules/UpdateModule.js.map +1 -1
  127. package/dist/esm/modules/VelocityOverLifetime.d.ts +7 -6
  128. package/dist/esm/modules/VelocityOverLifetime.d.ts.map +1 -0
  129. package/dist/esm/modules/VelocityOverLifetime.js +75 -73
  130. package/dist/esm/modules/VelocityOverLifetime.js.map +1 -1
  131. package/dist/esm/modules/WgslContribution.d.ts +16 -15
  132. package/dist/esm/modules/WgslContribution.d.ts.map +1 -0
  133. package/dist/esm/modules/WgslContribution.js +37 -29
  134. package/dist/esm/modules/WgslContribution.js.map +1 -1
  135. package/dist/esm/modules/index.d.ts +3 -1
  136. package/dist/esm/modules/index.d.ts.map +1 -0
  137. package/dist/esm/modules/spawnFields.d.ts +32 -0
  138. package/dist/esm/modules/spawnFields.d.ts.map +1 -0
  139. package/dist/esm/modules/spawnFields.js +31 -0
  140. package/dist/esm/modules/spawnFields.js.map +1 -0
  141. package/dist/esm/particlesBuildInfo.d.ts +1 -0
  142. package/dist/esm/particlesBuildInfo.d.ts.map +1 -0
  143. package/dist/esm/particlesBuildInfo.js +6 -4
  144. package/dist/esm/particlesBuildInfo.js.map +1 -1
  145. package/dist/esm/particlesExtension.d.ts +13 -5
  146. package/dist/esm/particlesExtension.d.ts.map +1 -0
  147. package/dist/esm/particlesExtension.js +38 -42
  148. package/dist/esm/particlesExtension.js.map +1 -1
  149. package/dist/esm/public.d.ts +11 -0
  150. package/dist/esm/public.d.ts.map +1 -0
  151. package/dist/esm/renderModes/MeshParticles.d.ts +128 -0
  152. package/dist/esm/renderModes/MeshParticles.d.ts.map +1 -0
  153. package/dist/esm/renderModes/MeshParticles.js +263 -0
  154. package/dist/esm/renderModes/MeshParticles.js.map +1 -0
  155. package/dist/esm/renderModes/ParticleBufferLayout.d.ts +59 -0
  156. package/dist/esm/renderModes/ParticleBufferLayout.d.ts.map +1 -0
  157. package/dist/esm/renderModes/ParticleBufferLayout.js +99 -0
  158. package/dist/esm/renderModes/ParticleBufferLayout.js.map +1 -0
  159. package/dist/esm/renderModes/ParticleInstanceWriter.d.ts +58 -0
  160. package/dist/esm/renderModes/ParticleInstanceWriter.d.ts.map +1 -0
  161. package/dist/esm/renderModes/ParticleInstanceWriter.js +173 -0
  162. package/dist/esm/renderModes/ParticleInstanceWriter.js.map +1 -0
  163. package/dist/esm/renderModes/ParticleMaterial.d.ts +18 -0
  164. package/dist/esm/renderModes/ParticleMaterial.d.ts.map +1 -0
  165. package/dist/esm/renderModes/ParticleMaterial.js +24 -0
  166. package/dist/esm/renderModes/ParticleMaterial.js.map +1 -0
  167. package/dist/esm/renderModes/ParticleRenderMode.d.ts +114 -0
  168. package/dist/esm/renderModes/ParticleRenderMode.d.ts.map +1 -0
  169. package/dist/esm/renderModes/ParticleRenderMode.js +99 -0
  170. package/dist/esm/renderModes/ParticleRenderMode.js.map +1 -0
  171. package/dist/esm/renderModes/QuadParticles.d.ts +55 -0
  172. package/dist/esm/renderModes/QuadParticles.d.ts.map +1 -0
  173. package/dist/esm/renderModes/QuadParticles.js +95 -0
  174. package/dist/esm/renderModes/QuadParticles.js.map +1 -0
  175. package/dist/esm/renderModes/RibbonParticles.d.ts +96 -0
  176. package/dist/esm/renderModes/RibbonParticles.d.ts.map +1 -0
  177. package/dist/esm/renderModes/RibbonParticles.js +202 -0
  178. package/dist/esm/renderModes/RibbonParticles.js.map +1 -0
  179. package/dist/esm/renderModes/shaders/mesh-particles.wgsl.js +6 -0
  180. package/dist/esm/renderModes/shaders/mesh-particles.wgsl.js.map +1 -0
  181. package/dist/esm/renderModes/shaders/mesh.vert.js +6 -0
  182. package/dist/esm/renderModes/shaders/mesh.vert.js.map +1 -0
  183. package/dist/esm/renderModes/shaders/quad-particles.wgsl.js +6 -0
  184. package/dist/esm/renderModes/shaders/quad-particles.wgsl.js.map +1 -0
  185. package/dist/esm/renderModes/shaders/ribbon-particles.wgsl.js +6 -0
  186. package/dist/esm/renderModes/shaders/ribbon-particles.wgsl.js.map +1 -0
  187. package/dist/esm/renderModes/shaders/ribbon.frag.js +6 -0
  188. package/dist/esm/renderModes/shaders/ribbon.frag.js.map +1 -0
  189. package/dist/esm/renderModes/shaders/ribbon.vert.js +6 -0
  190. package/dist/esm/renderModes/shaders/ribbon.vert.js.map +1 -0
  191. package/dist/esm/renderers/WebGl2ParticleRenderer.d.ts +48 -20
  192. package/dist/esm/renderers/WebGl2ParticleRenderer.d.ts.map +1 -0
  193. package/dist/esm/renderers/WebGl2ParticleRenderer.js +317 -321
  194. package/dist/esm/renderers/WebGl2ParticleRenderer.js.map +1 -1
  195. package/dist/esm/renderers/WebGpuParticleRenderer.d.ts +99 -18
  196. package/dist/esm/renderers/WebGpuParticleRenderer.d.ts.map +1 -0
  197. package/dist/esm/renderers/WebGpuParticleRenderer.js +542 -551
  198. package/dist/esm/renderers/WebGpuParticleRenderer.js.map +1 -1
  199. package/dist/esm/renderers/shaders/particle.frag.js +6 -0
  200. package/dist/esm/renderers/shaders/particle.frag.js.map +1 -0
  201. package/dist/esm/renderers/shaders/particle.vert.js +6 -0
  202. package/dist/esm/renderers/shaders/particle.vert.js.map +1 -0
  203. package/package.json +7 -14
  204. package/dist/esm/index.js.map +0 -1
  205. package/dist/esm/register.d.ts +0 -1
  206. package/dist/esm/register.js +0 -43
  207. package/dist/esm/register.js.map +0 -1
  208. package/dist/esm/renderers/glsl/particle.frag.js +0 -4
  209. package/dist/esm/renderers/glsl/particle.frag.js.map +0 -1
  210. package/dist/esm/renderers/glsl/particle.vert.js +0 -4
  211. package/dist/esm/renderers/glsl/particle.vert.js.map +0 -1
@@ -1,569 +1,669 @@
1
- import { Drawable, Rectangle, Texture, Spritesheet } from '@codexo/exojs';
2
- import { ParticleGpuState } from './gpu/ParticleGpuState.js';
1
+ import { ParticleGpuState } from "./gpu/ParticleGpuState.js";
2
+ import { ParticleSlotWriter, ParticleStorage } from "./ParticleStorage.js";
3
+ import { QuadParticles } from "./renderModes/QuadParticles.js";
4
+ import { Drawable, Rectangle, Spritesheet, Texture } from "@codexo/exojs";
3
5
 
4
- /// <reference types="@webgpu/types" />
6
+ //#region src/ParticleSystem.ts
5
7
  const defaultCapacity = 4096;
6
8
  /**
7
- * Lazily-initialised 1×1 opaque-white texture used as the default sprite
8
- * when a {@link ParticleSystem} is constructed without one. Particles
9
- * render as solid color quads (the per-particle `color` channel times
10
- * white-with-alpha-1). Shared across systems to avoid wasted texture
11
- * allocations.
12
- */
9
+ * Lazily-initialised 1×1 opaque-white texture used as the default sprite
10
+ * when a {@link ParticleSystem} is constructed without one. Particles
11
+ * render as solid color quads (the per-particle `color` channel times
12
+ * white-with-alpha-1). Shared across systems to avoid wasted texture
13
+ * allocations.
14
+ */
13
15
  let defaultWhiteTexture = null;
14
16
  const getDefaultWhiteTexture = () => {
15
- if (defaultWhiteTexture === null) {
16
- const canvas = document.createElement('canvas');
17
- canvas.width = 1;
18
- canvas.height = 1;
19
- const ctx = canvas.getContext('2d');
20
- if (ctx !== null) {
21
- ctx.fillStyle = '#ffffff';
22
- ctx.fillRect(0, 0, 1, 1);
23
- }
24
- defaultWhiteTexture = new Texture(canvas);
25
- }
26
- return defaultWhiteTexture;
17
+ if (defaultWhiteTexture === null) {
18
+ const canvas = document.createElement("canvas");
19
+ canvas.width = 1;
20
+ canvas.height = 1;
21
+ const ctx = canvas.getContext("2d");
22
+ if (ctx !== null) {
23
+ ctx.fillStyle = "#ffffff";
24
+ ctx.fillRect(0, 0, 1, 1);
25
+ }
26
+ defaultWhiteTexture = new Texture(canvas);
27
+ }
28
+ return defaultWhiteTexture;
27
29
  };
28
30
  /**
29
- * The central coordinator of the particle pipeline. `ParticleSystem` is a
30
- * {@link Drawable} that owns:
31
- *
32
- * - **SoA particle storage** one typed array per attribute (position,
33
- * velocity, scale, rotation, color, lifetime, ...), sized to a fixed
34
- * capacity at construction. User code reads/writes via
35
- * `system.posX[slot]`, `system.velX[slot]`, etc.
36
- * - **Spawn modules** — write new particles into freshly allocated slots.
37
- * - **Update modules** mutate the live range each frame (forces, color
38
- * blends, scale curves, drag, ...). Built-in modules ship both CPU and
39
- * WGSL implementations; custom modules can opt into GPU acceleration by
40
- * implementing `wgsl()`.
41
- * - **Death modules** fire once per dying particle, before its slot is
42
- * recycled (sub-emitters, event hooks).
43
- *
44
- * **Auto-routing CPU vs GPU:** at first {@link update}, the system checks:
45
- * if a `WebGpuBackend` was supplied AND every registered update module has
46
- * `wgsl()`, the GPU path engages a composite compute pipeline runs
47
- * integration plus all module bodies in one dispatch and writes directly
48
- * into the renderer's instance buffer (no CPU readback). Otherwise the CPU
49
- * path runs the existing per-module `apply()` loops.
50
- *
51
- * **Per-frame order in {@link update} (CPU mode):**
52
- * 1. Run every spawn module.
53
- * 2. Integrate position from velocity, rotation from rotationSpeed, advance `elapsed`.
54
- * 3. Run every update module on the live range.
55
- * 4. Compact: scan `[0, liveCount)` forward, fire death modules on expired
56
- * slots, copy survivors down. `liveCount` shrinks to the survivor count.
57
- *
58
- * **Per-frame order in {@link update} (GPU mode):**
59
- * 1. Run every spawn module (CPU writes initial values into the spawn slot).
60
- * 2. Detect expiries on CPU (via `elapsed >= lifetime`); fire death modules;
61
- * set `lifetime[slot] = -1` sentinel + clear `alive[slot]` so the GPU
62
- * shader skips them. **No compaction** slots are recycled on next spawn.
63
- * 3. Dispatch the composite compute pipeline. Integration + update modules
64
- * + pack-instances run in one pass; the instance buffer is written
65
- * directly. CPU SoA stays as-is for spawn writes.
66
- *
67
- * **Coordinate space:** particle positions are LOCAL to the system. The
68
- * system's `getGlobalTransform()` is applied on top during rendering — both
69
- * the WebGL2 and WebGPU shaders multiply `projection * translation * rotated`.
70
- * Setting world-space positions on individual particles double-translates.
71
- * Position the system itself via `system.setPosition(...)` and emit relative
72
- * to `(0, 0)`.
73
- *
74
- * @example
75
- * // Backend-agnostic runs CPU on WebGL2, GPU on WebGPU automatically.
76
- * const system = new ParticleSystem(loader.get(Texture, 'spark'), {
77
- * capacity: 8192,
78
- * });
79
- *
80
- * system.addSpawnModule(new RateSpawn({ rate: new Constant(60), ... }));
81
- * system.addUpdateModule(new ApplyForce(0, 980)); // gravity, GPU-eligible
82
- * system.addUpdateModule(new ColorOverLifetime(fireGradient));
83
- * scene.addChild(system);
84
- */
85
- class ParticleSystem extends Drawable {
86
- /** Maximum particle count this system will store. Fixed at construction. */
87
- capacity;
88
- posX;
89
- posY;
90
- velX;
91
- velY;
92
- scaleX;
93
- scaleY;
94
- rotations;
95
- rotationSpeeds;
96
- color; // packed 0xAABBGGRR
97
- elapsed; // seconds since spawn
98
- lifetime; // total seconds before expiry; -1 sentinel for dead in GPU mode
99
- textureIndex;
100
- /**
101
- * Number of currently live particles. In CPU mode this is exact: slots
102
- * `[0, liveCount)` are all alive after each `update()`. In GPU mode
103
- * this is a high-water mark — slots `[0, liveCount)` may contain dead
104
- * holes (filled in by future spawns); use {@link aliveCount} for the
105
- * actual alive count.
106
- */
107
- liveCount = 0;
108
- /**
109
- * Per-slot alive flag (1 = alive, 0 = dead). Maintained in both CPU
110
- * and GPU mode. Custom modules iterating the live range should check
111
- * this to skip dead slots in GPU mode.
112
- */
113
- alive;
114
- _spawnModules = [];
115
- _updateModules = [];
116
- _deathModules = [];
117
- _backend = null;
118
- _device = null;
119
- _gpuState = null;
120
- _gpuMode = false;
121
- _compiled = false;
122
- _spawnHint = 0; // round-robin pointer for first-dead lookup in GPU mode
123
- /**
124
- * In GPU mode, slots whose CPU SoA values need re-uploading to the GPU
125
- * (newly spawned, or just-expired with lifetime sentinel). Cleared
126
- * after each compute dispatch. CPU never overwrites integrated GPU
127
- * state only dirty slots flow CPU GPU.
128
- */
129
- _gpuDirtySlots = new Set();
130
- _texture;
131
- _frames = [];
132
- _textureFrame = new Rectangle();
133
- _vertices = new Float32Array(4);
134
- _texCoords = new Uint32Array(4);
135
- _updateTexCoords = true;
136
- _updateVertices = true;
137
- constructor(sourceOrOptions, framesOrOptions, finalOptions) {
138
- super();
139
- // Disambiguate the four valid call shapes via instanceof checks.
140
- // The TS overloads above already prevent illegal combinations like
141
- // `(texture, sheet)` or `(sheet, frames)` at compile time; this
142
- // narrowing only sorts out the legal ones.
143
- let texture = null;
144
- let frames = null;
145
- let options;
146
- if (sourceOrOptions instanceof Texture) {
147
- texture = sourceOrOptions;
148
- if (Array.isArray(framesOrOptions)) {
149
- frames = framesOrOptions;
150
- options = finalOptions ?? {};
151
- }
152
- else {
153
- options = framesOrOptions ?? {};
154
- }
155
- }
156
- else if (sourceOrOptions instanceof Spritesheet) {
157
- texture = sourceOrOptions.texture;
158
- frames = [...sourceOrOptions.frames.values()];
159
- options = framesOrOptions ?? {};
160
- }
161
- else {
162
- options = sourceOrOptions ?? {};
163
- }
164
- const capacity = options.capacity ?? defaultCapacity;
165
- if (capacity <= 0 || !Number.isInteger(capacity)) {
166
- throw new Error(`ParticleSystem capacity must be a positive integer (got ${capacity}).`);
167
- }
168
- this.capacity = capacity;
169
- this.posX = new Float32Array(capacity);
170
- this.posY = new Float32Array(capacity);
171
- this.velX = new Float32Array(capacity);
172
- this.velY = new Float32Array(capacity);
173
- this.scaleX = new Float32Array(capacity);
174
- this.scaleY = new Float32Array(capacity);
175
- this.rotations = new Float32Array(capacity);
176
- this.rotationSpeeds = new Float32Array(capacity);
177
- this.color = new Uint32Array(capacity);
178
- this.elapsed = new Float32Array(capacity);
179
- this.lifetime = new Float32Array(capacity);
180
- this.textureIndex = new Uint16Array(capacity);
181
- this.alive = new Uint8Array(capacity);
182
- this._device = options.device ?? null;
183
- this._texture = texture ?? getDefaultWhiteTexture();
184
- if (frames !== null) {
185
- for (const frame of frames) {
186
- this._frames.push(frame.clone());
187
- }
188
- }
189
- this.resetTextureFrame();
190
- }
191
- get texture() {
192
- return this._texture;
193
- }
194
- set texture(texture) {
195
- this.setTexture(texture);
196
- }
197
- get textureFrame() {
198
- return this._textureFrame;
199
- }
200
- set textureFrame(frame) {
201
- this.setTextureFrame(frame);
202
- }
203
- /**
204
- * Atlas frames declared on this system, or empty when the texture is
205
- * used as a single frame. Each particle's `textureIndex[i]` selects
206
- * an entry from this list; out-of-range indices are clamped to 0.
207
- */
208
- get frames() {
209
- return this._frames;
210
- }
211
- /** `true` when the system declares more than one atlas frame. */
212
- get hasAtlas() {
213
- return this._frames.length > 1;
214
- }
215
- get vertices() {
216
- if (this._updateVertices) {
217
- const { x, y, width, height } = this._textureFrame;
218
- const offsetX = width / 2;
219
- const offsetY = height / 2;
220
- this._vertices[0] = x - offsetX;
221
- this._vertices[1] = y - offsetY;
222
- this._vertices[2] = width - offsetX;
223
- this._vertices[3] = height - offsetY;
224
- this._updateVertices = false;
225
- }
226
- return this._vertices;
227
- }
228
- get texCoords() {
229
- if (this._updateTexCoords) {
230
- const { width, height } = this._texture;
231
- const { left, top, right, bottom } = this._textureFrame;
232
- const minX = ((left / width) * 65535) & 65535;
233
- const minY = (((top / height) * 65535) & 65535) << 16;
234
- const maxX = ((right / width) * 65535) & 65535;
235
- const maxY = (((bottom / height) * 65535) & 65535) << 16;
236
- if (this._texture.flipY) {
237
- this._texCoords[0] = maxY | minX;
238
- this._texCoords[1] = maxY | maxX;
239
- this._texCoords[2] = minY | maxX;
240
- this._texCoords[3] = minY | minX;
241
- }
242
- else {
243
- this._texCoords[0] = minY | minX;
244
- this._texCoords[1] = minY | maxX;
245
- this._texCoords[2] = maxY | maxX;
246
- this._texCoords[3] = maxY | minX;
247
- }
248
- this._updateTexCoords = false;
249
- }
250
- return this._texCoords;
251
- }
252
- /** `true` when the system is running on the GPU compute pipeline. */
253
- get gpuMode() {
254
- return this._gpuMode;
255
- }
256
- /** GPU-side state, or `null` in CPU mode. */
257
- get gpuState() {
258
- return this._gpuState;
259
- }
260
- /** Actual count of live particles (slots with `alive[i] === 1`). May differ from `liveCount` in GPU mode. */
261
- get aliveCount() {
262
- let count = 0;
263
- for (let i = 0; i < this.liveCount; i++) {
264
- if (this.alive[i] === 1)
265
- count++;
266
- }
267
- return count;
268
- }
269
- get spawnModules() {
270
- return this._spawnModules;
271
- }
272
- get updateModules() {
273
- return this._updateModules;
274
- }
275
- get deathModules() {
276
- return this._deathModules;
277
- }
278
- setTexture(texture) {
279
- if (this._texture !== texture) {
280
- this._texture = texture;
281
- this.resetTextureFrame();
282
- }
283
- return this;
284
- }
285
- setTextureFrame(frame) {
286
- this._textureFrame.copy(frame);
287
- this._updateTexCoords = true;
288
- this._updateVertices = true;
289
- this.getLocalBounds().set(0, 0, frame.width, frame.height);
290
- this._invalidateBoundsCascade();
291
- return this;
292
- }
293
- resetTextureFrame() {
294
- return this.setTextureFrame(Rectangle.temp.set(0, 0, this._texture.width, this._texture.height));
295
- }
296
- addSpawnModule(mod) {
297
- this._spawnModules.push(mod);
298
- return this;
299
- }
300
- addUpdateModule(mod) {
301
- if (this._compiled) {
302
- throw new Error('Cannot add update modules after the system has been compiled (first update). Register all modules before the first update().');
303
- }
304
- this._updateModules.push(mod);
305
- return this;
306
- }
307
- addDeathModule(mod) {
308
- this._deathModules.push(mod);
309
- return this;
310
- }
311
- clearSpawnModules() {
312
- for (const mod of this._spawnModules)
313
- mod.destroy();
314
- this._spawnModules.length = 0;
315
- return this;
316
- }
317
- clearUpdateModules() {
318
- for (const mod of this._updateModules)
319
- mod.destroy();
320
- this._updateModules.length = 0;
321
- return this;
322
- }
323
- clearDeathModules() {
324
- for (const mod of this._deathModules)
325
- mod.destroy();
326
- this._deathModules.length = 0;
327
- return this;
328
- }
329
- /**
330
- * Allocates a particle slot and returns its index. Returns `-1` when
331
- * the system is at {@link capacity}.
332
- *
333
- * **CPU mode:** slots are dense in `[0, liveCount)`. `spawn()` returns
334
- * the next sequential slot; `liveCount++`.
335
- *
336
- * **GPU mode:** slots may have dead holes. `spawn()` finds the first
337
- * `alive[i] === 0` slot via a round-robin hint pointer (amortised O(1),
338
- * worst case O(capacity) on full systems).
339
- */
340
- spawn() {
341
- if (this._gpuMode) {
342
- return this._spawnGpu();
343
- }
344
- return this._spawnCpu();
345
- }
346
- /** Resets the system to zero live particles without destroying it. */
347
- clearParticles() {
348
- this.liveCount = 0;
349
- this._spawnHint = 0;
350
- this.alive.fill(0);
351
- this.lifetime.fill(0);
352
- this.elapsed.fill(0);
353
- return this;
354
- }
355
- /**
356
- * @internal
357
- *
358
- * Collect-hook: captures the active backend before this node is emitted so
359
- * the next `update()` can compile a GPU pipeline if the backend turned out
360
- * to be `WebGpuBackend`. Re-captures and rebuilds when the backend
361
- * reference changes (e.g. after device-loss recovery).
362
- */
363
- /** @internal */
364
- _collect(builder, seq) {
365
- const backend = builder.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;
374
- }
375
- super._collect(builder, seq);
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);
392
- }
393
- return this;
394
- }
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] === 1)
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] = posX[i] + velX[i] * dt;
481
- posY[i] = posY[i] + velY[i] * dt;
482
- rotations[i] = rotations[i] + rotationSpeeds[i] * dt;
483
- elapsed[i] = 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;
499
- continue;
500
- }
501
- if (writeIndex !== readIndex) {
502
- this._copySlot(readIndex, writeIndex);
503
- alive[writeIndex] = 1;
504
- }
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] = 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
534
- }
535
- }
536
- // Trim trailing dead slots.
537
- let newLiveCount = this.liveCount;
538
- while (newLiveCount > 0 && alive[newLiveCount - 1] === 0) {
539
- newLiveCount--;
540
- }
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);
551
- }
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];
565
- }
566
- }
31
+ * The process-wide default render mode, created on first use.
32
+ *
33
+ * Every system constructed without `ParticleSystemOptions.render` draws with
34
+ * this one instance. The backends key their GPU resources on the mode's
35
+ * material, so sharing it means N systems cost one compiled program / pipeline
36
+ * set, one vertex array object and one vertex buffer between them - what a
37
+ * single system costs.
38
+ *
39
+ * Sharing the mode also shares its scratch buffer, which is safe because that
40
+ * buffer is only live between a `build()` and the upload that immediately
41
+ * follows it: the WebGL2 renderer flushes the pending system at the top of
42
+ * every `render()` before building the next one, and the WebGPU renderer
43
+ * copies the built bytes out through `queue.writeBuffer` before it moves on to
44
+ * the next drawcall. That is exactly how the renderers behaved when each of
45
+ * them owned one shared pack buffer for all systems.
46
+ *
47
+ * Never destroyed. It outlives every individual system (see
48
+ * {@link ParticleSystem.destroy}), so a scene that destroys one system leaves
49
+ * the others drawing and a system constructed afterwards reuses the already
50
+ * compiled program instead of paying for a fresh one. The GPU resources behind
51
+ * it are released by the backend when the renderer disconnects.
52
+ */
53
+ let defaultRenderMode = null;
54
+ const getDefaultRenderMode = () => {
55
+ defaultRenderMode ??= new QuadParticles();
56
+ return defaultRenderMode;
57
+ };
58
+ /**
59
+ * The central coordinator of the particle pipeline. `ParticleSystem` is a
60
+ * {@link Drawable} that owns:
61
+ *
62
+ * - **Particle storage** - one channel per attribute (position, velocity,
63
+ * scale, rotation, color, timing, ...), sized to a fixed capacity at
64
+ * construction. Modules and render modes address it by name through a
65
+ * {@link ParticleBatch}; user code brings particles into existence with
66
+ * {@link emit}.
67
+ * - **Spawn modules** - fill freshly emitted particles.
68
+ * - **Update modules** - mutate the live range each frame (forces, color
69
+ * blends, scale curves, drag, ...). Built-in modules ship both CPU and
70
+ * WGSL implementations; custom modules can opt into GPU acceleration by
71
+ * implementing `wgsl()`.
72
+ * - **Death modules** - fire once per dying particle, before its slot is
73
+ * recycled (sub-emitters, event hooks).
74
+ *
75
+ * **Auto-routing CPU vs GPU:** at first {@link update}, the system checks:
76
+ * if a `WebGpuBackend` was supplied AND every registered update module has
77
+ * `wgsl()` AND the render mode is GPU-eligible, the GPU path engages - a
78
+ * composite compute pipeline runs
79
+ * integration plus all module bodies in one dispatch and writes directly
80
+ * into the renderer's instance buffer (no CPU readback). Otherwise the CPU
81
+ * path runs the existing per-module `apply()` loops.
82
+ *
83
+ * **Per-frame order in {@link update} (CPU mode):**
84
+ * 1. Run every spawn module.
85
+ * 2. Integrate position from velocity, rotation from rotationSpeed, advance `elapsed`.
86
+ * 3. Run every update module on the live range.
87
+ * 4. Compact: scan `[0, liveCount)` forward, fire death modules on expired
88
+ * slots, copy survivors down. `liveCount` shrinks to the survivor count.
89
+ *
90
+ * **Per-frame order in {@link update} (GPU mode):**
91
+ * 1. Run every spawn module (CPU writes initial values into the spawn slot).
92
+ * 2. Detect expiries on CPU (via `elapsed >= lifetime`); fire death modules;
93
+ * set `lifetime[slot] = -1` sentinel + clear `alive[slot]` so the GPU
94
+ * shader skips them. **No compaction** - slots are recycled on next spawn.
95
+ * 3. Dispatch the composite compute pipeline. Integration + update modules
96
+ * + pack-instances run in one pass; the instance buffer is written
97
+ * directly. CPU SoA stays as-is for spawn writes.
98
+ *
99
+ * **Coordinate space:** particle positions are LOCAL to the system. The
100
+ * system's `getGlobalTransform()` is applied on top during rendering - both
101
+ * the WebGL2 and WebGPU shaders multiply `projection * translation * rotated`.
102
+ * Setting world-space positions on individual particles double-translates.
103
+ * Position the system itself via `system.setPosition(...)` and emit relative
104
+ * to `(0, 0)`.
105
+ *
106
+ * **Pixel snapping:** {@link Drawable.pixelSnapMode} is intentionally ignored
107
+ * for particle systems. Particle instances bake their own per-particle
108
+ * transforms in the emitter/compute path rather than reading the shared
109
+ * pixel-snap transform row, so a snap mode set on the system has no effect on
110
+ * rendered output - snapping thousands of independently-moving sub-pixel
111
+ * particles to the device grid is neither meaningful nor desirable.
112
+ *
113
+ * @example
114
+ * // Backend-agnostic - runs CPU on WebGL2, GPU on WebGPU automatically.
115
+ * const system = new ParticleSystem(loader.get('spark.png'), {
116
+ * capacity: 8192,
117
+ * });
118
+ *
119
+ * system.addSpawnModule(new RateSpawn({ rate: new Constant(60), ... }));
120
+ * system.addUpdateModule(new ApplyForce(0, 980)); // gravity, GPU-eligible
121
+ * system.addUpdateModule(new ColorOverLifetime(fireGradient));
122
+ * scene.addChild(system);
123
+ */
124
+ var ParticleSystem = class extends Drawable {
125
+ /** Maximum particle count this system will store. Fixed at construction. */
126
+ capacity;
127
+ /**
128
+ * The simulation's channel storage. Handed to update modules and render modes
129
+ * as a {@link ParticleBatch}; never exposed as a property of the system,
130
+ * because outside those two callbacks its integrated values are not
131
+ * backend-true.
132
+ * @internal
133
+ */
134
+ _storage;
135
+ _writer;
136
+ _spawnModules = [];
137
+ _updateModules = [];
138
+ _deathModules = [];
139
+ _backend = null;
140
+ _device = null;
141
+ _gpuState = null;
142
+ _gpuMode = false;
143
+ _compiled = false;
144
+ _spawnHint = 0;
145
+ /**
146
+ * In GPU mode, slots whose CPU SoA values need re-uploading to the GPU
147
+ * (newly spawned, or just-expired with lifetime sentinel). Cleared
148
+ * after each compute dispatch. CPU never overwrites integrated GPU
149
+ * state - only dirty slots flow CPU → GPU.
150
+ */
151
+ _gpuDirtySlots = /* @__PURE__ */ new Set();
152
+ /**
153
+ * Spawn lifetimes, in death order per slot, of the particles that expired
154
+ * since the last batch the device staged for readback. The device's own
155
+ * record cannot carry them: by the time the shader sees the particle, the CPU
156
+ * has already overwritten its lifetime with the expiry sentinel. A slot can
157
+ * appear more than once when it is recycled and dies again before the batch
158
+ * is staged, so the lifetimes queue up per slot.
159
+ */
160
+ _pendingDeathLifetimes = /* @__PURE__ */ new Map();
161
+ /** Total entries across `_pendingDeathLifetimes`, which is what the dispatch reports. */
162
+ _pendingDeathCount = 0;
163
+ /** Whether this system has already reported that its death backlog overflowed. */
164
+ _deathOverflowReported = false;
165
+ /**
166
+ * Slots handed out by `spawn()` while a recording window is open, or `null`
167
+ * when nothing is recording. Lets callers identify freshly spawned particles
168
+ * without diffing `liveCount`, which cannot see a recycled GPU slot.
169
+ */
170
+ _spawnRecord = null;
171
+ _renderMode;
172
+ /** Whether {@link destroy} may destroy {@link _renderMode} - false for the shared default. */
173
+ _ownsRenderMode;
174
+ _texture;
175
+ _frames = [];
176
+ _textureFrame = new Rectangle();
177
+ _vertices = /* @__PURE__ */ new Float32Array(4);
178
+ _texCoords = /* @__PURE__ */ new Uint32Array(4);
179
+ _updateTexCoords = true;
180
+ _updateVertices = true;
181
+ constructor(sourceOrOptions, framesOrOptions, finalOptions) {
182
+ super();
183
+ let texture = null;
184
+ let frames = null;
185
+ let options;
186
+ if (sourceOrOptions instanceof Texture) {
187
+ texture = sourceOrOptions;
188
+ if (Array.isArray(framesOrOptions)) {
189
+ frames = framesOrOptions;
190
+ options = finalOptions ?? {};
191
+ } else options = framesOrOptions ?? {};
192
+ } else if (sourceOrOptions instanceof Spritesheet) {
193
+ texture = sourceOrOptions.texture;
194
+ frames = [...sourceOrOptions.frames.values()];
195
+ options = framesOrOptions ?? {};
196
+ } else options = sourceOrOptions ?? {};
197
+ const capacity = options.capacity ?? defaultCapacity;
198
+ if (capacity <= 0 || !Number.isInteger(capacity)) throw new Error(`ParticleSystem capacity must be a positive integer (got ${capacity}).`);
199
+ this.capacity = capacity;
200
+ this._storage = new ParticleStorage(capacity);
201
+ this._writer = new ParticleSlotWriter(this._storage);
202
+ this._device = options.device ?? null;
203
+ this._ownsRenderMode = options.render !== void 0;
204
+ this._renderMode = options.render ?? getDefaultRenderMode();
205
+ this._texture = texture ?? getDefaultWhiteTexture();
206
+ if (frames !== null) for (const frame of frames) this._frames.push(frame.clone());
207
+ this.resetTextureFrame();
208
+ }
209
+ /**
210
+ * The render mode this system's particles are drawn with. Fixed at
211
+ * construction via `ParticleSystemOptions.render`; the backend renderers
212
+ * read it every draw to learn the vertex layout, shader and draw model.
213
+ *
214
+ * Without that option this is the shared default mode - the same instance
215
+ * every other defaulted system draws with, so do not destroy it or mutate
216
+ * its material.
217
+ */
218
+ get renderMode() {
219
+ return this._renderMode;
220
+ }
221
+ get texture() {
222
+ return this._texture;
223
+ }
224
+ set texture(texture) {
225
+ this.setTexture(texture);
226
+ }
227
+ get textureFrame() {
228
+ return this._textureFrame;
229
+ }
230
+ set textureFrame(frame) {
231
+ this.setTextureFrame(frame);
232
+ }
233
+ /**
234
+ * Atlas frames declared on this system, or empty when the texture is
235
+ * used as a single frame. Each particle's {@link textureIndex} selects
236
+ * an entry from this list; anything out of range shows frame 0.
237
+ */
238
+ get frames() {
239
+ return this._frames;
240
+ }
241
+ /** `true` when the system declares more than one atlas frame. */
242
+ get hasAtlas() {
243
+ return this._frames.length > 1;
244
+ }
245
+ get vertices() {
246
+ if (this._updateVertices) {
247
+ const { x, y, width, height } = this._textureFrame;
248
+ const offsetX = width / 2;
249
+ const offsetY = height / 2;
250
+ this._vertices[0] = x - offsetX;
251
+ this._vertices[1] = y - offsetY;
252
+ this._vertices[2] = width - offsetX;
253
+ this._vertices[3] = height - offsetY;
254
+ this._updateVertices = false;
255
+ }
256
+ return this._vertices;
257
+ }
258
+ get texCoords() {
259
+ if (this._updateTexCoords) {
260
+ const { width, height } = this._texture;
261
+ const { left, top, right, bottom } = this._textureFrame;
262
+ const minX = left / width * 65535 & 65535;
263
+ const minY = (top / height * 65535 & 65535) << 16;
264
+ const maxX = right / width * 65535 & 65535;
265
+ const maxY = (bottom / height * 65535 & 65535) << 16;
266
+ if (this._texture.flipY) {
267
+ this._texCoords[0] = maxY | minX;
268
+ this._texCoords[1] = maxY | maxX;
269
+ this._texCoords[2] = minY | maxX;
270
+ this._texCoords[3] = minY | minX;
271
+ } else {
272
+ this._texCoords[0] = minY | minX;
273
+ this._texCoords[1] = minY | maxX;
274
+ this._texCoords[2] = maxY | maxX;
275
+ this._texCoords[3] = maxY | minX;
276
+ }
277
+ this._updateTexCoords = false;
278
+ }
279
+ return this._texCoords;
280
+ }
281
+ /** `true` when the system is running on the GPU compute pipeline. */
282
+ get gpuMode() {
283
+ return this._gpuMode;
284
+ }
285
+ /** GPU-side state, or `null` in CPU mode. */
286
+ get gpuState() {
287
+ return this._gpuState;
288
+ }
289
+ /**
290
+ * Upper bound of the slot range that can hold live particles.
291
+ *
292
+ * Exact on the CPU path: after each `update()` slots `[0, liveCount)` are all
293
+ * alive. On the GPU path it is a high-water mark whose range can contain dead
294
+ * holes that future emissions fill; {@link aliveCount} counts the live ones.
295
+ */
296
+ get liveCount() {
297
+ return this._storage.count;
298
+ }
299
+ /** Actual count of live particles. May be below {@link liveCount} on the GPU path. */
300
+ get aliveCount() {
301
+ const { alive, count } = this._storage;
302
+ let live = 0;
303
+ for (let i = 0; i < count; i++) if (alive[i] === 1) live++;
304
+ return live;
305
+ }
306
+ get spawnModules() {
307
+ return this._spawnModules;
308
+ }
309
+ get updateModules() {
310
+ return this._updateModules;
311
+ }
312
+ get deathModules() {
313
+ return this._deathModules;
314
+ }
315
+ setTexture(texture) {
316
+ if (this._texture !== texture) {
317
+ this._texture = texture;
318
+ this.resetTextureFrame();
319
+ }
320
+ return this;
321
+ }
322
+ setTextureFrame(frame) {
323
+ this._textureFrame.copy(frame);
324
+ this._updateTexCoords = true;
325
+ this._updateVertices = true;
326
+ this._setLocalBounds(0, 0, frame.width, frame.height);
327
+ return this;
328
+ }
329
+ resetTextureFrame() {
330
+ return this.setTextureFrame(Rectangle.temp.set(0, 0, this._texture.width, this._texture.height));
331
+ }
332
+ addSpawnModule(mod) {
333
+ this._spawnModules.push(mod);
334
+ return this;
335
+ }
336
+ /**
337
+ * Registers an update module. Modules run in registration order, each seeing
338
+ * what the previous ones did.
339
+ *
340
+ * Modules may be added and removed at any time, including mid-flight: the
341
+ * next update rebuilds whatever the change invalidated. On the GPU path that
342
+ * is the compute program alone - live particles keep the state the device has
343
+ * been integrating. Adding a module without a `wgsl()` implementation to a
344
+ * running GPU system is the one change that cannot preserve them: the
345
+ * simulation moves to the CPU, which has no copy of the integrated state, so
346
+ * the system clears its live particles rather than continuing from stale
347
+ * values (see {@link clearParticles}).
348
+ */
349
+ addUpdateModule(mod) {
350
+ this._updateModules.push(mod);
351
+ this._invalidateProgram();
352
+ return this;
353
+ }
354
+ addDeathModule(mod) {
355
+ const hadNone = this._deathModules.length === 0;
356
+ this._deathModules.push(mod);
357
+ if (hadNone) this._invalidateProgram();
358
+ return this;
359
+ }
360
+ clearSpawnModules() {
361
+ for (const mod of this._spawnModules) mod.destroy();
362
+ this._spawnModules.length = 0;
363
+ return this;
364
+ }
365
+ clearUpdateModules() {
366
+ for (const mod of this._updateModules) mod.destroy();
367
+ this._updateModules.length = 0;
368
+ this._invalidateProgram();
369
+ return this;
370
+ }
371
+ clearDeathModules() {
372
+ for (const mod of this._deathModules) mod.destroy();
373
+ this._deathModules.length = 0;
374
+ this._invalidateProgram();
375
+ return this;
376
+ }
377
+ /**
378
+ * Brings one particle into existence and returns a writer for its initial
379
+ * values, or `null` when the system is at {@link capacity}.
380
+ *
381
+ * The particle starts at the spawn defaults - origin, no velocity, unit
382
+ * scale, no rotation, opaque white, frame 0, one second of life - so a caller
383
+ * writes only what it varies. The returned writer is a cursor that the next
384
+ * `emit()` rebinds, so it must not be stored.
385
+ *
386
+ * Emission is the only per-particle write that is true on every backend:
387
+ * spawn values originate on the CPU and are uploaded from there, while
388
+ * everything the simulation integrates afterwards lives wherever the
389
+ * simulation runs.
390
+ *
391
+ * @example
392
+ * ```ts
393
+ * const particle = system.emit();
394
+ *
395
+ * if (particle) {
396
+ * particle.position.set(120, 40);
397
+ * particle.velocity.set(0, -80);
398
+ * particle.lifetime = 2;
399
+ * }
400
+ * ```
401
+ */
402
+ emit() {
403
+ const slot = this._spawnSlot();
404
+ if (slot < 0) return null;
405
+ return this._writer.bind(slot);
406
+ }
407
+ /**
408
+ * Allocates one slot at the spawn defaults and returns its index, or `-1` at
409
+ * capacity. Backs {@link emit}; the simulation's own machinery addresses
410
+ * particles by slot, the public surface does not.
411
+ * @internal
412
+ */
413
+ _spawnSlot() {
414
+ const slot = this._gpuMode ? this._spawnGpu() : this._spawnCpu();
415
+ if (slot >= 0) this._storage.reset(slot);
416
+ return slot;
417
+ }
418
+ /**
419
+ * Begin recording the slots handed out by {@link spawn}.
420
+ *
421
+ * Callers that post-process freshly spawned particles cannot infer which
422
+ * slots those are from {@link liveCount}: in GPU mode a spawn may recycle a
423
+ * dead slot below the high-water mark, leaving the count unchanged, and the
424
+ * reused slots are scattered rather than contiguous. Pass the returned token
425
+ * back to {@link _endSpawnRecording}.
426
+ *
427
+ * @internal
428
+ */
429
+ _beginSpawnRecording() {
430
+ const previous = this._spawnRecord;
431
+ this._spawnRecord = [];
432
+ return previous;
433
+ }
434
+ /**
435
+ * End the recording started by {@link _beginSpawnRecording} and return the
436
+ * slots allocated during it. Slots are also handed to an enclosing recording
437
+ * so a nested spawn stays visible to the outer window.
438
+ *
439
+ * @internal
440
+ */
441
+ _endSpawnRecording(previous) {
442
+ const recorded = this._spawnRecord ?? [];
443
+ this._spawnRecord = previous;
444
+ if (previous !== null) for (const slot of recorded) previous.push(slot);
445
+ return recorded;
446
+ }
447
+ /**
448
+ * Shifts the particles a sub-emitter just produced by `(x, y)`, so a child
449
+ * spawner's distributions read as offsets from the death position.
450
+ * @internal
451
+ */
452
+ _offsetSpawned(slots, x, y) {
453
+ const { posX, posY } = this._storage;
454
+ for (const slot of slots) {
455
+ posX[slot] = posX[slot] + x;
456
+ posY[slot] = posY[slot] + y;
457
+ }
458
+ }
459
+ /** Resets the system to zero live particles without destroying it. */
460
+ clearParticles() {
461
+ const storage = this._storage;
462
+ storage.count = 0;
463
+ this._spawnHint = 0;
464
+ storage.alive.fill(0);
465
+ storage.lifetime.fill(0);
466
+ storage.elapsed.fill(0);
467
+ return this;
468
+ }
469
+ /**
470
+ * @internal
471
+ *
472
+ * Collect-hook: captures the active backend before this node is emitted so
473
+ * the next `update()` can compile a GPU pipeline if the backend turned out
474
+ * to be `WebGpuBackend`. Re-captures and rebuilds when the backend
475
+ * reference changes (e.g. after device-loss recovery).
476
+ */
477
+ /** @internal */
478
+ _collect(builder, seq) {
479
+ const backend = builder.backend;
480
+ if (this._backend !== backend) {
481
+ this._backend = backend;
482
+ if (this._gpuState !== null) {
483
+ this._gpuState.destroy();
484
+ this._gpuState = null;
485
+ }
486
+ this._gpuMode = false;
487
+ this._compiled = false;
488
+ }
489
+ super._collect(builder, seq);
490
+ }
491
+ /** Per-frame entry point. Routes to CPU or GPU pipeline based on auto-detection at first call. */
492
+ update(delta) {
493
+ if (!this._compiled) this._compile();
494
+ const dt = delta;
495
+ for (let i = 0; i < this._spawnModules.length; i++) this._spawnModules[i].apply(this, dt);
496
+ if (this._gpuMode) this._updateGpu(dt);
497
+ else this._updateCpu(dt);
498
+ return this;
499
+ }
500
+ destroy() {
501
+ super.destroy();
502
+ this.clearSpawnModules();
503
+ this.clearUpdateModules();
504
+ this.clearDeathModules();
505
+ if (this._ownsRenderMode) this._renderMode.destroy();
506
+ if (this._gpuState !== null) {
507
+ this._gpuState.destroy();
508
+ this._gpuState = null;
509
+ }
510
+ for (const frame of this._frames) frame.destroy();
511
+ this._frames.length = 0;
512
+ this._gpuMode = false;
513
+ this._compiled = false;
514
+ this._storage.count = 0;
515
+ this._storage.alive.fill(0);
516
+ this._textureFrame.destroy();
517
+ }
518
+ /**
519
+ * Marks the compiled program stale. The next update decides what that costs:
520
+ * a GPU system that stays GPU-eligible rebuilds only its program, everything
521
+ * else follows the transition rules in {@link _compile}.
522
+ */
523
+ _invalidateProgram() {
524
+ this._compiled = false;
525
+ }
526
+ _compile() {
527
+ this._compiled = true;
528
+ const eligible = this._updateModules.every((m) => typeof m.wgsl === "function") && this._renderMode.gpuEligible;
529
+ if (this._gpuState !== null) {
530
+ if (eligible) {
531
+ this._gpuState.setProgram(this._updateModules, this._deathModules.length > 0);
532
+ return;
533
+ }
534
+ this._gpuState.destroy();
535
+ this._gpuState = null;
536
+ this._gpuMode = false;
537
+ this._gpuDirtySlots.clear();
538
+ this.clearParticles();
539
+ return;
540
+ }
541
+ const backendDevice = this._backend?.device ?? null;
542
+ const device = this._device ?? backendDevice;
543
+ if (device === null) return;
544
+ if (!eligible) return;
545
+ this._gpuState = new ParticleGpuState(device, this.capacity, this._updateModules, this._frames, this._texture, this._deathModules.length > 0);
546
+ this._gpuMode = true;
547
+ const { alive, count } = this._storage;
548
+ for (let i = 0; i < count; i++) if (alive[i] === 1) this._gpuDirtySlots.add(i);
549
+ }
550
+ _spawnCpu() {
551
+ const storage = this._storage;
552
+ if (storage.count >= this.capacity) return -1;
553
+ const slot = storage.count++;
554
+ storage.alive[slot] = 1;
555
+ this._spawnRecord?.push(slot);
556
+ return slot;
557
+ }
558
+ _spawnGpu() {
559
+ const capacity = this.capacity;
560
+ const alive = this._storage.alive;
561
+ const start = this._spawnHint;
562
+ for (let i = start; i < capacity; i++) if (alive[i] === 0) return this._claimGpuSlot(i, i + 1 === capacity ? 0 : i + 1);
563
+ for (let i = 0; i < start; i++) if (alive[i] === 0) return this._claimGpuSlot(i, i + 1);
564
+ return -1;
565
+ }
566
+ _claimGpuSlot(slot, nextHint) {
567
+ const storage = this._storage;
568
+ storage.alive[slot] = 1;
569
+ this._spawnHint = nextHint;
570
+ if (slot >= storage.count) storage.count = slot + 1;
571
+ this._gpuDirtySlots.add(slot);
572
+ this._spawnRecord?.push(slot);
573
+ return slot;
574
+ }
575
+ _updateCpu(dt) {
576
+ const storage = this._storage;
577
+ const { posX, posY, velX, velY, rotations, rotationSpeeds, elapsed, lifetime, alive } = storage;
578
+ const liveCount = storage.count;
579
+ for (let i = 0; i < liveCount; i++) {
580
+ posX[i] = posX[i] + velX[i] * dt;
581
+ posY[i] = posY[i] + velY[i] * dt;
582
+ rotations[i] = rotations[i] + rotationSpeeds[i] * dt;
583
+ elapsed[i] = elapsed[i] + dt;
584
+ }
585
+ for (let i = 0; i < this._updateModules.length; i++) this._updateModules[i].apply(storage, dt);
586
+ const deathModules = this._deathModules;
587
+ let writeIndex = 0;
588
+ for (let readIndex = 0; readIndex < storage.count; readIndex++) {
589
+ if (elapsed[readIndex] >= lifetime[readIndex]) {
590
+ if (deathModules.length > 0) this._reportDeath(storage.snapshot(readIndex));
591
+ alive[readIndex] = 0;
592
+ continue;
593
+ }
594
+ if (writeIndex !== readIndex) {
595
+ storage.copySlot(readIndex, writeIndex);
596
+ alive[writeIndex] = 1;
597
+ }
598
+ writeIndex++;
599
+ }
600
+ for (let i = writeIndex; i < storage.count; i++) alive[i] = 0;
601
+ storage.count = writeIndex;
602
+ }
603
+ /** Hands one death snapshot to every registered death module, in registration order. */
604
+ _reportDeath(death) {
605
+ const deathModules = this._deathModules;
606
+ for (let m = 0; m < deathModules.length; m++) deathModules[m].onDeath(this, death);
607
+ }
608
+ _updateGpu(dt) {
609
+ const storage = this._storage;
610
+ const { elapsed, lifetime, alive } = storage;
611
+ const liveCount = storage.count;
612
+ const reportsDeaths = this._deathModules.length > 0;
613
+ for (let i = 0; i < liveCount; i++) {
614
+ if (alive[i] === 0) continue;
615
+ elapsed[i] = elapsed[i] + dt;
616
+ if (elapsed[i] >= lifetime[i]) {
617
+ if (reportsDeaths) {
618
+ const queued = this._pendingDeathLifetimes.get(i);
619
+ if (queued === void 0) this._pendingDeathLifetimes.set(i, [lifetime[i]]);
620
+ else queued.push(lifetime[i]);
621
+ this._pendingDeathCount++;
622
+ }
623
+ alive[i] = 0;
624
+ lifetime[i] = -1;
625
+ this._gpuDirtySlots.delete(i);
626
+ this._gpuState.uploadExpiry(i);
627
+ }
628
+ }
629
+ if (this._gpuDirtySlots.size > 0) {
630
+ this._gpuState.uploadDirty(this, this._gpuDirtySlots);
631
+ this._gpuDirtySlots.clear();
632
+ }
633
+ const staged = this._gpuState.dispatch(dt, liveCount, this._pendingDeathCount);
634
+ let newLiveCount = storage.count;
635
+ while (newLiveCount > 0 && alive[newLiveCount - 1] === 0) newLiveCount--;
636
+ storage.count = newLiveCount;
637
+ if (staged) {
638
+ const pending = this._pendingDeathLifetimes;
639
+ this._pendingDeathLifetimes = /* @__PURE__ */ new Map();
640
+ this._pendingDeathCount = 0;
641
+ this._drainDeaths(pending);
642
+ }
643
+ }
644
+ /**
645
+ * Delivers a staged batch of deaths once its readback has landed. Each record
646
+ * carries what the device integrated; the lifetime comes from the CPU, which
647
+ * is where it was written at spawn.
648
+ */
649
+ async _drainDeaths(pending) {
650
+ await this._gpuState?.readDeaths((records) => {
651
+ for (const record of records) this._reportDeath({
652
+ x: record.x,
653
+ y: record.y,
654
+ velocityX: record.velocityX,
655
+ velocityY: record.velocityY,
656
+ rotation: record.rotation,
657
+ scaleX: record.scaleX,
658
+ scaleY: record.scaleY,
659
+ color: record.color,
660
+ elapsed: record.elapsed,
661
+ lifetime: pending.get(record.slot)?.shift() ?? 0
662
+ });
663
+ });
664
+ }
665
+ };
567
666
 
667
+ //#endregion
568
668
  export { ParticleSystem };
569
- //# sourceMappingURL=ParticleSystem.js.map
669
+ //# sourceMappingURL=ParticleSystem.js.map