@aethervtt/shard-particles 0.0.1

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/dist/sim.js ADDED
@@ -0,0 +1,962 @@
1
+ import {
2
+ defineResource,
3
+ defineSystem,
4
+ mat4,
5
+ ProfilerResource
6
+ } from "@aethervtt/shard-core";
7
+ import { GpuBuffer } from "@aethervtt/shard-gpu";
8
+ import {
9
+ ComputedVisibility,
10
+ cameraOf,
11
+ ForwardStateResource,
12
+ Gpu,
13
+ GpuAssetsResource,
14
+ Meshes,
15
+ RenderPhase,
16
+ Shaders,
17
+ Views
18
+ } from "@aethervtt/shard-render";
19
+ import { Time } from "@aethervtt/shard-runtime";
20
+ import { Textures } from "@aethervtt/shard-texture";
21
+ import { GlobalTransform } from "@aethervtt/shard-transform";
22
+ import { ParticleEmitterOverrides, ParticleSystem } from "./components.js";
23
+ import { ParticleEffects } from "./effect.js";
24
+ import { MODULES } from "./modules.js";
25
+ import {
26
+ DRAW_BYTES,
27
+ emitterKey,
28
+ PARTICLE_FLOATS,
29
+ renderShader,
30
+ SIM_BYTES,
31
+ SORT_SHADER,
32
+ simulationShader
33
+ } from "./shaders.js";
34
+ import { pcg, rand } from "./values.js";
35
+ const PARTICLE_BYTES = PARTICLE_FLOATS * 4;
36
+ const MAX_SORTED = 65536;
37
+ class ParticleStore {
38
+ systems = /* @__PURE__ */ new Map();
39
+ frame = 0;
40
+ /** The view whose camera simulation uses (depth collision, culling, sorting). */
41
+ primary;
42
+ primaryCamera;
43
+ }
44
+ const Particles = defineResource("particles/Particles", {
45
+ description: "Running particle systems: GPU buffers, spawn schedules, alive counts.",
46
+ init: () => new ParticleStore()
47
+ });
48
+ function spawnCount(e, time, dt, rate) {
49
+ e.carry += rate * dt;
50
+ let n = Math.floor(e.carry);
51
+ e.carry -= n;
52
+ e.def.spawn.bursts.forEach((b, i) => {
53
+ const fired = e.fired[i] ?? 0;
54
+ if (b.cycles > 0 && fired >= b.cycles) return;
55
+ const at = b.time + fired * b.interval;
56
+ if (time >= at && (fired === 0 || b.interval > 0)) {
57
+ n += b.count;
58
+ e.fired[i] = fired + 1;
59
+ }
60
+ });
61
+ return Math.min(n, e.capacity);
62
+ }
63
+ const cpuParticle = {
64
+ pos: new Float32Array(3),
65
+ vel: new Float32Array(3),
66
+ age: 0,
67
+ life: 0,
68
+ rot: 0,
69
+ rotSpeed: 0,
70
+ seed: 0
71
+ };
72
+ function simulateCpu(e, sys, dt) {
73
+ const d = e.cpu;
74
+ const def = e.def;
75
+ const s = def.shape;
76
+ const init = def.init;
77
+ for (let i = 0; i < e.spawnCount; i++) {
78
+ const k = e.spawned + i;
79
+ const slot = k % e.capacity;
80
+ const seed = pcg((sys.seed ^ pcg(k)) >>> 0);
81
+ let px = 0;
82
+ let py = 0;
83
+ let pz = 0;
84
+ let dx = 0;
85
+ let dy = 1;
86
+ let dz = 0;
87
+ const dir = (k0) => {
88
+ const z = rand(seed, k0) * 2 - 1;
89
+ const phi = 6.2831853 * rand(seed, k0 + 1);
90
+ const r = Math.sqrt(Math.max(1 - z * z, 0));
91
+ dx = r * Math.cos(phi);
92
+ dy = z;
93
+ dz = r * Math.sin(phi);
94
+ };
95
+ if (s.type === "point") dir(6);
96
+ else if (s.type === "sphere") {
97
+ dir(6);
98
+ const r = s.radius * rand(seed, 9) ** 0.3333333;
99
+ px = dx * r;
100
+ py = dy * r;
101
+ pz = dz * r;
102
+ } else if (s.type === "cone") {
103
+ const a = s.angle * Math.PI / 180 * Math.sqrt(rand(seed, 6));
104
+ const phi = 6.2831853 * rand(seed, 7);
105
+ dx = Math.sin(a) * Math.cos(phi);
106
+ dy = Math.cos(a);
107
+ dz = Math.sin(a) * Math.sin(phi);
108
+ const r = s.radius * Math.sqrt(rand(seed, 8));
109
+ px = Math.cos(phi) * r;
110
+ pz = Math.sin(phi) * r;
111
+ } else {
112
+ px = (rand(seed, 6) - 0.5) * s.size[0];
113
+ py = (rand(seed, 7) - 0.5) * s.size[1];
114
+ pz = (rand(seed, 8) - 0.5) * s.size[2];
115
+ }
116
+ const mix = (r, t) => r[0] + (r[1] - r[0]) * t;
117
+ const speed = mix(init.speed, rand(seed, 1));
118
+ const p = cpuParticle;
119
+ p.pos[0] = px;
120
+ p.pos[1] = py;
121
+ p.pos[2] = pz;
122
+ p.vel[0] = dx * speed;
123
+ p.vel[1] = dy * speed;
124
+ p.vel[2] = dz * speed;
125
+ p.life = Math.max(mix(init.lifetime, rand(seed, 2)), 1e-3);
126
+ p.seed = seed;
127
+ p.rotSpeed = 0;
128
+ for (const m of def.update) MODULES[m.module].cpuInit?.(m, p);
129
+ if (!sys.local) {
130
+ const g = sys.model;
131
+ const x = p.pos[0];
132
+ const y = p.pos[1];
133
+ const z = p.pos[2];
134
+ for (let a = 0; a < 3; a++) {
135
+ p.pos[a] = g[a] * x + g[4 + a] * y + g[8 + a] * z + g[12 + a];
136
+ }
137
+ const vx = p.vel[0];
138
+ const vy = p.vel[1];
139
+ const vz = p.vel[2];
140
+ for (let a = 0; a < 3; a++) p.vel[a] = g[a] * vx + g[4 + a] * vy + g[8 + a] * vz;
141
+ }
142
+ const o = slot * PARTICLE_FLOATS;
143
+ d.set(p.pos, o);
144
+ d[o + 3] = 0;
145
+ d.set(p.vel, o + 4);
146
+ d[o + 7] = p.life;
147
+ d[o + 8] = mix(init.size, rand(seed, 3));
148
+ d[o + 9] = mix(init.rotation, rand(seed, 4)) * Math.PI / 180;
149
+ d[o + 10] = p.rotSpeed;
150
+ new Uint32Array(d.buffer, (o + 11) * 4, 1)[0] = seed;
151
+ const c = rand(seed, 5);
152
+ for (let a = 0; a < 4; a++)
153
+ d[o + 12 + a] = init.color[0][a] + (init.color[1][a] - init.color[0][a]) * c;
154
+ }
155
+ const u = new Uint32Array(d.buffer);
156
+ let alive = 0;
157
+ for (let i = 0; i < e.capacity; i++) {
158
+ const o = i * PARTICLE_FLOATS;
159
+ const life = d[o + 7];
160
+ let age = d[o + 3];
161
+ if (!(life > 0 && age < life)) continue;
162
+ const p = cpuParticle;
163
+ p.pos[0] = d[o];
164
+ p.pos[1] = d[o + 1];
165
+ p.pos[2] = d[o + 2];
166
+ p.vel[0] = d[o + 4];
167
+ p.vel[1] = d[o + 5];
168
+ p.vel[2] = d[o + 6];
169
+ age += dt;
170
+ p.age = age;
171
+ p.life = life;
172
+ p.rot = d[o + 9];
173
+ p.rotSpeed = d[o + 10];
174
+ p.seed = u[o + 11];
175
+ for (const m of def.update) MODULES[m.module].cpu?.(m, p, dt, sys.time);
176
+ for (let a = 0; a < 3; a++) d[o + a] = p.pos[a] + p.vel[a] * dt;
177
+ d[o + 3] = p.age;
178
+ d.set(p.vel, o + 4);
179
+ d[o + 9] = p.rot;
180
+ if (p.age < life) alive++;
181
+ }
182
+ e.alive = alive;
183
+ }
184
+ const simScratch = new Float32Array(SIM_BYTES / 4);
185
+ const simU32 = new Uint32Array(simScratch.buffer);
186
+ const drawScratch = new Float32Array(DRAW_BYTES / 4);
187
+ const drawU32 = new Uint32Array(drawScratch.buffer);
188
+ const identity = mat4.create();
189
+ const zeros = new Uint32Array(4);
190
+ const BLEND_INDEX = { additive: 0, alpha: 1, premultiplied: 2 };
191
+ function createEmitter(gpu, entity, def, cpu, previous) {
192
+ const keep = previous && previous.capacity === def.capacity;
193
+ const sortable = def.render.blend !== "additive" && def.capacity <= MAX_SORTED;
194
+ const n = sortable ? 2 ** Math.ceil(Math.log2(Math.max(2, def.capacity))) : 0;
195
+ const steps = sortable ? Math.log2(n) * (Math.log2(n) + 1) / 2 : 0;
196
+ const label = `particles/${entity}/${def.name}`;
197
+ const state = {
198
+ def,
199
+ key: emitterKey(def),
200
+ capacity: def.capacity,
201
+ particles: keep ? previous.particles : new GpuBuffer(gpu, {
202
+ label,
203
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
204
+ size: def.capacity * PARTICLE_BYTES
205
+ }),
206
+ cpu: cpu ? keep && previous.cpu ? previous.cpu : new Float32Array(def.capacity * PARTICLE_FLOATS) : void 0,
207
+ sim: previous?.sim ?? new GpuBuffer(gpu, { label: `${label}/sim`, usage: GPUBufferUsage.UNIFORM, size: SIM_BYTES }),
208
+ draw: previous?.draw ?? new GpuBuffer(gpu, {
209
+ label: `${label}/draw`,
210
+ usage: GPUBufferUsage.UNIFORM,
211
+ size: DRAW_BYTES
212
+ }),
213
+ counter: previous?.counter ?? gpu.device.createBuffer({
214
+ label: `${label}/alive`,
215
+ size: 16,
216
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST
217
+ }),
218
+ readbacks: previous?.readbacks ?? [0, 1, 2].map(() => ({
219
+ buffer: gpu.device.createBuffer({
220
+ label: `${label}/alive-readback`,
221
+ size: 16,
222
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
223
+ }),
224
+ busy: false
225
+ })),
226
+ spawned: keep ? previous.spawned : 0,
227
+ carry: keep ? previous.carry : 0,
228
+ fired: keep ? previous.fired : [],
229
+ spawnCount: 0,
230
+ backlog: 0,
231
+ alive: keep ? previous.alive : 0,
232
+ sort: sortable ? {
233
+ keys: gpu.device.createBuffer({
234
+ label: `${label}/keys`,
235
+ size: n * 4,
236
+ usage: GPUBufferUsage.STORAGE
237
+ }),
238
+ order: gpu.device.createBuffer({
239
+ label: `${label}/order`,
240
+ size: n * 4,
241
+ usage: GPUBufferUsage.STORAGE
242
+ }),
243
+ steps: gpu.device.createBuffer({
244
+ label: `${label}/steps`,
245
+ size: (steps + 1) * 256,
246
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
247
+ }),
248
+ count: steps,
249
+ n
250
+ } : void 0,
251
+ simulate: true,
252
+ dt: 0,
253
+ visible: true,
254
+ originShift: keep ? previous.originShift : new Float64Array(3)
255
+ };
256
+ if (state.sort) writeSortSteps(gpu, state.sort, def.capacity);
257
+ return state;
258
+ }
259
+ function writeSortSteps(gpu, s, capacity) {
260
+ const data = new Uint32Array((s.count + 1) * 64);
261
+ data[2] = s.n;
262
+ data[3] = capacity;
263
+ let slot = 1;
264
+ for (let k = 2; k <= s.n; k *= 2) {
265
+ for (let j = k / 2; j >= 1; j = Math.floor(j / 2)) {
266
+ data[slot * 64] = j;
267
+ data[slot * 64 + 1] = k;
268
+ data[slot * 64 + 2] = s.n;
269
+ data[slot * 64 + 3] = capacity;
270
+ slot++;
271
+ }
272
+ }
273
+ gpu.device.queue.writeBuffer(s.steps, 0, data);
274
+ }
275
+ function reach(effect) {
276
+ let r = 0;
277
+ for (const e of effect.emitters) {
278
+ if (e.bounds > 0) {
279
+ r = Math.max(r, e.bounds);
280
+ continue;
281
+ }
282
+ const size = Math.max(...e.shape.size);
283
+ r = Math.max(
284
+ r,
285
+ e.shape.radius + size + e.init.speed[1] * e.init.lifetime[1] * 1.5 + e.init.size[1]
286
+ );
287
+ }
288
+ return r;
289
+ }
290
+ const prepareParticles = defineSystem({
291
+ name: "particles/prepare",
292
+ description: "Schedules spawns, writes particle uniforms, and runs the CPU backend.",
293
+ setup: (world) => ({
294
+ q: world.query({ with: [ParticleSystem, GlobalTransform, ComputedVisibility] })
295
+ }),
296
+ run: ({ q }, world) => {
297
+ const store = world.resource(Particles);
298
+ const gpu = world.resource(Gpu);
299
+ const effects = world.resource(ParticleEffects);
300
+ const time = world.resource(Time);
301
+ store.frame++;
302
+ let primary;
303
+ for (const view of world.resource(Views).list) {
304
+ if (cameraOf(view) && (!primary || view.order < primary.order)) primary = view;
305
+ }
306
+ store.primary = primary?.name;
307
+ store.primaryCamera = primary ? cameraOf(primary) : void 0;
308
+ const cam = store.primaryCamera;
309
+ for (const table of q.tables) {
310
+ const effectRefs = table.column(ParticleSystem, "effect");
311
+ const playing = table.column(ParticleSystem, "playing");
312
+ const seeds = table.column(ParticleSystem, "seed");
313
+ const scales = table.column(ParticleSystem, "timeScale");
314
+ const spaces = table.column(ParticleSystem, "space");
315
+ const backends = table.column(ParticleSystem, "backend");
316
+ const g = table.column(GlobalTransform, "matrix");
317
+ const vis = table.column(ComputedVisibility, "visible");
318
+ const overrides = table.has(ParticleEmitterOverrides);
319
+ for (let i = 0; i < table.count; i++) {
320
+ const entity = table.entities[i];
321
+ const effect = effects.get(effectRefs[i]);
322
+ if (!effect || !vis[i]) continue;
323
+ const cpu = backends[i] === 1;
324
+ let sys = store.systems.get(entity);
325
+ if (!sys || sys.effect !== effect || sys.version !== effect.version || sys.cpu !== cpu) {
326
+ const old = sys;
327
+ sys = {
328
+ entity,
329
+ effect,
330
+ version: effect.version,
331
+ emitters: effect.emitters.map((def, k) => {
332
+ const prev = old?.effect === effect ? old.emitters[k] : void 0;
333
+ return createEmitter(
334
+ gpu,
335
+ entity,
336
+ def,
337
+ cpu,
338
+ prev && prev.def.name === def.name ? prev : void 0
339
+ );
340
+ }),
341
+ time: old?.effect === effect ? old.time : 0,
342
+ seed: seeds[i],
343
+ local: spaces[i] === 1,
344
+ cpu,
345
+ model: new Float32Array(16),
346
+ center: new Float32Array(3),
347
+ radius: reach(effect),
348
+ frame: 0,
349
+ seen: 0
350
+ };
351
+ store.systems.set(entity, sys);
352
+ }
353
+ sys.seen = store.frame;
354
+ sys.seed = seeds[i];
355
+ sys.local = spaces[i] === 1;
356
+ const o = i * 12;
357
+ const m = sys.model;
358
+ m[0] = g[o];
359
+ m[1] = g[o + 4];
360
+ m[2] = g[o + 8];
361
+ m[3] = 0;
362
+ m[4] = g[o + 1];
363
+ m[5] = g[o + 5];
364
+ m[6] = g[o + 9];
365
+ m[7] = 0;
366
+ m[8] = g[o + 2];
367
+ m[9] = g[o + 6];
368
+ m[10] = g[o + 10];
369
+ m[11] = 0;
370
+ m[12] = g[o + 3];
371
+ m[13] = g[o + 7];
372
+ m[14] = g[o + 11];
373
+ m[15] = 1;
374
+ sys.center[0] = m[12];
375
+ sys.center[1] = m[13];
376
+ sys.center[2] = m[14];
377
+ const visible = !cam || sphereVisible(cam.frustum, sys.center, sys.radius);
378
+ const dt = playing[i] ? time.delta * scales[i] : 0;
379
+ sys.frame++;
380
+ if (dt > 0) sys.time += dt;
381
+ for (const e of sys.emitters) {
382
+ e.visible = visible;
383
+ const mode = e.def.offscreen;
384
+ e.simulate = visible || mode === "simulate" || mode === "reduced" && sys.frame % 4 === 0;
385
+ e.dt = visible || mode === "simulate" ? dt : mode === "reduced" ? dt * 4 : 0;
386
+ if (!e.simulate) e.dt = 0;
387
+ let rate = e.def.spawn.rate;
388
+ if (overrides) {
389
+ const name = table.column(ParticleEmitterOverrides, "emitter")[i];
390
+ if (!name || name === e.def.name) {
391
+ const r = table.column(ParticleEmitterOverrides, "spawnRate")[i];
392
+ if (r >= 0) rate = r;
393
+ rate *= table.column(ParticleEmitterOverrides, "spawnScale")[i];
394
+ }
395
+ }
396
+ e.spawnCount = Math.min(
397
+ e.capacity,
398
+ e.backlog + (e.dt > 0 ? spawnCount(e, sys.time, e.dt, rate) : 0)
399
+ );
400
+ e.backlog = e.spawnCount;
401
+ writeUniforms(e, sys, cam);
402
+ if (sys.cpu && e.cpu) {
403
+ simulateCpu(e, sys, e.dt);
404
+ e.particles.write(e.cpu);
405
+ e.spawned += e.spawnCount;
406
+ e.backlog = 0;
407
+ }
408
+ }
409
+ }
410
+ }
411
+ for (const [entity, sys] of store.systems) {
412
+ if (sys.seen !== store.frame) store.systems.delete(entity);
413
+ }
414
+ }
415
+ });
416
+ function sphereVisible(planes, c, r) {
417
+ for (let p = 0; p < 6; p++) {
418
+ const d = planes[p * 4] * c[0] + planes[p * 4 + 1] * c[1] + planes[p * 4 + 2] * c[2] + planes[p * 4 + 3];
419
+ if (d < -r) return false;
420
+ }
421
+ return true;
422
+ }
423
+ function writeUniforms(e, sys, cam) {
424
+ const s = simScratch;
425
+ s.set(sys.model, 0);
426
+ s.set(sys.local ? identity : sys.model, 16);
427
+ s.set(cam?.viewProj ?? identity, 32);
428
+ s.set(cam?.invViewProj ?? identity, 48);
429
+ if (cam) s.set(cam.position, 64);
430
+ s[68] = cam?.width ?? 1;
431
+ s[69] = cam?.height ?? 1;
432
+ s[72] = e.dt;
433
+ s[73] = sys.time;
434
+ simU32[74] = e.capacity;
435
+ simU32[75] = e.spawned >>> 0;
436
+ simU32[76] = e.spawnCount;
437
+ simU32[77] = sys.seed;
438
+ simU32[78] = sys.local ? 1 : 0;
439
+ simU32[79] = cam ? 1 : 0;
440
+ s[80] = e.originShift[0];
441
+ s[81] = e.originShift[1];
442
+ s[82] = e.originShift[2];
443
+ e.sim.write(s);
444
+ const d = drawScratch;
445
+ d.set(sys.model, 0);
446
+ d[16] = e.def.render.emissive;
447
+ d[17] = e.def.render.softness;
448
+ d[18] = e.def.render.stretch;
449
+ d[19] = sys.local ? 1 : 0;
450
+ d[20] = Math.max(1, e.def.render.flipbook.columns);
451
+ d[21] = Math.max(1, e.def.render.flipbook.rows);
452
+ d[22] = e.def.render.flipbook.fps;
453
+ d[23] = 0;
454
+ drawU32[24] = BLEND_INDEX[e.def.render.blend];
455
+ drawU32[25] = e.sort ? 1 : 0;
456
+ drawU32[26] = drawU32[27] = 0;
457
+ e.draw.write(d);
458
+ }
459
+ const ParticleCaches = defineResource("particles/Caches", {
460
+ description: "Pipelines and bind groups of the particle passes.",
461
+ init: () => ({ generation: -1, compute: /* @__PURE__ */ new Map(), render: /* @__PURE__ */ new Map(), groups: /* @__PURE__ */ new Map() })
462
+ });
463
+ const ids = /* @__PURE__ */ new WeakMap();
464
+ let nextId = 1;
465
+ function idOf(o) {
466
+ let id = ids.get(o);
467
+ if (id === void 0) {
468
+ id = nextId++;
469
+ ids.set(o, id);
470
+ }
471
+ return id;
472
+ }
473
+ function caches(world, gpu) {
474
+ const c = world.initResource(ParticleCaches);
475
+ if (c.generation !== gpu.generation) {
476
+ c.generation = gpu.generation;
477
+ c.layouts = void 0;
478
+ c.compute.clear();
479
+ c.render.clear();
480
+ c.sort = void 0;
481
+ c.groups.clear();
482
+ c.white = c.noDepth = void 0;
483
+ c.noOrder = void 0;
484
+ c.sampler = void 0;
485
+ }
486
+ if (!c.layouts) {
487
+ const C = GPUShaderStage.COMPUTE;
488
+ const V = GPUShaderStage.VERTEX;
489
+ const F = GPUShaderStage.FRAGMENT;
490
+ c.layouts = {
491
+ sim: gpu.layouts.bindGroupLayout({
492
+ label: "particles/sim",
493
+ entries: [
494
+ { binding: 0, visibility: C, buffer: { type: "storage" } },
495
+ { binding: 1, visibility: C, buffer: { type: "uniform" } },
496
+ { binding: 2, visibility: C, texture: { sampleType: "depth" } },
497
+ { binding: 3, visibility: C, buffer: { type: "storage" } }
498
+ ]
499
+ }),
500
+ sort: gpu.layouts.bindGroupLayout({
501
+ label: "particles/sort",
502
+ entries: [
503
+ { binding: 0, visibility: C, buffer: { type: "uniform" } },
504
+ { binding: 10, visibility: C, buffer: { type: "read-only-storage" } },
505
+ { binding: 11, visibility: C, buffer: { type: "storage" } },
506
+ { binding: 12, visibility: C, buffer: { type: "storage" } },
507
+ { binding: 13, visibility: C, buffer: { type: "uniform" } },
508
+ { binding: 14, visibility: C, buffer: { type: "uniform" } }
509
+ ]
510
+ }),
511
+ data: gpu.layouts.bindGroupLayout({
512
+ label: "particles/data",
513
+ entries: [
514
+ { binding: 0, visibility: V, buffer: { type: "read-only-storage" } },
515
+ { binding: 1, visibility: V | F, buffer: { type: "uniform" } },
516
+ { binding: 2, visibility: V, buffer: { type: "read-only-storage" } }
517
+ ]
518
+ }),
519
+ texture: gpu.layouts.bindGroupLayout({
520
+ label: "particles/texture",
521
+ entries: [
522
+ { binding: 0, visibility: F, texture: { sampleType: "float" } },
523
+ { binding: 1, visibility: F, sampler: { type: "filtering" } },
524
+ { binding: 2, visibility: F, texture: { sampleType: "depth" } }
525
+ ]
526
+ }),
527
+ view: gpu.layouts.bindGroupLayout({
528
+ label: "particles/view",
529
+ entries: [{ binding: 0, visibility: V | F, buffer: { type: "uniform" } }]
530
+ })
531
+ };
532
+ }
533
+ if (!c.white) {
534
+ c.white = gpu.device.createTexture({
535
+ label: "particles/white",
536
+ size: [1, 1],
537
+ format: "rgba8unorm",
538
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
539
+ });
540
+ gpu.device.queue.writeTexture(
541
+ { texture: c.white },
542
+ new Uint8Array([255, 255, 255, 255]),
543
+ {},
544
+ [1, 1]
545
+ );
546
+ c.noDepth = gpu.device.createTexture({
547
+ label: "particles/no-depth",
548
+ size: [1, 1],
549
+ format: "depth32float",
550
+ usage: GPUTextureUsage.TEXTURE_BINDING
551
+ });
552
+ c.noOrder = gpu.device.createBuffer({
553
+ label: "particles/no-order",
554
+ size: 16,
555
+ usage: GPUBufferUsage.STORAGE
556
+ });
557
+ c.sampler = gpu.device.createSampler({
558
+ label: "particles",
559
+ magFilter: "linear",
560
+ minFilter: "linear",
561
+ mipmapFilter: "linear"
562
+ });
563
+ }
564
+ return c;
565
+ }
566
+ function group(gpu, c, slot, key, layout, entries) {
567
+ let g = c.groups.get(slot);
568
+ if (!g || g.key !== key) {
569
+ g = { key, group: gpu.device.createBindGroup({ label: slot, layout, entries: entries() }) };
570
+ c.groups.set(slot, g);
571
+ }
572
+ return g.group;
573
+ }
574
+ function computePipelines(ctx, c, e) {
575
+ const cached = c.compute.get(e.key);
576
+ if (cached) return cached;
577
+ const gpu = ctx.gpu;
578
+ const shaders = ctx.world.resource(Shaders);
579
+ const path = `particles::sim::e${idOf(e.def)}`;
580
+ if (!shaders.has(path)) shaders.register(path, simulationShader(e.def), `particles:${e.def.name}`);
581
+ const module = shaders.module(gpu, { root: path });
582
+ if (!module) {
583
+ gpu.pipelines.skipped++;
584
+ return void 0;
585
+ }
586
+ const layout = gpu.layouts.pipelineLayout({
587
+ label: "particles/sim",
588
+ bindGroupLayouts: [c.layouts.sim]
589
+ });
590
+ const spawn = gpu.pipelines.compute({
591
+ label: `${path}/spawn`,
592
+ layout,
593
+ compute: { module, entryPoint: "spawn" }
594
+ });
595
+ const update = gpu.pipelines.compute({
596
+ label: `${path}/update`,
597
+ layout,
598
+ compute: { module, entryPoint: "update" }
599
+ });
600
+ if (!spawn || !update) return void 0;
601
+ const p = { spawn, update };
602
+ c.compute.set(e.key, p);
603
+ return p;
604
+ }
605
+ function sortPipelines(ctx, c) {
606
+ if (c.sort) return c.sort;
607
+ const gpu = ctx.gpu;
608
+ const shaders = ctx.world.resource(Shaders);
609
+ if (!shaders.has("particles::sort"))
610
+ shaders.register("particles::sort", SORT_SHADER, "particles:sort");
611
+ const module = shaders.module(gpu, { root: "particles::sort" });
612
+ if (!module) return void 0;
613
+ const layout = gpu.layouts.pipelineLayout({
614
+ label: "particles/sort",
615
+ bindGroupLayouts: [c.layouts.sort]
616
+ });
617
+ const keys = gpu.pipelines.compute({
618
+ label: "particles/sort-keys",
619
+ layout,
620
+ compute: { module, entryPoint: "keys_pass" }
621
+ });
622
+ const sort = gpu.pipelines.compute({
623
+ label: "particles/sort",
624
+ layout,
625
+ compute: { module, entryPoint: "sort_pass" }
626
+ });
627
+ if (!keys || !sort) return void 0;
628
+ c.sort = { keys, sort };
629
+ return c.sort;
630
+ }
631
+ function simulateNode() {
632
+ return {
633
+ kind: "raw",
634
+ phase: RenderPhase.Resolve + 10,
635
+ enabled: (view) => cameraOf(view) !== void 0,
636
+ reads: ["depth"],
637
+ sideEffects: true,
638
+ run: (ctx) => {
639
+ const store = ctx.world.resource(Particles);
640
+ if (ctx.view.name !== store.primary || store.systems.size === 0) return;
641
+ const gpu = ctx.gpu;
642
+ const c = caches(ctx.world, gpu);
643
+ const depth = ctx.texture("depth");
644
+ const pv = ctx.world.resource(ForwardStateResource).views.get(ctx.view.name);
645
+ const pass = ctx.encoder.beginComputePass({
646
+ label: "particles/simulate",
647
+ timestampWrites: ctx.timestamps("particles/simulate")
648
+ });
649
+ for (const sys of store.systems.values()) {
650
+ for (const e of sys.emitters) {
651
+ if (!sys.cpu && e.simulate) {
652
+ const p = computePipelines(ctx, c, e);
653
+ if (!p) continue;
654
+ gpu.device.queue.writeBuffer(e.counter, 0, zeros);
655
+ const g = group(
656
+ gpu,
657
+ c,
658
+ `sim/${sys.entity}/${e.def.name}`,
659
+ `${idOf(e.particles.buffer)}/${idOf(e.sim.buffer)}/${idOf(depth)}`,
660
+ c.layouts.sim,
661
+ () => [
662
+ { binding: 0, resource: { buffer: e.particles.buffer } },
663
+ { binding: 1, resource: { buffer: e.sim.buffer } },
664
+ { binding: 2, resource: depth.createView() },
665
+ { binding: 3, resource: { buffer: e.counter } }
666
+ ]
667
+ );
668
+ pass.setBindGroup(0, g);
669
+ if (e.spawnCount > 0) {
670
+ pass.setPipeline(p.spawn);
671
+ pass.dispatchWorkgroups(Math.ceil(e.spawnCount / 64));
672
+ }
673
+ pass.setPipeline(p.update);
674
+ pass.dispatchWorkgroups(Math.ceil(e.capacity / 64));
675
+ e.originShift[0] = e.originShift[1] = e.originShift[2] = 0;
676
+ e.spawned += e.spawnCount;
677
+ e.backlog = 0;
678
+ }
679
+ if (e.sort && pv) sortEmitter(ctx, c, pass, sys, e, pv.uniform.buffer);
680
+ }
681
+ }
682
+ pass.end();
683
+ for (const sys of store.systems.values()) {
684
+ if (sys.cpu) continue;
685
+ for (const e of sys.emitters) {
686
+ const rb = e.readbacks.find((r) => !r.busy);
687
+ if (!rb) continue;
688
+ ctx.encoder.copyBufferToBuffer(e.counter, 0, rb.buffer, 0, 16);
689
+ rb.busy = true;
690
+ ctx.afterSubmit(() => {
691
+ rb.buffer.mapAsync(GPUMapMode.READ).then(
692
+ () => {
693
+ e.alive = new Uint32Array(rb.buffer.getMappedRange())[0];
694
+ rb.buffer.unmap();
695
+ rb.busy = false;
696
+ },
697
+ () => {
698
+ rb.busy = false;
699
+ }
700
+ );
701
+ });
702
+ }
703
+ }
704
+ }
705
+ };
706
+ }
707
+ function sortEmitter(ctx, c, pass, sys, e, viewBuffer) {
708
+ const gpu = ctx.gpu;
709
+ const p = sortPipelines(ctx, c);
710
+ const s = e.sort;
711
+ if (!p) return;
712
+ const modelBuffer = e.draw.buffer;
713
+ const groupFor = (i) => group(
714
+ gpu,
715
+ c,
716
+ `sort/${sys.entity}/${e.def.name}/${i}`,
717
+ `${idOf(e.particles.buffer)}/${idOf(viewBuffer)}/${idOf(s.keys)}`,
718
+ c.layouts.sort,
719
+ () => [
720
+ { binding: 0, resource: { buffer: viewBuffer } },
721
+ { binding: 10, resource: { buffer: e.particles.buffer } },
722
+ { binding: 11, resource: { buffer: s.keys } },
723
+ { binding: 12, resource: { buffer: s.order } },
724
+ { binding: 13, resource: { buffer: s.steps, offset: i * 256, size: 16 } },
725
+ // The draw uniform starts with the model matrix: particles in local space sort in world.
726
+ {
727
+ binding: 14,
728
+ resource: { buffer: sys.local ? modelBuffer : identityBuffer(gpu, c), size: 64 }
729
+ }
730
+ ]
731
+ );
732
+ pass.setPipeline(p.keys);
733
+ pass.setBindGroup(0, groupFor(0));
734
+ pass.dispatchWorkgroups(Math.ceil(s.n / 64));
735
+ pass.setPipeline(p.sort);
736
+ for (let i = 1; i <= s.count; i++) {
737
+ pass.setBindGroup(0, groupFor(i));
738
+ pass.dispatchWorkgroups(Math.ceil(s.n / 64));
739
+ }
740
+ }
741
+ let identityBuf;
742
+ function identityBuffer(gpu, _c) {
743
+ if (!identityBuf || identityBuf.generation !== gpu.generation) {
744
+ const buffer = gpu.device.createBuffer({
745
+ label: "particles/identity",
746
+ size: 64,
747
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
748
+ });
749
+ gpu.device.queue.writeBuffer(buffer, 0, identity);
750
+ identityBuf = { buffer, generation: gpu.generation };
751
+ }
752
+ return identityBuf.buffer;
753
+ }
754
+ const ADDITIVE = {
755
+ color: { srcFactor: "one", dstFactor: "one", operation: "add" },
756
+ alpha: { srcFactor: "zero", dstFactor: "one", operation: "add" }
757
+ };
758
+ const OVER = {
759
+ color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
760
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" }
761
+ };
762
+ function renderPipeline(ctx, c, e) {
763
+ const key = `${e.key}/${e.def.render.blend}`;
764
+ const cached = c.render.get(key);
765
+ if (cached) return cached;
766
+ const gpu = ctx.gpu;
767
+ const shaders = ctx.world.resource(Shaders);
768
+ const path = `particles::draw::e${idOf(e.def)}`;
769
+ if (!shaders.has(path)) shaders.register(path, renderShader(e.def), `particles:${e.def.name}`);
770
+ const module = shaders.module(gpu, { root: path });
771
+ if (!module) {
772
+ gpu.pipelines.skipped++;
773
+ return void 0;
774
+ }
775
+ const l = c.layouts;
776
+ const mesh = e.def.render.mode === "mesh";
777
+ const p = gpu.pipelines.render({
778
+ label: `${path}/${e.def.render.blend}`,
779
+ layout: gpu.layouts.pipelineLayout({
780
+ label: "particles/draw",
781
+ bindGroupLayouts: [l.view, l.data, l.texture]
782
+ }),
783
+ vertex: {
784
+ module,
785
+ entryPoint: "vs",
786
+ buffers: mesh ? [{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x3" }] }] : []
787
+ },
788
+ fragment: {
789
+ module,
790
+ entryPoint: "fs",
791
+ targets: [
792
+ { format: "rgba16float", blend: e.def.render.blend === "additive" ? ADDITIVE : OVER }
793
+ ]
794
+ },
795
+ primitive: { topology: "triangle-list" },
796
+ depthStencil: {
797
+ format: "depth32float",
798
+ depthWriteEnabled: false,
799
+ depthCompare: "greater-equal"
800
+ }
801
+ });
802
+ if (p) c.render.set(key, p);
803
+ return p;
804
+ }
805
+ function drawNode() {
806
+ return {
807
+ kind: "render",
808
+ phase: RenderPhase.Resolve + 20,
809
+ enabled: (view) => cameraOf(view) !== void 0,
810
+ after: ["particles/simulate"],
811
+ reads: ["depth"],
812
+ writes: ["hdr"],
813
+ color: [{ resource: "hdr" }],
814
+ depth: { resource: "depth", readOnly: true },
815
+ run: (ctx) => {
816
+ const store = ctx.world.resource(Particles);
817
+ if (store.systems.size === 0) return;
818
+ const gpu = ctx.gpu;
819
+ const c = caches(ctx.world, gpu);
820
+ const pv = ctx.world.resource(ForwardStateResource).views.get(ctx.view.name);
821
+ if (!pv) return;
822
+ const pass = ctx.renderPass;
823
+ const depth = ctx.texture("depth");
824
+ const assets = ctx.world.resource(GpuAssetsResource);
825
+ const textures = ctx.world.resource(Textures);
826
+ const meshes = ctx.world.resource(Meshes);
827
+ const cam = cameraOf(ctx.view);
828
+ pass.setBindGroup(
829
+ 0,
830
+ group(
831
+ gpu,
832
+ c,
833
+ `view/${ctx.view.name}`,
834
+ `${idOf(pv.uniform.buffer)}`,
835
+ c.layouts.view,
836
+ () => [{ binding: 0, resource: { buffer: pv.uniform.buffer } }]
837
+ )
838
+ );
839
+ for (const sys of store.systems.values()) {
840
+ if (!sphereVisible(cam.frustum, sys.center, sys.radius)) continue;
841
+ for (const e of sys.emitters) {
842
+ const p = renderPipeline(ctx, c, e);
843
+ if (!p) continue;
844
+ const texture = e.def.render.texture ? textures.get(e.def.render.texture) : void 0;
845
+ const gt = texture ? assets.texture(texture) : void 0;
846
+ const view = gt ? texture.usage === "color" ? gt.srgb : gt.linear : c.white.createView();
847
+ const order = e.sort?.order ?? c.noOrder;
848
+ pass.setPipeline(p);
849
+ pass.setBindGroup(
850
+ 1,
851
+ group(
852
+ gpu,
853
+ c,
854
+ `data/${sys.entity}/${e.def.name}`,
855
+ `${idOf(e.particles.buffer)}/${idOf(e.draw.buffer)}/${idOf(order)}`,
856
+ c.layouts.data,
857
+ () => [
858
+ { binding: 0, resource: { buffer: e.particles.buffer } },
859
+ { binding: 1, resource: { buffer: e.draw.buffer } },
860
+ { binding: 2, resource: { buffer: order } }
861
+ ]
862
+ )
863
+ );
864
+ pass.setBindGroup(
865
+ 2,
866
+ group(
867
+ gpu,
868
+ c,
869
+ `tex/${ctx.view.name}/${sys.entity}/${e.def.name}`,
870
+ `${gt ? idOf(gt.texture) : 0}/${idOf(depth)}`,
871
+ c.layouts.texture,
872
+ () => [
873
+ { binding: 0, resource: view },
874
+ { binding: 1, resource: c.sampler },
875
+ { binding: 2, resource: depth.createView() }
876
+ ]
877
+ )
878
+ );
879
+ if (e.def.render.mode === "mesh") {
880
+ const mesh = e.def.render.mesh ? meshes.get(e.def.render.mesh) : void 0;
881
+ if (!mesh) continue;
882
+ const gm = assets.mesh(mesh);
883
+ pass.setVertexBuffer(0, gm.positions);
884
+ if (gm.indices) {
885
+ pass.setIndexBuffer(gm.indices, gm.indexFormat);
886
+ pass.drawIndexed(gm.count, e.capacity);
887
+ } else pass.draw(gm.count, e.capacity);
888
+ } else {
889
+ pass.draw(6, e.capacity);
890
+ }
891
+ }
892
+ }
893
+ }
894
+ };
895
+ }
896
+ async function readParticles(gpu, e) {
897
+ const size = e.capacity * PARTICLE_BYTES;
898
+ const staging = gpu.device.createBuffer({
899
+ size,
900
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
901
+ });
902
+ const encoder = gpu.device.createCommandEncoder();
903
+ encoder.copyBufferToBuffer(e.particles.buffer, 0, staging, 0, size);
904
+ gpu.device.queue.submit([encoder.finish()]);
905
+ await staging.mapAsync(GPUMapMode.READ);
906
+ const out = new Float32Array(staging.getMappedRange().slice(0));
907
+ staging.unmap();
908
+ staging.destroy();
909
+ return out;
910
+ }
911
+ function shiftParticles(store, x, y, z) {
912
+ for (const sys of store.systems.values()) {
913
+ if (sys.local) continue;
914
+ for (const e of sys.emitters) {
915
+ if (sys.cpu && e.cpu) {
916
+ const d = e.cpu;
917
+ for (let o = 0; o < d.length; o += PARTICLE_FLOATS) {
918
+ d[o] = d[o] + x;
919
+ d[o + 1] = d[o + 1] + y;
920
+ d[o + 2] = d[o + 2] + z;
921
+ }
922
+ } else {
923
+ e.originShift[0] = e.originShift[0] + x;
924
+ e.originShift[1] = e.originShift[1] + y;
925
+ e.originShift[2] = e.originShift[2] + z;
926
+ }
927
+ }
928
+ }
929
+ }
930
+ function describeParticles(world) {
931
+ const store = world.tryResource(Particles);
932
+ if (!store) return void 0;
933
+ const gpuMs = world.tryResource(ProfilerResource)?.timing("gpu:particles/simulate")?.avg;
934
+ return {
935
+ gpuMs,
936
+ systems: [...store.systems.values()].map((sys) => ({
937
+ entity: sys.entity,
938
+ backend: sys.cpu ? "cpu" : "gpu",
939
+ emitters: sys.emitters.map((e) => ({
940
+ name: e.def.name,
941
+ alive: e.alive,
942
+ capacity: e.capacity,
943
+ sorted: e.sort !== void 0,
944
+ visible: e.visible,
945
+ spawned: e.spawned,
946
+ unsortedAlpha: e.def.render.blend !== "additive" && !e.sort
947
+ }))
948
+ }))
949
+ };
950
+ }
951
+ export {
952
+ MAX_SORTED,
953
+ ParticleCaches,
954
+ ParticleStore,
955
+ Particles,
956
+ describeParticles,
957
+ drawNode,
958
+ prepareParticles,
959
+ readParticles,
960
+ shiftParticles,
961
+ simulateNode
962
+ };