@cazala/party 0.1.0-next.45.433a20a → 0.1.0-next.46.59ce407
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/README.md +24 -13
- package/dist/engine.d.ts +8 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/index.js +382 -7
- package/dist/index.js.map +1 -1
- package/dist/interfaces.d.ts +45 -2
- package/dist/interfaces.d.ts.map +1 -1
- package/dist/runtimes/cpu/engine.d.ts +9 -2
- package/dist/runtimes/cpu/engine.d.ts.map +1 -1
- package/dist/runtimes/webgpu/engine.d.ts +9 -2
- package/dist/runtimes/webgpu/engine.d.ts.map +1 -1
- package/dist/runtimes/webgpu/gpu-resources.d.ts +5 -0
- package/dist/runtimes/webgpu/gpu-resources.d.ts.map +1 -1
- package/dist/runtimes/webgpu/local-query.d.ts +26 -0
- package/dist/runtimes/webgpu/local-query.d.ts.map +1 -0
- package/dist/runtimes/webgpu/particle-store.d.ts +12 -1
- package/dist/runtimes/webgpu/particle-store.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ A high-performance TypeScript particle physics engine with dual runtime support
|
|
|
10
10
|
- **Spatial Grid Optimization**: Efficient O(1) neighbor queries for collision detection
|
|
11
11
|
- **Real-time Oscillators**: Animate any module parameter with configurable frequency and bounds
|
|
12
12
|
- **Advanced Rendering**: Trails, particle instancing, line rendering with multiple color modes
|
|
13
|
-
- **
|
|
13
|
+
- **Export/Import Presets**: Export/import module settings (inputs + enabled state)
|
|
14
14
|
- **Cross-platform**: Works in all modern browsers with automatic feature detection
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
@@ -82,12 +82,11 @@ await engine.initialize();
|
|
|
82
82
|
// Add particles
|
|
83
83
|
for (let i = 0; i < 100; i++) {
|
|
84
84
|
engine.addParticle({
|
|
85
|
-
x: Math.random() * canvas.width,
|
|
86
|
-
y: Math.random() *
|
|
87
|
-
vx: (Math.random() - 0.5) * 4,
|
|
88
|
-
vy: (Math.random() - 0.5) * 4,
|
|
85
|
+
position: { x: Math.random() * canvas.width, y: Math.random() * canvas.height },
|
|
86
|
+
velocity: { x: (Math.random() - 0.5) * 4, y: (Math.random() - 0.5) * 4 },
|
|
89
87
|
mass: 1 + Math.random() * 2,
|
|
90
88
|
size: 3 + Math.random() * 7,
|
|
89
|
+
color: { r: 1, g: 1, b: 1, a: 1 },
|
|
91
90
|
});
|
|
92
91
|
}
|
|
93
92
|
|
|
@@ -111,8 +110,8 @@ const engine = new Engine({
|
|
|
111
110
|
constrainIterations: 50, // Constraint solver iterations
|
|
112
111
|
cellSize: 32, // Spatial grid cell size
|
|
113
112
|
maxNeighbors: 128, // Max neighbors per particle
|
|
114
|
-
maxParticles: 10000, //
|
|
115
|
-
clearColor:
|
|
113
|
+
maxParticles: 10000, // WebGPU buffer allocation + effective sim/render cap
|
|
114
|
+
clearColor: { r: 0, g: 0, b: 0, a: 1 }, // Background color
|
|
116
115
|
});
|
|
117
116
|
|
|
118
117
|
// Lifecycle
|
|
@@ -120,7 +119,7 @@ await engine.initialize();
|
|
|
120
119
|
engine.play();
|
|
121
120
|
engine.pause();
|
|
122
121
|
engine.stop();
|
|
123
|
-
engine.destroy();
|
|
122
|
+
await engine.destroy();
|
|
124
123
|
|
|
125
124
|
// State
|
|
126
125
|
const isPlaying = engine.isPlaying();
|
|
@@ -128,7 +127,13 @@ const fps = engine.getFPS();
|
|
|
128
127
|
const count = engine.getCount();
|
|
129
128
|
|
|
130
129
|
// Particles
|
|
131
|
-
engine.addParticle({
|
|
130
|
+
engine.addParticle({
|
|
131
|
+
position: { x, y },
|
|
132
|
+
velocity: { x: vx, y: vy },
|
|
133
|
+
mass,
|
|
134
|
+
size,
|
|
135
|
+
color: { r: 1, g: 1, b: 1, a: 1 },
|
|
136
|
+
});
|
|
132
137
|
engine.setParticles([...particles]);
|
|
133
138
|
const particles = await engine.getParticles();
|
|
134
139
|
engine.clear();
|
|
@@ -163,11 +168,11 @@ Particles are simple data structures with physics properties:
|
|
|
163
168
|
|
|
164
169
|
```typescript
|
|
165
170
|
const particle = {
|
|
166
|
-
x: 100, y: 100,
|
|
167
|
-
|
|
171
|
+
position: { x: 100, y: 100 }, // Position
|
|
172
|
+
velocity: { x: 1, y: -2 }, // Velocity
|
|
168
173
|
mass: 2.5, // Mass (negative = pinned)
|
|
169
174
|
size: 8, // Visual size
|
|
170
|
-
color:
|
|
175
|
+
color: { r: 1, g: 0.42, b: 0.21, a: 1 }, // Color (0..1 floats)
|
|
171
176
|
};
|
|
172
177
|
|
|
173
178
|
// Bulk operations (preferred for performance)
|
|
@@ -590,7 +595,13 @@ Full TypeScript support with comprehensive type definitions:
|
|
|
590
595
|
import type { IEngine, IParticle, Module } from "@cazala/party";
|
|
591
596
|
|
|
592
597
|
const engine: IEngine = new Engine({ /* ... */ });
|
|
593
|
-
const particle: IParticle = {
|
|
598
|
+
const particle: IParticle = {
|
|
599
|
+
position: { x: 0, y: 0 },
|
|
600
|
+
velocity: { x: 1, y: 1 },
|
|
601
|
+
mass: 1,
|
|
602
|
+
size: 5,
|
|
603
|
+
color: { r: 1, g: 1, b: 1, a: 1 },
|
|
604
|
+
};
|
|
594
605
|
```
|
|
595
606
|
|
|
596
607
|
## License
|
package/dist/engine.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IEngine, IParticle } from "./interfaces";
|
|
1
|
+
import { IEngine, IParticle, GetParticlesInRadiusOptions, GetParticlesInRadiusResult } from "./interfaces";
|
|
2
2
|
import { Module } from "./module";
|
|
3
3
|
export type EngineOptions = {
|
|
4
4
|
canvas: HTMLCanvasElement;
|
|
@@ -76,9 +76,15 @@ export declare class Engine implements IEngine {
|
|
|
76
76
|
getOscillatorsElapsedSeconds(): number;
|
|
77
77
|
setOscillatorsElapsedSeconds(seconds: number): void;
|
|
78
78
|
setParticles(p: IParticle[]): void;
|
|
79
|
-
addParticle(p: IParticle):
|
|
79
|
+
addParticle(p: IParticle): number;
|
|
80
|
+
setParticle(index: number, p: IParticle): void;
|
|
81
|
+
setParticleMass(index: number, mass: number): void;
|
|
80
82
|
getParticles(): Promise<IParticle[]>;
|
|
81
83
|
getParticle(index: number): Promise<IParticle>;
|
|
84
|
+
getParticlesInRadius(center: {
|
|
85
|
+
x: number;
|
|
86
|
+
y: number;
|
|
87
|
+
}, radius: number, opts?: GetParticlesInRadiusOptions): Promise<GetParticlesInRadiusResult>;
|
|
82
88
|
pinParticles(indexes: number[]): Promise<void>;
|
|
83
89
|
unpinParticles(indexes: number[]): Promise<void>;
|
|
84
90
|
unpinAll(): Promise<void>;
|
package/dist/engine.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -399,6 +399,33 @@ class GPUResources {
|
|
|
399
399
|
stagingBuffer.destroy();
|
|
400
400
|
}
|
|
401
401
|
}
|
|
402
|
+
/**
|
|
403
|
+
* Read an arbitrary GPUBuffer (storage/uniform) into an ArrayBuffer.
|
|
404
|
+
* Caller is responsible for interpreting the bytes.
|
|
405
|
+
*/
|
|
406
|
+
async readBuffer(buffer, sizeBytes) {
|
|
407
|
+
const bytes = Math.max(0, Math.floor(sizeBytes));
|
|
408
|
+
if (bytes === 0)
|
|
409
|
+
return new ArrayBuffer(0);
|
|
410
|
+
const stagingBuffer = this.getDevice().createBuffer({
|
|
411
|
+
size: bytes,
|
|
412
|
+
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
413
|
+
});
|
|
414
|
+
try {
|
|
415
|
+
const encoder = this.getDevice().createCommandEncoder();
|
|
416
|
+
encoder.copyBufferToBuffer(buffer, 0, stagingBuffer, 0, bytes);
|
|
417
|
+
this.getDevice().queue.submit([encoder.finish()]);
|
|
418
|
+
await this.getDevice().queue.onSubmittedWorkDone();
|
|
419
|
+
await stagingBuffer.mapAsync(GPUMapMode.READ);
|
|
420
|
+
const mapped = stagingBuffer.getMappedRange();
|
|
421
|
+
const out = mapped.slice(0);
|
|
422
|
+
stagingBuffer.unmap();
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
stagingBuffer.destroy();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
402
429
|
createModuleUniformBuffers(layouts) {
|
|
403
430
|
// Destroy old
|
|
404
431
|
this.moduleUniformBuffers.forEach(({ buffer }) => buffer.destroy());
|
|
@@ -1160,9 +1187,22 @@ class ParticleStore {
|
|
|
1160
1187
|
}
|
|
1161
1188
|
addParticle(p) {
|
|
1162
1189
|
if (this.count >= this.maxParticles)
|
|
1163
|
-
return;
|
|
1164
|
-
this.
|
|
1190
|
+
return -1;
|
|
1191
|
+
const index = this.count;
|
|
1192
|
+
this.writeAtIndex(index, p);
|
|
1165
1193
|
this.count++;
|
|
1194
|
+
return index;
|
|
1195
|
+
}
|
|
1196
|
+
setParticle(index, p) {
|
|
1197
|
+
if (index < 0 || index >= this.count)
|
|
1198
|
+
return;
|
|
1199
|
+
this.writeAtIndex(index, p);
|
|
1200
|
+
}
|
|
1201
|
+
setParticleMass(index, mass) {
|
|
1202
|
+
if (index < 0 || index >= this.count)
|
|
1203
|
+
return;
|
|
1204
|
+
const base = index * this.floatsPerParticle;
|
|
1205
|
+
this.data[base + 7] = mass;
|
|
1166
1206
|
}
|
|
1167
1207
|
clear() {
|
|
1168
1208
|
this.count = 0;
|
|
@@ -1192,6 +1232,28 @@ class ParticleStore {
|
|
|
1192
1232
|
},
|
|
1193
1233
|
};
|
|
1194
1234
|
}
|
|
1235
|
+
getFloatsPerParticle() {
|
|
1236
|
+
return this.floatsPerParticle;
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Write a full particle record at index into GPU storage.
|
|
1240
|
+
*/
|
|
1241
|
+
syncParticleToGPU(resources, index) {
|
|
1242
|
+
if (index < 0 || index >= this.count)
|
|
1243
|
+
return;
|
|
1244
|
+
const base = index * this.floatsPerParticle;
|
|
1245
|
+
const slice = this.data.subarray(base, base + this.floatsPerParticle);
|
|
1246
|
+
resources.writeParticleSlice(base, slice);
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Write a single mass value at index into GPU storage.
|
|
1250
|
+
*/
|
|
1251
|
+
syncParticleMassToGPU(resources, index) {
|
|
1252
|
+
if (index < 0 || index >= this.count)
|
|
1253
|
+
return;
|
|
1254
|
+
const offset = index * this.floatsPerParticle + 7;
|
|
1255
|
+
resources.writeParticleSlice(offset, new Float32Array([this.data[offset]]));
|
|
1256
|
+
}
|
|
1195
1257
|
/**
|
|
1196
1258
|
* Writes the currently active particle slice to the GPU particle buffer.
|
|
1197
1259
|
* Assumes the GPU storage buffer has already been created with matching capacity.
|
|
@@ -3437,6 +3499,219 @@ class RenderPipeline {
|
|
|
3437
3499
|
}
|
|
3438
3500
|
}
|
|
3439
3501
|
|
|
3502
|
+
/**
|
|
3503
|
+
* LocalQuery
|
|
3504
|
+
*
|
|
3505
|
+
* A small WebGPU compute pipeline used to query a bounded set of particles
|
|
3506
|
+
* in a region without doing a full GPU→CPU readback of the entire particle buffer.
|
|
3507
|
+
*
|
|
3508
|
+
* This is used by tool-like features (brush/pin/remove) that need local occupancy.
|
|
3509
|
+
*/
|
|
3510
|
+
class LocalQuery {
|
|
3511
|
+
constructor() {
|
|
3512
|
+
Object.defineProperty(this, "pipeline", {
|
|
3513
|
+
enumerable: true,
|
|
3514
|
+
configurable: true,
|
|
3515
|
+
writable: true,
|
|
3516
|
+
value: null
|
|
3517
|
+
});
|
|
3518
|
+
Object.defineProperty(this, "uniform", {
|
|
3519
|
+
enumerable: true,
|
|
3520
|
+
configurable: true,
|
|
3521
|
+
writable: true,
|
|
3522
|
+
value: null
|
|
3523
|
+
});
|
|
3524
|
+
Object.defineProperty(this, "count", {
|
|
3525
|
+
enumerable: true,
|
|
3526
|
+
configurable: true,
|
|
3527
|
+
writable: true,
|
|
3528
|
+
value: null
|
|
3529
|
+
});
|
|
3530
|
+
Object.defineProperty(this, "out", {
|
|
3531
|
+
enumerable: true,
|
|
3532
|
+
configurable: true,
|
|
3533
|
+
writable: true,
|
|
3534
|
+
value: null
|
|
3535
|
+
});
|
|
3536
|
+
Object.defineProperty(this, "outIdx", {
|
|
3537
|
+
enumerable: true,
|
|
3538
|
+
configurable: true,
|
|
3539
|
+
writable: true,
|
|
3540
|
+
value: null
|
|
3541
|
+
});
|
|
3542
|
+
Object.defineProperty(this, "capacity", {
|
|
3543
|
+
enumerable: true,
|
|
3544
|
+
configurable: true,
|
|
3545
|
+
writable: true,
|
|
3546
|
+
value: 0
|
|
3547
|
+
});
|
|
3548
|
+
Object.defineProperty(this, "device", {
|
|
3549
|
+
enumerable: true,
|
|
3550
|
+
configurable: true,
|
|
3551
|
+
writable: true,
|
|
3552
|
+
value: null
|
|
3553
|
+
});
|
|
3554
|
+
}
|
|
3555
|
+
dispose() {
|
|
3556
|
+
this.pipeline = null;
|
|
3557
|
+
this.uniform?.destroy();
|
|
3558
|
+
this.count?.destroy();
|
|
3559
|
+
this.out?.destroy();
|
|
3560
|
+
this.outIdx?.destroy();
|
|
3561
|
+
this.uniform = null;
|
|
3562
|
+
this.count = null;
|
|
3563
|
+
this.out = null;
|
|
3564
|
+
this.outIdx = null;
|
|
3565
|
+
this.capacity = 0;
|
|
3566
|
+
this.device = null;
|
|
3567
|
+
}
|
|
3568
|
+
ensure(resources, maxResults) {
|
|
3569
|
+
const device = resources.getDevice();
|
|
3570
|
+
// If device changed (runtime toggle / recreate), rebuild everything.
|
|
3571
|
+
if (this.device && this.device !== device) {
|
|
3572
|
+
this.dispose();
|
|
3573
|
+
}
|
|
3574
|
+
this.device = device;
|
|
3575
|
+
// (Re)allocate buffers if capacity changed
|
|
3576
|
+
if (this.capacity !== maxResults) {
|
|
3577
|
+
this.capacity = maxResults;
|
|
3578
|
+
this.uniform?.destroy();
|
|
3579
|
+
this.count?.destroy();
|
|
3580
|
+
this.out?.destroy();
|
|
3581
|
+
this.outIdx?.destroy();
|
|
3582
|
+
this.uniform = device.createBuffer({
|
|
3583
|
+
size: 8 * 4,
|
|
3584
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
3585
|
+
});
|
|
3586
|
+
this.count = device.createBuffer({
|
|
3587
|
+
size: 4,
|
|
3588
|
+
usage: GPUBufferUsage.STORAGE |
|
|
3589
|
+
GPUBufferUsage.COPY_DST |
|
|
3590
|
+
GPUBufferUsage.COPY_SRC,
|
|
3591
|
+
});
|
|
3592
|
+
this.out = device.createBuffer({
|
|
3593
|
+
size: maxResults * 16,
|
|
3594
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
|
|
3595
|
+
});
|
|
3596
|
+
this.outIdx = device.createBuffer({
|
|
3597
|
+
size: maxResults * 4,
|
|
3598
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
|
|
3599
|
+
});
|
|
3600
|
+
}
|
|
3601
|
+
if (this.pipeline)
|
|
3602
|
+
return;
|
|
3603
|
+
const code = `
|
|
3604
|
+
struct Particle {
|
|
3605
|
+
position: vec2<f32>,
|
|
3606
|
+
velocity: vec2<f32>,
|
|
3607
|
+
acceleration: vec2<f32>,
|
|
3608
|
+
size: f32,
|
|
3609
|
+
mass: f32,
|
|
3610
|
+
color: vec4<f32>,
|
|
3611
|
+
};
|
|
3612
|
+
|
|
3613
|
+
struct QueryUniforms {
|
|
3614
|
+
v0: vec4<f32>, // center.x, center.y, radius, maxResults (as f32)
|
|
3615
|
+
v1: vec4<f32>, // particleCount, 0,0,0
|
|
3616
|
+
};
|
|
3617
|
+
|
|
3618
|
+
@group(0) @binding(0) var<storage, read> particles: array<Particle>;
|
|
3619
|
+
@group(0) @binding(1) var<uniform> query: QueryUniforms;
|
|
3620
|
+
@group(0) @binding(2) var<storage, read_write> outCount: atomic<u32>;
|
|
3621
|
+
@group(0) @binding(3) var<storage, read_write> outData: array<vec4<f32>>;
|
|
3622
|
+
@group(0) @binding(4) var<storage, read_write> outIndex: array<u32>;
|
|
3623
|
+
|
|
3624
|
+
@compute @workgroup_size(256)
|
|
3625
|
+
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
3626
|
+
let i = gid.x;
|
|
3627
|
+
let count = u32(query.v1.x);
|
|
3628
|
+
if (i >= count) { return; }
|
|
3629
|
+
|
|
3630
|
+
let p = particles[i];
|
|
3631
|
+
if (p.mass == 0.0) { return; }
|
|
3632
|
+
|
|
3633
|
+
let cx = query.v0.x;
|
|
3634
|
+
let cy = query.v0.y;
|
|
3635
|
+
let radius = query.v0.z;
|
|
3636
|
+
let maxResults = u32(query.v0.w);
|
|
3637
|
+
|
|
3638
|
+
// Disc-intersection semantics: dist <= radius + p.size
|
|
3639
|
+
let dx = p.position.x - cx;
|
|
3640
|
+
let dy = p.position.y - cy;
|
|
3641
|
+
let rr = radius + p.size;
|
|
3642
|
+
if (dx * dx + dy * dy > rr * rr) { return; }
|
|
3643
|
+
|
|
3644
|
+
let outIdx = atomicAdd(&outCount, 1u);
|
|
3645
|
+
if (outIdx >= maxResults) { return; }
|
|
3646
|
+
outData[outIdx] = vec4<f32>(p.position.x, p.position.y, p.size, p.mass);
|
|
3647
|
+
outIndex[outIdx] = i;
|
|
3648
|
+
}
|
|
3649
|
+
`;
|
|
3650
|
+
this.pipeline = device.createComputePipeline({
|
|
3651
|
+
layout: "auto",
|
|
3652
|
+
compute: { module: device.createShaderModule({ code }), entryPoint: "main" },
|
|
3653
|
+
});
|
|
3654
|
+
}
|
|
3655
|
+
async getParticlesInRadius(resources, center, radius, particleCount, opts) {
|
|
3656
|
+
const maxResults = Math.max(1, Math.floor(opts?.maxResults ?? 20000));
|
|
3657
|
+
this.ensure(resources, maxResults);
|
|
3658
|
+
const device = resources.getDevice();
|
|
3659
|
+
const particleBuffer = resources.getParticleBuffer();
|
|
3660
|
+
if (!particleBuffer)
|
|
3661
|
+
return { particles: [], truncated: false };
|
|
3662
|
+
// Reset atomic counter
|
|
3663
|
+
device.queue.writeBuffer(this.count, 0, new Uint32Array([0]));
|
|
3664
|
+
// Uniform: v0 = [cx, cy, radius, maxResults], v1 = [particleCount, 0, 0, 0]
|
|
3665
|
+
const u = new Float32Array(8);
|
|
3666
|
+
u[0] = center.x;
|
|
3667
|
+
u[1] = center.y;
|
|
3668
|
+
u[2] = radius;
|
|
3669
|
+
u[3] = maxResults;
|
|
3670
|
+
u[4] = particleCount;
|
|
3671
|
+
device.queue.writeBuffer(this.uniform, 0, u);
|
|
3672
|
+
const bindGroup = device.createBindGroup({
|
|
3673
|
+
layout: this.pipeline.getBindGroupLayout(0),
|
|
3674
|
+
entries: [
|
|
3675
|
+
{ binding: 0, resource: { buffer: particleBuffer } },
|
|
3676
|
+
{ binding: 1, resource: { buffer: this.uniform } },
|
|
3677
|
+
{ binding: 2, resource: { buffer: this.count } },
|
|
3678
|
+
{ binding: 3, resource: { buffer: this.out } },
|
|
3679
|
+
{ binding: 4, resource: { buffer: this.outIdx } },
|
|
3680
|
+
],
|
|
3681
|
+
});
|
|
3682
|
+
const encoder = device.createCommandEncoder();
|
|
3683
|
+
const pass = encoder.beginComputePass();
|
|
3684
|
+
pass.setPipeline(this.pipeline);
|
|
3685
|
+
pass.setBindGroup(0, bindGroup);
|
|
3686
|
+
const wg = 256;
|
|
3687
|
+
const n = Math.max(0, Math.floor(particleCount));
|
|
3688
|
+
pass.dispatchWorkgroups(Math.ceil(n / wg));
|
|
3689
|
+
pass.end();
|
|
3690
|
+
device.queue.submit([encoder.finish()]);
|
|
3691
|
+
const countBuf = await resources.readBuffer(this.count, 4);
|
|
3692
|
+
const found = new Uint32Array(countBuf)[0] ?? 0;
|
|
3693
|
+
const truncated = found > maxResults;
|
|
3694
|
+
const readCount = Math.min(found, maxResults);
|
|
3695
|
+
if (readCount === 0)
|
|
3696
|
+
return { particles: [], truncated };
|
|
3697
|
+
const idxBuf = await resources.readBuffer(this.outIdx, readCount * 4);
|
|
3698
|
+
const indices = new Uint32Array(idxBuf);
|
|
3699
|
+
const outBuf = await resources.readBuffer(this.out, readCount * 16);
|
|
3700
|
+
const outFloats = new Float32Array(outBuf);
|
|
3701
|
+
const particles = [];
|
|
3702
|
+
for (let i = 0; i < readCount; i++) {
|
|
3703
|
+
const base = i * 4;
|
|
3704
|
+
particles.push({
|
|
3705
|
+
index: indices[i] ?? 0,
|
|
3706
|
+
position: { x: outFloats[base + 0], y: outFloats[base + 1] },
|
|
3707
|
+
size: outFloats[base + 2],
|
|
3708
|
+
mass: outFloats[base + 3],
|
|
3709
|
+
});
|
|
3710
|
+
}
|
|
3711
|
+
return { particles, truncated };
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3440
3715
|
class WebGPUEngine extends AbstractEngine {
|
|
3441
3716
|
constructor(options) {
|
|
3442
3717
|
super({
|
|
@@ -3515,6 +3790,12 @@ class WebGPUEngine extends AbstractEngine {
|
|
|
3515
3790
|
writable: true,
|
|
3516
3791
|
value: false
|
|
3517
3792
|
});
|
|
3793
|
+
Object.defineProperty(this, "localQuery", {
|
|
3794
|
+
enumerable: true,
|
|
3795
|
+
configurable: true,
|
|
3796
|
+
writable: true,
|
|
3797
|
+
value: void 0
|
|
3798
|
+
});
|
|
3518
3799
|
Object.defineProperty(this, "animate", {
|
|
3519
3800
|
enumerable: true,
|
|
3520
3801
|
configurable: true,
|
|
@@ -3584,6 +3865,7 @@ class WebGPUEngine extends AbstractEngine {
|
|
|
3584
3865
|
this.sim = new SimulationPipeline();
|
|
3585
3866
|
this.render = new RenderPipeline();
|
|
3586
3867
|
this.grid = new SpacialGrid(this.cellSize);
|
|
3868
|
+
this.localQuery = new LocalQuery();
|
|
3587
3869
|
}
|
|
3588
3870
|
async initialize() {
|
|
3589
3871
|
await this.resources.initialize();
|
|
@@ -3640,6 +3922,7 @@ class WebGPUEngine extends AbstractEngine {
|
|
|
3640
3922
|
cancelAnimationFrame(this.animationId);
|
|
3641
3923
|
this.animationId = null;
|
|
3642
3924
|
}
|
|
3925
|
+
this.localQuery.dispose();
|
|
3643
3926
|
await this.resources.dispose();
|
|
3644
3927
|
}
|
|
3645
3928
|
// Override setSize to also update WebGPU-specific resources
|
|
@@ -3663,12 +3946,24 @@ class WebGPUEngine extends AbstractEngine {
|
|
|
3663
3946
|
this.updateMaxSize(particle.size);
|
|
3664
3947
|
}
|
|
3665
3948
|
}
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3949
|
+
addParticle(p) {
|
|
3950
|
+
const index = this.particles.addParticle(p);
|
|
3951
|
+
if (index < 0)
|
|
3952
|
+
return -1;
|
|
3953
|
+
// Push only the new particle record to GPU (no full-scene readback).
|
|
3954
|
+
this.particles.syncParticleToGPU(this.resources, index);
|
|
3670
3955
|
// Update maxSize tracking
|
|
3671
3956
|
this.updateMaxSize(p.size);
|
|
3957
|
+
return index;
|
|
3958
|
+
}
|
|
3959
|
+
setParticle(index, p) {
|
|
3960
|
+
this.particles.setParticle(index, p);
|
|
3961
|
+
this.particles.syncParticleToGPU(this.resources, index);
|
|
3962
|
+
this.updateMaxSize(p.size);
|
|
3963
|
+
}
|
|
3964
|
+
setParticleMass(index, mass) {
|
|
3965
|
+
this.particles.setParticleMass(index, mass);
|
|
3966
|
+
this.particles.syncParticleMassToGPU(this.resources, index);
|
|
3672
3967
|
}
|
|
3673
3968
|
/**
|
|
3674
3969
|
* Forces GPU-to-CPU synchronization and returns current particle data.
|
|
@@ -3682,6 +3977,9 @@ class WebGPUEngine extends AbstractEngine {
|
|
|
3682
3977
|
await this.particles.syncFromGPU(this.resources);
|
|
3683
3978
|
return this.particles.getParticle(index);
|
|
3684
3979
|
}
|
|
3980
|
+
async getParticlesInRadius(center, radius, opts) {
|
|
3981
|
+
return await this.localQuery.getParticlesInRadius(this.resources, center, radius, this.getCount(), opts);
|
|
3982
|
+
}
|
|
3685
3983
|
getCount() {
|
|
3686
3984
|
const actualCount = this.particles.getCount();
|
|
3687
3985
|
if (this.maxParticles === null) {
|
|
@@ -4298,6 +4596,12 @@ class CPUEngine extends AbstractEngine {
|
|
|
4298
4596
|
writable: true,
|
|
4299
4597
|
value: null
|
|
4300
4598
|
});
|
|
4599
|
+
Object.defineProperty(this, "particleIdToIndex", {
|
|
4600
|
+
enumerable: true,
|
|
4601
|
+
configurable: true,
|
|
4602
|
+
writable: true,
|
|
4603
|
+
value: new Map()
|
|
4604
|
+
});
|
|
4301
4605
|
Object.defineProperty(this, "animate", {
|
|
4302
4606
|
enumerable: true,
|
|
4303
4607
|
configurable: true,
|
|
@@ -4372,6 +4676,7 @@ class CPUEngine extends AbstractEngine {
|
|
|
4372
4676
|
this.fpsEstimate = 60;
|
|
4373
4677
|
// Reset maxSize tracking
|
|
4374
4678
|
this.resetMaxSize();
|
|
4679
|
+
this.particleIdToIndex.clear();
|
|
4375
4680
|
}
|
|
4376
4681
|
getCount() {
|
|
4377
4682
|
const actualCount = this.particles.length;
|
|
@@ -4395,11 +4700,33 @@ class CPUEngine extends AbstractEngine {
|
|
|
4395
4700
|
for (const p of particle) {
|
|
4396
4701
|
this.updateMaxSize(p.size);
|
|
4397
4702
|
}
|
|
4703
|
+
this.particleIdToIndex.clear();
|
|
4398
4704
|
}
|
|
4399
4705
|
addParticle(particle) {
|
|
4706
|
+
const index = this.particles.length;
|
|
4400
4707
|
this.particles.push(new Particle(particle));
|
|
4401
4708
|
// Update maxSize tracking
|
|
4402
4709
|
this.updateMaxSize(particle.size);
|
|
4710
|
+
const created = this.particles[index];
|
|
4711
|
+
if (created)
|
|
4712
|
+
this.particleIdToIndex.set(created.id, index);
|
|
4713
|
+
return index;
|
|
4714
|
+
}
|
|
4715
|
+
setParticle(index, p) {
|
|
4716
|
+
if (index < 0)
|
|
4717
|
+
return;
|
|
4718
|
+
if (index >= this.particles.length)
|
|
4719
|
+
return;
|
|
4720
|
+
this.particles[index] = new Particle(p);
|
|
4721
|
+
// Best-effort maxSize tracking (monotonic)
|
|
4722
|
+
this.updateMaxSize(p.size);
|
|
4723
|
+
}
|
|
4724
|
+
setParticleMass(index, mass) {
|
|
4725
|
+
if (index < 0)
|
|
4726
|
+
return;
|
|
4727
|
+
if (index >= this.particles.length)
|
|
4728
|
+
return;
|
|
4729
|
+
this.particles[index].mass = mass;
|
|
4403
4730
|
}
|
|
4404
4731
|
getParticles() {
|
|
4405
4732
|
return Promise.resolve(this.particles.map((p) => p.toJSON()));
|
|
@@ -4407,10 +4734,47 @@ class CPUEngine extends AbstractEngine {
|
|
|
4407
4734
|
getParticle(index) {
|
|
4408
4735
|
return Promise.resolve(this.particles[index]);
|
|
4409
4736
|
}
|
|
4737
|
+
async getParticlesInRadius(center, radius, opts) {
|
|
4738
|
+
const maxResults = Math.max(1, Math.floor(opts?.maxResults ?? 20000));
|
|
4739
|
+
// Expand search radius to ensure we can find large particles whose discs
|
|
4740
|
+
// intersect the query circle: dist <= radius + p.size.
|
|
4741
|
+
const searchRadius = Math.max(0, radius) + this.getMaxSize();
|
|
4742
|
+
// Use the existing spatial grid (built during the last simulation tick).
|
|
4743
|
+
// Snapshot semantics: this is "as of last grid build" which is good enough
|
|
4744
|
+
// for tool usage and avoids global scans.
|
|
4745
|
+
const neighbors = this.grid.getParticles(new Vector(center.x, center.y), searchRadius,
|
|
4746
|
+
// Ask for up to maxResults+1 so we can mark truncated more reliably.
|
|
4747
|
+
maxResults + 1);
|
|
4748
|
+
const out = [];
|
|
4749
|
+
const r = Math.max(0, radius);
|
|
4750
|
+
for (const p of neighbors) {
|
|
4751
|
+
if (p.mass === 0)
|
|
4752
|
+
continue;
|
|
4753
|
+
const index = this.particleIdToIndex.get(p.id);
|
|
4754
|
+
if (index === undefined)
|
|
4755
|
+
continue;
|
|
4756
|
+
const dx = p.position.x - center.x;
|
|
4757
|
+
const dy = p.position.y - center.y;
|
|
4758
|
+
const rr = r + p.size;
|
|
4759
|
+
if (dx * dx + dy * dy <= rr * rr) {
|
|
4760
|
+
out.push({
|
|
4761
|
+
index,
|
|
4762
|
+
position: { x: p.position.x, y: p.position.y },
|
|
4763
|
+
size: p.size,
|
|
4764
|
+
mass: p.mass,
|
|
4765
|
+
});
|
|
4766
|
+
if (out.length >= maxResults + 1)
|
|
4767
|
+
break;
|
|
4768
|
+
}
|
|
4769
|
+
}
|
|
4770
|
+
const truncated = out.length > maxResults;
|
|
4771
|
+
return { particles: truncated ? out.slice(0, maxResults) : out, truncated };
|
|
4772
|
+
}
|
|
4410
4773
|
destroy() {
|
|
4411
4774
|
this.pause();
|
|
4412
4775
|
this.particles = [];
|
|
4413
4776
|
this.grid.clear();
|
|
4777
|
+
this.particleIdToIndex.clear();
|
|
4414
4778
|
return Promise.resolve();
|
|
4415
4779
|
}
|
|
4416
4780
|
// Handle configuration changes
|
|
@@ -4457,8 +4821,10 @@ class CPUEngine extends AbstractEngine {
|
|
|
4457
4821
|
// Update spatial grid with current particle positions and camera
|
|
4458
4822
|
this.grid.setCamera(this.view.getCamera().x, this.view.getCamera().y, this.view.getZoom());
|
|
4459
4823
|
this.grid.clear();
|
|
4824
|
+
this.particleIdToIndex.clear();
|
|
4460
4825
|
for (let i = 0; i < effectiveCount; i++) {
|
|
4461
4826
|
this.grid.insert(this.particles[i]);
|
|
4827
|
+
this.particleIdToIndex.set(this.particles[i].id, i);
|
|
4462
4828
|
}
|
|
4463
4829
|
// Global state for modules that need it
|
|
4464
4830
|
const globalState = {};
|
|
@@ -4948,7 +5314,13 @@ class Engine {
|
|
|
4948
5314
|
this.engine.setParticles(p);
|
|
4949
5315
|
}
|
|
4950
5316
|
addParticle(p) {
|
|
4951
|
-
this.engine.addParticle(p);
|
|
5317
|
+
return this.engine.addParticle(p);
|
|
5318
|
+
}
|
|
5319
|
+
setParticle(index, p) {
|
|
5320
|
+
this.engine.setParticle(index, p);
|
|
5321
|
+
}
|
|
5322
|
+
setParticleMass(index, mass) {
|
|
5323
|
+
this.engine.setParticleMass(index, mass);
|
|
4952
5324
|
}
|
|
4953
5325
|
getParticles() {
|
|
4954
5326
|
return this.engine.getParticles();
|
|
@@ -4956,6 +5328,9 @@ class Engine {
|
|
|
4956
5328
|
getParticle(index) {
|
|
4957
5329
|
return this.engine.getParticle(index);
|
|
4958
5330
|
}
|
|
5331
|
+
getParticlesInRadius(center, radius, opts) {
|
|
5332
|
+
return this.engine.getParticlesInRadius(center, radius, opts);
|
|
5333
|
+
}
|
|
4959
5334
|
// Helpers for pinning/unpinning
|
|
4960
5335
|
async pinParticles(indexes) {
|
|
4961
5336
|
const particles = await this.getParticles();
|