@codexo/exojs-particles 0.15.3 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +21 -43
  2. package/dist/esm/ParticleStorage.d.ts +188 -0
  3. package/dist/esm/ParticleStorage.d.ts.map +1 -0
  4. package/dist/esm/ParticleStorage.js +208 -0
  5. package/dist/esm/ParticleStorage.js.map +1 -0
  6. package/dist/esm/ParticleSystem.d.ts +189 -60
  7. package/dist/esm/ParticleSystem.d.ts.map +1 -0
  8. package/dist/esm/ParticleSystem.js +660 -560
  9. package/dist/esm/ParticleSystem.js.map +1 -1
  10. package/dist/esm/distributions/BoxArea.d.ts +1 -0
  11. package/dist/esm/distributions/BoxArea.d.ts.map +1 -0
  12. package/dist/esm/distributions/BoxArea.js +38 -44
  13. package/dist/esm/distributions/BoxArea.js.map +1 -1
  14. package/dist/esm/distributions/CircleArea.d.ts +1 -0
  15. package/dist/esm/distributions/CircleArea.d.ts.map +1 -0
  16. package/dist/esm/distributions/CircleArea.js +30 -28
  17. package/dist/esm/distributions/CircleArea.js.map +1 -1
  18. package/dist/esm/distributions/ColorGradient.d.ts +3 -2
  19. package/dist/esm/distributions/ColorGradient.d.ts.map +1 -0
  20. package/dist/esm/distributions/ColorGradient.js +70 -75
  21. package/dist/esm/distributions/ColorGradient.js.map +1 -1
  22. package/dist/esm/distributions/ConeDirection.d.ts +2 -1
  23. package/dist/esm/distributions/ConeDirection.d.ts.map +1 -0
  24. package/dist/esm/distributions/ConeDirection.js +41 -39
  25. package/dist/esm/distributions/ConeDirection.js.map +1 -1
  26. package/dist/esm/distributions/Constant.d.ts +1 -0
  27. package/dist/esm/distributions/Constant.d.ts.map +1 -0
  28. package/dist/esm/distributions/Constant.js +32 -32
  29. package/dist/esm/distributions/Constant.js.map +1 -1
  30. package/dist/esm/distributions/Curve.d.ts +2 -1
  31. package/dist/esm/distributions/Curve.d.ts.map +1 -0
  32. package/dist/esm/distributions/Curve.js +46 -55
  33. package/dist/esm/distributions/Curve.js.map +1 -1
  34. package/dist/esm/distributions/Distribution.d.ts +4 -3
  35. package/dist/esm/distributions/Distribution.d.ts.map +1 -0
  36. package/dist/esm/distributions/LineSegment.d.ts +1 -0
  37. package/dist/esm/distributions/LineSegment.d.ts.map +1 -0
  38. package/dist/esm/distributions/LineSegment.js +25 -23
  39. package/dist/esm/distributions/LineSegment.js.map +1 -1
  40. package/dist/esm/distributions/Range.d.ts +1 -0
  41. package/dist/esm/distributions/Range.d.ts.map +1 -0
  42. package/dist/esm/distributions/Range.js +18 -16
  43. package/dist/esm/distributions/Range.js.map +1 -1
  44. package/dist/esm/distributions/VectorRange.d.ts +1 -0
  45. package/dist/esm/distributions/VectorRange.d.ts.map +1 -0
  46. package/dist/esm/distributions/VectorRange.js +29 -27
  47. package/dist/esm/distributions/VectorRange.js.map +1 -1
  48. package/dist/esm/distributions/index.d.ts +1 -0
  49. package/dist/esm/distributions/index.d.ts.map +1 -0
  50. package/dist/esm/gpu/ParticleGpuState.d.ts +143 -18
  51. package/dist/esm/gpu/ParticleGpuState.d.ts.map +1 -0
  52. package/dist/esm/gpu/ParticleGpuState.js +655 -497
  53. package/dist/esm/gpu/ParticleGpuState.js.map +1 -1
  54. package/dist/esm/gpu/shaders/particle-simulate.wgsl.js +6 -0
  55. package/dist/esm/gpu/shaders/particle-simulate.wgsl.js.map +1 -0
  56. package/dist/esm/index.d.ts +1 -0
  57. package/dist/esm/index.d.ts.map +1 -0
  58. package/dist/esm/index.js +39 -32
  59. package/dist/esm/modules/AlphaFadeOverLifetime.d.ts +4 -3
  60. package/dist/esm/modules/AlphaFadeOverLifetime.d.ts.map +1 -0
  61. package/dist/esm/modules/AlphaFadeOverLifetime.js +68 -55
  62. package/dist/esm/modules/AlphaFadeOverLifetime.js.map +1 -1
  63. package/dist/esm/modules/ApplyForce.d.ts +4 -3
  64. package/dist/esm/modules/ApplyForce.d.ts.map +1 -0
  65. package/dist/esm/modules/ApplyForce.js +49 -43
  66. package/dist/esm/modules/ApplyForce.js.map +1 -1
  67. package/dist/esm/modules/AttractToPoint.d.ts +4 -3
  68. package/dist/esm/modules/AttractToPoint.d.ts.map +1 -0
  69. package/dist/esm/modules/AttractToPoint.js +73 -62
  70. package/dist/esm/modules/AttractToPoint.js.map +1 -1
  71. package/dist/esm/modules/BurstSpawn.d.ts +6 -18
  72. package/dist/esm/modules/BurstSpawn.d.ts.map +1 -0
  73. package/dist/esm/modules/BurstSpawn.js +56 -91
  74. package/dist/esm/modules/BurstSpawn.js.map +1 -1
  75. package/dist/esm/modules/ColorOverLifetime.d.ts +4 -3
  76. package/dist/esm/modules/ColorOverLifetime.d.ts.map +1 -0
  77. package/dist/esm/modules/ColorOverLifetime.js +65 -53
  78. package/dist/esm/modules/ColorOverLifetime.js.map +1 -1
  79. package/dist/esm/modules/ColorOverSpeed.d.ts +5 -4
  80. package/dist/esm/modules/ColorOverSpeed.d.ts.map +1 -0
  81. package/dist/esm/modules/ColorOverSpeed.js +90 -75
  82. package/dist/esm/modules/ColorOverSpeed.js.map +1 -1
  83. package/dist/esm/modules/DeathModule.d.ts +26 -10
  84. package/dist/esm/modules/DeathModule.d.ts.map +1 -0
  85. package/dist/esm/modules/DeathModule.js +38 -22
  86. package/dist/esm/modules/DeathModule.js.map +1 -1
  87. package/dist/esm/modules/Drag.d.ts +3 -2
  88. package/dist/esm/modules/Drag.d.ts.map +1 -0
  89. package/dist/esm/modules/Drag.js +43 -37
  90. package/dist/esm/modules/Drag.js.map +1 -1
  91. package/dist/esm/modules/OrbitalForce.d.ts +4 -3
  92. package/dist/esm/modules/OrbitalForce.d.ts.map +1 -0
  93. package/dist/esm/modules/OrbitalForce.js +70 -58
  94. package/dist/esm/modules/OrbitalForce.js.map +1 -1
  95. package/dist/esm/modules/RateSpawn.d.ts +7 -22
  96. package/dist/esm/modules/RateSpawn.d.ts.map +1 -0
  97. package/dist/esm/modules/RateSpawn.js +39 -71
  98. package/dist/esm/modules/RateSpawn.js.map +1 -1
  99. package/dist/esm/modules/RepelFromPoint.d.ts +4 -3
  100. package/dist/esm/modules/RepelFromPoint.d.ts.map +1 -0
  101. package/dist/esm/modules/RepelFromPoint.js +72 -62
  102. package/dist/esm/modules/RepelFromPoint.js.map +1 -1
  103. package/dist/esm/modules/RotateOverLifetime.d.ts +3 -2
  104. package/dist/esm/modules/RotateOverLifetime.d.ts.map +1 -0
  105. package/dist/esm/modules/RotateOverLifetime.js +40 -36
  106. package/dist/esm/modules/RotateOverLifetime.js.map +1 -1
  107. package/dist/esm/modules/ScaleOverLifetime.d.ts +5 -4
  108. package/dist/esm/modules/ScaleOverLifetime.d.ts.map +1 -0
  109. package/dist/esm/modules/ScaleOverLifetime.js +60 -50
  110. package/dist/esm/modules/ScaleOverLifetime.js.map +1 -1
  111. package/dist/esm/modules/SpawnModule.d.ts +15 -12
  112. package/dist/esm/modules/SpawnModule.d.ts.map +1 -0
  113. package/dist/esm/modules/SpawnModule.js +32 -28
  114. package/dist/esm/modules/SpawnModule.js.map +1 -1
  115. package/dist/esm/modules/SpawnOnDeath.d.ts +13 -9
  116. package/dist/esm/modules/SpawnOnDeath.d.ts.map +1 -0
  117. package/dist/esm/modules/SpawnOnDeath.js +41 -43
  118. package/dist/esm/modules/SpawnOnDeath.js.map +1 -1
  119. package/dist/esm/modules/Turbulence.d.ts +4 -3
  120. package/dist/esm/modules/Turbulence.d.ts.map +1 -0
  121. package/dist/esm/modules/Turbulence.js +101 -89
  122. package/dist/esm/modules/Turbulence.js.map +1 -1
  123. package/dist/esm/modules/UpdateModule.d.ts +21 -13
  124. package/dist/esm/modules/UpdateModule.d.ts.map +1 -0
  125. package/dist/esm/modules/UpdateModule.js +65 -63
  126. package/dist/esm/modules/UpdateModule.js.map +1 -1
  127. package/dist/esm/modules/VelocityOverLifetime.d.ts +7 -6
  128. package/dist/esm/modules/VelocityOverLifetime.d.ts.map +1 -0
  129. package/dist/esm/modules/VelocityOverLifetime.js +75 -73
  130. package/dist/esm/modules/VelocityOverLifetime.js.map +1 -1
  131. package/dist/esm/modules/WgslContribution.d.ts +16 -15
  132. package/dist/esm/modules/WgslContribution.d.ts.map +1 -0
  133. package/dist/esm/modules/WgslContribution.js +37 -29
  134. package/dist/esm/modules/WgslContribution.js.map +1 -1
  135. package/dist/esm/modules/index.d.ts +3 -1
  136. package/dist/esm/modules/index.d.ts.map +1 -0
  137. package/dist/esm/modules/spawnFields.d.ts +32 -0
  138. package/dist/esm/modules/spawnFields.d.ts.map +1 -0
  139. package/dist/esm/modules/spawnFields.js +31 -0
  140. package/dist/esm/modules/spawnFields.js.map +1 -0
  141. package/dist/esm/particlesBuildInfo.d.ts +1 -0
  142. package/dist/esm/particlesBuildInfo.d.ts.map +1 -0
  143. package/dist/esm/particlesBuildInfo.js +6 -4
  144. package/dist/esm/particlesBuildInfo.js.map +1 -1
  145. package/dist/esm/particlesExtension.d.ts +13 -5
  146. package/dist/esm/particlesExtension.d.ts.map +1 -0
  147. package/dist/esm/particlesExtension.js +38 -42
  148. package/dist/esm/particlesExtension.js.map +1 -1
  149. package/dist/esm/public.d.ts +11 -0
  150. package/dist/esm/public.d.ts.map +1 -0
  151. package/dist/esm/renderModes/MeshParticles.d.ts +128 -0
  152. package/dist/esm/renderModes/MeshParticles.d.ts.map +1 -0
  153. package/dist/esm/renderModes/MeshParticles.js +263 -0
  154. package/dist/esm/renderModes/MeshParticles.js.map +1 -0
  155. package/dist/esm/renderModes/ParticleBufferLayout.d.ts +59 -0
  156. package/dist/esm/renderModes/ParticleBufferLayout.d.ts.map +1 -0
  157. package/dist/esm/renderModes/ParticleBufferLayout.js +99 -0
  158. package/dist/esm/renderModes/ParticleBufferLayout.js.map +1 -0
  159. package/dist/esm/renderModes/ParticleInstanceWriter.d.ts +58 -0
  160. package/dist/esm/renderModes/ParticleInstanceWriter.d.ts.map +1 -0
  161. package/dist/esm/renderModes/ParticleInstanceWriter.js +173 -0
  162. package/dist/esm/renderModes/ParticleInstanceWriter.js.map +1 -0
  163. package/dist/esm/renderModes/ParticleMaterial.d.ts +18 -0
  164. package/dist/esm/renderModes/ParticleMaterial.d.ts.map +1 -0
  165. package/dist/esm/renderModes/ParticleMaterial.js +24 -0
  166. package/dist/esm/renderModes/ParticleMaterial.js.map +1 -0
  167. package/dist/esm/renderModes/ParticleRenderMode.d.ts +114 -0
  168. package/dist/esm/renderModes/ParticleRenderMode.d.ts.map +1 -0
  169. package/dist/esm/renderModes/ParticleRenderMode.js +99 -0
  170. package/dist/esm/renderModes/ParticleRenderMode.js.map +1 -0
  171. package/dist/esm/renderModes/QuadParticles.d.ts +55 -0
  172. package/dist/esm/renderModes/QuadParticles.d.ts.map +1 -0
  173. package/dist/esm/renderModes/QuadParticles.js +95 -0
  174. package/dist/esm/renderModes/QuadParticles.js.map +1 -0
  175. package/dist/esm/renderModes/RibbonParticles.d.ts +96 -0
  176. package/dist/esm/renderModes/RibbonParticles.d.ts.map +1 -0
  177. package/dist/esm/renderModes/RibbonParticles.js +202 -0
  178. package/dist/esm/renderModes/RibbonParticles.js.map +1 -0
  179. package/dist/esm/renderModes/shaders/mesh-particles.wgsl.js +6 -0
  180. package/dist/esm/renderModes/shaders/mesh-particles.wgsl.js.map +1 -0
  181. package/dist/esm/renderModes/shaders/mesh.vert.js +6 -0
  182. package/dist/esm/renderModes/shaders/mesh.vert.js.map +1 -0
  183. package/dist/esm/renderModes/shaders/quad-particles.wgsl.js +6 -0
  184. package/dist/esm/renderModes/shaders/quad-particles.wgsl.js.map +1 -0
  185. package/dist/esm/renderModes/shaders/ribbon-particles.wgsl.js +6 -0
  186. package/dist/esm/renderModes/shaders/ribbon-particles.wgsl.js.map +1 -0
  187. package/dist/esm/renderModes/shaders/ribbon.frag.js +6 -0
  188. package/dist/esm/renderModes/shaders/ribbon.frag.js.map +1 -0
  189. package/dist/esm/renderModes/shaders/ribbon.vert.js +6 -0
  190. package/dist/esm/renderModes/shaders/ribbon.vert.js.map +1 -0
  191. package/dist/esm/renderers/WebGl2ParticleRenderer.d.ts +48 -20
  192. package/dist/esm/renderers/WebGl2ParticleRenderer.d.ts.map +1 -0
  193. package/dist/esm/renderers/WebGl2ParticleRenderer.js +317 -321
  194. package/dist/esm/renderers/WebGl2ParticleRenderer.js.map +1 -1
  195. package/dist/esm/renderers/WebGpuParticleRenderer.d.ts +99 -18
  196. package/dist/esm/renderers/WebGpuParticleRenderer.d.ts.map +1 -0
  197. package/dist/esm/renderers/WebGpuParticleRenderer.js +542 -551
  198. package/dist/esm/renderers/WebGpuParticleRenderer.js.map +1 -1
  199. package/dist/esm/renderers/shaders/particle.frag.js +6 -0
  200. package/dist/esm/renderers/shaders/particle.frag.js.map +1 -0
  201. package/dist/esm/renderers/shaders/particle.vert.js +6 -0
  202. package/dist/esm/renderers/shaders/particle.vert.js.map +1 -0
  203. package/package.json +8 -15
  204. package/dist/esm/index.js.map +0 -1
  205. package/dist/esm/register.d.ts +0 -1
  206. package/dist/esm/register.js +0 -43
  207. package/dist/esm/register.js.map +0 -1
  208. package/dist/esm/renderers/glsl/particle.frag.js +0 -4
  209. package/dist/esm/renderers/glsl/particle.frag.js.map +0 -1
  210. package/dist/esm/renderers/glsl/particle.vert.js +0 -4
  211. package/dist/esm/renderers/glsl/particle.vert.js.map +0 -1
