@codexo/exojs 0.7.13 → 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 (134) hide show
  1. package/CHANGELOG.md +403 -0
  2. package/dist/esm/index.js +28 -7
  3. package/dist/esm/index.js.map +1 -1
  4. package/dist/esm/particles/ParticleSystem.d.ts +180 -83
  5. package/dist/esm/particles/ParticleSystem.js +446 -133
  6. package/dist/esm/particles/ParticleSystem.js.map +1 -1
  7. package/dist/esm/particles/distributions/BoxArea.d.ts +17 -0
  8. package/dist/esm/particles/distributions/BoxArea.js +48 -0
  9. package/dist/esm/particles/distributions/BoxArea.js.map +1 -0
  10. package/dist/esm/particles/distributions/CircleArea.d.ts +19 -0
  11. package/dist/esm/particles/distributions/CircleArea.js +33 -0
  12. package/dist/esm/particles/distributions/CircleArea.js.map +1 -0
  13. package/dist/esm/particles/distributions/ConeDirection.d.ts +28 -0
  14. package/dist/esm/particles/distributions/ConeDirection.js +44 -0
  15. package/dist/esm/particles/distributions/ConeDirection.js.map +1 -0
  16. package/dist/esm/particles/distributions/Constant.d.ts +17 -0
  17. package/dist/esm/particles/distributions/Constant.js +35 -0
  18. package/dist/esm/particles/distributions/Constant.js.map +1 -0
  19. package/dist/esm/particles/distributions/Curve.d.ts +30 -0
  20. package/dist/esm/particles/distributions/Curve.js +53 -0
  21. package/dist/esm/particles/distributions/Curve.js.map +1 -0
  22. package/dist/esm/particles/distributions/Distribution.d.ts +45 -0
  23. package/dist/esm/particles/distributions/Gradient.d.ts +40 -0
  24. package/dist/esm/particles/distributions/Gradient.js +72 -0
  25. package/dist/esm/particles/distributions/Gradient.js.map +1 -0
  26. package/dist/esm/particles/distributions/LineSegment.d.ts +15 -0
  27. package/dist/esm/particles/distributions/LineSegment.js +27 -0
  28. package/dist/esm/particles/distributions/LineSegment.js.map +1 -0
  29. package/dist/esm/particles/distributions/Range.d.ts +12 -0
  30. package/dist/esm/particles/distributions/Range.js +19 -0
  31. package/dist/esm/particles/distributions/Range.js.map +1 -0
  32. package/dist/esm/particles/distributions/VectorRange.d.ts +20 -0
  33. package/dist/esm/particles/distributions/VectorRange.js +31 -0
  34. package/dist/esm/particles/distributions/VectorRange.js.map +1 -0
  35. package/dist/esm/particles/distributions/index.d.ts +12 -0
  36. package/dist/esm/particles/gpu/ParticleGpuState.d.ts +57 -0
  37. package/dist/esm/particles/gpu/ParticleGpuState.js +508 -0
  38. package/dist/esm/particles/gpu/ParticleGpuState.js.map +1 -0
  39. package/dist/esm/particles/index.d.ts +2 -10
  40. package/dist/esm/particles/modules/AlphaFadeOverLifetime.d.ts +24 -0
  41. package/dist/esm/particles/modules/AlphaFadeOverLifetime.js +60 -0
  42. package/dist/esm/particles/modules/AlphaFadeOverLifetime.js.map +1 -0
  43. package/dist/esm/particles/modules/ApplyForce.d.ts +20 -0
  44. package/dist/esm/particles/modules/ApplyForce.js +48 -0
  45. package/dist/esm/particles/modules/ApplyForce.js.map +1 -0
  46. package/dist/esm/particles/modules/AttractToPoint.d.ts +27 -0
  47. package/dist/esm/particles/modules/AttractToPoint.js +73 -0
  48. package/dist/esm/particles/modules/AttractToPoint.js.map +1 -0
  49. package/dist/esm/particles/modules/BurstSpawn.d.ts +53 -0
  50. package/dist/esm/particles/modules/BurstSpawn.js +94 -0
  51. package/dist/esm/particles/modules/BurstSpawn.js.map +1 -0
  52. package/dist/esm/particles/modules/ColorOverLifetime.d.ts +22 -0
  53. package/dist/esm/particles/modules/ColorOverLifetime.js +65 -0
  54. package/dist/esm/particles/modules/ColorOverLifetime.js.map +1 -0
  55. package/dist/esm/particles/modules/ColorOverSpeed.d.ts +27 -0
  56. package/dist/esm/particles/modules/ColorOverSpeed.js +86 -0
  57. package/dist/esm/particles/modules/ColorOverSpeed.js.map +1 -0
  58. package/dist/esm/particles/modules/DeathModule.d.ts +24 -0
  59. package/dist/esm/particles/modules/DeathModule.js +25 -0
  60. package/dist/esm/particles/modules/DeathModule.js.map +1 -0
  61. package/dist/esm/particles/modules/Drag.d.ts +20 -0
  62. package/dist/esm/particles/modules/Drag.js +45 -0
  63. package/dist/esm/particles/modules/Drag.js.map +1 -0
  64. package/dist/esm/particles/modules/OrbitalForce.d.ts +28 -0
  65. package/dist/esm/particles/modules/OrbitalForce.js +65 -0
  66. package/dist/esm/particles/modules/OrbitalForce.js.map +1 -0
  67. package/dist/esm/particles/modules/RateSpawn.d.ts +41 -0
  68. package/dist/esm/particles/modules/RateSpawn.js +76 -0
  69. package/dist/esm/particles/modules/RateSpawn.js.map +1 -0
  70. package/dist/esm/particles/modules/RepelFromPoint.d.ts +24 -0
  71. package/dist/esm/particles/modules/RepelFromPoint.js +76 -0
  72. package/dist/esm/particles/modules/RepelFromPoint.js.map +1 -0
  73. package/dist/esm/particles/modules/RotateOverLifetime.d.ts +20 -0
  74. package/dist/esm/particles/modules/RotateOverLifetime.js +43 -0
  75. package/dist/esm/particles/modules/RotateOverLifetime.js.map +1 -0
  76. package/dist/esm/particles/modules/ScaleOverLifetime.d.ts +26 -0
  77. package/dist/esm/particles/modules/ScaleOverLifetime.js +59 -0
  78. package/dist/esm/particles/modules/ScaleOverLifetime.js.map +1 -0
  79. package/dist/esm/particles/modules/SpawnModule.d.ts +30 -0
  80. package/dist/esm/particles/modules/SpawnModule.js +31 -0
  81. package/dist/esm/particles/modules/SpawnModule.js.map +1 -0
  82. package/dist/esm/particles/modules/SpawnOnDeath.d.ts +24 -0
  83. package/dist/esm/particles/modules/SpawnOnDeath.js +47 -0
  84. package/dist/esm/particles/modules/SpawnOnDeath.js.map +1 -0
  85. package/dist/esm/particles/modules/Turbulence.d.ts +30 -0
  86. package/dist/esm/particles/modules/Turbulence.js +122 -0
  87. package/dist/esm/particles/modules/Turbulence.js.map +1 -0
  88. package/dist/esm/particles/modules/UpdateModule.d.ts +95 -0
  89. package/dist/esm/particles/modules/UpdateModule.js +66 -0
  90. package/dist/esm/particles/modules/UpdateModule.js.map +1 -0
  91. package/dist/esm/particles/modules/VelocityOverLifetime.d.ts +30 -0
  92. package/dist/esm/particles/modules/VelocityOverLifetime.js +84 -0
  93. package/dist/esm/particles/modules/VelocityOverLifetime.js.map +1 -0
  94. package/dist/esm/particles/modules/WgslContribution.d.ts +81 -0
  95. package/dist/esm/particles/modules/WgslContribution.js +34 -0
  96. package/dist/esm/particles/modules/WgslContribution.js.map +1 -0
  97. package/dist/esm/particles/modules/index.d.ts +22 -0
  98. package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.d.ts +9 -14
  99. package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.js +90 -61
  100. package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.js.map +1 -1
  101. package/dist/esm/rendering/webgl2/glsl/particle.vert.js +1 -1
  102. package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.d.ts +9 -0
  103. package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.js +107 -23
  104. package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.js.map +1 -1
  105. package/dist/esm/rendering/webgpu/compute/WebGpuComputePipeline.d.ts +52 -0
  106. package/dist/esm/rendering/webgpu/compute/WebGpuStorageBuffer.d.ts +29 -0
  107. package/dist/esm/rendering/webgpu/compute/index.d.ts +3 -0
  108. package/dist/exo.esm.js +2657 -742
  109. package/dist/exo.esm.js.map +1 -1
  110. package/package.json +1 -1
  111. package/dist/esm/particles/Particle.d.ts +0 -77
  112. package/dist/esm/particles/Particle.js +0 -143
  113. package/dist/esm/particles/Particle.js.map +0 -1
  114. package/dist/esm/particles/ParticleProperties.d.ts +0 -29
  115. package/dist/esm/particles/affectors/ColorAffector.d.ts +0 -30
  116. package/dist/esm/particles/affectors/ColorAffector.js +0 -55
  117. package/dist/esm/particles/affectors/ColorAffector.js.map +0 -1
  118. package/dist/esm/particles/affectors/ForceAffector.d.ts +0 -24
  119. package/dist/esm/particles/affectors/ForceAffector.js +0 -39
  120. package/dist/esm/particles/affectors/ForceAffector.js.map +0 -1
  121. package/dist/esm/particles/affectors/ParticleAffector.d.ts +0 -19
  122. package/dist/esm/particles/affectors/ScaleAffector.d.ts +0 -23
  123. package/dist/esm/particles/affectors/ScaleAffector.js +0 -38
  124. package/dist/esm/particles/affectors/ScaleAffector.js.map +0 -1
  125. package/dist/esm/particles/affectors/TorqueAffector.d.ts +0 -23
  126. package/dist/esm/particles/affectors/TorqueAffector.js +0 -37
  127. package/dist/esm/particles/affectors/TorqueAffector.js.map +0 -1
  128. package/dist/esm/particles/emitters/ParticleEmitter.d.ts +0 -19
  129. package/dist/esm/particles/emitters/ParticleOptions.d.ts +0 -62
  130. package/dist/esm/particles/emitters/ParticleOptions.js +0 -120
  131. package/dist/esm/particles/emitters/ParticleOptions.js.map +0 -1
  132. package/dist/esm/particles/emitters/UniversalEmitter.d.ts +0 -40
  133. package/dist/esm/particles/emitters/UniversalEmitter.js +0 -68
  134. package/dist/esm/particles/emitters/UniversalEmitter.js.map +0 -1
package/dist/exo.esm.js CHANGED
@@ -1386,7 +1386,7 @@ class Signal {
1386
1386
  }
1387
1387
 
1388
1388
  /** τ = 2π, the full-circle radian constant. */
1389
- const tau = Math.PI * 2;
1389
+ const tau$2 = Math.PI * 2;
1390
1390
  /** Multiply a degree value by this constant to convert to radians. */
1391
1391
  const radiansPerDegree = Math.PI / 180;
1392
1392
  /** Multiply a radian value by this constant to convert to degrees. */
@@ -8354,7 +8354,7 @@ class WebGl2MeshRenderer extends AbstractWebGl2Renderer {
8354
8354
  }
8355
8355
  }
8356
8356
 
8357
- var vertexSource$1 = "#version 300 es\nprecision lowp float;\nprecision lowp int;\n\n// Per-instance attributes (one entry per particle, 24 bytes total).\nlayout(location = 0) in vec2 a_translation; // particle position in system-local space\nlayout(location = 1) in vec2 a_scale; // particle scale\nlayout(location = 2) in float a_rotation; // particle rotation in degrees\nlayout(location = 3) in vec4 a_color; // RGBA tint\n\nuniform mat3 u_projection;\nuniform mat3 u_systemTransform;\nuniform vec4 u_localBounds; // left, top, right, bottom (system.vertices)\nuniform vec4 u_uvBounds; // uMin, vMin, uMax, vMax (flipY-swapped)\n\nout vec2 v_texcoord;\nout vec4 v_color;\n\nvoid main(void) {\n // Static index buffer is [0,1,2,0,2,3] (triangle-list), so gl_VertexID 0..3\n // maps to TL, TR, BR, BL via the same bit math the sprite renderer uses.\n int vid = gl_VertexID;\n int cornerX = ((vid + 1) >> 1) & 1;\n int cornerY = vid >> 1;\n\n float localX = (cornerX == 0) ? u_localBounds.x : u_localBounds.z;\n float localY = (cornerY == 0) ? u_localBounds.y : u_localBounds.w;\n\n // Per-particle scale + rotation.\n vec2 rotation = vec2(sin(radians(a_rotation)), cos(radians(a_rotation)));\n vec2 transformed = vec2(\n (localX * (a_scale.x * rotation.y)) + (localY * (a_scale.y * rotation.x)),\n (localX * (a_scale.x * -rotation.x)) + (localY * (a_scale.y * rotation.y))\n );\n\n vec3 worldPos = vec3(transformed + a_translation, 1.0);\n\n gl_Position = vec4((u_projection * u_systemTransform * worldPos).xy, 0.0, 1.0);\n\n float u = (cornerX == 0) ? u_uvBounds.x : u_uvBounds.z;\n float v = (cornerY == 0) ? u_uvBounds.y : u_uvBounds.w;\n v_texcoord = vec2(u, v);\n\n v_color = vec4(a_color.rgb * a_color.a, a_color.a);\n}\n";
8357
+ var vertexSource$1 = "#version 300 es\nprecision lowp float;\nprecision lowp int;\n\n// Per-instance attributes (one entry per particle, 40 bytes total).\nlayout(location = 0) in vec2 a_translation; // particle position in system-local space\nlayout(location = 1) in vec2 a_scale; // particle scale\nlayout(location = 2) in float a_rotation; // particle rotation in degrees\nlayout(location = 3) in vec4 a_color; // RGBA tint\nlayout(location = 4) in vec2 a_uvMin; // top-left UV (u, v) — pre-resolved per instance\nlayout(location = 5) in vec2 a_uvMax; // bottom-right UV (u, v) — pre-resolved per instance\n\nuniform mat3 u_projection;\nuniform mat3 u_systemTransform;\nuniform vec4 u_localBounds; // left, top, right, bottom (system.vertices)\n\nout vec2 v_texcoord;\nout vec4 v_color;\n\nvoid main(void) {\n // Static index buffer is [0,1,2,0,2,3] (triangle-list), so gl_VertexID 0..3\n // maps to TL, TR, BR, BL via the same bit math the sprite renderer uses.\n int vid = gl_VertexID;\n int cornerX = ((vid + 1) >> 1) & 1;\n int cornerY = vid >> 1;\n\n float localX = (cornerX == 0) ? u_localBounds.x : u_localBounds.z;\n float localY = (cornerY == 0) ? u_localBounds.y : u_localBounds.w;\n\n // Per-particle scale + rotation.\n vec2 rotation = vec2(sin(radians(a_rotation)), cos(radians(a_rotation)));\n vec2 transformed = vec2(\n (localX * (a_scale.x * rotation.y)) + (localY * (a_scale.y * rotation.x)),\n (localX * (a_scale.x * -rotation.x)) + (localY * (a_scale.y * rotation.y))\n );\n\n vec3 worldPos = vec3(transformed + a_translation, 1.0);\n\n gl_Position = vec4((u_projection * u_systemTransform * worldPos).xy, 0.0, 1.0);\n\n float u = (cornerX == 0) ? a_uvMin.x : a_uvMax.x;\n float v = (cornerY == 0) ? a_uvMin.y : a_uvMax.y;\n v_texcoord = vec2(u, v);\n\n v_color = vec4(a_color.rgb * a_color.a, a_color.a);\n}\n";
8358
8358
 
8359
8359
  var fragmentSource$1 = "#version 300 es\nprecision lowp float;\n\nuniform sampler2D u_texture;\n\nin vec2 v_texcoord;\nin vec4 v_color;\n\nlayout(location = 0) out vec4 fragColor;\n\nvoid main(void) {\n fragColor = texture(u_texture, v_texcoord) * v_color;\n}\n";
8360
8360
 
@@ -8363,30 +8363,26 @@ var fragmentSource$1 = "#version 300 es\nprecision lowp float;\n\nuniform sample
8363
8363
  *
8364
8364
  * One ParticleSystem = one batch. Each `render(system)` flushes any
8365
8365
  * pending batch, sets the system-level uniforms (transform, local
8366
- * bounds, UV bounds, texture), and packs every active particle into
8367
- * the per-instance buffer. The next `flush()` issues a single
8368
- * `drawElementsInstanced` for that system.
8366
+ * bounds, texture), and packs every active particle into the per-instance
8367
+ * buffer. The next `flush()` issues a single `drawElementsInstanced`.
8369
8368
  *
8370
- * Per-instance layout (24 bytes per particle, 4 attributes):
8369
+ * Per-instance layout (40 bytes per particle, 6 attributes):
8371
8370
  * ```
8372
8371
  * translation f32x2 (offset 0, 8 bytes) particle position (system-local)
8373
8372
  * scale f32x2 (offset 8, 8 bytes)
8374
8373
  * rotation f32 (offset 16, 4 bytes) degrees
8375
8374
  * color u8x4 (offset 20, 4 bytes) RGBA tint, normalised
8375
+ * uvMin f32x2 (offset 24, 8 bytes) pre-resolved frame uvMin
8376
+ * uvMax f32x2 (offset 32, 8 bytes) pre-resolved frame uvMax
8376
8377
  * ```
8377
8378
  *
8378
- * vs the previous per-vertex layout (36 bytes per vertex × 4 verts =
8379
- * 144 bytes per particle), this is an 83% bandwidth reduction and
8380
- * roughly 80% fewer CPU writes per particle (one pack call vs four
8381
- * duplicated vertex writes plus per-corner UV coords).
8382
- *
8383
- * The system transform stays as a uniform — mixing systems in one
8384
- * batch would require either a per-instance transform matrix (more
8385
- * bandwidth) or per-particle texture-slot indexing (multi-texture
8386
- * support similar to the sprite renderer). Both are follow-ups; the
8387
- * current pattern keeps the renderer focused on the per-particle win.
8379
+ * UVs are baked per-particle so the system can carry an atlas of frames
8380
+ * `system.frames` declares the rectangles; each particle's
8381
+ * `textureIndex` selects one. The pack loop resolves frame-rectangle to
8382
+ * UVs once per particle per frame; no per-instance shader-side indexing
8383
+ * needed.
8388
8384
  */
8389
- const instanceStrideBytes$2 = 24;
8385
+ const instanceStrideBytes$2 = 40;
8390
8386
  const wordsPerInstance$1 = instanceStrideBytes$2 / Uint32Array.BYTES_PER_ELEMENT;
8391
8387
  const indicesPerQuad = 6;
8392
8388
  const quadIndices$2 = new Uint16Array([0, 1, 2, 0, 2, 3]);
@@ -8415,7 +8411,7 @@ class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
8415
8411
  }
8416
8412
  render(system) {
8417
8413
  const backend = this.getBackend();
8418
- const { texture, particles, blendMode } = system;
8414
+ const { texture, blendMode } = system;
8419
8415
  const textureChanged = texture !== this._currentTexture;
8420
8416
  const blendModeChanged = blendMode !== this._currentBlendMode;
8421
8417
  // System transform / texture / UV / local-bounds are uniforms, so
@@ -8433,32 +8429,94 @@ class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
8433
8429
  // System-level uniforms are set before packing so the eventual
8434
8430
  // flush() can sync them in one go.
8435
8431
  const localBounds = system.vertices;
8436
- const uvBounds = this._unpackUvBounds(system);
8437
8432
  this._shader
8438
8433
  .getUniform('u_systemTransform')
8439
8434
  .setValue(system.getGlobalTransform().toArray(false));
8440
8435
  this._shader
8441
8436
  .getUniform('u_localBounds')
8442
8437
  .setValue(localBounds);
8443
- this._shader
8444
- .getUniform('u_uvBounds')
8445
- .setValue(uvBounds);
8446
8438
  const f32 = this._instanceFloat32;
8447
8439
  const u32 = this._instanceUint32;
8448
- const limit = Math.min(particles.length, this._batchSize);
8440
+ const { posX, posY, scaleX, scaleY, rotations, color, textureIndex, alive } = system;
8441
+ const limit = Math.min(system.liveCount, this._batchSize);
8442
+ // Pre-compute frame UVs from system.frames + texture; falls back
8443
+ // to the system.textureFrame when no atlas is declared.
8444
+ const { uvMins, uvMaxs } = this._computeFrameUvs(system);
8445
+ const frameCount = uvMins.length / 2;
8446
+ const fallbackFrame = frameCount > 0 ? 0 : 0;
8447
+ let writeIndex = 0;
8449
8448
  for (let i = 0; i < limit; i++) {
8450
- const particle = particles[i];
8451
- const offset = i * wordsPerInstance$1;
8452
- f32[offset + 0] = particle.position.x;
8453
- f32[offset + 1] = particle.position.y;
8454
- f32[offset + 2] = particle.scale.x;
8455
- f32[offset + 3] = particle.scale.y;
8456
- f32[offset + 4] = particle.rotation;
8457
- u32[offset + 5] = particle.tint.toRgba();
8458
- }
8459
- this._instanceCount = limit;
8460
- return this;
8461
- }
8449
+ // Skip dead slots in GPU-mode systems where the live range can
8450
+ // contain holes.
8451
+ if (alive[i] === 0) {
8452
+ continue;
8453
+ }
8454
+ const offset = writeIndex * wordsPerInstance$1;
8455
+ const frame = textureIndex[i] < frameCount ? textureIndex[i] : fallbackFrame;
8456
+ const uvBase = frame * 2;
8457
+ f32[offset + 0] = posX[i];
8458
+ f32[offset + 1] = posY[i];
8459
+ f32[offset + 2] = scaleX[i];
8460
+ f32[offset + 3] = scaleY[i];
8461
+ f32[offset + 4] = rotations[i];
8462
+ u32[offset + 5] = color[i];
8463
+ f32[offset + 6] = uvMins[uvBase + 0];
8464
+ f32[offset + 7] = uvMins[uvBase + 1];
8465
+ f32[offset + 8] = uvMaxs[uvBase + 0];
8466
+ f32[offset + 9] = uvMaxs[uvBase + 1];
8467
+ writeIndex++;
8468
+ }
8469
+ this._instanceCount = writeIndex;
8470
+ return this;
8471
+ }
8472
+ /**
8473
+ * Compute (uvMin, uvMax) pairs for every declared frame on the system.
8474
+ * Pulled lazily and cached per (system, texture-version) to avoid the
8475
+ * arithmetic in the hot pack loop. Falls back to a single entry from
8476
+ * `system.textureFrame` when no atlas is declared.
8477
+ */
8478
+ _computeFrameUvs(system) {
8479
+ const frames = system.frames;
8480
+ const tex = system.texture;
8481
+ const texW = tex.width;
8482
+ const texH = tex.height;
8483
+ const flipY = tex.flipY;
8484
+ const count = frames.length === 0 ? 1 : frames.length;
8485
+ // Re-allocate scratch when capacity grows.
8486
+ if (this._uvMinsScratch.length < count * 2) {
8487
+ this._uvMinsScratch = new Float32Array(count * 2);
8488
+ this._uvMaxsScratch = new Float32Array(count * 2);
8489
+ }
8490
+ const mins = this._uvMinsScratch;
8491
+ const maxs = this._uvMaxsScratch;
8492
+ if (frames.length === 0) {
8493
+ const f = system.textureFrame;
8494
+ const minU = f.left / texW;
8495
+ const maxU = f.right / texW;
8496
+ const topV = f.top / texH;
8497
+ const bottomV = f.bottom / texH;
8498
+ mins[0] = minU;
8499
+ mins[1] = flipY ? bottomV : topV;
8500
+ maxs[0] = maxU;
8501
+ maxs[1] = flipY ? topV : bottomV;
8502
+ return { uvMins: mins, uvMaxs: maxs };
8503
+ }
8504
+ for (let i = 0; i < frames.length; i++) {
8505
+ const f = frames[i];
8506
+ const o = i * 2;
8507
+ const minU = f.left / texW;
8508
+ const maxU = f.right / texW;
8509
+ const topV = f.top / texH;
8510
+ const bottomV = f.bottom / texH;
8511
+ mins[o + 0] = minU;
8512
+ mins[o + 1] = flipY ? bottomV : topV;
8513
+ maxs[o + 0] = maxU;
8514
+ maxs[o + 1] = flipY ? topV : bottomV;
8515
+ }
8516
+ return { uvMins: mins, uvMaxs: maxs };
8517
+ }
8518
+ _uvMinsScratch = new Float32Array(2);
8519
+ _uvMaxsScratch = new Float32Array(2);
8462
8520
  flush() {
8463
8521
  const backend = this.getBackendOrNull();
8464
8522
  const instanceBuffer = this._instanceBuffer;
@@ -8498,6 +8556,8 @@ class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
8498
8556
  .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_scale'), gl.FLOAT, false, instanceStrideBytes$2, 8, false, 1)
8499
8557
  .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_rotation'), gl.FLOAT, false, instanceStrideBytes$2, 16, false, 1)
8500
8558
  .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, instanceStrideBytes$2, 20, false, 1)
