insomni 0.2.0-alpha.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.
@@ -0,0 +1,4804 @@
1
+ import { n as resolveRoot } from "./root-CHradZKM.mjs";
2
+ import { A as DEPTH_FORMAT, _ as TAU, t as ALPHA_BLEND } from "./pipeline-BWCAZTKx.mjs";
3
+ import { i as SPATIAL_HASH_PARAMS_WGSL } from "./spatial-hash-C1crBjTo.mjs";
4
+ const PREPARE_INDIRECT_WGSL = `
5
+ @group(0) @binding(0) var<storage, read> aliveCount : u32;
6
+ @group(0) @binding(1) var<storage, read_write> dispatchArgs : array<u32, 3>;
7
+ @group(0) @binding(2) var<storage, read_write> drawArgs : array<u32, 4>;
8
+
9
+ @compute @workgroup_size(1)
10
+ fn main() {
11
+ let n = aliveCount;
12
+ dispatchArgs[0] = (n + 64u - 1u) / 64u;
13
+ dispatchArgs[1] = 1u;
14
+ dispatchArgs[2] = 1u;
15
+
16
+ // drawIndirect: [vertexCount, instanceCount, firstVertex, firstInstance]
17
+ drawArgs[0] = 6u;
18
+ drawArgs[1] = n;
19
+ drawArgs[2] = 0u;
20
+ drawArgs[3] = 0u;
21
+ }
22
+ `;
23
+ const BOUNDS_WGSL = `
24
+ struct BoundsParams {
25
+ min: vec2f,
26
+ max: vec2f,
27
+ mode: u32,
28
+ restitution: f32,
29
+ _pad0: u32,
30
+ _pad1: u32,
31
+ };
32
+
33
+ @group(0) @binding(0) var<storage, read_write> positions: array<vec2f>;
34
+ @group(0) @binding(1) var<storage, read_write> velocities: array<vec2f>;
35
+ @group(0) @binding(2) var<storage, read> alive: array<u32>;
36
+ @group(0) @binding(3) var<storage, read> aliveCount: u32;
37
+ @group(0) @binding(4) var<uniform> params: BoundsParams;
38
+
39
+ @compute @workgroup_size(64)
40
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
41
+ let i = gid.x;
42
+ if (i >= aliveCount) { return; }
43
+ if (alive[i] == 0u) { return; }
44
+ if (params.mode == 0u) { return; }
45
+
46
+ var p = positions[i];
47
+ var v = velocities[i];
48
+ let lo = params.min;
49
+ let hi = params.max;
50
+ let size = hi - lo;
51
+
52
+ if (params.mode == 1u) {
53
+ if (p.x < lo.x) { p.x = lo.x; v.x = 0.0; }
54
+ else if (p.x > hi.x) { p.x = hi.x; v.x = 0.0; }
55
+ if (p.y < lo.y) { p.y = lo.y; v.y = 0.0; }
56
+ else if (p.y > hi.y) { p.y = hi.y; v.y = 0.0; }
57
+ } else if (params.mode == 2u) {
58
+ // Fully vectorised modulo — handles arbitrary excursions (e.g. huge dt).
59
+ let rel = (p - lo) - size * floor((p - lo) / size);
60
+ p = lo + rel;
61
+ } else if (params.mode == 3u) {
62
+ let r = params.restitution;
63
+ if (p.x < lo.x) { p.x = lo.x; v.x = -v.x * r; }
64
+ else if (p.x > hi.x) { p.x = hi.x; v.x = -v.x * r; }
65
+ if (p.y < lo.y) { p.y = lo.y; v.y = -v.y * r; }
66
+ else if (p.y > hi.y) { p.y = hi.y; v.y = -v.y * r; }
67
+ }
68
+
69
+ positions[i] = p;
70
+ velocities[i] = v;
71
+ }
72
+ `;
73
+ //#endregion
74
+ //#region src/particles/bounds.ts
75
+ var BoundsPipeline = class {
76
+ device;
77
+ mode;
78
+ paramsBuffer;
79
+ bindGroup;
80
+ pipeline;
81
+ constructor(root, state, mode, label = "particles.bounds") {
82
+ this.device = root.device;
83
+ this.mode = mode;
84
+ if (mode.kind === "none") {
85
+ this.paramsBuffer = null;
86
+ this.bindGroup = null;
87
+ this.pipeline = null;
88
+ return;
89
+ }
90
+ this.paramsBuffer = this.device.createBuffer({
91
+ label: `${label}.params`,
92
+ size: 32,
93
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
94
+ });
95
+ const scratch = /* @__PURE__ */ new ArrayBuffer(32);
96
+ const f = new Float32Array(scratch);
97
+ const u = new Uint32Array(scratch);
98
+ f[0] = mode.rect[0];
99
+ f[1] = mode.rect[1];
100
+ f[2] = mode.rect[2];
101
+ f[3] = mode.rect[3];
102
+ u[4] = encodeMode(mode);
103
+ f[5] = mode.kind === "bounce" ? mode.restitution ?? .8 : 0;
104
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, scratch);
105
+ const layout = this.device.createBindGroupLayout({
106
+ label: `${label}.layout`,
107
+ entries: [
108
+ {
109
+ binding: 0,
110
+ visibility: GPUShaderStage.COMPUTE,
111
+ buffer: { type: "storage" }
112
+ },
113
+ {
114
+ binding: 1,
115
+ visibility: GPUShaderStage.COMPUTE,
116
+ buffer: { type: "storage" }
117
+ },
118
+ {
119
+ binding: 2,
120
+ visibility: GPUShaderStage.COMPUTE,
121
+ buffer: { type: "read-only-storage" }
122
+ },
123
+ {
124
+ binding: 3,
125
+ visibility: GPUShaderStage.COMPUTE,
126
+ buffer: { type: "read-only-storage" }
127
+ },
128
+ {
129
+ binding: 4,
130
+ visibility: GPUShaderStage.COMPUTE,
131
+ buffer: { type: "uniform" }
132
+ }
133
+ ]
134
+ });
135
+ this.bindGroup = this.device.createBindGroup({
136
+ label: `${label}.group`,
137
+ layout,
138
+ entries: [
139
+ {
140
+ binding: 0,
141
+ resource: { buffer: state.builtins.positions }
142
+ },
143
+ {
144
+ binding: 1,
145
+ resource: { buffer: state.builtins.velocities }
146
+ },
147
+ {
148
+ binding: 2,
149
+ resource: { buffer: state.builtins.alive }
150
+ },
151
+ {
152
+ binding: 3,
153
+ resource: { buffer: state.builtins.aliveCount }
154
+ },
155
+ {
156
+ binding: 4,
157
+ resource: { buffer: this.paramsBuffer }
158
+ }
159
+ ]
160
+ });
161
+ this.pipeline = this.device.createComputePipeline({
162
+ label: `${label}.pipeline`,
163
+ layout: this.device.createPipelineLayout({
164
+ label: `${label}.pipelineLayout`,
165
+ bindGroupLayouts: [layout]
166
+ }),
167
+ compute: {
168
+ module: this.device.createShaderModule({
169
+ label: `${label}.shader`,
170
+ code: BOUNDS_WGSL
171
+ }),
172
+ entryPoint: "main"
173
+ }
174
+ });
175
+ }
176
+ /** Record one bounds dispatch. No-op when mode is `none`. */
177
+ record(pass, dispatchArgs) {
178
+ if (!this.pipeline || !this.bindGroup) return;
179
+ pass.setPipeline(this.pipeline);
180
+ pass.setBindGroup(0, this.bindGroup);
181
+ pass.dispatchWorkgroupsIndirect(dispatchArgs, 0);
182
+ }
183
+ destroy() {
184
+ this.paramsBuffer?.destroy();
185
+ }
186
+ get isNoop() {
187
+ return this.mode.kind === "none";
188
+ }
189
+ };
190
+ function encodeMode(mode) {
191
+ switch (mode.kind) {
192
+ case "none": return 0;
193
+ case "clamp": return 1;
194
+ case "wrap": return 2;
195
+ case "bounce": return 3;
196
+ }
197
+ }
198
+ //#endregion
199
+ //#region src/particles/codegen/pipeline-cache.ts
200
+ var PipelineCache = class {
201
+ cache = /* @__PURE__ */ new Map();
202
+ /**
203
+ * Return the pipeline for `key`, calling `factory()` to build it on miss.
204
+ * Factories are only invoked on cold lookups.
205
+ */
206
+ getOrCreate(key, factory) {
207
+ const existing = this.cache.get(key);
208
+ if (existing !== void 0) return existing;
209
+ const fresh = factory();
210
+ this.cache.set(key, fresh);
211
+ return fresh;
212
+ }
213
+ /** Current cache size — useful for tests and introspection. */
214
+ get size() {
215
+ return this.cache.size;
216
+ }
217
+ /**
218
+ * Drop every cached pipeline. WebGPU pipelines are reference-counted and
219
+ * released when the JS handle becomes unreferenced; nothing extra needed.
220
+ */
221
+ clear() {
222
+ this.cache.clear();
223
+ }
224
+ };
225
+ const COMPACT_ELEMENTS_PER_BLOCK = 256 * 4;
226
+ /**
227
+ * Maximum capacity supported by the single-workgroup Pass B below. A larger
228
+ * capacity would require a hierarchical third pass; rejected at system
229
+ * construction to keep the compaction module simple.
230
+ */
231
+ const COMPACT_MAX_CAPACITY = 256 * COMPACT_ELEMENTS_PER_BLOCK;
232
+ const COMPACT_SCAN_LOCAL_WGSL = `
233
+ struct ScanParams {
234
+ capacity: u32,
235
+ _pad0: u32,
236
+ _pad1: u32,
237
+ _pad2: u32,
238
+ };
239
+
240
+ @group(0) @binding(0) var<storage, read> alive: array<u32>;
241
+ @group(0) @binding(1) var<storage, read_write> scanLocal: array<u32>;
242
+ @group(0) @binding(2) var<storage, read_write> blockTotals: array<u32>;
243
+ @group(0) @binding(3) var<uniform> params: ScanParams;
244
+
245
+ const BLOCK_SIZE: u32 = 256u;
246
+ const ELEMENTS_PER_THREAD: u32 = 4u;
247
+ const ELEMENTS_PER_BLOCK: u32 = ${COMPACT_ELEMENTS_PER_BLOCK}u;
248
+
249
+ var<workgroup> threadSums: array<u32, 256>;
250
+
251
+ @compute @workgroup_size(256)
252
+ fn main(
253
+ @builtin(local_invocation_id) lid: vec3u,
254
+ @builtin(workgroup_id) wid: vec3u,
255
+ ) {
256
+ let tid = lid.x;
257
+ let base = wid.x * ELEMENTS_PER_BLOCK + tid * ELEMENTS_PER_THREAD;
258
+
259
+ // Phase 1 — read my 4 flags, serially accumulate an exclusive prefix of
260
+ // just this thread's values. \`local[k]\` = sum of my flags strictly before k.
261
+ var local: array<u32, 4>;
262
+ var threadSum: u32 = 0u;
263
+ for (var k: u32 = 0u; k < ELEMENTS_PER_THREAD; k = k + 1u) {
264
+ let gi = base + k;
265
+ var v: u32 = 0u;
266
+ if (gi < params.capacity) {
267
+ if (alive[gi] != 0u) { v = 1u; }
268
+ }
269
+ local[k] = threadSum;
270
+ threadSum = threadSum + v;
271
+ }
272
+
273
+ // Phase 2 — Hillis-Steele inclusive scan across threadSums. Two barriers
274
+ // per iteration (read-before-write then write-before-read) — WGSL does not
275
+ // permit racing loads with stores.
276
+ threadSums[tid] = threadSum;
277
+ workgroupBarrier();
278
+ for (var offset: u32 = 1u; offset < BLOCK_SIZE; offset = offset << 1u) {
279
+ var t: u32 = 0u;
280
+ if (tid >= offset) {
281
+ t = threadSums[tid - offset];
282
+ }
283
+ workgroupBarrier();
284
+ threadSums[tid] = threadSums[tid] + t;
285
+ workgroupBarrier();
286
+ }
287
+
288
+ let threadInclusive = threadSums[tid];
289
+ let threadExclusive = threadInclusive - threadSum;
290
+
291
+ // Phase 3 — emit per-element block-local exclusive prefix.
292
+ for (var k: u32 = 0u; k < ELEMENTS_PER_THREAD; k = k + 1u) {
293
+ let gi = base + k;
294
+ if (gi < params.capacity) {
295
+ scanLocal[gi] = threadExclusive + local[k];
296
+ }
297
+ }
298
+
299
+ // Thread 255 holds the block's inclusive total (= sum of all flags in the
300
+ // 1024-element block). Write it once.
301
+ if (tid == BLOCK_SIZE - 1u) {
302
+ blockTotals[wid.x] = threadInclusive;
303
+ }
304
+ }
305
+ `;
306
+ const COMPACT_SCAN_BLOCKS_WGSL = `
307
+ struct BlocksParams {
308
+ blockCount: u32,
309
+ _pad0: u32,
310
+ _pad1: u32,
311
+ _pad2: u32,
312
+ };
313
+
314
+ @group(0) @binding(0) var<storage, read> blockTotals: array<u32>;
315
+ @group(0) @binding(1) var<storage, read_write> blockOffsets: array<u32>;
316
+ @group(0) @binding(2) var<storage, read_write> aliveCount: atomic<u32>;
317
+ @group(0) @binding(3) var<uniform> params: BlocksParams;
318
+
319
+ const BLOCK_SIZE_B: u32 = 256u;
320
+
321
+ var<workgroup> sdata: array<u32, 256>;
322
+
323
+ @compute @workgroup_size(256)
324
+ fn main(@builtin(local_invocation_id) lid: vec3u) {
325
+ let tid = lid.x;
326
+ let n = params.blockCount;
327
+
328
+ var v: u32 = 0u;
329
+ if (tid < n) {
330
+ v = blockTotals[tid];
331
+ }
332
+ sdata[tid] = v;
333
+ workgroupBarrier();
334
+
335
+ for (var offset: u32 = 1u; offset < BLOCK_SIZE_B; offset = offset << 1u) {
336
+ var t: u32 = 0u;
337
+ if (tid >= offset) {
338
+ t = sdata[tid - offset];
339
+ }
340
+ workgroupBarrier();
341
+ sdata[tid] = sdata[tid] + t;
342
+ workgroupBarrier();
343
+ }
344
+
345
+ let inclusive = sdata[tid];
346
+ let exclusive = inclusive - v;
347
+
348
+ if (tid < n) {
349
+ blockOffsets[tid] = exclusive;
350
+ }
351
+
352
+ // Last *valid* thread writes the grand total. For n==0 nothing writes,
353
+ // but construction guarantees capacity > 0 → n >= 1.
354
+ if (n > 0u && tid == n - 1u) {
355
+ atomicStore(&aliveCount, inclusive);
356
+ }
357
+ }
358
+ `;
359
+ //#endregion
360
+ //#region src/particles/kernels/compact-alive-rewrite.ts
361
+ const COMPACT_ALIVE_REWRITE_WGSL = `
362
+ struct Params {
363
+ capacity: u32,
364
+ _pad0: u32,
365
+ _pad1: u32,
366
+ _pad2: u32,
367
+ };
368
+
369
+ @group(0) @binding(0) var<storage, read_write> alive: array<u32>;
370
+ @group(0) @binding(1) var<storage, read> aliveCount: u32;
371
+ @group(0) @binding(2) var<uniform> params: Params;
372
+
373
+ @compute @workgroup_size(256)
374
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
375
+ let i = gid.x;
376
+ if (i >= params.capacity) { return; }
377
+ alive[i] = select(0u, 1u, i < aliveCount);
378
+ }
379
+ `;
380
+ //#endregion
381
+ //#region src/particles/kernels/compact-scatter.ts
382
+ /**
383
+ * Build a scatter kernel for `wgslType`. Kernels are cached by type in the
384
+ * `CompactionPipeline` harness — two `f32` attributes (say, `age` and
385
+ * `charge`) share the same pipeline object, they just bind different buffers.
386
+ */
387
+ function buildCompactScatterWGSL(wgslType) {
388
+ return `
389
+ struct ScatterParams {
390
+ capacity: u32,
391
+ _pad0: u32,
392
+ _pad1: u32,
393
+ _pad2: u32,
394
+ };
395
+
396
+ @group(0) @binding(0) var<storage, read> src: array<${wgslType}>;
397
+ @group(0) @binding(1) var<storage, read_write> dst: array<${wgslType}>;
398
+ @group(0) @binding(2) var<storage, read> alive: array<u32>;
399
+ @group(0) @binding(3) var<storage, read> scanLocal: array<u32>;
400
+ @group(0) @binding(4) var<storage, read> blockOffsets: array<u32>;
401
+ @group(0) @binding(5) var<uniform> params: ScatterParams;
402
+
403
+ const ELEMENTS_PER_BLOCK: u32 = ${COMPACT_ELEMENTS_PER_BLOCK}u;
404
+
405
+ @compute @workgroup_size(256)
406
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
407
+ let srcIdx = gid.x;
408
+ if (srcIdx >= params.capacity) { return; }
409
+ if (alive[srcIdx] == 0u) { return; }
410
+
411
+ let dstIdx = scanLocal[srcIdx] + blockOffsets[srcIdx / ELEMENTS_PER_BLOCK];
412
+ dst[dstIdx] = src[srcIdx];
413
+ }
414
+ `;
415
+ }
416
+ //#endregion
417
+ //#region src/particles/compaction.ts
418
+ var CompactionPipeline = class {
419
+ device;
420
+ state;
421
+ dispatchWorkgroups;
422
+ scanLocalParams;
423
+ scanLocalBindGroup;
424
+ scanLocalPipeline;
425
+ scanBlocksParams;
426
+ scanBlocksBindGroup;
427
+ scanBlocksPipeline;
428
+ scatterParams;
429
+ scatterLayout;
430
+ scatterPipelineByType = /* @__PURE__ */ new Map();
431
+ scatterEntries;
432
+ aliveRewriteParams;
433
+ aliveRewriteBindGroup;
434
+ aliveRewritePipeline;
435
+ constructor(root, state, label = "particles.compaction") {
436
+ this.device = root.device;
437
+ this.state = state;
438
+ this.dispatchWorkgroups = Math.ceil(state.capacity / 256);
439
+ this.scanLocalParams = this.createParamsBuffer(`${label}.scanLocal.params`, 16);
440
+ writeU32Params(this.device, this.scanLocalParams, [
441
+ state.capacity,
442
+ 0,
443
+ 0,
444
+ 0
445
+ ]);
446
+ {
447
+ const layout = this.device.createBindGroupLayout({
448
+ label: `${label}.scanLocal.layout`,
449
+ entries: [
450
+ {
451
+ binding: 0,
452
+ visibility: GPUShaderStage.COMPUTE,
453
+ buffer: { type: "read-only-storage" }
454
+ },
455
+ {
456
+ binding: 1,
457
+ visibility: GPUShaderStage.COMPUTE,
458
+ buffer: { type: "storage" }
459
+ },
460
+ {
461
+ binding: 2,
462
+ visibility: GPUShaderStage.COMPUTE,
463
+ buffer: { type: "storage" }
464
+ },
465
+ {
466
+ binding: 3,
467
+ visibility: GPUShaderStage.COMPUTE,
468
+ buffer: { type: "uniform" }
469
+ }
470
+ ]
471
+ });
472
+ this.scanLocalBindGroup = this.device.createBindGroup({
473
+ label: `${label}.scanLocal.group`,
474
+ layout,
475
+ entries: [
476
+ {
477
+ binding: 0,
478
+ resource: { buffer: state.builtins.alive }
479
+ },
480
+ {
481
+ binding: 1,
482
+ resource: { buffer: state.compaction.scanLocal }
483
+ },
484
+ {
485
+ binding: 2,
486
+ resource: { buffer: state.compaction.blockTotals }
487
+ },
488
+ {
489
+ binding: 3,
490
+ resource: { buffer: this.scanLocalParams }
491
+ }
492
+ ]
493
+ });
494
+ this.scanLocalPipeline = this.device.createComputePipeline({
495
+ label: `${label}.scanLocal.pipeline`,
496
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [layout] }),
497
+ compute: {
498
+ module: this.device.createShaderModule({
499
+ label: `${label}.scanLocal.shader`,
500
+ code: COMPACT_SCAN_LOCAL_WGSL
501
+ }),
502
+ entryPoint: "main"
503
+ }
504
+ });
505
+ }
506
+ this.scanBlocksParams = this.createParamsBuffer(`${label}.scanBlocks.params`, 16);
507
+ writeU32Params(this.device, this.scanBlocksParams, [
508
+ state.compaction.blockCount,
509
+ 0,
510
+ 0,
511
+ 0
512
+ ]);
513
+ {
514
+ const layout = this.device.createBindGroupLayout({
515
+ label: `${label}.scanBlocks.layout`,
516
+ entries: [
517
+ {
518
+ binding: 0,
519
+ visibility: GPUShaderStage.COMPUTE,
520
+ buffer: { type: "read-only-storage" }
521
+ },
522
+ {
523
+ binding: 1,
524
+ visibility: GPUShaderStage.COMPUTE,
525
+ buffer: { type: "storage" }
526
+ },
527
+ {
528
+ binding: 2,
529
+ visibility: GPUShaderStage.COMPUTE,
530
+ buffer: { type: "storage" }
531
+ },
532
+ {
533
+ binding: 3,
534
+ visibility: GPUShaderStage.COMPUTE,
535
+ buffer: { type: "uniform" }
536
+ }
537
+ ]
538
+ });
539
+ this.scanBlocksBindGroup = this.device.createBindGroup({
540
+ label: `${label}.scanBlocks.group`,
541
+ layout,
542
+ entries: [
543
+ {
544
+ binding: 0,
545
+ resource: { buffer: state.compaction.blockTotals }
546
+ },
547
+ {
548
+ binding: 1,
549
+ resource: { buffer: state.compaction.blockOffsets }
550
+ },
551
+ {
552
+ binding: 2,
553
+ resource: { buffer: state.builtins.aliveCount }
554
+ },
555
+ {
556
+ binding: 3,
557
+ resource: { buffer: this.scanBlocksParams }
558
+ }
559
+ ]
560
+ });
561
+ this.scanBlocksPipeline = this.device.createComputePipeline({
562
+ label: `${label}.scanBlocks.pipeline`,
563
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [layout] }),
564
+ compute: {
565
+ module: this.device.createShaderModule({
566
+ label: `${label}.scanBlocks.shader`,
567
+ code: COMPACT_SCAN_BLOCKS_WGSL
568
+ }),
569
+ entryPoint: "main"
570
+ }
571
+ });
572
+ }
573
+ this.scatterParams = this.createParamsBuffer(`${label}.scatter.params`, 16);
574
+ writeU32Params(this.device, this.scatterParams, [
575
+ state.capacity,
576
+ 0,
577
+ 0,
578
+ 0
579
+ ]);
580
+ this.scatterLayout = this.device.createBindGroupLayout({
581
+ label: `${label}.scatter.layout`,
582
+ entries: [
583
+ {
584
+ binding: 0,
585
+ visibility: GPUShaderStage.COMPUTE,
586
+ buffer: { type: "read-only-storage" }
587
+ },
588
+ {
589
+ binding: 1,
590
+ visibility: GPUShaderStage.COMPUTE,
591
+ buffer: { type: "storage" }
592
+ },
593
+ {
594
+ binding: 2,
595
+ visibility: GPUShaderStage.COMPUTE,
596
+ buffer: { type: "read-only-storage" }
597
+ },
598
+ {
599
+ binding: 3,
600
+ visibility: GPUShaderStage.COMPUTE,
601
+ buffer: { type: "read-only-storage" }
602
+ },
603
+ {
604
+ binding: 4,
605
+ visibility: GPUShaderStage.COMPUTE,
606
+ buffer: { type: "read-only-storage" }
607
+ },
608
+ {
609
+ binding: 5,
610
+ visibility: GPUShaderStage.COMPUTE,
611
+ buffer: { type: "uniform" }
612
+ }
613
+ ]
614
+ });
615
+ this.scatterEntries = state.scatteredAttributes.map((attr) => {
616
+ const pipeline = this.getOrBuildScatterPipeline(attr.wgslType, label);
617
+ return {
618
+ attr,
619
+ bindGroup: this.device.createBindGroup({
620
+ label: `${label}.scatter.${attr.name}.group`,
621
+ layout: this.scatterLayout,
622
+ entries: [
623
+ {
624
+ binding: 0,
625
+ resource: { buffer: attr.main }
626
+ },
627
+ {
628
+ binding: 1,
629
+ resource: { buffer: attr.scratch }
630
+ },
631
+ {
632
+ binding: 2,
633
+ resource: { buffer: state.builtins.alive }
634
+ },
635
+ {
636
+ binding: 3,
637
+ resource: { buffer: state.compaction.scanLocal }
638
+ },
639
+ {
640
+ binding: 4,
641
+ resource: { buffer: state.compaction.blockOffsets }
642
+ },
643
+ {
644
+ binding: 5,
645
+ resource: { buffer: this.scatterParams }
646
+ }
647
+ ]
648
+ }),
649
+ pipeline
650
+ };
651
+ });
652
+ this.aliveRewriteParams = this.createParamsBuffer(`${label}.aliveRewrite.params`, 16);
653
+ writeU32Params(this.device, this.aliveRewriteParams, [
654
+ state.capacity,
655
+ 0,
656
+ 0,
657
+ 0
658
+ ]);
659
+ {
660
+ const layout = this.device.createBindGroupLayout({
661
+ label: `${label}.aliveRewrite.layout`,
662
+ entries: [
663
+ {
664
+ binding: 0,
665
+ visibility: GPUShaderStage.COMPUTE,
666
+ buffer: { type: "storage" }
667
+ },
668
+ {
669
+ binding: 1,
670
+ visibility: GPUShaderStage.COMPUTE,
671
+ buffer: { type: "read-only-storage" }
672
+ },
673
+ {
674
+ binding: 2,
675
+ visibility: GPUShaderStage.COMPUTE,
676
+ buffer: { type: "uniform" }
677
+ }
678
+ ]
679
+ });
680
+ this.aliveRewriteBindGroup = this.device.createBindGroup({
681
+ label: `${label}.aliveRewrite.group`,
682
+ layout,
683
+ entries: [
684
+ {
685
+ binding: 0,
686
+ resource: { buffer: state.builtins.alive }
687
+ },
688
+ {
689
+ binding: 1,
690
+ resource: { buffer: state.builtins.aliveCount }
691
+ },
692
+ {
693
+ binding: 2,
694
+ resource: { buffer: this.aliveRewriteParams }
695
+ }
696
+ ]
697
+ });
698
+ this.aliveRewritePipeline = this.device.createComputePipeline({
699
+ label: `${label}.aliveRewrite.pipeline`,
700
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [layout] }),
701
+ compute: {
702
+ module: this.device.createShaderModule({
703
+ label: `${label}.aliveRewrite.shader`,
704
+ code: COMPACT_ALIVE_REWRITE_WGSL
705
+ }),
706
+ entryPoint: "main"
707
+ }
708
+ });
709
+ }
710
+ }
711
+ /**
712
+ * Record every compute-pass dispatch for one compaction. Does NOT record
713
+ * the copyback — caller ends the pass then calls `recordCopyback` on the
714
+ * same encoder.
715
+ */
716
+ record(pass) {
717
+ pass.setPipeline(this.scanLocalPipeline);
718
+ pass.setBindGroup(0, this.scanLocalBindGroup);
719
+ pass.dispatchWorkgroups(this.state.compaction.blockCount);
720
+ pass.setPipeline(this.scanBlocksPipeline);
721
+ pass.setBindGroup(0, this.scanBlocksBindGroup);
722
+ pass.dispatchWorkgroups(1);
723
+ for (const entry of this.scatterEntries) {
724
+ pass.setPipeline(entry.pipeline);
725
+ pass.setBindGroup(0, entry.bindGroup);
726
+ pass.dispatchWorkgroups(this.dispatchWorkgroups);
727
+ }
728
+ pass.setPipeline(this.aliveRewritePipeline);
729
+ pass.setBindGroup(0, this.aliveRewriteBindGroup);
730
+ pass.dispatchWorkgroups(this.dispatchWorkgroups);
731
+ }
732
+ /**
733
+ * Copy every scratch buffer back into its main attribute buffer. Must be
734
+ * called on the same encoder after `record()`, after the compute pass has
735
+ * ended. Cost scales with total attribute bytes × capacity — 1-2 MB per
736
+ * step at our target scales, which is well under a millisecond on any
737
+ * GPU that supports WebGPU.
738
+ */
739
+ recordCopyback(encoder) {
740
+ for (const entry of this.scatterEntries) encoder.copyBufferToBuffer(entry.attr.scratch, 0, entry.attr.main, 0, this.state.capacity * entry.attr.strideBytes);
741
+ }
742
+ destroy() {
743
+ this.scanLocalParams.destroy();
744
+ this.scanBlocksParams.destroy();
745
+ this.scatterParams.destroy();
746
+ this.aliveRewriteParams.destroy();
747
+ }
748
+ createParamsBuffer(label, size) {
749
+ return this.device.createBuffer({
750
+ label,
751
+ size,
752
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
753
+ });
754
+ }
755
+ getOrBuildScatterPipeline(wgslType, label) {
756
+ const existing = this.scatterPipelineByType.get(wgslType);
757
+ if (existing) return existing;
758
+ const pipeline = this.device.createComputePipeline({
759
+ label: `${label}.scatter.${wgslType}.pipeline`,
760
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.scatterLayout] }),
761
+ compute: {
762
+ module: this.device.createShaderModule({
763
+ label: `${label}.scatter.${wgslType}.shader`,
764
+ code: buildCompactScatterWGSL(wgslType)
765
+ }),
766
+ entryPoint: "main"
767
+ }
768
+ });
769
+ this.scatterPipelineByType.set(wgslType, pipeline);
770
+ return pipeline;
771
+ }
772
+ };
773
+ /**
774
+ * Small helper to initialise a static uniform buffer with a 4×u32 payload.
775
+ * Every compaction params buffer is fixed at construction (capacity / block
776
+ * count never change during a system's lifetime), so we write once and forget.
777
+ */
778
+ function writeU32Params(device, buffer, values) {
779
+ const scratch = new Uint32Array(buffer.size / 4);
780
+ for (let i = 0; i < values.length; i++) scratch[i] = values[i] ?? 0;
781
+ device.queue.writeBuffer(buffer, 0, scratch);
782
+ }
783
+ //#endregion
784
+ //#region src/particles/kernels/integrate.ts
785
+ const INTEGRATE_WGSL = `
786
+ struct IntegrateParams {
787
+ dt: f32,
788
+ _pad0: f32,
789
+ _pad1: f32,
790
+ _pad2: f32,
791
+ };
792
+
793
+ @group(0) @binding(0) var<storage, read_write> positions: array<vec2f>;
794
+ @group(0) @binding(1) var<storage, read_write> velocities: array<vec2f>;
795
+ @group(0) @binding(2) var<storage, read_write> accelPrev: array<vec2f>;
796
+ @group(0) @binding(3) var<storage, read> accel: array<vec2f>;
797
+ @group(0) @binding(4) var<storage, read_write> ages: array<f32>;
798
+ @group(0) @binding(5) var<storage, read> lifetimes: array<f32>;
799
+ @group(0) @binding(6) var<storage, read_write> alive: array<u32>;
800
+ @group(0) @binding(7) var<storage, read> aliveCount: u32;
801
+ @group(0) @binding(8) var<uniform> params: IntegrateParams;
802
+
803
+ @compute @workgroup_size(64)
804
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
805
+ let i = gid.x;
806
+ if (i >= aliveCount) { return; }
807
+ if (alive[i] == 0u) { return; }
808
+
809
+ let dt = params.dt;
810
+ let aPrev = accelPrev[i];
811
+ let a = accel[i];
812
+ let v = velocities[i];
813
+
814
+ positions[i] = positions[i] + v * dt + 0.5 * aPrev * dt * dt;
815
+ velocities[i] = v + 0.5 * (aPrev + a) * dt;
816
+ accelPrev[i] = a;
817
+
818
+ let newAge = ages[i] + dt;
819
+ ages[i] = newAge;
820
+ if (newAge >= lifetimes[i]) {
821
+ alive[i] = 0u;
822
+ }
823
+ }
824
+ `;
825
+ //#endregion
826
+ //#region src/particles/integrator.ts
827
+ var IntegratorPipeline = class {
828
+ device;
829
+ paramsBuffer;
830
+ bindGroup;
831
+ pipeline;
832
+ scratch = new Float32Array(16 / 4);
833
+ constructor(root, state, label = "particles.integrate") {
834
+ this.device = root.device;
835
+ this.paramsBuffer = this.device.createBuffer({
836
+ label: `${label}.params`,
837
+ size: 16,
838
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
839
+ });
840
+ const layout = this.device.createBindGroupLayout({
841
+ label: `${label}.layout`,
842
+ entries: [
843
+ {
844
+ binding: 0,
845
+ visibility: GPUShaderStage.COMPUTE,
846
+ buffer: { type: "storage" }
847
+ },
848
+ {
849
+ binding: 1,
850
+ visibility: GPUShaderStage.COMPUTE,
851
+ buffer: { type: "storage" }
852
+ },
853
+ {
854
+ binding: 2,
855
+ visibility: GPUShaderStage.COMPUTE,
856
+ buffer: { type: "storage" }
857
+ },
858
+ {
859
+ binding: 3,
860
+ visibility: GPUShaderStage.COMPUTE,
861
+ buffer: { type: "read-only-storage" }
862
+ },
863
+ {
864
+ binding: 4,
865
+ visibility: GPUShaderStage.COMPUTE,
866
+ buffer: { type: "storage" }
867
+ },
868
+ {
869
+ binding: 5,
870
+ visibility: GPUShaderStage.COMPUTE,
871
+ buffer: { type: "read-only-storage" }
872
+ },
873
+ {
874
+ binding: 6,
875
+ visibility: GPUShaderStage.COMPUTE,
876
+ buffer: { type: "storage" }
877
+ },
878
+ {
879
+ binding: 7,
880
+ visibility: GPUShaderStage.COMPUTE,
881
+ buffer: { type: "read-only-storage" }
882
+ },
883
+ {
884
+ binding: 8,
885
+ visibility: GPUShaderStage.COMPUTE,
886
+ buffer: { type: "uniform" }
887
+ }
888
+ ]
889
+ });
890
+ this.bindGroup = this.device.createBindGroup({
891
+ label: `${label}.group`,
892
+ layout,
893
+ entries: [
894
+ {
895
+ binding: 0,
896
+ resource: { buffer: state.builtins.positions }
897
+ },
898
+ {
899
+ binding: 1,
900
+ resource: { buffer: state.builtins.velocities }
901
+ },
902
+ {
903
+ binding: 2,
904
+ resource: { buffer: state.builtins.accelPrev }
905
+ },
906
+ {
907
+ binding: 3,
908
+ resource: { buffer: state.builtins.accel }
909
+ },
910
+ {
911
+ binding: 4,
912
+ resource: { buffer: state.builtins.ages }
913
+ },
914
+ {
915
+ binding: 5,
916
+ resource: { buffer: state.builtins.lifetimes }
917
+ },
918
+ {
919
+ binding: 6,
920
+ resource: { buffer: state.builtins.alive }
921
+ },
922
+ {
923
+ binding: 7,
924
+ resource: { buffer: state.builtins.aliveCount }
925
+ },
926
+ {
927
+ binding: 8,
928
+ resource: { buffer: this.paramsBuffer }
929
+ }
930
+ ]
931
+ });
932
+ this.pipeline = this.device.createComputePipeline({
933
+ label: `${label}.pipeline`,
934
+ layout: this.device.createPipelineLayout({
935
+ label: `${label}.pipelineLayout`,
936
+ bindGroupLayouts: [layout]
937
+ }),
938
+ compute: {
939
+ module: this.device.createShaderModule({
940
+ label: `${label}.shader`,
941
+ code: INTEGRATE_WGSL
942
+ }),
943
+ entryPoint: "main"
944
+ }
945
+ });
946
+ }
947
+ /**
948
+ * Record one integration dispatch. Caller supplies the shared dispatch-args
949
+ * buffer populated by `prepareIndirect` earlier in the frame.
950
+ */
951
+ record(pass, dispatchArgs, dt) {
952
+ this.scratch[0] = dt;
953
+ this.scratch[1] = 0;
954
+ this.scratch[2] = 0;
955
+ this.scratch[3] = 0;
956
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, this.scratch);
957
+ pass.setPipeline(this.pipeline);
958
+ pass.setBindGroup(0, this.bindGroup);
959
+ pass.dispatchWorkgroupsIndirect(dispatchArgs, 0);
960
+ }
961
+ destroy() {
962
+ this.paramsBuffer.destroy();
963
+ }
964
+ };
965
+ //#endregion
966
+ //#region src/particles/attributes.ts
967
+ const ATTRIBUTE_TYPE_INFO = {
968
+ f32: {
969
+ wgsl: "f32",
970
+ strideBytes: 4,
971
+ integer: false,
972
+ scalarCount: 1
973
+ },
974
+ u32: {
975
+ wgsl: "u32",
976
+ strideBytes: 4,
977
+ integer: true,
978
+ scalarCount: 1
979
+ },
980
+ i32: {
981
+ wgsl: "i32",
982
+ strideBytes: 4,
983
+ integer: true,
984
+ scalarCount: 1
985
+ },
986
+ vec2f: {
987
+ wgsl: "vec2f",
988
+ strideBytes: 8,
989
+ integer: false,
990
+ scalarCount: 2
991
+ },
992
+ vec3f: {
993
+ wgsl: "vec3f",
994
+ strideBytes: 16,
995
+ integer: false,
996
+ scalarCount: 3
997
+ },
998
+ vec4f: {
999
+ wgsl: "vec4f",
1000
+ strideBytes: 16,
1001
+ integer: false,
1002
+ scalarCount: 4
1003
+ },
1004
+ vec2u: {
1005
+ wgsl: "vec2u",
1006
+ strideBytes: 8,
1007
+ integer: true,
1008
+ scalarCount: 2
1009
+ },
1010
+ vec4u: {
1011
+ wgsl: "vec4u",
1012
+ strideBytes: 16,
1013
+ integer: true,
1014
+ scalarCount: 4
1015
+ }
1016
+ };
1017
+ /** Names owned by the system; user attributes cannot shadow these. */
1018
+ const RESERVED_ATTRIBUTE_NAMES = new Set([
1019
+ "position",
1020
+ "velocity",
1021
+ "accel",
1022
+ "accelPrev",
1023
+ "age",
1024
+ "lifetime",
1025
+ "alive",
1026
+ "aliveCount",
1027
+ "index"
1028
+ ]);
1029
+ /**
1030
+ * Validate a user-supplied catalog. Throws on:
1031
+ * • unknown / unsupported type literals
1032
+ * • names that collide with built-ins (§ `RESERVED_ATTRIBUTE_NAMES`)
1033
+ * • names that are empty or would be invalid WGSL identifiers
1034
+ *
1035
+ * Returns the catalog unchanged on success so callers can chain.
1036
+ */
1037
+ function validateAttributeCatalog(catalog) {
1038
+ for (const [name, type] of Object.entries(catalog)) {
1039
+ if (!name) throw new Error("ParticleSystem: attribute name must be non-empty");
1040
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`ParticleSystem: attribute name '${name}' must be a valid identifier`);
1041
+ if (RESERVED_ATTRIBUTE_NAMES.has(name)) throw new Error(`ParticleSystem: attribute '${name}' collides with a built-in. Reserved: ${[...RESERVED_ATTRIBUTE_NAMES].join(", ")}.`);
1042
+ if (!(type in ATTRIBUTE_TYPE_INFO)) throw new Error(`ParticleSystem: attribute '${name}' has unsupported type '${type}'. Supported: ${Object.keys(ATTRIBUTE_TYPE_INFO).join(", ")}.`);
1043
+ }
1044
+ return catalog;
1045
+ }
1046
+ /** WGSL type literal (e.g. `vec2f`). */
1047
+ function attributeWgslType(type) {
1048
+ return ATTRIBUTE_TYPE_INFO[type].wgsl;
1049
+ }
1050
+ //#endregion
1051
+ //#region src/particles/kernels/atomic-accel.ts
1052
+ const ATOMIC_ACCEL_WGSL = `
1053
+ fn atomicAddF32(slot: u32, v: f32) {
1054
+ loop {
1055
+ let old_u = atomicLoad(&accel[slot]);
1056
+ let new_u = bitcast<u32>(bitcast<f32>(old_u) + v);
1057
+ let r = atomicCompareExchangeWeak(&accel[slot], old_u, new_u);
1058
+ if (r.exchanged) { break; }
1059
+ }
1060
+ }
1061
+
1062
+ fn addAccel(particleIndex: u32, delta: vec2f) {
1063
+ atomicAddF32(particleIndex * 2u + 0u, delta.x);
1064
+ atomicAddF32(particleIndex * 2u + 1u, delta.y);
1065
+ }
1066
+ `;
1067
+ //#endregion
1068
+ //#region src/particles/codegen/attributes.ts
1069
+ /**
1070
+ * Stable, sorted list of (name, type) for the catalog. Used both to emit
1071
+ * bindings deterministically and to hash pipeline-cache keys.
1072
+ */
1073
+ function catalogEntries(catalog) {
1074
+ return Object.entries(catalog).map(([k, v]) => [k, v]);
1075
+ }
1076
+ /**
1077
+ * Shadow-safe identifier for a bound attribute. Appending `_` avoids WGSL
1078
+ * keyword collisions for names like `alignment`, `sample`, etc., and keeps
1079
+ * the struct field name (`species`) distinct from the array binding
1080
+ * (`species_`).
1081
+ */
1082
+ function bindingName(attrName) {
1083
+ return `${attrName}_`;
1084
+ }
1085
+ //#endregion
1086
+ //#region src/particles/emitter.ts
1087
+ function samplePosition(shape, i, count, rand = Math.random) {
1088
+ switch (shape.kind) {
1089
+ case "point": {
1090
+ const at = typeof shape.at === "function" ? shape.at() : shape.at;
1091
+ return [at[0], at[1]];
1092
+ }
1093
+ case "disc": {
1094
+ const theta = rand() * TAU;
1095
+ const r = Math.sqrt(rand()) * shape.radius;
1096
+ return [shape.center[0] + Math.cos(theta) * r, shape.center[1] + Math.sin(theta) * r];
1097
+ }
1098
+ case "ring": {
1099
+ const theta = rand() * TAU;
1100
+ const thickness = shape.thickness ?? 0;
1101
+ const r = shape.radius + (rand() - .5) * thickness;
1102
+ return [shape.center[0] + Math.cos(theta) * r, shape.center[1] + Math.sin(theta) * r];
1103
+ }
1104
+ case "rect": return [shape.min[0] + rand() * (shape.max[0] - shape.min[0]), shape.min[1] + rand() * (shape.max[1] - shape.min[1])];
1105
+ case "line": {
1106
+ const t = rand();
1107
+ return [shape.a[0] + t * (shape.b[0] - shape.a[0]), shape.a[1] + t * (shape.b[1] - shape.a[1])];
1108
+ }
1109
+ case "grid": {
1110
+ const cols = Math.max(1, shape.cols);
1111
+ const rows = Math.max(1, shape.rows);
1112
+ const idx = i % (cols * rows);
1113
+ const col = idx % cols;
1114
+ const row = Math.floor(idx / cols);
1115
+ const sp = shape.spacing;
1116
+ const sx = typeof sp === "number" ? sp : sp[0];
1117
+ const sy = typeof sp === "number" ? sp : sp[1];
1118
+ return [shape.origin[0] + (col - (cols - 1) * .5) * sx, shape.origin[1] + (row - (rows - 1) * .5) * sy];
1119
+ }
1120
+ case "fn": return shape.fn(i, count);
1121
+ }
1122
+ }
1123
+ function sampleVelocity(shape, i, count, origin, rand = Math.random) {
1124
+ if (!shape) return [0, 0];
1125
+ switch (shape.kind) {
1126
+ case "zero": return [0, 0];
1127
+ case "uniform": return [shape.value[0], shape.value[1]];
1128
+ case "random": {
1129
+ const [sMin, sMax] = shape.speed;
1130
+ const speed = sMin + rand() * (sMax - sMin);
1131
+ const theta = rand() * TAU;
1132
+ return [Math.cos(theta) * speed, Math.sin(theta) * speed];
1133
+ }
1134
+ case "cone": {
1135
+ const [sMin, sMax] = shape.speed;
1136
+ const speed = sMin + rand() * (sMax - sMin);
1137
+ const dir = typeof shape.dir === "function" ? shape.dir() : shape.dir;
1138
+ const theta = Math.atan2(dir[1], dir[0]) + (rand() - .5) * shape.spread;
1139
+ return [Math.cos(theta) * speed, Math.sin(theta) * speed];
1140
+ }
1141
+ case "tangential": {
1142
+ const [sMin, sMax] = shape.speed;
1143
+ const speed = sMin + rand() * (sMax - sMin);
1144
+ const dx = origin[0] - shape.center[0];
1145
+ const dy = origin[1] - shape.center[1];
1146
+ const len = Math.hypot(dx, dy);
1147
+ if (len < 1e-6) return [0, 0];
1148
+ const sign = shape.ccw === false ? -1 : 1;
1149
+ return [-dy / len * speed * sign, dx / len * speed * sign];
1150
+ }
1151
+ case "fn": return shape.fn(i, count);
1152
+ }
1153
+ }
1154
+ function sampleScalarRange(range, rand = Math.random) {
1155
+ if (range === void 0) return Infinity;
1156
+ if (typeof range === "number") return range;
1157
+ return range[0] + rand() * (range[1] - range[0]);
1158
+ }
1159
+ /**
1160
+ * Resolve an attribute sampler to the concrete scalar values written to
1161
+ * staging. Always returns exactly `scalarCount` numbers. Integer attribute
1162
+ * types are truncated to 32-bit integers at pack time, not here.
1163
+ */
1164
+ function sampleAttribute(sampler, type, i, count, rand = Math.random) {
1165
+ const n = ATTRIBUTE_TYPE_INFO[type].scalarCount;
1166
+ const out = Array.from({ length: n }, () => 0);
1167
+ if (sampler === void 0) return out;
1168
+ if (typeof sampler === "function") {
1169
+ const v = sampler(i, count);
1170
+ if (typeof v === "number") out[0] = v;
1171
+ else for (let k = 0; k < n && k < v.length; k++) out[k] = v[k] ?? 0;
1172
+ return out;
1173
+ }
1174
+ if (typeof sampler === "number") {
1175
+ out[0] = sampler;
1176
+ return out;
1177
+ }
1178
+ if (Array.isArray(sampler)) {
1179
+ if (n === 1 && sampler.length === 2) {
1180
+ const a = sampler[0] ?? 0;
1181
+ const b = sampler[1] ?? 0;
1182
+ out[0] = a + rand() * (b - a);
1183
+ return out;
1184
+ }
1185
+ for (let k = 0; k < n && k < sampler.length; k++) out[k] = sampler[k] ?? 0;
1186
+ return out;
1187
+ }
1188
+ return out;
1189
+ }
1190
+ /**
1191
+ * F32-slot stride for the emit staging buffer given a catalog. Built-ins
1192
+ * occupy the first 5 slots; each user attribute adds `scalarCount` slots
1193
+ * (vec3f packs tight as 3 slots in staging — the GPU buffer pads to 4 when
1194
+ * the kernel writes `vec3f(...)`).
1195
+ */
1196
+ function emitStagingStride(catalog) {
1197
+ let stride = 5;
1198
+ for (const [, type] of catalogEntries(catalog)) stride += ATTRIBUTE_TYPE_INFO[type].scalarCount;
1199
+ return stride;
1200
+ }
1201
+ /**
1202
+ * Per-attribute staging offset (in f32 slots, relative to the particle base).
1203
+ * Returns a map `{ attrName -> offsetF32 }` in catalog declaration order.
1204
+ */
1205
+ function emitStagingOffsets(catalog) {
1206
+ const offsets = {};
1207
+ let cursor = 5;
1208
+ for (const [name, type] of catalogEntries(catalog)) {
1209
+ offsets[name] = cursor;
1210
+ cursor += ATTRIBUTE_TYPE_INFO[type].scalarCount;
1211
+ }
1212
+ return offsets;
1213
+ }
1214
+ /**
1215
+ * Write one spec's worth of staging data into `out` starting at `offsetF32`.
1216
+ *
1217
+ * `out` is the Float32Array view over the shared staging backing buffer;
1218
+ * integer attributes are written through `outU32` (a Uint32Array view over
1219
+ * the same buffer) so their bit pattern survives unchanged — the WGSL emit
1220
+ * kernel bitcasts them back to `u32`/`i32`.
1221
+ */
1222
+ function packEmitBatch(out, outU32, offsetF32, spec, catalog = {}, rand = Math.random) {
1223
+ const stride = emitStagingStride(catalog);
1224
+ const offsets = emitStagingOffsets(catalog);
1225
+ const count = Math.min(spec.count, (out.length - offsetF32) / stride) | 0;
1226
+ const entries = catalogEntries(catalog);
1227
+ for (let i = 0; i < count; i++) {
1228
+ const pos = samplePosition(spec.position, i, count, rand);
1229
+ const vel = sampleVelocity(spec.velocity, i, count, pos, rand);
1230
+ const life = sampleScalarRange(spec.lifetime, rand);
1231
+ const base = offsetF32 + i * stride;
1232
+ out[base + 0] = pos[0];
1233
+ out[base + 1] = pos[1];
1234
+ out[base + 2] = vel[0];
1235
+ out[base + 3] = vel[1];
1236
+ out[base + 4] = life;
1237
+ for (const [name, type] of entries) {
1238
+ const info = ATTRIBUTE_TYPE_INFO[type];
1239
+ const values = sampleAttribute(spec.attributes?.[name], type, i, count, rand);
1240
+ const slot = base + (offsets[name] ?? 0);
1241
+ if (info.integer) for (let k = 0; k < info.scalarCount; k++) outU32[slot + k] = Math.trunc(values[k] ?? 0) | 0;
1242
+ else for (let k = 0; k < info.scalarCount; k++) out[slot + k] = values[k] ?? 0;
1243
+ }
1244
+ }
1245
+ }
1246
+ /**
1247
+ * Generate the emit kernel WGSL for a given catalog. For an empty catalog
1248
+ * this matches the pre-codegen kernel — built-ins only.
1249
+ */
1250
+ function generateEmitWgsl(catalog) {
1251
+ const entries = catalogEntries(catalog);
1252
+ const offsets = emitStagingOffsets(catalog);
1253
+ const stride = emitStagingStride(catalog);
1254
+ const userBindings = [];
1255
+ const userWrites = [];
1256
+ for (let i = 0; i < entries.length; i++) {
1257
+ const entry = entries[i];
1258
+ if (entry === void 0) continue;
1259
+ const [name, type] = entry;
1260
+ const info = ATTRIBUTE_TYPE_INFO[type];
1261
+ const binding = 9 + i;
1262
+ const bindName = `${name}_`;
1263
+ userBindings.push(`@group(0) @binding(${binding}) var<storage, read_write> ${bindName} : array<${info.wgsl}>;`);
1264
+ const off = offsets[name] ?? 0;
1265
+ userWrites.push(` ${bindName}[dst] = ${readAttrExpr(type, off)};`);
1266
+ }
1267
+ return `
1268
+ struct EmitParams {
1269
+ capacity: u32,
1270
+ _pad0: u32,
1271
+ _pad1: u32,
1272
+ _pad2: u32,
1273
+ };
1274
+
1275
+ @group(0) @binding(0) var<storage, read_write> positions: array<vec2f>;
1276
+ @group(0) @binding(1) var<storage, read_write> velocities: array<vec2f>;
1277
+ @group(0) @binding(2) var<storage, read_write> accelPrev: array<vec2f>;
1278
+ @group(0) @binding(3) var<storage, read_write> ages: array<f32>;
1279
+ @group(0) @binding(4) var<storage, read_write> lifetimes: array<f32>;
1280
+ @group(0) @binding(5) var<storage, read_write> alive: array<u32>;
1281
+ @group(0) @binding(6) var<storage, read> staging: array<f32>;
1282
+ @group(0) @binding(7) var<storage, read> reserve: array<u32, 2>;
1283
+ @group(0) @binding(8) var<uniform> params: EmitParams;
1284
+ ${userBindings.length > 0 ? "\n" + userBindings.join("\n") : ""}
1285
+
1286
+ @compute @workgroup_size(64)
1287
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
1288
+ let i = gid.x;
1289
+ let count = reserve[1];
1290
+ if (i >= count) {
1291
+ return;
1292
+ }
1293
+ let dst = reserve[0] + i;
1294
+ if (dst >= params.capacity) {
1295
+ return;
1296
+ }
1297
+
1298
+ let base = i * ${stride}u;
1299
+ positions[dst] = vec2f(staging[base + 0u], staging[base + 1u]);
1300
+ velocities[dst] = vec2f(staging[base + 2u], staging[base + 3u]);
1301
+ lifetimes[dst] = staging[base + 4u];
1302
+ ages[dst] = 0.0;
1303
+ accelPrev[dst] = vec2f(0.0);
1304
+ alive[dst] = 1u;
1305
+ ${userWrites.length > 0 ? "\n" + userWrites.join("\n") : ""}
1306
+ }
1307
+ `;
1308
+ }
1309
+ /**
1310
+ * WGSL expression that reads one attribute value from `staging` starting at
1311
+ * `offsetF32` slots past `base`. Integer attributes are stored bit-identically
1312
+ * in the staging f32 slots by `packEmitBatch`, so we `bitcast` back.
1313
+ */
1314
+ function readAttrExpr(type, offsetF32) {
1315
+ const o = (k) => `staging[base + ${offsetF32 + k}u]`;
1316
+ switch (type) {
1317
+ case "f32": return o(0);
1318
+ case "u32": return `bitcast<u32>(${o(0)})`;
1319
+ case "i32": return `bitcast<i32>(${o(0)})`;
1320
+ case "vec2f": return `vec2f(${o(0)}, ${o(1)})`;
1321
+ case "vec3f": return `vec3f(${o(0)}, ${o(1)}, ${o(2)})`;
1322
+ case "vec4f": return `vec4f(${o(0)}, ${o(1)}, ${o(2)}, ${o(3)})`;
1323
+ case "vec2u": return `vec2u(bitcast<u32>(${o(0)}), bitcast<u32>(${o(1)}))`;
1324
+ case "vec4u": return `vec4u(bitcast<u32>(${o(0)}), bitcast<u32>(${o(1)}), bitcast<u32>(${o(2)}), bitcast<u32>(${o(3)}))`;
1325
+ }
1326
+ }
1327
+ //#endregion
1328
+ //#region src/particles/kernels/emit-reserve.ts
1329
+ const EMIT_RESERVE_WGSL = `
1330
+ struct ReserveParams {
1331
+ requestedCount: u32,
1332
+ capacity: u32,
1333
+ _pad0: u32,
1334
+ _pad1: u32,
1335
+ };
1336
+
1337
+ @group(0) @binding(0) var<storage, read_write> aliveCount: atomic<u32>;
1338
+ @group(0) @binding(1) var<storage, read_write> reserve: array<u32, 2>;
1339
+ @group(0) @binding(2) var<uniform> params: ReserveParams;
1340
+
1341
+ @compute @workgroup_size(1)
1342
+ fn main() {
1343
+ let before = atomicLoad(&aliveCount);
1344
+ // Saturating clamp: if aliveCount is already at or past capacity, reserve 0.
1345
+ let available = select(0u, params.capacity - before, before < params.capacity);
1346
+ let actual = min(params.requestedCount, available);
1347
+ reserve[0] = before;
1348
+ reserve[1] = actual;
1349
+ atomicStore(&aliveCount, before + actual);
1350
+ }
1351
+ `;
1352
+ //#endregion
1353
+ //#region src/particles/kernels/emit-pipeline.ts
1354
+ var EmitPipeline = class {
1355
+ device;
1356
+ state;
1357
+ /** F32-slot stride per particle in the staging buffer. Depends on catalog. */
1358
+ stagingStrideF32;
1359
+ stagingBuffer;
1360
+ stagingBackingBuffer;
1361
+ stagingF32;
1362
+ stagingU32;
1363
+ reserveParams;
1364
+ reserveBindGroup;
1365
+ reservePipeline;
1366
+ reserveScratch = new Uint32Array(16 / 4);
1367
+ emitParams;
1368
+ emitBindGroup;
1369
+ emitPipeline;
1370
+ constructor(root, state, label = "particles.emit") {
1371
+ this.device = root.device;
1372
+ this.state = state;
1373
+ this.stagingStrideF32 = emitStagingStride(state.catalog);
1374
+ const stagingSlots = state.capacity * this.stagingStrideF32;
1375
+ const stagingBytes = Math.max(stagingSlots * 4, 16);
1376
+ this.stagingBuffer = this.device.createBuffer({
1377
+ label: `${label}.staging`,
1378
+ size: stagingBytes,
1379
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
1380
+ });
1381
+ this.stagingBackingBuffer = /* @__PURE__ */ new ArrayBuffer(stagingSlots * 4);
1382
+ this.stagingF32 = new Float32Array(this.stagingBackingBuffer);
1383
+ this.stagingU32 = new Uint32Array(this.stagingBackingBuffer);
1384
+ this.reserveParams = this.device.createBuffer({
1385
+ label: `${label}.reserve.params`,
1386
+ size: 16,
1387
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
1388
+ });
1389
+ {
1390
+ const layout = this.device.createBindGroupLayout({
1391
+ label: `${label}.reserve.layout`,
1392
+ entries: [
1393
+ {
1394
+ binding: 0,
1395
+ visibility: GPUShaderStage.COMPUTE,
1396
+ buffer: { type: "storage" }
1397
+ },
1398
+ {
1399
+ binding: 1,
1400
+ visibility: GPUShaderStage.COMPUTE,
1401
+ buffer: { type: "storage" }
1402
+ },
1403
+ {
1404
+ binding: 2,
1405
+ visibility: GPUShaderStage.COMPUTE,
1406
+ buffer: { type: "uniform" }
1407
+ }
1408
+ ]
1409
+ });
1410
+ this.reserveBindGroup = this.device.createBindGroup({
1411
+ label: `${label}.reserve.group`,
1412
+ layout,
1413
+ entries: [
1414
+ {
1415
+ binding: 0,
1416
+ resource: { buffer: state.builtins.aliveCount }
1417
+ },
1418
+ {
1419
+ binding: 1,
1420
+ resource: { buffer: state.compaction.emitReserve }
1421
+ },
1422
+ {
1423
+ binding: 2,
1424
+ resource: { buffer: this.reserveParams }
1425
+ }
1426
+ ]
1427
+ });
1428
+ this.reservePipeline = this.device.createComputePipeline({
1429
+ label: `${label}.reserve.pipeline`,
1430
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [layout] }),
1431
+ compute: {
1432
+ module: this.device.createShaderModule({
1433
+ label: `${label}.reserve.shader`,
1434
+ code: EMIT_RESERVE_WGSL
1435
+ }),
1436
+ entryPoint: "main"
1437
+ }
1438
+ });
1439
+ }
1440
+ this.emitParams = this.device.createBuffer({
1441
+ label: `${label}.params`,
1442
+ size: 16,
1443
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
1444
+ });
1445
+ {
1446
+ const scratch = new Uint32Array(16 / 4);
1447
+ scratch[0] = state.capacity;
1448
+ this.device.queue.writeBuffer(this.emitParams, 0, scratch);
1449
+ }
1450
+ {
1451
+ const layoutEntries = [
1452
+ ...[
1453
+ 0,
1454
+ 1,
1455
+ 2,
1456
+ 3,
1457
+ 4,
1458
+ 5
1459
+ ].map((binding) => ({
1460
+ binding,
1461
+ visibility: GPUShaderStage.COMPUTE,
1462
+ buffer: { type: "storage" }
1463
+ })),
1464
+ {
1465
+ binding: 6,
1466
+ visibility: GPUShaderStage.COMPUTE,
1467
+ buffer: { type: "read-only-storage" }
1468
+ },
1469
+ {
1470
+ binding: 7,
1471
+ visibility: GPUShaderStage.COMPUTE,
1472
+ buffer: { type: "read-only-storage" }
1473
+ },
1474
+ {
1475
+ binding: 8,
1476
+ visibility: GPUShaderStage.COMPUTE,
1477
+ buffer: { type: "uniform" }
1478
+ }
1479
+ ];
1480
+ const bindEntries = [
1481
+ {
1482
+ binding: 0,
1483
+ resource: { buffer: state.builtins.positions }
1484
+ },
1485
+ {
1486
+ binding: 1,
1487
+ resource: { buffer: state.builtins.velocities }
1488
+ },
1489
+ {
1490
+ binding: 2,
1491
+ resource: { buffer: state.builtins.accelPrev }
1492
+ },
1493
+ {
1494
+ binding: 3,
1495
+ resource: { buffer: state.builtins.ages }
1496
+ },
1497
+ {
1498
+ binding: 4,
1499
+ resource: { buffer: state.builtins.lifetimes }
1500
+ },
1501
+ {
1502
+ binding: 5,
1503
+ resource: { buffer: state.builtins.alive }
1504
+ },
1505
+ {
1506
+ binding: 6,
1507
+ resource: { buffer: this.stagingBuffer }
1508
+ },
1509
+ {
1510
+ binding: 7,
1511
+ resource: { buffer: state.compaction.emitReserve }
1512
+ },
1513
+ {
1514
+ binding: 8,
1515
+ resource: { buffer: this.emitParams }
1516
+ }
1517
+ ];
1518
+ for (const attr of state.userAttrs) {
1519
+ const binding = 9 + attr.bindingIndex;
1520
+ layoutEntries.push({
1521
+ binding,
1522
+ visibility: GPUShaderStage.COMPUTE,
1523
+ buffer: { type: "storage" }
1524
+ });
1525
+ bindEntries.push({
1526
+ binding,
1527
+ resource: { buffer: attr.buffer }
1528
+ });
1529
+ }
1530
+ const layout = this.device.createBindGroupLayout({
1531
+ label: `${label}.layout`,
1532
+ entries: layoutEntries
1533
+ });
1534
+ this.emitBindGroup = this.device.createBindGroup({
1535
+ label: `${label}.group`,
1536
+ layout,
1537
+ entries: bindEntries
1538
+ });
1539
+ this.emitPipeline = this.device.createComputePipeline({
1540
+ label: `${label}.pipeline`,
1541
+ layout: this.device.createPipelineLayout({
1542
+ label: `${label}.pipelineLayout`,
1543
+ bindGroupLayouts: [layout]
1544
+ }),
1545
+ compute: {
1546
+ module: this.device.createShaderModule({
1547
+ label: `${label}.shader`,
1548
+ code: generateEmitWgsl(state.catalog)
1549
+ }),
1550
+ entryPoint: "main"
1551
+ }
1552
+ });
1553
+ }
1554
+ }
1555
+ /**
1556
+ * Pack a batch of `spec` into the staging scratch starting at
1557
+ * `stagingOffsetF32`. `count` is clamped only by staging-buffer room — the
1558
+ * GPU-side capacity clamp now happens in the reserve kernel, so the CPU
1559
+ * does not need to know the live aliveCount. Callers use the returned
1560
+ * `count` to advance the staging cursor.
1561
+ */
1562
+ packBatch(spec, stagingOffsetF32) {
1563
+ const stagingRoom = Math.max(0, (this.stagingF32.length - stagingOffsetF32) / this.stagingStrideF32);
1564
+ const count = Math.max(0, Math.min(spec.count, stagingRoom)) | 0;
1565
+ if (count > 0) packEmitBatch(this.stagingF32, this.stagingU32, stagingOffsetF32, {
1566
+ ...spec,
1567
+ count
1568
+ }, this.state.catalog);
1569
+ return {
1570
+ spec,
1571
+ count
1572
+ };
1573
+ }
1574
+ /**
1575
+ * Upload the packed staging buffer and dispatch reserve + emit for a
1576
+ * contiguous run of `totalCount` particles. The GPU may clamp this to
1577
+ * fewer particles if capacity is exhausted — the kernel's internal
1578
+ * `i >= count` check covers that.
1579
+ */
1580
+ flush(encoder, totalCount) {
1581
+ if (totalCount <= 0) return;
1582
+ const usedBytes = totalCount * this.stagingStrideF32 * 4;
1583
+ this.device.queue.writeBuffer(this.stagingBuffer, 0, this.stagingBackingBuffer, 0, usedBytes);
1584
+ this.reserveScratch[0] = totalCount;
1585
+ this.reserveScratch[1] = this.state.capacity;
1586
+ this.reserveScratch[2] = 0;
1587
+ this.reserveScratch[3] = 0;
1588
+ this.device.queue.writeBuffer(this.reserveParams, 0, this.reserveScratch);
1589
+ const pass = encoder.beginComputePass({ label: "particles.emit.pass" });
1590
+ pass.setPipeline(this.reservePipeline);
1591
+ pass.setBindGroup(0, this.reserveBindGroup);
1592
+ pass.dispatchWorkgroups(1);
1593
+ pass.setPipeline(this.emitPipeline);
1594
+ pass.setBindGroup(0, this.emitBindGroup);
1595
+ const workgroups = Math.ceil(totalCount / 64);
1596
+ pass.dispatchWorkgroups(workgroups);
1597
+ pass.end();
1598
+ }
1599
+ destroy() {
1600
+ this.stagingBuffer.destroy();
1601
+ this.reserveParams.destroy();
1602
+ this.emitParams.destroy();
1603
+ }
1604
+ };
1605
+ //#endregion
1606
+ //#region src/particles/kernels/prepare-indirect-pipeline.ts
1607
+ var PrepareIndirectPipeline = class {
1608
+ bindGroup;
1609
+ pipeline;
1610
+ constructor(root, state, label = "particles.prepareIndirect") {
1611
+ const device = root.device;
1612
+ const layout = device.createBindGroupLayout({
1613
+ label: `${label}.layout`,
1614
+ entries: [
1615
+ {
1616
+ binding: 0,
1617
+ visibility: GPUShaderStage.COMPUTE,
1618
+ buffer: { type: "read-only-storage" }
1619
+ },
1620
+ {
1621
+ binding: 1,
1622
+ visibility: GPUShaderStage.COMPUTE,
1623
+ buffer: { type: "storage" }
1624
+ },
1625
+ {
1626
+ binding: 2,
1627
+ visibility: GPUShaderStage.COMPUTE,
1628
+ buffer: { type: "storage" }
1629
+ }
1630
+ ]
1631
+ });
1632
+ this.bindGroup = device.createBindGroup({
1633
+ label: `${label}.group`,
1634
+ layout,
1635
+ entries: [
1636
+ {
1637
+ binding: 0,
1638
+ resource: { buffer: state.builtins.aliveCount }
1639
+ },
1640
+ {
1641
+ binding: 1,
1642
+ resource: { buffer: state.indirect.dispatchArgs }
1643
+ },
1644
+ {
1645
+ binding: 2,
1646
+ resource: { buffer: state.indirect.drawArgs }
1647
+ }
1648
+ ]
1649
+ });
1650
+ this.pipeline = device.createComputePipeline({
1651
+ label: `${label}.pipeline`,
1652
+ layout: device.createPipelineLayout({
1653
+ label: `${label}.pipelineLayout`,
1654
+ bindGroupLayouts: [layout]
1655
+ }),
1656
+ compute: {
1657
+ module: device.createShaderModule({
1658
+ label: `${label}.shader`,
1659
+ code: PREPARE_INDIRECT_WGSL
1660
+ }),
1661
+ entryPoint: "main"
1662
+ }
1663
+ });
1664
+ }
1665
+ record(pass) {
1666
+ pass.setPipeline(this.pipeline);
1667
+ pass.setBindGroup(0, this.bindGroup);
1668
+ pass.dispatchWorkgroups(1);
1669
+ }
1670
+ };
1671
+ //#endregion
1672
+ //#region src/particles/kernels/zero-accel.ts
1673
+ const ZERO_ACCEL_WGSL = `
1674
+ @group(0) @binding(0) var<storage, read_write> accel: array<atomic<u32>>;
1675
+
1676
+ struct Params { count: u32, _pad0: u32, _pad1: u32, _pad2: u32 };
1677
+ @group(0) @binding(1) var<uniform> params: Params;
1678
+
1679
+ @compute @workgroup_size(64)
1680
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
1681
+ let i = gid.x;
1682
+ if (i >= params.count) {
1683
+ return;
1684
+ }
1685
+ atomicStore(&accel[i], 0u);
1686
+ }
1687
+ `;
1688
+ //#endregion
1689
+ //#region src/particles/kernels/zero-accel-pipeline.ts
1690
+ const ZERO_ACCEL_PARAMS_BYTES = 16;
1691
+ var ZeroAccelPipeline = class {
1692
+ bindGroup;
1693
+ pipeline;
1694
+ workgroupCount;
1695
+ paramsBuffer;
1696
+ device;
1697
+ constructor(root, state, label = "particles.zeroAccel") {
1698
+ this.device = root.device;
1699
+ const slotCount = state.capacity * 2;
1700
+ this.workgroupCount = Math.ceil(slotCount / 64);
1701
+ this.paramsBuffer = this.device.createBuffer({
1702
+ label: `${label}.params`,
1703
+ size: ZERO_ACCEL_PARAMS_BYTES,
1704
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
1705
+ });
1706
+ const scratch = new Uint32Array(ZERO_ACCEL_PARAMS_BYTES / 4);
1707
+ scratch[0] = slotCount;
1708
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, scratch);
1709
+ const layout = this.device.createBindGroupLayout({
1710
+ label: `${label}.layout`,
1711
+ entries: [{
1712
+ binding: 0,
1713
+ visibility: GPUShaderStage.COMPUTE,
1714
+ buffer: { type: "storage" }
1715
+ }, {
1716
+ binding: 1,
1717
+ visibility: GPUShaderStage.COMPUTE,
1718
+ buffer: { type: "uniform" }
1719
+ }]
1720
+ });
1721
+ this.bindGroup = this.device.createBindGroup({
1722
+ label: `${label}.group`,
1723
+ layout,
1724
+ entries: [{
1725
+ binding: 0,
1726
+ resource: { buffer: state.builtins.accel }
1727
+ }, {
1728
+ binding: 1,
1729
+ resource: { buffer: this.paramsBuffer }
1730
+ }]
1731
+ });
1732
+ this.pipeline = this.device.createComputePipeline({
1733
+ label: `${label}.pipeline`,
1734
+ layout: this.device.createPipelineLayout({
1735
+ label: `${label}.pipelineLayout`,
1736
+ bindGroupLayouts: [layout]
1737
+ }),
1738
+ compute: {
1739
+ module: this.device.createShaderModule({
1740
+ label: `${label}.shader`,
1741
+ code: ZERO_ACCEL_WGSL
1742
+ }),
1743
+ entryPoint: "main"
1744
+ }
1745
+ });
1746
+ }
1747
+ record(pass) {
1748
+ pass.setPipeline(this.pipeline);
1749
+ pass.setBindGroup(0, this.bindGroup);
1750
+ pass.dispatchWorkgroups(this.workgroupCount);
1751
+ }
1752
+ destroy() {
1753
+ this.paramsBuffer.destroy();
1754
+ }
1755
+ };
1756
+ //#endregion
1757
+ //#region src/particles/render/shader.wgsl.ts
1758
+ /** Signed distance fn bodies in UV space (p ∈ [-1, 1]²; shape covers uv ≤ 0). */
1759
+ const BUILTIN_SHAPE_WGSL = {
1760
+ circle: `return length(p) - 1.0;`,
1761
+ square: `return max(abs(p.x), abs(p.y)) - 1.0;`,
1762
+ triangle: `
1763
+ let k = sqrt(3.0);
1764
+ var q = vec2f(abs(p.x) - 0.8660254, p.y + 0.5);
1765
+ if (q.x + k * q.y > 0.0) {
1766
+ q = vec2f(q.x - k * q.y, -k * q.x - q.y) * 0.5;
1767
+ }
1768
+ q.x -= clamp(q.x, -1.7320508, 0.0);
1769
+ return -length(q) * sign(q.y);`,
1770
+ "rounded-square": `
1771
+ let b = vec2f(0.75, 0.75);
1772
+ let r = 0.25;
1773
+ let q = abs(p) - b + vec2f(r);
1774
+ return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - r;`
1775
+ };
1776
+ function resolveShapeBody(shape) {
1777
+ if (typeof shape === "string") return BUILTIN_SHAPE_WGSL[shape];
1778
+ return shape.sdf;
1779
+ }
1780
+ function buildParticleRenderWgsl(opts) {
1781
+ const shapeBody = resolveShapeBody(opts.shape);
1782
+ const fadeMode = opts.fade;
1783
+ const lutBindings = fadeMode === "lut" ? `
1784
+ @group(3) @binding(0) var lutTexture: texture_2d<f32>;
1785
+ @group(3) @binding(1) var lutSampler: sampler;
1786
+ ` : "";
1787
+ const fadeExpr = fadeMode === "none" ? `1.0` : fadeMode === "linear" ? `clamp(1.0 - t, 0.0, 1.0)` : `textureSampleLevel(lutTexture, lutSampler, vec2f(clamp(t, 0.0, 1.0), 0.5), 0.0).r`;
1788
+ return `
1789
+ struct Camera {
1790
+ vpCol0: vec2f,
1791
+ vpCol1: vec2f,
1792
+ vpCol2: vec2f,
1793
+ };
1794
+
1795
+ struct RenderParams {
1796
+ color: vec4f,
1797
+ size: f32,
1798
+ velocityStretch: f32,
1799
+ fadeByAge: f32,
1800
+ _pad0: f32,
1801
+ };
1802
+
1803
+ struct Particle {
1804
+ position: vec2f,
1805
+ velocity: vec2f,
1806
+ age: f32,
1807
+ lifetime: f32,
1808
+ index: u32,
1809
+ };
1810
+
1811
+ @group(0) @binding(0) var<uniform> camera: Camera;
1812
+
1813
+ @group(1) @binding(0) var<storage, read> positions: array<vec2f>;
1814
+ @group(1) @binding(1) var<storage, read> alive: array<u32>;
1815
+ @group(1) @binding(2) var<storage, read> velocities: array<vec2f>;
1816
+ @group(1) @binding(3) var<storage, read> ages: array<f32>;
1817
+ @group(1) @binding(4) var<storage, read> lifetimes: array<f32>;
1818
+
1819
+ @group(2) @binding(0) var<uniform> render: RenderParams;
1820
+ ${lutBindings}
1821
+
1822
+ fn loadParticle(i: u32) -> Particle {
1823
+ return Particle(positions[i], velocities[i], ages[i], lifetimes[i], i);
1824
+ }
1825
+
1826
+ fn shapeSdf(p: vec2f) -> f32 {
1827
+ ${shapeBody}
1828
+ }
1829
+ ${opts.color === "uniform" ? `
1830
+ fn fragColor(p: Particle) -> vec4f {
1831
+ return render.color;
1832
+ }` : `
1833
+ fn fragColor(p: Particle) -> vec4f {
1834
+ ${opts.color.wgsl}
1835
+ }`}
1836
+
1837
+ struct VOut {
1838
+ @builtin(position) clip: vec4f,
1839
+ @location(0) uv: vec2f,
1840
+ @location(1) aliveF: f32,
1841
+ @location(2) alphaScale: f32,
1842
+ @location(3) @interpolate(flat) iid: u32,
1843
+ };
1844
+
1845
+ const QUAD: array<vec2f, 6> = array<vec2f, 6>(
1846
+ vec2f(-1.0, -1.0),
1847
+ vec2f( 1.0, -1.0),
1848
+ vec2f(-1.0, 1.0),
1849
+ vec2f( 1.0, -1.0),
1850
+ vec2f( 1.0, 1.0),
1851
+ vec2f(-1.0, 1.0),
1852
+ );
1853
+
1854
+ @vertex
1855
+ fn vmain(
1856
+ @builtin(vertex_index) vid: u32,
1857
+ @builtin(instance_index) iid: u32,
1858
+ ) -> VOut {
1859
+ let corner = QUAD[vid];
1860
+ let center = positions[iid];
1861
+
1862
+ var dir = vec2f(1.0, 0.0);
1863
+ var perp = vec2f(0.0, 1.0);
1864
+ var lengthScale = render.size;
1865
+ let widthScale = render.size;
1866
+ if (render.velocityStretch > 0.0) {
1867
+ let v = velocities[iid];
1868
+ let speed = length(v);
1869
+ if (speed > 1e-4) {
1870
+ dir = v / speed;
1871
+ perp = vec2f(-dir.y, dir.x);
1872
+ lengthScale = render.size + render.velocityStretch * speed;
1873
+ }
1874
+ }
1875
+
1876
+ let local = dir * (corner.x * lengthScale) + perp * (corner.y * widthScale);
1877
+ let world = center + local;
1878
+ let ndc = camera.vpCol0 * world.x + camera.vpCol1 * world.y + camera.vpCol2;
1879
+
1880
+ var out: VOut;
1881
+ out.clip = vec4f(ndc, 0.5, 1.0);
1882
+ out.uv = corner;
1883
+ out.aliveF = select(0.0, 1.0, alive[iid] == 1u);
1884
+
1885
+ var alphaScale = 1.0;
1886
+ if (render.fadeByAge > 0.5) {
1887
+ let life = lifetimes[iid];
1888
+ let t = ages[iid] / max(life, 1e-6);
1889
+ alphaScale = ${fadeExpr};
1890
+ }
1891
+ out.alphaScale = alphaScale;
1892
+ out.iid = iid;
1893
+ return out;
1894
+ }
1895
+
1896
+ @fragment
1897
+ fn fmain(in: VOut) -> @location(0) vec4f {
1898
+ if (in.aliveF < 0.5) { discard; }
1899
+
1900
+ let d = shapeSdf(in.uv);
1901
+ let aa = fwidth(d);
1902
+ let coverage = 1.0 - smoothstep(-aa, aa, d);
1903
+ if (coverage < 0.003) { discard; }
1904
+
1905
+ let p = loadParticle(in.iid);
1906
+ let base = fragColor(p);
1907
+ let a = base.a * coverage * in.alphaScale;
1908
+ let rgb = base.rgb * a;
1909
+ return vec4f(rgb, a);
1910
+ }
1911
+ `;
1912
+ }
1913
+ /**
1914
+ * Compile the render pipeline and allocate its render-side bind groups. The
1915
+ * compute-side bind groups in `state` use COMPUTE visibility; the render
1916
+ * pipeline needs VERTEX|FRAGMENT, so we build a parallel pair here.
1917
+ */
1918
+ function createParticleRenderPipeline(opts) {
1919
+ const { device, state, colorFormat, cameraBindGroupLayout, shader } = opts;
1920
+ const label = opts.label ?? "particles.render";
1921
+ const particleVisibility = GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT;
1922
+ const particleBindGroupLayout = device.createBindGroupLayout({
1923
+ label: `${label}.particleLayout`,
1924
+ entries: [
1925
+ 0,
1926
+ 1,
1927
+ 2,
1928
+ 3,
1929
+ 4
1930
+ ].map((binding) => ({
1931
+ binding,
1932
+ visibility: particleVisibility,
1933
+ buffer: { type: "read-only-storage" }
1934
+ }))
1935
+ });
1936
+ const paramsBindGroupLayout = device.createBindGroupLayout({
1937
+ label: `${label}.paramsLayout`,
1938
+ entries: [{
1939
+ binding: 0,
1940
+ visibility: particleVisibility,
1941
+ buffer: { type: "uniform" }
1942
+ }]
1943
+ });
1944
+ const lutBindGroupLayout = shader.fade === "lut" ? device.createBindGroupLayout({
1945
+ label: `${label}.lutLayout`,
1946
+ entries: [{
1947
+ binding: 0,
1948
+ visibility: GPUShaderStage.VERTEX,
1949
+ texture: {
1950
+ sampleType: "float",
1951
+ viewDimension: "2d"
1952
+ }
1953
+ }, {
1954
+ binding: 1,
1955
+ visibility: GPUShaderStage.VERTEX,
1956
+ sampler: { type: "filtering" }
1957
+ }]
1958
+ }) : null;
1959
+ const particleBindGroup = device.createBindGroup({
1960
+ label: `${label}.particleGroup`,
1961
+ layout: particleBindGroupLayout,
1962
+ entries: [
1963
+ {
1964
+ binding: 0,
1965
+ resource: { buffer: state.builtins.positions }
1966
+ },
1967
+ {
1968
+ binding: 1,
1969
+ resource: { buffer: state.builtins.alive }
1970
+ },
1971
+ {
1972
+ binding: 2,
1973
+ resource: { buffer: state.builtins.velocities }
1974
+ },
1975
+ {
1976
+ binding: 3,
1977
+ resource: { buffer: state.builtins.ages }
1978
+ },
1979
+ {
1980
+ binding: 4,
1981
+ resource: { buffer: state.builtins.lifetimes }
1982
+ }
1983
+ ]
1984
+ });
1985
+ const paramsBuffer = device.createBuffer({
1986
+ label: `${label}.params`,
1987
+ size: 32,
1988
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
1989
+ });
1990
+ const paramsBindGroup = device.createBindGroup({
1991
+ label: `${label}.paramsGroup`,
1992
+ layout: paramsBindGroupLayout,
1993
+ entries: [{
1994
+ binding: 0,
1995
+ resource: { buffer: paramsBuffer }
1996
+ }]
1997
+ });
1998
+ const module = device.createShaderModule({
1999
+ label: `${label}.shader`,
2000
+ code: buildParticleRenderWgsl(shader)
2001
+ });
2002
+ const bindGroupLayouts = [
2003
+ cameraBindGroupLayout,
2004
+ particleBindGroupLayout,
2005
+ paramsBindGroupLayout
2006
+ ];
2007
+ if (lutBindGroupLayout) bindGroupLayouts.push(lutBindGroupLayout);
2008
+ const pipelineLayout = device.createPipelineLayout({
2009
+ label: `${label}.pipelineLayout`,
2010
+ bindGroupLayouts
2011
+ });
2012
+ return {
2013
+ pipeline: device.createRenderPipeline({
2014
+ label: `${label}.pipeline`,
2015
+ layout: pipelineLayout,
2016
+ vertex: {
2017
+ module,
2018
+ entryPoint: "vmain"
2019
+ },
2020
+ fragment: {
2021
+ module,
2022
+ entryPoint: "fmain",
2023
+ targets: [{
2024
+ format: colorFormat,
2025
+ blend: ALPHA_BLEND
2026
+ }]
2027
+ },
2028
+ primitive: {
2029
+ topology: "triangle-list",
2030
+ cullMode: "none"
2031
+ },
2032
+ depthStencil: {
2033
+ format: DEPTH_FORMAT,
2034
+ depthWriteEnabled: false,
2035
+ depthCompare: "always"
2036
+ },
2037
+ multisample: { count: opts.sampleCount ?? 1 }
2038
+ }),
2039
+ particleBindGroupLayout,
2040
+ paramsBindGroupLayout,
2041
+ lutBindGroupLayout,
2042
+ particleBindGroup,
2043
+ paramsBindGroup,
2044
+ paramsBuffer,
2045
+ fade: shader.fade
2046
+ };
2047
+ }
2048
+ /**
2049
+ * Pack a `RenderParams` struct into a writable view. Matches the WGSL layout:
2050
+ * color: vec4f (offset 0, 16 bytes)
2051
+ * size: f32 (offset 16, 4 bytes)
2052
+ * velocityStretch: f32 (offset 20, 4 bytes)
2053
+ * fadeByAge: f32 (offset 24, 4 bytes) — 0 or 1
2054
+ * _pad0 f32 (offset 28, 4 bytes)
2055
+ */
2056
+ function writeRenderParams(buffer, opts) {
2057
+ buffer[0] = opts.color[0];
2058
+ buffer[1] = opts.color[1];
2059
+ buffer[2] = opts.color[2];
2060
+ buffer[3] = opts.color[3];
2061
+ buffer[4] = opts.size;
2062
+ buffer[5] = opts.velocityStretch ?? 0;
2063
+ buffer[6] = opts.fadeEnabled ? 1 : 0;
2064
+ buffer[7] = 0;
2065
+ }
2066
+ //#endregion
2067
+ //#region src/particles/render/drawable-v3.ts
2068
+ const DEFAULT_COLOR = {
2069
+ r: 1,
2070
+ g: 1,
2071
+ b: 1,
2072
+ a: 1
2073
+ };
2074
+ const DEFAULT_SIZE = 4;
2075
+ const LUT_SIZE = 64;
2076
+ function resolveShape(opt) {
2077
+ return opt ?? "circle";
2078
+ }
2079
+ function resolveColor(opt) {
2080
+ return opt.colorWgsl !== void 0 ? { wgsl: opt.colorWgsl } : "uniform";
2081
+ }
2082
+ function resolveFadeMode(opt) {
2083
+ if (!opt) return "none";
2084
+ if (opt === true) return "linear";
2085
+ return "lut";
2086
+ }
2087
+ var ParticleDrawableV3 = class {
2088
+ __customDrawable = true;
2089
+ space;
2090
+ clipRect = void 0;
2091
+ opaque = false;
2092
+ device;
2093
+ bundle;
2094
+ drawArgsBuffer;
2095
+ scratch = new Float32Array(32 / 4);
2096
+ lutTexture = null;
2097
+ lutSampler = null;
2098
+ lutBindGroup = null;
2099
+ size;
2100
+ color;
2101
+ velocityStretch;
2102
+ fadeByAge;
2103
+ destroyed = false;
2104
+ constructor(renderer, state, options = {}) {
2105
+ this.device = renderer.device;
2106
+ this.space = options.space ?? "world";
2107
+ this.size = options.size ?? DEFAULT_SIZE;
2108
+ this.color = options.color ?? DEFAULT_COLOR;
2109
+ this.velocityStretch = options.velocityStretch ?? 0;
2110
+ this.fadeByAge = options.fadeByAge ?? false;
2111
+ this.drawArgsBuffer = state.indirect.drawArgs;
2112
+ const fadeMode = resolveFadeMode(this.fadeByAge);
2113
+ this.bundle = createParticleRenderPipeline({
2114
+ device: this.device,
2115
+ state,
2116
+ colorFormat: renderer.colorFormat,
2117
+ cameraBindGroupLayout: renderer.getCameraBindGroupLayout(),
2118
+ shader: {
2119
+ shape: resolveShape(options.shape),
2120
+ color: resolveColor(options),
2121
+ fade: fadeMode
2122
+ },
2123
+ sampleCount: 1
2124
+ });
2125
+ if (fadeMode === "lut" && this.bundle.lutBindGroupLayout) this.buildLut(typeof this.fadeByAge === "object" ? this.fadeByAge.curve : (t) => 1 - t);
2126
+ this.uploadParams();
2127
+ }
2128
+ /**
2129
+ * Replace color / size / stretch / fade flag. Takes effect on the next
2130
+ * frame. Does NOT rebuild the pipeline — shape and colorWgsl are baked in
2131
+ * at construction. Swapping a curve function re-uploads the LUT texture.
2132
+ */
2133
+ setOptions(options) {
2134
+ if (options.size !== void 0) this.size = options.size;
2135
+ if (options.color !== void 0) this.color = options.color;
2136
+ if (options.velocityStretch !== void 0) this.velocityStretch = options.velocityStretch;
2137
+ if (options.fadeByAge !== void 0) {
2138
+ const nextMode = resolveFadeMode(options.fadeByAge);
2139
+ if (nextMode !== resolveFadeMode(this.fadeByAge)) throw new Error("ParticleDrawableV3.setOptions: fadeByAge mode (off/linear/curve) is baked into the pipeline at construction. Create a new drawable to switch.");
2140
+ this.fadeByAge = options.fadeByAge;
2141
+ if (nextMode === "lut" && typeof this.fadeByAge === "object") this.buildLut(this.fadeByAge.curve);
2142
+ }
2143
+ this.uploadParams();
2144
+ }
2145
+ record(pass, ctx) {
2146
+ if (this.destroyed) return;
2147
+ pass.setPipeline(this.bundle.pipeline);
2148
+ pass.setBindGroup(0, ctx.cameraBindGroup);
2149
+ pass.setBindGroup(1, this.bundle.particleBindGroup);
2150
+ pass.setBindGroup(2, this.bundle.paramsBindGroup);
2151
+ if (this.lutBindGroup) pass.setBindGroup(3, this.lutBindGroup);
2152
+ pass.drawIndirect(this.drawArgsBuffer, 0);
2153
+ }
2154
+ destroy() {
2155
+ if (this.destroyed) return;
2156
+ this.bundle.paramsBuffer.destroy();
2157
+ this.lutTexture?.destroy();
2158
+ this.destroyed = true;
2159
+ }
2160
+ buildLut(curve) {
2161
+ if (!this.lutTexture) {
2162
+ this.lutTexture = this.device.createTexture({
2163
+ label: "particles.render.ageLut",
2164
+ size: {
2165
+ width: LUT_SIZE,
2166
+ height: 1
2167
+ },
2168
+ format: "r8unorm",
2169
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
2170
+ });
2171
+ this.lutSampler = this.device.createSampler({
2172
+ label: "particles.render.ageLutSampler",
2173
+ magFilter: "linear",
2174
+ minFilter: "linear",
2175
+ addressModeU: "clamp-to-edge"
2176
+ });
2177
+ if (this.bundle.lutBindGroupLayout) this.lutBindGroup = this.device.createBindGroup({
2178
+ label: "particles.render.ageLutGroup",
2179
+ layout: this.bundle.lutBindGroupLayout,
2180
+ entries: [{
2181
+ binding: 0,
2182
+ resource: this.lutTexture.createView()
2183
+ }, {
2184
+ binding: 1,
2185
+ resource: this.lutSampler
2186
+ }]
2187
+ });
2188
+ }
2189
+ const bytes = new Uint8Array(LUT_SIZE);
2190
+ for (let i = 0; i < LUT_SIZE; i++) {
2191
+ const v = curve(i / (LUT_SIZE - 1));
2192
+ bytes[i] = Math.max(0, Math.min(255, Math.round(v * 255)));
2193
+ }
2194
+ this.device.queue.writeTexture({ texture: this.lutTexture }, bytes, {
2195
+ bytesPerRow: LUT_SIZE,
2196
+ rowsPerImage: 1
2197
+ }, {
2198
+ width: LUT_SIZE,
2199
+ height: 1
2200
+ });
2201
+ }
2202
+ uploadParams() {
2203
+ writeRenderParams(this.scratch, {
2204
+ color: [
2205
+ this.color.r,
2206
+ this.color.g,
2207
+ this.color.b,
2208
+ this.color.a
2209
+ ],
2210
+ size: this.size,
2211
+ velocityStretch: this.velocityStretch,
2212
+ fadeEnabled: !!this.fadeByAge
2213
+ });
2214
+ this.device.queue.writeBuffer(this.bundle.paramsBuffer, 0, this.scratch);
2215
+ }
2216
+ };
2217
+ //#endregion
2218
+ //#region src/particles/spatial-hash.ts
2219
+ /**
2220
+ * Return an identifier usable as a map key for a `reach` request. Two forces
2221
+ * with reach values within 0.001 world-units share a hash.
2222
+ */
2223
+ function quantizeReach(reach) {
2224
+ return Math.round(reach * 1e3) / 1e3;
2225
+ }
2226
+ /**
2227
+ * Smallest power of two ≥ `n`, with a hard floor of 16 so the mask-based
2228
+ * bucket lookup never degenerates to 0.
2229
+ */
2230
+ function nextPow2(n) {
2231
+ let p = 16;
2232
+ while (p < n) p <<= 1;
2233
+ return p;
2234
+ }
2235
+ const CLEAR_WGSL = `${SPATIAL_HASH_PARAMS_WGSL}
2236
+ @group(0) @binding(0) var<storage, read_write> heads: array<atomic<u32>>;
2237
+ @group(0) @binding(1) var<uniform> params: SpatialHashParams;
2238
+
2239
+ @compute @workgroup_size(64)
2240
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
2241
+ let i = gid.x;
2242
+ if (i >= params.bucketCount) { return; }
2243
+ atomicStore(&heads[i], EMPTY_SLOT);
2244
+ }
2245
+ `;
2246
+ const BUILD_WGSL = `${SPATIAL_HASH_PARAMS_WGSL}
2247
+ @group(0) @binding(0) var<storage, read_write> heads: array<atomic<u32>>;
2248
+ @group(0) @binding(1) var<storage, read_write> next: array<u32>;
2249
+ @group(0) @binding(2) var<storage, read_write> cells: array<vec2i>;
2250
+ @group(0) @binding(3) var<storage, read> positions: array<vec2f>;
2251
+ @group(0) @binding(4) var<storage, read> alive: array<u32>;
2252
+ @group(0) @binding(5) var<storage, read> aliveCount: u32;
2253
+ @group(0) @binding(6) var<uniform> params: SpatialHashParams;
2254
+
2255
+ @compute @workgroup_size(64)
2256
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
2257
+ let i = gid.x;
2258
+ if (i >= aliveCount) { return; }
2259
+ next[i] = EMPTY_SLOT;
2260
+ if (alive[i] == 0u) {
2261
+ // Dead slot: park at a cell that can never match a live particle's 3×3
2262
+ // neighbourhood (INT_MAX). Keeps the accumulate loop's cell-equality
2263
+ // guard honest without an extra alive check per neighbour.
2264
+ cells[i] = vec2i(2147483647, 2147483647);
2265
+ return;
2266
+ }
2267
+ let cell = worldCell(positions[i], params.invCellSize);
2268
+ cells[i] = cell;
2269
+ let bucket = hashCell(cell, params.bucketMask);
2270
+ let prev = atomicExchange(&heads[bucket], i);
2271
+ next[i] = prev;
2272
+ }
2273
+ `;
2274
+ var ParticleSpatialHash = class {
2275
+ reach;
2276
+ cellSize;
2277
+ bucketCount;
2278
+ /** Buffers consumers bind in their accumulate kernels (heads/next/cells/params). */
2279
+ consumeBindGroupLayout;
2280
+ consumeBindGroup;
2281
+ device;
2282
+ heads;
2283
+ next;
2284
+ cells;
2285
+ paramsBuffer;
2286
+ clearBindGroup;
2287
+ clearPipeline;
2288
+ clearWorkgroups;
2289
+ buildBindGroup;
2290
+ buildPipeline;
2291
+ constructor(opts) {
2292
+ const { root, state, reach } = opts;
2293
+ this.device = root.device;
2294
+ this.reach = reach;
2295
+ const cellSize = 2 * Math.max(reach, .001);
2296
+ this.cellSize = cellSize;
2297
+ this.bucketCount = nextPow2(Math.max(state.capacity * 4, 16));
2298
+ this.clearWorkgroups = Math.ceil(this.bucketCount / 64);
2299
+ const label = opts.label ?? `particles.hash.r${reach}`;
2300
+ this.heads = this.device.createBuffer({
2301
+ label: `${label}.heads`,
2302
+ size: this.bucketCount * 4,
2303
+ usage: GPUBufferUsage.STORAGE
2304
+ });
2305
+ this.next = this.device.createBuffer({
2306
+ label: `${label}.next`,
2307
+ size: Math.max(state.capacity, 4) * 4,
2308
+ usage: GPUBufferUsage.STORAGE
2309
+ });
2310
+ this.cells = this.device.createBuffer({
2311
+ label: `${label}.cells`,
2312
+ size: Math.max(state.capacity, 2) * 8,
2313
+ usage: GPUBufferUsage.STORAGE
2314
+ });
2315
+ this.paramsBuffer = this.device.createBuffer({
2316
+ label: `${label}.params`,
2317
+ size: 32,
2318
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
2319
+ });
2320
+ {
2321
+ const ab = /* @__PURE__ */ new ArrayBuffer(32);
2322
+ const u = new Uint32Array(ab);
2323
+ const f = new Float32Array(ab);
2324
+ u[0] = 0;
2325
+ u[1] = 0;
2326
+ u[2] = this.bucketCount - 1;
2327
+ u[3] = this.bucketCount;
2328
+ f[4] = 1 / cellSize;
2329
+ f[5] = 1;
2330
+ f[6] = 0;
2331
+ f[7] = 0;
2332
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, ab);
2333
+ }
2334
+ const clearLayout = this.device.createBindGroupLayout({
2335
+ label: `${label}.clear.layout`,
2336
+ entries: [{
2337
+ binding: 0,
2338
+ visibility: GPUShaderStage.COMPUTE,
2339
+ buffer: { type: "storage" }
2340
+ }, {
2341
+ binding: 1,
2342
+ visibility: GPUShaderStage.COMPUTE,
2343
+ buffer: { type: "uniform" }
2344
+ }]
2345
+ });
2346
+ this.clearBindGroup = this.device.createBindGroup({
2347
+ label: `${label}.clear.group`,
2348
+ layout: clearLayout,
2349
+ entries: [{
2350
+ binding: 0,
2351
+ resource: { buffer: this.heads }
2352
+ }, {
2353
+ binding: 1,
2354
+ resource: { buffer: this.paramsBuffer }
2355
+ }]
2356
+ });
2357
+ this.clearPipeline = this.device.createComputePipeline({
2358
+ label: `${label}.clear.pipeline`,
2359
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [clearLayout] }),
2360
+ compute: {
2361
+ module: this.device.createShaderModule({
2362
+ label: `${label}.clear.shader`,
2363
+ code: CLEAR_WGSL
2364
+ }),
2365
+ entryPoint: "main"
2366
+ }
2367
+ });
2368
+ const buildLayout = this.device.createBindGroupLayout({
2369
+ label: `${label}.build.layout`,
2370
+ entries: [
2371
+ {
2372
+ binding: 0,
2373
+ visibility: GPUShaderStage.COMPUTE,
2374
+ buffer: { type: "storage" }
2375
+ },
2376
+ {
2377
+ binding: 1,
2378
+ visibility: GPUShaderStage.COMPUTE,
2379
+ buffer: { type: "storage" }
2380
+ },
2381
+ {
2382
+ binding: 2,
2383
+ visibility: GPUShaderStage.COMPUTE,
2384
+ buffer: { type: "storage" }
2385
+ },
2386
+ {
2387
+ binding: 3,
2388
+ visibility: GPUShaderStage.COMPUTE,
2389
+ buffer: { type: "read-only-storage" }
2390
+ },
2391
+ {
2392
+ binding: 4,
2393
+ visibility: GPUShaderStage.COMPUTE,
2394
+ buffer: { type: "read-only-storage" }
2395
+ },
2396
+ {
2397
+ binding: 5,
2398
+ visibility: GPUShaderStage.COMPUTE,
2399
+ buffer: { type: "read-only-storage" }
2400
+ },
2401
+ {
2402
+ binding: 6,
2403
+ visibility: GPUShaderStage.COMPUTE,
2404
+ buffer: { type: "uniform" }
2405
+ }
2406
+ ]
2407
+ });
2408
+ this.buildBindGroup = this.device.createBindGroup({
2409
+ label: `${label}.build.group`,
2410
+ layout: buildLayout,
2411
+ entries: [
2412
+ {
2413
+ binding: 0,
2414
+ resource: { buffer: this.heads }
2415
+ },
2416
+ {
2417
+ binding: 1,
2418
+ resource: { buffer: this.next }
2419
+ },
2420
+ {
2421
+ binding: 2,
2422
+ resource: { buffer: this.cells }
2423
+ },
2424
+ {
2425
+ binding: 3,
2426
+ resource: { buffer: state.builtins.positions }
2427
+ },
2428
+ {
2429
+ binding: 4,
2430
+ resource: { buffer: state.builtins.alive }
2431
+ },
2432
+ {
2433
+ binding: 5,
2434
+ resource: { buffer: state.builtins.aliveCount }
2435
+ },
2436
+ {
2437
+ binding: 6,
2438
+ resource: { buffer: this.paramsBuffer }
2439
+ }
2440
+ ]
2441
+ });
2442
+ this.buildPipeline = this.device.createComputePipeline({
2443
+ label: `${label}.build.pipeline`,
2444
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [buildLayout] }),
2445
+ compute: {
2446
+ module: this.device.createShaderModule({
2447
+ label: `${label}.build.shader`,
2448
+ code: BUILD_WGSL
2449
+ }),
2450
+ entryPoint: "main"
2451
+ }
2452
+ });
2453
+ this.consumeBindGroupLayout = this.device.createBindGroupLayout({
2454
+ label: `${label}.consume.layout`,
2455
+ entries: [
2456
+ {
2457
+ binding: 0,
2458
+ visibility: GPUShaderStage.COMPUTE,
2459
+ buffer: { type: "read-only-storage" }
2460
+ },
2461
+ {
2462
+ binding: 1,
2463
+ visibility: GPUShaderStage.COMPUTE,
2464
+ buffer: { type: "read-only-storage" }
2465
+ },
2466
+ {
2467
+ binding: 2,
2468
+ visibility: GPUShaderStage.COMPUTE,
2469
+ buffer: { type: "read-only-storage" }
2470
+ },
2471
+ {
2472
+ binding: 3,
2473
+ visibility: GPUShaderStage.COMPUTE,
2474
+ buffer: { type: "uniform" }
2475
+ }
2476
+ ]
2477
+ });
2478
+ this.consumeBindGroup = this.device.createBindGroup({
2479
+ label: `${label}.consume.group`,
2480
+ layout: this.consumeBindGroupLayout,
2481
+ entries: [
2482
+ {
2483
+ binding: 0,
2484
+ resource: { buffer: this.heads }
2485
+ },
2486
+ {
2487
+ binding: 1,
2488
+ resource: { buffer: this.next }
2489
+ },
2490
+ {
2491
+ binding: 2,
2492
+ resource: { buffer: this.cells }
2493
+ },
2494
+ {
2495
+ binding: 3,
2496
+ resource: { buffer: this.paramsBuffer }
2497
+ }
2498
+ ]
2499
+ });
2500
+ }
2501
+ /**
2502
+ * Record clear + indirect build onto the open compute pass. Caller supplies
2503
+ * the same `dispatchArgs` buffer `prepareIndirect` wrote this frame.
2504
+ */
2505
+ record(pass, dispatchArgs) {
2506
+ pass.setPipeline(this.clearPipeline);
2507
+ pass.setBindGroup(0, this.clearBindGroup);
2508
+ pass.dispatchWorkgroups(this.clearWorkgroups);
2509
+ pass.setPipeline(this.buildPipeline);
2510
+ pass.setBindGroup(0, this.buildBindGroup);
2511
+ pass.dispatchWorkgroupsIndirect(dispatchArgs, 0);
2512
+ }
2513
+ destroy() {
2514
+ this.heads.destroy();
2515
+ this.next.destroy();
2516
+ this.cells.destroy();
2517
+ this.paramsBuffer.destroy();
2518
+ }
2519
+ };
2520
+ /**
2521
+ * WGSL snippet declaring the consumer spatial-hash bindings at a given bind
2522
+ * group. Pairwise kernels embed this after the particle prelude.
2523
+ */
2524
+ function spatialHashConsumerWgsl(group) {
2525
+ return `${SPATIAL_HASH_PARAMS_WGSL}
2526
+ @group(${group}) @binding(0) var<storage, read> heads: array<u32>;
2527
+ @group(${group}) @binding(1) var<storage, read> next: array<u32>;
2528
+ @group(${group}) @binding(2) var<storage, read> cells: array<vec2i>;
2529
+ @group(${group}) @binding(3) var<uniform> hashParams: SpatialHashParams;
2530
+ `;
2531
+ }
2532
+ //#endregion
2533
+ //#region src/particles/state.ts
2534
+ const BUILTIN_STRIDES = {
2535
+ positions: 8,
2536
+ velocities: 8,
2537
+ accelPrev: 8,
2538
+ accel: 8,
2539
+ ages: 4,
2540
+ lifetimes: 4,
2541
+ alive: 4
2542
+ };
2543
+ /**
2544
+ * Built-in attributes that must survive compaction. `accel` is excluded
2545
+ * because it is zeroed at the start of every step; `alive` is excluded
2546
+ * because the alive-rewrite pass regenerates it from the new count.
2547
+ */
2548
+ const SCATTERED_BUILTINS = [
2549
+ {
2550
+ name: "positions",
2551
+ wgslType: "vec2f",
2552
+ strideBytes: 8
2553
+ },
2554
+ {
2555
+ name: "velocities",
2556
+ wgslType: "vec2f",
2557
+ strideBytes: 8
2558
+ },
2559
+ {
2560
+ name: "accelPrev",
2561
+ wgslType: "vec2f",
2562
+ strideBytes: 8
2563
+ },
2564
+ {
2565
+ name: "ages",
2566
+ wgslType: "f32",
2567
+ strideBytes: 4
2568
+ },
2569
+ {
2570
+ name: "lifetimes",
2571
+ wgslType: "f32",
2572
+ strideBytes: 4
2573
+ }
2574
+ ];
2575
+ function createParticleState(opts) {
2576
+ const { root, capacity } = opts;
2577
+ if (!Number.isInteger(capacity) || capacity <= 0) throw new Error(`ParticleSystem: capacity must be a positive integer (got ${capacity})`);
2578
+ if (capacity > 262144) throw new Error(`ParticleSystem: capacity ${capacity} exceeds the single-pass-B scan limit (${COMPACT_MAX_CAPACITY}). Hierarchical scan is v2; lower the capacity or split into multiple systems.`);
2579
+ const catalog = validateAttributeCatalog(opts.attributes ?? {});
2580
+ const device = root.device;
2581
+ const label = opts.label ?? "particles";
2582
+ const builtins = {
2583
+ positions: createStorage(device, `${label}.positions`, capacity * BUILTIN_STRIDES.positions),
2584
+ velocities: createStorage(device, `${label}.velocities`, capacity * BUILTIN_STRIDES.velocities),
2585
+ accelPrev: createStorage(device, `${label}.accelPrev`, capacity * BUILTIN_STRIDES.accelPrev),
2586
+ accel: createStorage(device, `${label}.accel`, capacity * BUILTIN_STRIDES.accel),
2587
+ ages: createStorage(device, `${label}.ages`, capacity * BUILTIN_STRIDES.ages),
2588
+ lifetimes: createStorage(device, `${label}.lifetimes`, capacity * BUILTIN_STRIDES.lifetimes),
2589
+ alive: createStorage(device, `${label}.alive`, capacity * BUILTIN_STRIDES.alive),
2590
+ aliveCount: device.createBuffer({
2591
+ label: `${label}.aliveCount`,
2592
+ size: 4,
2593
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST
2594
+ })
2595
+ };
2596
+ const indirect = {
2597
+ dispatchArgs: device.createBuffer({
2598
+ label: `${label}.dispatchArgs`,
2599
+ size: 12,
2600
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST
2601
+ }),
2602
+ drawArgs: device.createBuffer({
2603
+ label: `${label}.drawArgs`,
2604
+ size: 16,
2605
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST
2606
+ })
2607
+ };
2608
+ const userAttrs = catalogEntries(catalog).map(([name, type], i) => {
2609
+ const info = ATTRIBUTE_TYPE_INFO[type];
2610
+ return {
2611
+ name,
2612
+ type,
2613
+ strideBytes: info.strideBytes,
2614
+ bindingIndex: i,
2615
+ buffer: createStorage(device, `${label}.attr.${name}`, capacity * info.strideBytes)
2616
+ };
2617
+ });
2618
+ const stateBindGroupLayout = device.createBindGroupLayout({
2619
+ label: `${label}.state.layout`,
2620
+ entries: [
2621
+ {
2622
+ binding: 0,
2623
+ visibility: GPUShaderStage.COMPUTE,
2624
+ buffer: { type: "read-only-storage" }
2625
+ },
2626
+ {
2627
+ binding: 1,
2628
+ visibility: GPUShaderStage.COMPUTE,
2629
+ buffer: { type: "read-only-storage" }
2630
+ },
2631
+ {
2632
+ binding: 2,
2633
+ visibility: GPUShaderStage.COMPUTE,
2634
+ buffer: { type: "read-only-storage" }
2635
+ },
2636
+ {
2637
+ binding: 3,
2638
+ visibility: GPUShaderStage.COMPUTE,
2639
+ buffer: { type: "storage" }
2640
+ },
2641
+ {
2642
+ binding: 4,
2643
+ visibility: GPUShaderStage.COMPUTE,
2644
+ buffer: { type: "read-only-storage" }
2645
+ },
2646
+ {
2647
+ binding: 5,
2648
+ visibility: GPUShaderStage.COMPUTE,
2649
+ buffer: { type: "read-only-storage" }
2650
+ },
2651
+ {
2652
+ binding: 6,
2653
+ visibility: GPUShaderStage.COMPUTE,
2654
+ buffer: { type: "read-only-storage" }
2655
+ },
2656
+ {
2657
+ binding: 7,
2658
+ visibility: GPUShaderStage.COMPUTE,
2659
+ buffer: { type: "storage" }
2660
+ }
2661
+ ]
2662
+ });
2663
+ const userAttrBindGroupLayout = device.createBindGroupLayout({
2664
+ label: `${label}.userAttrs.layout`,
2665
+ entries: userAttrs.map((attr) => ({
2666
+ binding: attr.bindingIndex,
2667
+ visibility: GPUShaderStage.COMPUTE,
2668
+ buffer: { type: "read-only-storage" }
2669
+ }))
2670
+ });
2671
+ const stateBindGroup = device.createBindGroup({
2672
+ label: `${label}.state.group`,
2673
+ layout: stateBindGroupLayout,
2674
+ entries: [
2675
+ {
2676
+ binding: 0,
2677
+ resource: { buffer: builtins.positions }
2678
+ },
2679
+ {
2680
+ binding: 1,
2681
+ resource: { buffer: builtins.velocities }
2682
+ },
2683
+ {
2684
+ binding: 2,
2685
+ resource: { buffer: builtins.accelPrev }
2686
+ },
2687
+ {
2688
+ binding: 3,
2689
+ resource: { buffer: builtins.accel }
2690
+ },
2691
+ {
2692
+ binding: 4,
2693
+ resource: { buffer: builtins.ages }
2694
+ },
2695
+ {
2696
+ binding: 5,
2697
+ resource: { buffer: builtins.lifetimes }
2698
+ },
2699
+ {
2700
+ binding: 6,
2701
+ resource: { buffer: builtins.alive }
2702
+ },
2703
+ {
2704
+ binding: 7,
2705
+ resource: { buffer: builtins.aliveCount }
2706
+ }
2707
+ ]
2708
+ });
2709
+ const userAttrBindGroup = device.createBindGroup({
2710
+ label: `${label}.userAttrs.group`,
2711
+ layout: userAttrBindGroupLayout,
2712
+ entries: userAttrs.map((attr) => ({
2713
+ binding: attr.bindingIndex,
2714
+ resource: { buffer: attr.buffer }
2715
+ }))
2716
+ });
2717
+ const builtinScatter = SCATTERED_BUILTINS.map((info) => ({
2718
+ name: info.name,
2719
+ wgslType: info.wgslType,
2720
+ strideBytes: info.strideBytes,
2721
+ main: builtins[info.name],
2722
+ scratch: createStorage(device, `${label}.scratch.${info.name}`, capacity * info.strideBytes)
2723
+ }));
2724
+ const userScatter = userAttrs.map((attr) => ({
2725
+ name: attr.name,
2726
+ wgslType: ATTRIBUTE_TYPE_INFO[attr.type].wgsl,
2727
+ strideBytes: attr.strideBytes,
2728
+ main: attr.buffer,
2729
+ scratch: createStorage(device, `${label}.scratch.attr.${attr.name}`, capacity * attr.strideBytes)
2730
+ }));
2731
+ const scatteredAttributes = [...builtinScatter, ...userScatter];
2732
+ const blockCount = Math.ceil(capacity / COMPACT_ELEMENTS_PER_BLOCK);
2733
+ const compaction = {
2734
+ scanLocal: createStorage(device, `${label}.compaction.scanLocal`, capacity * 4),
2735
+ blockTotals: createStorage(device, `${label}.compaction.blockTotals`, blockCount * 4),
2736
+ blockOffsets: createStorage(device, `${label}.compaction.blockOffsets`, blockCount * 4),
2737
+ emitReserve: createStorage(device, `${label}.compaction.emitReserve`, 8),
2738
+ blockCount
2739
+ };
2740
+ return {
2741
+ capacity,
2742
+ catalog,
2743
+ builtins,
2744
+ indirect,
2745
+ compaction,
2746
+ userAttrs,
2747
+ scatteredAttributes,
2748
+ stateBindGroupLayout,
2749
+ userAttrBindGroupLayout,
2750
+ stateBindGroup,
2751
+ userAttrBindGroup,
2752
+ destroy() {
2753
+ for (const b of Object.values(builtins)) b.destroy();
2754
+ indirect.dispatchArgs.destroy();
2755
+ indirect.drawArgs.destroy();
2756
+ for (const attr of userAttrs) attr.buffer.destroy();
2757
+ for (const attr of scatteredAttributes) attr.scratch.destroy();
2758
+ compaction.scanLocal.destroy();
2759
+ compaction.blockTotals.destroy();
2760
+ compaction.blockOffsets.destroy();
2761
+ compaction.emitReserve.destroy();
2762
+ }
2763
+ };
2764
+ }
2765
+ function createStorage(device, label, size) {
2766
+ const paddedSize = Math.max(size, 16);
2767
+ return device.createBuffer({
2768
+ label,
2769
+ size: paddedSize,
2770
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST
2771
+ });
2772
+ }
2773
+ //#endregion
2774
+ //#region src/particles/system.ts
2775
+ /**
2776
+ * GPU particle system. Manages a fixed-capacity pool of particles with SoA
2777
+ * attribute storage, a pluggable force chain, CPU-driven emission, and
2778
+ * dedicated draw-indirect rendering.
2779
+ *
2780
+ * Typical usage:
2781
+ *
2782
+ * ```ts
2783
+ * const system = new ParticleSystem(renderer.getRoot(), {
2784
+ * capacity: 20_000,
2785
+ * bounds: { kind: "wrap", rect: [-400, -300, 400, 300] },
2786
+ * });
2787
+ * system.addForce(drag({ coefficient: 0.2 }));
2788
+ * system.addForce(gravity({ direction: [0, 1], strength: 300 }));
2789
+ * const drawable = system.createDrawable(renderer, { fadeByAge: true });
2790
+ *
2791
+ * // per frame:
2792
+ * system.emit({ count: 20, position: {...}, velocity: {...} });
2793
+ * system.step(dt);
2794
+ * renderer.render([drawable]);
2795
+ * ```
2796
+ *
2797
+ * Capacity is bounded at construction (no auto-grow). The compaction
2798
+ * module runs every step to keep the alive list dense; `getAliveCount()`
2799
+ * returns the GPU-authoritative count via a non-blocking readback.
2800
+ */
2801
+ var ParticleSystem = class {
2802
+ capacity;
2803
+ /** Attribute names in declaration order. Binding indices match this order. */
2804
+ attributeNames;
2805
+ /** @internal — exposed for kernel attach in later phases. */
2806
+ state;
2807
+ /** @internal — shared across every pipeline this system compiles. */
2808
+ pipelineCache = new PipelineCache();
2809
+ root;
2810
+ dt;
2811
+ drawables = [];
2812
+ emitQueue = [];
2813
+ forces = [];
2814
+ /** Spatial hashes keyed by quantized reach. Shared across pairwise forces. */
2815
+ hashes = /* @__PURE__ */ new Map();
2816
+ nextForceId = 1;
2817
+ emitStagingCursor = 0;
2818
+ emitPipeline = null;
2819
+ prepareIndirectPipeline = null;
2820
+ zeroAccelPipeline = null;
2821
+ integratorPipeline = null;
2822
+ boundsPipeline = null;
2823
+ compactionPipeline = null;
2824
+ aliveCountCache = 0;
2825
+ aliveCountReadback = null;
2826
+ aliveCountReadbackInFlight = false;
2827
+ destroyed = false;
2828
+ constructor(owner, options) {
2829
+ this.options = options;
2830
+ this.root = resolveRoot(owner);
2831
+ this.state = createParticleState({
2832
+ root: this.root,
2833
+ capacity: options.capacity,
2834
+ attributes: options.attributes
2835
+ });
2836
+ this.capacity = this.state.capacity;
2837
+ this.attributeNames = this.state.userAttrs.map((a) => a.name);
2838
+ this.dt = options.dt ?? 1 / 60;
2839
+ }
2840
+ getAttributeNames() {
2841
+ return this.attributeNames;
2842
+ }
2843
+ /**
2844
+ * Write raw data into a user-declared attribute buffer at the given element
2845
+ * offset. Intended for one-shot initialisation (e.g. seeding `species` after
2846
+ * emitting a fixed population) and interactive tooling — not a hot-path API;
2847
+ * the emit kernel does not yet write user attributes, so callers that need
2848
+ * per-emission attribute values fill them here directly.
2849
+ */
2850
+ writeAttribute(name, data, offsetElements = 0) {
2851
+ this.assertAlive();
2852
+ const attr = this.state.userAttrs.find((a) => a.name === name);
2853
+ if (!attr) throw new Error(`ParticleSystem.writeAttribute: '${name}' is not a declared attribute. Declared: ${this.attributeNames.join(", ") || "<none>"}.`);
2854
+ this.root.device.queue.writeBuffer(attr.buffer, offsetElements * attr.strideBytes, data.buffer, data.byteOffset, data.byteLength);
2855
+ }
2856
+ /**
2857
+ * Most recent live-particle count read from the GPU. aliveCount is
2858
+ * GPU-authoritative — the CPU does not mirror it exactly — so this returns
2859
+ * a cached value from the last completed readback. Calling this schedules
2860
+ * a fresh readback in the background (non-blocking); repeated callers
2861
+ * (e.g. a per-frame HUD) converge within one frame of latency. Returns 0
2862
+ * until the first readback completes, which normally takes 1-2 frames.
2863
+ */
2864
+ getAliveCount() {
2865
+ if (this.destroyed) return this.aliveCountCache;
2866
+ this.scheduleAliveCountReadback();
2867
+ return this.aliveCountCache;
2868
+ }
2869
+ scheduleAliveCountReadback() {
2870
+ if (this.aliveCountReadbackInFlight) return;
2871
+ const device = this.root.device;
2872
+ if (!this.aliveCountReadback) this.aliveCountReadback = device.createBuffer({
2873
+ label: "particles.aliveCount.readback",
2874
+ size: 4,
2875
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
2876
+ });
2877
+ const encoder = device.createCommandEncoder({ label: "particles.aliveCount.encoder" });
2878
+ encoder.copyBufferToBuffer(this.state.builtins.aliveCount, 0, this.aliveCountReadback, 0, 4);
2879
+ device.queue.submit([encoder.finish()]);
2880
+ this.aliveCountReadbackInFlight = true;
2881
+ const buffer = this.aliveCountReadback;
2882
+ buffer.mapAsync(GPUMapMode.READ).then(() => {
2883
+ if (this.destroyed) return;
2884
+ this.aliveCountCache = new Uint32Array(buffer.getMappedRange())[0] ?? 0;
2885
+ buffer.unmap();
2886
+ }).catch(() => {}).finally(() => {
2887
+ this.aliveCountReadbackInFlight = false;
2888
+ });
2889
+ }
2890
+ /**
2891
+ * Build a `CustomDrawable` that renders this system's live particles.
2892
+ * Pass to the v3 renderer's `render([...])`. Multiple drawables may share
2893
+ * a system.
2894
+ */
2895
+ createV3Drawable(renderer, options = {}) {
2896
+ this.assertAlive();
2897
+ const drawable = new ParticleDrawableV3(renderer, this.state, options);
2898
+ this.drawables.push(drawable);
2899
+ return drawable;
2900
+ }
2901
+ /**
2902
+ * Queue an emission. CPU-packs now; the staging upload + reserve + emit
2903
+ * dispatch run at the start of the next `step()`. Overflow past capacity
2904
+ * is dropped silently on the GPU (the reserve kernel clamps to the
2905
+ * remaining room). Multiple emits in one frame coalesce into one upload.
2906
+ */
2907
+ emit(spec) {
2908
+ if (this.destroyed) return;
2909
+ if (spec.count <= 0) return;
2910
+ const pipeline = this.ensureEmitPipeline();
2911
+ const batch = pipeline.packBatch(spec, this.emitStagingCursor);
2912
+ if (batch.count === 0) return;
2913
+ this.emitQueue.push(batch);
2914
+ this.emitStagingCursor += batch.count * pipeline.stagingStrideF32;
2915
+ }
2916
+ /**
2917
+ * Attach a force. Returns a numeric id that can later be passed to
2918
+ * `removeForce`. Forces run in registration order during each `step()`,
2919
+ * accumulating into the per-particle acceleration buffer before the
2920
+ * integrator sweep.
2921
+ */
2922
+ addForce(force) {
2923
+ this.assertAlive();
2924
+ const id = this.nextForceId++;
2925
+ const handle = force.attach({
2926
+ root: this.root,
2927
+ state: this.state,
2928
+ label: `particles.force${id}`,
2929
+ requestSpatialHash: (reach) => this.getOrCreateHash(reach)
2930
+ });
2931
+ this.forces.push({
2932
+ id,
2933
+ force,
2934
+ handle
2935
+ });
2936
+ return id;
2937
+ }
2938
+ getOrCreateHash(reach) {
2939
+ const key = quantizeReach(reach);
2940
+ const existing = this.hashes.get(key);
2941
+ if (existing) return existing;
2942
+ const hash = new ParticleSpatialHash({
2943
+ root: this.root,
2944
+ state: this.state,
2945
+ reach: key,
2946
+ label: `particles.hash.r${key}`
2947
+ });
2948
+ this.hashes.set(key, hash);
2949
+ return hash;
2950
+ }
2951
+ removeForce(id) {
2952
+ const idx = this.forces.findIndex((f) => f.id === id);
2953
+ if (idx < 0) return;
2954
+ this.forces[idx].handle.destroy();
2955
+ this.forces.splice(idx, 1);
2956
+ }
2957
+ hasForce(id) {
2958
+ return this.forces.some((f) => f.id === id);
2959
+ }
2960
+ /**
2961
+ * Advance the simulation by one tick. Records the full pipeline
2962
+ * (emission → prepareIndirect → zeroAccel → forces → integrate → bounds
2963
+ * → compaction + copyback) into a single command encoder and submits.
2964
+ *
2965
+ * `dt` overrides the system's configured timestep for this call only.
2966
+ * Useful for variable-rate rendering (e.g. reduce dt when catching up on
2967
+ * multiple frames).
2968
+ */
2969
+ step(dt = this.dt) {
2970
+ if (this.destroyed) return;
2971
+ const encoder = this.root.device.createCommandEncoder({ label: "particles.step" });
2972
+ if (this.pendingEmitCount() > 0) this.flushEmissions(encoder);
2973
+ const dispatchArgs = this.state.indirect.dispatchArgs;
2974
+ const pass = encoder.beginComputePass({ label: "particles.step.pass" });
2975
+ this.ensurePrepareIndirect().record(pass);
2976
+ this.ensureZeroAccel().record(pass);
2977
+ for (const hash of this.hashes.values()) hash.record(pass, dispatchArgs);
2978
+ const forceCtx = {
2979
+ dispatchArgs,
2980
+ dt
2981
+ };
2982
+ for (const { force, handle } of this.forces) {
2983
+ force.prepare?.(forceCtx);
2984
+ handle.record(pass, forceCtx);
2985
+ }
2986
+ this.ensureIntegrator().record(pass, dispatchArgs, dt);
2987
+ this.ensureBounds().record(pass, dispatchArgs);
2988
+ const compaction = this.ensureCompaction();
2989
+ compaction.record(pass);
2990
+ pass.end();
2991
+ compaction.recordCopyback(encoder);
2992
+ this.root.device.queue.submit([encoder.finish()]);
2993
+ }
2994
+ destroy() {
2995
+ if (this.destroyed) return;
2996
+ for (const d of this.drawables) d.destroy();
2997
+ this.drawables.length = 0;
2998
+ for (const { handle } of this.forces) handle.destroy();
2999
+ this.forces.length = 0;
3000
+ for (const hash of this.hashes.values()) hash.destroy();
3001
+ this.hashes.clear();
3002
+ this.emitPipeline?.destroy();
3003
+ this.zeroAccelPipeline?.destroy();
3004
+ this.integratorPipeline?.destroy();
3005
+ this.boundsPipeline?.destroy();
3006
+ this.compactionPipeline?.destroy();
3007
+ this.aliveCountReadback?.destroy();
3008
+ this.emitPipeline = null;
3009
+ this.prepareIndirectPipeline = null;
3010
+ this.zeroAccelPipeline = null;
3011
+ this.integratorPipeline = null;
3012
+ this.boundsPipeline = null;
3013
+ this.compactionPipeline = null;
3014
+ this.aliveCountReadback = null;
3015
+ this.state.destroy();
3016
+ this.pipelineCache.clear();
3017
+ this.destroyed = true;
3018
+ }
3019
+ flushEmissions(encoder) {
3020
+ const total = this.pendingEmitCount();
3021
+ if (total === 0) return;
3022
+ const pipeline = this.emitPipeline;
3023
+ if (!pipeline) return;
3024
+ pipeline.flush(encoder, total);
3025
+ this.emitQueue.length = 0;
3026
+ this.emitStagingCursor = 0;
3027
+ }
3028
+ ensureEmitPipeline() {
3029
+ if (!this.emitPipeline) this.emitPipeline = new EmitPipeline(this.root, this.state);
3030
+ return this.emitPipeline;
3031
+ }
3032
+ ensurePrepareIndirect() {
3033
+ if (!this.prepareIndirectPipeline) this.prepareIndirectPipeline = new PrepareIndirectPipeline(this.root, this.state);
3034
+ return this.prepareIndirectPipeline;
3035
+ }
3036
+ ensureZeroAccel() {
3037
+ if (!this.zeroAccelPipeline) this.zeroAccelPipeline = new ZeroAccelPipeline(this.root, this.state);
3038
+ return this.zeroAccelPipeline;
3039
+ }
3040
+ ensureIntegrator() {
3041
+ if (!this.integratorPipeline) this.integratorPipeline = new IntegratorPipeline(this.root, this.state);
3042
+ return this.integratorPipeline;
3043
+ }
3044
+ ensureBounds() {
3045
+ if (!this.boundsPipeline) this.boundsPipeline = new BoundsPipeline(this.root, this.state, this.options.bounds ?? { kind: "none" });
3046
+ return this.boundsPipeline;
3047
+ }
3048
+ ensureCompaction() {
3049
+ if (!this.compactionPipeline) this.compactionPipeline = new CompactionPipeline(this.root, this.state);
3050
+ return this.compactionPipeline;
3051
+ }
3052
+ pendingEmitCount() {
3053
+ let n = 0;
3054
+ for (const b of this.emitQueue) n += b.count;
3055
+ return n;
3056
+ }
3057
+ assertAlive() {
3058
+ if (this.destroyed) throw new Error("ParticleSystem: called on a destroyed system");
3059
+ }
3060
+ };
3061
+ //#endregion
3062
+ //#region src/particles/forces/attractor.ts
3063
+ const ATTRACTOR_PARAMS_BYTES = 32;
3064
+ /** Falloff → integer code packed into the params uniform. */
3065
+ const FALLOFF_CODE = {
3066
+ inverseSquare: 0,
3067
+ linear: 1,
3068
+ spring: 2
3069
+ };
3070
+ const ATTRACTOR_WGSL = `
3071
+ struct AttractorParams {
3072
+ point: vec2f,
3073
+ strength: f32,
3074
+ reach: f32,
3075
+ falloffCode: u32,
3076
+ _pad0: u32,
3077
+ _pad1: u32,
3078
+ _pad2: u32,
3079
+ };
3080
+
3081
+ @group(0) @binding(0) var<storage, read> positions: array<vec2f>;
3082
+ @group(0) @binding(1) var<storage, read_write> accel: array<atomic<u32>>;
3083
+ @group(0) @binding(2) var<storage, read> alive: array<u32>;
3084
+ @group(0) @binding(3) var<storage, read> aliveCount: u32;
3085
+ @group(0) @binding(4) var<uniform> params: AttractorParams;
3086
+ ${ATOMIC_ACCEL_WGSL}
3087
+ @compute @workgroup_size(64)
3088
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
3089
+ let i = gid.x;
3090
+ if (i >= aliveCount) { return; }
3091
+ if (alive[i] == 0u) { return; }
3092
+
3093
+ let delta = params.point - positions[i];
3094
+ let d2 = dot(delta, delta);
3095
+ let reach2 = params.reach * params.reach;
3096
+ if (params.reach > 0.0 && d2 > reach2) { return; }
3097
+
3098
+ var force = vec2f(0.0);
3099
+ if (params.falloffCode == 2u) {
3100
+ // Spring: F = k·delta. No singularity at the origin.
3101
+ force = delta * params.strength;
3102
+ } else {
3103
+ let d = max(sqrt(d2), 1e-4);
3104
+ let dir = delta / d;
3105
+ if (params.falloffCode == 0u) {
3106
+ // Inverse-square. Clamp d to avoid divergence at the point.
3107
+ force = dir * (params.strength / max(d2, 1e-4));
3108
+ } else {
3109
+ // Linear: fade to 0 at reach (or at 1 world unit if no reach set).
3110
+ let scale = select(1.0, 1.0 - d / params.reach, params.reach > 0.0);
3111
+ force = dir * (params.strength * max(scale, 0.0));
3112
+ }
3113
+ }
3114
+
3115
+ atomicAddF32(i * 2u + 0u, force.x);
3116
+ atomicAddF32(i * 2u + 1u, force.y);
3117
+ }
3118
+ `;
3119
+ /**
3120
+ * Radial attraction / repulsion around a fixed world point. Use negative
3121
+ * `strength` for repulsion. Falloff kinds control how force decays with
3122
+ * distance; `reach` optionally hard-clips beyond that radius.
3123
+ */
3124
+ function attractor(options) {
3125
+ return {
3126
+ kind: "attractor",
3127
+ attach(ctx) {
3128
+ return new AttractorHandle(ctx, options);
3129
+ }
3130
+ };
3131
+ }
3132
+ var AttractorHandle = class {
3133
+ device;
3134
+ paramsBuffer;
3135
+ paramsScratch = new ArrayBuffer(ATTRACTOR_PARAMS_BYTES);
3136
+ paramsF32 = new Float32Array(this.paramsScratch);
3137
+ paramsU32 = new Uint32Array(this.paramsScratch);
3138
+ bindGroup;
3139
+ pipeline;
3140
+ constructor(ctx, opts) {
3141
+ const { state } = ctx;
3142
+ const label = `${ctx.label}.attractor`;
3143
+ this.device = ctx.root.device;
3144
+ this.paramsBuffer = this.device.createBuffer({
3145
+ label: `${label}.params`,
3146
+ size: ATTRACTOR_PARAMS_BYTES,
3147
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
3148
+ });
3149
+ this.writeParams(opts);
3150
+ const layout = this.device.createBindGroupLayout({
3151
+ label: `${label}.layout`,
3152
+ entries: [
3153
+ {
3154
+ binding: 0,
3155
+ visibility: GPUShaderStage.COMPUTE,
3156
+ buffer: { type: "read-only-storage" }
3157
+ },
3158
+ {
3159
+ binding: 1,
3160
+ visibility: GPUShaderStage.COMPUTE,
3161
+ buffer: { type: "storage" }
3162
+ },
3163
+ {
3164
+ binding: 2,
3165
+ visibility: GPUShaderStage.COMPUTE,
3166
+ buffer: { type: "read-only-storage" }
3167
+ },
3168
+ {
3169
+ binding: 3,
3170
+ visibility: GPUShaderStage.COMPUTE,
3171
+ buffer: { type: "read-only-storage" }
3172
+ },
3173
+ {
3174
+ binding: 4,
3175
+ visibility: GPUShaderStage.COMPUTE,
3176
+ buffer: { type: "uniform" }
3177
+ }
3178
+ ]
3179
+ });
3180
+ this.bindGroup = this.device.createBindGroup({
3181
+ label: `${label}.group`,
3182
+ layout,
3183
+ entries: [
3184
+ {
3185
+ binding: 0,
3186
+ resource: { buffer: state.builtins.positions }
3187
+ },
3188
+ {
3189
+ binding: 1,
3190
+ resource: { buffer: state.builtins.accel }
3191
+ },
3192
+ {
3193
+ binding: 2,
3194
+ resource: { buffer: state.builtins.alive }
3195
+ },
3196
+ {
3197
+ binding: 3,
3198
+ resource: { buffer: state.builtins.aliveCount }
3199
+ },
3200
+ {
3201
+ binding: 4,
3202
+ resource: { buffer: this.paramsBuffer }
3203
+ }
3204
+ ]
3205
+ });
3206
+ this.pipeline = this.device.createComputePipeline({
3207
+ label: `${label}.pipeline`,
3208
+ layout: this.device.createPipelineLayout({
3209
+ label: `${label}.pipelineLayout`,
3210
+ bindGroupLayouts: [layout]
3211
+ }),
3212
+ compute: {
3213
+ module: this.device.createShaderModule({
3214
+ label: `${label}.shader`,
3215
+ code: ATTRACTOR_WGSL
3216
+ }),
3217
+ entryPoint: "main"
3218
+ }
3219
+ });
3220
+ }
3221
+ writeParams(opts) {
3222
+ this.paramsF32[0] = opts.point[0];
3223
+ this.paramsF32[1] = opts.point[1];
3224
+ this.paramsF32[2] = opts.strength;
3225
+ this.paramsF32[3] = opts.reach ?? 0;
3226
+ this.paramsU32[4] = FALLOFF_CODE[opts.falloff ?? "inverseSquare"];
3227
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsScratch);
3228
+ }
3229
+ record(pass, ctx) {
3230
+ pass.setPipeline(this.pipeline);
3231
+ pass.setBindGroup(0, this.bindGroup);
3232
+ pass.dispatchWorkgroupsIndirect(ctx.dispatchArgs, 0);
3233
+ }
3234
+ destroy() {
3235
+ this.paramsBuffer.destroy();
3236
+ }
3237
+ };
3238
+ /**
3239
+ * Like `attractor`, but the anchor point is resampled from `target()` on
3240
+ * every step. Built on the same kernel — pointer dragging the canvas is the
3241
+ * canonical use.
3242
+ */
3243
+ function pointer(options) {
3244
+ const falloff = options.falloff ?? "inverseSquare";
3245
+ return {
3246
+ kind: "pointer",
3247
+ attach(ctx) {
3248
+ return new PointerHandle(ctx, options, falloff);
3249
+ }
3250
+ };
3251
+ }
3252
+ var PointerHandle = class extends AttractorHandle {
3253
+ target;
3254
+ strengthFn;
3255
+ reach;
3256
+ falloff;
3257
+ constructor(ctx, opts, falloff) {
3258
+ const strengthFn = typeof opts.strength === "function" ? opts.strength : () => opts.strength;
3259
+ super(ctx, {
3260
+ point: opts.target(),
3261
+ strength: strengthFn(),
3262
+ reach: opts.reach,
3263
+ falloff
3264
+ });
3265
+ this.target = opts.target;
3266
+ this.strengthFn = strengthFn;
3267
+ this.reach = opts.reach ?? 0;
3268
+ this.falloff = falloff;
3269
+ }
3270
+ record(pass, ctx) {
3271
+ const pt = this.target();
3272
+ this.writeParams({
3273
+ point: [pt[0], pt[1]],
3274
+ strength: this.strengthFn(),
3275
+ reach: this.reach,
3276
+ falloff: this.falloff
3277
+ });
3278
+ super.record(pass, ctx);
3279
+ }
3280
+ };
3281
+ //#endregion
3282
+ //#region src/particles/forces/boids.ts
3283
+ const BOIDS_PARAMS_BYTES = 32;
3284
+ const BOIDS_WGSL = `
3285
+ struct BoidsParams {
3286
+ reach: f32,
3287
+ separationRadius: f32,
3288
+ alignment: f32,
3289
+ cohesion: f32,
3290
+ separation: f32,
3291
+ maxSpeed: f32,
3292
+ _pad0: f32,
3293
+ _pad1: f32,
3294
+ };
3295
+
3296
+ @group(0) @binding(0) var<storage, read> positions: array<vec2f>;
3297
+ @group(0) @binding(1) var<storage, read> velocities: array<vec2f>;
3298
+ @group(0) @binding(2) var<storage, read_write> accel: array<atomic<u32>>;
3299
+ @group(0) @binding(3) var<storage, read> alive: array<u32>;
3300
+ @group(0) @binding(4) var<storage, read> aliveCount: u32;
3301
+
3302
+ ${spatialHashConsumerWgsl(1)}
3303
+
3304
+ @group(2) @binding(0) var<uniform> params: BoidsParams;
3305
+ ${ATOMIC_ACCEL_WGSL}
3306
+ fn safeNormalize(v: vec2f) -> vec2f {
3307
+ let len = length(v);
3308
+ if (len < 1e-6) { return vec2f(0.0); }
3309
+ return v / len;
3310
+ }
3311
+
3312
+ @compute @workgroup_size(64)
3313
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
3314
+ let i = gid.x;
3315
+ if (i >= aliveCount) { return; }
3316
+ if (alive[i] == 0u) { return; }
3317
+
3318
+ let myPos = positions[i];
3319
+ let myVel = velocities[i];
3320
+ let myCell = worldCell(myPos, hashParams.invCellSize);
3321
+
3322
+ let reach2 = params.reach * params.reach;
3323
+ let sepR2 = params.separationRadius * params.separationRadius;
3324
+
3325
+ var sumVel = vec2f(0.0);
3326
+ var sumPos = vec2f(0.0);
3327
+ var sumSep = vec2f(0.0);
3328
+ var countNeighbours = 0u;
3329
+ var countSep = 0u;
3330
+
3331
+ for (var oy = -1; oy <= 1; oy = oy + 1) {
3332
+ for (var ox = -1; ox <= 1; ox = ox + 1) {
3333
+ let targetCell = myCell + vec2i(ox, oy);
3334
+ var cursor = heads[hashCell(targetCell, hashParams.bucketMask)];
3335
+ loop {
3336
+ if (cursor == EMPTY_SLOT) { break; }
3337
+ if (cursor != i && all(cells[cursor] == targetCell) && alive[cursor] != 0u) {
3338
+ let delta = positions[cursor] - myPos;
3339
+ let d2 = dot(delta, delta);
3340
+ if (d2 <= reach2) {
3341
+ sumVel = sumVel + velocities[cursor];
3342
+ sumPos = sumPos + positions[cursor];
3343
+ countNeighbours = countNeighbours + 1u;
3344
+ if (d2 < sepR2 && d2 > 1e-8) {
3345
+ // Push proportional to 1/d — closer neighbours push harder.
3346
+ sumSep = sumSep - delta / sqrt(d2);
3347
+ countSep = countSep + 1u;
3348
+ }
3349
+ }
3350
+ }
3351
+ cursor = next[cursor];
3352
+ }
3353
+ }
3354
+ }
3355
+
3356
+ if (countNeighbours == 0u && countSep == 0u) { return; }
3357
+
3358
+ var force = vec2f(0.0);
3359
+ if (countNeighbours > 0u) {
3360
+ let inv = 1.0 / f32(countNeighbours);
3361
+ // Alignment: steer toward the average neighbour heading.
3362
+ let avgVel = sumVel * inv;
3363
+ let desiredAlign = safeNormalize(avgVel) * params.maxSpeed;
3364
+ force = force + (desiredAlign - myVel) * params.alignment;
3365
+
3366
+ // Cohesion: steer toward the local centroid.
3367
+ let centroid = sumPos * inv;
3368
+ let desiredCoh = safeNormalize(centroid - myPos) * params.maxSpeed;
3369
+ force = force + (desiredCoh - myVel) * params.cohesion;
3370
+ }
3371
+ if (countSep > 0u) {
3372
+ let sepDir = safeNormalize(sumSep);
3373
+ let desiredSep = sepDir * params.maxSpeed;
3374
+ force = force + (desiredSep - myVel) * params.separation;
3375
+ }
3376
+
3377
+ atomicAddF32(i * 2u + 0u, force.x);
3378
+ atomicAddF32(i * 2u + 1u, force.y);
3379
+ }
3380
+ `;
3381
+ /**
3382
+ * Reynolds boids — alignment, cohesion, separation over a spatial-hash
3383
+ * neighbourhood. Shares the system's hash at the given `reach`.
3384
+ */
3385
+ function boids(options) {
3386
+ return {
3387
+ kind: "boids",
3388
+ attach(ctx) {
3389
+ return new BoidsHandle(ctx, options);
3390
+ }
3391
+ };
3392
+ }
3393
+ var BoidsHandle = class {
3394
+ device;
3395
+ pipeline;
3396
+ stateBindGroup;
3397
+ hashBindGroup;
3398
+ paramsGroup;
3399
+ paramsBuffer;
3400
+ constructor(ctx, opts) {
3401
+ const { root, state } = ctx;
3402
+ this.device = root.device;
3403
+ const label = `${ctx.label}.boids`;
3404
+ const hash = ctx.requestSpatialHash(opts.reach);
3405
+ this.hashBindGroup = hash.consumeBindGroup;
3406
+ const stateLayout = this.device.createBindGroupLayout({
3407
+ label: `${label}.state.layout`,
3408
+ entries: [
3409
+ {
3410
+ binding: 0,
3411
+ visibility: GPUShaderStage.COMPUTE,
3412
+ buffer: { type: "read-only-storage" }
3413
+ },
3414
+ {
3415
+ binding: 1,
3416
+ visibility: GPUShaderStage.COMPUTE,
3417
+ buffer: { type: "read-only-storage" }
3418
+ },
3419
+ {
3420
+ binding: 2,
3421
+ visibility: GPUShaderStage.COMPUTE,
3422
+ buffer: { type: "storage" }
3423
+ },
3424
+ {
3425
+ binding: 3,
3426
+ visibility: GPUShaderStage.COMPUTE,
3427
+ buffer: { type: "read-only-storage" }
3428
+ },
3429
+ {
3430
+ binding: 4,
3431
+ visibility: GPUShaderStage.COMPUTE,
3432
+ buffer: { type: "read-only-storage" }
3433
+ }
3434
+ ]
3435
+ });
3436
+ this.stateBindGroup = this.device.createBindGroup({
3437
+ label: `${label}.state.group`,
3438
+ layout: stateLayout,
3439
+ entries: [
3440
+ {
3441
+ binding: 0,
3442
+ resource: { buffer: state.builtins.positions }
3443
+ },
3444
+ {
3445
+ binding: 1,
3446
+ resource: { buffer: state.builtins.velocities }
3447
+ },
3448
+ {
3449
+ binding: 2,
3450
+ resource: { buffer: state.builtins.accel }
3451
+ },
3452
+ {
3453
+ binding: 3,
3454
+ resource: { buffer: state.builtins.alive }
3455
+ },
3456
+ {
3457
+ binding: 4,
3458
+ resource: { buffer: state.builtins.aliveCount }
3459
+ }
3460
+ ]
3461
+ });
3462
+ this.paramsBuffer = this.device.createBuffer({
3463
+ label: `${label}.params`,
3464
+ size: BOIDS_PARAMS_BYTES,
3465
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
3466
+ });
3467
+ const scratch = new Float32Array(BOIDS_PARAMS_BYTES / 4);
3468
+ scratch[0] = opts.reach;
3469
+ scratch[1] = opts.separationRadius ?? opts.reach * .4;
3470
+ scratch[2] = opts.alignment ?? 1;
3471
+ scratch[3] = opts.cohesion ?? .5;
3472
+ scratch[4] = opts.separation ?? 1.5;
3473
+ scratch[5] = opts.maxSpeed ?? 100;
3474
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, scratch);
3475
+ const paramsLayout = this.device.createBindGroupLayout({
3476
+ label: `${label}.params.layout`,
3477
+ entries: [{
3478
+ binding: 0,
3479
+ visibility: GPUShaderStage.COMPUTE,
3480
+ buffer: { type: "uniform" }
3481
+ }]
3482
+ });
3483
+ this.paramsGroup = this.device.createBindGroup({
3484
+ label: `${label}.params.group`,
3485
+ layout: paramsLayout,
3486
+ entries: [{
3487
+ binding: 0,
3488
+ resource: { buffer: this.paramsBuffer }
3489
+ }]
3490
+ });
3491
+ this.pipeline = this.device.createComputePipeline({
3492
+ label: `${label}.pipeline`,
3493
+ layout: this.device.createPipelineLayout({
3494
+ label: `${label}.pipelineLayout`,
3495
+ bindGroupLayouts: [
3496
+ stateLayout,
3497
+ hash.consumeBindGroupLayout,
3498
+ paramsLayout
3499
+ ]
3500
+ }),
3501
+ compute: {
3502
+ module: this.device.createShaderModule({
3503
+ label: `${label}.shader`,
3504
+ code: BOIDS_WGSL
3505
+ }),
3506
+ entryPoint: "main"
3507
+ }
3508
+ });
3509
+ }
3510
+ record(pass, ctx) {
3511
+ pass.setPipeline(this.pipeline);
3512
+ pass.setBindGroup(0, this.stateBindGroup);
3513
+ pass.setBindGroup(1, this.hashBindGroup);
3514
+ pass.setBindGroup(2, this.paramsGroup);
3515
+ pass.dispatchWorkgroupsIndirect(ctx.dispatchArgs, 0);
3516
+ }
3517
+ destroy() {
3518
+ this.paramsBuffer.destroy();
3519
+ }
3520
+ };
3521
+ //#endregion
3522
+ //#region src/particles/forces/drag.ts
3523
+ const DRAG_PARAMS_BYTES = 16;
3524
+ const DRAG_WGSL = `
3525
+ struct DragParams {
3526
+ coefficient: f32,
3527
+ _pad0: f32,
3528
+ _pad1: f32,
3529
+ _pad2: f32,
3530
+ };
3531
+
3532
+ @group(0) @binding(0) var<storage, read> velocities: array<vec2f>;
3533
+ @group(0) @binding(1) var<storage, read_write> accel: array<atomic<u32>>;
3534
+ @group(0) @binding(2) var<storage, read> alive: array<u32>;
3535
+ @group(0) @binding(3) var<storage, read> aliveCount: u32;
3536
+ @group(0) @binding(4) var<uniform> params: DragParams;
3537
+ ${ATOMIC_ACCEL_WGSL}
3538
+ @compute @workgroup_size(64)
3539
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
3540
+ let i = gid.x;
3541
+ if (i >= aliveCount) { return; }
3542
+ if (alive[i] == 0u) { return; }
3543
+
3544
+ let v = velocities[i];
3545
+ let a = -params.coefficient * v;
3546
+ atomicAddF32(i * 2u + 0u, a.x);
3547
+ atomicAddF32(i * 2u + 1u, a.y);
3548
+ }
3549
+ `;
3550
+ /** Linear velocity damping. `a += -coefficient * v`. */
3551
+ function drag(options) {
3552
+ return {
3553
+ kind: "drag",
3554
+ attach(ctx) {
3555
+ return new DragHandle(ctx, options);
3556
+ }
3557
+ };
3558
+ }
3559
+ var DragHandle = class {
3560
+ device;
3561
+ paramsBuffer;
3562
+ bindGroup;
3563
+ pipeline;
3564
+ constructor(ctx, opts) {
3565
+ const { state } = ctx;
3566
+ const label = `${ctx.label}.drag`;
3567
+ this.device = ctx.root.device;
3568
+ this.paramsBuffer = this.device.createBuffer({
3569
+ label: `${label}.params`,
3570
+ size: DRAG_PARAMS_BYTES,
3571
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
3572
+ });
3573
+ const scratch = new Float32Array(DRAG_PARAMS_BYTES / 4);
3574
+ scratch[0] = opts.coefficient;
3575
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, scratch);
3576
+ const layout = this.device.createBindGroupLayout({
3577
+ label: `${label}.layout`,
3578
+ entries: [
3579
+ {
3580
+ binding: 0,
3581
+ visibility: GPUShaderStage.COMPUTE,
3582
+ buffer: { type: "read-only-storage" }
3583
+ },
3584
+ {
3585
+ binding: 1,
3586
+ visibility: GPUShaderStage.COMPUTE,
3587
+ buffer: { type: "storage" }
3588
+ },
3589
+ {
3590
+ binding: 2,
3591
+ visibility: GPUShaderStage.COMPUTE,
3592
+ buffer: { type: "read-only-storage" }
3593
+ },
3594
+ {
3595
+ binding: 3,
3596
+ visibility: GPUShaderStage.COMPUTE,
3597
+ buffer: { type: "read-only-storage" }
3598
+ },
3599
+ {
3600
+ binding: 4,
3601
+ visibility: GPUShaderStage.COMPUTE,
3602
+ buffer: { type: "uniform" }
3603
+ }
3604
+ ]
3605
+ });
3606
+ this.bindGroup = this.device.createBindGroup({
3607
+ label: `${label}.group`,
3608
+ layout,
3609
+ entries: [
3610
+ {
3611
+ binding: 0,
3612
+ resource: { buffer: state.builtins.velocities }
3613
+ },
3614
+ {
3615
+ binding: 1,
3616
+ resource: { buffer: state.builtins.accel }
3617
+ },
3618
+ {
3619
+ binding: 2,
3620
+ resource: { buffer: state.builtins.alive }
3621
+ },
3622
+ {
3623
+ binding: 3,
3624
+ resource: { buffer: state.builtins.aliveCount }
3625
+ },
3626
+ {
3627
+ binding: 4,
3628
+ resource: { buffer: this.paramsBuffer }
3629
+ }
3630
+ ]
3631
+ });
3632
+ this.pipeline = this.device.createComputePipeline({
3633
+ label: `${label}.pipeline`,
3634
+ layout: this.device.createPipelineLayout({
3635
+ label: `${label}.pipelineLayout`,
3636
+ bindGroupLayouts: [layout]
3637
+ }),
3638
+ compute: {
3639
+ module: this.device.createShaderModule({
3640
+ label: `${label}.shader`,
3641
+ code: DRAG_WGSL
3642
+ }),
3643
+ entryPoint: "main"
3644
+ }
3645
+ });
3646
+ }
3647
+ record(pass, ctx) {
3648
+ pass.setPipeline(this.pipeline);
3649
+ pass.setBindGroup(0, this.bindGroup);
3650
+ pass.dispatchWorkgroupsIndirect(ctx.dispatchArgs, 0);
3651
+ }
3652
+ destroy() {
3653
+ this.paramsBuffer.destroy();
3654
+ }
3655
+ };
3656
+ //#endregion
3657
+ //#region src/particles/forces/field.ts
3658
+ const FIELD_PARAMS_BYTES = 32;
3659
+ const FIELD_WGSL = `
3660
+ struct FieldParams {
3661
+ col0: vec2f, // (m00, m10) — applied to world.x
3662
+ col1: vec2f, // (m01, m11) — applied to world.y
3663
+ col2: vec2f, // (m02, m12) — translation
3664
+ strength: f32,
3665
+ _pad: f32,
3666
+ };
3667
+
3668
+ @group(0) @binding(0) var<storage, read> positions: array<vec2f>;
3669
+ @group(0) @binding(1) var<storage, read_write> accel: array<atomic<u32>>;
3670
+ @group(0) @binding(2) var<storage, read> alive: array<u32>;
3671
+ @group(0) @binding(3) var<storage, read> aliveCount: u32;
3672
+ @group(0) @binding(4) var<uniform> params: FieldParams;
3673
+ @group(0) @binding(5) var fieldTex: texture_2d<f32>;
3674
+ @group(0) @binding(6) var fieldSamp: sampler;
3675
+ ${ATOMIC_ACCEL_WGSL}
3676
+ @compute @workgroup_size(64)
3677
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
3678
+ let i = gid.x;
3679
+ if (i >= aliveCount) { return; }
3680
+ if (alive[i] == 0u) { return; }
3681
+
3682
+ let pos = positions[i];
3683
+ let uv = params.col0 * pos.x + params.col1 * pos.y + params.col2;
3684
+ let s = textureSampleLevel(fieldTex, fieldSamp, uv, 0.0);
3685
+ let f = s.xy * params.strength;
3686
+
3687
+ atomicAddF32(i * 2u + 0u, f.x);
3688
+ atomicAddF32(i * 2u + 1u, f.y);
3689
+ }
3690
+ `;
3691
+ /**
3692
+ * Texture-sampled flow-field force. Particles receive
3693
+ * `sampleRG(worldToUv·pos) * strength` as an acceleration each step.
3694
+ */
3695
+ function field(options) {
3696
+ return {
3697
+ kind: "field",
3698
+ attach(ctx) {
3699
+ return new FieldHandle(ctx, options);
3700
+ }
3701
+ };
3702
+ }
3703
+ var FieldHandle = class {
3704
+ device;
3705
+ paramsBuffer;
3706
+ paramsScratch = new Float32Array(FIELD_PARAMS_BYTES / 4);
3707
+ bindGroup;
3708
+ pipeline;
3709
+ strength;
3710
+ matrix;
3711
+ constructor(ctx, opts) {
3712
+ const { state } = ctx;
3713
+ const label = `${ctx.label}.field`;
3714
+ this.device = ctx.root.device;
3715
+ this.strength = opts.strength;
3716
+ this.matrix = resolveMatrix(opts);
3717
+ this.paramsBuffer = this.device.createBuffer({
3718
+ label: `${label}.params`,
3719
+ size: FIELD_PARAMS_BYTES,
3720
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
3721
+ });
3722
+ const addressMode = opts.wrap === "repeat" ? "repeat" : "clamp-to-edge";
3723
+ const filter = opts.filter ?? "linear";
3724
+ const sampler = this.device.createSampler({
3725
+ label: `${label}.sampler`,
3726
+ addressModeU: addressMode,
3727
+ addressModeV: addressMode,
3728
+ magFilter: filter,
3729
+ minFilter: filter
3730
+ });
3731
+ const layout = this.device.createBindGroupLayout({
3732
+ label: `${label}.layout`,
3733
+ entries: [
3734
+ {
3735
+ binding: 0,
3736
+ visibility: GPUShaderStage.COMPUTE,
3737
+ buffer: { type: "read-only-storage" }
3738
+ },
3739
+ {
3740
+ binding: 1,
3741
+ visibility: GPUShaderStage.COMPUTE,
3742
+ buffer: { type: "storage" }
3743
+ },
3744
+ {
3745
+ binding: 2,
3746
+ visibility: GPUShaderStage.COMPUTE,
3747
+ buffer: { type: "read-only-storage" }
3748
+ },
3749
+ {
3750
+ binding: 3,
3751
+ visibility: GPUShaderStage.COMPUTE,
3752
+ buffer: { type: "read-only-storage" }
3753
+ },
3754
+ {
3755
+ binding: 4,
3756
+ visibility: GPUShaderStage.COMPUTE,
3757
+ buffer: { type: "uniform" }
3758
+ },
3759
+ {
3760
+ binding: 5,
3761
+ visibility: GPUShaderStage.COMPUTE,
3762
+ texture: { sampleType: filter === "linear" ? "float" : "unfilterable-float" }
3763
+ },
3764
+ {
3765
+ binding: 6,
3766
+ visibility: GPUShaderStage.COMPUTE,
3767
+ sampler: { type: filter === "linear" ? "filtering" : "non-filtering" }
3768
+ }
3769
+ ]
3770
+ });
3771
+ this.bindGroup = this.device.createBindGroup({
3772
+ label: `${label}.group`,
3773
+ layout,
3774
+ entries: [
3775
+ {
3776
+ binding: 0,
3777
+ resource: { buffer: state.builtins.positions }
3778
+ },
3779
+ {
3780
+ binding: 1,
3781
+ resource: { buffer: state.builtins.accel }
3782
+ },
3783
+ {
3784
+ binding: 2,
3785
+ resource: { buffer: state.builtins.alive }
3786
+ },
3787
+ {
3788
+ binding: 3,
3789
+ resource: { buffer: state.builtins.aliveCount }
3790
+ },
3791
+ {
3792
+ binding: 4,
3793
+ resource: { buffer: this.paramsBuffer }
3794
+ },
3795
+ {
3796
+ binding: 5,
3797
+ resource: opts.texture.createView()
3798
+ },
3799
+ {
3800
+ binding: 6,
3801
+ resource: sampler
3802
+ }
3803
+ ]
3804
+ });
3805
+ this.pipeline = this.device.createComputePipeline({
3806
+ label: `${label}.pipeline`,
3807
+ layout: this.device.createPipelineLayout({
3808
+ label: `${label}.pipelineLayout`,
3809
+ bindGroupLayouts: [layout]
3810
+ }),
3811
+ compute: {
3812
+ module: this.device.createShaderModule({
3813
+ label: `${label}.shader`,
3814
+ code: FIELD_WGSL
3815
+ }),
3816
+ entryPoint: "main"
3817
+ }
3818
+ });
3819
+ }
3820
+ /** Swap strength at runtime without rebuilding the pipeline. */
3821
+ setStrength(strength) {
3822
+ this.strength = strength;
3823
+ }
3824
+ record(pass, ctx) {
3825
+ this.paramsScratch[0] = this.matrix[0];
3826
+ this.paramsScratch[1] = this.matrix[3];
3827
+ this.paramsScratch[2] = this.matrix[1];
3828
+ this.paramsScratch[3] = this.matrix[4];
3829
+ this.paramsScratch[4] = this.matrix[2];
3830
+ this.paramsScratch[5] = this.matrix[5];
3831
+ this.paramsScratch[6] = this.strength;
3832
+ this.paramsScratch[7] = 0;
3833
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsScratch);
3834
+ pass.setPipeline(this.pipeline);
3835
+ pass.setBindGroup(0, this.bindGroup);
3836
+ pass.dispatchWorkgroupsIndirect(ctx.dispatchArgs, 0);
3837
+ }
3838
+ destroy() {
3839
+ this.paramsBuffer.destroy();
3840
+ }
3841
+ };
3842
+ function resolveMatrix(opts) {
3843
+ if (opts.worldToUv && opts.worldBounds) throw new Error("field force: pass either `worldBounds` or `worldToUv`, not both.");
3844
+ if (opts.worldToUv) return opts.worldToUv;
3845
+ if (opts.worldBounds) {
3846
+ const { x, y, width, height } = opts.worldBounds;
3847
+ if (width <= 0 || height <= 0) throw new Error("field force: worldBounds.width and .height must be positive.");
3848
+ return [
3849
+ 1 / width,
3850
+ 0,
3851
+ -x / width,
3852
+ 0,
3853
+ 1 / height,
3854
+ -y / height
3855
+ ];
3856
+ }
3857
+ const w = opts.texture.width;
3858
+ const h = opts.texture.height;
3859
+ return [
3860
+ 1 / w,
3861
+ 0,
3862
+ 0,
3863
+ 0,
3864
+ 1 / h,
3865
+ 0
3866
+ ];
3867
+ }
3868
+ //#endregion
3869
+ //#region src/particles/forces/gravity.ts
3870
+ const GRAVITY_PARAMS_BYTES = 16;
3871
+ const GRAVITY_WGSL = `
3872
+ struct GravityParams {
3873
+ accel: vec2f,
3874
+ _pad0: f32,
3875
+ _pad1: f32,
3876
+ };
3877
+
3878
+ @group(0) @binding(0) var<storage, read_write> accel: array<atomic<u32>>;
3879
+ @group(0) @binding(1) var<storage, read> alive: array<u32>;
3880
+ @group(0) @binding(2) var<storage, read> aliveCount: u32;
3881
+ @group(0) @binding(3) var<uniform> params: GravityParams;
3882
+ ${ATOMIC_ACCEL_WGSL}
3883
+ @compute @workgroup_size(64)
3884
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
3885
+ let i = gid.x;
3886
+ if (i >= aliveCount) { return; }
3887
+ if (alive[i] == 0u) { return; }
3888
+ atomicAddF32(i * 2u + 0u, params.accel.x);
3889
+ atomicAddF32(i * 2u + 1u, params.accel.y);
3890
+ }
3891
+ `;
3892
+ /** Uniform acceleration in a fixed direction. Classic "falling" force. */
3893
+ function gravity(options) {
3894
+ return {
3895
+ kind: "gravity",
3896
+ attach(ctx) {
3897
+ return new GravityHandle(ctx, options);
3898
+ }
3899
+ };
3900
+ }
3901
+ var GravityHandle = class {
3902
+ device;
3903
+ paramsBuffer;
3904
+ bindGroup;
3905
+ pipeline;
3906
+ constructor(ctx, opts) {
3907
+ const { state } = ctx;
3908
+ const label = `${ctx.label}.gravity`;
3909
+ this.device = ctx.root.device;
3910
+ const len = Math.hypot(opts.direction[0], opts.direction[1]);
3911
+ const nx = len > 1e-9 ? opts.direction[0] / len : 0;
3912
+ const ny = len > 1e-9 ? opts.direction[1] / len : 0;
3913
+ this.paramsBuffer = this.device.createBuffer({
3914
+ label: `${label}.params`,
3915
+ size: GRAVITY_PARAMS_BYTES,
3916
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
3917
+ });
3918
+ const scratch = new Float32Array(GRAVITY_PARAMS_BYTES / 4);
3919
+ scratch[0] = nx * opts.strength;
3920
+ scratch[1] = ny * opts.strength;
3921
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, scratch);
3922
+ const layout = this.device.createBindGroupLayout({
3923
+ label: `${label}.layout`,
3924
+ entries: [
3925
+ {
3926
+ binding: 0,
3927
+ visibility: GPUShaderStage.COMPUTE,
3928
+ buffer: { type: "storage" }
3929
+ },
3930
+ {
3931
+ binding: 1,
3932
+ visibility: GPUShaderStage.COMPUTE,
3933
+ buffer: { type: "read-only-storage" }
3934
+ },
3935
+ {
3936
+ binding: 2,
3937
+ visibility: GPUShaderStage.COMPUTE,
3938
+ buffer: { type: "read-only-storage" }
3939
+ },
3940
+ {
3941
+ binding: 3,
3942
+ visibility: GPUShaderStage.COMPUTE,
3943
+ buffer: { type: "uniform" }
3944
+ }
3945
+ ]
3946
+ });
3947
+ this.bindGroup = this.device.createBindGroup({
3948
+ label: `${label}.group`,
3949
+ layout,
3950
+ entries: [
3951
+ {
3952
+ binding: 0,
3953
+ resource: { buffer: state.builtins.accel }
3954
+ },
3955
+ {
3956
+ binding: 1,
3957
+ resource: { buffer: state.builtins.alive }
3958
+ },
3959
+ {
3960
+ binding: 2,
3961
+ resource: { buffer: state.builtins.aliveCount }
3962
+ },
3963
+ {
3964
+ binding: 3,
3965
+ resource: { buffer: this.paramsBuffer }
3966
+ }
3967
+ ]
3968
+ });
3969
+ this.pipeline = this.device.createComputePipeline({
3970
+ label: `${label}.pipeline`,
3971
+ layout: this.device.createPipelineLayout({
3972
+ label: `${label}.pipelineLayout`,
3973
+ bindGroupLayouts: [layout]
3974
+ }),
3975
+ compute: {
3976
+ module: this.device.createShaderModule({
3977
+ label: `${label}.shader`,
3978
+ code: GRAVITY_WGSL
3979
+ }),
3980
+ entryPoint: "main"
3981
+ }
3982
+ });
3983
+ }
3984
+ record(pass, ctx) {
3985
+ pass.setPipeline(this.pipeline);
3986
+ pass.setBindGroup(0, this.bindGroup);
3987
+ pass.dispatchWorkgroupsIndirect(ctx.dispatchArgs, 0);
3988
+ }
3989
+ destroy() {
3990
+ this.paramsBuffer.destroy();
3991
+ }
3992
+ };
3993
+ //#endregion
3994
+ //#region src/particles/forces/noise.ts
3995
+ const NOISE_PARAMS_BYTES = 32;
3996
+ const KIND_CODE = {
3997
+ simplex: 0,
3998
+ curl: 1
3999
+ };
4000
+ const NOISE_WGSL = `
4001
+ struct NoiseParams {
4002
+ scale: f32,
4003
+ strength: f32,
4004
+ timeOffset: f32,
4005
+ kindCode: u32,
4006
+ _pad0: u32,
4007
+ _pad1: u32,
4008
+ _pad2: u32,
4009
+ _pad3: u32,
4010
+ };
4011
+
4012
+ @group(0) @binding(0) var<storage, read> positions: array<vec2f>;
4013
+ @group(0) @binding(1) var<storage, read_write> accel: array<atomic<u32>>;
4014
+ @group(0) @binding(2) var<storage, read> alive: array<u32>;
4015
+ @group(0) @binding(3) var<storage, read> aliveCount: u32;
4016
+ @group(0) @binding(4) var<uniform> params: NoiseParams;
4017
+ ${ATOMIC_ACCEL_WGSL}
4018
+ // ---- Simplex 2D ---------------------------------------------------------
4019
+ // Adapted from Ashima Arts' GLSL snoise2 (MIT). Returns a value roughly in
4020
+ // [-1, 1].
4021
+
4022
+ fn mod289v2(x: vec2f) -> vec2f { return x - floor(x * (1.0 / 289.0)) * 289.0; }
4023
+ fn mod289v3(x: vec3f) -> vec3f { return x - floor(x * (1.0 / 289.0)) * 289.0; }
4024
+ fn permute(x: vec3f) -> vec3f { return mod289v3(((x * 34.0) + 1.0) * x); }
4025
+
4026
+ fn snoise2(v: vec2f) -> f32 {
4027
+ let C = vec4f(0.211324865405187, // (3-sqrt(3))/6
4028
+ 0.366025403784439, // 0.5*(sqrt(3)-1)
4029
+ -0.577350269189626, // -1 + 2*C.x
4030
+ 0.024390243902439); // 1/41
4031
+ var i = floor(v + dot(v, C.yy));
4032
+ let x0 = v - i + dot(i, C.xx);
4033
+ var i1 = vec2f(0.0, 0.0);
4034
+ if (x0.x > x0.y) { i1 = vec2f(1.0, 0.0); } else { i1 = vec2f(0.0, 1.0); }
4035
+ let x1 = x0.xy + C.xx - i1;
4036
+ let x2 = x0.xy + C.zz;
4037
+ i = mod289v2(i);
4038
+ let p = permute(permute(vec3f(i.y, i.y + i1.y, i.y + 1.0))
4039
+ + vec3f(i.x, i.x + i1.x, i.x + 1.0));
4040
+ let m0 = max(vec3f(0.5) - vec3f(dot(x0, x0), dot(x1, x1), dot(x2, x2)), vec3f(0.0));
4041
+ let m = m0 * m0 * m0 * m0;
4042
+ let x = 2.0 * fract(p * C.www) - 1.0;
4043
+ let h = abs(x) - 0.5;
4044
+ let ox = floor(x + 0.5);
4045
+ let a0 = x - ox;
4046
+ let norm = 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
4047
+ let g = vec3f(a0.x * x0.x + h.x * x0.y,
4048
+ a0.y * x1.x + h.y * x1.y,
4049
+ a0.z * x2.x + h.z * x2.y);
4050
+ return 130.0 * dot(m * norm, g);
4051
+ }
4052
+
4053
+ // Sample the "scalar field" the particle sits in at world-space pos.
4054
+ fn field(p: vec2f) -> f32 {
4055
+ return snoise2(p * params.scale + vec2f(params.timeOffset, 0.0));
4056
+ }
4057
+
4058
+ @compute @workgroup_size(64)
4059
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
4060
+ let i = gid.x;
4061
+ if (i >= aliveCount) { return; }
4062
+ if (alive[i] == 0u) { return; }
4063
+
4064
+ let pos = positions[i];
4065
+ // Finite-difference step in world units. Small relative to scale so
4066
+ // gradient accuracy is decent; large enough to avoid catastrophic
4067
+ // cancellation in f32.
4068
+ let eps = max(0.25 / max(params.scale, 1e-6), 1e-3);
4069
+ let n_px = field(pos + vec2f(eps, 0.0));
4070
+ let n_mx = field(pos - vec2f(eps, 0.0));
4071
+ let n_py = field(pos + vec2f(0.0, eps));
4072
+ let n_my = field(pos - vec2f(0.0, eps));
4073
+ let grad = vec2f((n_px - n_mx), (n_py - n_my)) / (2.0 * eps);
4074
+
4075
+ var f: vec2f;
4076
+ if (params.kindCode == 1u) {
4077
+ // 2D curl of scalar field: (dN/dy, -dN/dx)
4078
+ f = vec2f(grad.y, -grad.x) * params.strength;
4079
+ } else {
4080
+ f = grad * params.strength;
4081
+ }
4082
+
4083
+ atomicAddF32(i * 2u + 0u, f.x);
4084
+ atomicAddF32(i * 2u + 1u, f.y);
4085
+ }
4086
+ `;
4087
+ /**
4088
+ * Simplex-noise flow force. `curl` (default) gives a divergence-free field
4089
+ * for wispy, flow-like motion; `simplex` applies the scalar gradient
4090
+ * directly and looks more turbulent.
4091
+ */
4092
+ function noise(options) {
4093
+ return {
4094
+ kind: "noise",
4095
+ attach(ctx) {
4096
+ return new NoiseHandle(ctx, options);
4097
+ }
4098
+ };
4099
+ }
4100
+ var NoiseHandle = class {
4101
+ device;
4102
+ paramsBuffer;
4103
+ paramsScratch = new ArrayBuffer(NOISE_PARAMS_BYTES);
4104
+ paramsF32 = new Float32Array(this.paramsScratch);
4105
+ paramsU32 = new Uint32Array(this.paramsScratch);
4106
+ bindGroup;
4107
+ pipeline;
4108
+ scale;
4109
+ strength;
4110
+ kindCode;
4111
+ timeScale;
4112
+ timeOffset = 0;
4113
+ constructor(ctx, opts) {
4114
+ const { state } = ctx;
4115
+ const label = `${ctx.label}.noise`;
4116
+ this.device = ctx.root.device;
4117
+ this.scale = opts.scale;
4118
+ this.strength = opts.strength;
4119
+ this.kindCode = KIND_CODE[opts.kind ?? "curl"];
4120
+ this.timeScale = opts.timeScale ?? 1;
4121
+ this.paramsBuffer = this.device.createBuffer({
4122
+ label: `${label}.params`,
4123
+ size: NOISE_PARAMS_BYTES,
4124
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
4125
+ });
4126
+ const layout = this.device.createBindGroupLayout({
4127
+ label: `${label}.layout`,
4128
+ entries: [
4129
+ {
4130
+ binding: 0,
4131
+ visibility: GPUShaderStage.COMPUTE,
4132
+ buffer: { type: "read-only-storage" }
4133
+ },
4134
+ {
4135
+ binding: 1,
4136
+ visibility: GPUShaderStage.COMPUTE,
4137
+ buffer: { type: "storage" }
4138
+ },
4139
+ {
4140
+ binding: 2,
4141
+ visibility: GPUShaderStage.COMPUTE,
4142
+ buffer: { type: "read-only-storage" }
4143
+ },
4144
+ {
4145
+ binding: 3,
4146
+ visibility: GPUShaderStage.COMPUTE,
4147
+ buffer: { type: "read-only-storage" }
4148
+ },
4149
+ {
4150
+ binding: 4,
4151
+ visibility: GPUShaderStage.COMPUTE,
4152
+ buffer: { type: "uniform" }
4153
+ }
4154
+ ]
4155
+ });
4156
+ this.bindGroup = this.device.createBindGroup({
4157
+ label: `${label}.group`,
4158
+ layout,
4159
+ entries: [
4160
+ {
4161
+ binding: 0,
4162
+ resource: { buffer: state.builtins.positions }
4163
+ },
4164
+ {
4165
+ binding: 1,
4166
+ resource: { buffer: state.builtins.accel }
4167
+ },
4168
+ {
4169
+ binding: 2,
4170
+ resource: { buffer: state.builtins.alive }
4171
+ },
4172
+ {
4173
+ binding: 3,
4174
+ resource: { buffer: state.builtins.aliveCount }
4175
+ },
4176
+ {
4177
+ binding: 4,
4178
+ resource: { buffer: this.paramsBuffer }
4179
+ }
4180
+ ]
4181
+ });
4182
+ this.pipeline = this.device.createComputePipeline({
4183
+ label: `${label}.pipeline`,
4184
+ layout: this.device.createPipelineLayout({
4185
+ label: `${label}.pipelineLayout`,
4186
+ bindGroupLayouts: [layout]
4187
+ }),
4188
+ compute: {
4189
+ module: this.device.createShaderModule({
4190
+ label: `${label}.shader`,
4191
+ code: NOISE_WGSL
4192
+ }),
4193
+ entryPoint: "main"
4194
+ }
4195
+ });
4196
+ }
4197
+ record(pass, ctx) {
4198
+ this.timeOffset += ctx.dt * this.timeScale;
4199
+ this.paramsF32[0] = this.scale;
4200
+ this.paramsF32[1] = this.strength;
4201
+ this.paramsF32[2] = this.timeOffset;
4202
+ this.paramsU32[3] = this.kindCode;
4203
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsScratch);
4204
+ pass.setPipeline(this.pipeline);
4205
+ pass.setBindGroup(0, this.bindGroup);
4206
+ pass.dispatchWorkgroupsIndirect(ctx.dispatchArgs, 0);
4207
+ }
4208
+ destroy() {
4209
+ this.paramsBuffer.destroy();
4210
+ }
4211
+ };
4212
+ //#endregion
4213
+ //#region src/particles/forces/pairwise.ts
4214
+ /**
4215
+ * Generic pairwise force. Typically invoked by presets (`particleLife`,
4216
+ * `boids`) but also exposed directly for ad-hoc user kernels.
4217
+ */
4218
+ function pairwise(options) {
4219
+ return {
4220
+ kind: "pairwise",
4221
+ attach(ctx) {
4222
+ return new PairwiseHandle(ctx, options);
4223
+ }
4224
+ };
4225
+ }
4226
+ function generatePairwisePrelude(state, reads) {
4227
+ for (const name of reads) if (!(name in state.catalog)) throw new Error(`pairwise: attribute '${name}' is not declared on the system. Declared: ${Object.keys(state.catalog).join(", ") || "<none>"}.`);
4228
+ const readsSet = new Set(reads);
4229
+ const entries = catalogEntries(state.catalog);
4230
+ const lines = [];
4231
+ lines.push("// === generated pairwise prelude ===");
4232
+ lines.push("@group(0) @binding(0) var<storage, read> positions : array<vec2f>;");
4233
+ lines.push("@group(0) @binding(1) var<storage, read_write> accel : array<atomic<u32>>;");
4234
+ lines.push("@group(0) @binding(2) var<storage, read> alive : array<u32>;");
4235
+ lines.push("@group(0) @binding(3) var<storage, read_write> aliveCount : atomic<u32>;");
4236
+ const userAttrLayoutEntries = [];
4237
+ const userAttrBindings = [];
4238
+ let nextBinding = 0;
4239
+ for (const [name, type] of entries) {
4240
+ if (!readsSet.has(name)) continue;
4241
+ const attr = state.userAttrs.find((a) => a.name === name);
4242
+ if (!attr) continue;
4243
+ const wgsl = attributeWgslType(type);
4244
+ const binding = nextBinding++;
4245
+ lines.push(`@group(1) @binding(${binding}) var<storage, read> ${bindingName(name)} : array<${wgsl}>;`);
4246
+ userAttrLayoutEntries.push({
4247
+ binding,
4248
+ visibility: GPUShaderStage.COMPUTE,
4249
+ buffer: { type: "read-only-storage" }
4250
+ });
4251
+ userAttrBindings.push({
4252
+ binding,
4253
+ resource: { buffer: attr.buffer }
4254
+ });
4255
+ }
4256
+ lines.push("");
4257
+ lines.push("struct Particle {");
4258
+ lines.push(" index: u32,");
4259
+ lines.push(" position: vec2f,");
4260
+ for (const [name, type] of entries) {
4261
+ if (!readsSet.has(name)) continue;
4262
+ lines.push(` ${name}: ${attributeWgslType(type)},`);
4263
+ }
4264
+ lines.push("};");
4265
+ lines.push("");
4266
+ lines.push("fn loadParticle(i: u32) -> Particle {");
4267
+ lines.push(" return Particle(");
4268
+ lines.push(" i,");
4269
+ lines.push(" positions[i],");
4270
+ for (const [name] of entries) {
4271
+ if (!readsSet.has(name)) continue;
4272
+ lines.push(` ${bindingName(name)}[i],`);
4273
+ }
4274
+ lines.push(" );");
4275
+ lines.push("}");
4276
+ lines.push(ATOMIC_ACCEL_WGSL);
4277
+ lines.push("// === end pairwise prelude ===");
4278
+ lines.push("");
4279
+ return {
4280
+ wgsl: lines.join("\n"),
4281
+ userAttrLayoutEntries,
4282
+ userAttrBindings
4283
+ };
4284
+ }
4285
+ var PairwiseHandle = class {
4286
+ device;
4287
+ hash;
4288
+ pipeline;
4289
+ stateBindGroup;
4290
+ userAttrBindGroup;
4291
+ hashBindGroup;
4292
+ paramsGroup;
4293
+ paramsBuffer;
4294
+ paramsSize;
4295
+ constructor(ctx, opts) {
4296
+ const { root, state } = ctx;
4297
+ this.device = root.device;
4298
+ const label = `${ctx.label}.${opts.label ?? "pairwise"}`;
4299
+ this.hash = ctx.requestSpatialHash(opts.reach);
4300
+ const stateLayout = this.device.createBindGroupLayout({
4301
+ label: `${label}.state.layout`,
4302
+ entries: [
4303
+ {
4304
+ binding: 0,
4305
+ visibility: GPUShaderStage.COMPUTE,
4306
+ buffer: { type: "read-only-storage" }
4307
+ },
4308
+ {
4309
+ binding: 1,
4310
+ visibility: GPUShaderStage.COMPUTE,
4311
+ buffer: { type: "storage" }
4312
+ },
4313
+ {
4314
+ binding: 2,
4315
+ visibility: GPUShaderStage.COMPUTE,
4316
+ buffer: { type: "read-only-storage" }
4317
+ },
4318
+ {
4319
+ binding: 3,
4320
+ visibility: GPUShaderStage.COMPUTE,
4321
+ buffer: { type: "storage" }
4322
+ }
4323
+ ]
4324
+ });
4325
+ this.stateBindGroup = this.device.createBindGroup({
4326
+ label: `${label}.state.group`,
4327
+ layout: stateLayout,
4328
+ entries: [
4329
+ {
4330
+ binding: 0,
4331
+ resource: { buffer: state.builtins.positions }
4332
+ },
4333
+ {
4334
+ binding: 1,
4335
+ resource: { buffer: state.builtins.accel }
4336
+ },
4337
+ {
4338
+ binding: 2,
4339
+ resource: { buffer: state.builtins.alive }
4340
+ },
4341
+ {
4342
+ binding: 3,
4343
+ resource: { buffer: state.builtins.aliveCount }
4344
+ }
4345
+ ]
4346
+ });
4347
+ const { wgsl: preludeWgsl, userAttrLayoutEntries, userAttrBindings } = generatePairwisePrelude(state, opts.reads ?? []);
4348
+ const userAttrLayout = this.device.createBindGroupLayout({
4349
+ label: `${label}.userAttrs.layout`,
4350
+ entries: userAttrLayoutEntries
4351
+ });
4352
+ this.userAttrBindGroup = this.device.createBindGroup({
4353
+ label: `${label}.userAttrs.group`,
4354
+ layout: userAttrLayout,
4355
+ entries: userAttrBindings
4356
+ });
4357
+ let paramsBuffer = null;
4358
+ const groupLayoutEntries = [];
4359
+ const groupEntries = [];
4360
+ let paramsStructWgsl = "";
4361
+ let paramsBindingWgsl = "";
4362
+ this.paramsSize = opts.params?.bytes ?? 0;
4363
+ if (opts.params) {
4364
+ paramsBuffer = this.device.createBuffer({
4365
+ label: `${label}.params`,
4366
+ size: opts.params.bytes,
4367
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
4368
+ });
4369
+ if (opts.params.write) {
4370
+ const ab = new ArrayBuffer(opts.params.bytes);
4371
+ opts.params.write(new DataView(ab));
4372
+ this.device.queue.writeBuffer(paramsBuffer, 0, ab);
4373
+ }
4374
+ paramsStructWgsl = opts.params.structWgsl;
4375
+ paramsBindingWgsl = `@group(3) @binding(0) var<uniform> params: PairwiseParams;`;
4376
+ groupLayoutEntries.push({
4377
+ binding: 0,
4378
+ visibility: GPUShaderStage.COMPUTE,
4379
+ buffer: { type: "uniform" }
4380
+ });
4381
+ groupEntries.push({
4382
+ binding: 0,
4383
+ resource: { buffer: paramsBuffer }
4384
+ });
4385
+ }
4386
+ let extraWgsl = "";
4387
+ if (opts.extraBindings) for (const extra of opts.extraBindings) {
4388
+ groupLayoutEntries.push(extra.layoutEntry);
4389
+ groupEntries.push(extra.bindGroupEntry);
4390
+ extraWgsl += `\n${extra.wgsl}`;
4391
+ }
4392
+ this.paramsBuffer = paramsBuffer;
4393
+ const group3Layout = groupLayoutEntries.length ? this.device.createBindGroupLayout({
4394
+ label: `${label}.group3.layout`,
4395
+ entries: groupLayoutEntries
4396
+ }) : null;
4397
+ this.paramsGroup = group3Layout ? this.device.createBindGroup({
4398
+ label: `${label}.group3.group`,
4399
+ layout: group3Layout,
4400
+ entries: groupEntries
4401
+ }) : null;
4402
+ const kernelWgsl = `${preludeWgsl}
4403
+ ${spatialHashConsumerWgsl(2)}
4404
+ ${paramsStructWgsl}
4405
+ ${paramsBindingWgsl}
4406
+ ${extraWgsl}
4407
+
4408
+ @compute @workgroup_size(64)
4409
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
4410
+ let i = gid.x;
4411
+ let n = atomicLoad(&aliveCount);
4412
+ if (i >= n) { return; }
4413
+ if (alive[i] == 0u) { return; }
4414
+
4415
+ let me = loadParticle(i);
4416
+ let myCell = worldCell(me.position, hashParams.invCellSize);
4417
+
4418
+ for (var oy = -1; oy <= 1; oy = oy + 1) {
4419
+ for (var ox = -1; ox <= 1; ox = ox + 1) {
4420
+ let targetCell = myCell + vec2i(ox, oy);
4421
+ var cursor = heads[hashCell(targetCell, hashParams.bucketMask)];
4422
+ loop {
4423
+ if (cursor == EMPTY_SLOT) { break; }
4424
+ if (cursor != i && all(cells[cursor] == targetCell) && alive[cursor] != 0u) {
4425
+ let other = loadParticle(cursor);
4426
+ ${opts.wgsl}
4427
+ }
4428
+ cursor = next[cursor];
4429
+ }
4430
+ }
4431
+ }
4432
+ }
4433
+ `;
4434
+ this.hashBindGroup = this.hash.consumeBindGroup;
4435
+ const bindGroupLayouts = [
4436
+ stateLayout,
4437
+ userAttrLayout,
4438
+ this.hash.consumeBindGroupLayout
4439
+ ];
4440
+ if (group3Layout) bindGroupLayouts.push(group3Layout);
4441
+ this.pipeline = this.device.createComputePipeline({
4442
+ label: `${label}.pipeline`,
4443
+ layout: this.device.createPipelineLayout({
4444
+ label: `${label}.pipelineLayout`,
4445
+ bindGroupLayouts
4446
+ }),
4447
+ compute: {
4448
+ module: this.device.createShaderModule({
4449
+ label: `${label}.shader`,
4450
+ code: kernelWgsl
4451
+ }),
4452
+ entryPoint: "main"
4453
+ }
4454
+ });
4455
+ }
4456
+ record(pass, ctx) {
4457
+ pass.setPipeline(this.pipeline);
4458
+ pass.setBindGroup(0, this.stateBindGroup);
4459
+ pass.setBindGroup(1, this.userAttrBindGroup);
4460
+ pass.setBindGroup(2, this.hashBindGroup);
4461
+ if (this.paramsGroup) pass.setBindGroup(3, this.paramsGroup);
4462
+ pass.dispatchWorkgroupsIndirect(ctx.dispatchArgs, 0);
4463
+ }
4464
+ /**
4465
+ * Overwrite the params uniform. Caller packs into a view matching the
4466
+ * struct declared at construction. Bytes beyond `paramsSize` are ignored.
4467
+ */
4468
+ writeParams(data) {
4469
+ if (!this.paramsBuffer) return;
4470
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, data.buffer, data.byteOffset, Math.min(data.byteLength, this.paramsSize));
4471
+ }
4472
+ destroy() {
4473
+ this.paramsBuffer?.destroy();
4474
+ }
4475
+ };
4476
+ //#endregion
4477
+ //#region src/particles/forces/speed-limit.ts
4478
+ const LIMIT_PARAMS_BYTES = 16;
4479
+ const SPEED_LIMIT_WGSL = `
4480
+ struct LimitParams {
4481
+ maxSpeed: f32,
4482
+ invDt: f32,
4483
+ _pad0: f32,
4484
+ _pad1: f32,
4485
+ };
4486
+
4487
+ @group(0) @binding(0) var<storage, read> velocities: array<vec2f>;
4488
+ @group(0) @binding(1) var<storage, read_write> accel: array<atomic<u32>>;
4489
+ @group(0) @binding(2) var<storage, read> alive: array<u32>;
4490
+ @group(0) @binding(3) var<storage, read> aliveCount: u32;
4491
+ @group(0) @binding(4) var<uniform> params: LimitParams;
4492
+ ${ATOMIC_ACCEL_WGSL}
4493
+ @compute @workgroup_size(64)
4494
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
4495
+ let i = gid.x;
4496
+ if (i >= aliveCount) { return; }
4497
+ if (alive[i] == 0u) { return; }
4498
+
4499
+ let v = velocities[i];
4500
+ let sp = length(v);
4501
+ if (sp <= params.maxSpeed || sp < 1e-6) { return; }
4502
+
4503
+ // Desired v' = (v / sp) * maxSpeed. Needed Δv = v' − v = v · (maxSpeed/sp − 1).
4504
+ // With Δv = a · dt we need a = Δv · (1/dt).
4505
+ let scale = (params.maxSpeed / sp - 1.0) * params.invDt;
4506
+ let correction = v * scale;
4507
+
4508
+ atomicAddF32(i * 2u + 0u, correction.x);
4509
+ atomicAddF32(i * 2u + 1u, correction.y);
4510
+ }
4511
+ `;
4512
+ /**
4513
+ * Hard cap on particle speed. Applies a corrective acceleration so that
4514
+ * after the next integrator step, speed ≤ maxSpeed. Register AFTER other
4515
+ * forces to see the full accumulated excess.
4516
+ */
4517
+ function speedLimit(options) {
4518
+ return {
4519
+ kind: "speedLimit",
4520
+ attach(ctx) {
4521
+ return new SpeedLimitHandle(ctx, options);
4522
+ }
4523
+ };
4524
+ }
4525
+ var SpeedLimitHandle = class {
4526
+ device;
4527
+ paramsBuffer;
4528
+ paramsScratch = new Float32Array(LIMIT_PARAMS_BYTES / 4);
4529
+ bindGroup;
4530
+ pipeline;
4531
+ maxSpeed;
4532
+ constructor(ctx, opts) {
4533
+ const { state } = ctx;
4534
+ const label = `${ctx.label}.speedLimit`;
4535
+ this.device = ctx.root.device;
4536
+ this.maxSpeed = opts.maxSpeed;
4537
+ this.paramsBuffer = this.device.createBuffer({
4538
+ label: `${label}.params`,
4539
+ size: LIMIT_PARAMS_BYTES,
4540
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
4541
+ });
4542
+ const layout = this.device.createBindGroupLayout({
4543
+ label: `${label}.layout`,
4544
+ entries: [
4545
+ {
4546
+ binding: 0,
4547
+ visibility: GPUShaderStage.COMPUTE,
4548
+ buffer: { type: "read-only-storage" }
4549
+ },
4550
+ {
4551
+ binding: 1,
4552
+ visibility: GPUShaderStage.COMPUTE,
4553
+ buffer: { type: "storage" }
4554
+ },
4555
+ {
4556
+ binding: 2,
4557
+ visibility: GPUShaderStage.COMPUTE,
4558
+ buffer: { type: "read-only-storage" }
4559
+ },
4560
+ {
4561
+ binding: 3,
4562
+ visibility: GPUShaderStage.COMPUTE,
4563
+ buffer: { type: "read-only-storage" }
4564
+ },
4565
+ {
4566
+ binding: 4,
4567
+ visibility: GPUShaderStage.COMPUTE,
4568
+ buffer: { type: "uniform" }
4569
+ }
4570
+ ]
4571
+ });
4572
+ this.bindGroup = this.device.createBindGroup({
4573
+ label: `${label}.group`,
4574
+ layout,
4575
+ entries: [
4576
+ {
4577
+ binding: 0,
4578
+ resource: { buffer: state.builtins.velocities }
4579
+ },
4580
+ {
4581
+ binding: 1,
4582
+ resource: { buffer: state.builtins.accel }
4583
+ },
4584
+ {
4585
+ binding: 2,
4586
+ resource: { buffer: state.builtins.alive }
4587
+ },
4588
+ {
4589
+ binding: 3,
4590
+ resource: { buffer: state.builtins.aliveCount }
4591
+ },
4592
+ {
4593
+ binding: 4,
4594
+ resource: { buffer: this.paramsBuffer }
4595
+ }
4596
+ ]
4597
+ });
4598
+ this.pipeline = this.device.createComputePipeline({
4599
+ label: `${label}.pipeline`,
4600
+ layout: this.device.createPipelineLayout({
4601
+ label: `${label}.pipelineLayout`,
4602
+ bindGroupLayouts: [layout]
4603
+ }),
4604
+ compute: {
4605
+ module: this.device.createShaderModule({
4606
+ label: `${label}.shader`,
4607
+ code: SPEED_LIMIT_WGSL
4608
+ }),
4609
+ entryPoint: "main"
4610
+ }
4611
+ });
4612
+ }
4613
+ record(pass, ctx) {
4614
+ this.paramsScratch[0] = this.maxSpeed;
4615
+ this.paramsScratch[1] = 1 / Math.max(ctx.dt, 1e-6);
4616
+ this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsScratch);
4617
+ pass.setPipeline(this.pipeline);
4618
+ pass.setBindGroup(0, this.bindGroup);
4619
+ pass.dispatchWorkgroupsIndirect(ctx.dispatchArgs, 0);
4620
+ }
4621
+ destroy() {
4622
+ this.paramsBuffer.destroy();
4623
+ }
4624
+ };
4625
+ //#endregion
4626
+ //#region src/particles/forces/particle-life.ts
4627
+ const MAX_SPECIES = 64;
4628
+ const PARAMS_BYTES = 32;
4629
+ var PairMatrix = class {
4630
+ speciesCount;
4631
+ /** Flat f32 data, length = speciesCount² × 4. Row-major. */
4632
+ data;
4633
+ constructor(speciesCount) {
4634
+ if (!Number.isInteger(speciesCount) || speciesCount <= 0 || speciesCount > MAX_SPECIES) throw new Error(`PairMatrix: speciesCount must be 1..${MAX_SPECIES} (got ${speciesCount})`);
4635
+ this.speciesCount = speciesCount;
4636
+ this.data = new Float32Array(speciesCount * speciesCount * 4);
4637
+ }
4638
+ set(a, b, rule) {
4639
+ const base = (a * this.speciesCount + b) * 4;
4640
+ this.data[base + 0] = rule.attract;
4641
+ this.data[base + 1] = rule.reach ?? 0;
4642
+ this.data[base + 2] = rule.minDist ?? 0;
4643
+ this.data[base + 3] = 0;
4644
+ }
4645
+ get(a, b) {
4646
+ const base = (a * this.speciesCount + b) * 4;
4647
+ return {
4648
+ attract: this.data[base + 0] ?? 0,
4649
+ reach: this.data[base + 1] ?? 0,
4650
+ minDist: this.data[base + 2] ?? 0
4651
+ };
4652
+ }
4653
+ };
4654
+ function randomPairMatrix(speciesCount, opts = {}) {
4655
+ const rand = opts.rand ?? Math.random;
4656
+ const [aMin, aMax] = opts.attract ?? [-1, 1];
4657
+ const m = new PairMatrix(speciesCount);
4658
+ for (let a = 0; a < speciesCount; a++) for (let b = 0; b < speciesCount; b++) {
4659
+ const attract = aMin + rand() * (aMax - aMin);
4660
+ const reach = opts.reach ? opts.reach[0] + rand() * (opts.reach[1] - opts.reach[0]) : 0;
4661
+ const minDist = opts.minDist ? opts.minDist[0] + rand() * (opts.minDist[1] - opts.minDist[0]) : 0;
4662
+ m.set(a, b, {
4663
+ attract,
4664
+ reach,
4665
+ minDist
4666
+ });
4667
+ }
4668
+ return m;
4669
+ }
4670
+ /**
4671
+ * Create a particleLife force. The returned Force's handle exposes
4672
+ * `updateMatrix(newMatrix)` so callers can regenerate the matrix without
4673
+ * rebuilding the pipeline.
4674
+ */
4675
+ function particleLife(options) {
4676
+ return new ParticleLifeForce(options);
4677
+ }
4678
+ var ParticleLifeForce = class {
4679
+ kind = "particleLife";
4680
+ opts;
4681
+ handle = null;
4682
+ constructor(opts) {
4683
+ this.opts = opts;
4684
+ }
4685
+ attach(ctx) {
4686
+ const speciesName = this.opts.speciesAttribute ?? "species";
4687
+ const speciesType = ctx.state.catalog[speciesName];
4688
+ if (speciesType !== "u32") throw new Error(`particleLife: requires attribute '${speciesName}' declared as 'u32' (got ${speciesType ?? "undeclared"}). Declare it on the system.`);
4689
+ const handle = new ParticleLifeHandle(ctx, this.opts);
4690
+ this.handle = handle;
4691
+ return handle;
4692
+ }
4693
+ /**
4694
+ * Replace the matrix contents on the GPU. Attach first; no-op if called
4695
+ * before. The matrix size must match the speciesCount fixed at attach time.
4696
+ */
4697
+ updateMatrix(matrix) {
4698
+ this.handle?.updateMatrix(matrix);
4699
+ }
4700
+ };
4701
+ var ParticleLifeHandle = class {
4702
+ device;
4703
+ matrixBuffer;
4704
+ speciesCount;
4705
+ inner;
4706
+ constructor(ctx, opts) {
4707
+ this.device = ctx.root.device;
4708
+ this.speciesCount = opts.matrix.speciesCount;
4709
+ const speciesName = opts.speciesAttribute ?? "species";
4710
+ const matrixBytes = this.speciesCount * this.speciesCount * 4 * 4;
4711
+ this.matrixBuffer = this.device.createBuffer({
4712
+ label: `${ctx.label}.particleLife.matrix`,
4713
+ size: matrixBytes,
4714
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
4715
+ });
4716
+ this.device.queue.writeBuffer(this.matrixBuffer, 0, opts.matrix.data);
4717
+ const paramsStructWgsl = `
4718
+ struct PairwiseParams {
4719
+ globalReach: f32,
4720
+ minDist: f32,
4721
+ repelStrength: f32,
4722
+ speciesCount: u32,
4723
+ _pad0: f32,
4724
+ _pad1: f32,
4725
+ _pad2: f32,
4726
+ _pad3: f32,
4727
+ };
4728
+
4729
+ struct PairRule {
4730
+ attract: f32,
4731
+ reach: f32,
4732
+ minDist: f32,
4733
+ _pad: f32,
4734
+ };
4735
+ `;
4736
+ const body = `
4737
+ let rule = pairMatrix[me.${speciesName} * params.speciesCount + other.${speciesName}];
4738
+ let delta = other.position - me.position;
4739
+ let d2 = dot(delta, delta);
4740
+ let effectiveReach = select(params.globalReach, rule.reach, rule.reach > 0.0);
4741
+ if (d2 <= effectiveReach * effectiveReach) {
4742
+ let d = max(sqrt(d2), 1e-4);
4743
+ let dir = delta / d;
4744
+ let effMinDist = select(params.minDist, rule.minDist, rule.minDist > 0.0);
4745
+ if (d < effMinDist) {
4746
+ // Short-range universal repulsion — prevents collapse regardless of sign.
4747
+ let pen = effMinDist - d;
4748
+ addAccel(me.index, dir * -params.repelStrength * pen);
4749
+ } else {
4750
+ // Asymmetric long-range attract/repel, linear falloff 1 → 0 across
4751
+ // [effMinDist, effectiveReach]. Newton's third law is deliberately
4752
+ // not enforced; the body writes only to me.
4753
+ let range = max(effectiveReach - effMinDist, 1e-3);
4754
+ let falloff = 1.0 - (d - effMinDist) / range;
4755
+ addAccel(me.index, dir * rule.attract * falloff);
4756
+ }
4757
+ }
4758
+ `;
4759
+ this.inner = new PairwiseHandle({
4760
+ ...ctx,
4761
+ label: `${ctx.label}.particleLife`
4762
+ }, {
4763
+ reach: opts.reach,
4764
+ reads: [speciesName],
4765
+ wgsl: body,
4766
+ label: "pairwise",
4767
+ params: {
4768
+ structWgsl: paramsStructWgsl,
4769
+ bytes: PARAMS_BYTES,
4770
+ write: (dv) => {
4771
+ dv.setFloat32(0, opts.reach, true);
4772
+ dv.setFloat32(4, opts.minDist, true);
4773
+ dv.setFloat32(8, opts.repelStrength ?? 20, true);
4774
+ dv.setUint32(12, this.speciesCount, true);
4775
+ }
4776
+ },
4777
+ extraBindings: [{
4778
+ wgsl: `@group(3) @binding(1) var<storage, read> pairMatrix: array<PairRule>;`,
4779
+ layoutEntry: {
4780
+ binding: 1,
4781
+ visibility: GPUShaderStage.COMPUTE,
4782
+ buffer: { type: "read-only-storage" }
4783
+ },
4784
+ bindGroupEntry: {
4785
+ binding: 1,
4786
+ resource: { buffer: this.matrixBuffer }
4787
+ }
4788
+ }]
4789
+ });
4790
+ }
4791
+ record(pass, ctx) {
4792
+ this.inner.record(pass, ctx);
4793
+ }
4794
+ updateMatrix(matrix) {
4795
+ if (matrix.speciesCount !== this.speciesCount) throw new Error(`particleLife.updateMatrix: speciesCount mismatch (force=${this.speciesCount}, new=${matrix.speciesCount}). Remove + re-add the force to change species count.`);
4796
+ this.device.queue.writeBuffer(this.matrixBuffer, 0, matrix.data);
4797
+ }
4798
+ destroy() {
4799
+ this.matrixBuffer.destroy();
4800
+ this.inner.destroy();
4801
+ }
4802
+ };
4803
+ //#endregion
4804
+ export { FieldHandle, PairMatrix, PairwiseHandle, ParticleDrawableV3, ParticleLifeForce, ParticleSpatialHash, ParticleSystem, attractor, boids, drag, field, gravity, noise, pairwise, particleLife, pointer, randomPairMatrix, speedLimit };