@@ -1,417 +1,569 @@
1
- import { wgslUniformByteSize } from '../modules/WgslContribution.js';
1
+ import { getWgslUniformByteSize } from "../modules/WgslContribution.js";
2
+ import particle_simulate_default from "./shaders/particle-simulate.wgsl.js";
3
+ import { WebGpuComputePipeline, WebGpuStorageBuffer, WebGpuUniformBuffer, fillShaderSource, reflectComputeBindings } from "@codexo/exojs/renderer-sdk";
2
4
 
3
- /// <reference types="@webgpu/types" />
5
+ //#region src/gpu/ParticleGpuState.ts
4
6
  /**
5
- * GPU-side mirror of one {@link ParticleSystem}. Owns:
6
- *
7
- * - **8 packed storage buffers** for the per-particle SoA data:
8
- * positions/velocities/scales/rotInfo/timing as `vec2<f32>`, color and
9
- * textureIndex as `u32`, plus the instance output buffer. Sits at the
10
- * default WebGPU `maxStorageBuffersPerShaderStage = 8` limit.
11
- * - **One uniform buffer** for sim state (`dt`, `liveCount`).
12
- * - **One uniform buffer** for module configs (concatenated per-module
13
- * structs with WGSL std140-ish alignment).
14
- * - **One uniform buffer** for frame UVs `array<vec4<f32>, N>` where N
15
- * is the system's frame count (or 1 when no atlas is declared). Each
16
- * vec4 is `(uvMinX, uvMinY, uvMaxX, uvMaxY)` already flipY-adjusted.
17
- * - **N 1D textures** for modules that use lookup tables (Curve / ColorGradient).
18
- * - **Composite compute pipeline** built once at construction by
19
- * concatenating the integration step + every registered module body +
20
- * the pack-instances step into a single shader.
21
- *
22
- * The compute shader's pack-instances step reads `textureIndex[i]`, looks
23
- * up the matching frame UV, and writes a 40-byte interleaved record into
24
- * the instance output buffer (`STORAGE | VERTEX`). The renderer binds that
25
- * buffer directly as instanced vertex source — no readback.
26
- */
7
+ * GPU-side mirror of one {@link ParticleSystem}. Owns:
8
+ *
9
+ * - **8 packed storage buffers** for the per-particle SoA data:
10
+ * positions/velocities/scales/rotInfo/timing as `vec2<f32>`, color and
11
+ * textureIndex as `u32`, plus the instance output buffer. Sits at the
12
+ * default WebGPU `maxStorageBuffersPerShaderStage = 8` limit. Built on
13
+ * the shared {@link WebGpuStorageBuffer} SDK primitive.
14
+ * - **Three uniform buffers** (sim state `dt`/`liveCount`, module configs -
15
+ * concatenated per-module structs with WGSL std140-ish alignment - and
16
+ * frame UVs, `array<vec4<f32>, N>` where N is the system's frame count or
17
+ * 1 when no atlas is declared, each vec4 `(uvMinX, uvMinY, uvMaxX, uvMaxY)`
18
+ * already flipY-adjusted), built on the shared {@link WebGpuUniformBuffer}
19
+ * SDK primitive.
20
+ * - **N 1D textures** for modules that use lookup tables (Curve / ColorGradient).
21
+ * - **Composite compute pipeline** built once at construction by
22
+ * concatenating the integration step + every registered module body +
23
+ * the pack-instances step into a single shader, via the shared
24
+ * {@link WebGpuComputePipeline} SDK primitive (two bind groups: group 0
25
+ * holds uniforms + module lookup textures/samplers, group 1 holds the
26
+ * 8 SoA storage buffers). The bind-group *layouts* are derived straight
27
+ * from the shader's own `@group`/`@binding` declarations via
28
+ * {@link reflectComputeBindings} - no hand-written binding list kept in
29
+ * sync with the WGSL text by hand.
30
+ *
31
+ * The compute shader's pack-instances step reads `textureIndex[i]`, looks
32
+ * up the matching frame UV, and writes a 40-byte interleaved record into
33
+ * the instance output buffer (`STORAGE | VERTEX`). The renderer binds that
34
+ * buffer directly as instanced vertex source - no readback.
35
+ */
27
36
  const workgroupSize = 64;