8559
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_uvMin'), gl.FLOAT, false, instanceStrideBytes$2, 24, false, 1)
8560
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_uvMax'), gl.FLOAT, false, instanceStrideBytes$2, 32, false, 1)
8501
8561
  .connect(this._createVaoRuntime(this._connection));
8502
8562
  }
8503
8563
  onDisconnect() {
@@ -8519,37 +8579,6 @@ class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
8519
8579
  this.disconnect();
8520
8580
  this._shader.destroy();
8521
8581
  }
8522
- /**
8523
- * Convert the system's per-corner packed-u32 texCoords into the
8524
- * (uMin, vMin, uMax, vMax) bounds the new vertex shader expects.
8525
- * Already accounts for flipY (the system's `texCoords` baked it in).
8526
- *
8527
- * The four packed u32s, by corner index, are:
8528
- * [0] TL = (uMin/uMax | vMin/vMax depending on flipY)
8529
- * [1] TR
8530
- * [2] BR
8531
- * [3] BL
8532
- * We need (uMin, vMin, uMax, vMax) — the corner-extreme values.
8533
- */
8534
- _uvBoundsScratch = new Float32Array(4);
8535
- _unpackUvBounds(system) {
8536
- const texCoords = system.texCoords;
8537
- // Each packed u32: low 16 bits = U normalised to 0..65535,
8538
- // high 16 bits = V normalised.
8539
- const uTopLeft = (texCoords[0] & 0xFFFF) / 0xFFFF;
8540
- const vTopLeft = ((texCoords[0] >>> 16) & 0xFFFF) / 0xFFFF;
8541
- const uBottomRight = (texCoords[2] & 0xFFFF) / 0xFFFF;
8542
- const vBottomRight = ((texCoords[2] >>> 16) & 0xFFFF) / 0xFFFF;
8543
- // For flipY: TL.v becomes vMax (was minY → maxY). The shader picks
8544
- // (cornerY == 0 ? vMin : vMax); writing TL.v into vMin and BR.v into
8545
- // vMax matches the original per-corner ordering whether or not flipY
8546
- // was applied at texCoords pack time.
8547
- this._uvBoundsScratch[0] = uTopLeft;
8548
- this._uvBoundsScratch[1] = vTopLeft;
8549
- this._uvBoundsScratch[2] = uBottomRight;
8550
- this._uvBoundsScratch[3] = vBottomRight;
8551
- return this._uvBoundsScratch;
8552
- }
8553
8582
  _createConnection(gl) {
8554
8583
  const vaoHandle = gl.createVertexArray();
8555
8584
  if (vaoHandle === null) {
@@ -9068,169 +9097,830 @@ class Sprite extends Drawable {
9068
9097
  RenderNode.setInternalSpriteFactory(() => new Sprite(null));
9069
9098
 
9070
9099
  /**
9071
- * Mutable per-instance state for a single live particle. Implements
9072
- * {@link ParticleProperties} so affectors can mutate it through the shared
9073
- * interface. Particles are pooled by {@link ParticleSystem}: expired instances
9074
- * are moved to the graveyard and reused via {@link ParticleSystem.requestParticle}
9075
- * rather than garbage-collected.
9100
+ * Slices a single {@link Texture} into named frames and optional named
9101
+ * animation sequences.
9076
9102
  *
9077
- * Do not construct directly use {@link ParticleSystem.requestParticle} to
9078
- * obtain a recycled or fresh instance, then configure it through
9079
- * {@link Particle.applyOptions}.
9103
+ * Each frame is stored as both a {@link Rectangle} (the pixel region) and a
9104
+ * pre-configured {@link Sprite} ready for direct rendering. Animations are
9105
+ * ordered lists of frame names that {@link AnimatedSprite.fromSpritesheet}
9106
+ * consumes to create playback clips.
9080
9107
  */
9081
- class Particle {
9082
- _totalLifetime = Time.oneSecond.clone();
9083
- _elapsedLifetime = Time.zero.clone();
9084
- _position = Vector.zero.clone();
9085
- _velocity = Vector.zero.clone();
9086
- _scale = Vector.one.clone();
9087
- _rotation = 0;
9088
- _rotationSpeed = 0;
9089
- _textureIndex = 0;
9090
- _tint = Color.white.clone();
9091
- get totalLifetime() {
9092
- return this._totalLifetime;
9093
- }
9094
- set totalLifetime(totalLifetime) {
9095
- this._totalLifetime.copy(totalLifetime);
9096
- }
9097
- get elapsedLifetime() {
9098
- return this._elapsedLifetime;
9099
- }
9100
- set elapsedLifetime(elapsedLifetime) {
9101
- this._elapsedLifetime.copy(elapsedLifetime);
9102
- }
9103
- get position() {
9104
- return this._position;
9105
- }
9106
- set position(position) {
9107
- this._position.copy(position);
9108
- }
9109
- get velocity() {
9110
- return this._velocity;
9111
- }
9112
- set velocity(velocity) {
9113
- this._velocity.copy(velocity);
9108
+ class Spritesheet {
9109
+ texture;
9110
+ frames = new Map();
9111
+ sprites = new Map();
9112
+ animations = new Map();
9113
+ constructor(texture, data) {
9114
+ this.texture = texture;
9115
+ this.parse(data);
9114
9116
  }
9115
- get scale() {
9116
- return this._scale;
9117
+ /**
9118
+ * Parse a {@link SpritesheetData} descriptor, populating `frames`,
9119
+ * `sprites`, and `animations`. When `keepFrames` is `false` (default),
9120
+ * all existing frames and sprites are destroyed before parsing.
9121
+ */
9122
+ parse(data, keepFrames = false) {
9123
+ if (!keepFrames) {
9124
+ this.clear();
9125
+ }
9126
+ for (const [name, frame] of Object.entries(data.frames)) {
9127
+ this.addFrame(name, frame);
9128
+ }
9129
+ if (data.animations) {
9130
+ for (const [animationName, frameNames] of Object.entries(data.animations)) {
9131
+ this.defineAnimation(animationName, frameNames);
9132
+ }
9133
+ }
9117
9134
  }
9118
- set scale(scale) {
9119
- this._scale.copy(scale);
9135
+ /** Register a single frame by name, creating its {@link Rectangle} and pre-configured {@link Sprite}. */
9136
+ addFrame(name, data) {
9137
+ const { x, y, w, h } = data.frame;
9138
+ const frame = new Rectangle(x, y, w, h);
9139
+ const sprite = new Sprite(this.texture);
9140
+ sprite.setTextureFrame(frame);
9141
+ this.frames.set(name, frame);
9142
+ this.sprites.set(name, sprite);
9120
9143
  }
9121
- get tint() {
9122
- return this._tint;
9144
+ /** Register an animation sequence as an ordered list of frame names. All referenced frames must already exist. */
9145
+ defineAnimation(name, frameNames) {
9146
+ if (name.trim().length === 0) {
9147
+ throw new Error('Spritesheet animation names must be non-empty strings.');
9148
+ }
9149
+ if (!Array.isArray(frameNames) || frameNames.length === 0) {
9150
+ throw new Error(`Spritesheet animation "${name}" must reference at least one frame.`);
9151
+ }
9152
+ for (const frameName of frameNames) {
9153
+ if (!this.frames.has(frameName)) {
9154
+ throw new Error(`Spritesheet animation "${name}" references missing frame "${frameName}".`);
9155
+ }
9156
+ }
9157
+ this.animations.set(name, [...frameNames]);
9158
+ return this;
9123
9159
  }
9124
- set tint(tint) {
9125
- this._tint.copy(tint);
9160
+ /** Return the {@link Rectangle} for the named frame. Throws if the frame does not exist. */
9161
+ getFrame(name) {
9162
+ const frame = this.frames.get(name);
9163
+ if (!frame) {
9164
+ throw new Error(`Spritesheet frame named ${name} is not available!`);
9165
+ }
9166
+ return frame;
9126
9167
  }
9127
- get rotation() {
9128
- return this._rotation;
9168
+ /** Return the ordered frame-name list for the named animation. Throws if the animation does not exist. */
9169
+ getAnimationFrameNames(name) {
9170
+ const frames = this.animations.get(name);
9171
+ if (!frames) {
9172
+ throw new Error(`Spritesheet animation named ${name} is not available!`);
9173
+ }
9174
+ return frames;
9129
9175
  }
9130
- set rotation(degrees) {
9131
- this._rotation = trimRotation(degrees);
9176
+ /** Return the pre-configured {@link Sprite} for the named frame. Throws if the frame does not exist. */
9177
+ getFrameSprite(name) {
9178
+ const sprite = this.sprites.get(name);
9179
+ if (!sprite) {
9180
+ throw new Error(`Spritesheet frame named ${name} is not available!`);
9181
+ }
9182
+ return sprite;
9132
9183
  }
9133
- get rotationSpeed() {
9134
- return this._rotationSpeed;
9184
+ /** Destroy all registered frames, sprites, and animations, resetting the spritesheet to an empty state. */
9185
+ clear() {
9186
+ for (const frame of this.frames.values()) {
9187
+ frame.destroy();
9188
+ }
9189
+ this.frames.clear();
9190
+ for (const sprite of this.sprites.values()) {
9191
+ sprite.destroy();
9192
+ }
9193
+ this.sprites.clear();
9194
+ this.animations.clear();
9195
+ return this;
9135
9196
  }
9136
- set rotationSpeed(rotationSpeed) {
9137
- this._rotationSpeed = rotationSpeed;
9197
+ destroy() {
9198
+ this.clear();
9138
9199
  }
9139
- get textureIndex() {
9140
- return this._textureIndex;
9200
+ }
9201
+
9202
+ /**
9203
+ * Compute the byte size of a uniform struct from its declared fields,
9204
+ * respecting WGSL std140-like alignment rules. Each field aligns to its
9205
+ * natural alignment; the struct itself rounds to its largest alignment.
9206
+ *
9207
+ * Used by the codegen to size the system's combined uniform buffer.
9208
+ */
9209
+ const wgslUniformByteSize = (fields) => {
9210
+ let offset = 0;
9211
+ let maxAlign = 4;
9212
+ for (const field of fields) {
9213
+ const { size, align } = wgslFieldLayout(field.type);
9214
+ offset = Math.ceil(offset / align) * align;
9215
+ offset += size;
9216
+ maxAlign = Math.max(maxAlign, align);
9217
+ }
9218
+ return Math.ceil(offset / maxAlign) * maxAlign;
9219
+ };
9220
+ /** Per-WGSL-primitive size and alignment in bytes. */
9221
+ const wgslFieldLayout = (type) => {
9222
+ switch (type) {
9223
+ case 'f32':
9224
+ case 'i32':
9225
+ case 'u32':
9226
+ return { size: 4, align: 4 };
9227
+ case 'vec2<f32>':
9228
+ return { size: 8, align: 8 };
9229
+ case 'vec4<f32>':
9230
+ return { size: 16, align: 16 };
9141
9231
  }
9142
- set textureIndex(textureIndex) {
9143
- this._textureIndex = textureIndex;
9232
+ };
9233
+
9234
+ /// <reference types="@webgpu/types" />
9235
+ /**
9236
+ * GPU-side mirror of one {@link ParticleSystem}. Owns:
9237
+ *
9238
+ * - **8 packed storage buffers** for the per-particle SoA data:
9239
+ * positions/velocities/scales/rotInfo/timing as `vec2<f32>`, color and
9240
+ * textureIndex as `u32`, plus the instance output buffer. Sits at the
9241
+ * default WebGPU `maxStorageBuffersPerShaderStage = 8` limit.
9242
+ * - **One uniform buffer** for sim state (`dt`, `liveCount`).
9243
+ * - **One uniform buffer** for module configs (concatenated per-module
9244
+ * structs with WGSL std140-ish alignment).
9245
+ * - **One uniform buffer** for frame UVs — `array<vec4<f32>, N>` where N
9246
+ * is the system's frame count (or 1 when no atlas is declared). Each
9247
+ * vec4 is `(uvMinX, uvMinY, uvMaxX, uvMaxY)` already flipY-adjusted.
9248
+ * - **N 1D textures** for modules that use lookup tables (Curve / Gradient).
9249
+ * - **Composite compute pipeline** built once at construction by
9250
+ * concatenating the integration step + every registered module body +
9251
+ * the pack-instances step into a single shader.
9252
+ *
9253
+ * The compute shader's pack-instances step reads `textureIndex[i]`, looks
9254
+ * up the matching frame UV, and writes a 40-byte interleaved record into
9255
+ * the instance output buffer (`STORAGE | VERTEX`). The renderer binds that
9256
+ * buffer directly as instanced vertex source — no readback.
9257
+ */
9258
+ const workgroupSize = 64;
9259
+ const instanceBytes = 40; // 5 × f32 + 1 × u32 + 4 × f32 (uvMin.xy, uvMax.xy)
9260
+ class ParticleGpuState {
9261
+ device;
9262
+ capacity;
9263
+ /** GPU buffer holding interleaved per-instance vertex data, written by compute, read as VERTEX by the renderer. */
9264
+ instanceBuffer;
9265
+ _positions;
9266
+ _velocities;
9267
+ _scales;
9268
+ _rotInfo;
9269
+ _timing;
9270
+ _color;
9271
+ _textureIndex;
9272
+ _simUniformBuffer;
9273
+ _simUniformData = new ArrayBuffer(16);
9274
+ _simUniformView;
9275
+ _moduleUniformBuffer;
9276
+ _moduleUniformData;
9277
+ _moduleUniformView;
9278
+ _moduleSlots;
9279
+ _framesUniformBuffer;
9280
+ _framesUniformData;
9281
+ _framesUniformView;
9282
+ _frameCount;
9283
+ _moduleTextures = new Map();
9284
+ _samplerFiltering;
9285
+ _samplerNonFiltering;
9286
+ _pipeline;
9287
+ _bindGroup0;
9288
+ _bindGroup1;
9289
+ constructor(device, capacity, modules, frames, texture) {
9290
+ this.device = device;
9291
+ this.capacity = capacity;
9292
+ for (const m of modules) {
9293
+ if (!m.wgsl) {
9294
+ throw new Error(`ParticleGpuState: module ${m.constructor.name} has no wgsl() — `
9295
+ + 'all registered UpdateModules must be GPU-eligible.');
9296
+ }
9297
+ }
9298
+ // Module uniform layout.
9299
+ const slots = [];
9300
+ let uniformOffset = 0;
9301
+ for (const m of modules) {
9302
+ const c = m.wgsl();
9303
+ const fields = c.uniforms ?? [];
9304
+ const size = wgslUniformByteSize(fields);
9305
+ uniformOffset = Math.ceil(uniformOffset / 16) * 16;
9306
+ slots.push({
9307
+ module: m,
9308
+ contribution: c,
9309
+ uniformByteOffset: uniformOffset,
9310
+ uniformByteSize: size,
9311
+ });
9312
+ uniformOffset += size;
9313
+ }
9314
+ const totalUniformBytes = Math.max(16, Math.ceil(uniformOffset / 16) * 16);
9315
+ this._moduleSlots = slots;
9316
+ if (uniformOffset > 0) {
9317
+ this._moduleUniformData = new ArrayBuffer(totalUniformBytes);
9318
+ this._moduleUniformView = new DataView(this._moduleUniformData);
9319
+ this._moduleUniformBuffer = device.createBuffer({
9320
+ label: 'particle-module-uniforms',
9321
+ size: totalUniformBytes,
9322
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
9323
+ });
9324
+ }
9325
+ else {
9326
+ this._moduleUniformData = null;
9327
+ this._moduleUniformView = null;
9328
+ this._moduleUniformBuffer = null;
9329
+ }
9330
+ // Frames uniform buffer.
9331
+ this._frameCount = Math.max(1, frames.length);
9332
+ this._framesUniformData = new ArrayBuffer(this._frameCount * 16);
9333
+ this._framesUniformView = new Float32Array(this._framesUniformData);
9334
+ this._framesUniformBuffer = device.createBuffer({
9335
+ label: 'particle-frames-uniforms',
9336
+ size: this._framesUniformData.byteLength,
9337
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
9338
+ });
9339
+ this._writeFrames(frames, texture);
9340
+ const vec2Bytes = capacity * 8;
9341
+ const u32Bytes = capacity * 4;
9342
+ this._positions = device.createBuffer({ label: 'particle-positions', size: vec2Bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
9343
+ this._velocities = device.createBuffer({ label: 'particle-velocities', size: vec2Bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
9344
+ this._scales = device.createBuffer({ label: 'particle-scales', size: vec2Bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
9345
+ this._rotInfo = device.createBuffer({ label: 'particle-rotInfo', size: vec2Bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
9346
+ this._timing = device.createBuffer({ label: 'particle-timing', size: vec2Bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
9347
+ this._color = device.createBuffer({ label: 'particle-color', size: u32Bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
9348
+ this._textureIndex = device.createBuffer({ label: 'particle-textureIndex', size: u32Bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
9349
+ this.instanceBuffer = device.createBuffer({
9350
+ label: 'particle-instance-output',
9351
+ size: capacity * instanceBytes,
9352
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
9353
+ });
9354
+ this._simUniformView = new DataView(this._simUniformData);
9355
+ this._simUniformBuffer = device.createBuffer({
9356
+ label: 'particle-sim-uniforms',
9357
+ size: 16,
9358
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
9359
+ });
9360
+ // r32float textures aren't filterable in core WebGPU (would require
9361
+ // the optional `float32-filterable` feature). Use `nearest` for
9362
+ // r32float curve LUTs (256 taps is fine without interpolation) and
9363
+ // `linear` for rgba8unorm gradients which support filtering natively.
9364
+ this._samplerFiltering = device.createSampler({
9365
+ label: 'particle-lookup-sampler-filtering',
9366
+ minFilter: 'linear',
9367
+ magFilter: 'linear',
9368
+ addressModeU: 'clamp-to-edge',
9369
+ });
9370
+ this._samplerNonFiltering = device.createSampler({
9371
+ label: 'particle-lookup-sampler-non-filtering',
9372
+ minFilter: 'nearest',
9373
+ magFilter: 'nearest',
9374
+ addressModeU: 'clamp-to-edge',
9375
+ });
9376
+ // Allocate textures for modules that need them.
9377
+ for (const slot of slots) {
9378
+ const c = slot.contribution;
9379
+ if (!c.textures)
9380
+ continue;
9381
+ for (const t of c.textures) {
9382
+ const tex = device.createTexture({
9383
+ label: `particle-tex-${c.key}-${t.name}`,
9384
+ size: { width: 256, height: 1, depthOrArrayLayers: 1 },
9385
+ format: t.format,
9386
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
9387
+ dimension: '1d',
9388
+ });
9389
+ this._moduleTextures.set(`${c.key}_${t.name}`, tex);
9390
+ }
9391
+ }
9392
+ const wgsl = this._buildShader(slots);
9393
+ const bindGroup0Layout = this._buildBindGroup0Layout(slots);
9394
+ const bindGroup1Layout = device.createBindGroupLayout({
9395
+ label: 'particle-soa-bgl',
9396
+ entries: [
9397
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
9398
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
9399
+ { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
9400
+ { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
9401
+ { binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
9402
+ { binding: 5, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
9403
+ { binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } }, // textureIndex (matches WGSL `var<storage, read>`)
9404
+ { binding: 7, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
9405
+ ],
9406
+ });
9407
+ const pipelineLayout = device.createPipelineLayout({
9408
+ label: 'particle-compute-layout',
9409
+ bindGroupLayouts: [bindGroup0Layout, bindGroup1Layout],
9410
+ });
9411
+ const shaderModule = device.createShaderModule({
9412
+ label: 'particle-compute-shader',
9413
+ code: wgsl,
9414
+ });
9415
+ this._pipeline = device.createComputePipeline({
9416
+ label: 'particle-compute-pipeline',
9417
+ layout: pipelineLayout,
9418
+ compute: {
9419
+ module: shaderModule,
9420
+ entryPoint: 'main',
9421
+ },
9422
+ });
9423
+ this._bindGroup0 = this._buildBindGroup0(bindGroup0Layout, slots);
9424
+ this._bindGroup1 = device.createBindGroup({
9425
+ label: 'particle-soa-bg',
9426
+ layout: bindGroup1Layout,
9427
+ entries: [
9428
+ { binding: 0, resource: { buffer: this._positions } },
9429
+ { binding: 1, resource: { buffer: this._velocities } },
9430
+ { binding: 2, resource: { buffer: this._scales } },
9431
+ { binding: 3, resource: { buffer: this._rotInfo } },
9432
+ { binding: 4, resource: { buffer: this._timing } },
9433
+ { binding: 5, resource: { buffer: this._color } },
9434
+ { binding: 6, resource: { buffer: this._textureIndex } },
9435
+ { binding: 7, resource: { buffer: this.instanceBuffer } },
9436
+ ],
9437
+ });
9438
+ // Modules upload their lookup textures.
9439
+ for (const slot of slots) {
9440
+ if (!slot.module.uploadTextures)
9441
+ continue;
9442
+ const moduleTextures = new Map();
9443
+ for (const t of slot.contribution.textures ?? []) {
9444
+ const tex = this._moduleTextures.get(`${slot.contribution.key}_${t.name}`);
9445
+ if (tex !== undefined) {
9446
+ moduleTextures.set(t.name, tex);
9447
+ }
9448
+ }
9449
+ slot.module.uploadTextures(device, moduleTextures);
9450
+ }
9144
9451
  }
9145
- /**
9146
- * Time remaining before this particle expires, returned via the shared
9147
- * `Time.temp` scratch value — copy before storing if you need it beyond
9148
- * the current frame.
9149
- */
9150
- get remainingLifetime() {
9151
- return Time.temp.set(this._totalLifetime.milliseconds - this._elapsedLifetime.milliseconds);
9452
+ dispatch(system, dt) {
9453
+ const liveCount = system.liveCount;
9454
+ if (liveCount <= 0) {
9455
+ return;
9456
+ }
9457
+ this._writeSimUniforms(dt, liveCount);
9458
+ this._writeModuleUniforms(dt);
9459
+ const encoder = this.device.createCommandEncoder({ label: 'particle-compute' });
9460
+ const pass = encoder.beginComputePass({ label: 'particle-compute-pass' });
9461
+ pass.setPipeline(this._pipeline);
9462
+ pass.setBindGroup(0, this._bindGroup0);
9463
+ pass.setBindGroup(1, this._bindGroup1);
9464
+ pass.dispatchWorkgroups(Math.ceil(liveCount / workgroupSize));
9465
+ pass.end();
9466
+ this.device.queue.submit([encoder.finish()]);
9152
9467
  }
9153
- /**
9154
- * Fraction of total lifetime already elapsed, in [0, 1]. Used by
9155
- * {@link ColorAffector} as the interpolation factor for tint blending.
9156
- */
9157
- get elapsedRatio() {
9158
- return this._elapsedLifetime.milliseconds / this._totalLifetime.milliseconds;
9468
+ destroy() {
9469
+ this._positions.destroy();
9470
+ this._velocities.destroy();
9471
+ this._scales.destroy();
9472
+ this._rotInfo.destroy();
9473
+ this._timing.destroy();
9474
+ this._color.destroy();
9475
+ this._textureIndex.destroy();
9476
+ this.instanceBuffer.destroy();
9477
+ this._simUniformBuffer.destroy();
9478
+ this._framesUniformBuffer.destroy();
9479
+ this._moduleUniformBuffer?.destroy();
9480
+ for (const tex of this._moduleTextures.values()) {
9481
+ tex.destroy();
9482
+ }
9483
+ this._moduleTextures.clear();
9484
+ }
9485
+ _writeFrames(frames, texture) {
9486
+ const view = this._framesUniformView;
9487
+ const w = texture.width;
9488
+ const h = texture.height;
9489
+ const flipY = texture.flipY;
9490
+ if (frames.length === 0) {
9491
+ // Single-frame fallback — full texture.
9492
+ view[0] = 0;
9493
+ view[1] = flipY ? 1 : 0;
9494
+ view[2] = 1;
9495
+ view[3] = flipY ? 0 : 1;
9496
+ }
9497
+ else {
9498
+ for (let i = 0; i < frames.length; i++) {
9499
+ const f = frames[i];
9500
+ const o = i * 4;
9501
+ const minU = f.left / w;
9502
+ const maxU = f.right / w;
9503
+ const topV = f.top / h;
9504
+ const bottomV = f.bottom / h;
9505
+ view[o + 0] = minU;
9506
+ view[o + 1] = flipY ? bottomV : topV;
9507
+ view[o + 2] = maxU;
9508
+ view[o + 3] = flipY ? topV : bottomV;
9509
+ }
9510
+ }
9511
+ this.device.queue.writeBuffer(this._framesUniformBuffer, 0, this._framesUniformData);
9512
+ }
9513
+ /**
9514
+ * Push the listed CPU SoA slots to the GPU. Called by `ParticleSystem`
9515
+ * with newly-spawned slots and just-expired slots (lifetime sentinel).
9516
+ * Slots not in the dirty set are left alone — GPU keeps the integrated
9517
+ * state from previous compute dispatches.
9518
+ *
9519
+ * Each dirty slot triggers 7 small `queue.writeBuffer` calls (one per
9520
+ * SoA channel). For typical spawn rates (≤200/s) this is negligible
9521
+ * (≤1400 calls/s); contiguous-range batching is a future optimisation.
9522
+ */
9523
+ uploadDirty(system, slots) {
9524
+ const queue = this.device.queue;
9525
+ const scratch2 = this._dirtyScratchVec2;
9526
+ const scratch1 = this._dirtyScratchU32;
9527
+ for (const slot of slots) {
9528
+ const byteOffset2 = slot * 8;
9529
+ const byteOffset1 = slot * 4;
9530
+ scratch2[0] = system.posX[slot];
9531
+ scratch2[1] = system.posY[slot];
9532
+ queue.writeBuffer(this._positions, byteOffset2, scratch2.buffer, 0, 8);
9533
+ scratch2[0] = system.velX[slot];
9534
+ scratch2[1] = system.velY[slot];
9535
+ queue.writeBuffer(this._velocities, byteOffset2, scratch2.buffer, 0, 8);
9536
+ scratch2[0] = system.scaleX[slot];
9537
+ scratch2[1] = system.scaleY[slot];
9538
+ queue.writeBuffer(this._scales, byteOffset2, scratch2.buffer, 0, 8);
9539
+ scratch2[0] = system.rotations[slot];
9540
+ scratch2[1] = system.rotationSpeeds[slot];
9541
+ queue.writeBuffer(this._rotInfo, byteOffset2, scratch2.buffer, 0, 8);
9542
+ scratch2[0] = system.elapsed[slot];
9543
+ scratch2[1] = system.lifetime[slot];
9544
+ queue.writeBuffer(this._timing, byteOffset2, scratch2.buffer, 0, 8);
9545
+ scratch1[0] = system.color[slot];
9546
+ queue.writeBuffer(this._color, byteOffset1, scratch1.buffer, 0, 4);
9547
+ scratch1[0] = system.textureIndex[slot];
9548
+ queue.writeBuffer(this._textureIndex, byteOffset1, scratch1.buffer, 0, 4);
9549
+ }
9550
+ }
9551
+ _dirtyScratchVec2 = new Float32Array(2);
9552
+ _dirtyScratchU32 = new Uint32Array(1);
9553
+ _writeSimUniforms(dt, liveCount) {
9554
+ this._simUniformView.setFloat32(0, dt, true);
9555
+ this._simUniformView.setUint32(4, liveCount, true);
9556
+ this.device.queue.writeBuffer(this._simUniformBuffer, 0, this._simUniformData);
9557
+ }
9558
+ _writeModuleUniforms(dt) {
9559
+ if (this._moduleUniformView === null || this._moduleUniformBuffer === null || this._moduleUniformData === null) {
9560
+ return;
9561
+ }
9562
+ for (const slot of this._moduleSlots) {
9563
+ slot.module.writeUniforms?.(this._moduleUniformView, slot.uniformByteOffset, dt);
9564
+ }
9565
+ this.device.queue.writeBuffer(this._moduleUniformBuffer, 0, this._moduleUniformData);
9159
9566
  }
9160
- /** Fraction of total lifetime still remaining, in [0, 1]. */
9161
- get remainingRatio() {
9162
- return this.remainingLifetime.milliseconds / this._totalLifetime.milliseconds;
9567
+ _buildBindGroup0Layout(slots) {
9568
+ const entries = [
9569
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
9570
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
9571
+ ];
9572
+ if (this._moduleUniformBuffer !== null) {
9573
+ entries.push({ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } });
9574
+ }
9575
+ let textureBindingIdx = this._moduleUniformBuffer !== null ? 3 : 2;
9576
+ for (const slot of slots) {
9577
+ for (const t of slot.contribution.textures ?? []) {
9578
+ const filterable = t.format !== 'r32float';
9579
+ entries.push({
9580
+ binding: textureBindingIdx++,
9581
+ visibility: GPUShaderStage.COMPUTE,
9582
+ texture: {
9583
+ viewDimension: '1d',
9584
+ sampleType: filterable ? 'float' : 'unfilterable-float',
9585
+ },
9586
+ });
9587
+ entries.push({
9588
+ binding: textureBindingIdx++,
9589
+ visibility: GPUShaderStage.COMPUTE,
9590
+ sampler: { type: filterable ? 'filtering' : 'non-filtering' },
9591
+ });
9592
+ }
9593
+ }
9594
+ return this.device.createBindGroupLayout({ label: 'particle-uniforms-bgl', entries });
9163
9595
  }
9164
- /**
9165
- * Returns `true` once `elapsedLifetime` exceeds `totalLifetime`. Expired
9166
- * particles are moved to the graveyard by {@link ParticleSystem.update}
9167
- * before affectors run.
9168
- */
9169
- get expired() {
9170
- return this._elapsedLifetime.greaterThan(this._totalLifetime);
9596
+ _buildBindGroup0(layout, slots) {
9597
+ const entries = [
9598
+ { binding: 0, resource: { buffer: this._simUniformBuffer } },
9599
+ { binding: 1, resource: { buffer: this._framesUniformBuffer } },
9600
+ ];
9601
+ if (this._moduleUniformBuffer !== null) {
9602
+ entries.push({ binding: 2, resource: { buffer: this._moduleUniformBuffer } });
9603
+ }
9604
+ let textureBindingIdx = this._moduleUniformBuffer !== null ? 3 : 2;
9605
+ for (const slot of slots) {
9606
+ for (const t of slot.contribution.textures ?? []) {
9607
+ const tex = this._moduleTextures.get(`${slot.contribution.key}_${t.name}`);
9608
+ const filterable = t.format !== 'r32float';
9609
+ const sampler = filterable ? this._samplerFiltering : this._samplerNonFiltering;
9610
+ entries.push({ binding: textureBindingIdx++, resource: tex.createView({ dimension: '1d' }) });
9611
+ entries.push({ binding: textureBindingIdx++, resource: sampler });
9612
+ }
9613
+ }
9614
+ return this.device.createBindGroup({ label: 'particle-uniforms-bg', layout, entries });
9615
+ }
9616
+ _buildShader(slots) {
9617
+ const sections = [];
9618
+ sections.push(`
9619
+ struct SimUniforms {
9620
+ dt: f32,
9621
+ liveCount: u32,
9622
+ _pad0: u32,
9623
+ _pad1: u32,
9624
+ }
9625
+
9626
+ struct FrameUniforms {
9627
+ frames: array<vec4<f32>, ${this._frameCount}>,
9628
+ }
9629
+
9630
+ @group(0) @binding(0) var<uniform> sim: SimUniforms;
9631
+ @group(0) @binding(1) var<uniform> frameUv: FrameUniforms;
9632
+ `);
9633
+ const moduleStructFields = [];
9634
+ for (const slot of slots) {
9635
+ const c = slot.contribution;
9636
+ const fields = c.uniforms ?? [];
9637
+ if (fields.length === 0) {
9638
+ continue;
9639
+ }
9640
+ sections.push(this._renderModuleStruct(c.key, fields));
9641
+ moduleStructFields.push(`u_${c.key}: ${c.key}Uniforms,`);
9642
+ }
9643
+ if (moduleStructFields.length > 0) {
9644
+ sections.push(`
9645
+ struct ModuleUniforms {
9646
+ ${moduleStructFields.map((s) => ` ${s}`).join('\n')}
9647
+ }
9648
+
9649
+ @group(0) @binding(2) var<uniform> modules: ModuleUniforms;
9650
+ `);
9651
+ }
9652
+ let textureBindingIdx = moduleStructFields.length > 0 ? 3 : 2;
9653
+ for (const slot of slots) {
9654
+ for (const t of slot.contribution.textures ?? []) {
9655
+ sections.push(`
9656
+ @group(0) @binding(${textureBindingIdx++}) var u_${slot.contribution.key}_${t.name}: texture_1d<f32>;
9657
+ @group(0) @binding(${textureBindingIdx++}) var u_${slot.contribution.key}_${t.name}_sampler: sampler;
9658
+ `);
9659
+ }
9660
+ }
9661
+ sections.push(`
9662
+ @group(1) @binding(0) var<storage, read_write> positions: array<vec2<f32>>;
9663
+ @group(1) @binding(1) var<storage, read_write> velocities: array<vec2<f32>>;
9664
+ @group(1) @binding(2) var<storage, read_write> scales: array<vec2<f32>>;
9665
+ @group(1) @binding(3) var<storage, read_write> rotInfo: array<vec2<f32>>;
9666
+ @group(1) @binding(4) var<storage, read_write> timing: array<vec2<f32>>;
9667
+ @group(1) @binding(5) var<storage, read_write> color: array<u32>;
9668
+ @group(1) @binding(6) var<storage, read> textureIndex: array<u32>;
9669
+ @group(1) @binding(7) var<storage, read_write> instanceOutput: array<u32>;
9670
+ `);
9671
+ // Module preludes (helper functions/constants). Concatenated in
9672
+ // registration order; modules sharing the same key are emitted only
9673
+ // once (the contribution body strings are still inlined per-instance,
9674
+ // but the prelude function definitions can't be duplicated).
9675
+ const seenPreludeKeys = new Set();
9676
+ for (const slot of slots) {
9677
+ const prelude = slot.contribution.prelude;
9678
+ if (prelude === undefined || prelude.trim() === '')
9679
+ continue;
9680
+ if (seenPreludeKeys.has(slot.contribution.key))
9681
+ continue;
9682
+ seenPreludeKeys.add(slot.contribution.key);
9683
+ sections.push(prelude);
9684
+ }
9685
+ const moduleBodies = slots.map((s) => s.contribution.body).join('\n');
9686
+ const frameCountConst = this._frameCount;
9687
+ sections.push(`
9688
+ @compute @workgroup_size(${workgroupSize})
9689
+ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
9690
+ let idx = gid.x;
9691
+ if (idx >= sim.liveCount) { return; }
9692
+
9693
+ let dt = sim.dt;
9694
+
9695
+ // Skip dead particles (lifetime sentinel < 0). Write zero-scale instance
9696
+ // so the renderer doesn't accidentally draw them.
9697
+ if (timing[idx].y < 0.0) {
9698
+ let outBaseDead = idx * 10u;
9699
+ for (var k: u32 = 0u; k < 10u; k++) { instanceOutput[outBaseDead + k] = 0u; }
9700
+ return;
9171
9701
  }
9172
- /**
9173
- * Bulk-copies every field from `options` into this particle, replacing all
9174
- * previous state. Called immediately after {@link ParticleSystem.requestParticle}
9175
- * to configure a recycled particle for reuse.
9176
- */
9177
- applyOptions(options) {
9178
- const { totalLifetime, elapsedLifetime, position, velocity, scale, tint, rotation, rotationSpeed, textureIndex, } = options;
9179
- this._totalLifetime.copy(totalLifetime);
9180
- this._elapsedLifetime.copy(elapsedLifetime);
9181
- this._position.copy(position);
9182
- this._velocity.copy(velocity);
9183
- this._scale.copy(scale);
9184
- this._tint.copy(tint);
9185
- this._rotation = rotation;
9186
- this._rotationSpeed = rotationSpeed;
9187
- this._textureIndex = textureIndex;
9188
- return this;
9702
+
9703
+ // Integration.
9704
+ positions[idx] = positions[idx] + velocities[idx] * dt;
9705
+ rotInfo[idx].x = rotInfo[idx].x + rotInfo[idx].y * dt;
9706
+ timing[idx].x = timing[idx].x + dt;
9707
+
9708
+ // Module bodies (in registration order).
9709
+ ${moduleBodies}
9710
+
9711
+ // Resolve frame UVs.
9712
+ let frameIndex = min(textureIndex[idx], ${frameCountConst}u - 1u);
9713
+ let frameUvBounds = frameUv.frames[frameIndex];
9714
+
9715
+ // Pack interleaved instance data (10 u32s per particle):
9716
+ // x, y, scaleX, scaleY, rotation (f32×5) + color (u32) + uvMin.xy (f32×2) + uvMax.xy (f32×2)
9717
+ let outBase = idx * 10u;
9718
+ instanceOutput[outBase + 0u] = bitcast<u32>(positions[idx].x);
9719
+ instanceOutput[outBase + 1u] = bitcast<u32>(positions[idx].y);
9720
+ instanceOutput[outBase + 2u] = bitcast<u32>(scales[idx].x);
9721
+ instanceOutput[outBase + 3u] = bitcast<u32>(scales[idx].y);
9722
+ instanceOutput[outBase + 4u] = bitcast<u32>(rotInfo[idx].x);
9723
+ instanceOutput[outBase + 5u] = color[idx];
9724
+ instanceOutput[outBase + 6u] = bitcast<u32>(frameUvBounds.x);
9725
+ instanceOutput[outBase + 7u] = bitcast<u32>(frameUvBounds.y);
9726
+ instanceOutput[outBase + 8u] = bitcast<u32>(frameUvBounds.z);
9727
+ instanceOutput[outBase + 9u] = bitcast<u32>(frameUvBounds.w);
9728
+ }
9729
+ `);
9730
+ return sections.join('\n\n');
9189
9731
  }
9190
- /**
9191
- * Destroys all owned value objects. Called by
9192
- * {@link ParticleSystem.clearParticles} when the pool is flushed entirely.
9193
- * Do not call on individual particles mid-simulation; let the system
9194
- * recycle them via the graveyard instead.
9195
- */
9196
- destroy() {
9197
- this._totalLifetime.destroy();
9198
- this._elapsedLifetime.destroy();
9199
- this._position.destroy();
9200
- this._velocity.destroy();
9201
- this._scale.destroy();
9202
- this._tint.destroy();
9732
+ _renderModuleStruct(key, fields) {
9733
+ const lines = fields.map((f) => ` ${f.name}: ${f.type},`).join('\n');
9734
+ return `struct ${key}Uniforms {\n${lines}\n}`;
9203
9735
  }
9204
9736
  }
9205
9737
 
9738
+ /// <reference types="@webgpu/types" />
9739
+ const defaultCapacity = 4096;
9740
+ /**
9741
+ * Lazily-initialised 1×1 opaque-white texture used as the default sprite
9742
+ * when a {@link ParticleSystem} is constructed without one. Particles
9743
+ * render as solid color quads (the per-particle `color` channel times
9744
+ * white-with-alpha-1). Shared across systems to avoid wasted texture
9745
+ * allocations.
9746
+ */
9747
+ let defaultWhiteTexture = null;
9748
+ const getDefaultWhiteTexture = () => {
9749
+ if (defaultWhiteTexture === null) {
9750
+ const canvas = document.createElement('canvas');
9751
+ canvas.width = 1;
9752
+ canvas.height = 1;
9753
+ const ctx = canvas.getContext('2d');
9754
+ if (ctx !== null) {
9755
+ ctx.fillStyle = '#ffffff';
9756
+ ctx.fillRect(0, 0, 1, 1);
9757
+ }
9758
+ defaultWhiteTexture = new Texture(canvas);
9759
+ }
9760
+ return defaultWhiteTexture;
9761
+ };
9206
9762
  /**
9207
- * The central coordinator of the particle triad. `ParticleSystem` is a
9208
- * {@link Drawable} that owns a list of {@link ParticleEmitter} spawners, a
9209
- * list of {@link ParticleAffector} mutators, and the live/graveyard particle
9210
- * pools. Each call to {@link ParticleSystem.update} runs all emitters to
9211
- * spawn new particles, advances every live particle's position and lifetime,
9212
- * retires expired ones to the graveyard for pooling, and runs all affectors
9213
- * on the survivors.
9763
+ * The central coordinator of the particle pipeline. `ParticleSystem` is a
9764
+ * {@link Drawable} that owns:
9765
+ *
9766
+ * - **SoA particle storage** one typed array per attribute (position,
9767
+ * velocity, scale, rotation, color, lifetime, ...), sized to a fixed
9768
+ * capacity at construction. User code reads/writes via
9769
+ * `system.posX[slot]`, `system.velX[slot]`, etc.
9770
+ * - **Spawn modules** — write new particles into freshly allocated slots.
9771
+ * - **Update modules** — mutate the live range each frame (forces, color
9772
+ * blends, scale curves, drag, ...). Built-in modules ship both CPU and
9773
+ * WGSL implementations; custom modules can opt into GPU acceleration by
9774
+ * implementing `wgsl()`.
9775
+ * - **Death modules** — fire once per dying particle, before its slot is
9776
+ * recycled (sub-emitters, event hooks).
9777
+ *
9778
+ * **Auto-routing CPU vs GPU:** at first {@link update}, the system checks:
9779
+ * if a `WebGpuBackend` was supplied AND every registered update module has
9780
+ * `wgsl()`, the GPU path engages — a composite compute pipeline runs
9781
+ * integration plus all module bodies in one dispatch and writes directly
9782
+ * into the renderer's instance buffer (no CPU readback). Otherwise the CPU
9783
+ * path runs the existing per-module `apply()` loops.
9784
+ *
9785
+ * **Per-frame order in {@link update} (CPU mode):**
9786
+ * 1. Run every spawn module.
9787
+ * 2. Integrate position from velocity, rotation from rotationSpeed, advance `elapsed`.
9788
+ * 3. Run every update module on the live range.
9789
+ * 4. Compact: scan `[0, liveCount)` forward, fire death modules on expired
9790
+ * slots, copy survivors down. `liveCount` shrinks to the survivor count.
9791
+ *
9792
+ * **Per-frame order in {@link update} (GPU mode):**
9793
+ * 1. Run every spawn module (CPU writes initial values into the spawn slot).
9794
+ * 2. Detect expiries on CPU (via `elapsed >= lifetime`); fire death modules;
9795
+ * set `lifetime[slot] = -1` sentinel + clear `alive[slot]` so the GPU
9796
+ * shader skips them. **No compaction** — slots are recycled on next spawn.
9797
+ * 3. Dispatch the composite compute pipeline. Integration + update modules
9798
+ * + pack-instances run in one pass; the instance buffer is written
9799
+ * directly. CPU SoA stays as-is for spawn writes.
9800
+ *
9801
+ * **Coordinate space:** particle positions are LOCAL to the system. The
9802
+ * system's `getGlobalTransform()` is applied on top during rendering — both
9803
+ * the WebGL2 and WebGPU shaders multiply `projection * translation * rotated`.
9804
+ * Setting world-space positions on individual particles double-translates.
9805
+ * Position the system itself via `system.setPosition(...)` and emit relative
9806
+ * to `(0, 0)`.
9807
+ *
9808
+ * @example
9809
+ * // Backend-agnostic — runs CPU on WebGL2, GPU on WebGPU automatically.
9810
+ * const system = new ParticleSystem(loader.get(Texture, 'spark'), {
9811
+ * capacity: 8192,
9812
+ * backend: app.backend,
9813
+ * });
9214
9814
  *
9215
- * Rendering reads {@link ParticleSystem.vertices} and
9216
- * {@link ParticleSystem.texCoords} (lazily recomputed on texture-frame
9217
- * changes) plus the live {@link ParticleSystem.particles} array to draw each
9218
- * sprite.
9815
+ * system.addSpawnModule(new RateSpawn({ rate: new Constant(60), ... }));
9816
+ * system.addUpdateModule(new ApplyForce(0, 980)); // gravity, GPU-eligible
9817
+ * system.addUpdateModule(new ColorOverLifetime(fireGradient));
9818
+ * scene.addChild(system);
9219
9819
  */
9220
9820
  class ParticleSystem extends Drawable {
9221
- _emitters = [];
9222
- _affectors = [];
9223
- _particles = [];
9224
- _graveyard = [];
9821
+ /** Maximum particle count this system will store. Fixed at construction. */
9822
+ capacity;
9823
+ posX;
9824
+ posY;
9825
+ velX;
9826
+ velY;
9827
+ scaleX;
9828
+ scaleY;
9829
+ rotations;
9830
+ rotationSpeeds;
9831
+ color; // packed 0xAABBGGRR
9832
+ elapsed; // seconds since spawn
9833
+ lifetime; // total seconds before expiry; -1 sentinel for dead in GPU mode
9834
+ textureIndex;
9835
+ /**
9836
+ * Number of currently live particles. In CPU mode this is exact: slots
9837
+ * `[0, liveCount)` are all alive after each `update()`. In GPU mode
9838
+ * this is a high-water mark — slots `[0, liveCount)` may contain dead
9839
+ * holes (filled in by future spawns); use {@link aliveCount} for the
9840
+ * actual alive count.
9841
+ */
9842
+ liveCount = 0;
9843
+ /**
9844
+ * Per-slot alive flag (1 = alive, 0 = dead). Maintained in both CPU
9845
+ * and GPU mode. Custom modules iterating the live range should check
9846
+ * this to skip dead slots in GPU mode.
9847
+ */
9848
+ alive;
9849
+ _spawnModules = [];
9850
+ _updateModules = [];
9851
+ _deathModules = [];
9852
+ _backend = null;
9853
+ _device = null;
9854
+ _gpuState = null;
9855
+ _gpuMode = false;
9856
+ _compiled = false;
9857
+ _spawnHint = 0; // round-robin pointer for first-dead lookup in GPU mode
9858
+ /**
9859
+ * In GPU mode, slots whose CPU SoA values need re-uploading to the GPU
9860
+ * (newly spawned, or just-expired with lifetime sentinel). Cleared
9861
+ * after each compute dispatch. CPU never overwrites integrated GPU
9862
+ * state — only dirty slots flow CPU → GPU.
9863
+ */
9864
+ _gpuDirtySlots = new Set();
9225
9865
  _texture;
9866
+ _frames = [];
9226
9867
  _textureFrame = new Rectangle();
9227
9868
  _vertices = new Float32Array(4);
9228
9869
  _texCoords = new Uint32Array(4);
9229
9870
  _updateTexCoords = true;
9230
9871
  _updateVertices = true;
9231
- constructor(texture) {
9872
+ constructor(arg1, arg2, arg3) {
9232
9873
  super();
9233
- this._texture = texture;
9874
+ // Disambiguate the four valid call shapes via instanceof checks.
9875
+ // The TS overloads above already prevent illegal combinations like
9876
+ // `(texture, sheet)` or `(sheet, frames)` at compile time; this
9877
+ // narrowing only sorts out the legal ones.
9878
+ let texture = null;
9879
+ let frames = null;
9880
+ let options = {};
9881
+ if (arg1 instanceof Texture) {
9882
+ texture = arg1;
9883
+ if (Array.isArray(arg2)) {
9884
+ frames = arg2;
9885
+ options = arg3 ?? {};
9886
+ }
9887
+ else {
9888
+ options = arg2 ?? {};
9889
+ }
9890
+ }
9891
+ else if (arg1 instanceof Spritesheet) {
9892
+ texture = arg1.texture;
9893
+ frames = [...arg1.frames.values()];
9894
+ options = arg2 ?? {};
9895
+ }
9896
+ else {
9897
+ options = arg1 ?? {};
9898
+ }
9899
+ const capacity = options.capacity ?? defaultCapacity;
9900
+ if (capacity <= 0 || !Number.isInteger(capacity)) {
9901
+ throw new Error(`ParticleSystem capacity must be a positive integer (got ${capacity}).`);
9902
+ }
9903
+ this.capacity = capacity;
9904
+ this.posX = new Float32Array(capacity);
9905
+ this.posY = new Float32Array(capacity);
9906
+ this.velX = new Float32Array(capacity);
9907
+ this.velY = new Float32Array(capacity);
9908
+ this.scaleX = new Float32Array(capacity);
9909
+ this.scaleY = new Float32Array(capacity);
9910
+ this.rotations = new Float32Array(capacity);
9911
+ this.rotationSpeeds = new Float32Array(capacity);
9912
+ this.color = new Uint32Array(capacity);
9913
+ this.elapsed = new Float32Array(capacity);
9914
+ this.lifetime = new Float32Array(capacity);
9915
+ this.textureIndex = new Uint16Array(capacity);
9916
+ this.alive = new Uint8Array(capacity);
9917
+ this._device = options.device ?? null;
9918
+ this._texture = texture ?? getDefaultWhiteTexture();
9919
+ if (frames !== null) {
9920
+ for (const frame of frames) {
9921
+ this._frames.push(frame.clone());
9922
+ }
9923
+ }
9234
9924
  this.resetTextureFrame();
9235
9925
  }
9236
9926
  get texture() {
@@ -9246,11 +9936,17 @@ class ParticleSystem extends Drawable {
9246
9936
  this.setTextureFrame(frame);
9247
9937
  }
9248
9938
  /**
9249
- * Quad corner offsets for the current {@link textureFrame}, in local
9250
- * space as `[minX, minY, maxX, maxY]`. Recomputed lazily whenever
9251
- * `textureFrame` changes. Used by the renderer to position each particle
9252
- * sprite relative to its world position.
9939
+ * Atlas frames declared on this system, or empty when the texture is
9940
+ * used as a single frame. Each particle's `textureIndex[i]` selects
9941
+ * an entry from this list; out-of-range indices are clamped to 0.
9253
9942
  */
9943
+ get frames() {
9944
+ return this._frames;
9945
+ }
9946
+ /** `true` when the system declares more than one atlas frame. */
9947
+ get hasAtlas() {
9948
+ return this._frames.length > 1;
9949
+ }
9254
9950
  get vertices() {
9255
9951
  if (this._updateVertices) {
9256
9952
  const { x, y, width, height } = this._textureFrame;
@@ -9264,12 +9960,6 @@ class ParticleSystem extends Drawable {
9264
9960
  }
9265
9961
  return this._vertices;
9266
9962
  }
9267
- /**
9268
- * Packed UV coordinates for the current {@link textureFrame} as four
9269
- * `Uint32` values, each encoding a `(u, v)` pair in the upper/lower 16
9270
- * bits (normalised to 0–65535). Vertex order respects
9271
- * {@link Texture.flipY}. Recomputed lazily on texture or frame changes.
9272
- */
9273
9963
  get texCoords() {
9274
9964
  if (this._updateTexCoords) {
9275
9965
  const { width, height } = this._texture;
@@ -9294,27 +9984,32 @@ class ParticleSystem extends Drawable {
9294
9984
  }
9295
9985
  return this._texCoords;
9296
9986
  }
9297
- get emitters() {
9298
- return this._emitters;
9987
+ /** `true` when the system is running on the GPU compute pipeline. */
9988
+ get gpuMode() {
9989
+ return this._gpuMode;
9299
9990
  }
9300
- get affectors() {
9301
- return this._affectors;
9991
+ /** GPU-side state, or `null` in CPU mode. */
9992
+ get gpuState() {
9993
+ return this._gpuState;
9302
9994
  }
9303
- get particles() {
9304
- return this._particles;
9995
+ /** Actual count of live particles (slots with `alive[i] === 1`). May differ from `liveCount` in GPU mode. */
9996
+ get aliveCount() {
9997
+ let count = 0;
9998
+ for (let i = 0; i < this.liveCount; i++) {
9999
+ if (this.alive[i])
10000
+ count++;
10001
+ }
10002
+ return count;
9305
10003
  }
9306
- /**
9307
- * Pool of expired {@link Particle} instances waiting to be recycled.
9308
- * {@link requestParticle} pops from this array before allocating a new
9309
- * instance, keeping GC pressure low during sustained emission.
9310
- */
9311
- get graveyard() {
9312
- return this._graveyard;
10004
+ get spawnModules() {
10005
+ return this._spawnModules;
10006
+ }
10007
+ get updateModules() {
10008
+ return this._updateModules;
10009
+ }
10010
+ get deathModules() {
10011
+ return this._deathModules;
9313
10012
  }
9314
- /**
9315
- * Replaces the particle sprite texture and resets the texture frame to
9316
- * cover the full new texture. No-ops if `texture` is the same instance.
9317
- */
9318
10013
  setTexture(texture) {
9319
10014
  if (this._texture !== texture) {
9320
10015
  this._texture = texture;
@@ -9322,11 +10017,6 @@ class ParticleSystem extends Drawable {
9322
10017
  }
9323
10018
  return this;
9324
10019
  }
9325
- /**
9326
- * Sets the sub-rectangle of the texture used as the particle sprite,
9327
- * invalidating cached vertices and UV coordinates and updating the system's
9328
- * local bounds to match the frame dimensions.
9329
- */
9330
10020
  setTextureFrame(frame) {
9331
10021
  this._textureFrame.copy(frame);
9332
10022
  this._updateTexCoords = true;
@@ -9335,122 +10025,275 @@ class ParticleSystem extends Drawable {
9335
10025
  this._invalidateBoundsCascade();
9336
10026
  return this;
9337
10027
  }
9338
- /** Resets the texture frame to the full dimensions of the current texture. */
9339
10028
  resetTextureFrame() {
9340
10029
  return this.setTextureFrame(Rectangle.temp.set(0, 0, this._texture.width, this._texture.height));
9341
10030
  }
9342
- /** Registers `emitter` to be called each tick during {@link update}. */
9343
- addEmitter(emitter) {
9344
- this._emitters.push(emitter);
10031
+ addSpawnModule(mod) {
10032
+ this._spawnModules.push(mod);
9345
10033
  return this;
9346
10034
  }
9347
- /** Destroys and removes all registered emitters. */
9348
- clearEmitters() {
9349
- for (const emitter of this._emitters) {
9350
- emitter.destroy();
10035
+ addUpdateModule(mod) {
10036
+ if (this._compiled) {
10037
+ throw new Error('Cannot add update modules after the system has been compiled (first update). Register all modules before the first update().');
9351
10038
  }
9352
- this._emitters.length = 0;
10039
+ this._updateModules.push(mod);
9353
10040
  return this;
9354
10041
  }
9355
- /** Registers `affector` to run on every live particle each tick during {@link update}. */
9356
- addAffector(affector) {
9357
- this._affectors.push(affector);
10042
+ addDeathModule(mod) {
10043
+ this._deathModules.push(mod);
9358
10044
  return this;
9359
10045
  }
9360
- /** Destroys and removes all registered affectors. */
9361
- clearAffectors() {
9362
- for (const affector of this._affectors) {
9363
- affector.destroy();
9364
- }
9365
- this._affectors.length = 0;
10046
+ clearSpawnModules() {
10047
+ for (const mod of this._spawnModules)
10048
+ mod.destroy();
10049
+ this._spawnModules.length = 0;
9366
10050
  return this;
9367
10051
  }
9368
- /**
9369
- * Returns a recycled particle from the {@link graveyard}, or allocates a
9370
- * new one if the pool is empty. Call {@link Particle.applyOptions}
9371
- * immediately after to reset its state before passing it to
9372
- * {@link emitParticle}.
9373
- */
9374
- requestParticle() {
9375
- return this._graveyard.pop() || new Particle();
9376
- }
9377
- /** Adds a fully-configured `particle` to the live pool. Typically called by emitters. */
9378
- emitParticle(particle) {
9379
- this._particles.push(particle);
10052
+ clearUpdateModules() {
10053
+ for (const mod of this._updateModules)
10054
+ mod.destroy();
10055
+ this._updateModules.length = 0;
9380
10056
  return this;
9381
10057
  }
9382
- /**
9383
- * Advances a single particle by one `delta` step: increments
9384
- * `elapsedLifetime`, integrates velocity into position, and applies
9385
- * `rotationSpeed` to rotation. Called for every live particle by
9386
- * {@link update} before the affector pass.
9387
- */
9388
- updateParticle(particle, delta) {
9389
- const seconds = delta.seconds;
9390
- particle.elapsedLifetime.addTime(delta);
9391
- particle.position.add(seconds * particle.velocity.x, seconds * particle.velocity.y);
9392
- particle.rotation += (seconds * particle.rotationSpeed);
10058
+ clearDeathModules() {
10059
+ for (const mod of this._deathModules)
10060
+ mod.destroy();
10061
+ this._deathModules.length = 0;
9393
10062
  return this;
9394
10063
  }
9395
10064
  /**
9396
- * Destroys and removes all particles from both the live pool and the
9397
- * graveyard. Use when resetting or recycling the entire system.
10065
+ * Allocates a particle slot and returns its index. Returns `-1` when
10066
+ * the system is at {@link capacity}.
10067
+ *
10068
+ * **CPU mode:** slots are dense in `[0, liveCount)`. `spawn()` returns
10069
+ * the next sequential slot; `liveCount++`.
10070
+ *
10071
+ * **GPU mode:** slots may have dead holes. `spawn()` finds the first
10072
+ * `alive[i] === 0` slot via a round-robin hint pointer (amortised O(1),
10073
+ * worst case O(capacity) on full systems).
9398
10074
  */
9399
- clearParticles() {
9400
- for (const particle of this._particles) {
9401
- particle.destroy();
9402
- }
9403
- for (const particle of this._graveyard) {
9404
- particle.destroy();
10075
+ spawn() {
10076
+ if (this._gpuMode) {
10077
+ return this._spawnGpu();
9405
10078
  }
9406
- this._particles.length = 0;
9407
- this._graveyard.length = 0;
10079
+ return this._spawnCpu();
10080
+ }
10081
+ /** Resets the system to zero live particles without destroying it. */
10082
+ clearParticles() {
10083
+ this.liveCount = 0;
10084
+ this._spawnHint = 0;
10085
+ this.alive.fill(0);
10086
+ this.lifetime.fill(0);
10087
+ this.elapsed.fill(0);
9408
10088
  return this;
9409
10089
  }
9410
10090
  /**
9411
- * Advances the full simulation by one `delta` step: runs all emitters,
9412
- * then for each live particle calls {@link updateParticle}, moves expired
9413
- * ones to the {@link graveyard}, and runs all affectors on survivors.
9414
- * The particle array is iterated in reverse to allow in-place splice
9415
- * without re-indexing.
10091
+ * Engine-side render hook. Captures the active backend on each call so
10092
+ * the next `update()` can compile a GPU pipeline if the backend turned
10093
+ * out to be `WebGpuBackend`. Re-captures and rebuilds when the backend
10094
+ * reference changes (e.g. after device-loss recovery).
9416
10095
  */
9417
- update(delta) {
9418
- const emitters = this._emitters;
9419
- const affectors = this._affectors;
9420
- const particles = this._particles;
9421
- const graveyard = this._graveyard;
9422
- const len = particles.length;
9423
- for (const emitter of emitters) {
9424
- emitter.apply(this, delta);
9425
- }
9426
- let expireCount = 0;
9427
- for (let i = len - 1; i >= 0; i--) {
9428
- this.updateParticle(particles[i], delta);
9429
- if (particles[i].expired) {
9430
- graveyard.push(particles[i]);
9431
- expireCount++;
9432
- continue;
9433
- }
9434
- if (expireCount > 0) {
9435
- particles.splice(i + 1, expireCount);
9436
- expireCount = 0;
9437
- }
9438
- for (const affector of affectors) {
9439
- affector.apply(particles[i], delta);
10096
+ render(backend) {
10097
+ if (this._backend !== backend) {
10098
+ this._backend = backend;
10099
+ if (this._gpuState !== null) {
10100
+ this._gpuState.destroy();
10101
+ this._gpuState = null;
9440
10102
  }
10103
+ this._gpuMode = false;
10104
+ this._compiled = false;
10105
+ }
10106
+ return super.render(backend);
10107
+ }
10108
+ /** Per-frame entry point. Routes to CPU or GPU pipeline based on auto-detection at first call. */
10109
+ update(delta) {
10110
+ if (!this._compiled) {
10111
+ this._compile();
9441
10112
  }
9442
- if (expireCount > 0) {
9443
- particles.splice(0, expireCount);
10113
+ const dt = delta.seconds;
10114
+ // 1. Spawn (CPU writes SoA in both modes).
10115
+ for (let i = 0; i < this._spawnModules.length; i++) {
10116
+ this._spawnModules[i].apply(this, dt);
10117
+ }
10118
+ if (this._gpuMode) {
10119
+ this._updateGpu(dt);
10120
+ }
10121
+ else {
10122
+ this._updateCpu(dt);
9444
10123
  }
9445
10124
  return this;
9446
10125
  }
9447
10126
  destroy() {
9448
10127
  super.destroy();
9449
- this.clearEmitters();
9450
- this.clearAffectors();
9451
- this.clearParticles();
10128
+ this.clearSpawnModules();
10129
+ this.clearUpdateModules();
10130
+ this.clearDeathModules();
10131
+ if (this._gpuState !== null) {
10132
+ this._gpuState.destroy();
10133
+ this._gpuState = null;
10134
+ }
10135
+ for (const frame of this._frames) {
10136
+ frame.destroy();
10137
+ }
10138
+ this._frames.length = 0;
10139
+ this._gpuMode = false;
10140
+ this._compiled = false;
10141
+ this.liveCount = 0;
10142
+ this.alive.fill(0);
9452
10143
  this._textureFrame.destroy();
9453
10144
  }
10145
+ _compile() {
10146
+ this._compiled = true;
10147
+ // Duck-typed `instanceof WebGpuBackend` — avoids importing the
10148
+ // backend class (which registers a renderer for ParticleSystem
10149
+ // and would create a circular dependency). WebGl2Backend has no
10150
+ // `device` field, so this naturally falls back to CPU mode.
10151
+ const backendDevice = this._backend?.device ?? null;
10152
+ const device = this._device ?? backendDevice;
10153
+ if (device === null) {
10154
+ return;
10155
+ }
10156
+ const allEligible = this._updateModules.every((m) => typeof m.wgsl === 'function');
10157
+ if (!allEligible) {
10158
+ return;
10159
+ }
10160
+ this._gpuState = new ParticleGpuState(device, this.capacity, this._updateModules, this._frames, this._texture);
10161
+ this._gpuMode = true;
10162
+ // Mark every currently-alive slot dirty so the initial upload
10163
+ // matches CPU state; subsequent frames only push deltas.
10164
+ for (let i = 0; i < this.liveCount; i++) {
10165
+ if (this.alive[i])
10166
+ this._gpuDirtySlots.add(i);
10167
+ }
10168
+ }
10169
+ _spawnCpu() {
10170
+ if (this.liveCount >= this.capacity) {
10171
+ return -1;
10172
+ }
10173
+ const slot = this.liveCount++;
10174
+ this.alive[slot] = 1;
10175
+ this.elapsed[slot] = 0;
10176
+ return slot;
10177
+ }
10178
+ _spawnGpu() {
10179
+ const capacity = this.capacity;
10180
+ const alive = this.alive;
10181
+ const start = this._spawnHint;
10182
+ // Search forward from hint, then wrap.
10183
+ for (let i = start; i < capacity; i++) {
10184
+ if (alive[i] === 0) {
10185
+ alive[i] = 1;
10186
+ this.elapsed[i] = 0;
10187
+ this._spawnHint = i + 1 === capacity ? 0 : i + 1;
10188
+ if (i >= this.liveCount)
10189
+ this.liveCount = i + 1;
10190
+ this._gpuDirtySlots.add(i);
10191
+ return i;
10192
+ }
10193
+ }
10194
+ for (let i = 0; i < start; i++) {
10195
+ if (alive[i] === 0) {
10196
+ alive[i] = 1;
10197
+ this.elapsed[i] = 0;
10198
+ this._spawnHint = i + 1;
10199
+ if (i >= this.liveCount)
10200
+ this.liveCount = i + 1;
10201
+ this._gpuDirtySlots.add(i);
10202
+ return i;
10203
+ }
10204
+ }
10205
+ return -1;
10206
+ }
10207
+ _updateCpu(dt) {
10208
+ const { posX, posY, velX, velY, rotations, rotationSpeeds, elapsed } = this;
10209
+ const liveCount = this.liveCount;
10210
+ for (let i = 0; i < liveCount; i++) {
10211
+ posX[i] += velX[i] * dt;
10212
+ posY[i] += velY[i] * dt;
10213
+ rotations[i] += rotationSpeeds[i] * dt;
10214
+ elapsed[i] += dt;
10215
+ }
10216
+ for (let i = 0; i < this._updateModules.length; i++) {
10217
+ this._updateModules[i].apply(this, dt);
10218
+ }
10219
+ // Compact: forward pass, fire death modules on expired, copy survivors down.
10220
+ const lifetime = this.lifetime;
10221
+ const alive = this.alive;
10222
+ const deathModules = this._deathModules;
10223
+ let writeIndex = 0;
10224
+ for (let readIndex = 0; readIndex < this.liveCount; readIndex++) {
10225
+ if (elapsed[readIndex] >= lifetime[readIndex]) {
10226
+ for (let m = 0; m < deathModules.length; m++) {
10227
+ deathModules[m].onDeath(this, readIndex);
10228
+ }
10229
+ alive[readIndex] = 0;
10230
+ continue;
10231
+ }
10232
+ if (writeIndex !== readIndex) {
10233
+ this._copySlot(readIndex, writeIndex);
10234
+ alive[writeIndex] = 1;
10235
+ }
10236
+ writeIndex++;
10237
+ }
10238
+ for (let i = writeIndex; i < this.liveCount; i++) {
10239
+ alive[i] = 0;
10240
+ }
10241
+ this.liveCount = writeIndex;
10242
+ }
10243
+ _updateGpu(dt) {
10244
+ // CPU advances its own copy of `elapsed` for expire detection only.
10245
+ // GPU's `timing[idx].x` is advanced independently inside the compute
10246
+ // shader; the two are never synced after spawn. They tick at the
10247
+ // same rate (both add `dt` per frame) so they stay equivalent in
10248
+ // practice (modulo numerical drift).
10249
+ const elapsed = this.elapsed;
10250
+ const lifetime = this.lifetime;
10251
+ const alive = this.alive;
10252
+ const deathModules = this._deathModules;
10253
+ const liveCount = this.liveCount;
10254
+ for (let i = 0; i < liveCount; i++) {
10255
+ if (alive[i] === 0)
10256
+ continue;
10257
+ elapsed[i] += dt;
10258
+ if (elapsed[i] >= lifetime[i]) {
10259
+ for (let m = 0; m < deathModules.length; m++) {
10260
+ deathModules[m].onDeath(this, i);
10261
+ }
10262
+ alive[i] = 0;
10263
+ lifetime[i] = -1; // sentinel — GPU shader skips
10264
+ this._gpuDirtySlots.add(i); // upload the sentinel so GPU sees the death
10265
+ }
10266
+ }
10267
+ // Trim trailing dead slots.
10268
+ let newLiveCount = this.liveCount;
10269
+ while (newLiveCount > 0 && alive[newLiveCount - 1] === 0) {
10270
+ newLiveCount--;
10271
+ }
10272
+ this.liveCount = newLiveCount;
10273
+ // Push dirty slots (new spawns + just-expired) to GPU. CPU is NOT
10274
+ // the source of truth for integrated position/velocity/etc. after
10275
+ // spawn — uploading the full live range every frame would wipe
10276
+ // out GPU's integrated state.
10277
+ if (this._gpuDirtySlots.size > 0) {
10278
+ this._gpuState.uploadDirty(this, this._gpuDirtySlots);
10279
+ this._gpuDirtySlots.clear();
10280
+ }
10281
+ this._gpuState.dispatch(this, dt);
10282
+ }
10283
+ _copySlot(from, to) {
10284
+ this.posX[to] = this.posX[from];
10285
+ this.posY[to] = this.posY[from];
10286
+ this.velX[to] = this.velX[from];
10287
+ this.velY[to] = this.velY[from];
10288
+ this.scaleX[to] = this.scaleX[from];
10289
+ this.scaleY[to] = this.scaleY[from];
10290
+ this.rotations[to] = this.rotations[from];
10291
+ this.rotationSpeeds[to] = this.rotationSpeeds[from];
10292
+ this.color[to] = this.color[from];
10293
+ this.elapsed[to] = this.elapsed[from];
10294
+ this.lifetime[to] = this.lifetime[from];
10295
+ this.textureIndex[to] = this.textureIndex[from];
10296
+ }
9454
10297
  }
9455
10298
 
9456
10299
  /**
@@ -11306,13 +12149,15 @@ var particleTexture: texture_2d<f32>;
11306
12149
  @group(1) @binding(1)
11307
12150
  var particleSampler: sampler;
11308
12151
 
11309
- // Per-instance attributes (one entry per particle, 24 bytes total).
12152
+ // Per-instance attributes (one entry per particle, 40 bytes total).
11310
12153
  struct VertexInput {
11311
12154
  @location(0) unitPosition: vec2<f32>, // per-vertex (static unit quad)
11312
12155
  @location(1) translation: vec2<f32>,
11313
12156
  @location(2) scale: vec2<f32>,
11314
12157
  @location(3) rotation: f32,
11315
12158
  @location(4) color: vec4<f32>,
12159
+ @location(5) uvMin: vec2<f32>, // pre-resolved frame UV (top-left)
12160
+ @location(6) uvMax: vec2<f32>, // pre-resolved frame UV (bottom-right)
11316
12161
  };
11317
12162
 
11318
12163
  struct VertexOutput {
@@ -11325,8 +12170,6 @@ struct VertexOutput {
11325
12170
  fn vertexMain(input: VertexInput) -> VertexOutput {
11326
12171
  let quadMin = uniforms.localBounds.xy;
11327
12172
  let quadSize = uniforms.localBounds.zw;
11328
- let uvMin = uniforms.uvBounds.xy;
11329
- let uvMax = uniforms.uvBounds.zw;
11330
12173
 
11331
12174
  let localPosition = quadMin + (input.unitPosition * quadSize);
11332
12175
  let radians = radians(input.rotation);
@@ -11340,7 +12183,7 @@ fn vertexMain(input: VertexInput) -> VertexOutput {
11340
12183
  var output: VertexOutput;
11341
12184
 
11342
12185
  output.position = uniforms.projection * uniforms.translation * vec4<f32>(rotated, 0.0, 1.0);
11343
- output.texcoord = uvMin + ((uvMax - uvMin) * input.unitPosition);
12186
+ output.texcoord = input.uvMin + ((input.uvMax - input.uvMin) * input.unitPosition);
11344
12187
  output.color = vec4(input.color.rgb * input.color.a, input.color.a);
11345
12188
 
11346
12189
  return output;
@@ -11355,8 +12198,8 @@ fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
11355
12198
  }
11356
12199
  `;
11357
12200
  const staticVertexStrideBytes = 8;
11358
- const instanceWords = 6;
11359
- const instanceStrideBytes = 24;
12201
+ const instanceWords = 10;
12202
+ const instanceStrideBytes = 40;
11360
12203
  const indicesPerParticle = 6;
11361
12204
  const uniformByteLength = 176;
11362
12205
  const initialParticleCapacity = 1;
@@ -11397,7 +12240,7 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
11397
12240
  || texture.source === null
11398
12241
  || texture.width === 0
11399
12242
  || texture.height === 0
11400
- || system.particles.length === 0) {
12243
+ || system.liveCount === 0) {
11401
12244
  return;
11402
12245
  }
11403
12246
  backend.setBlendMode(system.blendMode);
@@ -11459,7 +12302,7 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
11459
12302
  for (let drawCallIndex = 0; drawCallIndex < this._drawCallCount; drawCallIndex++) {
11460
12303
  const drawCall = this._drawCalls[drawCallIndex];
11461
12304
  const system = drawCall.system;
11462
- const particleCount = system.particles.length;
12305
+ const particleCount = system.liveCount;
11463
12306
  if (particleCount === 0) {
11464
12307
  continue;
11465
12308
  }
@@ -11475,10 +12318,21 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
11475
12318
  resource: textureBinding.sampler,
11476
12319
  }],
11477
12320
  });
11478
- this._ensureCapacity(particleCount);
11479
- this._writeInstanceData(system.vertices, system.texCoords, system.particles);
11480
12321
  this._writeUniformData(backend, system, drawCall.texture);
11481
- device.queue.writeBuffer(this._instanceBuffer, 0, this._instanceData, 0, particleCount * instanceStrideBytes);
12322
+ // GPU mode: the system's compute pipeline already wrote the
12323
+ // interleaved instance data into its own buffer. Bind it
12324
+ // directly — no CPU pack, no writeBuffer for instance data.
12325
+ // CPU mode: pack from CPU SoA into our owned instance buffer.
12326
+ let drawInstanceCount = particleCount;
12327
+ const instanceBuffer = (() => {
12328
+ if (system.gpuMode && system.gpuState !== null) {
12329
+ return system.gpuState.instanceBuffer;
12330
+ }
12331
+ this._ensureCapacity(particleCount);
12332
+ drawInstanceCount = this._writeInstanceData(system);
12333
+ device.queue.writeBuffer(this._instanceBuffer, 0, this._instanceData, 0, drawInstanceCount * instanceStrideBytes);
12334
+ return this._instanceBuffer;
12335
+ })();
11482
12336
  device.queue.writeBuffer(uniformBuffer, 0, this._uniformData.buffer, this._uniformData.byteOffset, this._uniformData.byteLength);
11483
12337
  const encoder = device.createCommandEncoder();
11484
12338
  const pass = encoder.beginRenderPass({
@@ -11492,9 +12346,9 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
11492
12346
  pass.setPipeline(pipeline);
11493
12347
  pass.setBindGroup(1, textureBindGroup);
11494
12348
  pass.setVertexBuffer(0, staticVertexBuffer);
11495
- pass.setVertexBuffer(1, this._instanceBuffer);
12349
+ pass.setVertexBuffer(1, instanceBuffer);
11496
12350
  pass.setIndexBuffer(indexBuffer, 'uint16');
11497
- pass.drawIndexed(indicesPerParticle, particleCount, 0, 0, 0);
12351
+ pass.drawIndexed(indicesPerParticle, drawInstanceCount, 0, 0, 0);
11498
12352
  backend.stats.batches++;
11499
12353
  backend.stats.drawCalls++;
11500
12354
  pass.end();
@@ -11637,17 +12491,82 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
11637
12491
  uvMinX, uvMinY, uvMaxX, uvMaxY,
11638
12492
  ]);
11639
12493
  }
11640
- _writeInstanceData(_vertices, _texCoords, particles) {
11641
- for (let particleIndex = 0; particleIndex < particles.length; particleIndex++) {
11642
- const particle = particles[particleIndex];
11643
- const targetIndex = particleIndex * instanceWords;
11644
- this._float32View[targetIndex + 0] = particle.position.x;
11645
- this._float32View[targetIndex + 1] = particle.position.y;
11646
- this._float32View[targetIndex + 2] = particle.scale.x;
11647
- this._float32View[targetIndex + 3] = particle.scale.y;
11648
- this._float32View[targetIndex + 4] = particle.rotation;
11649
- this._uint32View[targetIndex + 5] = particle.tint.toRgba();
11650
- }
12494
+ _writeInstanceData(system) {
12495
+ const { posX, posY, scaleX, scaleY, rotations, color, textureIndex, alive, liveCount } = system;
12496
+ const f32 = this._float32View;
12497
+ const u32 = this._uint32View;
12498
+ const { uvMins, uvMaxs } = this._computeFrameUvs(system);
12499
+ const frameCount = uvMins.length / 2;
12500
+ let writeIndex = 0;
12501
+ for (let particleIndex = 0; particleIndex < liveCount; particleIndex++) {
12502
+ // Skip dead slots — present in GPU-mode systems where the live
12503
+ // range can carry holes filled in on next spawn.
12504
+ if (alive[particleIndex] === 0) {
12505
+ continue;
12506
+ }
12507
+ const targetIndex = writeIndex * instanceWords;
12508
+ const frame = textureIndex[particleIndex] < frameCount ? textureIndex[particleIndex] : 0;
12509
+ const uvBase = frame * 2;
12510
+ f32[targetIndex + 0] = posX[particleIndex];
12511
+ f32[targetIndex + 1] = posY[particleIndex];
12512
+ f32[targetIndex + 2] = scaleX[particleIndex];
12513
+ f32[targetIndex + 3] = scaleY[particleIndex];
12514
+ f32[targetIndex + 4] = rotations[particleIndex];
12515
+ u32[targetIndex + 5] = color[particleIndex];
12516
+ f32[targetIndex + 6] = uvMins[uvBase + 0];
12517
+ f32[targetIndex + 7] = uvMins[uvBase + 1];
12518
+ f32[targetIndex + 8] = uvMaxs[uvBase + 0];
12519
+ f32[targetIndex + 9] = uvMaxs[uvBase + 1];
12520
+ writeIndex++;
12521
+ }
12522
+ return writeIndex;
12523
+ }
12524
+ /**
12525
+ * Same atlas/UV-resolution as the WebGL2 path. Returns the per-frame
12526
+ * (uvMin, uvMax) pairs derived from `system.frames` (or fallback to
12527
+ * `system.textureFrame` when no atlas is declared), already flipY-
12528
+ * adjusted for the current texture.
12529
+ */
12530
+ _uvMinsScratch = new Float32Array(2);
12531
+ _uvMaxsScratch = new Float32Array(2);
12532
+ _computeFrameUvs(system) {
12533
+ const frames = system.frames;
12534
+ const tex = system.texture;
12535
+ const texW = tex.width;
12536
+ const texH = tex.height;
12537
+ const flipY = tex.flipY;
12538
+ const count = frames.length === 0 ? 1 : frames.length;
12539
+ if (this._uvMinsScratch.length < count * 2) {
12540
+ this._uvMinsScratch = new Float32Array(count * 2);
12541
+ this._uvMaxsScratch = new Float32Array(count * 2);
12542
+ }
12543
+ const mins = this._uvMinsScratch;
12544
+ const maxs = this._uvMaxsScratch;
12545
+ if (frames.length === 0) {
12546
+ const f = system.textureFrame;
12547
+ const minU = f.left / texW;
12548
+ const maxU = f.right / texW;
12549
+ const topV = f.top / texH;
12550
+ const bottomV = f.bottom / texH;
12551
+ mins[0] = minU;
12552
+ mins[1] = flipY ? bottomV : topV;
12553
+ maxs[0] = maxU;
12554
+ maxs[1] = flipY ? topV : bottomV;
12555
+ return { uvMins: mins, uvMaxs: maxs };
12556
+ }
12557
+ for (let i = 0; i < frames.length; i++) {
12558
+ const f = frames[i];
12559
+ const o = i * 2;
12560
+ const minU = f.left / texW;
12561
+ const maxU = f.right / texW;
12562
+ const topV = f.top / texH;
12563
+ const bottomV = f.bottom / texH;
12564
+ mins[o + 0] = minU;
12565
+ mins[o + 1] = flipY ? bottomV : topV;
12566
+ maxs[o + 0] = maxU;
12567
+ maxs[o + 1] = flipY ? topV : bottomV;
12568
+ }
12569
+ return { uvMins: mins, uvMaxs: maxs };
11651
12570
  }
11652
12571
  _getPipeline(blendMode, format) {
11653
12572
  const pipelineKey = `${blendMode}:${format}`;
@@ -11686,6 +12605,14 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
11686
12605
  shaderLocation: 4,
11687
12606
  offset: 20,
11688
12607
  format: 'unorm8x4',
12608
+ }, {
12609
+ shaderLocation: 5,
12610
+ offset: 24,
12611
+ format: 'float32x2',
12612
+ }, {
12613
+ shaderLocation: 6,
12614
+ offset: 32,
12615
+ format: 'float32x2',
11689
12616
  }],
11690
12617
  }],
11691
12618
  },
@@ -24309,7 +25236,7 @@ const buildRectangle = (x, y, width, height) => {
24309
25236
  const buildStar = (centerX, centerY, points, radius, innerRadius = radius / 2, rotation = 0) => {
24310
25237
  const startAngle = (Math.PI / -2) + rotation;
24311
25238
  const length = points * 2;
24312
- const delta = tau / length;
25239
+ const delta = tau$2 / length;
24313
25240
  const path = [];
24314
25241
  for (let i = 0; i < length; i++) {
24315
25242
  const angle = startAngle + (i * delta);
@@ -25381,347 +26308,1438 @@ function* substepSweep(fromX, fromY, deltaX, deltaY, maxStepSize) {
25381
26308
  }
25382
26309
 
25383
26310
  /**
25384
- * Linearly interpolates a particle's {@link Particle.tint} from `fromColor`
25385
- * to `toColor` over the particle's full lifetime. The blend factor is
25386
- * {@link Particle.elapsedRatio} 0 at birth, 1 at expiry — so the
25387
- * transition is always proportional to how long the particle has lived
25388
- * regardless of delta size.
26311
+ * Distribution that always returns the same value. Implements both
26312
+ * {@link Distribution} and {@link LifetimeFunction} so it can stand in
26313
+ * wherever a randomized or time-parameterised value is expected.
26314
+ *
26315
+ * For mutable types (Vector, Color, ...) the constant value is copied into
26316
+ * `out` if provided, otherwise the constructor's value is returned directly.
26317
+ * Mutating that returned reference mutates the distribution's source.
25389
26318
  */
25390
- class ColorAffector {
25391
- _fromColor;
25392
- _toColor;
25393
- constructor(fromColor, toColor) {
25394
- this._fromColor = fromColor.clone();
25395
- this._toColor = toColor.clone();
25396
- }
25397
- get fromColor() {
25398
- return this._fromColor;
26319
+ class Constant {
26320
+ value;
26321
+ constructor(value) {
26322
+ this.value = value;
25399
26323
  }
25400
- set fromColor(color) {
25401
- this.setFromColor(color);
26324
+ sample(out) {
26325
+ return this._copyOrReturn(out);
25402
26326
  }
25403
- get toColor() {
25404
- return this._toColor;
26327
+ evaluate(_t, out) {
26328
+ return this._copyOrReturn(out);
25405
26329
  }
25406
- set toColor(color) {
25407
- this.setToColor(color);
25408
- }
25409
- setFromColor(color) {
25410
- this._fromColor.copy(color);
25411
- return this;
25412
- }
25413
- setToColor(color) {
25414
- this._toColor.copy(color);
25415
- return this;
25416
- }
25417
- /**
25418
- * Sets `particle.tint` to the RGBA value interpolated between
25419
- * {@link fromColor} and {@link toColor} at the particle's current
25420
- * {@link Particle.elapsedRatio}. `delta` is unused but required by the
25421
- * {@link ParticleAffector} contract.
25422
- */
25423
- apply(particle, delta) {
25424
- const ratio = particle.elapsedRatio;
25425
- const { r: r1, g: g1, b: b1, a: a1 } = this._fromColor;
25426
- const { r: r2, g: g2, b: b2, a: a2 } = this._toColor;
25427
- particle.tint.set(((r2 - r1) * ratio) + r1, ((g2 - g1) * ratio) + g1, ((b2 - b1) * ratio) + b1, ((a2 - a1) * ratio) + a1);
25428
- return this;
25429
- }
25430
- destroy() {
25431
- this._fromColor.destroy();
25432
- this._toColor.destroy();
26330
+ _copyOrReturn(out) {
26331
+ if (out === undefined || out === null) {
26332
+ return this.value;
26333
+ }
26334
+ const target = out;
26335
+ if (typeof target.copy === 'function') {
26336
+ target.copy(this.value);
26337
+ return out;
26338
+ }
26339
+ return this.value;
25433
26340
  }
25434
26341
  }
25435
26342
 
25436
26343
  /**
25437
- * Applies a constant 2-D acceleration to every particle's
25438
- * {@link Particle.velocity} each tick, simulating forces such as gravity
25439
- * (`new ForceAffector(0, 980)`) or wind. Velocity is mutated in place;
25440
- * the system then integrates position from velocity in
25441
- * {@link ParticleSystem.updateParticle}.
26344
+ * Uniform random number in `[min, max]`. Each `sample()` returns a fresh
26345
+ * roll; the bounds are inclusive on both ends (modulo the rounding bias
26346
+ * inherent to `Math.random()`).
25442
26347
  */
25443
- class ForceAffector {
25444
- _acceleration;
25445
- constructor(accelerationX, accelerationY) {
25446
- this._acceleration = new Vector(accelerationX, accelerationY);
25447
- }
25448
- get acceleration() {
25449
- return this._acceleration;
25450
- }
25451
- set acceleration(acceleration) {
25452
- this.setAcceleration(acceleration);
25453
- }
25454
- setAcceleration(acceleration) {
25455
- this._acceleration.copy(acceleration);
25456
- return this;
25457
- }
25458
- /**
25459
- * Adds `acceleration * delta.seconds` to `particle.velocity`, implementing
25460
- * Euler integration for the configured force vector.
25461
- */
25462
- apply(particle, delta) {
25463
- particle.velocity.add(delta.seconds * this._acceleration.x, delta.seconds * this._acceleration.y);
25464
- return this;
26348
+ class Range {
26349
+ min;
26350
+ max;
26351
+ constructor(min, max) {
26352
+ this.min = min;
26353
+ this.max = max;
25465
26354
  }
25466
- destroy() {
25467
- this._acceleration.destroy();
26355
+ sample() {
26356
+ return this.min + Math.random() * (this.max - this.min);
25468
26357
  }
25469
26358
  }
25470
26359
 
25471
26360
  /**
25472
- * Additively grows or shrinks a particle's {@link Particle.scale} each tick
25473
- * by a constant rate vector. A positive factor enlarges the sprite; a
25474
- * negative factor shrinks it. Use `new ScaleAffector(-1, -1)` to make
25475
- * particles fade out in size over one second.
26361
+ * Uniform random vector with each axis sampled independently in its own
26362
+ * `[min, max]` range. Each `sample()` writes into the provided `out` Vector
26363
+ * (or an internal scratch instance when `out` is omitted).
26364
+ *
26365
+ * @example
26366
+ * const knockback = new VectorRange(-300, 300, -800, -200); // any X, upward Y
26367
+ * knockback.sample(particle.velocity); // writes into existing instance, no alloc
25476
26368
  */
25477
- class ScaleAffector {
25478
- _scaleFactor;
25479
- constructor(factorX, factorY) {
25480
- this._scaleFactor = new Vector(factorX, factorY);
25481
- }
25482
- get scaleFactor() {
25483
- return this._scaleFactor;
25484
- }
25485
- set scaleFactor(scaleFactor) {
25486
- this.setScaleFactor(scaleFactor);
25487
- }
25488
- setScaleFactor(scaleFactor) {
25489
- this._scaleFactor.copy(scaleFactor);
25490
- return this;
25491
- }
25492
- /**
25493
- * Adds `scaleFactor * delta.seconds` to `particle.scale` on both axes,
25494
- * implementing linear scale drift for the configured rate.
25495
- */
25496
- apply(particle, delta) {
25497
- particle.scale.add(delta.seconds * this._scaleFactor.x, delta.seconds * this._scaleFactor.y);
25498
- return this;
25499
- }
25500
- destroy() {
25501
- this._scaleFactor.destroy();
26369
+ class VectorRange {
26370
+ minX;
26371
+ maxX;
26372
+ minY;
26373
+ maxY;
26374
+ _scratch = new Vector();
26375
+ constructor(minX, maxX, minY, maxY) {
26376
+ this.minX = minX;
26377
+ this.maxX = maxX;
26378
+ this.minY = minY;
26379
+ this.maxY = maxY;
26380
+ }
26381
+ sample(out = this._scratch) {
26382
+ out.set(this.minX + Math.random() * (this.maxX - this.minX), this.minY + Math.random() * (this.maxY - this.minY));
26383
+ return out;
25502
26384
  }
25503
26385
  }
25504
26386
 
26387
+ const tau$1 = Math.PI * 2;
25505
26388
  /**
25506
- * Accelerates a particle's angular velocity ({@link Particle.rotationSpeed})
25507
- * by a constant `angularAcceleration` (degrees per second²) each tick.
25508
- * The updated `rotationSpeed` is then integrated into
25509
- * {@link Particle.rotation} by {@link ParticleSystem.updateParticle}.
25510
- * Use a negative value to decelerate spin over time.
26389
+ * Random unit vector inside a cone, scaled by a speed magnitude.
26390
+ *
26391
+ * The cone is centred on `directionAngle` (in radians, 0 = +X, π/2 = +Y for
26392
+ * screen-down coordinates) and spans `±halfAngle` radians around it. Speed
26393
+ * is sampled uniformly in `[minSpeed, maxSpeed]`.
26394
+ *
26395
+ * Use for emission cones, explosions, fountain spread — anywhere the
26396
+ * direction has a preferred axis with bounded variance.
26397
+ *
26398
+ * @example
26399
+ * // Upward fountain with ±15° spread, 200-300 px/s:
26400
+ * const up = new ConeDirection(-Math.PI / 2, Math.PI / 12, 200, 300);
26401
+ * up.sample(particle.velocity);
25511
26402
  */
25512
- class TorqueAffector {
25513
- _angularAcceleration;
25514
- constructor(angularAcceleration) {
25515
- this._angularAcceleration = angularAcceleration;
25516
- }
25517
- get angularAcceleration() {
25518
- return this._angularAcceleration;
25519
- }
25520
- set angularAcceleration(angularAcceleration) {
25521
- this.setAngularAcceleration(angularAcceleration);
25522
- }
25523
- setAngularAcceleration(angularAcceleration) {
25524
- this._angularAcceleration = angularAcceleration;
25525
- return this;
25526
- }
25527
- /**
25528
- * Adds `angularAcceleration * delta.seconds` to `particle.rotationSpeed`,
25529
- * increasing or decreasing spin rate for this timestep.
25530
- */
25531
- apply(particle, delta) {
25532
- particle.rotationSpeed += (delta.seconds * this._angularAcceleration);
25533
- return this;
25534
- }
25535
- destroy() {
25536
- // no-op — pure value class, kept for Destroyable interface conformance
26403
+ class ConeDirection {
26404
+ directionAngle;
26405
+ halfAngle;
26406
+ minSpeed;
26407
+ maxSpeed;
26408
+ _scratch = new Vector();
26409
+ constructor(directionAngle, halfAngle, minSpeed, maxSpeed) {
26410
+ this.directionAngle = directionAngle;
26411
+ this.halfAngle = halfAngle;
26412
+ this.minSpeed = minSpeed;
26413
+ this.maxSpeed = maxSpeed;
26414
+ }
26415
+ sample(out = this._scratch) {
26416
+ const angle = this.directionAngle + (Math.random() * 2 - 1) * this.halfAngle;
26417
+ const speed = this.minSpeed + Math.random() * (this.maxSpeed - this.minSpeed);
26418
+ out.set(Math.cos(angle) * speed, Math.sin(angle) * speed);
26419
+ return out;
26420
+ }
26421
+ /** Convenience: omnidirectional emission (full circle, 0..2π). */
26422
+ static omni(minSpeed, maxSpeed) {
26423
+ return new ConeDirection(0, tau$1 / 2, minSpeed, maxSpeed);
25537
26424
  }
25538
26425
  }
25539
26426
 
26427
+ const tau = Math.PI * 2;
25540
26428
  /**
25541
- * Spawn-time configuration snapshot passed to {@link ParticleEmitter.apply}
25542
- * implementations. Implements {@link ParticleProperties} so the same affector
25543
- * interface can read default values when needed. All fields have sensible
25544
- * defaults (1 s lifetime, zero position/velocity, unit scale, white tint)
25545
- * and every object-valued field is deep-cloned on construction, making it
25546
- * safe to share one `ParticleOptions` instance across multiple emitters.
26429
+ * Random point on or inside a circle of radius `radius`, centred at
26430
+ * `(centerX, centerY)`. With `mode: 'edge'` the result lies exactly on the
26431
+ * circumference; with `mode: 'volume'` (default) it lies anywhere in the
26432
+ * disk, with uniform area density (sqrt-of-random-radius distribution).
26433
+ *
26434
+ * Use as a spawn-position distribution for circular emitters.
25547
26435
  */
25548
- class ParticleOptions {
25549
- _totalLifetime;
25550
- _elapsedLifetime;
25551
- _position;
25552
- _velocity;
25553
- _scale;
25554
- _tint;
25555
- _rotation;
25556
- _rotationSpeed;
25557
- _textureIndex;
25558
- /**
25559
- * Creates a new options snapshot. Any omitted field falls back to its
25560
- * default value (1 s lifetime, zero position/velocity, unit scale,
25561
- * zero rotation, white tint, texture index 0). All object values are
25562
- * cloned so later mutations to the source objects do not affect this
25563
- * instance.
25564
- */
25565
- constructor(options = {}) {
25566
- const { totalLifetime, elapsedLifetime, position, velocity, scale, rotation, rotationSpeed, textureIndex, tint, } = options;
25567
- this._totalLifetime = (totalLifetime ?? Time.oneSecond).clone();
25568
- this._elapsedLifetime = (elapsedLifetime ?? Time.zero).clone();
25569
- this._position = (position ?? Vector.zero).clone();
25570
- this._velocity = (velocity ?? Vector.zero).clone();
25571
- this._scale = (scale ?? Vector.one).clone();
25572
- this._tint = (tint ?? Color.white).clone();
25573
- this._rotation = rotation ?? 0;
25574
- this._rotationSpeed = rotationSpeed ?? 0;
25575
- this._textureIndex = textureIndex ?? 0;
25576
- }
25577
- get totalLifetime() {
25578
- return this._totalLifetime;
25579
- }
25580
- set totalLifetime(totalLifetime) {
25581
- this._totalLifetime.copy(totalLifetime);
25582
- }
25583
- get elapsedLifetime() {
25584
- return this._elapsedLifetime;
25585
- }
25586
- set elapsedLifetime(elapsedLifetime) {
25587
- this._elapsedLifetime.copy(elapsedLifetime);
25588
- }
25589
- /**
25590
- * Spawn position for particles emitted with these options, expressed in
25591
- * the owning ParticleSystem's LOCAL coordinate space — the system's own
25592
- * `getGlobalTransform()` is applied on top during rendering (both the
25593
- * WebGL2 and WebGPU shaders do `projection * translation * rotated`).
25594
- *
25595
- * Setting a world-space value here (e.g. `system.x + offset`) will
25596
- * double-translate the emitter because the shader will translate again.
25597
- * For an emitter anchored at the system origin, use small offsets around
25598
- * `(0, 0)` and position the system itself via `system.setPosition(...)`.
25599
- */
25600
- get position() {
25601
- return this._position;
25602
- }
25603
- set position(position) {
25604
- this._position.copy(position);
25605
- }
25606
- get velocity() {
25607
- return this._velocity;
25608
- }
25609
- set velocity(velocity) {
25610
- this._velocity.copy(velocity);
25611
- }
25612
- get scale() {
25613
- return this._scale;
25614
- }
25615
- set scale(scale) {
25616
- this._scale.copy(scale);
25617
- }
25618
- get rotation() {
25619
- return this._rotation;
25620
- }
25621
- set rotation(degrees) {
25622
- this._rotation = trimRotation(degrees);
25623
- }
25624
- get rotationSpeed() {
25625
- return this._rotationSpeed;
25626
- }
25627
- set rotationSpeed(rotationSpeed) {
25628
- this._rotationSpeed = rotationSpeed;
25629
- }
25630
- get textureIndex() {
25631
- return this._textureIndex;
25632
- }
25633
- set textureIndex(textureIndex) {
25634
- this._textureIndex = textureIndex;
26436
+ class CircleArea {
26437
+ centerX;
26438
+ centerY;
26439
+ radius;
26440
+ mode;
26441
+ _scratch = new Vector();
26442
+ constructor(centerX, centerY, radius, mode = 'volume') {
26443
+ this.centerX = centerX;
26444
+ this.centerY = centerY;
26445
+ this.radius = radius;
26446
+ this.mode = mode;
25635
26447
  }
25636
- get tint() {
25637
- return this._tint;
26448
+ sample(out = this._scratch) {
26449
+ const angle = Math.random() * tau;
26450
+ const r = this.mode === 'edge' ? this.radius : this.radius * Math.sqrt(Math.random());
26451
+ out.set(this.centerX + Math.cos(angle) * r, this.centerY + Math.sin(angle) * r);
26452
+ return out;
25638
26453
  }
25639
- set tint(color) {
25640
- this._tint.copy(color);
26454
+ }
26455
+
26456
+ /**
26457
+ * Random point inside an axis-aligned box. With `mode: 'edge'` the result
26458
+ * lies on the perimeter (uniform along all four edges combined); with
26459
+ * `mode: 'volume'` (default) it's uniformly distributed across the area.
26460
+ */
26461
+ class BoxArea {
26462
+ minX;
26463
+ maxX;
26464
+ minY;
26465
+ maxY;
26466
+ mode;
26467
+ _scratch = new Vector();
26468
+ constructor(minX, maxX, minY, maxY, mode = 'volume') {
26469
+ this.minX = minX;
26470
+ this.maxX = maxX;
26471
+ this.minY = minY;
26472
+ this.maxY = maxY;
26473
+ this.mode = mode;
26474
+ }
26475
+ sample(out = this._scratch) {
26476
+ if (this.mode === 'volume') {
26477
+ out.set(this.minX + Math.random() * (this.maxX - this.minX), this.minY + Math.random() * (this.maxY - this.minY));
26478
+ return out;
26479
+ }
26480
+ const w = this.maxX - this.minX;
26481
+ const h = this.maxY - this.minY;
26482
+ const perimeter = (w + h) * 2;
26483
+ const t = Math.random() * perimeter;
26484
+ if (t < w) {
26485
+ out.set(this.minX + t, this.minY);
26486
+ }
26487
+ else if (t < w + h) {
26488
+ out.set(this.maxX, this.minY + (t - w));
26489
+ }
26490
+ else if (t < w * 2 + h) {
26491
+ out.set(this.maxX - (t - w - h), this.maxY);
26492
+ }
26493
+ else {
26494
+ out.set(this.minX, this.maxY - (t - w * 2 - h));
26495
+ }
26496
+ return out;
25641
26497
  }
25642
- /** Destroys all owned value objects. Called by the owning emitter's `destroy()`. */
25643
- destroy() {
25644
- this._totalLifetime.destroy();
25645
- this._elapsedLifetime.destroy();
25646
- this._position.destroy();
25647
- this._velocity.destroy();
25648
- this._scale.destroy();
25649
- this._tint.destroy();
26498
+ }
26499
+
26500
+ /**
26501
+ * Random point on a line segment between `(x0, y0)` and `(x1, y1)`. Uniform
26502
+ * parameter distribution: `t = Math.random()` then `lerp(start, end, t)`.
26503
+ */
26504
+ class LineSegment {
26505
+ x0;
26506
+ y0;
26507
+ x1;
26508
+ y1;
26509
+ _scratch = new Vector();
26510
+ constructor(x0, y0, x1, y1) {
26511
+ this.x0 = x0;
26512
+ this.y0 = y0;
26513
+ this.x1 = x1;
26514
+ this.y1 = y1;
26515
+ }
26516
+ sample(out = this._scratch) {
26517
+ const t = Math.random();
26518
+ out.set(this.x0 + (this.x1 - this.x0) * t, this.y0 + (this.y1 - this.y0) * t);
26519
+ return out;
25650
26520
  }
25651
26521
  }
25652
26522
 
26523
+ const compareT$1 = (a, b) => a.t - b.t;
25653
26524
  /**
25654
- * Rate-based concrete {@link ParticleEmitter} that spawns a fixed number of
25655
- * particles per second, configured via {@link UniversalEmitter.emissionRate}
25656
- * and {@link UniversalEmitter.particleOptions}. Sub-frame fractions are
25657
- * accumulated in an internal delta so that low emission rates (e.g. 0.5
25658
- * particles/s) remain accurate over time without integer truncation error.
26525
+ * Piecewise-linear keyframe curve sampled by lifetime ratio `t` in `[0, 1]`.
26526
+ *
26527
+ * Keyframes are stored sorted by `t` and clamped at the endpoints — sampling
26528
+ * outside the keyframe range returns the nearest endpoint value. The
26529
+ * implementation tracks the last accessed segment so monotonically advancing
26530
+ * `t` (the typical case for per-particle lifetime sampling) costs O(1)
26531
+ * amortized per call instead of O(log n).
25659
26532
  *
25660
26533
  * @example
25661
- * const emitter = new UniversalEmitter(60, new ParticleOptions({ velocity: new Vector(0, -200) }));
25662
- * particleSystem.addEmitter(emitter);
26534
+ * // Scale pulses up then down over lifetime:
26535
+ * const sizeCurve = new Curve([
26536
+ * { t: 0, v: 0.2 },
26537
+ * { t: 0.4, v: 1.5 },
26538
+ * { t: 1, v: 0.0 },
26539
+ * ]);
26540
+ * scale.x = scale.y = sizeCurve.evaluate(particle.elapsedRatio);
25663
26541
  */
25664
- class UniversalEmitter {
25665
- _emissionRate;
25666
- _particleOptions;
25667
- _emissionDelta = 0;
25668
- constructor(emissionRate, particleOptions) {
25669
- this._emissionRate = emissionRate;
25670
- this._particleOptions = particleOptions ?? new ParticleOptions();
25671
- }
25672
- get emissionRate() {
25673
- return this._emissionRate;
26542
+ class Curve {
26543
+ _keys;
26544
+ _lastSegment = 0;
26545
+ constructor(keys) {
26546
+ if (keys.length === 0) {
26547
+ throw new Error('Curve requires at least one keyframe.');
26548
+ }
26549
+ this._keys = [...keys].sort(compareT$1);
26550
+ }
26551
+ evaluate(t) {
26552
+ const keys = this._keys;
26553
+ const last = keys.length - 1;
26554
+ if (t <= keys[0].t)
26555
+ return keys[0].v;
26556
+ if (t >= keys[last].t)
26557
+ return keys[last].v;
26558
+ // Cache-friendly forward search: most callers sweep t monotonically.
26559
+ let segment = this._lastSegment;
26560
+ if (t < keys[segment].t) {
26561
+ segment = 0;
26562
+ }
26563
+ while (segment < last && t > keys[segment + 1].t) {
26564
+ segment++;
26565
+ }
26566
+ this._lastSegment = segment;
26567
+ const a = keys[segment];
26568
+ const b = keys[segment + 1];
26569
+ const ratio = (t - a.t) / (b.t - a.t);
26570
+ return a.v + (b.v - a.v) * ratio;
25674
26571
  }
25675
- set emissionRate(particlesPerSecond) {
25676
- this._emissionRate = particlesPerSecond;
25677
- }
25678
- get particleOptions() {
25679
- return this._particleOptions;
25680
- }
25681
- set particleOptions(particleOptions) {
25682
- this._particleOptions = particleOptions;
25683
- }
25684
- /**
25685
- * Computes how many whole particles to spawn for the given `time` slice,
25686
- * carrying any fractional remainder into the next call. This accumulator
25687
- * prevents emission-rate drift when `emissionRate * delta` is not an
25688
- * integer (e.g. 30 fps × 0.5 particles/s = 0.5 per frame → every other
25689
- * frame emits one).
25690
- */
25691
- computeParticleCount(time) {
25692
- const particleAmount = (this._emissionRate * time.seconds) + this._emissionDelta;
25693
- const particles = particleAmount | 0;
25694
- this._emissionDelta = (particleAmount - particles);
25695
- return particles;
26572
+ }
26573
+
26574
+ const compareT = (a, b) => a.t - b.t;
26575
+ /**
26576
+ * Piecewise-linear color gradient sampled by lifetime ratio `t` in `[0, 1]`.
26577
+ * Keyframes are stored sorted by `t`; sampling outside the range returns
26578
+ * the nearest endpoint color. Like {@link Curve}, the last accessed segment
26579
+ * is cached so monotonically advancing `t` is O(1) amortised.
26580
+ *
26581
+ * Two output paths:
26582
+ * - {@link evaluate} writes into a `Color` instance (own scratch by default).
26583
+ * - {@link evaluateRgba} returns the packed `0xAABBGGRR` u32 directly,
26584
+ * skipping the Color object. Use this in tight per-particle inner loops
26585
+ * that write into a `Uint32Array` instance buffer.
26586
+ *
26587
+ * @example
26588
+ * const fire = new Gradient([
26589
+ * { t: 0, color: new Color(1, 1, 1, 1) }, // white
26590
+ * { t: 0.3, color: new Color(1, 0.7, 0.1, 1) }, // orange
26591
+ * { t: 0.7, color: new Color(0.4, 0.1, 0, 0.6) },
26592
+ * { t: 1, color: new Color(0, 0, 0, 0) }, // transparent black
26593
+ * ]);
26594
+ */
26595
+ class Gradient {
26596
+ _keys;
26597
+ _scratch = new Color();
26598
+ _lastSegment = 0;
26599
+ constructor(keys) {
26600
+ if (keys.length === 0) {
26601
+ throw new Error('Gradient requires at least one keyframe.');
26602
+ }
26603
+ this._keys = [...keys].map((k) => ({ t: k.t, color: k.color.clone() })).sort(compareT);
26604
+ }
26605
+ evaluate(t, out = this._scratch) {
26606
+ const keys = this._keys;
26607
+ const last = keys.length - 1;
26608
+ if (t <= keys[0].t) {
26609
+ out.copy(keys[0].color);
26610
+ return out;
26611
+ }
26612
+ if (t >= keys[last].t) {
26613
+ out.copy(keys[last].color);
26614
+ return out;
26615
+ }
26616
+ let segment = this._lastSegment;
26617
+ if (t < keys[segment].t) {
26618
+ segment = 0;
26619
+ }
26620
+ while (segment < last && t > keys[segment + 1].t) {
26621
+ segment++;
26622
+ }
26623
+ this._lastSegment = segment;
26624
+ const a = keys[segment].color;
26625
+ const b = keys[segment + 1].color;
26626
+ const ka = keys[segment].t;
26627
+ const kb = keys[segment + 1].t;
26628
+ const ratio = (t - ka) / (kb - ka);
26629
+ out.set(a.r + (b.r - a.r) * ratio, a.g + (b.g - a.g) * ratio, a.b + (b.b - a.b) * ratio, a.a + (b.a - a.a) * ratio);
26630
+ return out;
26631
+ }
26632
+ /**
26633
+ * Returns the gradient sample at `t` packed into a single 32-bit RGBA
26634
+ * integer (`0xAABBGGRR`). Avoids the {@link Color} object on the hot
26635
+ * path; suitable for direct write into a `Uint32Array` instance buffer.
26636
+ */
26637
+ evaluateRgba(t) {
26638
+ return this.evaluate(t, this._scratch).toRgba();
25696
26639
  }
25697
- /**
25698
- * Requests the correct number of particles from `system` for this frame,
25699
- * configures each one with {@link particleOptions} via
25700
- * {@link Particle.applyOptions}, and emits them into the live pool.
25701
- */
25702
- apply(system, delta) {
25703
- const count = this.computeParticleCount(delta);
25704
- const options = this._particleOptions;
26640
+ }
26641
+
26642
+ /**
26643
+ * Per-frame particle spawner. Subclasses decide how many particles to emit
26644
+ * each tick (rate-based, burst, on-demand) and write their initial values
26645
+ * directly into the system's typed-array slots.
26646
+ *
26647
+ * Implementation pattern:
26648
+ *
26649
+ * ```ts
26650
+ * apply(system, dt) {
26651
+ * const count = this.computeSpawnCount(dt);
26652
+ * for (let i = 0; i < count; i++) {
26653
+ * const slot = system.spawn();
26654
+ * if (slot < 0) break; // capacity exhausted
26655
+ * system.posX[slot] = ...;
26656
+ * system.velX[slot] = ...;
26657
+ * system.lifetime[slot] = ...;
26658
+ * // ...etc
26659
+ * }
26660
+ * }
26661
+ * ```
26662
+ *
26663
+ * Spawn modules run before integration each frame. Multiple modules can be
26664
+ * registered on one system and execute in registration order.
26665
+ */
26666
+ class SpawnModule {
26667
+ /** Optional cleanup hook called from `ParticleSystem.destroy`. */
26668
+ destroy() { }
26669
+ }
26670
+
26671
+ /**
26672
+ * Per-frame, per-batch mutator. Operates on the system's SoA storage
26673
+ * directly — typically a single tight loop over `[0, system.liveCount)`
26674
+ * that reads/writes the relevant `Float32Array`s.
26675
+ *
26676
+ * Implementations must always provide a CPU `apply()`. To make a module
26677
+ * GPU-eligible (executed inside the system's composite compute shader on
26678
+ * WebGPU backends), additionally implement {@link wgsl} and
26679
+ * {@link writeUniforms}. Modules that declare a {@link WgslContribution}
26680
+ * may also opt to declare a 1D texture binding via the `textures` field
26681
+ * (used by `Curve` / `Gradient`-driven modules) — in which case
26682
+ * {@link uploadTextures} runs once at compile time to upload the data.
26683
+ *
26684
+ * Implementation pattern (CPU-only module):
26685
+ *
26686
+ * ```ts
26687
+ * class MyModule extends UpdateModule {
26688
+ * apply(system, dt) {
26689
+ * const { velX, velY, liveCount } = system;
26690
+ * for (let i = 0; i < liveCount; i++) { velX[i] *= 0.99; velY[i] *= 0.99; }
26691
+ * }
26692
+ * }
26693
+ * ```
26694
+ *
26695
+ * Implementation pattern (GPU-eligible module):
26696
+ *
26697
+ * ```ts
26698
+ * class MyForce extends UpdateModule {
26699
+ * constructor(public ax: number, public ay: number) { super(); }
26700
+ *
26701
+ * apply(system, dt) {
26702
+ * const { velX, velY, liveCount } = system;
26703
+ * for (let i = 0; i < liveCount; i++) { velX[i] += this.ax * dt; velY[i] += this.ay * dt; }
26704
+ * }
26705
+ *
26706
+ * wgsl(): WgslContribution {
26707
+ * return {
26708
+ * key: 'MyForce',
26709
+ * uniforms: [{ name: 'ax', type: 'f32' }, { name: 'ay', type: 'f32' }],
26710
+ * body: `velX[idx] += u_MyForce.ax * dt; velY[idx] += u_MyForce.ay * dt;`,
26711
+ * };
26712
+ * }
26713
+ *
26714
+ * writeUniforms(view, offset) {
26715
+ * view.setFloat32(offset + 0, this.ax, true);
26716
+ * view.setFloat32(offset + 4, this.ay, true);
26717
+ * }
26718
+ * }
26719
+ * ```
26720
+ *
26721
+ * If *any* registered update module on a system lacks `wgsl()`, the system
26722
+ * forces CPU mode regardless of backend — preserving the contract that
26723
+ * `apply()` is always honoured. Built-in modules ship both
26724
+ * implementations; custom modules can opt into GPU acceleration at their
26725
+ * authors' discretion.
26726
+ *
26727
+ * Update modules run after integration each frame. Multiple modules execute
26728
+ * in registration order; later modules see the effects of earlier ones.
26729
+ */
26730
+ class UpdateModule {
26731
+ /** Optional cleanup hook called from `ParticleSystem.destroy`. */
26732
+ destroy() { }
26733
+ }
26734
+
26735
+ /**
26736
+ * Per-particle hook invoked exactly once when a particle expires, before
26737
+ * its slot is recycled by the compaction pass. The dying particle's data
26738
+ * is still readable at `system.posX[slot]` etc.
26739
+ *
26740
+ * Use for sub-emitters (spawn child particles where this one died), event
26741
+ * dispatch (trigger an audio cue, score event), or trail termination.
26742
+ *
26743
+ * Implementation pattern:
26744
+ *
26745
+ * ```ts
26746
+ * onDeath(system, slot) {
26747
+ * const x = system.posX[slot];
26748
+ * const y = system.posY[slot];
26749
+ * this._childSystem.spawnBurstAt(x, y, 8);
26750
+ * }
26751
+ * ```
26752
+ */
26753
+ class DeathModule {
26754
+ /** Optional cleanup hook called from `ParticleSystem.destroy`. */
26755
+ destroy() { }
26756
+ }
26757
+
26758
+ /**
26759
+ * Continuous, rate-based spawner. Emits a {@link RateSpawnConfig.rate}
26760
+ * sample per second; sub-frame fractions accumulate so low rates (e.g.
26761
+ * 0.5 particles/s) remain accurate over time.
26762
+ *
26763
+ * Each property is independently randomised via its {@link Distribution}.
26764
+ * Every spawned particle gets a fresh sample for every configured field.
26765
+ */
26766
+ class RateSpawn extends SpawnModule {
26767
+ config;
26768
+ _accumulator = 0;
26769
+ _vec = new Vector();
26770
+ _color = new Color();
26771
+ constructor(config) {
26772
+ super();
26773
+ this.config = config;
26774
+ }
26775
+ apply(system, dt) {
26776
+ const cfg = this.config;
26777
+ const rate = cfg.rate.sample();
26778
+ this._accumulator += rate * dt;
26779
+ const count = this._accumulator | 0;
26780
+ if (count <= 0) {
26781
+ return;
26782
+ }
26783
+ this._accumulator -= count;
26784
+ const v = this._vec;
26785
+ const c = this._color;
25705
26786
  for (let i = 0; i < count; i++) {
25706
- const particle = system.requestParticle();
25707
- particle.applyOptions(options);
25708
- system.emitParticle(particle);
26787
+ const slot = system.spawn();
26788
+ if (slot < 0) {
26789
+ this._accumulator = 0;
26790
+ return;
26791
+ }
26792
+ system.lifetime[slot] = cfg.lifetime ? cfg.lifetime.sample() : 1;
26793
+ if (cfg.position) {
26794
+ cfg.position.sample(v);
26795
+ system.posX[slot] = v.x;
26796
+ system.posY[slot] = v.y;
26797
+ }
26798
+ else {
26799
+ system.posX[slot] = 0;
26800
+ system.posY[slot] = 0;
26801
+ }
26802
+ if (cfg.velocity) {
26803
+ cfg.velocity.sample(v);
26804
+ system.velX[slot] = v.x;
26805
+ system.velY[slot] = v.y;
26806
+ }
26807
+ else {
26808
+ system.velX[slot] = 0;
26809
+ system.velY[slot] = 0;
26810
+ }
26811
+ if (cfg.scale) {
26812
+ cfg.scale.sample(v);
26813
+ system.scaleX[slot] = v.x;
26814
+ system.scaleY[slot] = v.y;
26815
+ }
26816
+ else {
26817
+ system.scaleX[slot] = 1;
26818
+ system.scaleY[slot] = 1;
26819
+ }
26820
+ system.rotations[slot] = cfg.rotation ? cfg.rotation.sample() : 0;
26821
+ system.rotationSpeeds[slot] = cfg.rotationSpeed ? cfg.rotationSpeed.sample() : 0;
26822
+ system.color[slot] = cfg.tint ? cfg.tint.sample(c).toRgba() : 0xffffffff;
26823
+ system.textureIndex[slot] = cfg.textureIndex ? (cfg.textureIndex.sample() | 0) : 0;
25709
26824
  }
25710
- return this;
25711
- }
25712
- destroy() {
25713
- this._particleOptions.destroy();
25714
26825
  }
25715
26826
  }
25716
26827
 
25717
26828
  /**
25718
- * Immediate-mode 2D shape API backed by {@link Mesh} children.
25719
- *
25720
- * Each draw call (e.g. `drawCircle`, `drawRectangle`, `drawLine`) appends a
25721
- * new {@link Mesh} child colored with the current `fillColor` or `lineColor`.
25722
- * The active `lineWidth` controls stroke thickness for path and outline draws.
25723
- * Path commands (`moveTo`, `lineTo`, `quadraticCurveTo`, etc.) track a cursor
25724
- * point and flush a Mesh on each segment.
26829
+ * Discrete-burst spawner. Fires at scheduled times with a fixed count per
26830
+ * burst. Useful for explosions, hit-impacts, level-up effects.
26831
+ *
26832
+ * @example
26833
+ * new BurstSpawn({
26834
+ * schedule: [{ time: 0, count: 50 }, { time: 0.2, count: 25 }],
26835
+ * velocity: ConeDirection.omni(150, 350),
26836
+ * lifetime: new Range(0.4, 0.9),
26837
+ * });
26838
+ */
26839
+ class BurstSpawn extends SpawnModule {
26840
+ config;
26841
+ _elapsed = 0;
26842
+ _nextIndex = 0;
26843
+ _vec = new Vector();
26844
+ _color = new Color();
26845
+ constructor(config) {
26846
+ super();
26847
+ this.config = config;
26848
+ }
26849
+ apply(system, dt) {
26850
+ const cfg = this.config;
26851
+ const schedule = cfg.schedule;
26852
+ if (schedule.length === 0) {
26853
+ return;
26854
+ }
26855
+ this._elapsed += dt;
26856
+ while (this._nextIndex < schedule.length && this._elapsed >= schedule[this._nextIndex].time) {
26857
+ this._spawnBurst(system, schedule[this._nextIndex].count);
26858
+ this._nextIndex++;
26859
+ }
26860
+ if (cfg.loop && this._nextIndex >= schedule.length) {
26861
+ this._elapsed = 0;
26862
+ this._nextIndex = 0;
26863
+ }
26864
+ }
26865
+ /** Restart the schedule from t=0. */
26866
+ reset() {
26867
+ this._elapsed = 0;
26868
+ this._nextIndex = 0;
26869
+ return this;
26870
+ }
26871
+ _spawnBurst(system, count) {
26872
+ const cfg = this.config;
26873
+ const v = this._vec;
26874
+ const c = this._color;
26875
+ for (let i = 0; i < count; i++) {
26876
+ const slot = system.spawn();
26877
+ if (slot < 0) {
26878
+ return;
26879
+ }
26880
+ system.lifetime[slot] = cfg.lifetime ? cfg.lifetime.sample() : 1;
26881
+ if (cfg.position) {
26882
+ cfg.position.sample(v);
26883
+ system.posX[slot] = v.x;
26884
+ system.posY[slot] = v.y;
26885
+ }
26886
+ else {
26887
+ system.posX[slot] = 0;
26888
+ system.posY[slot] = 0;
26889
+ }
26890
+ if (cfg.velocity) {
26891
+ cfg.velocity.sample(v);
26892
+ system.velX[slot] = v.x;
26893
+ system.velY[slot] = v.y;
26894
+ }
26895
+ else {
26896
+ system.velX[slot] = 0;
26897
+ system.velY[slot] = 0;
26898
+ }
26899
+ if (cfg.scale) {
26900
+ cfg.scale.sample(v);
26901
+ system.scaleX[slot] = v.x;
26902
+ system.scaleY[slot] = v.y;
26903
+ }
26904
+ else {
26905
+ system.scaleX[slot] = 1;
26906
+ system.scaleY[slot] = 1;
26907
+ }
26908
+ system.rotations[slot] = cfg.rotation ? cfg.rotation.sample() : 0;
26909
+ system.rotationSpeeds[slot] = cfg.rotationSpeed ? cfg.rotationSpeed.sample() : 0;
26910
+ system.color[slot] = cfg.tint ? cfg.tint.sample(c).toRgba() : 0xffffffff;
26911
+ system.textureIndex[slot] = cfg.textureIndex ? (cfg.textureIndex.sample() | 0) : 0;
26912
+ }
26913
+ }
26914
+ }
26915
+
26916
+ /**
26917
+ * Adds a constant 2D acceleration to every live particle's velocity each
26918
+ * frame. Use for gravity (`new ApplyForce(0, 980)`), wind, or any uniform
26919
+ * force field. Force is applied to all particles equally — for per-particle
26920
+ * variation, layer multiple ApplyForce modules with different gates.
26921
+ *
26922
+ * GPU-eligible: implements {@link UpdateModule.wgsl} so this module runs in
26923
+ * the system's compute shader on WebGPU backends with no CPU readback.
26924
+ */
26925
+ class ApplyForce extends UpdateModule {
26926
+ accelerationX;
26927
+ accelerationY;
26928
+ constructor(accelerationX, accelerationY) {
26929
+ super();
26930
+ this.accelerationX = accelerationX;
26931
+ this.accelerationY = accelerationY;
26932
+ }
26933
+ apply(system, dt) {
26934
+ const { velX, velY, liveCount } = system;
26935
+ const ax = this.accelerationX * dt;
26936
+ const ay = this.accelerationY * dt;
26937
+ for (let i = 0; i < liveCount; i++) {
26938
+ velX[i] += ax;
26939
+ velY[i] += ay;
26940
+ }
26941
+ }
26942
+ wgsl() {
26943
+ return {
26944
+ key: 'ApplyForce',
26945
+ uniforms: [
26946
+ { name: 'ax', type: 'f32' },
26947
+ { name: 'ay', type: 'f32' },
26948
+ ],
26949
+ body: `
26950
+ velocities[idx] = velocities[idx] + vec2<f32>(modules.u_ApplyForce.ax, modules.u_ApplyForce.ay) * dt;
26951
+ `,
26952
+ };
26953
+ }
26954
+ writeUniforms(view, offset) {
26955
+ view.setFloat32(offset + 0, this.accelerationX, true);
26956
+ view.setFloat32(offset + 4, this.accelerationY, true);
26957
+ }
26958
+ }
26959
+
26960
+ /**
26961
+ * Exponential velocity damping. Each frame multiplies every live particle's
26962
+ * velocity by `(1 - drag * dt)`, simulating linear air resistance.
26963
+ *
26964
+ * `drag = 0` is no damping; `drag = 1` halves velocity in ~1 second; higher
26965
+ * values slow particles faster. Negative values accelerate (don't do that
26966
+ * unless you mean it).
26967
+ *
26968
+ * GPU-eligible.
26969
+ */
26970
+ class Drag extends UpdateModule {
26971
+ drag;
26972
+ constructor(drag) {
26973
+ super();
26974
+ this.drag = drag;
26975
+ }
26976
+ apply(system, dt) {
26977
+ const { velX, velY, liveCount } = system;
26978
+ const factor = 1 - this.drag * dt;
26979
+ for (let i = 0; i < liveCount; i++) {
26980
+ velX[i] *= factor;
26981
+ velY[i] *= factor;
26982
+ }
26983
+ }
26984
+ wgsl() {
26985
+ return {
26986
+ key: 'Drag',
26987
+ uniforms: [
26988
+ { name: 'drag', type: 'f32' },
26989
+ ],
26990
+ body: `
26991
+ let dragFactor = 1.0 - modules.u_Drag.drag * dt;
26992
+ velocities[idx] = velocities[idx] * dragFactor;
26993
+ `,
26994
+ };
26995
+ }
26996
+ writeUniforms(view, offset) {
26997
+ view.setFloat32(offset + 0, this.drag, true);
26998
+ }
26999
+ }
27000
+
27001
+ /// <reference types="@webgpu/types" />
27002
+ const lookupSize$4 = 256;
27003
+ /**
27004
+ * Per-frame, per-particle color sampler. Each live particle's tint is set
27005
+ * to the gradient evaluated at the particle's current `elapsed / lifetime`
27006
+ * ratio, packed RGBA. Replaces the per-particle blend of `ColorAffector`
27007
+ * (legacy) with a multi-keyframe gradient.
27008
+ *
27009
+ * GPU-eligible: the gradient is uploaded once as a 256-tap 1D RGBA8 texture
27010
+ * and sampled with linear filtering on the GPU.
27011
+ */
27012
+ class ColorOverLifetime extends UpdateModule {
27013
+ gradient;
27014
+ constructor(gradient) {
27015
+ super();
27016
+ this.gradient = gradient;
27017
+ }
27018
+ apply(system, _dt) {
27019
+ const { color, elapsed, lifetime, liveCount } = system;
27020
+ const gradient = this.gradient;
27021
+ for (let i = 0; i < liveCount; i++) {
27022
+ const t = elapsed[i] / lifetime[i];
27023
+ color[i] = gradient.evaluateRgba(t);
27024
+ }
27025
+ }
27026
+ wgsl() {
27027
+ return {
27028
+ key: 'ColorOverLifetime',
27029
+ textures: [{ name: 'gradient', format: 'rgba8unorm' }],
27030
+ body: `
27031
+ let colorT = clamp(timing[idx].x / max(timing[idx].y, 0.000001), 0.0, 1.0);
27032
+ let colorSample = textureSampleLevel(u_ColorOverLifetime_gradient, u_ColorOverLifetime_gradient_sampler, colorT, 0.0);
27033
+ let r = u32(colorSample.r * 255.0) & 255u;
27034
+ let g = u32(colorSample.g * 255.0) & 255u;
27035
+ let b = u32(colorSample.b * 255.0) & 255u;
27036
+ let a = u32(colorSample.a * 255.0) & 255u;
27037
+ color[idx] = (a << 24u) | (b << 16u) | (g << 8u) | r;
27038
+ `,
27039
+ };
27040
+ }
27041
+ uploadTextures(device, textures) {
27042
+ const texture = textures.get('gradient');
27043
+ if (texture === undefined) {
27044
+ return;
27045
+ }
27046
+ const data = new Uint8Array(lookupSize$4 * 4);
27047
+ const scratch = new Color();
27048
+ for (let i = 0; i < lookupSize$4; i++) {
27049
+ const t = i / (lookupSize$4 - 1);
27050
+ this.gradient.evaluate(t, scratch);
27051
+ const o = i * 4;
27052
+ data[o + 0] = scratch.r & 255;
27053
+ data[o + 1] = scratch.g & 255;
27054
+ data[o + 2] = scratch.b & 255;
27055
+ data[o + 3] = ((scratch.a * 255) | 0) & 255;
27056
+ }
27057
+ device.queue.writeTexture({ texture }, data.buffer, { offset: 0, bytesPerRow: lookupSize$4 * 4, rowsPerImage: 1 }, { width: lookupSize$4, height: 1, depthOrArrayLayers: 1 });
27058
+ }
27059
+ }
27060
+
27061
+ /// <reference types="@webgpu/types" />
27062
+ const lookupSize$3 = 256;
27063
+ /**
27064
+ * Per-frame, per-particle color sampler driven by velocity magnitude rather
27065
+ * than lifetime ratio. Each live particle's tint is set to the gradient
27066
+ * evaluated at `clamp((|velocity| - minSpeed) / (maxSpeed - minSpeed), 0, 1)`.
27067
+ *
27068
+ * Use cases: heat-mapping (slow=blue, fast=red), velocity-tinted trails,
27069
+ * speed-gated highlights.
27070
+ *
27071
+ * GPU-eligible: gradient uploaded as a 256-tap 1D RGBA8 texture, sampled
27072
+ * with linear filtering. Replaces the full color word — pair with a
27073
+ * separate {@link AlphaFadeOverLifetime} after this module if you want to
27074
+ * keep alpha controlled by lifetime.
27075
+ */
27076
+ class ColorOverSpeed extends UpdateModule {
27077
+ gradient;
27078
+ minSpeed;
27079
+ maxSpeed;
27080
+ constructor(gradient, minSpeed, maxSpeed) {
27081
+ super();
27082
+ this.gradient = gradient;
27083
+ this.minSpeed = minSpeed;
27084
+ this.maxSpeed = maxSpeed;
27085
+ }
27086
+ apply(system, _dt) {
27087
+ const { velX, velY, color, liveCount } = system;
27088
+ const gradient = this.gradient;
27089
+ const min = this.minSpeed;
27090
+ const span = Math.max(1e-5, this.maxSpeed - this.minSpeed);
27091
+ for (let i = 0; i < liveCount; i++) {
27092
+ const speed = Math.sqrt(velX[i] * velX[i] + velY[i] * velY[i]);
27093
+ const t = Math.max(0, Math.min(1, (speed - min) / span));
27094
+ color[i] = gradient.evaluateRgba(t);
27095
+ }
27096
+ }
27097
+ wgsl() {
27098
+ return {
27099
+ key: 'ColorOverSpeed',
27100
+ uniforms: [
27101
+ { name: 'minSpeed', type: 'f32' },
27102
+ { name: 'invSpan', type: 'f32' },
27103
+ ],
27104
+ textures: [{ name: 'gradient', format: 'rgba8unorm' }],
27105
+ body: `
27106
+ let speedMag = length(velocities[idx]);
27107
+ let speedT = clamp((speedMag - modules.u_ColorOverSpeed.minSpeed) * modules.u_ColorOverSpeed.invSpan, 0.0, 1.0);
27108
+ let speedSample = textureSampleLevel(u_ColorOverSpeed_gradient, u_ColorOverSpeed_gradient_sampler, speedT, 0.0);
27109
+ let speedR = u32(speedSample.r * 255.0) & 255u;
27110
+ let speedG = u32(speedSample.g * 255.0) & 255u;
27111
+ let speedB = u32(speedSample.b * 255.0) & 255u;
27112
+ let speedA = u32(speedSample.a * 255.0) & 255u;
27113
+ color[idx] = (speedA << 24u) | (speedB << 16u) | (speedG << 8u) | speedR;
27114
+ `,
27115
+ };
27116
+ }
27117
+ writeUniforms(view, offset) {
27118
+ const span = Math.max(1e-5, this.maxSpeed - this.minSpeed);
27119
+ view.setFloat32(offset + 0, this.minSpeed, true);
27120
+ view.setFloat32(offset + 4, 1 / span, true);
27121
+ }
27122
+ uploadTextures(device, textures) {
27123
+ const texture = textures.get('gradient');
27124
+ if (texture === undefined) {
27125
+ return;
27126
+ }
27127
+ const data = new Uint8Array(lookupSize$3 * 4);
27128
+ const scratch = new Color();
27129
+ for (let i = 0; i < lookupSize$3; i++) {
27130
+ const t = i / (lookupSize$3 - 1);
27131
+ this.gradient.evaluate(t, scratch);
27132
+ const o = i * 4;
27133
+ data[o + 0] = scratch.r & 255;
27134
+ data[o + 1] = scratch.g & 255;
27135
+ data[o + 2] = scratch.b & 255;
27136
+ data[o + 3] = ((scratch.a * 255) | 0) & 255;
27137
+ }
27138
+ device.queue.writeTexture({ texture }, data.buffer, { offset: 0, bytesPerRow: lookupSize$3 * 4, rowsPerImage: 1 }, { width: lookupSize$3, height: 1, depthOrArrayLayers: 1 });
27139
+ }
27140
+ }
27141
+
27142
+ /// <reference types="@webgpu/types" />
27143
+ const lookupSize$2 = 256;
27144
+ /**
27145
+ * Sets every live particle's scale to a curve sampled at the particle's
27146
+ * current lifetime ratio. Both axes share one curve — for non-uniform
27147
+ * scaling layer two ScaleOverLifetime modules with separate `axis` filters
27148
+ * (or extend with a per-axis variant).
27149
+ *
27150
+ * Common patterns: shrink-to-zero (start at 1, end at 0), pulse (sine-like
27151
+ * curve up to peak then down), slow-grow (linear ramp).
27152
+ *
27153
+ * GPU-eligible: the curve is uploaded once as a 256-tap 1D R32F texture and
27154
+ * sampled with linear filtering on the GPU — no curve evaluation cost in
27155
+ * the inner loop.
27156
+ */
27157
+ class ScaleOverLifetime extends UpdateModule {
27158
+ curve;
27159
+ constructor(curve) {
27160
+ super();
27161
+ this.curve = curve;
27162
+ }
27163
+ apply(system, _dt) {
27164
+ const { scaleX, scaleY, elapsed, lifetime, liveCount } = system;
27165
+ const curve = this.curve;
27166
+ for (let i = 0; i < liveCount; i++) {
27167
+ const t = elapsed[i] / lifetime[i];
27168
+ const s = curve.evaluate(t);
27169
+ scaleX[i] = s;
27170
+ scaleY[i] = s;
27171
+ }
27172
+ }
27173
+ wgsl() {
27174
+ return {
27175
+ key: 'ScaleOverLifetime',
27176
+ textures: [{ name: 'curve', format: 'r32float' }],
27177
+ body: `
27178
+ let scaleT = clamp(timing[idx].x / max(timing[idx].y, 0.000001), 0.0, 1.0);
27179
+ let scaleSample = textureSampleLevel(u_ScaleOverLifetime_curve, u_ScaleOverLifetime_curve_sampler, scaleT, 0.0).r;
27180
+ scales[idx] = vec2<f32>(scaleSample, scaleSample);
27181
+ `,
27182
+ };
27183
+ }
27184
+ uploadTextures(device, textures) {
27185
+ const texture = textures.get('curve');
27186
+ if (texture === undefined) {
27187
+ return;
27188
+ }
27189
+ const data = new Float32Array(lookupSize$2);
27190
+ for (let i = 0; i < lookupSize$2; i++) {
27191
+ data[i] = this.curve.evaluate(i / (lookupSize$2 - 1));
27192
+ }
27193
+ device.queue.writeTexture({ texture }, data.buffer, { offset: 0, bytesPerRow: lookupSize$2 * 4, rowsPerImage: 1 }, { width: lookupSize$2, height: 1, depthOrArrayLayers: 1 });
27194
+ }
27195
+ }
27196
+
27197
+ /**
27198
+ * Adds a constant angular acceleration to every live particle each frame
27199
+ * (analogous to the legacy `TorqueAffector`). The system's integrate pass
27200
+ * advances `rotation` from `rotationSpeed`; this module increments
27201
+ * `rotationSpeed` itself.
27202
+ *
27203
+ * Units: degrees per second². Negative values decelerate spin.
27204
+ *
27205
+ * GPU-eligible.
27206
+ */
27207
+ class RotateOverLifetime extends UpdateModule {
27208
+ angularAcceleration;
27209
+ constructor(angularAcceleration) {
27210
+ super();
27211
+ this.angularAcceleration = angularAcceleration;
27212
+ }
27213
+ apply(system, dt) {
27214
+ const { rotationSpeeds, liveCount } = system;
27215
+ const delta = this.angularAcceleration * dt;
27216
+ for (let i = 0; i < liveCount; i++) {
27217
+ rotationSpeeds[i] += delta;
27218
+ }
27219
+ }
27220
+ wgsl() {
27221
+ return {
27222
+ key: 'RotateOverLifetime',
27223
+ uniforms: [
27224
+ { name: 'angularAcceleration', type: 'f32' },
27225
+ ],
27226
+ body: `
27227
+ rotInfo[idx].y = rotInfo[idx].y + modules.u_RotateOverLifetime.angularAcceleration * dt;
27228
+ `,
27229
+ };
27230
+ }
27231
+ writeUniforms(view, offset) {
27232
+ view.setFloat32(offset + 0, this.angularAcceleration, true);
27233
+ }
27234
+ }
27235
+
27236
+ /// <reference types="@webgpu/types" />
27237
+ const lookupSize$1 = 256;
27238
+ /**
27239
+ * Fades only the alpha channel over a particle's lifetime, leaving RGB
27240
+ * untouched. Pair with a spawn-time tint or a separate `ColorOverLifetime`
27241
+ * to keep the color layer stable while controlling opacity from a single
27242
+ * curve.
27243
+ *
27244
+ * The default curve `1 → 0` produces a linear fade-out. For a fade-in then
27245
+ * fade-out, pass a curve like `[0,0]→[0.5,1]→[1,0]`.
27246
+ *
27247
+ * GPU-eligible: uploads the curve as a 256-tap 1D R32F texture; alpha is
27248
+ * resampled per-particle in the compute shader and stitched into the
27249
+ * existing color word with a single mask + shift.
27250
+ */
27251
+ class AlphaFadeOverLifetime extends UpdateModule {
27252
+ curve;
27253
+ constructor(curve) {
27254
+ super();
27255
+ this.curve = curve;
27256
+ }
27257
+ apply(system, _dt) {
27258
+ const { color, elapsed, lifetime, liveCount } = system;
27259
+ const curve = this.curve;
27260
+ for (let i = 0; i < liveCount; i++) {
27261
+ const t = elapsed[i] / lifetime[i];
27262
+ const a = curve.evaluate(t);
27263
+ const alphaByte = (Math.max(0, Math.min(1, a)) * 255) & 255;
27264
+ color[i] = (color[i] & 0x00ffffff) | (alphaByte << 24);
27265
+ }
27266
+ }
27267
+ wgsl() {
27268
+ return {
27269
+ key: 'AlphaFadeOverLifetime',
27270
+ textures: [{ name: 'curve', format: 'r32float' }],
27271
+ body: `
27272
+ let alphaT = clamp(timing[idx].x / max(timing[idx].y, 0.000001), 0.0, 1.0);
27273
+ let alphaSample = textureSampleLevel(u_AlphaFadeOverLifetime_curve, u_AlphaFadeOverLifetime_curve_sampler, alphaT, 0.0).r;
27274
+ let alphaByte = u32(clamp(alphaSample, 0.0, 1.0) * 255.0) & 255u;
27275
+ color[idx] = (color[idx] & 0x00ffffffu) | (alphaByte << 24u);
27276
+ `,
27277
+ };
27278
+ }
27279
+ uploadTextures(device, textures) {
27280
+ const texture = textures.get('curve');
27281
+ if (texture === undefined) {
27282
+ return;
27283
+ }
27284
+ const data = new Float32Array(lookupSize$1);
27285
+ for (let i = 0; i < lookupSize$1; i++) {
27286
+ data[i] = this.curve.evaluate(i / (lookupSize$1 - 1));
27287
+ }
27288
+ device.queue.writeTexture({ texture }, data.buffer, { offset: 0, bytesPerRow: lookupSize$1 * 4, rowsPerImage: 1 }, { width: lookupSize$1, height: 1, depthOrArrayLayers: 1 });
27289
+ }
27290
+ }
27291
+
27292
+ /// <reference types="@webgpu/types" />
27293
+ const lookupSize = 256;
27294
+ /**
27295
+ * Multiplies every live particle's velocity by a curve sampled at the
27296
+ * particle's current `elapsed / lifetime` ratio. The same scalar is applied
27297
+ * to both axes — the direction is preserved, only the magnitude scales.
27298
+ *
27299
+ * Common patterns:
27300
+ * - **Snappy spawn, slow drift:** `[0,1]→[1,0.1]` — fast initial motion
27301
+ * that decays over lifetime.
27302
+ * - **Late acceleration:** `[0,0.2]→[1,1]` — particles ease in.
27303
+ * - **Pulse:** sine-shaped curve for breathing-style motion.
27304
+ *
27305
+ * Note: the curve **replaces** velocity each frame relative to the previous
27306
+ * frame's velocity, so the effect is multiplicative. Pair with `ApplyForce`
27307
+ * if you want a constant external acceleration on top.
27308
+ *
27309
+ * GPU-eligible: uploads the curve as a 256-tap 1D R32F texture; sampled
27310
+ * once per particle per frame.
27311
+ */
27312
+ class VelocityOverLifetime extends UpdateModule {
27313
+ curve;
27314
+ _prevSample = new Float32Array(0);
27315
+ constructor(curve) {
27316
+ super();
27317
+ this.curve = curve;
27318
+ }
27319
+ apply(system, _dt) {
27320
+ const { velX, velY, elapsed, lifetime, liveCount } = system;
27321
+ const curve = this.curve;
27322
+ // Resize per-particle previous-sample cache once.
27323
+ if (this._prevSample.length < system.capacity) {
27324
+ this._prevSample = new Float32Array(system.capacity);
27325
+ this._prevSample.fill(1);
27326
+ }
27327
+ const prev = this._prevSample;
27328
+ for (let i = 0; i < liveCount; i++) {
27329
+ const t = elapsed[i] / lifetime[i];
27330
+ const sample = curve.evaluate(t);
27331
+ const last = prev[i] === 0 ? 1 : prev[i];
27332
+ const delta = sample / last;
27333
+ velX[i] *= delta;
27334
+ velY[i] *= delta;
27335
+ prev[i] = sample === 0 ? 1e-6 : sample;
27336
+ }
27337
+ }
27338
+ wgsl() {
27339
+ // GPU path uses a different formulation: re-derive velocity from
27340
+ // initial speed at t=0 by storing nothing — instead, scale relative
27341
+ // to the previous sample stored in scales[idx] alpha? No — keep it
27342
+ // simple and stateless: scale the integrated velocity by the
27343
+ // *ratio* of (current sample) / (sample at previous frame's t).
27344
+ // We approximate the previous t with `(elapsed - dt) / lifetime`.
27345
+ return {
27346
+ key: 'VelocityOverLifetime',
27347
+ textures: [{ name: 'curve', format: 'r32float' }],
27348
+ body: `
27349
+ let velLifetime = max(timing[idx].y, 0.000001);
27350
+ let velTNow = clamp(timing[idx].x / velLifetime, 0.0, 1.0);
27351
+ let velTPrev = clamp((timing[idx].x - dt) / velLifetime, 0.0, 1.0);
27352
+ let velSampleNow = textureSampleLevel(u_VelocityOverLifetime_curve, u_VelocityOverLifetime_curve_sampler, velTNow, 0.0).r;
27353
+ let velSamplePrev = textureSampleLevel(u_VelocityOverLifetime_curve, u_VelocityOverLifetime_curve_sampler, velTPrev, 0.0).r;
27354
+ let velRatio = velSampleNow / max(velSamplePrev, 0.000001);
27355
+ velocities[idx] = velocities[idx] * velRatio;
27356
+ `,
27357
+ };
27358
+ }
27359
+ uploadTextures(device, textures) {
27360
+ const texture = textures.get('curve');
27361
+ if (texture === undefined) {
27362
+ return;
27363
+ }
27364
+ const data = new Float32Array(lookupSize);
27365
+ for (let i = 0; i < lookupSize; i++) {
27366
+ data[i] = this.curve.evaluate(i / (lookupSize - 1));
27367
+ }
27368
+ device.queue.writeTexture({ texture }, data.buffer, { offset: 0, bytesPerRow: lookupSize * 4, rowsPerImage: 1 }, { width: lookupSize, height: 1, depthOrArrayLayers: 1 });
27369
+ }
27370
+ }
27371
+
27372
+ /**
27373
+ * Pulls every live particle toward a fixed point in the system's local
27374
+ * coordinate space. Acceleration magnitude is `strength` (units / s²),
27375
+ * applied along the direction `(point − particle)`. The optional `falloff`
27376
+ * radius softens the pull near the center — particles within `falloff`
27377
+ * units lerp the strength linearly to zero, preventing the singularity at
27378
+ * `r = 0` from yielding infinite acceleration.
27379
+ *
27380
+ * Use cases: orbit anchors, pin emitters to a moving target, simulate a
27381
+ * "black hole" pickup. For repulsion, use {@link RepelFromPoint}; for
27382
+ * tangential motion, layer with {@link OrbitalForce}.
27383
+ *
27384
+ * GPU-eligible.
27385
+ */
27386
+ class AttractToPoint extends UpdateModule {
27387
+ x;
27388
+ y;
27389
+ strength;
27390
+ falloff;
27391
+ constructor(x, y, strength, falloff = 0) {
27392
+ super();
27393
+ this.x = x;
27394
+ this.y = y;
27395
+ this.strength = strength;
27396
+ this.falloff = falloff;
27397
+ }
27398
+ apply(system, dt) {
27399
+ const { posX, posY, velX, velY, liveCount } = system;
27400
+ const { x, y, strength, falloff } = this;
27401
+ for (let i = 0; i < liveCount; i++) {
27402
+ const dx = x - posX[i];
27403
+ const dy = y - posY[i];
27404
+ const distSq = dx * dx + dy * dy;
27405
+ const dist = Math.sqrt(distSq);
27406
+ if (dist < 1e-5)
27407
+ continue;
27408
+ const k = falloff > 0 ? Math.min(1, dist / falloff) : 1;
27409
+ const a = (strength * k * dt) / dist;
27410
+ velX[i] += dx * a;
27411
+ velY[i] += dy * a;
27412
+ }
27413
+ }
27414
+ wgsl() {
27415
+ return {
27416
+ key: 'AttractToPoint',
27417
+ uniforms: [
27418
+ { name: 'point', type: 'vec2<f32>' },
27419
+ { name: 'strength', type: 'f32' },
27420
+ { name: 'falloff', type: 'f32' },
27421
+ ],
27422
+ body: `
27423
+ let attractDelta = modules.u_AttractToPoint.point - positions[idx];
27424
+ let attractDist = length(attractDelta);
27425
+ if (attractDist > 0.00001) {
27426
+ let attractK = select(1.0, min(1.0, attractDist / max(modules.u_AttractToPoint.falloff, 0.000001)), modules.u_AttractToPoint.falloff > 0.0);
27427
+ let attractAccel = (modules.u_AttractToPoint.strength * attractK * dt) / attractDist;
27428
+ velocities[idx] = velocities[idx] + attractDelta * attractAccel;
27429
+ }
27430
+ `,
27431
+ };
27432
+ }
27433
+ writeUniforms(view, offset) {
27434
+ view.setFloat32(offset + 0, this.x, true);
27435
+ view.setFloat32(offset + 4, this.y, true);
27436
+ view.setFloat32(offset + 8, this.strength, true);
27437
+ view.setFloat32(offset + 12, this.falloff, true);
27438
+ }
27439
+ }
27440
+
27441
+ /**
27442
+ * Pushes every live particle away from a fixed point in the system's local
27443
+ * coordinate space. Acceleration magnitude is `strength` (units / s²)
27444
+ * along the direction `(particle − point)`. The optional `radius` controls
27445
+ * the influence range — particles farther than `radius` are unaffected.
27446
+ * Setting `radius = 0` (default) means infinite range with no falloff.
27447
+ *
27448
+ * Use cases: shockwaves, explosion blast, mouse-cursor repel field.
27449
+ *
27450
+ * GPU-eligible.
27451
+ */
27452
+ class RepelFromPoint extends UpdateModule {
27453
+ x;
27454
+ y;
27455
+ strength;
27456
+ radius;
27457
+ constructor(x, y, strength, radius = 0) {
27458
+ super();
27459
+ this.x = x;
27460
+ this.y = y;
27461
+ this.strength = strength;
27462
+ this.radius = radius;
27463
+ }
27464
+ apply(system, dt) {
27465
+ const { posX, posY, velX, velY, liveCount } = system;
27466
+ const { x, y, strength, radius } = this;
27467
+ const radiusSq = radius * radius;
27468
+ for (let i = 0; i < liveCount; i++) {
27469
+ const dx = posX[i] - x;
27470
+ const dy = posY[i] - y;
27471
+ const distSq = dx * dx + dy * dy;
27472
+ if (distSq < 1e-10)
27473
+ continue;
27474
+ if (radius > 0 && distSq > radiusSq)
27475
+ continue;
27476
+ const dist = Math.sqrt(distSq);
27477
+ const falloff = radius > 0 ? 1 - dist / radius : 1;
27478
+ const a = (strength * falloff * dt) / dist;
27479
+ velX[i] += dx * a;
27480
+ velY[i] += dy * a;
27481
+ }
27482
+ }
27483
+ wgsl() {
27484
+ return {
27485
+ key: 'RepelFromPoint',
27486
+ uniforms: [
27487
+ { name: 'point', type: 'vec2<f32>' },
27488
+ { name: 'strength', type: 'f32' },
27489
+ { name: 'radius', type: 'f32' },
27490
+ ],
27491
+ body: `
27492
+ let repelDelta = positions[idx] - modules.u_RepelFromPoint.point;
27493
+ let repelDistSq = dot(repelDelta, repelDelta);
27494
+ let repelRadius = modules.u_RepelFromPoint.radius;
27495
+ let repelInRange = (repelRadius <= 0.0) || (repelDistSq <= repelRadius * repelRadius);
27496
+ if (repelDistSq > 0.0000001 && repelInRange) {
27497
+ let repelDist = sqrt(repelDistSq);
27498
+ let repelFalloff = select(1.0, 1.0 - repelDist / max(repelRadius, 0.000001), repelRadius > 0.0);
27499
+ let repelAccel = (modules.u_RepelFromPoint.strength * repelFalloff * dt) / repelDist;
27500
+ velocities[idx] = velocities[idx] + repelDelta * repelAccel;
27501
+ }
27502
+ `,
27503
+ };
27504
+ }
27505
+ writeUniforms(view, offset) {
27506
+ view.setFloat32(offset + 0, this.x, true);
27507
+ view.setFloat32(offset + 4, this.y, true);
27508
+ view.setFloat32(offset + 8, this.strength, true);
27509
+ view.setFloat32(offset + 12, this.radius, true);
27510
+ }
27511
+ }
27512
+
27513
+ /**
27514
+ * Applies a tangential acceleration around a center point — perpendicular
27515
+ * to the radial vector `(particle − center)`. Combined with an attract /
27516
+ * repel module that controls the radial distance, this produces orbital,
27517
+ * spiral, or vortex motion.
27518
+ *
27519
+ * `angularSpeed` is the target angular velocity in radians/second. The
27520
+ * effective tangential acceleration scales with `radius * angularSpeed`,
27521
+ * so distant particles get pushed harder (matching uniform circular
27522
+ * motion). Positive values orbit counter-clockwise, negative clockwise.
27523
+ *
27524
+ * Use cases: galactic spirals, smoke vortices around an attractor, wind
27525
+ * eddies. Layer with {@link AttractToPoint} for stable orbits.
27526
+ *
27527
+ * GPU-eligible.
27528
+ */
27529
+ class OrbitalForce extends UpdateModule {
27530
+ x;
27531
+ y;
27532
+ angularSpeed;
27533
+ constructor(x, y, angularSpeed) {
27534
+ super();
27535
+ this.x = x;
27536
+ this.y = y;
27537
+ this.angularSpeed = angularSpeed;
27538
+ }
27539
+ apply(system, dt) {
27540
+ const { posX, posY, velX, velY, liveCount } = system;
27541
+ const { x, y, angularSpeed } = this;
27542
+ const omega = angularSpeed * dt;
27543
+ for (let i = 0; i < liveCount; i++) {
27544
+ const dx = posX[i] - x;
27545
+ const dy = posY[i] - y;
27546
+ // Perpendicular vector: (-dy, dx) for counter-clockwise.
27547
+ velX[i] += -dy * omega;
27548
+ velY[i] += dx * omega;
27549
+ }
27550
+ }
27551
+ wgsl() {
27552
+ return {
27553
+ key: 'OrbitalForce',
27554
+ uniforms: [
27555
+ { name: 'center', type: 'vec2<f32>' },
27556
+ { name: 'angularSpeed', type: 'f32' },
27557
+ { name: '_pad0', type: 'f32' },
27558
+ ],
27559
+ body: `
27560
+ let orbitDelta = positions[idx] - modules.u_OrbitalForce.center;
27561
+ let orbitOmega = modules.u_OrbitalForce.angularSpeed * dt;
27562
+ velocities[idx] = velocities[idx] + vec2<f32>(-orbitDelta.y, orbitDelta.x) * orbitOmega;
27563
+ `,
27564
+ };
27565
+ }
27566
+ writeUniforms(view, offset) {
27567
+ view.setFloat32(offset + 0, this.x, true);
27568
+ view.setFloat32(offset + 4, this.y, true);
27569
+ view.setFloat32(offset + 8, this.angularSpeed, true);
27570
+ view.setFloat32(offset + 12, 0, true);
27571
+ }
27572
+ }
27573
+
27574
+ /**
27575
+ * Adds a smooth pseudo-random force field that animates over time.
27576
+ * Implemented as 2D value noise with cubic Hermite smoothing — sampled
27577
+ * twice per particle (offset to decorrelate x and y components) and scaled
27578
+ * by `strength`. The field evolves at `timeScale` units per second; lower
27579
+ * values produce slow-moving currents, higher values produce buzzy chaos.
27580
+ *
27581
+ * `frequency` controls the spatial granularity: small values (≈ 0.005)
27582
+ * yield broad swirls across the playfield, large values (≈ 0.1) produce
27583
+ * tight per-particle jitter.
27584
+ *
27585
+ * Use cases: smoke turbulence, organic swirls, wind eddies, dust haze.
27586
+ * Pair with {@link Drag} to keep particle velocities bounded.
27587
+ *
27588
+ * GPU-eligible. The noise function is identical on CPU and GPU so visual
27589
+ * results match across backends (modulo float precision).
27590
+ */
27591
+ class Turbulence extends UpdateModule {
27592
+ strength;
27593
+ frequency;
27594
+ timeScale;
27595
+ _time = 0;
27596
+ constructor(strength, frequency = 0.01, timeScale = 1) {
27597
+ super();
27598
+ this.strength = strength;
27599
+ this.frequency = frequency;
27600
+ this.timeScale = timeScale;
27601
+ }
27602
+ apply(system, dt) {
27603
+ this._time += dt * this.timeScale;
27604
+ const t = this._time;
27605
+ const f = this.frequency;
27606
+ const s = this.strength * dt;
27607
+ const { posX, posY, velX, velY, liveCount } = system;
27608
+ for (let i = 0; i < liveCount; i++) {
27609
+ const x = posX[i] * f;
27610
+ const y = posY[i] * f;
27611
+ const nx = valueNoise2(x + t, y);
27612
+ const ny = valueNoise2(x, y + t + 17.31);
27613
+ velX[i] += (nx * 2 - 1) * s;
27614
+ velY[i] += (ny * 2 - 1) * s;
27615
+ }
27616
+ }
27617
+ wgsl() {
27618
+ return {
27619
+ key: 'Turbulence',
27620
+ uniforms: [
27621
+ { name: 'strength', type: 'f32' },
27622
+ { name: 'frequency', type: 'f32' },
27623
+ { name: 'time', type: 'f32' },
27624
+ { name: '_pad0', type: 'f32' },
27625
+ ],
27626
+ prelude: `
27627
+ fn exojs_turbulence_hash21(p: vec2<f32>) -> f32 {
27628
+ let n = sin(dot(p, vec2<f32>(127.1, 311.7))) * 43758.5453;
27629
+ return fract(n);
27630
+ }
27631
+
27632
+ fn exojs_turbulence_valueNoise2(x: f32, y: f32) -> f32 {
27633
+ let xi = floor(x);
27634
+ let yi = floor(y);
27635
+ let xf = x - xi;
27636
+ let yf = y - yi;
27637
+ let u = xf * xf * (3.0 - 2.0 * xf);
27638
+ let v = yf * yf * (3.0 - 2.0 * yf);
27639
+ let a = exojs_turbulence_hash21(vec2<f32>(xi, yi));
27640
+ let b = exojs_turbulence_hash21(vec2<f32>(xi + 1.0, yi));
27641
+ let c = exojs_turbulence_hash21(vec2<f32>(xi, yi + 1.0));
27642
+ let d = exojs_turbulence_hash21(vec2<f32>(xi + 1.0, yi + 1.0));
27643
+ let ab = a + (b - a) * u;
27644
+ let cd = c + (d - c) * u;
27645
+ return ab + (cd - ab) * v;
27646
+ }
27647
+ `,
27648
+ body: `
27649
+ let turbF = modules.u_Turbulence.frequency;
27650
+ let turbT = modules.u_Turbulence.time;
27651
+ let turbS = modules.u_Turbulence.strength * dt;
27652
+ let turbX = positions[idx].x * turbF;
27653
+ let turbY = positions[idx].y * turbF;
27654
+ let turbNx = exojs_turbulence_valueNoise2(turbX + turbT, turbY);
27655
+ let turbNy = exojs_turbulence_valueNoise2(turbX, turbY + turbT + 17.31);
27656
+ velocities[idx] = velocities[idx] + vec2<f32>(turbNx * 2.0 - 1.0, turbNy * 2.0 - 1.0) * turbS;
27657
+ `,
27658
+ };
27659
+ }
27660
+ writeUniforms(view, offset, dt) {
27661
+ // GPU mode: apply() never runs, so advance _time here once per frame.
27662
+ // CPU mode: apply() already advances _time before update finishes,
27663
+ // and writeUniforms is not called → no double-advance.
27664
+ this._time += dt * this.timeScale;
27665
+ view.setFloat32(offset + 0, this.strength, true);
27666
+ view.setFloat32(offset + 4, this.frequency, true);
27667
+ view.setFloat32(offset + 8, this._time, true);
27668
+ view.setFloat32(offset + 12, 0, true);
27669
+ }
27670
+ }
27671
+ function hash21(x, y) {
27672
+ let n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
27673
+ n = n - Math.floor(n);
27674
+ return n;
27675
+ }
27676
+ function valueNoise2(x, y) {
27677
+ const xi = Math.floor(x);
27678
+ const yi = Math.floor(y);
27679
+ const xf = x - xi;
27680
+ const yf = y - yi;
27681
+ const u = xf * xf * (3 - 2 * xf);
27682
+ const v = yf * yf * (3 - 2 * yf);
27683
+ const a = hash21(xi, yi);
27684
+ const b = hash21(xi + 1, yi);
27685
+ const c = hash21(xi, yi + 1);
27686
+ const d = hash21(xi + 1, yi + 1);
27687
+ const ab = a + (b - a) * u;
27688
+ const cd = c + (d - c) * u;
27689
+ return ab + (cd - ab) * v;
27690
+ }
27691
+
27692
+ /**
27693
+ * Sub-emitter: triggers a child {@link SpawnModule} on a target system at
27694
+ * the dying particle's position. Use for explosion-on-impact, sparks at
27695
+ * end-of-life, multi-stage VFX.
27696
+ *
27697
+ * The child module receives a synthesized `dt` of 0 — it must spawn
27698
+ * immediately rather than rely on rate accumulation. {@link BurstSpawn}
27699
+ * works naturally; {@link RateSpawn} is the wrong fit here.
27700
+ *
27701
+ * Position is the only field forwarded — child distributions decide
27702
+ * everything else. To keep child particles riding the parent's velocity,
27703
+ * configure the child's velocity distribution to match.
27704
+ */
27705
+ class SpawnOnDeath extends DeathModule {
27706
+ targetSystem;
27707
+ spawner;
27708
+ /** Number of times to invoke the spawner per dying particle. Default 1. */
27709
+ count;
27710
+ constructor(targetSystem, spawner, count = 1) {
27711
+ super();
27712
+ this.targetSystem = targetSystem;
27713
+ this.spawner = spawner;
27714
+ this.count = count;
27715
+ }
27716
+ onDeath(parent, slot) {
27717
+ const target = this.targetSystem;
27718
+ const x = parent.posX[slot];
27719
+ const y = parent.posY[slot];
27720
+ // Snapshot the target's pre-spawn count so we can apply the
27721
+ // position to whichever slots the spawner adds.
27722
+ const before = target.liveCount;
27723
+ for (let n = 0; n < this.count; n++) {
27724
+ this.spawner.apply(target, 0);
27725
+ }
27726
+ const added = target.liveCount - before;
27727
+ for (let i = 0; i < added; i++) {
27728
+ const dst = before + i;
27729
+ target.posX[dst] += x;
27730
+ target.posY[dst] += y;
27731
+ }
27732
+ }
27733
+ }
27734
+
27735
+ /**
27736
+ * Immediate-mode 2D shape API backed by {@link Mesh} children.
27737
+ *
27738
+ * Each draw call (e.g. `drawCircle`, `drawRectangle`, `drawLine`) appends a
27739
+ * new {@link Mesh} child colored with the current `fillColor` or `lineColor`.
27740
+ * The active `lineWidth` controls stroke thickness for path and outline draws.
27741
+ * Path commands (`moveTo`, `lineTo`, `quadraticCurveTo`, etc.) track a cursor
27742
+ * point and flush a Mesh on each segment.
25725
27743
  *
25726
27744
  * Call {@link clear} to remove all child meshes and reset pen state. Because
25727
27745
  * each shape is a separate Mesh, `Graphics` inherits full filter, blend,
@@ -25850,10 +27868,10 @@ class Graphics extends Container {
25850
27868
  }
25851
27869
  let sweep = endAngle - startAngle;
25852
27870
  if (!anticlockwise && sweep < 0) {
25853
- sweep += tau;
27871
+ sweep += tau$2;
25854
27872
  }
25855
27873
  else if (anticlockwise && sweep > 0) {
25856
- sweep -= tau;
27874
+ sweep -= tau$2;
25857
27875
  }
25858
27876
  if (sweep === 0) {
25859
27877
  return this;
@@ -26013,109 +28031,6 @@ function upgradeFragmentShaderToGl300(source) {
26013
28031
  return header + transformed;
26014
28032
  }
26015
28033
 
26016
- /**
26017
- * Slices a single {@link Texture} into named frames and optional named
26018
- * animation sequences.
26019
- *
26020
- * Each frame is stored as both a {@link Rectangle} (the pixel region) and a
26021
- * pre-configured {@link Sprite} ready for direct rendering. Animations are
26022
- * ordered lists of frame names that {@link AnimatedSprite.fromSpritesheet}
26023
- * consumes to create playback clips.
26024
- */
26025
- class Spritesheet {
26026
- texture;
26027
- frames = new Map();
26028
- sprites = new Map();
26029
- animations = new Map();
26030
- constructor(texture, data) {
26031
- this.texture = texture;
26032
- this.parse(data);
26033
- }
26034
- /**
26035
- * Parse a {@link SpritesheetData} descriptor, populating `frames`,
26036
- * `sprites`, and `animations`. When `keepFrames` is `false` (default),
26037
- * all existing frames and sprites are destroyed before parsing.
26038
- */
26039
- parse(data, keepFrames = false) {
26040
- if (!keepFrames) {
26041
- this.clear();
26042
- }
26043
- for (const [name, frame] of Object.entries(data.frames)) {
26044
- this.addFrame(name, frame);
26045
- }
26046
- if (data.animations) {
26047
- for (const [animationName, frameNames] of Object.entries(data.animations)) {
26048
- this.defineAnimation(animationName, frameNames);
26049
- }
26050
- }
26051
- }
26052
- /** Register a single frame by name, creating its {@link Rectangle} and pre-configured {@link Sprite}. */
26053
- addFrame(name, data) {
26054
- const { x, y, w, h } = data.frame;
26055
- const frame = new Rectangle(x, y, w, h);
26056
- const sprite = new Sprite(this.texture);
26057
- sprite.setTextureFrame(frame);
26058
- this.frames.set(name, frame);
26059
- this.sprites.set(name, sprite);
26060
- }
26061
- /** Register an animation sequence as an ordered list of frame names. All referenced frames must already exist. */
26062
- defineAnimation(name, frameNames) {
26063
- if (name.trim().length === 0) {
26064
- throw new Error('Spritesheet animation names must be non-empty strings.');
26065
- }
26066
- if (!Array.isArray(frameNames) || frameNames.length === 0) {
26067
- throw new Error(`Spritesheet animation "${name}" must reference at least one frame.`);
26068
- }
26069
- for (const frameName of frameNames) {
26070
- if (!this.frames.has(frameName)) {
26071
- throw new Error(`Spritesheet animation "${name}" references missing frame "${frameName}".`);
26072
- }
26073
- }
26074
- this.animations.set(name, [...frameNames]);
26075
- return this;
26076
- }
26077
- /** Return the {@link Rectangle} for the named frame. Throws if the frame does not exist. */
26078
- getFrame(name) {
26079
- const frame = this.frames.get(name);
26080
- if (!frame) {
26081
- throw new Error(`Spritesheet frame named ${name} is not available!`);
26082
- }
26083
- return frame;
26084
- }
26085
- /** Return the ordered frame-name list for the named animation. Throws if the animation does not exist. */
26086
- getAnimationFrameNames(name) {
26087
- const frames = this.animations.get(name);
26088
- if (!frames) {
26089
- throw new Error(`Spritesheet animation named ${name} is not available!`);
26090
- }
26091
- return frames;
26092
- }
26093
- /** Return the pre-configured {@link Sprite} for the named frame. Throws if the frame does not exist. */
26094
- getFrameSprite(name) {
26095
- const sprite = this.sprites.get(name);
26096
- if (!sprite) {
26097
- throw new Error(`Spritesheet frame named ${name} is not available!`);
26098
- }
26099
- return sprite;
26100
- }
26101
- /** Destroy all registered frames, sprites, and animations, resetting the spritesheet to an empty state. */
26102
- clear() {
26103
- for (const frame of this.frames.values()) {
26104
- frame.destroy();
26105
- }
26106
- this.frames.clear();
26107
- for (const sprite of this.sprites.values()) {
26108
- sprite.destroy();
26109
- }
26110
- this.sprites.clear();
26111
- this.animations.clear();
26112
- return this;
26113
- }
26114
- destroy() {
26115
- this.clear();
26116
- }
26117
- }
26118
-
26119
28034
  const defaultClipFps = 12;
26120
28035
  /**
26121
28036
  * A {@link Sprite} that advances through a sequence of texture-frame
@@ -28227,5 +30142,5 @@ class IndexedDbStore {
28227
30142
  }
28228
30143
  }
28229
30144
 
28230
- export { AbstractAssetFactory, AbstractMedia, AbstractWebGl2BatchedRenderer, AbstractWebGl2Renderer, AbstractWebGpuRenderer, AnimatedSprite, Application, ApplicationStatus, ArcadeStickGamepadMapping, AudioAnalyser, AudioBus, AudioFilter, AudioListener, AudioManager, BeatDetector, BinaryFactory, BlendModes, BlurFilter, Bounds, BufferTypes, BufferUsage, BundleLoadError, CacheFirstStrategy, CallbackRenderPass, Capabilities, ChannelOffset, ChannelSize, ChorusFilter, Circle, Clock, CollisionType, Color, ColorAffector, ColorFilter, CompressorFilter, Container, DelayFilter, Drawable, DuckingFilter, DynamicGlyphAtlas, Ease, Ellipse, Envelope, EqualizerFilter, FactoryRegistry, Filter, Flags, FontFactory, ForceAffector, GameCubeGamepadMapping, Gamepad, GamepadAxis, GamepadButton, GamepadMapping, GamepadMappingFamily, GamepadPromptLayouts, GenericDualAnalogGamepadMapping, GranularFilter, Graphics, HighpassFilter, ImageFactory, IndexedDbDatabase, IndexedDbStore, InputBinding, InputManager, InteractionEvent, InteractionManager, Interval, JoyConLeftGamepadMapping, JoyConRightGamepadMapping, Json, JsonFactory, Keyboard, Line, Loader, LowpassFilter, Matrix, Mesh, Music, MusicFactory, NetworkOnlyStrategy, ObservableSize, ObservableVector, OscillatorSound, Particle, ParticleOptions, ParticleSystem, PitchShiftFilter, PlayStationGamepadMapping, Pointer, PointerState, PointerStateFlag, PolarVector, Polygon, Quadtree, Random, Rectangle, RenderBackendType, RenderNode, RenderTarget, RenderTargetPass, RenderTexture, RendererRegistry, RenderingPrimitives, ReverbFilter, Sampler, ScaleAffector, ScaleModes, Scene, SceneManager, SceneNode, Segment, Shader, ShaderAttribute, ShaderPrimitives, ShaderUniform, Signal, Size, Sound, SoundFactory, SoundPoolStrategy, Sprite, SpriteFlags, Spritesheet, SteamControllerGamepadMapping, SteamDeckGamepadMapping, SvgAsset, SvgFactory, SwitchProGamepadMapping, Text, TextAsset, TextFactory, TextStyle, Texture, TextureFactory, Time, Timer, TorqueAffector, Tween, TweenManager, TweenState, UniversalEmitter, Vector, Video, VideoFactory, View, ViewFlags, VocoderFilter, VoronoiRegion, VttAsset, VttFactory, WasmFactory, WebGl2Backend, WebGl2MeshRenderer, WebGl2ParticleRenderer, WebGl2RenderBuffer, WebGl2ShaderBlock, WebGl2ShaderFilter, WebGl2SpriteRenderer, WebGl2VertexArrayObject, WebGpuBackend, WebGpuMeshRenderer, WebGpuParticleRenderer, WebGpuShaderFilter, WebGpuSpriteRenderer, WorkletFilter, WrapModes, XboxGamepadMapping, bezierCurveTo, buildCircle, buildEllipse, buildLine, buildPath, buildPolygon, buildRectangle, buildStar, builtInGamepadDefinitions, canvasSourceToDataUrl, clamp, createRenderStats, createWebGl2ShaderProgram, crossFade, decodeAudioData, defineAssetManifest, degreesPerRadian, degreesToRadians, determineMimeType, emptyArrayBuffer, getAudioContext, getAudioManager, getCanvasSourceSize, getCollisionCircleCircle, getCollisionCircleRectangle, getCollisionEllipseCircle, getCollisionEllipseRectangle, getCollisionPolygonCircle, getCollisionRectangleRectangle, getCollisionSat, getDistance, getOfflineAudioContext, getPreciseTime, getTextureSourceSize, getVoronoiRegion$1 as getVoronoiRegion, getWebGpuBlendState, hours, inRange, intersectionCircleCircle, intersectionCircleEllipse, intersectionCirclePoly, intersectionEllipseEllipse, intersectionEllipsePoly, intersectionLineCircle, intersectionLineEllipse, intersectionLineLine, intersectionLinePoly, intersectionLineRect, intersectionPointCircle, intersectionPointEllipse, intersectionPointLine, intersectionPointPoint, intersectionPointPoly, intersectionPointRect, intersectionPolyPoly, intersectionRectCircle, intersectionRectEllipse, intersectionRectPoly, intersectionRectRect, intersectionSat, isAudioContextReady, isPowerOfTwo, layoutText, lerp, matchesIds, maxPointers, milliseconds, minutes, noop$1 as noop, onAudioContextReady, parseGamepadDescriptor, pointerSlotSize, quadraticCurveTo, radiansPerDegree, radiansToDegrees, rand, registerWorkletProcessor, removeArrayItems, resetRenderStats, resolveDefinition, resolveGamepadDefinition, seconds, sign, stopEvent, substepSweep, supportsCodec, supportsEventOptions, supportsIndexedDb, supportsPointerEvents, supportsTouchEvents, supportsWebAudio, sweepCircleAgainst, sweepCircleVsCircle, sweepCircleVsRectangle, sweepRectangle, sweepRectangleAgainst, tau, trimRotation, upgradeFragmentShaderToGl300, webGl2PrimitiveArrayConstructors, webGl2PrimitiveByteSizeMapping, webGl2PrimitiveTypeNames };
30145
+ export { AbstractAssetFactory, AbstractMedia, AbstractWebGl2BatchedRenderer, AbstractWebGl2Renderer, AbstractWebGpuRenderer, AlphaFadeOverLifetime, AnimatedSprite, Application, ApplicationStatus, ApplyForce, ArcadeStickGamepadMapping, AttractToPoint, AudioAnalyser, AudioBus, AudioFilter, AudioListener, AudioManager, BeatDetector, BinaryFactory, BlendModes, BlurFilter, Bounds, BoxArea, BufferTypes, BufferUsage, BundleLoadError, BurstSpawn, CacheFirstStrategy, CallbackRenderPass, Capabilities, ChannelOffset, ChannelSize, ChorusFilter, Circle, CircleArea, Clock, CollisionType, Color, ColorFilter, ColorOverLifetime, ColorOverSpeed, CompressorFilter, ConeDirection, Constant, Container, Curve, DeathModule, DelayFilter, Drag, Drawable, DuckingFilter, DynamicGlyphAtlas, Ease, Ellipse, Envelope, EqualizerFilter, FactoryRegistry, Filter, Flags, FontFactory, GameCubeGamepadMapping, Gamepad, GamepadAxis, GamepadButton, GamepadMapping, GamepadMappingFamily, GamepadPromptLayouts, GenericDualAnalogGamepadMapping, Gradient, GranularFilter, Graphics, HighpassFilter, ImageFactory, IndexedDbDatabase, IndexedDbStore, InputBinding, InputManager, InteractionEvent, InteractionManager, Interval, JoyConLeftGamepadMapping, JoyConRightGamepadMapping, Json, JsonFactory, Keyboard, Line, LineSegment, Loader, LowpassFilter, Matrix, Mesh, Music, MusicFactory, NetworkOnlyStrategy, ObservableSize, ObservableVector, OrbitalForce, OscillatorSound, ParticleSystem, PitchShiftFilter, PlayStationGamepadMapping, Pointer, PointerState, PointerStateFlag, PolarVector, Polygon, Quadtree, Random, Range, RateSpawn, Rectangle, RenderBackendType, RenderNode, RenderTarget, RenderTargetPass, RenderTexture, RendererRegistry, RenderingPrimitives, RepelFromPoint, ReverbFilter, RotateOverLifetime, Sampler, ScaleModes, ScaleOverLifetime, Scene, SceneManager, SceneNode, Segment, Shader, ShaderAttribute, ShaderPrimitives, ShaderUniform, Signal, Size, Sound, SoundFactory, SoundPoolStrategy, SpawnModule, SpawnOnDeath, Sprite, SpriteFlags, Spritesheet, SteamControllerGamepadMapping, SteamDeckGamepadMapping, SvgAsset, SvgFactory, SwitchProGamepadMapping, Text, TextAsset, TextFactory, TextStyle, Texture, TextureFactory, Time, Timer, Turbulence, Tween, TweenManager, TweenState, UpdateModule, Vector, VectorRange, VelocityOverLifetime, Video, VideoFactory, View, ViewFlags, VocoderFilter, VoronoiRegion, VttAsset, VttFactory, WasmFactory, WebGl2Backend, WebGl2MeshRenderer, WebGl2ParticleRenderer, WebGl2RenderBuffer, WebGl2ShaderBlock, WebGl2ShaderFilter, WebGl2SpriteRenderer, WebGl2VertexArrayObject, WebGpuBackend, WebGpuMeshRenderer, WebGpuParticleRenderer, WebGpuShaderFilter, WebGpuSpriteRenderer, WorkletFilter, WrapModes, XboxGamepadMapping, bezierCurveTo, buildCircle, buildEllipse, buildLine, buildPath, buildPolygon, buildRectangle, buildStar, builtInGamepadDefinitions, canvasSourceToDataUrl, clamp, createRenderStats, createWebGl2ShaderProgram, crossFade, decodeAudioData, defineAssetManifest, degreesPerRadian, degreesToRadians, determineMimeType, emptyArrayBuffer, getAudioContext, getAudioManager, getCanvasSourceSize, getCollisionCircleCircle, getCollisionCircleRectangle, getCollisionEllipseCircle, getCollisionEllipseRectangle, getCollisionPolygonCircle, getCollisionRectangleRectangle, getCollisionSat, getDistance, getOfflineAudioContext, getPreciseTime, getTextureSourceSize, getVoronoiRegion$1 as getVoronoiRegion, getWebGpuBlendState, hours, inRange, intersectionCircleCircle, intersectionCircleEllipse, intersectionCirclePoly, intersectionEllipseEllipse, intersectionEllipsePoly, intersectionLineCircle, intersectionLineEllipse, intersectionLineLine, intersectionLinePoly, intersectionLineRect, intersectionPointCircle, intersectionPointEllipse, intersectionPointLine, intersectionPointPoint, intersectionPointPoly, intersectionPointRect, intersectionPolyPoly, intersectionRectCircle, intersectionRectEllipse, intersectionRectPoly, intersectionRectRect, intersectionSat, isAudioContextReady, isPowerOfTwo, layoutText, lerp, matchesIds, maxPointers, milliseconds, minutes, noop$1 as noop, onAudioContextReady, parseGamepadDescriptor, pointerSlotSize, quadraticCurveTo, radiansPerDegree, radiansToDegrees, rand, registerWorkletProcessor, removeArrayItems, resetRenderStats, resolveDefinition, resolveGamepadDefinition, seconds, sign, stopEvent, substepSweep, supportsCodec, supportsEventOptions, supportsIndexedDb, supportsPointerEvents, supportsTouchEvents, supportsWebAudio, sweepCircleAgainst, sweepCircleVsCircle, sweepCircleVsRectangle, sweepRectangle, sweepRectangleAgainst, tau$2 as tau, trimRotation, upgradeFragmentShaderToGl300, webGl2PrimitiveArrayConstructors, webGl2PrimitiveByteSizeMapping, webGl2PrimitiveTypeNames, wgslFieldLayout, wgslUniformByteSize };
28231
30146
  //# sourceMappingURL=exo.esm.js.map