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.
- package/LICENSE.md +674 -0
- package/README.md +234 -0
- package/dist/advanced.d.mts +76 -0
- package/dist/advanced.mjs +81 -0
- package/dist/assemble-BT3CXbSx.mjs +1574 -0
- package/dist/camera-view-DHmMiKvP.d.mts +326 -0
- package/dist/frame-mHNdKRpF.mjs +135 -0
- package/dist/index-CmMZCMJT.d.mts +39 -0
- package/dist/index-DkJfpntS.d.mts +2417 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +6612 -0
- package/dist/internal.d.mts +892 -0
- package/dist/internal.mjs +566 -0
- package/dist/logger-DSyBF3Y_.mjs +15 -0
- package/dist/particles.d.mts +816 -0
- package/dist/particles.mjs +4804 -0
- package/dist/pipeline-BWCAZTKx.mjs +470 -0
- package/dist/pipeline-DE3a1Pnk.d.mts +115 -0
- package/dist/reactivity-B7I0pvzm.mjs +191 -0
- package/dist/reactivity.d.mts +2 -0
- package/dist/reactivity.mjs +2 -0
- package/dist/renderer-DzZqd1bY.d.mts +4566 -0
- package/dist/root-CHradZKM.mjs +30 -0
- package/dist/shape-DfZP9Jdk.mjs +349 -0
- package/dist/space-CeDnj6eu.mjs +11240 -0
- package/dist/spatial-Bd3Ay8I2.d.mts +85 -0
- package/dist/spatial-hash-C1crBjTo.mjs +77 -0
- package/dist/spatial.d.mts +2 -0
- package/dist/spatial.mjs +121 -0
- package/dist/text-font-D7GGDtTK.d.mts +185 -0
- package/dist/text-ttf.d.mts +91 -0
- package/dist/text-ttf.mjs +298 -0
- package/dist/texture-dABoqFoP.mjs +131 -0
- package/dist/viewport.d.mts +2 -0
- package/dist/viewport.mjs +274 -0
- package/package.json +69 -0
|
@@ -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 };
|