28
- const instanceBytes = 40; // 5 × f32 + 1 × u32 + 4 × f32 (uvMin.xy, uvMax.xy)
29
- class ParticleGpuState {
30
- device;
31
- capacity;
32
- /** GPU buffer holding interleaved per-instance vertex data, written by compute, read as VERTEX by the renderer. */
33
- instanceBuffer;
34
- _positions;
35
- _velocities;
36
- _scales;
37
- _rotInfo;
38
- _timing;
39
- _color;
40
- _textureIndex;
41
- _simUniformBuffer;
42
- _simUniformData = new ArrayBuffer(16);
43
- _simUniformView;
44
- _moduleUniformBuffer;
45
- _moduleUniformData;
46
- _moduleUniformView;
47
- _moduleSlots;
48
- _framesUniformBuffer;
49
- _framesUniformData;
50
- _framesUniformView;
51
- _frameCount;
52
- _moduleTextures = new Map();
53
- _samplerFiltering;
54
- _samplerNonFiltering;
55
- _pipeline;
56
- _bindGroup0;
57
- _bindGroup1;
58
- constructor(device, capacity, modules, frames, texture) {
59
- this.device = device;
60
- this.capacity = capacity;
61
- for (const m of modules) {
62
- if (!m.wgsl) {
63
- throw new Error(`ParticleGpuState: module ${m.constructor.name} has no wgsl() ` + 'all registered UpdateModules must be GPU-eligible.');
64
- }
65
- }
66
- // Module uniform layout.
67
- const slots = [];
68
- let uniformOffset = 0;
69
- for (const m of modules) {
70
- const c = m.wgsl();
71
- const fields = c.uniforms ?? [];
72
- const size = wgslUniformByteSize(fields);
73
- uniformOffset = Math.ceil(uniformOffset / 16) * 16;
74
- slots.push({
75
- module: m,
76
- contribution: c,
77
- uniformByteOffset: uniformOffset,
78
- uniformByteSize: size,
79
- });
80
- uniformOffset += size;
81
- }
82
- const totalUniformBytes = Math.max(16, Math.ceil(uniformOffset / 16) * 16);
83
- this._moduleSlots = slots;
84
- if (uniformOffset > 0) {
85
- this._moduleUniformData = new ArrayBuffer(totalUniformBytes);
86
- this._moduleUniformView = new DataView(this._moduleUniformData);
87
- this._moduleUniformBuffer = device.createBuffer({
88
- label: 'particle-module-uniforms',
89
- size: totalUniformBytes,
90
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
91
- });
92
- }
93
- else {
94
- this._moduleUniformData = null;
95
- this._moduleUniformView = null;
96
- this._moduleUniformBuffer = null;
97
- }
98
- // Frames uniform buffer.
99
- this._frameCount = Math.max(1, frames.length);
100
- this._framesUniformData = new ArrayBuffer(this._frameCount * 16);
101
- this._framesUniformView = new Float32Array(this._framesUniformData);
102
- this._framesUniformBuffer = device.createBuffer({
103
- label: 'particle-frames-uniforms',
104
- size: this._framesUniformData.byteLength,
105
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
106
- });
107
- this._writeFrames(frames, texture);
108
- const vec2Bytes = capacity * 8;
109
- const u32Bytes = capacity * 4;
110
- this._positions = device.createBuffer({
111
- label: 'particle-positions',
112
- size: vec2Bytes,
113
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
114
- });
115
- this._velocities = device.createBuffer({
116
- label: 'particle-velocities',
117
- size: vec2Bytes,
118
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
119
- });
120
- this._scales = device.createBuffer({
121
- label: 'particle-scales',
122
- size: vec2Bytes,
123
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
124
- });
125
- this._rotInfo = device.createBuffer({
126
- label: 'particle-rotInfo',
127
- size: vec2Bytes,
128
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
129
- });
130
- this._timing = device.createBuffer({
131
- label: 'particle-timing',
132
- size: vec2Bytes,
133
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
134
- });
135
- this._color = device.createBuffer({
136
- label: 'particle-color',
137
- size: u32Bytes,
138
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
139
- });
140
- this._textureIndex = device.createBuffer({
141
- label: 'particle-textureIndex',
142
- size: u32Bytes,
143
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
144
- });
145
- this.instanceBuffer = device.createBuffer({
146
- label: 'particle-instance-output',
147
- size: capacity * instanceBytes,
148
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
149
- });
150
- this._simUniformView = new DataView(this._simUniformData);
151
- this._simUniformBuffer = device.createBuffer({
152
- label: 'particle-sim-uniforms',
153
- size: 16,
154
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
155
- });
156
- // r32float textures aren't filterable in core WebGPU (would require
157
- // the optional `float32-filterable` feature). Use `nearest` for
158
- // r32float curve LUTs (256 taps is fine without interpolation) and
159
- // `linear` for rgba8unorm gradients which support filtering natively.
160
- this._samplerFiltering = device.createSampler({
161
- label: 'particle-lookup-sampler-filtering',
162
- minFilter: 'linear',
163
- magFilter: 'linear',
164
- addressModeU: 'clamp-to-edge',
165
- });
166
- this._samplerNonFiltering = device.createSampler({
167
- label: 'particle-lookup-sampler-non-filtering',
168
- minFilter: 'nearest',
169
- magFilter: 'nearest',
170
- addressModeU: 'clamp-to-edge',
171
- });
172
- // Allocate textures for modules that need them.
173
- for (const slot of slots) {
174
- const c = slot.contribution;
175
- if (!c.textures)
176
- continue;
177
- for (const t of c.textures) {
178
- const tex = device.createTexture({
179
- label: `particle-tex-${c.key}-${t.name}`,
180
- size: { width: 256, height: 1, depthOrArrayLayers: 1 },
181
- format: t.format,
182
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
183
- dimension: '1d',
184
- });
185
- this._moduleTextures.set(`${c.key}_${t.name}`, tex);
186
- }
187
- }
188
- const wgsl = this._buildShader(slots);
189
- const bindGroup0Layout = this._buildBindGroup0Layout(slots);
190
- const bindGroup1Layout = device.createBindGroupLayout({
191
- label: 'particle-soa-bgl',
192
- entries: [
193
- { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
194
- { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
195
- { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
196
- { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
197
- { binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
198
- { binding: 5, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
199
- { binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } }, // textureIndex (matches WGSL `var<storage, read>`)
200
- { binding: 7, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
201
- ],
202
- });
203
- const pipelineLayout = device.createPipelineLayout({
204
- label: 'particle-compute-layout',
205
- bindGroupLayouts: [bindGroup0Layout, bindGroup1Layout],
206
- });
207
- const shaderModule = device.createShaderModule({
208
- label: 'particle-compute-shader',
209
- code: wgsl,
210
- });
211
- this._pipeline = device.createComputePipeline({
212
- label: 'particle-compute-pipeline',
213
- layout: pipelineLayout,
214
- compute: {
215
- module: shaderModule,
216
- entryPoint: 'main',
217
- },
218
- });
219
- this._bindGroup0 = this._buildBindGroup0(bindGroup0Layout, slots);
220
- this._bindGroup1 = device.createBindGroup({
221
- label: 'particle-soa-bg',
222
- layout: bindGroup1Layout,
223
- entries: [
224
- { binding: 0, resource: { buffer: this._positions } },
225
- { binding: 1, resource: { buffer: this._velocities } },
226
- { binding: 2, resource: { buffer: this._scales } },
227
- { binding: 3, resource: { buffer: this._rotInfo } },
228
- { binding: 4, resource: { buffer: this._timing } },
229
- { binding: 5, resource: { buffer: this._color } },
230
- { binding: 6, resource: { buffer: this._textureIndex } },
231
- { binding: 7, resource: { buffer: this.instanceBuffer } },
232
- ],
233
- });
234
- // Modules upload their lookup textures.
235
- for (const slot of slots) {
236
- if (!slot.module.uploadTextures)
237
- continue;
238
- const moduleTextures = new Map();
239
- for (const t of slot.contribution.textures ?? []) {
240
- const tex = this._moduleTextures.get(`${slot.contribution.key}_${t.name}`);
241
- if (tex !== undefined) {
242
- moduleTextures.set(t.name, tex);
243
- }
244
- }
245
- slot.module.uploadTextures(device, moduleTextures);
246
- }
247
- }
248
- dispatch(system, dt) {
249
- const liveCount = system.liveCount;
250
- if (liveCount <= 0) {
251
- return;
252
- }
253
- this._writeSimUniforms(dt, liveCount);
254
- this._writeModuleUniforms(dt);
255
- const encoder = this.device.createCommandEncoder({ label: 'particle-compute' });
256
- const pass = encoder.beginComputePass({ label: 'particle-compute-pass' });
257
- pass.setPipeline(this._pipeline);
258
- pass.setBindGroup(0, this._bindGroup0);
259
- pass.setBindGroup(1, this._bindGroup1);
260
- pass.dispatchWorkgroups(Math.ceil(liveCount / workgroupSize));
261
- pass.end();
262
- this.device.queue.submit([encoder.finish()]);
263
- }
264
- destroy() {
265
- this._positions.destroy();
266
- this._velocities.destroy();
267
- this._scales.destroy();
268
- this._rotInfo.destroy();
269
- this._timing.destroy();
270
- this._color.destroy();
271
- this._textureIndex.destroy();
272
- this.instanceBuffer.destroy();
273
- this._simUniformBuffer.destroy();
274
- this._framesUniformBuffer.destroy();
275
- this._moduleUniformBuffer?.destroy();
276
- for (const tex of this._moduleTextures.values()) {
277
- tex.destroy();
278
- }
279
- this._moduleTextures.clear();
280
- }
281
- _writeFrames(frames, texture) {
282
- const view = this._framesUniformView;
283
- const w = texture.width;
284
- const h = texture.height;
285
- const flipY = texture.flipY;
286
- if (frames.length === 0) {
287
- // Single-frame fallback — full texture.
288
- view[0] = 0;
289
- view[1] = flipY ? 1 : 0;
290
- view[2] = 1;
291
- view[3] = flipY ? 0 : 1;
292
- }
293
- else {
294
- for (let i = 0; i < frames.length; i++) {
295
- const f = frames[i];
296
- const o = i * 4;
297
- const minU = f.left / w;
298
- const maxU = f.right / w;
299
- const topV = f.top / h;
300
- const bottomV = f.bottom / h;
301
- view[o + 0] = minU;
302
- view[o + 1] = flipY ? bottomV : topV;
303
- view[o + 2] = maxU;
304
- view[o + 3] = flipY ? topV : bottomV;
305
- }
306
- }
307
- this.device.queue.writeBuffer(this._framesUniformBuffer, 0, this._framesUniformData);
308
- }
309
- /**
310
- * Push the listed CPU SoA slots to the GPU. Called by `ParticleSystem`
311
- * with newly-spawned slots and just-expired slots (lifetime sentinel).
312
- * Slots not in the dirty set are left alone — GPU keeps the integrated
313
- * state from previous compute dispatches.
314
- *
315
- * Each dirty slot triggers 7 small `queue.writeBuffer` calls (one per
316
- * SoA channel). For typical spawn rates (≤200/s) this is negligible
317
- * (≤1400 calls/s); contiguous-range batching is a future optimisation.
318
- */
319
- uploadDirty(system, slots) {
320
- const queue = this.device.queue;
321
- const scratch2 = this._dirtyScratchVec2;
322
- const scratch1 = this._dirtyScratchU32;
323
- for (const slot of slots) {
324
- const byteOffset2 = slot * 8;
325
- const byteOffset1 = slot * 4;
326
- scratch2[0] = system.posX[slot];
327
- scratch2[1] = system.posY[slot];
328
- queue.writeBuffer(this._positions, byteOffset2, scratch2.buffer, 0, 8);
329
- scratch2[0] = system.velX[slot];
330
- scratch2[1] = system.velY[slot];
331
- queue.writeBuffer(this._velocities, byteOffset2, scratch2.buffer, 0, 8);
332
- scratch2[0] = system.scaleX[slot];
333
- scratch2[1] = system.scaleY[slot];
334
- queue.writeBuffer(this._scales, byteOffset2, scratch2.buffer, 0, 8);
335
- scratch2[0] = system.rotations[slot];
336
- scratch2[1] = system.rotationSpeeds[slot];
337
- queue.writeBuffer(this._rotInfo, byteOffset2, scratch2.buffer, 0, 8);
338
- scratch2[0] = system.elapsed[slot];
339
- scratch2[1] = system.lifetime[slot];
340
- queue.writeBuffer(this._timing, byteOffset2, scratch2.buffer, 0, 8);
341
- scratch1[0] = system.color[slot];
342
- queue.writeBuffer(this._color, byteOffset1, scratch1.buffer, 0, 4);
343
- scratch1[0] = system.textureIndex[slot];
344
- queue.writeBuffer(this._textureIndex, byteOffset1, scratch1.buffer, 0, 4);
345
- }
346
- }
347
- _dirtyScratchVec2 = new Float32Array(2);
348
- _dirtyScratchU32 = new Uint32Array(1);
349
- _writeSimUniforms(dt, liveCount) {
350
- this._simUniformView.setFloat32(0, dt, true);
351
- this._simUniformView.setUint32(4, liveCount, true);
352
- this.device.queue.writeBuffer(this._simUniformBuffer, 0, this._simUniformData);
353
- }
354
- _writeModuleUniforms(dt) {
355
- if (this._moduleUniformView === null || this._moduleUniformBuffer === null || this._moduleUniformData === null) {
356
- return;
357
- }
358
- for (const slot of this._moduleSlots) {
359
- slot.module.writeUniforms?.(this._moduleUniformView, slot.uniformByteOffset, dt);
360
- }
361
- this.device.queue.writeBuffer(this._moduleUniformBuffer, 0, this._moduleUniformData);
362
- }
363
- _buildBindGroup0Layout(slots) {
364
- const entries = [
365
- { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
366
- { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
367
- ];
368
- if (this._moduleUniformBuffer !== null) {
369
- entries.push({ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } });
370
- }
371
- let textureBindingIndex = this._moduleUniformBuffer !== null ? 3 : 2;
372
- for (const slot of slots) {
373
- for (const t of slot.contribution.textures ?? []) {
374
- const filterable = t.format !== 'r32float';
375
- entries.push({
376
- binding: textureBindingIndex++,
377
- visibility: GPUShaderStage.COMPUTE,
378
- texture: {
379
- viewDimension: '1d',
380
- sampleType: filterable ? 'float' : 'unfilterable-float',
381
- },
382
- });
383
- entries.push({
384
- binding: textureBindingIndex++,
385
- visibility: GPUShaderStage.COMPUTE,
386
- sampler: { type: filterable ? 'filtering' : 'non-filtering' },
387
- });
388
- }
389
- }
390
- return this.device.createBindGroupLayout({ label: 'particle-uniforms-bgl', entries });
391
- }
392
- _buildBindGroup0(layout, slots) {
393
- const entries = [
394
- { binding: 0, resource: { buffer: this._simUniformBuffer } },
395
- { binding: 1, resource: { buffer: this._framesUniformBuffer } },
396
- ];
397
- if (this._moduleUniformBuffer !== null) {
398
- entries.push({ binding: 2, resource: { buffer: this._moduleUniformBuffer } });
399
- }
400
- let textureBindingIndex = this._moduleUniformBuffer !== null ? 3 : 2;
401
- for (const slot of slots) {
402
- for (const t of slot.contribution.textures ?? []) {
403
- const tex = this._moduleTextures.get(`${slot.contribution.key}_${t.name}`);
404
- const filterable = t.format !== 'r32float';
405
- const sampler = filterable ? this._samplerFiltering : this._samplerNonFiltering;
406
- entries.push({ binding: textureBindingIndex++, resource: tex.createView({ dimension: '1d' }) });
407
- entries.push({ binding: textureBindingIndex++, resource: sampler });
408
- }
409
- }
410
- return this.device.createBindGroup({ label: 'particle-uniforms-bg', layout, entries });
411
- }
412
- _buildShader(slots) {
413
- const sections = [];
414
- sections.push(`
37
+ const instanceBytes = 40;
38
+ /** 8 x f32 (position, velocity, rotation, scale, elapsed) + 2 x u32 (color, slot). */
39
+ const deathRecordBytes = 40;
40
+ const deathStagingSlots = 3;
41
+ const minDeathStagingRecords = 32;
42
+ const deathRecordFloats = 10;
43
+ var ParticleGpuState = class {
44
+ device;
45
+ capacity;
46
+ /** GPU buffer holding interleaved per-instance vertex data, written by compute, read as VERTEX by the renderer. */
47
+ instanceBuffer;
48
+ _positions;
49
+ _velocities;
50
+ _scales;
51
+ _rotInfo;
52
+ _timing;
53
+ _color;
54
+ _instanceStorageBuffer;
55
+ /**
56
+ * Death records the compute shader appended, plus the atomic append counter
57
+ * in its first four bytes. Allocated only while the system has death modules,
58
+ * so a system without them pays neither the memory nor the copy.
59
+ *
60
+ * Detecting a death and reporting one have separate lifetimes, and the path
61
+ * between them has four stages: the shader appends here; records the copy has
62
+ * not taken yet stay here as a device-side backlog; a copy moves a batch into
63
+ * a staging slot; and the mapped batches are handed on in submission order.
64
+ */
65
+ _deathBuffer = null;
66
+ _deathCounterReset = /* @__PURE__ */ new Uint32Array(1);
67
+ /**
68
+ * Readback slots for the death buffer. A slot stays unavailable from the
69
+ * submit that copies into it until its map resolves, so a single slot would
70
+ * make every death that happens while a readback is in flight either a
71
+ * validation error or a lost record. Three slots cover the queue depth a
72
+ * frame loop runs at; a fourth death batch waits on the device instead.
73
+ */
74
+ _deathStaging = [];
75
+ /** Copies submitted into a staging slot, oldest first, awaiting delivery. */
76
+ _stagedDeaths = [];
77
+ /**
78
+ * Whether the death buffer holds records no readback has claimed yet. The
79
+ * append counter is reset only once a copy has taken them, so a step that
80
+ * finds no free staging slot leaves its records in place and the next step
81
+ * appends behind them.
82
+ */
83
+ _deathBufferDirty = false;
84
+ /** Tail of the delivery chain, so batches are reported in submission order. */
85
+ _deathDelivery = Promise.resolve();
86
+ _simUniformBuffer;
87
+ _simUniformData = /* @__PURE__ */ new ArrayBuffer(16);
88
+ _simUniformView;
89
+ _moduleUniformBuffer = null;
90
+ _moduleUniformData = null;
91
+ _moduleUniformView = null;
92
+ _moduleSlots = [];
93
+ _framesUniformBuffer;
94
+ _framesUniformData;
95
+ _framesUniformView;
96
+ _frameCount;
97
+ _moduleTextures = /* @__PURE__ */ new Map();
98
+ _samplerFiltering;
99
+ _samplerNonFiltering;
100
+ _pipelineWrapper = null;
101
+ _bindGroup0 = null;
102
+ _bindGroup1 = null;
103
+ _reportsDeaths = false;
104
+ _destroyed = false;
105
+ constructor(device, capacity, modules, frames, texture, reportsDeaths = false) {
106
+ this.device = device;
107
+ this.capacity = capacity;
108
+ this._frameCount = Math.max(1, frames.length);
109
+ this._framesUniformData = /* @__PURE__ */ new ArrayBuffer(this._frameCount * 16);
110
+ this._framesUniformView = new Float32Array(this._framesUniformData);
111
+ this._framesUniformBuffer = new WebGpuUniformBuffer(device, this._framesUniformData.byteLength, "particle-frames-uniforms");
112
+ this._writeFrames(frames, texture);
113
+ const vec2Bytes = capacity * 8;
114
+ const vec4Bytes = capacity * 16;
115
+ const u32Bytes = capacity * 4;
116
+ this._positions = new WebGpuStorageBuffer(device, vec2Bytes, "particle-positions");
117
+ this._velocities = new WebGpuStorageBuffer(device, vec2Bytes, "particle-velocities");
118
+ this._scales = new WebGpuStorageBuffer(device, vec2Bytes, "particle-scales");
119
+ this._rotInfo = new WebGpuStorageBuffer(device, vec4Bytes, "particle-rotInfo");
120
+ this._timing = new WebGpuStorageBuffer(device, vec2Bytes, "particle-timing");
121
+ this._color = new WebGpuStorageBuffer(device, u32Bytes, "particle-color");
122
+ this._instanceStorageBuffer = new WebGpuStorageBuffer(device, capacity * instanceBytes, "particle-instance-output", GPUBufferUsage.VERTEX);
123
+ this.instanceBuffer = this._instanceStorageBuffer.buffer;
124
+ this._simUniformView = new DataView(this._simUniformData);
125
+ this._simUniformBuffer = new WebGpuUniformBuffer(device, 16, "particle-sim-uniforms");
126
+ this._samplerFiltering = device.createSampler({
127
+ label: "particle-lookup-sampler-filtering",
128
+ minFilter: "linear",
129
+ magFilter: "linear",
130
+ addressModeU: "clamp-to-edge"
131
+ });
132
+ this._samplerNonFiltering = device.createSampler({
133
+ label: "particle-lookup-sampler-non-filtering",
134
+ minFilter: "nearest",
135
+ magFilter: "nearest",
136
+ addressModeU: "clamp-to-edge"
137
+ });
138
+ this.setProgram(modules, reportsDeaths);
139
+ }
140
+ /**
141
+ * Compiles the pipeline for `modules` and binds it to the buffers this state
142
+ * already owns.
143
+ *
144
+ * The simulation lives in those buffers, not in the program, so changing the
145
+ * module list rebuilds the shader, its uniforms and its lookup textures while
146
+ * every live particle keeps the position and velocity the GPU last integrated.
147
+ */
148
+ setProgram(modules, reportsDeaths) {
149
+ for (const m of modules) if (!m.wgsl) throw new Error(`ParticleGpuState: module ${m.constructor.name} has no wgsl() - all registered UpdateModules must be GPU-eligible.`);
150
+ this._destroyProgram();
151
+ this._reportsDeaths = reportsDeaths;
152
+ const slots = [];
153
+ let uniformOffset = 0;
154
+ for (const m of modules) {
155
+ const c = m.wgsl();
156
+ const fields = c.uniforms ?? [];
157
+ const size = getWgslUniformByteSize(fields);
158
+ uniformOffset = Math.ceil(uniformOffset / 16) * 16;
159
+ slots.push({
160
+ module: m,
161
+ contribution: c,
162
+ uniformByteOffset: uniformOffset,
163
+ uniformByteSize: size
164
+ });
165
+ uniformOffset += size;
166
+ }
167
+ const totalUniformBytes = Math.max(16, Math.ceil(uniformOffset / 16) * 16);
168
+ this._moduleSlots = slots;
169
+ if (uniformOffset > 0) {
170
+ this._moduleUniformData = new ArrayBuffer(totalUniformBytes);
171
+ this._moduleUniformView = new DataView(this._moduleUniformData);
172
+ this._moduleUniformBuffer = new WebGpuUniformBuffer(this.device, totalUniformBytes, "particle-module-uniforms");
173
+ }
174
+ if (reportsDeaths && this._deathBuffer === null) this._deathBuffer = new WebGpuStorageBuffer(this.device, 4 + this.capacity * deathRecordBytes, "particle-deaths", GPUBufferUsage.COPY_SRC);
175
+ for (const slot of slots) {
176
+ const c = slot.contribution;
177
+ if (!c.textures) continue;
178
+ for (const t of c.textures) {
179
+ const tex = this.device.createTexture({
180
+ label: `particle-tex-${c.key}-${t.name}`,
181
+ size: {
182
+ width: 256,
183
+ height: 1,
184
+ depthOrArrayLayers: 1
185
+ },
186
+ format: t.format,
187
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
188
+ dimension: "1d"
189
+ });
190
+ this._moduleTextures.set(`${c.key}_${t.name}`, tex);
191
+ }
192
+ }
193
+ const wgsl = this._buildShader(slots);
194
+ this._pipelineWrapper = WebGpuComputePipeline.create(this.device, {
195
+ wgsl,
196
+ workgroupSize,
197
+ bindingGroups: reflectComputeBindings(wgsl, { nonFilteringResources: this._nonFilteringResourceNames(slots) }),
198
+ label: "particle-compute"
199
+ });
200
+ this._bindGroup0 = this._pipelineWrapper.createBindGroup(0, this._buildBindGroup0Entries(slots), "particle-uniforms-bg");
201
+ this._bindGroup1 = this._pipelineWrapper.createBindGroup(1, this._buildSoaBindGroupEntries(), "particle-soa-bg");
202
+ for (const slot of slots) {
203
+ if (!slot.module.uploadTextures) continue;
204
+ const moduleTextures = /* @__PURE__ */ new Map();
205
+ for (const t of slot.contribution.textures ?? []) {
206
+ const tex = this._moduleTextures.get(`${slot.contribution.key}_${t.name}`);
207
+ if (tex !== void 0) moduleTextures.set(t.name, tex);
208
+ }
209
+ slot.module.uploadTextures(this.device, moduleTextures);
210
+ }
211
+ }
212
+ /** Releases everything the current program owns, leaving the simulation buffers untouched. */
213
+ _destroyProgram() {
214
+ this._moduleUniformBuffer?.destroy();
215
+ this._moduleUniformBuffer = null;
216
+ this._moduleUniformData = null;
217
+ this._moduleUniformView = null;
218
+ this._moduleSlots = [];
219
+ for (const tex of this._moduleTextures.values()) tex.destroy();
220
+ this._moduleTextures.clear();
221
+ this._pipelineWrapper = null;
222
+ this._bindGroup0 = null;
223
+ this._bindGroup1 = null;
224
+ }
225
+ /**
226
+ * Runs one simulation step over `[0, dispatchCount)`.
227
+ *
228
+ * `pendingDeaths` is how many expired particles the caller is still holding,
229
+ * including the ones it marked for this step. The shader appends exactly one
230
+ * record per marked particle, so the readback copies the used prefix of the
231
+ * death buffer without first having to read its counter back - the count is
232
+ * already known on this side.
233
+ *
234
+ * Returns whether a death batch was staged for readback. Deaths stay on the
235
+ * device while every staging slot is in flight, so a step can report deaths
236
+ * without staging any, and a later step can stage a batch spanning several
237
+ * steps. A caller keeping per-death state has to hold it, and keep counting it
238
+ * into `pendingDeaths`, until a step stages - then hand over all of it.
239
+ */
240
+ dispatch(dt, dispatchCount, pendingDeaths = 0) {
241
+ const pipeline = this._pipelineWrapper;
242
+ const bindGroup0 = this._bindGroup0;
243
+ const bindGroup1 = this._bindGroup1;
244
+ if (pipeline === null || bindGroup0 === null || bindGroup1 === null) return false;
245
+ const simulating = dispatchCount > 0;
246
+ const reporting = pendingDeaths > 0 && this._reportsDeaths && this._deathBuffer !== null;
247
+ if (!simulating && !reporting) return false;
248
+ if (simulating) {
249
+ this._writeSimUniforms(dt, dispatchCount);
250
+ this._writeModuleUniforms(dt);
251
+ }
252
+ if (reporting && !this._deathBufferDirty) {
253
+ this._deathCounterReset[0] = 0;
254
+ this._deathBuffer.write(this._deathCounterReset, 0);
255
+ }
256
+ const encoder = this.device.createCommandEncoder({ label: "particle-compute" });
257
+ if (simulating) {
258
+ const pass = encoder.beginComputePass({ label: "particle-compute-pass" });
259
+ pipeline.dispatch(pass, dispatchCount, [bindGroup0, bindGroup1]);
260
+ pass.end();
261
+ }
262
+ let staged = false;
263
+ if (reporting) {
264
+ this._deathBufferDirty = true;
265
+ const count = Math.min(pendingDeaths, this.capacity);
266
+ const slot = this._acquireDeathStaging(count);
267
+ if (slot !== null) {
268
+ encoder.copyBufferToBuffer(this._deathBuffer.buffer, 4, slot.buffer, 0, count * deathRecordBytes);
269
+ slot.busy = true;
270
+ this._stagedDeaths.push({
271
+ slot,
272
+ count
273
+ });
274
+ this._deathBufferDirty = false;
275
+ staged = true;
276
+ }
277
+ }
278
+ this.device.queue.submit([encoder.finish()]);
279
+ return staged;
280
+ }
281
+ /**
282
+ * Resolves the oldest staged death batch and hands its records to `receive`
283
+ * once the copy has landed.
284
+ *
285
+ * The mapping is asynchronous by nature, so a death is delivered no earlier
286
+ * than the frame after it happened. Batches are delivered in the order they
287
+ * were submitted, and nothing blocks on a map: further steps keep staging
288
+ * into other slots while one is in flight. A device that goes away mid-map
289
+ * resolves to nothing rather than throwing: the deaths are lost with the
290
+ * simulation that produced them.
291
+ */
292
+ readDeaths(receive) {
293
+ const batch = this._stagedDeaths.shift();
294
+ if (batch === void 0 || this._destroyed) return Promise.resolve();
295
+ const previous = this._deathDelivery;
296
+ let settled;
297
+ this._deathDelivery = new Promise((resolve) => {
298
+ settled = resolve;
299
+ });
300
+ return this._deliverDeaths(batch, previous, receive).finally(() => settled());
301
+ }
302
+ async _deliverDeaths(batch, previous, receive) {
303
+ const { slot, count } = batch;
304
+ const bytes = count * deathRecordBytes;
305
+ try {
306
+ await slot.buffer.mapAsync(GPUMapMode.READ, 0, bytes);
307
+ } catch {
308
+ slot.busy = false;
309
+ return;
310
+ }
311
+ if (this._destroyed) {
312
+ slot.busy = false;
313
+ return;
314
+ }
315
+ const records = [];
316
+ const mapped = slot.buffer.getMappedRange(0, bytes);
317
+ const floats = new Float32Array(mapped);
318
+ const uints = new Uint32Array(mapped);
319
+ for (let i = 0; i < count; i++) {
320
+ const base = i * deathRecordFloats;
321
+ records.push({
322
+ x: floats[base + 0],
323
+ y: floats[base + 1],
324
+ velocityX: floats[base + 2],
325
+ velocityY: floats[base + 3],
326
+ rotation: floats[base + 4],
327
+ scaleX: floats[base + 5],
328
+ scaleY: floats[base + 6],
329
+ elapsed: floats[base + 7],
330
+ color: uints[base + 8],
331
+ slot: uints[base + 9]
332
+ });
333
+ }
334
+ slot.buffer.unmap();
335
+ slot.busy = false;
336
+ records.sort((a, b) => a.slot - b.slot);
337
+ await previous;
338
+ if (this._destroyed) return;
339
+ receive(records);
340
+ }
341
+ /**
342
+ * Claims a staging slot able to hold `records`, or null while every slot of
343
+ * the ring is still in flight.
344
+ */
345
+ _acquireDeathStaging(records) {
346
+ let reusable = null;
347
+ for (const slot of this._deathStaging) {
348
+ if (slot.busy) continue;
349
+ if (slot.records >= records) return slot;
350
+ reusable = slot;
351
+ }
352
+ if (reusable === null && this._deathStaging.length >= deathStagingSlots) return null;
353
+ const size = Math.max(records, (reusable?.records ?? 0) * 2, minDeathStagingRecords);
354
+ const buffer = this.device.createBuffer({
355
+ label: "particle-deaths-staging",
356
+ size: size * deathRecordBytes,
357
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
358
+ });
359
+ if (reusable !== null) {
360
+ reusable.buffer.destroy();
361
+ reusable.buffer = buffer;
362
+ reusable.records = size;
363
+ return reusable;
364
+ }
365
+ const slot = {
366
+ buffer,
367
+ records: size,
368
+ busy: false
369
+ };
370
+ this._deathStaging.push(slot);
371
+ return slot;
372
+ }
373
+ _releaseDeathStaging() {
374
+ for (const slot of this._deathStaging) slot.buffer.destroy();
375
+ this._deathStaging.length = 0;
376
+ this._stagedDeaths.length = 0;
377
+ this._deathBufferDirty = false;
378
+ }
379
+ destroy() {
380
+ this._destroyed = true;
381
+ this._destroyProgram();
382
+ this._positions.destroy();
383
+ this._velocities.destroy();
384
+ this._scales.destroy();
385
+ this._rotInfo.destroy();
386
+ this._timing.destroy();
387
+ this._color.destroy();
388
+ this._instanceStorageBuffer.destroy();
389
+ this._simUniformBuffer.destroy();
390
+ this._framesUniformBuffer.destroy();
391
+ this._deathBuffer?.destroy();
392
+ this._deathBuffer = null;
393
+ this._releaseDeathStaging();
394
+ }
395
+ _writeFrames(frames, texture) {
396
+ const view = this._framesUniformView;
397
+ const w = texture.width;
398
+ const h = texture.height;
399
+ const flipY = texture.flipY;
400
+ if (frames.length === 0) {
401
+ view[0] = 0;
402
+ view[1] = flipY ? 1 : 0;
403
+ view[2] = 1;
404
+ view[3] = flipY ? 0 : 1;
405
+ } else for (let i = 0; i < frames.length; i++) {
406
+ const f = frames[i];
407
+ const o = i * 4;
408
+ const minU = f.left / w;
409
+ const maxU = f.right / w;
410
+ const topV = f.top / h;
411
+ const bottomV = f.bottom / h;
412
+ view[o + 0] = minU;
413
+ view[o + 1] = flipY ? bottomV : topV;
414
+ view[o + 2] = maxU;
415
+ view[o + 3] = flipY ? topV : bottomV;
416
+ }
417
+ this._framesUniformBuffer.write(this._framesUniformView);
418
+ }
419
+ /**
420
+ * Push the listed CPU SoA slots to the GPU. Called by `ParticleSystem`
421
+ * with newly-spawned slots and just-expired slots (lifetime sentinel).
422
+ * Slots not in the dirty set are left alone - GPU keeps the integrated
423
+ * state from previous compute dispatches.
424
+ *
425
+ * Each dirty slot triggers 7 small writes (one per SoA channel, via
426
+ * {@link WebGpuStorageBuffer.write}). For typical spawn rates (≤200/s)
427
+ * this is negligible (≤1400 calls/s); contiguous-range batching is a
428
+ * future optimisation.
429
+ */
430
+ uploadDirty(system, slots) {
431
+ const scratch2 = this._dirtyScratchVec2;
432
+ const scratch4 = this._dirtyScratchVec4;
433
+ const scratch1 = this._dirtyScratchU32;
434
+ const storage = system._storage;
435
+ for (const slot of slots) {
436
+ const byteOffset2 = slot * 8;
437
+ const byteOffset1 = slot * 4;
438
+ scratch2[0] = storage.posX[slot];
439
+ scratch2[1] = storage.posY[slot];
440
+ this._positions.write(scratch2, byteOffset2);
441
+ scratch2[0] = storage.velX[slot];
442
+ scratch2[1] = storage.velY[slot];
443
+ this._velocities.write(scratch2, byteOffset2);
444
+ scratch2[0] = storage.scaleX[slot];
445
+ scratch2[1] = storage.scaleY[slot];
446
+ this._scales.write(scratch2, byteOffset2);
447
+ scratch4[0] = storage.rotations[slot];
448
+ scratch4[1] = storage.rotationSpeeds[slot];
449
+ scratch4[2] = storage.frame[slot];
450
+ scratch4[3] = 0;
451
+ this._rotInfo.write(scratch4, slot * 16);
452
+ scratch2[0] = storage.elapsed[slot];
453
+ scratch2[1] = storage.lifetime[slot];
454
+ this._timing.write(scratch2, byteOffset2);
455
+ scratch1[0] = storage.color[slot];
456
+ this._color.write(scratch1, byteOffset1);
457
+ }
458
+ }
459
+ /**
460
+ * Marks `slot` expired for the device without touching anything else about
461
+ * it. Only the lifetime lane is written: a full slot upload would push the
462
+ * CPU's stale position and velocity over the values the device integrated,
463
+ * and those are exactly what the death record is supposed to carry.
464
+ */
465
+ uploadExpiry(slot) {
466
+ this._expiryScratch[0] = -1;
467
+ this._timing.write(this._expiryScratch, slot * 8 + 4);
468
+ }
469
+ _expiryScratch = /* @__PURE__ */ new Float32Array(1);
470
+ _dirtyScratchVec2 = /* @__PURE__ */ new Float32Array(2);
471
+ _dirtyScratchVec4 = /* @__PURE__ */ new Float32Array(4);
472
+ _dirtyScratchU32 = /* @__PURE__ */ new Uint32Array(1);
473
+ _writeSimUniforms(dt, liveCount) {
474
+ this._simUniformView.setFloat32(0, dt, true);
475
+ this._simUniformView.setUint32(4, liveCount, true);
476
+ this._simUniformBuffer.write(this._simUniformView);
477
+ }
478
+ _writeModuleUniforms(dt) {
479
+ if (this._moduleUniformView === null || this._moduleUniformBuffer === null || this._moduleUniformData === null) return;
480
+ for (const slot of this._moduleSlots) slot.module.writeUniforms?.(this._moduleUniformView, slot.uniformByteOffset, dt);
481
+ this._moduleUniformBuffer.write(this._moduleUniformView);
482
+ }
483
+ /**
484
+ * WGSL variable names of module lookup textures/samplers whose format isn't natively
485
+ * filterable (`r32float` Curve LUTs) - fed to {@link reflectComputeBindings} so it declares
486
+ * `'unfilterable-float'`/`'non-filtering'` for those specific bindings instead of the default
487
+ * `'float'`/`'filtering'`, an ambiguity the WGSL text itself can't resolve (see that
488
+ * function's doc comment).
489
+ */
490
+ _nonFilteringResourceNames(slots) {
491
+ const names = /* @__PURE__ */ new Set();
492
+ for (const slot of slots) for (const t of slot.contribution.textures ?? []) if (t.format === "r32float") {
493
+ names.add(`u_${slot.contribution.key}_${t.name}`);
494
+ names.add(`u_${slot.contribution.key}_${t.name}_sampler`);
495
+ }
496
+ return names;
497
+ }
498
+ /** Group-0 bind-group entries, matching group 0's bindings (reflected from the shader text) one-to-one. */
499
+ _buildBindGroup0Entries(slots) {
500
+ const entries = [{
501
+ binding: 0,
502
+ buffer: this._simUniformBuffer.buffer
503
+ }, {
504
+ binding: 1,
505
+ buffer: this._framesUniformBuffer.buffer
506
+ }];
507
+ if (this._moduleUniformBuffer !== null) entries.push({
508
+ binding: 2,
509
+ buffer: this._moduleUniformBuffer.buffer
510
+ });
511
+ let textureBindingIndex = this._moduleUniformBuffer !== null ? 3 : 2;
512
+ for (const slot of slots) for (const t of slot.contribution.textures ?? []) {
513
+ const tex = this._moduleTextures.get(`${slot.contribution.key}_${t.name}`);
514
+ const sampler = t.format !== "r32float" ? this._samplerFiltering : this._samplerNonFiltering;
515
+ entries.push({
516
+ binding: textureBindingIndex++,
517
+ textureView: tex.createView({ dimension: "1d" })
518
+ });
519
+ entries.push({
520
+ binding: textureBindingIndex++,
521
+ sampler
522
+ });
523
+ }
524
+ return entries;
525
+ }
526
+ /** Group-1 bind-group entries: the 8 SoA storage buffers, matching group 1's bindings (reflected from the shader text) one-to-one. */
527
+ _buildSoaBindGroupEntries() {
528
+ const entries = [
529
+ {
530
+ binding: 0,
531
+ buffer: this._positions.buffer
532
+ },
533
+ {
534
+ binding: 1,
535
+ buffer: this._velocities.buffer
536
+ },
537
+ {
538
+ binding: 2,
539
+ buffer: this._scales.buffer
540
+ },
541
+ {
542
+ binding: 3,
543
+ buffer: this._rotInfo.buffer
544
+ },
545
+ {
546
+ binding: 4,
547
+ buffer: this._timing.buffer
548
+ },
549
+ {
550
+ binding: 5,
551
+ buffer: this._color.buffer
552
+ },
553
+ {
554
+ binding: 6,
555
+ buffer: this._instanceStorageBuffer.buffer
556
+ }
557
+ ];
558
+ if (this._reportsDeaths && this._deathBuffer !== null) entries.push({
559
+ binding: 7,
560
+ buffer: this._deathBuffer.buffer
561
+ });
562
+ return entries;
563
+ }
564
+ _buildShader(slots) {
565
+ const sections = [];
566
+ sections.push(`
415
567
  struct SimUniforms {
416
568
  dt: f32,
417
569
  liveCount: u32,
@@ -426,110 +578,116 @@ struct FrameUniforms {
426
578
  @group(0) @binding(0) var<uniform> sim: SimUniforms;
427
579
  @group(0) @binding(1) var<uniform> frameUv: FrameUniforms;
428
580
  `);
429
- const moduleStructFields = [];
430
- for (const slot of slots) {
431
- const c = slot.contribution;
432
- const fields = c.uniforms ?? [];
433
- if (fields.length === 0) {
434
- continue;
435
- }
436
- sections.push(this._renderModuleStruct(c.key, fields));
437
- moduleStructFields.push(`u_${c.key}: ${c.key}Uniforms,`);
438
- }
439
- if (moduleStructFields.length > 0) {
440
- sections.push(`
581
+ const moduleStructFields = [];
582
+ for (const slot of slots) {
583
+ const c = slot.contribution;
584
+ const fields = c.uniforms ?? [];
585
+ if (fields.length === 0) continue;
586
+ sections.push(this._renderModuleStruct(c.key, fields));
587
+ moduleStructFields.push(`u_${c.key}: ${c.key}Uniforms,`);
588
+ }
589
+ if (moduleStructFields.length > 0) sections.push(`
441
590
  struct ModuleUniforms {
442
- ${moduleStructFields.map(s => ` ${s}`).join('\n')}
591
+ ${moduleStructFields.map((s) => ` ${s}`).join("\n")}
443
592
  }
444
593
 
445
594
  @group(0) @binding(2) var<uniform> modules: ModuleUniforms;
446
595
  `);
447
- }
448
- let textureBindingIndex = moduleStructFields.length > 0 ? 3 : 2;
449
- for (const slot of slots) {
450
- for (const t of slot.contribution.textures ?? []) {
451
- sections.push(`
596
+ let textureBindingIndex = moduleStructFields.length > 0 ? 3 : 2;
597
+ for (const slot of slots) for (const t of slot.contribution.textures ?? []) sections.push(`
452
598
  @group(0) @binding(${textureBindingIndex++}) var u_${slot.contribution.key}_${t.name}: texture_1d<f32>;
453
599
  @group(0) @binding(${textureBindingIndex++}) var u_${slot.contribution.key}_${t.name}_sampler: sampler;
454
600
  `);
455
- }
456
- }
457
- sections.push(`
601
+ sections.push(`
458
602
  @group(1) @binding(0) var<storage, read_write> positions: array<vec2<f32>>;
459
603
  @group(1) @binding(1) var<storage, read_write> velocities: array<vec2<f32>>;
460
604
  @group(1) @binding(2) var<storage, read_write> scales: array<vec2<f32>>;
461
- @group(1) @binding(3) var<storage, read_write> rotInfo: array<vec2<f32>>;
605
+ @group(1) @binding(3) var<storage, read_write> rotInfo: array<vec4<f32>>;
462
606
  @group(1) @binding(4) var<storage, read_write> timing: array<vec2<f32>>;
463
607
  @group(1) @binding(5) var<storage, read_write> color: array<u32>;
464
- @group(1) @binding(6) var<storage, read> textureIndex: array<u32>;
465
- @group(1) @binding(7) var<storage, read_write> instanceOutput: array<u32>;
608
+ @group(1) @binding(6) var<storage, read_write> instanceOutput: array<u32>;
466
609
  `);
467
- // Module preludes (helper functions/constants). Concatenated in
468
- // registration order; modules sharing the same key are emitted only
469
- // once (the contribution body strings are still inlined per-instance,
470
- // but the prelude function definitions can't be duplicated).
471
- const seenPreludeKeys = new Set();
472
- for (const slot of slots) {
473
- const prelude = slot.contribution.prelude;
474
- if (prelude === undefined || prelude.trim() === '')
475
- continue;
476
- if (seenPreludeKeys.has(slot.contribution.key))
477
- continue;
478
- seenPreludeKeys.add(slot.contribution.key);
479
- sections.push(prelude);
480
- }
481
- const moduleBodies = slots.map(s => s.contribution.body).join('\n');
482
- const frameCountConst = this._frameCount;
483
- sections.push(`
484
- @compute @workgroup_size(${workgroupSize})
485
- fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
486
- let idx = gid.x;
487
- if (idx >= sim.liveCount) { return; }
488
-
489
- let dt = sim.dt;
610
+ if (this._reportsDeaths) sections.push(`
611
+ struct DeathRecord {
612
+ x: f32,
613
+ y: f32,
614
+ velocityX: f32,
615
+ velocityY: f32,
616
+ rotation: f32,
617
+ scaleX: f32,
618
+ scaleY: f32,
619
+ elapsed: f32,
620
+ color: u32,
621
+ slot: u32,
622
+ }
490
623
 
491
- // Skip dead particles (lifetime sentinel < 0). Write zero-scale instance
492
- // so the renderer doesn't accidentally draw them.
493
- if (timing[idx].y < 0.0) {
494
- let outBaseDead = idx * 10u;
495
- for (var k: u32 = 0u; k < 10u; k++) { instanceOutput[outBaseDead + k] = 0u; }
496
- return;
497
- }
624
+ struct DeathBuffer {
625
+ count: atomic<u32>,
626
+ records: array<DeathRecord>,
627
+ }
498
628
 
499
- // Integration.
500
- positions[idx] = positions[idx] + velocities[idx] * dt;
501
- rotInfo[idx].x = rotInfo[idx].x + rotInfo[idx].y * dt;
502
- timing[idx].x = timing[idx].x + dt;
629
+ @group(1) @binding(7) var<storage, read_write> deaths: DeathBuffer;
630
+ `);
631
+ const seenPreludeKeys = /* @__PURE__ */ new Set();
632
+ for (const slot of slots) {
633
+ const prelude = slot.contribution.prelude;
634
+ if (prelude === void 0 || prelude.trim() === "") continue;
635
+ if (seenPreludeKeys.has(slot.contribution.key)) continue;
636
+ seenPreludeKeys.add(slot.contribution.key);
637
+ sections.push(prelude);
638
+ }
639
+ const moduleBodies = slots.map((s) => s.contribution.body).join("\n");
640
+ sections.push(`
641
+ ${fillShaderSource(particle_simulate_default, {
642
+ workgroupSize,
643
+ moduleBodies,
644
+ frameCount: this._frameCount,
645
+ deathReport: this._deathReportSource()
646
+ })} `);
647
+ return sections.join("\n\n");
648
+ }
649
+ /**
650
+ * The block that captures a particle's state the first time the shader sees
651
+ * its expiry sentinel, or nothing when the system has no death modules.
652
+ *
653
+ * The CPU marks an expired particle with `lifetime = -1` and the shader
654
+ * rewrites that to `-2` once captured, so exactly one record is appended per
655
+ * death even though a dead slot is visited every frame until it is reused.
656
+ */
657
+ _deathReportSource() {
658
+ if (!this._reportsDeaths) return "";
659
+ return `
660
+ if (timing[idx].y > -1.5) {
661
+ // The CPU found this particle expired from an elapsed time it had
662
+ // already advanced for this frame, so the device owes it the matching
663
+ // integration step before the snapshot is taken. Without it a death
664
+ // reported from the GPU would sit one step behind the same death
665
+ // reported from the CPU pipeline.
666
+ positions[idx] = positions[idx] + velocities[idx] * dt;
667
+ rotInfo[idx].x = rotInfo[idx].x + rotInfo[idx].y * dt;
668
+ timing[idx].x = timing[idx].x + dt;
503
669
 
504
- // Module bodies (in registration order).
505
- ${moduleBodies}
670
+ let at = atomicAdd(&deaths.count, 1u);
506
671
 
507
- // Resolve frame UVs.
508
- let frameIndex = min(textureIndex[idx], ${frameCountConst}u - 1u);
509
- let frameUvBounds = frameUv.frames[frameIndex];
672
+ deaths.records[at].x = positions[idx].x;
673
+ deaths.records[at].y = positions[idx].y;
674
+ deaths.records[at].velocityX = velocities[idx].x;
675
+ deaths.records[at].velocityY = velocities[idx].y;
676
+ deaths.records[at].rotation = rotInfo[idx].x;
677
+ deaths.records[at].scaleX = scales[idx].x;
678
+ deaths.records[at].scaleY = scales[idx].y;
679
+ deaths.records[at].elapsed = timing[idx].x;
680
+ deaths.records[at].color = color[idx];
681
+ deaths.records[at].slot = idx;
510
682
 
511
- // Pack interleaved instance data (10 u32s per particle):
512
- // x, y, scaleX, scaleY, rotation (f32×5) + color (u32) + uvMin.xy (f32×2) + uvMax.xy (f32×2)
513
- let outBase = idx * 10u;
514
- instanceOutput[outBase + 0u] = bitcast<u32>(positions[idx].x);
515
- instanceOutput[outBase + 1u] = bitcast<u32>(positions[idx].y);
516
- instanceOutput[outBase + 2u] = bitcast<u32>(scales[idx].x);
517
- instanceOutput[outBase + 3u] = bitcast<u32>(scales[idx].y);
518
- instanceOutput[outBase + 4u] = bitcast<u32>(rotInfo[idx].x);
519
- instanceOutput[outBase + 5u] = color[idx];
520
- instanceOutput[outBase + 6u] = bitcast<u32>(frameUvBounds.x);
521
- instanceOutput[outBase + 7u] = bitcast<u32>(frameUvBounds.y);
522
- instanceOutput[outBase + 8u] = bitcast<u32>(frameUvBounds.z);
523
- instanceOutput[outBase + 9u] = bitcast<u32>(frameUvBounds.w);
524
- }
525
- `);
526
- return sections.join('\n\n');
527
- }
528
- _renderModuleStruct(key, fields) {
529
- const lines = fields.map(f => ` ${f.name}: ${f.type},`).join('\n');
530
- return `struct ${key}Uniforms {\n${lines}\n}`;
531
- }
532
- }
683
+ timing[idx].y = -2.0;
684
+ }`;
685
+ }
686
+ _renderModuleStruct(key, fields) {
687
+ return `struct ${key}Uniforms {\n${fields.map((f) => ` ${f.name}: ${f.type},`).join("\n")}\n}`;
688
+ }
689
+ };
533
690
 
691
+ //#endregion
534
692
  export { ParticleGpuState };
535
- //# sourceMappingURL=ParticleGpuState.js.map
693
+ //# sourceMappingURL=ParticleGpuState.js.map