@codexo/exojs 0.7.13 → 0.8.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/CHANGELOG.md +403 -0
- package/dist/esm/index.js +28 -7
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/particles/ParticleSystem.d.ts +180 -83
- package/dist/esm/particles/ParticleSystem.js +446 -133
- package/dist/esm/particles/ParticleSystem.js.map +1 -1
- package/dist/esm/particles/distributions/BoxArea.d.ts +17 -0
- package/dist/esm/particles/distributions/BoxArea.js +48 -0
- package/dist/esm/particles/distributions/BoxArea.js.map +1 -0
- package/dist/esm/particles/distributions/CircleArea.d.ts +19 -0
- package/dist/esm/particles/distributions/CircleArea.js +33 -0
- package/dist/esm/particles/distributions/CircleArea.js.map +1 -0
- package/dist/esm/particles/distributions/ConeDirection.d.ts +28 -0
- package/dist/esm/particles/distributions/ConeDirection.js +44 -0
- package/dist/esm/particles/distributions/ConeDirection.js.map +1 -0
- package/dist/esm/particles/distributions/Constant.d.ts +17 -0
- package/dist/esm/particles/distributions/Constant.js +35 -0
- package/dist/esm/particles/distributions/Constant.js.map +1 -0
- package/dist/esm/particles/distributions/Curve.d.ts +30 -0
- package/dist/esm/particles/distributions/Curve.js +53 -0
- package/dist/esm/particles/distributions/Curve.js.map +1 -0
- package/dist/esm/particles/distributions/Distribution.d.ts +45 -0
- package/dist/esm/particles/distributions/Gradient.d.ts +40 -0
- package/dist/esm/particles/distributions/Gradient.js +72 -0
- package/dist/esm/particles/distributions/Gradient.js.map +1 -0
- package/dist/esm/particles/distributions/LineSegment.d.ts +15 -0
- package/dist/esm/particles/distributions/LineSegment.js +27 -0
- package/dist/esm/particles/distributions/LineSegment.js.map +1 -0
- package/dist/esm/particles/distributions/Range.d.ts +12 -0
- package/dist/esm/particles/distributions/Range.js +19 -0
- package/dist/esm/particles/distributions/Range.js.map +1 -0
- package/dist/esm/particles/distributions/VectorRange.d.ts +20 -0
- package/dist/esm/particles/distributions/VectorRange.js +31 -0
- package/dist/esm/particles/distributions/VectorRange.js.map +1 -0
- package/dist/esm/particles/distributions/index.d.ts +12 -0
- package/dist/esm/particles/gpu/ParticleGpuState.d.ts +57 -0
- package/dist/esm/particles/gpu/ParticleGpuState.js +508 -0
- package/dist/esm/particles/gpu/ParticleGpuState.js.map +1 -0
- package/dist/esm/particles/index.d.ts +2 -10
- package/dist/esm/particles/modules/AlphaFadeOverLifetime.d.ts +24 -0
- package/dist/esm/particles/modules/AlphaFadeOverLifetime.js +60 -0
- package/dist/esm/particles/modules/AlphaFadeOverLifetime.js.map +1 -0
- package/dist/esm/particles/modules/ApplyForce.d.ts +20 -0
- package/dist/esm/particles/modules/ApplyForce.js +48 -0
- package/dist/esm/particles/modules/ApplyForce.js.map +1 -0
- package/dist/esm/particles/modules/AttractToPoint.d.ts +27 -0
- package/dist/esm/particles/modules/AttractToPoint.js +73 -0
- package/dist/esm/particles/modules/AttractToPoint.js.map +1 -0
- package/dist/esm/particles/modules/BurstSpawn.d.ts +53 -0
- package/dist/esm/particles/modules/BurstSpawn.js +94 -0
- package/dist/esm/particles/modules/BurstSpawn.js.map +1 -0
- package/dist/esm/particles/modules/ColorOverLifetime.d.ts +22 -0
- package/dist/esm/particles/modules/ColorOverLifetime.js +65 -0
- package/dist/esm/particles/modules/ColorOverLifetime.js.map +1 -0
- package/dist/esm/particles/modules/ColorOverSpeed.d.ts +27 -0
- package/dist/esm/particles/modules/ColorOverSpeed.js +86 -0
- package/dist/esm/particles/modules/ColorOverSpeed.js.map +1 -0
- package/dist/esm/particles/modules/DeathModule.d.ts +24 -0
- package/dist/esm/particles/modules/DeathModule.js +25 -0
- package/dist/esm/particles/modules/DeathModule.js.map +1 -0
- package/dist/esm/particles/modules/Drag.d.ts +20 -0
- package/dist/esm/particles/modules/Drag.js +45 -0
- package/dist/esm/particles/modules/Drag.js.map +1 -0
- package/dist/esm/particles/modules/OrbitalForce.d.ts +28 -0
- package/dist/esm/particles/modules/OrbitalForce.js +65 -0
- package/dist/esm/particles/modules/OrbitalForce.js.map +1 -0
- package/dist/esm/particles/modules/RateSpawn.d.ts +41 -0
- package/dist/esm/particles/modules/RateSpawn.js +76 -0
- package/dist/esm/particles/modules/RateSpawn.js.map +1 -0
- package/dist/esm/particles/modules/RepelFromPoint.d.ts +24 -0
- package/dist/esm/particles/modules/RepelFromPoint.js +76 -0
- package/dist/esm/particles/modules/RepelFromPoint.js.map +1 -0
- package/dist/esm/particles/modules/RotateOverLifetime.d.ts +20 -0
- package/dist/esm/particles/modules/RotateOverLifetime.js +43 -0
- package/dist/esm/particles/modules/RotateOverLifetime.js.map +1 -0
- package/dist/esm/particles/modules/ScaleOverLifetime.d.ts +26 -0
- package/dist/esm/particles/modules/ScaleOverLifetime.js +59 -0
- package/dist/esm/particles/modules/ScaleOverLifetime.js.map +1 -0
- package/dist/esm/particles/modules/SpawnModule.d.ts +30 -0
- package/dist/esm/particles/modules/SpawnModule.js +31 -0
- package/dist/esm/particles/modules/SpawnModule.js.map +1 -0
- package/dist/esm/particles/modules/SpawnOnDeath.d.ts +24 -0
- package/dist/esm/particles/modules/SpawnOnDeath.js +47 -0
- package/dist/esm/particles/modules/SpawnOnDeath.js.map +1 -0
- package/dist/esm/particles/modules/Turbulence.d.ts +30 -0
- package/dist/esm/particles/modules/Turbulence.js +122 -0
- package/dist/esm/particles/modules/Turbulence.js.map +1 -0
- package/dist/esm/particles/modules/UpdateModule.d.ts +95 -0
- package/dist/esm/particles/modules/UpdateModule.js +66 -0
- package/dist/esm/particles/modules/UpdateModule.js.map +1 -0
- package/dist/esm/particles/modules/VelocityOverLifetime.d.ts +30 -0
- package/dist/esm/particles/modules/VelocityOverLifetime.js +84 -0
- package/dist/esm/particles/modules/VelocityOverLifetime.js.map +1 -0
- package/dist/esm/particles/modules/WgslContribution.d.ts +81 -0
- package/dist/esm/particles/modules/WgslContribution.js +34 -0
- package/dist/esm/particles/modules/WgslContribution.js.map +1 -0
- package/dist/esm/particles/modules/index.d.ts +22 -0
- package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.d.ts +9 -14
- package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.js +90 -61
- package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.js.map +1 -1
- package/dist/esm/rendering/webgl2/glsl/particle.vert.js +1 -1
- package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.d.ts +9 -0
- package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.js +107 -23
- package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.js.map +1 -1
- package/dist/esm/rendering/webgpu/compute/WebGpuComputePipeline.d.ts +52 -0
- package/dist/esm/rendering/webgpu/compute/WebGpuStorageBuffer.d.ts +29 -0
- package/dist/esm/rendering/webgpu/compute/index.d.ts +3 -0
- package/dist/exo.esm.js +2657 -742
- package/dist/exo.esm.js.map +1 -1
- package/package.json +1 -1
- package/dist/esm/particles/Particle.d.ts +0 -77
- package/dist/esm/particles/Particle.js +0 -143
- package/dist/esm/particles/Particle.js.map +0 -1
- package/dist/esm/particles/ParticleProperties.d.ts +0 -29
- package/dist/esm/particles/affectors/ColorAffector.d.ts +0 -30
- package/dist/esm/particles/affectors/ColorAffector.js +0 -55
- package/dist/esm/particles/affectors/ColorAffector.js.map +0 -1
- package/dist/esm/particles/affectors/ForceAffector.d.ts +0 -24
- package/dist/esm/particles/affectors/ForceAffector.js +0 -39
- package/dist/esm/particles/affectors/ForceAffector.js.map +0 -1
- package/dist/esm/particles/affectors/ParticleAffector.d.ts +0 -19
- package/dist/esm/particles/affectors/ScaleAffector.d.ts +0 -23
- package/dist/esm/particles/affectors/ScaleAffector.js +0 -38
- package/dist/esm/particles/affectors/ScaleAffector.js.map +0 -1
- package/dist/esm/particles/affectors/TorqueAffector.d.ts +0 -23
- package/dist/esm/particles/affectors/TorqueAffector.js +0 -37
- package/dist/esm/particles/affectors/TorqueAffector.js.map +0 -1
- package/dist/esm/particles/emitters/ParticleEmitter.d.ts +0 -19
- package/dist/esm/particles/emitters/ParticleOptions.d.ts +0 -62
- package/dist/esm/particles/emitters/ParticleOptions.js +0 -120
- package/dist/esm/particles/emitters/ParticleOptions.js.map +0 -1
- package/dist/esm/particles/emitters/UniversalEmitter.d.ts +0 -40
- package/dist/esm/particles/emitters/UniversalEmitter.js +0 -68
- package/dist/esm/particles/emitters/UniversalEmitter.js.map +0 -1
package/dist/exo.esm.js
CHANGED
|
@@ -1386,7 +1386,7 @@ class Signal {
|
|
|
1386
1386
|
}
|
|
1387
1387
|
|
|
1388
1388
|
/** τ = 2π, the full-circle radian constant. */
|
|
1389
|
-
const tau = Math.PI * 2;
|
|
1389
|
+
const tau$2 = Math.PI * 2;
|
|
1390
1390
|
/** Multiply a degree value by this constant to convert to radians. */
|
|
1391
1391
|
const radiansPerDegree = Math.PI / 180;
|
|
1392
1392
|
/** Multiply a radian value by this constant to convert to degrees. */
|
|
@@ -8354,7 +8354,7 @@ class WebGl2MeshRenderer extends AbstractWebGl2Renderer {
|
|
|
8354
8354
|
}
|
|
8355
8355
|
}
|
|
8356
8356
|
|
|
8357
|
-
var vertexSource$1 = "#version 300 es\nprecision lowp float;\nprecision lowp int;\n\n// Per-instance attributes (one entry per particle,
|
|
8357
|
+
var vertexSource$1 = "#version 300 es\nprecision lowp float;\nprecision lowp int;\n\n// Per-instance attributes (one entry per particle, 40 bytes total).\nlayout(location = 0) in vec2 a_translation; // particle position in system-local space\nlayout(location = 1) in vec2 a_scale; // particle scale\nlayout(location = 2) in float a_rotation; // particle rotation in degrees\nlayout(location = 3) in vec4 a_color; // RGBA tint\nlayout(location = 4) in vec2 a_uvMin; // top-left UV (u, v) — pre-resolved per instance\nlayout(location = 5) in vec2 a_uvMax; // bottom-right UV (u, v) — pre-resolved per instance\n\nuniform mat3 u_projection;\nuniform mat3 u_systemTransform;\nuniform vec4 u_localBounds; // left, top, right, bottom (system.vertices)\n\nout vec2 v_texcoord;\nout vec4 v_color;\n\nvoid main(void) {\n // Static index buffer is [0,1,2,0,2,3] (triangle-list), so gl_VertexID 0..3\n // maps to TL, TR, BR, BL via the same bit math the sprite renderer uses.\n int vid = gl_VertexID;\n int cornerX = ((vid + 1) >> 1) & 1;\n int cornerY = vid >> 1;\n\n float localX = (cornerX == 0) ? u_localBounds.x : u_localBounds.z;\n float localY = (cornerY == 0) ? u_localBounds.y : u_localBounds.w;\n\n // Per-particle scale + rotation.\n vec2 rotation = vec2(sin(radians(a_rotation)), cos(radians(a_rotation)));\n vec2 transformed = vec2(\n (localX * (a_scale.x * rotation.y)) + (localY * (a_scale.y * rotation.x)),\n (localX * (a_scale.x * -rotation.x)) + (localY * (a_scale.y * rotation.y))\n );\n\n vec3 worldPos = vec3(transformed + a_translation, 1.0);\n\n gl_Position = vec4((u_projection * u_systemTransform * worldPos).xy, 0.0, 1.0);\n\n float u = (cornerX == 0) ? a_uvMin.x : a_uvMax.x;\n float v = (cornerY == 0) ? a_uvMin.y : a_uvMax.y;\n v_texcoord = vec2(u, v);\n\n v_color = vec4(a_color.rgb * a_color.a, a_color.a);\n}\n";
|
|
8358
8358
|
|
|
8359
8359
|
var fragmentSource$1 = "#version 300 es\nprecision lowp float;\n\nuniform sampler2D u_texture;\n\nin vec2 v_texcoord;\nin vec4 v_color;\n\nlayout(location = 0) out vec4 fragColor;\n\nvoid main(void) {\n fragColor = texture(u_texture, v_texcoord) * v_color;\n}\n";
|
|
8360
8360
|
|
|
@@ -8363,30 +8363,26 @@ var fragmentSource$1 = "#version 300 es\nprecision lowp float;\n\nuniform sample
|
|
|
8363
8363
|
*
|
|
8364
8364
|
* One ParticleSystem = one batch. Each `render(system)` flushes any
|
|
8365
8365
|
* pending batch, sets the system-level uniforms (transform, local
|
|
8366
|
-
* bounds,
|
|
8367
|
-
*
|
|
8368
|
-
* `drawElementsInstanced` for that system.
|
|
8366
|
+
* bounds, texture), and packs every active particle into the per-instance
|
|
8367
|
+
* buffer. The next `flush()` issues a single `drawElementsInstanced`.
|
|
8369
8368
|
*
|
|
8370
|
-
* Per-instance layout (
|
|
8369
|
+
* Per-instance layout (40 bytes per particle, 6 attributes):
|
|
8371
8370
|
* ```
|
|
8372
8371
|
* translation f32x2 (offset 0, 8 bytes) particle position (system-local)
|
|
8373
8372
|
* scale f32x2 (offset 8, 8 bytes)
|
|
8374
8373
|
* rotation f32 (offset 16, 4 bytes) degrees
|
|
8375
8374
|
* color u8x4 (offset 20, 4 bytes) RGBA tint, normalised
|
|
8375
|
+
* uvMin f32x2 (offset 24, 8 bytes) pre-resolved frame uvMin
|
|
8376
|
+
* uvMax f32x2 (offset 32, 8 bytes) pre-resolved frame uvMax
|
|
8376
8377
|
* ```
|
|
8377
8378
|
*
|
|
8378
|
-
*
|
|
8379
|
-
*
|
|
8380
|
-
*
|
|
8381
|
-
*
|
|
8382
|
-
*
|
|
8383
|
-
* The system transform stays as a uniform — mixing systems in one
|
|
8384
|
-
* batch would require either a per-instance transform matrix (more
|
|
8385
|
-
* bandwidth) or per-particle texture-slot indexing (multi-texture
|
|
8386
|
-
* support similar to the sprite renderer). Both are follow-ups; the
|
|
8387
|
-
* current pattern keeps the renderer focused on the per-particle win.
|
|
8379
|
+
* UVs are baked per-particle so the system can carry an atlas of frames
|
|
8380
|
+
* — `system.frames` declares the rectangles; each particle's
|
|
8381
|
+
* `textureIndex` selects one. The pack loop resolves frame-rectangle to
|
|
8382
|
+
* UVs once per particle per frame; no per-instance shader-side indexing
|
|
8383
|
+
* needed.
|
|
8388
8384
|
*/
|
|
8389
|
-
const instanceStrideBytes$2 =
|
|
8385
|
+
const instanceStrideBytes$2 = 40;
|
|
8390
8386
|
const wordsPerInstance$1 = instanceStrideBytes$2 / Uint32Array.BYTES_PER_ELEMENT;
|
|
8391
8387
|
const indicesPerQuad = 6;
|
|
8392
8388
|
const quadIndices$2 = new Uint16Array([0, 1, 2, 0, 2, 3]);
|
|
@@ -8415,7 +8411,7 @@ class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
|
|
|
8415
8411
|
}
|
|
8416
8412
|
render(system) {
|
|
8417
8413
|
const backend = this.getBackend();
|
|
8418
|
-
const { texture,
|
|
8414
|
+
const { texture, blendMode } = system;
|
|
8419
8415
|
const textureChanged = texture !== this._currentTexture;
|
|
8420
8416
|
const blendModeChanged = blendMode !== this._currentBlendMode;
|
|
8421
8417
|
// System transform / texture / UV / local-bounds are uniforms, so
|
|
@@ -8433,32 +8429,94 @@ class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
|
|
|
8433
8429
|
// System-level uniforms are set before packing so the eventual
|
|
8434
8430
|
// flush() can sync them in one go.
|
|
8435
8431
|
const localBounds = system.vertices;
|
|
8436
|
-
const uvBounds = this._unpackUvBounds(system);
|
|
8437
8432
|
this._shader
|
|
8438
8433
|
.getUniform('u_systemTransform')
|
|
8439
8434
|
.setValue(system.getGlobalTransform().toArray(false));
|
|
8440
8435
|
this._shader
|
|
8441
8436
|
.getUniform('u_localBounds')
|
|
8442
8437
|
.setValue(localBounds);
|
|
8443
|
-
this._shader
|
|
8444
|
-
.getUniform('u_uvBounds')
|
|
8445
|
-
.setValue(uvBounds);
|
|
8446
8438
|
const f32 = this._instanceFloat32;
|
|
8447
8439
|
const u32 = this._instanceUint32;
|
|
8448
|
-
const
|
|
8440
|
+
const { posX, posY, scaleX, scaleY, rotations, color, textureIndex, alive } = system;
|
|
8441
|
+
const limit = Math.min(system.liveCount, this._batchSize);
|
|
8442
|
+
// Pre-compute frame UVs from system.frames + texture; falls back
|
|
8443
|
+
// to the system.textureFrame when no atlas is declared.
|
|
8444
|
+
const { uvMins, uvMaxs } = this._computeFrameUvs(system);
|
|
8445
|
+
const frameCount = uvMins.length / 2;
|
|
8446
|
+
const fallbackFrame = frameCount > 0 ? 0 : 0;
|
|
8447
|
+
let writeIndex = 0;
|
|
8449
8448
|
for (let i = 0; i < limit; i++) {
|
|
8450
|
-
|
|
8451
|
-
|
|
8452
|
-
|
|
8453
|
-
|
|
8454
|
-
|
|
8455
|
-
|
|
8456
|
-
|
|
8457
|
-
|
|
8458
|
-
|
|
8459
|
-
|
|
8460
|
-
|
|
8461
|
-
|
|
8449
|
+
// Skip dead slots in GPU-mode systems where the live range can
|
|
8450
|
+
// contain holes.
|
|
8451
|
+
if (alive[i] === 0) {
|
|
8452
|
+
continue;
|
|
8453
|
+
}
|
|
8454
|
+
const offset = writeIndex * wordsPerInstance$1;
|
|
8455
|
+
const frame = textureIndex[i] < frameCount ? textureIndex[i] : fallbackFrame;
|
|
8456
|
+
const uvBase = frame * 2;
|
|
8457
|
+
f32[offset + 0] = posX[i];
|
|
8458
|
+
f32[offset + 1] = posY[i];
|
|
8459
|
+
f32[offset + 2] = scaleX[i];
|
|
8460
|
+
f32[offset + 3] = scaleY[i];
|
|
8461
|
+
f32[offset + 4] = rotations[i];
|
|
8462
|
+
u32[offset + 5] = color[i];
|
|
8463
|
+
f32[offset + 6] = uvMins[uvBase + 0];
|
|
8464
|
+
f32[offset + 7] = uvMins[uvBase + 1];
|
|
8465
|
+
f32[offset + 8] = uvMaxs[uvBase + 0];
|
|
8466
|
+
f32[offset + 9] = uvMaxs[uvBase + 1];
|
|
8467
|
+
writeIndex++;
|
|
8468
|
+
}
|
|
8469
|
+
this._instanceCount = writeIndex;
|
|
8470
|
+
return this;
|
|
8471
|
+
}
|
|
8472
|
+
/**
|
|
8473
|
+
* Compute (uvMin, uvMax) pairs for every declared frame on the system.
|
|
8474
|
+
* Pulled lazily and cached per (system, texture-version) to avoid the
|
|
8475
|
+
* arithmetic in the hot pack loop. Falls back to a single entry from
|
|
8476
|
+
* `system.textureFrame` when no atlas is declared.
|
|
8477
|
+
*/
|
|
8478
|
+
_computeFrameUvs(system) {
|
|
8479
|
+
const frames = system.frames;
|
|
8480
|
+
const tex = system.texture;
|
|
8481
|
+
const texW = tex.width;
|
|
8482
|
+
const texH = tex.height;
|
|
8483
|
+
const flipY = tex.flipY;
|
|
8484
|
+
const count = frames.length === 0 ? 1 : frames.length;
|
|
8485
|
+
// Re-allocate scratch when capacity grows.
|
|
8486
|
+
if (this._uvMinsScratch.length < count * 2) {
|
|
8487
|
+
this._uvMinsScratch = new Float32Array(count * 2);
|
|
8488
|
+
this._uvMaxsScratch = new Float32Array(count * 2);
|
|
8489
|
+
}
|
|
8490
|
+
const mins = this._uvMinsScratch;
|
|
8491
|
+
const maxs = this._uvMaxsScratch;
|
|
8492
|
+
if (frames.length === 0) {
|
|
8493
|
+
const f = system.textureFrame;
|
|
8494
|
+
const minU = f.left / texW;
|
|
8495
|
+
const maxU = f.right / texW;
|
|
8496
|
+
const topV = f.top / texH;
|
|
8497
|
+
const bottomV = f.bottom / texH;
|
|
8498
|
+
mins[0] = minU;
|
|
8499
|
+
mins[1] = flipY ? bottomV : topV;
|
|
8500
|
+
maxs[0] = maxU;
|
|
8501
|
+
maxs[1] = flipY ? topV : bottomV;
|
|
8502
|
+
return { uvMins: mins, uvMaxs: maxs };
|
|
8503
|
+
}
|
|
8504
|
+
for (let i = 0; i < frames.length; i++) {
|
|
8505
|
+
const f = frames[i];
|
|
8506
|
+
const o = i * 2;
|
|
8507
|
+
const minU = f.left / texW;
|
|
8508
|
+
const maxU = f.right / texW;
|
|
8509
|
+
const topV = f.top / texH;
|
|
8510
|
+
const bottomV = f.bottom / texH;
|
|
8511
|
+
mins[o + 0] = minU;
|
|
8512
|
+
mins[o + 1] = flipY ? bottomV : topV;
|
|
8513
|
+
maxs[o + 0] = maxU;
|
|
8514
|
+
maxs[o + 1] = flipY ? topV : bottomV;
|
|
8515
|
+
}
|
|
8516
|
+
return { uvMins: mins, uvMaxs: maxs };
|
|
8517
|
+
}
|
|
8518
|
+
_uvMinsScratch = new Float32Array(2);
|
|
8519
|
+
_uvMaxsScratch = new Float32Array(2);
|
|
8462
8520
|
flush() {
|
|
8463
8521
|
const backend = this.getBackendOrNull();
|
|
8464
8522
|
const instanceBuffer = this._instanceBuffer;
|
|
@@ -8498,6 +8556,8 @@ class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
|
|
|
8498
8556
|
.addAttribute(this._instanceBuffer, this._shader.getAttribute('a_scale'), gl.FLOAT, false, instanceStrideBytes$2, 8, false, 1)
|
|
8499
8557
|
.addAttribute(this._instanceBuffer, this._shader.getAttribute('a_rotation'), gl.FLOAT, false, instanceStrideBytes$2, 16, false, 1)
|
|
8500
8558
|
.addAttribute(this._instanceBuffer, this._shader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, instanceStrideBytes$2, 20, false, 1)
|
|
8559
|
+
.addAttribute(this._instanceBuffer, this._shader.getAttribute('a_uvMin'), gl.FLOAT, false, instanceStrideBytes$2, 24, false, 1)
|
|
8560
|
+
.addAttribute(this._instanceBuffer, this._shader.getAttribute('a_uvMax'), gl.FLOAT, false, instanceStrideBytes$2, 32, false, 1)
|
|
8501
8561
|
.connect(this._createVaoRuntime(this._connection));
|
|
8502
8562
|
}
|
|
8503
8563
|
onDisconnect() {
|
|
@@ -8519,37 +8579,6 @@ class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
|
|
|
8519
8579
|
this.disconnect();
|
|
8520
8580
|
this._shader.destroy();
|
|
8521
8581
|
}
|
|
8522
|
-
/**
|
|
8523
|
-
* Convert the system's per-corner packed-u32 texCoords into the
|
|
8524
|
-
* (uMin, vMin, uMax, vMax) bounds the new vertex shader expects.
|
|
8525
|
-
* Already accounts for flipY (the system's `texCoords` baked it in).
|
|
8526
|
-
*
|
|
8527
|
-
* The four packed u32s, by corner index, are:
|
|
8528
|
-
* [0] TL = (uMin/uMax | vMin/vMax depending on flipY)
|
|
8529
|
-
* [1] TR
|
|
8530
|
-
* [2] BR
|
|
8531
|
-
* [3] BL
|
|
8532
|
-
* We need (uMin, vMin, uMax, vMax) — the corner-extreme values.
|
|
8533
|
-
*/
|
|
8534
|
-
_uvBoundsScratch = new Float32Array(4);
|
|
8535
|
-
_unpackUvBounds(system) {
|
|
8536
|
-
const texCoords = system.texCoords;
|
|
8537
|
-
// Each packed u32: low 16 bits = U normalised to 0..65535,
|
|
8538
|
-
// high 16 bits = V normalised.
|
|
8539
|
-
const uTopLeft = (texCoords[0] & 0xFFFF) / 0xFFFF;
|
|
8540
|
-
const vTopLeft = ((texCoords[0] >>> 16) & 0xFFFF) / 0xFFFF;
|
|
8541
|
-
const uBottomRight = (texCoords[2] & 0xFFFF) / 0xFFFF;
|
|
8542
|
-
const vBottomRight = ((texCoords[2] >>> 16) & 0xFFFF) / 0xFFFF;
|
|
8543
|
-
// For flipY: TL.v becomes vMax (was minY → maxY). The shader picks
|
|
8544
|
-
// (cornerY == 0 ? vMin : vMax); writing TL.v into vMin and BR.v into
|
|
8545
|
-
// vMax matches the original per-corner ordering whether or not flipY
|
|
8546
|
-
// was applied at texCoords pack time.
|
|
8547
|
-
this._uvBoundsScratch[0] = uTopLeft;
|
|
8548
|
-
this._uvBoundsScratch[1] = vTopLeft;
|
|
8549
|
-
this._uvBoundsScratch[2] = uBottomRight;
|
|
8550
|
-
this._uvBoundsScratch[3] = vBottomRight;
|
|
8551
|
-
return this._uvBoundsScratch;
|
|
8552
|
-
}
|
|
8553
8582
|
_createConnection(gl) {
|
|
8554
8583
|
const vaoHandle = gl.createVertexArray();
|
|
8555
8584
|
if (vaoHandle === null) {
|
|
@@ -9068,169 +9097,830 @@ class Sprite extends Drawable {
|
|
|
9068
9097
|
RenderNode.setInternalSpriteFactory(() => new Sprite(null));
|
|
9069
9098
|
|
|
9070
9099
|
/**
|
|
9071
|
-
*
|
|
9072
|
-
*
|
|
9073
|
-
* interface. Particles are pooled by {@link ParticleSystem}: expired instances
|
|
9074
|
-
* are moved to the graveyard and reused via {@link ParticleSystem.requestParticle}
|
|
9075
|
-
* rather than garbage-collected.
|
|
9100
|
+
* Slices a single {@link Texture} into named frames and optional named
|
|
9101
|
+
* animation sequences.
|
|
9076
9102
|
*
|
|
9077
|
-
*
|
|
9078
|
-
*
|
|
9079
|
-
* {@link
|
|
9103
|
+
* Each frame is stored as both a {@link Rectangle} (the pixel region) and a
|
|
9104
|
+
* pre-configured {@link Sprite} ready for direct rendering. Animations are
|
|
9105
|
+
* ordered lists of frame names that {@link AnimatedSprite.fromSpritesheet}
|
|
9106
|
+
* consumes to create playback clips.
|
|
9080
9107
|
*/
|
|
9081
|
-
class
|
|
9082
|
-
|
|
9083
|
-
|
|
9084
|
-
|
|
9085
|
-
|
|
9086
|
-
|
|
9087
|
-
|
|
9088
|
-
|
|
9089
|
-
_textureIndex = 0;
|
|
9090
|
-
_tint = Color.white.clone();
|
|
9091
|
-
get totalLifetime() {
|
|
9092
|
-
return this._totalLifetime;
|
|
9093
|
-
}
|
|
9094
|
-
set totalLifetime(totalLifetime) {
|
|
9095
|
-
this._totalLifetime.copy(totalLifetime);
|
|
9096
|
-
}
|
|
9097
|
-
get elapsedLifetime() {
|
|
9098
|
-
return this._elapsedLifetime;
|
|
9099
|
-
}
|
|
9100
|
-
set elapsedLifetime(elapsedLifetime) {
|
|
9101
|
-
this._elapsedLifetime.copy(elapsedLifetime);
|
|
9102
|
-
}
|
|
9103
|
-
get position() {
|
|
9104
|
-
return this._position;
|
|
9105
|
-
}
|
|
9106
|
-
set position(position) {
|
|
9107
|
-
this._position.copy(position);
|
|
9108
|
-
}
|
|
9109
|
-
get velocity() {
|
|
9110
|
-
return this._velocity;
|
|
9111
|
-
}
|
|
9112
|
-
set velocity(velocity) {
|
|
9113
|
-
this._velocity.copy(velocity);
|
|
9108
|
+
class Spritesheet {
|
|
9109
|
+
texture;
|
|
9110
|
+
frames = new Map();
|
|
9111
|
+
sprites = new Map();
|
|
9112
|
+
animations = new Map();
|
|
9113
|
+
constructor(texture, data) {
|
|
9114
|
+
this.texture = texture;
|
|
9115
|
+
this.parse(data);
|
|
9114
9116
|
}
|
|
9115
|
-
|
|
9116
|
-
|
|
9117
|
+
/**
|
|
9118
|
+
* Parse a {@link SpritesheetData} descriptor, populating `frames`,
|
|
9119
|
+
* `sprites`, and `animations`. When `keepFrames` is `false` (default),
|
|
9120
|
+
* all existing frames and sprites are destroyed before parsing.
|
|
9121
|
+
*/
|
|
9122
|
+
parse(data, keepFrames = false) {
|
|
9123
|
+
if (!keepFrames) {
|
|
9124
|
+
this.clear();
|
|
9125
|
+
}
|
|
9126
|
+
for (const [name, frame] of Object.entries(data.frames)) {
|
|
9127
|
+
this.addFrame(name, frame);
|
|
9128
|
+
}
|
|
9129
|
+
if (data.animations) {
|
|
9130
|
+
for (const [animationName, frameNames] of Object.entries(data.animations)) {
|
|
9131
|
+
this.defineAnimation(animationName, frameNames);
|
|
9132
|
+
}
|
|
9133
|
+
}
|
|
9117
9134
|
}
|
|
9118
|
-
|
|
9119
|
-
|
|
9135
|
+
/** Register a single frame by name, creating its {@link Rectangle} and pre-configured {@link Sprite}. */
|
|
9136
|
+
addFrame(name, data) {
|
|
9137
|
+
const { x, y, w, h } = data.frame;
|
|
9138
|
+
const frame = new Rectangle(x, y, w, h);
|
|
9139
|
+
const sprite = new Sprite(this.texture);
|
|
9140
|
+
sprite.setTextureFrame(frame);
|
|
9141
|
+
this.frames.set(name, frame);
|
|
9142
|
+
this.sprites.set(name, sprite);
|
|
9120
9143
|
}
|
|
9121
|
-
|
|
9122
|
-
|
|
9144
|
+
/** Register an animation sequence as an ordered list of frame names. All referenced frames must already exist. */
|
|
9145
|
+
defineAnimation(name, frameNames) {
|
|
9146
|
+
if (name.trim().length === 0) {
|
|
9147
|
+
throw new Error('Spritesheet animation names must be non-empty strings.');
|
|
9148
|
+
}
|
|
9149
|
+
if (!Array.isArray(frameNames) || frameNames.length === 0) {
|
|
9150
|
+
throw new Error(`Spritesheet animation "${name}" must reference at least one frame.`);
|
|
9151
|
+
}
|
|
9152
|
+
for (const frameName of frameNames) {
|
|
9153
|
+
if (!this.frames.has(frameName)) {
|
|
9154
|
+
throw new Error(`Spritesheet animation "${name}" references missing frame "${frameName}".`);
|
|
9155
|
+
}
|
|
9156
|
+
}
|
|
9157
|
+
this.animations.set(name, [...frameNames]);
|
|
9158
|
+
return this;
|
|
9123
9159
|
}
|
|
9124
|
-
|
|
9125
|
-
|
|
9160
|
+
/** Return the {@link Rectangle} for the named frame. Throws if the frame does not exist. */
|
|
9161
|
+
getFrame(name) {
|
|
9162
|
+
const frame = this.frames.get(name);
|
|
9163
|
+
if (!frame) {
|
|
9164
|
+
throw new Error(`Spritesheet frame named ${name} is not available!`);
|
|
9165
|
+
}
|
|
9166
|
+
return frame;
|
|
9126
9167
|
}
|
|
9127
|
-
|
|
9128
|
-
|
|
9168
|
+
/** Return the ordered frame-name list for the named animation. Throws if the animation does not exist. */
|
|
9169
|
+
getAnimationFrameNames(name) {
|
|
9170
|
+
const frames = this.animations.get(name);
|
|
9171
|
+
if (!frames) {
|
|
9172
|
+
throw new Error(`Spritesheet animation named ${name} is not available!`);
|
|
9173
|
+
}
|
|
9174
|
+
return frames;
|
|
9129
9175
|
}
|
|
9130
|
-
|
|
9131
|
-
|
|
9176
|
+
/** Return the pre-configured {@link Sprite} for the named frame. Throws if the frame does not exist. */
|
|
9177
|
+
getFrameSprite(name) {
|
|
9178
|
+
const sprite = this.sprites.get(name);
|
|
9179
|
+
if (!sprite) {
|
|
9180
|
+
throw new Error(`Spritesheet frame named ${name} is not available!`);
|
|
9181
|
+
}
|
|
9182
|
+
return sprite;
|
|
9132
9183
|
}
|
|
9133
|
-
|
|
9134
|
-
|
|
9184
|
+
/** Destroy all registered frames, sprites, and animations, resetting the spritesheet to an empty state. */
|
|
9185
|
+
clear() {
|
|
9186
|
+
for (const frame of this.frames.values()) {
|
|
9187
|
+
frame.destroy();
|
|
9188
|
+
}
|
|
9189
|
+
this.frames.clear();
|
|
9190
|
+
for (const sprite of this.sprites.values()) {
|
|
9191
|
+
sprite.destroy();
|
|
9192
|
+
}
|
|
9193
|
+
this.sprites.clear();
|
|
9194
|
+
this.animations.clear();
|
|
9195
|
+
return this;
|
|
9135
9196
|
}
|
|
9136
|
-
|
|
9137
|
-
this.
|
|
9197
|
+
destroy() {
|
|
9198
|
+
this.clear();
|
|
9138
9199
|
}
|
|
9139
|
-
|
|
9140
|
-
|
|
9200
|
+
}
|
|
9201
|
+
|
|
9202
|
+
/**
|
|
9203
|
+
* Compute the byte size of a uniform struct from its declared fields,
|
|
9204
|
+
* respecting WGSL std140-like alignment rules. Each field aligns to its
|
|
9205
|
+
* natural alignment; the struct itself rounds to its largest alignment.
|
|
9206
|
+
*
|
|
9207
|
+
* Used by the codegen to size the system's combined uniform buffer.
|
|
9208
|
+
*/
|
|
9209
|
+
const wgslUniformByteSize = (fields) => {
|
|
9210
|
+
let offset = 0;
|
|
9211
|
+
let maxAlign = 4;
|
|
9212
|
+
for (const field of fields) {
|
|
9213
|
+
const { size, align } = wgslFieldLayout(field.type);
|
|
9214
|
+
offset = Math.ceil(offset / align) * align;
|
|
9215
|
+
offset += size;
|
|
9216
|
+
maxAlign = Math.max(maxAlign, align);
|
|
9217
|
+
}
|
|
9218
|
+
return Math.ceil(offset / maxAlign) * maxAlign;
|
|
9219
|
+
};
|
|
9220
|
+
/** Per-WGSL-primitive size and alignment in bytes. */
|
|
9221
|
+
const wgslFieldLayout = (type) => {
|
|
9222
|
+
switch (type) {
|
|
9223
|
+
case 'f32':
|
|
9224
|
+
case 'i32':
|
|
9225
|
+
case 'u32':
|
|
9226
|
+
return { size: 4, align: 4 };
|
|
9227
|
+
case 'vec2<f32>':
|
|
9228
|
+
return { size: 8, align: 8 };
|
|
9229
|
+
case 'vec4<f32>':
|
|
9230
|
+
return { size: 16, align: 16 };
|
|
9141
9231
|
}
|
|
9142
|
-
|
|
9143
|
-
|
|
9232
|
+
};
|
|
9233
|
+
|
|
9234
|
+
/// <reference types="@webgpu/types" />
|
|
9235
|
+
/**
|
|
9236
|
+
* GPU-side mirror of one {@link ParticleSystem}. Owns:
|
|
9237
|
+
*
|
|
9238
|
+
* - **8 packed storage buffers** for the per-particle SoA data:
|
|
9239
|
+
* positions/velocities/scales/rotInfo/timing as `vec2<f32>`, color and
|
|
9240
|
+
* textureIndex as `u32`, plus the instance output buffer. Sits at the
|
|
9241
|
+
* default WebGPU `maxStorageBuffersPerShaderStage = 8` limit.
|
|
9242
|
+
* - **One uniform buffer** for sim state (`dt`, `liveCount`).
|
|
9243
|
+
* - **One uniform buffer** for module configs (concatenated per-module
|
|
9244
|
+
* structs with WGSL std140-ish alignment).
|
|
9245
|
+
* - **One uniform buffer** for frame UVs — `array<vec4<f32>, N>` where N
|
|
9246
|
+
* is the system's frame count (or 1 when no atlas is declared). Each
|
|
9247
|
+
* vec4 is `(uvMinX, uvMinY, uvMaxX, uvMaxY)` already flipY-adjusted.
|
|
9248
|
+
* - **N 1D textures** for modules that use lookup tables (Curve / Gradient).
|
|
9249
|
+
* - **Composite compute pipeline** built once at construction by
|
|
9250
|
+
* concatenating the integration step + every registered module body +
|
|
9251
|
+
* the pack-instances step into a single shader.
|
|
9252
|
+
*
|
|
9253
|
+
* The compute shader's pack-instances step reads `textureIndex[i]`, looks
|
|
9254
|
+
* up the matching frame UV, and writes a 40-byte interleaved record into
|
|
9255
|
+
* the instance output buffer (`STORAGE | VERTEX`). The renderer binds that
|
|
9256
|
+
* buffer directly as instanced vertex source — no readback.
|
|
9257
|
+
*/
|
|
9258
|
+
const workgroupSize = 64;
|
|
9259
|
+
const instanceBytes = 40; // 5 × f32 + 1 × u32 + 4 × f32 (uvMin.xy, uvMax.xy)
|
|
9260
|
+
class ParticleGpuState {
|
|
9261
|
+
device;
|
|
9262
|
+
capacity;
|
|
9263
|
+
/** GPU buffer holding interleaved per-instance vertex data, written by compute, read as VERTEX by the renderer. */
|
|
9264
|
+
instanceBuffer;
|
|
9265
|
+
_positions;
|
|
9266
|
+
_velocities;
|
|
9267
|
+
_scales;
|
|
9268
|
+
_rotInfo;
|
|
9269
|
+
_timing;
|
|
9270
|
+
_color;
|
|
9271
|
+
_textureIndex;
|
|
9272
|
+
_simUniformBuffer;
|
|
9273
|
+
_simUniformData = new ArrayBuffer(16);
|
|
9274
|
+
_simUniformView;
|
|
9275
|
+
_moduleUniformBuffer;
|
|
9276
|
+
_moduleUniformData;
|
|
9277
|
+
_moduleUniformView;
|
|
9278
|
+
_moduleSlots;
|
|
9279
|
+
_framesUniformBuffer;
|
|
9280
|
+
_framesUniformData;
|
|
9281
|
+
_framesUniformView;
|
|
9282
|
+
_frameCount;
|
|
9283
|
+
_moduleTextures = new Map();
|
|
9284
|
+
_samplerFiltering;
|
|
9285
|
+
_samplerNonFiltering;
|
|
9286
|
+
_pipeline;
|
|
9287
|
+
_bindGroup0;
|
|
9288
|
+
_bindGroup1;
|
|
9289
|
+
constructor(device, capacity, modules, frames, texture) {
|
|
9290
|
+
this.device = device;
|
|
9291
|
+
this.capacity = capacity;
|
|
9292
|
+
for (const m of modules) {
|
|
9293
|
+
if (!m.wgsl) {
|
|
9294
|
+
throw new Error(`ParticleGpuState: module ${m.constructor.name} has no wgsl() — `
|
|
9295
|
+
+ 'all registered UpdateModules must be GPU-eligible.');
|
|
9296
|
+
}
|
|
9297
|
+
}
|
|
9298
|
+
// Module uniform layout.
|
|
9299
|
+
const slots = [];
|
|
9300
|
+
let uniformOffset = 0;
|
|
9301
|
+
for (const m of modules) {
|
|
9302
|
+
const c = m.wgsl();
|
|
9303
|
+
const fields = c.uniforms ?? [];
|
|
9304
|
+
const size = wgslUniformByteSize(fields);
|
|
9305
|
+
uniformOffset = Math.ceil(uniformOffset / 16) * 16;
|
|
9306
|
+
slots.push({
|
|
9307
|
+
module: m,
|
|
9308
|
+
contribution: c,
|
|
9309
|
+
uniformByteOffset: uniformOffset,
|
|
9310
|
+
uniformByteSize: size,
|
|
9311
|
+
});
|
|
9312
|
+
uniformOffset += size;
|
|
9313
|
+
}
|
|
9314
|
+
const totalUniformBytes = Math.max(16, Math.ceil(uniformOffset / 16) * 16);
|
|
9315
|
+
this._moduleSlots = slots;
|
|
9316
|
+
if (uniformOffset > 0) {
|
|
9317
|
+
this._moduleUniformData = new ArrayBuffer(totalUniformBytes);
|
|
9318
|
+
this._moduleUniformView = new DataView(this._moduleUniformData);
|
|
9319
|
+
this._moduleUniformBuffer = device.createBuffer({
|
|
9320
|
+
label: 'particle-module-uniforms',
|
|
9321
|
+
size: totalUniformBytes,
|
|
9322
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
9323
|
+
});
|
|
9324
|
+
}
|
|
9325
|
+
else {
|
|
9326
|
+
this._moduleUniformData = null;
|
|
9327
|
+
this._moduleUniformView = null;
|
|
9328
|
+
this._moduleUniformBuffer = null;
|
|
9329
|
+
}
|
|
9330
|
+
// Frames uniform buffer.
|
|
9331
|
+
this._frameCount = Math.max(1, frames.length);
|
|
9332
|
+
this._framesUniformData = new ArrayBuffer(this._frameCount * 16);
|
|
9333
|
+
this._framesUniformView = new Float32Array(this._framesUniformData);
|
|
9334
|
+
this._framesUniformBuffer = device.createBuffer({
|
|
9335
|
+
label: 'particle-frames-uniforms',
|
|
9336
|
+
size: this._framesUniformData.byteLength,
|
|
9337
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
9338
|
+
});
|
|
9339
|
+
this._writeFrames(frames, texture);
|
|
9340
|
+
const vec2Bytes = capacity * 8;
|
|
9341
|
+
const u32Bytes = capacity * 4;
|
|
9342
|
+
this._positions = device.createBuffer({ label: 'particle-positions', size: vec2Bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
|
9343
|
+
this._velocities = device.createBuffer({ label: 'particle-velocities', size: vec2Bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
|
9344
|
+
this._scales = device.createBuffer({ label: 'particle-scales', size: vec2Bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
|
9345
|
+
this._rotInfo = device.createBuffer({ label: 'particle-rotInfo', size: vec2Bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
|
9346
|
+
this._timing = device.createBuffer({ label: 'particle-timing', size: vec2Bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
|
9347
|
+
this._color = device.createBuffer({ label: 'particle-color', size: u32Bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
|
9348
|
+
this._textureIndex = device.createBuffer({ label: 'particle-textureIndex', size: u32Bytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
|
|
9349
|
+
this.instanceBuffer = device.createBuffer({
|
|
9350
|
+
label: 'particle-instance-output',
|
|
9351
|
+
size: capacity * instanceBytes,
|
|
9352
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
9353
|
+
});
|
|
9354
|
+
this._simUniformView = new DataView(this._simUniformData);
|
|
9355
|
+
this._simUniformBuffer = device.createBuffer({
|
|
9356
|
+
label: 'particle-sim-uniforms',
|
|
9357
|
+
size: 16,
|
|
9358
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
9359
|
+
});
|
|
9360
|
+
// r32float textures aren't filterable in core WebGPU (would require
|
|
9361
|
+
// the optional `float32-filterable` feature). Use `nearest` for
|
|
9362
|
+
// r32float curve LUTs (256 taps is fine without interpolation) and
|
|
9363
|
+
// `linear` for rgba8unorm gradients which support filtering natively.
|
|
9364
|
+
this._samplerFiltering = device.createSampler({
|
|
9365
|
+
label: 'particle-lookup-sampler-filtering',
|
|
9366
|
+
minFilter: 'linear',
|
|
9367
|
+
magFilter: 'linear',
|
|
9368
|
+
addressModeU: 'clamp-to-edge',
|
|
9369
|
+
});
|
|
9370
|
+
this._samplerNonFiltering = device.createSampler({
|
|
9371
|
+
label: 'particle-lookup-sampler-non-filtering',
|
|
9372
|
+
minFilter: 'nearest',
|
|
9373
|
+
magFilter: 'nearest',
|
|
9374
|
+
addressModeU: 'clamp-to-edge',
|
|
9375
|
+
});
|
|
9376
|
+
// Allocate textures for modules that need them.
|
|
9377
|
+
for (const slot of slots) {
|
|
9378
|
+
const c = slot.contribution;
|
|
9379
|
+
if (!c.textures)
|
|
9380
|
+
continue;
|
|
9381
|
+
for (const t of c.textures) {
|
|
9382
|
+
const tex = device.createTexture({
|
|
9383
|
+
label: `particle-tex-${c.key}-${t.name}`,
|
|
9384
|
+
size: { width: 256, height: 1, depthOrArrayLayers: 1 },
|
|
9385
|
+
format: t.format,
|
|
9386
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
|
|
9387
|
+
dimension: '1d',
|
|
9388
|
+
});
|
|
9389
|
+
this._moduleTextures.set(`${c.key}_${t.name}`, tex);
|
|
9390
|
+
}
|
|
9391
|
+
}
|
|
9392
|
+
const wgsl = this._buildShader(slots);
|
|
9393
|
+
const bindGroup0Layout = this._buildBindGroup0Layout(slots);
|
|
9394
|
+
const bindGroup1Layout = device.createBindGroupLayout({
|
|
9395
|
+
label: 'particle-soa-bgl',
|
|
9396
|
+
entries: [
|
|
9397
|
+
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
|
9398
|
+
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
|
9399
|
+
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
|
9400
|
+
{ binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
|
9401
|
+
{ binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
|
9402
|
+
{ binding: 5, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
|
9403
|
+
{ binding: 6, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } }, // textureIndex (matches WGSL `var<storage, read>`)
|
|
9404
|
+
{ binding: 7, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
|
|
9405
|
+
],
|
|
9406
|
+
});
|
|
9407
|
+
const pipelineLayout = device.createPipelineLayout({
|
|
9408
|
+
label: 'particle-compute-layout',
|
|
9409
|
+
bindGroupLayouts: [bindGroup0Layout, bindGroup1Layout],
|
|
9410
|
+
});
|
|
9411
|
+
const shaderModule = device.createShaderModule({
|
|
9412
|
+
label: 'particle-compute-shader',
|
|
9413
|
+
code: wgsl,
|
|
9414
|
+
});
|
|
9415
|
+
this._pipeline = device.createComputePipeline({
|
|
9416
|
+
label: 'particle-compute-pipeline',
|
|
9417
|
+
layout: pipelineLayout,
|
|
9418
|
+
compute: {
|
|
9419
|
+
module: shaderModule,
|
|
9420
|
+
entryPoint: 'main',
|
|
9421
|
+
},
|
|
9422
|
+
});
|
|
9423
|
+
this._bindGroup0 = this._buildBindGroup0(bindGroup0Layout, slots);
|
|
9424
|
+
this._bindGroup1 = device.createBindGroup({
|
|
9425
|
+
label: 'particle-soa-bg',
|
|
9426
|
+
layout: bindGroup1Layout,
|
|
9427
|
+
entries: [
|
|
9428
|
+
{ binding: 0, resource: { buffer: this._positions } },
|
|
9429
|
+
{ binding: 1, resource: { buffer: this._velocities } },
|
|
9430
|
+
{ binding: 2, resource: { buffer: this._scales } },
|
|
9431
|
+
{ binding: 3, resource: { buffer: this._rotInfo } },
|
|
9432
|
+
{ binding: 4, resource: { buffer: this._timing } },
|
|
9433
|
+
{ binding: 5, resource: { buffer: this._color } },
|
|
9434
|
+
{ binding: 6, resource: { buffer: this._textureIndex } },
|
|
9435
|
+
{ binding: 7, resource: { buffer: this.instanceBuffer } },
|
|
9436
|
+
],
|
|
9437
|
+
});
|
|
9438
|
+
// Modules upload their lookup textures.
|
|
9439
|
+
for (const slot of slots) {
|
|
9440
|
+
if (!slot.module.uploadTextures)
|
|
9441
|
+
continue;
|
|
9442
|
+
const moduleTextures = new Map();
|
|
9443
|
+
for (const t of slot.contribution.textures ?? []) {
|
|
9444
|
+
const tex = this._moduleTextures.get(`${slot.contribution.key}_${t.name}`);
|
|
9445
|
+
if (tex !== undefined) {
|
|
9446
|
+
moduleTextures.set(t.name, tex);
|
|
9447
|
+
}
|
|
9448
|
+
}
|
|
9449
|
+
slot.module.uploadTextures(device, moduleTextures);
|
|
9450
|
+
}
|
|
9144
9451
|
}
|
|
9145
|
-
|
|
9146
|
-
|
|
9147
|
-
|
|
9148
|
-
|
|
9149
|
-
|
|
9150
|
-
|
|
9151
|
-
|
|
9452
|
+
dispatch(system, dt) {
|
|
9453
|
+
const liveCount = system.liveCount;
|
|
9454
|
+
if (liveCount <= 0) {
|
|
9455
|
+
return;
|
|
9456
|
+
}
|
|
9457
|
+
this._writeSimUniforms(dt, liveCount);
|
|
9458
|
+
this._writeModuleUniforms(dt);
|
|
9459
|
+
const encoder = this.device.createCommandEncoder({ label: 'particle-compute' });
|
|
9460
|
+
const pass = encoder.beginComputePass({ label: 'particle-compute-pass' });
|
|
9461
|
+
pass.setPipeline(this._pipeline);
|
|
9462
|
+
pass.setBindGroup(0, this._bindGroup0);
|
|
9463
|
+
pass.setBindGroup(1, this._bindGroup1);
|
|
9464
|
+
pass.dispatchWorkgroups(Math.ceil(liveCount / workgroupSize));
|
|
9465
|
+
pass.end();
|
|
9466
|
+
this.device.queue.submit([encoder.finish()]);
|
|
9152
9467
|
}
|
|
9153
|
-
|
|
9154
|
-
|
|
9155
|
-
|
|
9156
|
-
|
|
9157
|
-
|
|
9158
|
-
|
|
9468
|
+
destroy() {
|
|
9469
|
+
this._positions.destroy();
|
|
9470
|
+
this._velocities.destroy();
|
|
9471
|
+
this._scales.destroy();
|
|
9472
|
+
this._rotInfo.destroy();
|
|
9473
|
+
this._timing.destroy();
|
|
9474
|
+
this._color.destroy();
|
|
9475
|
+
this._textureIndex.destroy();
|
|
9476
|
+
this.instanceBuffer.destroy();
|
|
9477
|
+
this._simUniformBuffer.destroy();
|
|
9478
|
+
this._framesUniformBuffer.destroy();
|
|
9479
|
+
this._moduleUniformBuffer?.destroy();
|
|
9480
|
+
for (const tex of this._moduleTextures.values()) {
|
|
9481
|
+
tex.destroy();
|
|
9482
|
+
}
|
|
9483
|
+
this._moduleTextures.clear();
|
|
9484
|
+
}
|
|
9485
|
+
_writeFrames(frames, texture) {
|
|
9486
|
+
const view = this._framesUniformView;
|
|
9487
|
+
const w = texture.width;
|
|
9488
|
+
const h = texture.height;
|
|
9489
|
+
const flipY = texture.flipY;
|
|
9490
|
+
if (frames.length === 0) {
|
|
9491
|
+
// Single-frame fallback — full texture.
|
|
9492
|
+
view[0] = 0;
|
|
9493
|
+
view[1] = flipY ? 1 : 0;
|
|
9494
|
+
view[2] = 1;
|
|
9495
|
+
view[3] = flipY ? 0 : 1;
|
|
9496
|
+
}
|
|
9497
|
+
else {
|
|
9498
|
+
for (let i = 0; i < frames.length; i++) {
|
|
9499
|
+
const f = frames[i];
|
|
9500
|
+
const o = i * 4;
|
|
9501
|
+
const minU = f.left / w;
|
|
9502
|
+
const maxU = f.right / w;
|
|
9503
|
+
const topV = f.top / h;
|
|
9504
|
+
const bottomV = f.bottom / h;
|
|
9505
|
+
view[o + 0] = minU;
|
|
9506
|
+
view[o + 1] = flipY ? bottomV : topV;
|
|
9507
|
+
view[o + 2] = maxU;
|
|
9508
|
+
view[o + 3] = flipY ? topV : bottomV;
|
|
9509
|
+
}
|
|
9510
|
+
}
|
|
9511
|
+
this.device.queue.writeBuffer(this._framesUniformBuffer, 0, this._framesUniformData);
|
|
9512
|
+
}
|
|
9513
|
+
/**
|
|
9514
|
+
* Push the listed CPU SoA slots to the GPU. Called by `ParticleSystem`
|
|
9515
|
+
* with newly-spawned slots and just-expired slots (lifetime sentinel).
|
|
9516
|
+
* Slots not in the dirty set are left alone — GPU keeps the integrated
|
|
9517
|
+
* state from previous compute dispatches.
|
|
9518
|
+
*
|
|
9519
|
+
* Each dirty slot triggers 7 small `queue.writeBuffer` calls (one per
|
|
9520
|
+
* SoA channel). For typical spawn rates (≤200/s) this is negligible
|
|
9521
|
+
* (≤1400 calls/s); contiguous-range batching is a future optimisation.
|
|
9522
|
+
*/
|
|
9523
|
+
uploadDirty(system, slots) {
|
|
9524
|
+
const queue = this.device.queue;
|
|
9525
|
+
const scratch2 = this._dirtyScratchVec2;
|
|
9526
|
+
const scratch1 = this._dirtyScratchU32;
|
|
9527
|
+
for (const slot of slots) {
|
|
9528
|
+
const byteOffset2 = slot * 8;
|
|
9529
|
+
const byteOffset1 = slot * 4;
|
|
9530
|
+
scratch2[0] = system.posX[slot];
|
|
9531
|
+
scratch2[1] = system.posY[slot];
|
|
9532
|
+
queue.writeBuffer(this._positions, byteOffset2, scratch2.buffer, 0, 8);
|
|
9533
|
+
scratch2[0] = system.velX[slot];
|
|
9534
|
+
scratch2[1] = system.velY[slot];
|
|
9535
|
+
queue.writeBuffer(this._velocities, byteOffset2, scratch2.buffer, 0, 8);
|
|
9536
|
+
scratch2[0] = system.scaleX[slot];
|
|
9537
|
+
scratch2[1] = system.scaleY[slot];
|
|
9538
|
+
queue.writeBuffer(this._scales, byteOffset2, scratch2.buffer, 0, 8);
|
|
9539
|
+
scratch2[0] = system.rotations[slot];
|
|
9540
|
+
scratch2[1] = system.rotationSpeeds[slot];
|
|
9541
|
+
queue.writeBuffer(this._rotInfo, byteOffset2, scratch2.buffer, 0, 8);
|
|
9542
|
+
scratch2[0] = system.elapsed[slot];
|
|
9543
|
+
scratch2[1] = system.lifetime[slot];
|
|
9544
|
+
queue.writeBuffer(this._timing, byteOffset2, scratch2.buffer, 0, 8);
|
|
9545
|
+
scratch1[0] = system.color[slot];
|
|
9546
|
+
queue.writeBuffer(this._color, byteOffset1, scratch1.buffer, 0, 4);
|
|
9547
|
+
scratch1[0] = system.textureIndex[slot];
|
|
9548
|
+
queue.writeBuffer(this._textureIndex, byteOffset1, scratch1.buffer, 0, 4);
|
|
9549
|
+
}
|
|
9550
|
+
}
|
|
9551
|
+
_dirtyScratchVec2 = new Float32Array(2);
|
|
9552
|
+
_dirtyScratchU32 = new Uint32Array(1);
|
|
9553
|
+
_writeSimUniforms(dt, liveCount) {
|
|
9554
|
+
this._simUniformView.setFloat32(0, dt, true);
|
|
9555
|
+
this._simUniformView.setUint32(4, liveCount, true);
|
|
9556
|
+
this.device.queue.writeBuffer(this._simUniformBuffer, 0, this._simUniformData);
|
|
9557
|
+
}
|
|
9558
|
+
_writeModuleUniforms(dt) {
|
|
9559
|
+
if (this._moduleUniformView === null || this._moduleUniformBuffer === null || this._moduleUniformData === null) {
|
|
9560
|
+
return;
|
|
9561
|
+
}
|
|
9562
|
+
for (const slot of this._moduleSlots) {
|
|
9563
|
+
slot.module.writeUniforms?.(this._moduleUniformView, slot.uniformByteOffset, dt);
|
|
9564
|
+
}
|
|
9565
|
+
this.device.queue.writeBuffer(this._moduleUniformBuffer, 0, this._moduleUniformData);
|
|
9159
9566
|
}
|
|
9160
|
-
|
|
9161
|
-
|
|
9162
|
-
|
|
9567
|
+
_buildBindGroup0Layout(slots) {
|
|
9568
|
+
const entries = [
|
|
9569
|
+
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
|
|
9570
|
+
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
|
|
9571
|
+
];
|
|
9572
|
+
if (this._moduleUniformBuffer !== null) {
|
|
9573
|
+
entries.push({ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } });
|
|
9574
|
+
}
|
|
9575
|
+
let textureBindingIdx = this._moduleUniformBuffer !== null ? 3 : 2;
|
|
9576
|
+
for (const slot of slots) {
|
|
9577
|
+
for (const t of slot.contribution.textures ?? []) {
|
|
9578
|
+
const filterable = t.format !== 'r32float';
|
|
9579
|
+
entries.push({
|
|
9580
|
+
binding: textureBindingIdx++,
|
|
9581
|
+
visibility: GPUShaderStage.COMPUTE,
|
|
9582
|
+
texture: {
|
|
9583
|
+
viewDimension: '1d',
|
|
9584
|
+
sampleType: filterable ? 'float' : 'unfilterable-float',
|
|
9585
|
+
},
|
|
9586
|
+
});
|
|
9587
|
+
entries.push({
|
|
9588
|
+
binding: textureBindingIdx++,
|
|
9589
|
+
visibility: GPUShaderStage.COMPUTE,
|
|
9590
|
+
sampler: { type: filterable ? 'filtering' : 'non-filtering' },
|
|
9591
|
+
});
|
|
9592
|
+
}
|
|
9593
|
+
}
|
|
9594
|
+
return this.device.createBindGroupLayout({ label: 'particle-uniforms-bgl', entries });
|
|
9163
9595
|
}
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
|
|
9167
|
-
|
|
9168
|
-
|
|
9169
|
-
|
|
9170
|
-
|
|
9596
|
+
_buildBindGroup0(layout, slots) {
|
|
9597
|
+
const entries = [
|
|
9598
|
+
{ binding: 0, resource: { buffer: this._simUniformBuffer } },
|
|
9599
|
+
{ binding: 1, resource: { buffer: this._framesUniformBuffer } },
|
|
9600
|
+
];
|
|
9601
|
+
if (this._moduleUniformBuffer !== null) {
|
|
9602
|
+
entries.push({ binding: 2, resource: { buffer: this._moduleUniformBuffer } });
|
|
9603
|
+
}
|
|
9604
|
+
let textureBindingIdx = this._moduleUniformBuffer !== null ? 3 : 2;
|
|
9605
|
+
for (const slot of slots) {
|
|
9606
|
+
for (const t of slot.contribution.textures ?? []) {
|
|
9607
|
+
const tex = this._moduleTextures.get(`${slot.contribution.key}_${t.name}`);
|
|
9608
|
+
const filterable = t.format !== 'r32float';
|
|
9609
|
+
const sampler = filterable ? this._samplerFiltering : this._samplerNonFiltering;
|
|
9610
|
+
entries.push({ binding: textureBindingIdx++, resource: tex.createView({ dimension: '1d' }) });
|
|
9611
|
+
entries.push({ binding: textureBindingIdx++, resource: sampler });
|
|
9612
|
+
}
|
|
9613
|
+
}
|
|
9614
|
+
return this.device.createBindGroup({ label: 'particle-uniforms-bg', layout, entries });
|
|
9615
|
+
}
|
|
9616
|
+
_buildShader(slots) {
|
|
9617
|
+
const sections = [];
|
|
9618
|
+
sections.push(`
|
|
9619
|
+
struct SimUniforms {
|
|
9620
|
+
dt: f32,
|
|
9621
|
+
liveCount: u32,
|
|
9622
|
+
_pad0: u32,
|
|
9623
|
+
_pad1: u32,
|
|
9624
|
+
}
|
|
9625
|
+
|
|
9626
|
+
struct FrameUniforms {
|
|
9627
|
+
frames: array<vec4<f32>, ${this._frameCount}>,
|
|
9628
|
+
}
|
|
9629
|
+
|
|
9630
|
+
@group(0) @binding(0) var<uniform> sim: SimUniforms;
|
|
9631
|
+
@group(0) @binding(1) var<uniform> frameUv: FrameUniforms;
|
|
9632
|
+
`);
|
|
9633
|
+
const moduleStructFields = [];
|
|
9634
|
+
for (const slot of slots) {
|
|
9635
|
+
const c = slot.contribution;
|
|
9636
|
+
const fields = c.uniforms ?? [];
|
|
9637
|
+
if (fields.length === 0) {
|
|
9638
|
+
continue;
|
|
9639
|
+
}
|
|
9640
|
+
sections.push(this._renderModuleStruct(c.key, fields));
|
|
9641
|
+
moduleStructFields.push(`u_${c.key}: ${c.key}Uniforms,`);
|
|
9642
|
+
}
|
|
9643
|
+
if (moduleStructFields.length > 0) {
|
|
9644
|
+
sections.push(`
|
|
9645
|
+
struct ModuleUniforms {
|
|
9646
|
+
${moduleStructFields.map((s) => ` ${s}`).join('\n')}
|
|
9647
|
+
}
|
|
9648
|
+
|
|
9649
|
+
@group(0) @binding(2) var<uniform> modules: ModuleUniforms;
|
|
9650
|
+
`);
|
|
9651
|
+
}
|
|
9652
|
+
let textureBindingIdx = moduleStructFields.length > 0 ? 3 : 2;
|
|
9653
|
+
for (const slot of slots) {
|
|
9654
|
+
for (const t of slot.contribution.textures ?? []) {
|
|
9655
|
+
sections.push(`
|
|
9656
|
+
@group(0) @binding(${textureBindingIdx++}) var u_${slot.contribution.key}_${t.name}: texture_1d<f32>;
|
|
9657
|
+
@group(0) @binding(${textureBindingIdx++}) var u_${slot.contribution.key}_${t.name}_sampler: sampler;
|
|
9658
|
+
`);
|
|
9659
|
+
}
|
|
9660
|
+
}
|
|
9661
|
+
sections.push(`
|
|
9662
|
+
@group(1) @binding(0) var<storage, read_write> positions: array<vec2<f32>>;
|
|
9663
|
+
@group(1) @binding(1) var<storage, read_write> velocities: array<vec2<f32>>;
|
|
9664
|
+
@group(1) @binding(2) var<storage, read_write> scales: array<vec2<f32>>;
|
|
9665
|
+
@group(1) @binding(3) var<storage, read_write> rotInfo: array<vec2<f32>>;
|
|
9666
|
+
@group(1) @binding(4) var<storage, read_write> timing: array<vec2<f32>>;
|
|
9667
|
+
@group(1) @binding(5) var<storage, read_write> color: array<u32>;
|
|
9668
|
+
@group(1) @binding(6) var<storage, read> textureIndex: array<u32>;
|
|
9669
|
+
@group(1) @binding(7) var<storage, read_write> instanceOutput: array<u32>;
|
|
9670
|
+
`);
|
|
9671
|
+
// Module preludes (helper functions/constants). Concatenated in
|
|
9672
|
+
// registration order; modules sharing the same key are emitted only
|
|
9673
|
+
// once (the contribution body strings are still inlined per-instance,
|
|
9674
|
+
// but the prelude function definitions can't be duplicated).
|
|
9675
|
+
const seenPreludeKeys = new Set();
|
|
9676
|
+
for (const slot of slots) {
|
|
9677
|
+
const prelude = slot.contribution.prelude;
|
|
9678
|
+
if (prelude === undefined || prelude.trim() === '')
|
|
9679
|
+
continue;
|
|
9680
|
+
if (seenPreludeKeys.has(slot.contribution.key))
|
|
9681
|
+
continue;
|
|
9682
|
+
seenPreludeKeys.add(slot.contribution.key);
|
|
9683
|
+
sections.push(prelude);
|
|
9684
|
+
}
|
|
9685
|
+
const moduleBodies = slots.map((s) => s.contribution.body).join('\n');
|
|
9686
|
+
const frameCountConst = this._frameCount;
|
|
9687
|
+
sections.push(`
|
|
9688
|
+
@compute @workgroup_size(${workgroupSize})
|
|
9689
|
+
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
9690
|
+
let idx = gid.x;
|
|
9691
|
+
if (idx >= sim.liveCount) { return; }
|
|
9692
|
+
|
|
9693
|
+
let dt = sim.dt;
|
|
9694
|
+
|
|
9695
|
+
// Skip dead particles (lifetime sentinel < 0). Write zero-scale instance
|
|
9696
|
+
// so the renderer doesn't accidentally draw them.
|
|
9697
|
+
if (timing[idx].y < 0.0) {
|
|
9698
|
+
let outBaseDead = idx * 10u;
|
|
9699
|
+
for (var k: u32 = 0u; k < 10u; k++) { instanceOutput[outBaseDead + k] = 0u; }
|
|
9700
|
+
return;
|
|
9171
9701
|
}
|
|
9172
|
-
|
|
9173
|
-
|
|
9174
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
|
|
9179
|
-
|
|
9180
|
-
|
|
9181
|
-
|
|
9182
|
-
|
|
9183
|
-
|
|
9184
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9702
|
+
|
|
9703
|
+
// Integration.
|
|
9704
|
+
positions[idx] = positions[idx] + velocities[idx] * dt;
|
|
9705
|
+
rotInfo[idx].x = rotInfo[idx].x + rotInfo[idx].y * dt;
|
|
9706
|
+
timing[idx].x = timing[idx].x + dt;
|
|
9707
|
+
|
|
9708
|
+
// Module bodies (in registration order).
|
|
9709
|
+
${moduleBodies}
|
|
9710
|
+
|
|
9711
|
+
// Resolve frame UVs.
|
|
9712
|
+
let frameIndex = min(textureIndex[idx], ${frameCountConst}u - 1u);
|
|
9713
|
+
let frameUvBounds = frameUv.frames[frameIndex];
|
|
9714
|
+
|
|
9715
|
+
// Pack interleaved instance data (10 u32s per particle):
|
|
9716
|
+
// x, y, scaleX, scaleY, rotation (f32×5) + color (u32) + uvMin.xy (f32×2) + uvMax.xy (f32×2)
|
|
9717
|
+
let outBase = idx * 10u;
|
|
9718
|
+
instanceOutput[outBase + 0u] = bitcast<u32>(positions[idx].x);
|
|
9719
|
+
instanceOutput[outBase + 1u] = bitcast<u32>(positions[idx].y);
|
|
9720
|
+
instanceOutput[outBase + 2u] = bitcast<u32>(scales[idx].x);
|
|
9721
|
+
instanceOutput[outBase + 3u] = bitcast<u32>(scales[idx].y);
|
|
9722
|
+
instanceOutput[outBase + 4u] = bitcast<u32>(rotInfo[idx].x);
|
|
9723
|
+
instanceOutput[outBase + 5u] = color[idx];
|
|
9724
|
+
instanceOutput[outBase + 6u] = bitcast<u32>(frameUvBounds.x);
|
|
9725
|
+
instanceOutput[outBase + 7u] = bitcast<u32>(frameUvBounds.y);
|
|
9726
|
+
instanceOutput[outBase + 8u] = bitcast<u32>(frameUvBounds.z);
|
|
9727
|
+
instanceOutput[outBase + 9u] = bitcast<u32>(frameUvBounds.w);
|
|
9728
|
+
}
|
|
9729
|
+
`);
|
|
9730
|
+
return sections.join('\n\n');
|
|
9189
9731
|
}
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
* Do not call on individual particles mid-simulation; let the system
|
|
9194
|
-
* recycle them via the graveyard instead.
|
|
9195
|
-
*/
|
|
9196
|
-
destroy() {
|
|
9197
|
-
this._totalLifetime.destroy();
|
|
9198
|
-
this._elapsedLifetime.destroy();
|
|
9199
|
-
this._position.destroy();
|
|
9200
|
-
this._velocity.destroy();
|
|
9201
|
-
this._scale.destroy();
|
|
9202
|
-
this._tint.destroy();
|
|
9732
|
+
_renderModuleStruct(key, fields) {
|
|
9733
|
+
const lines = fields.map((f) => ` ${f.name}: ${f.type},`).join('\n');
|
|
9734
|
+
return `struct ${key}Uniforms {\n${lines}\n}`;
|
|
9203
9735
|
}
|
|
9204
9736
|
}
|
|
9205
9737
|
|
|
9738
|
+
/// <reference types="@webgpu/types" />
|
|
9739
|
+
const defaultCapacity = 4096;
|
|
9740
|
+
/**
|
|
9741
|
+
* Lazily-initialised 1×1 opaque-white texture used as the default sprite
|
|
9742
|
+
* when a {@link ParticleSystem} is constructed without one. Particles
|
|
9743
|
+
* render as solid color quads (the per-particle `color` channel times
|
|
9744
|
+
* white-with-alpha-1). Shared across systems to avoid wasted texture
|
|
9745
|
+
* allocations.
|
|
9746
|
+
*/
|
|
9747
|
+
let defaultWhiteTexture = null;
|
|
9748
|
+
const getDefaultWhiteTexture = () => {
|
|
9749
|
+
if (defaultWhiteTexture === null) {
|
|
9750
|
+
const canvas = document.createElement('canvas');
|
|
9751
|
+
canvas.width = 1;
|
|
9752
|
+
canvas.height = 1;
|
|
9753
|
+
const ctx = canvas.getContext('2d');
|
|
9754
|
+
if (ctx !== null) {
|
|
9755
|
+
ctx.fillStyle = '#ffffff';
|
|
9756
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
9757
|
+
}
|
|
9758
|
+
defaultWhiteTexture = new Texture(canvas);
|
|
9759
|
+
}
|
|
9760
|
+
return defaultWhiteTexture;
|
|
9761
|
+
};
|
|
9206
9762
|
/**
|
|
9207
|
-
* The central coordinator of the particle
|
|
9208
|
-
* {@link Drawable} that owns
|
|
9209
|
-
*
|
|
9210
|
-
*
|
|
9211
|
-
*
|
|
9212
|
-
*
|
|
9213
|
-
*
|
|
9763
|
+
* The central coordinator of the particle pipeline. `ParticleSystem` is a
|
|
9764
|
+
* {@link Drawable} that owns:
|
|
9765
|
+
*
|
|
9766
|
+
* - **SoA particle storage** — one typed array per attribute (position,
|
|
9767
|
+
* velocity, scale, rotation, color, lifetime, ...), sized to a fixed
|
|
9768
|
+
* capacity at construction. User code reads/writes via
|
|
9769
|
+
* `system.posX[slot]`, `system.velX[slot]`, etc.
|
|
9770
|
+
* - **Spawn modules** — write new particles into freshly allocated slots.
|
|
9771
|
+
* - **Update modules** — mutate the live range each frame (forces, color
|
|
9772
|
+
* blends, scale curves, drag, ...). Built-in modules ship both CPU and
|
|
9773
|
+
* WGSL implementations; custom modules can opt into GPU acceleration by
|
|
9774
|
+
* implementing `wgsl()`.
|
|
9775
|
+
* - **Death modules** — fire once per dying particle, before its slot is
|
|
9776
|
+
* recycled (sub-emitters, event hooks).
|
|
9777
|
+
*
|
|
9778
|
+
* **Auto-routing CPU vs GPU:** at first {@link update}, the system checks:
|
|
9779
|
+
* if a `WebGpuBackend` was supplied AND every registered update module has
|
|
9780
|
+
* `wgsl()`, the GPU path engages — a composite compute pipeline runs
|
|
9781
|
+
* integration plus all module bodies in one dispatch and writes directly
|
|
9782
|
+
* into the renderer's instance buffer (no CPU readback). Otherwise the CPU
|
|
9783
|
+
* path runs the existing per-module `apply()` loops.
|
|
9784
|
+
*
|
|
9785
|
+
* **Per-frame order in {@link update} (CPU mode):**
|
|
9786
|
+
* 1. Run every spawn module.
|
|
9787
|
+
* 2. Integrate position from velocity, rotation from rotationSpeed, advance `elapsed`.
|
|
9788
|
+
* 3. Run every update module on the live range.
|
|
9789
|
+
* 4. Compact: scan `[0, liveCount)` forward, fire death modules on expired
|
|
9790
|
+
* slots, copy survivors down. `liveCount` shrinks to the survivor count.
|
|
9791
|
+
*
|
|
9792
|
+
* **Per-frame order in {@link update} (GPU mode):**
|
|
9793
|
+
* 1. Run every spawn module (CPU writes initial values into the spawn slot).
|
|
9794
|
+
* 2. Detect expiries on CPU (via `elapsed >= lifetime`); fire death modules;
|
|
9795
|
+
* set `lifetime[slot] = -1` sentinel + clear `alive[slot]` so the GPU
|
|
9796
|
+
* shader skips them. **No compaction** — slots are recycled on next spawn.
|
|
9797
|
+
* 3. Dispatch the composite compute pipeline. Integration + update modules
|
|
9798
|
+
* + pack-instances run in one pass; the instance buffer is written
|
|
9799
|
+
* directly. CPU SoA stays as-is for spawn writes.
|
|
9800
|
+
*
|
|
9801
|
+
* **Coordinate space:** particle positions are LOCAL to the system. The
|
|
9802
|
+
* system's `getGlobalTransform()` is applied on top during rendering — both
|
|
9803
|
+
* the WebGL2 and WebGPU shaders multiply `projection * translation * rotated`.
|
|
9804
|
+
* Setting world-space positions on individual particles double-translates.
|
|
9805
|
+
* Position the system itself via `system.setPosition(...)` and emit relative
|
|
9806
|
+
* to `(0, 0)`.
|
|
9807
|
+
*
|
|
9808
|
+
* @example
|
|
9809
|
+
* // Backend-agnostic — runs CPU on WebGL2, GPU on WebGPU automatically.
|
|
9810
|
+
* const system = new ParticleSystem(loader.get(Texture, 'spark'), {
|
|
9811
|
+
* capacity: 8192,
|
|
9812
|
+
* backend: app.backend,
|
|
9813
|
+
* });
|
|
9214
9814
|
*
|
|
9215
|
-
*
|
|
9216
|
-
*
|
|
9217
|
-
*
|
|
9218
|
-
*
|
|
9815
|
+
* system.addSpawnModule(new RateSpawn({ rate: new Constant(60), ... }));
|
|
9816
|
+
* system.addUpdateModule(new ApplyForce(0, 980)); // gravity, GPU-eligible
|
|
9817
|
+
* system.addUpdateModule(new ColorOverLifetime(fireGradient));
|
|
9818
|
+
* scene.addChild(system);
|
|
9219
9819
|
*/
|
|
9220
9820
|
class ParticleSystem extends Drawable {
|
|
9221
|
-
|
|
9222
|
-
|
|
9223
|
-
|
|
9224
|
-
|
|
9821
|
+
/** Maximum particle count this system will store. Fixed at construction. */
|
|
9822
|
+
capacity;
|
|
9823
|
+
posX;
|
|
9824
|
+
posY;
|
|
9825
|
+
velX;
|
|
9826
|
+
velY;
|
|
9827
|
+
scaleX;
|
|
9828
|
+
scaleY;
|
|
9829
|
+
rotations;
|
|
9830
|
+
rotationSpeeds;
|
|
9831
|
+
color; // packed 0xAABBGGRR
|
|
9832
|
+
elapsed; // seconds since spawn
|
|
9833
|
+
lifetime; // total seconds before expiry; -1 sentinel for dead in GPU mode
|
|
9834
|
+
textureIndex;
|
|
9835
|
+
/**
|
|
9836
|
+
* Number of currently live particles. In CPU mode this is exact: slots
|
|
9837
|
+
* `[0, liveCount)` are all alive after each `update()`. In GPU mode
|
|
9838
|
+
* this is a high-water mark — slots `[0, liveCount)` may contain dead
|
|
9839
|
+
* holes (filled in by future spawns); use {@link aliveCount} for the
|
|
9840
|
+
* actual alive count.
|
|
9841
|
+
*/
|
|
9842
|
+
liveCount = 0;
|
|
9843
|
+
/**
|
|
9844
|
+
* Per-slot alive flag (1 = alive, 0 = dead). Maintained in both CPU
|
|
9845
|
+
* and GPU mode. Custom modules iterating the live range should check
|
|
9846
|
+
* this to skip dead slots in GPU mode.
|
|
9847
|
+
*/
|
|
9848
|
+
alive;
|
|
9849
|
+
_spawnModules = [];
|
|
9850
|
+
_updateModules = [];
|
|
9851
|
+
_deathModules = [];
|
|
9852
|
+
_backend = null;
|
|
9853
|
+
_device = null;
|
|
9854
|
+
_gpuState = null;
|
|
9855
|
+
_gpuMode = false;
|
|
9856
|
+
_compiled = false;
|
|
9857
|
+
_spawnHint = 0; // round-robin pointer for first-dead lookup in GPU mode
|
|
9858
|
+
/**
|
|
9859
|
+
* In GPU mode, slots whose CPU SoA values need re-uploading to the GPU
|
|
9860
|
+
* (newly spawned, or just-expired with lifetime sentinel). Cleared
|
|
9861
|
+
* after each compute dispatch. CPU never overwrites integrated GPU
|
|
9862
|
+
* state — only dirty slots flow CPU → GPU.
|
|
9863
|
+
*/
|
|
9864
|
+
_gpuDirtySlots = new Set();
|
|
9225
9865
|
_texture;
|
|
9866
|
+
_frames = [];
|
|
9226
9867
|
_textureFrame = new Rectangle();
|
|
9227
9868
|
_vertices = new Float32Array(4);
|
|
9228
9869
|
_texCoords = new Uint32Array(4);
|
|
9229
9870
|
_updateTexCoords = true;
|
|
9230
9871
|
_updateVertices = true;
|
|
9231
|
-
constructor(
|
|
9872
|
+
constructor(arg1, arg2, arg3) {
|
|
9232
9873
|
super();
|
|
9233
|
-
|
|
9874
|
+
// Disambiguate the four valid call shapes via instanceof checks.
|
|
9875
|
+
// The TS overloads above already prevent illegal combinations like
|
|
9876
|
+
// `(texture, sheet)` or `(sheet, frames)` at compile time; this
|
|
9877
|
+
// narrowing only sorts out the legal ones.
|
|
9878
|
+
let texture = null;
|
|
9879
|
+
let frames = null;
|
|
9880
|
+
let options = {};
|
|
9881
|
+
if (arg1 instanceof Texture) {
|
|
9882
|
+
texture = arg1;
|
|
9883
|
+
if (Array.isArray(arg2)) {
|
|
9884
|
+
frames = arg2;
|
|
9885
|
+
options = arg3 ?? {};
|
|
9886
|
+
}
|
|
9887
|
+
else {
|
|
9888
|
+
options = arg2 ?? {};
|
|
9889
|
+
}
|
|
9890
|
+
}
|
|
9891
|
+
else if (arg1 instanceof Spritesheet) {
|
|
9892
|
+
texture = arg1.texture;
|
|
9893
|
+
frames = [...arg1.frames.values()];
|
|
9894
|
+
options = arg2 ?? {};
|
|
9895
|
+
}
|
|
9896
|
+
else {
|
|
9897
|
+
options = arg1 ?? {};
|
|
9898
|
+
}
|
|
9899
|
+
const capacity = options.capacity ?? defaultCapacity;
|
|
9900
|
+
if (capacity <= 0 || !Number.isInteger(capacity)) {
|
|
9901
|
+
throw new Error(`ParticleSystem capacity must be a positive integer (got ${capacity}).`);
|
|
9902
|
+
}
|
|
9903
|
+
this.capacity = capacity;
|
|
9904
|
+
this.posX = new Float32Array(capacity);
|
|
9905
|
+
this.posY = new Float32Array(capacity);
|
|
9906
|
+
this.velX = new Float32Array(capacity);
|
|
9907
|
+
this.velY = new Float32Array(capacity);
|
|
9908
|
+
this.scaleX = new Float32Array(capacity);
|
|
9909
|
+
this.scaleY = new Float32Array(capacity);
|
|
9910
|
+
this.rotations = new Float32Array(capacity);
|
|
9911
|
+
this.rotationSpeeds = new Float32Array(capacity);
|
|
9912
|
+
this.color = new Uint32Array(capacity);
|
|
9913
|
+
this.elapsed = new Float32Array(capacity);
|
|
9914
|
+
this.lifetime = new Float32Array(capacity);
|
|
9915
|
+
this.textureIndex = new Uint16Array(capacity);
|
|
9916
|
+
this.alive = new Uint8Array(capacity);
|
|
9917
|
+
this._device = options.device ?? null;
|
|
9918
|
+
this._texture = texture ?? getDefaultWhiteTexture();
|
|
9919
|
+
if (frames !== null) {
|
|
9920
|
+
for (const frame of frames) {
|
|
9921
|
+
this._frames.push(frame.clone());
|
|
9922
|
+
}
|
|
9923
|
+
}
|
|
9234
9924
|
this.resetTextureFrame();
|
|
9235
9925
|
}
|
|
9236
9926
|
get texture() {
|
|
@@ -9246,11 +9936,17 @@ class ParticleSystem extends Drawable {
|
|
|
9246
9936
|
this.setTextureFrame(frame);
|
|
9247
9937
|
}
|
|
9248
9938
|
/**
|
|
9249
|
-
*
|
|
9250
|
-
*
|
|
9251
|
-
*
|
|
9252
|
-
* sprite relative to its world position.
|
|
9939
|
+
* Atlas frames declared on this system, or empty when the texture is
|
|
9940
|
+
* used as a single frame. Each particle's `textureIndex[i]` selects
|
|
9941
|
+
* an entry from this list; out-of-range indices are clamped to 0.
|
|
9253
9942
|
*/
|
|
9943
|
+
get frames() {
|
|
9944
|
+
return this._frames;
|
|
9945
|
+
}
|
|
9946
|
+
/** `true` when the system declares more than one atlas frame. */
|
|
9947
|
+
get hasAtlas() {
|
|
9948
|
+
return this._frames.length > 1;
|
|
9949
|
+
}
|
|
9254
9950
|
get vertices() {
|
|
9255
9951
|
if (this._updateVertices) {
|
|
9256
9952
|
const { x, y, width, height } = this._textureFrame;
|
|
@@ -9264,12 +9960,6 @@ class ParticleSystem extends Drawable {
|
|
|
9264
9960
|
}
|
|
9265
9961
|
return this._vertices;
|
|
9266
9962
|
}
|
|
9267
|
-
/**
|
|
9268
|
-
* Packed UV coordinates for the current {@link textureFrame} as four
|
|
9269
|
-
* `Uint32` values, each encoding a `(u, v)` pair in the upper/lower 16
|
|
9270
|
-
* bits (normalised to 0–65535). Vertex order respects
|
|
9271
|
-
* {@link Texture.flipY}. Recomputed lazily on texture or frame changes.
|
|
9272
|
-
*/
|
|
9273
9963
|
get texCoords() {
|
|
9274
9964
|
if (this._updateTexCoords) {
|
|
9275
9965
|
const { width, height } = this._texture;
|
|
@@ -9294,27 +9984,32 @@ class ParticleSystem extends Drawable {
|
|
|
9294
9984
|
}
|
|
9295
9985
|
return this._texCoords;
|
|
9296
9986
|
}
|
|
9297
|
-
|
|
9298
|
-
|
|
9987
|
+
/** `true` when the system is running on the GPU compute pipeline. */
|
|
9988
|
+
get gpuMode() {
|
|
9989
|
+
return this._gpuMode;
|
|
9299
9990
|
}
|
|
9300
|
-
|
|
9301
|
-
|
|
9991
|
+
/** GPU-side state, or `null` in CPU mode. */
|
|
9992
|
+
get gpuState() {
|
|
9993
|
+
return this._gpuState;
|
|
9302
9994
|
}
|
|
9303
|
-
|
|
9304
|
-
|
|
9995
|
+
/** Actual count of live particles (slots with `alive[i] === 1`). May differ from `liveCount` in GPU mode. */
|
|
9996
|
+
get aliveCount() {
|
|
9997
|
+
let count = 0;
|
|
9998
|
+
for (let i = 0; i < this.liveCount; i++) {
|
|
9999
|
+
if (this.alive[i])
|
|
10000
|
+
count++;
|
|
10001
|
+
}
|
|
10002
|
+
return count;
|
|
9305
10003
|
}
|
|
9306
|
-
|
|
9307
|
-
|
|
9308
|
-
|
|
9309
|
-
|
|
9310
|
-
|
|
9311
|
-
|
|
9312
|
-
|
|
10004
|
+
get spawnModules() {
|
|
10005
|
+
return this._spawnModules;
|
|
10006
|
+
}
|
|
10007
|
+
get updateModules() {
|
|
10008
|
+
return this._updateModules;
|
|
10009
|
+
}
|
|
10010
|
+
get deathModules() {
|
|
10011
|
+
return this._deathModules;
|
|
9313
10012
|
}
|
|
9314
|
-
/**
|
|
9315
|
-
* Replaces the particle sprite texture and resets the texture frame to
|
|
9316
|
-
* cover the full new texture. No-ops if `texture` is the same instance.
|
|
9317
|
-
*/
|
|
9318
10013
|
setTexture(texture) {
|
|
9319
10014
|
if (this._texture !== texture) {
|
|
9320
10015
|
this._texture = texture;
|
|
@@ -9322,11 +10017,6 @@ class ParticleSystem extends Drawable {
|
|
|
9322
10017
|
}
|
|
9323
10018
|
return this;
|
|
9324
10019
|
}
|
|
9325
|
-
/**
|
|
9326
|
-
* Sets the sub-rectangle of the texture used as the particle sprite,
|
|
9327
|
-
* invalidating cached vertices and UV coordinates and updating the system's
|
|
9328
|
-
* local bounds to match the frame dimensions.
|
|
9329
|
-
*/
|
|
9330
10020
|
setTextureFrame(frame) {
|
|
9331
10021
|
this._textureFrame.copy(frame);
|
|
9332
10022
|
this._updateTexCoords = true;
|
|
@@ -9335,122 +10025,275 @@ class ParticleSystem extends Drawable {
|
|
|
9335
10025
|
this._invalidateBoundsCascade();
|
|
9336
10026
|
return this;
|
|
9337
10027
|
}
|
|
9338
|
-
/** Resets the texture frame to the full dimensions of the current texture. */
|
|
9339
10028
|
resetTextureFrame() {
|
|
9340
10029
|
return this.setTextureFrame(Rectangle.temp.set(0, 0, this._texture.width, this._texture.height));
|
|
9341
10030
|
}
|
|
9342
|
-
|
|
9343
|
-
|
|
9344
|
-
this._emitters.push(emitter);
|
|
10031
|
+
addSpawnModule(mod) {
|
|
10032
|
+
this._spawnModules.push(mod);
|
|
9345
10033
|
return this;
|
|
9346
10034
|
}
|
|
9347
|
-
|
|
9348
|
-
|
|
9349
|
-
|
|
9350
|
-
emitter.destroy();
|
|
10035
|
+
addUpdateModule(mod) {
|
|
10036
|
+
if (this._compiled) {
|
|
10037
|
+
throw new Error('Cannot add update modules after the system has been compiled (first update). Register all modules before the first update().');
|
|
9351
10038
|
}
|
|
9352
|
-
this.
|
|
10039
|
+
this._updateModules.push(mod);
|
|
9353
10040
|
return this;
|
|
9354
10041
|
}
|
|
9355
|
-
|
|
9356
|
-
|
|
9357
|
-
this._affectors.push(affector);
|
|
10042
|
+
addDeathModule(mod) {
|
|
10043
|
+
this._deathModules.push(mod);
|
|
9358
10044
|
return this;
|
|
9359
10045
|
}
|
|
9360
|
-
|
|
9361
|
-
|
|
9362
|
-
|
|
9363
|
-
|
|
9364
|
-
}
|
|
9365
|
-
this._affectors.length = 0;
|
|
10046
|
+
clearSpawnModules() {
|
|
10047
|
+
for (const mod of this._spawnModules)
|
|
10048
|
+
mod.destroy();
|
|
10049
|
+
this._spawnModules.length = 0;
|
|
9366
10050
|
return this;
|
|
9367
10051
|
}
|
|
9368
|
-
|
|
9369
|
-
|
|
9370
|
-
|
|
9371
|
-
|
|
9372
|
-
* {@link emitParticle}.
|
|
9373
|
-
*/
|
|
9374
|
-
requestParticle() {
|
|
9375
|
-
return this._graveyard.pop() || new Particle();
|
|
9376
|
-
}
|
|
9377
|
-
/** Adds a fully-configured `particle` to the live pool. Typically called by emitters. */
|
|
9378
|
-
emitParticle(particle) {
|
|
9379
|
-
this._particles.push(particle);
|
|
10052
|
+
clearUpdateModules() {
|
|
10053
|
+
for (const mod of this._updateModules)
|
|
10054
|
+
mod.destroy();
|
|
10055
|
+
this._updateModules.length = 0;
|
|
9380
10056
|
return this;
|
|
9381
10057
|
}
|
|
9382
|
-
|
|
9383
|
-
|
|
9384
|
-
|
|
9385
|
-
|
|
9386
|
-
* {@link update} before the affector pass.
|
|
9387
|
-
*/
|
|
9388
|
-
updateParticle(particle, delta) {
|
|
9389
|
-
const seconds = delta.seconds;
|
|
9390
|
-
particle.elapsedLifetime.addTime(delta);
|
|
9391
|
-
particle.position.add(seconds * particle.velocity.x, seconds * particle.velocity.y);
|
|
9392
|
-
particle.rotation += (seconds * particle.rotationSpeed);
|
|
10058
|
+
clearDeathModules() {
|
|
10059
|
+
for (const mod of this._deathModules)
|
|
10060
|
+
mod.destroy();
|
|
10061
|
+
this._deathModules.length = 0;
|
|
9393
10062
|
return this;
|
|
9394
10063
|
}
|
|
9395
10064
|
/**
|
|
9396
|
-
*
|
|
9397
|
-
*
|
|
10065
|
+
* Allocates a particle slot and returns its index. Returns `-1` when
|
|
10066
|
+
* the system is at {@link capacity}.
|
|
10067
|
+
*
|
|
10068
|
+
* **CPU mode:** slots are dense in `[0, liveCount)`. `spawn()` returns
|
|
10069
|
+
* the next sequential slot; `liveCount++`.
|
|
10070
|
+
*
|
|
10071
|
+
* **GPU mode:** slots may have dead holes. `spawn()` finds the first
|
|
10072
|
+
* `alive[i] === 0` slot via a round-robin hint pointer (amortised O(1),
|
|
10073
|
+
* worst case O(capacity) on full systems).
|
|
9398
10074
|
*/
|
|
9399
|
-
|
|
9400
|
-
|
|
9401
|
-
|
|
9402
|
-
}
|
|
9403
|
-
for (const particle of this._graveyard) {
|
|
9404
|
-
particle.destroy();
|
|
10075
|
+
spawn() {
|
|
10076
|
+
if (this._gpuMode) {
|
|
10077
|
+
return this._spawnGpu();
|
|
9405
10078
|
}
|
|
9406
|
-
this.
|
|
9407
|
-
|
|
10079
|
+
return this._spawnCpu();
|
|
10080
|
+
}
|
|
10081
|
+
/** Resets the system to zero live particles without destroying it. */
|
|
10082
|
+
clearParticles() {
|
|
10083
|
+
this.liveCount = 0;
|
|
10084
|
+
this._spawnHint = 0;
|
|
10085
|
+
this.alive.fill(0);
|
|
10086
|
+
this.lifetime.fill(0);
|
|
10087
|
+
this.elapsed.fill(0);
|
|
9408
10088
|
return this;
|
|
9409
10089
|
}
|
|
9410
10090
|
/**
|
|
9411
|
-
*
|
|
9412
|
-
*
|
|
9413
|
-
*
|
|
9414
|
-
*
|
|
9415
|
-
* without re-indexing.
|
|
10091
|
+
* Engine-side render hook. Captures the active backend on each call so
|
|
10092
|
+
* the next `update()` can compile a GPU pipeline if the backend turned
|
|
10093
|
+
* out to be `WebGpuBackend`. Re-captures and rebuilds when the backend
|
|
10094
|
+
* reference changes (e.g. after device-loss recovery).
|
|
9416
10095
|
*/
|
|
9417
|
-
|
|
9418
|
-
|
|
9419
|
-
|
|
9420
|
-
|
|
9421
|
-
|
|
9422
|
-
|
|
9423
|
-
for (const emitter of emitters) {
|
|
9424
|
-
emitter.apply(this, delta);
|
|
9425
|
-
}
|
|
9426
|
-
let expireCount = 0;
|
|
9427
|
-
for (let i = len - 1; i >= 0; i--) {
|
|
9428
|
-
this.updateParticle(particles[i], delta);
|
|
9429
|
-
if (particles[i].expired) {
|
|
9430
|
-
graveyard.push(particles[i]);
|
|
9431
|
-
expireCount++;
|
|
9432
|
-
continue;
|
|
9433
|
-
}
|
|
9434
|
-
if (expireCount > 0) {
|
|
9435
|
-
particles.splice(i + 1, expireCount);
|
|
9436
|
-
expireCount = 0;
|
|
9437
|
-
}
|
|
9438
|
-
for (const affector of affectors) {
|
|
9439
|
-
affector.apply(particles[i], delta);
|
|
10096
|
+
render(backend) {
|
|
10097
|
+
if (this._backend !== backend) {
|
|
10098
|
+
this._backend = backend;
|
|
10099
|
+
if (this._gpuState !== null) {
|
|
10100
|
+
this._gpuState.destroy();
|
|
10101
|
+
this._gpuState = null;
|
|
9440
10102
|
}
|
|
10103
|
+
this._gpuMode = false;
|
|
10104
|
+
this._compiled = false;
|
|
10105
|
+
}
|
|
10106
|
+
return super.render(backend);
|
|
10107
|
+
}
|
|
10108
|
+
/** Per-frame entry point. Routes to CPU or GPU pipeline based on auto-detection at first call. */
|
|
10109
|
+
update(delta) {
|
|
10110
|
+
if (!this._compiled) {
|
|
10111
|
+
this._compile();
|
|
9441
10112
|
}
|
|
9442
|
-
|
|
9443
|
-
|
|
10113
|
+
const dt = delta.seconds;
|
|
10114
|
+
// 1. Spawn (CPU writes SoA in both modes).
|
|
10115
|
+
for (let i = 0; i < this._spawnModules.length; i++) {
|
|
10116
|
+
this._spawnModules[i].apply(this, dt);
|
|
10117
|
+
}
|
|
10118
|
+
if (this._gpuMode) {
|
|
10119
|
+
this._updateGpu(dt);
|
|
10120
|
+
}
|
|
10121
|
+
else {
|
|
10122
|
+
this._updateCpu(dt);
|
|
9444
10123
|
}
|
|
9445
10124
|
return this;
|
|
9446
10125
|
}
|
|
9447
10126
|
destroy() {
|
|
9448
10127
|
super.destroy();
|
|
9449
|
-
this.
|
|
9450
|
-
this.
|
|
9451
|
-
this.
|
|
10128
|
+
this.clearSpawnModules();
|
|
10129
|
+
this.clearUpdateModules();
|
|
10130
|
+
this.clearDeathModules();
|
|
10131
|
+
if (this._gpuState !== null) {
|
|
10132
|
+
this._gpuState.destroy();
|
|
10133
|
+
this._gpuState = null;
|
|
10134
|
+
}
|
|
10135
|
+
for (const frame of this._frames) {
|
|
10136
|
+
frame.destroy();
|
|
10137
|
+
}
|
|
10138
|
+
this._frames.length = 0;
|
|
10139
|
+
this._gpuMode = false;
|
|
10140
|
+
this._compiled = false;
|
|
10141
|
+
this.liveCount = 0;
|
|
10142
|
+
this.alive.fill(0);
|
|
9452
10143
|
this._textureFrame.destroy();
|
|
9453
10144
|
}
|
|
10145
|
+
_compile() {
|
|
10146
|
+
this._compiled = true;
|
|
10147
|
+
// Duck-typed `instanceof WebGpuBackend` — avoids importing the
|
|
10148
|
+
// backend class (which registers a renderer for ParticleSystem
|
|
10149
|
+
// and would create a circular dependency). WebGl2Backend has no
|
|
10150
|
+
// `device` field, so this naturally falls back to CPU mode.
|
|
10151
|
+
const backendDevice = this._backend?.device ?? null;
|
|
10152
|
+
const device = this._device ?? backendDevice;
|
|
10153
|
+
if (device === null) {
|
|
10154
|
+
return;
|
|
10155
|
+
}
|
|
10156
|
+
const allEligible = this._updateModules.every((m) => typeof m.wgsl === 'function');
|
|
10157
|
+
if (!allEligible) {
|
|
10158
|
+
return;
|
|
10159
|
+
}
|
|
10160
|
+
this._gpuState = new ParticleGpuState(device, this.capacity, this._updateModules, this._frames, this._texture);
|
|
10161
|
+
this._gpuMode = true;
|
|
10162
|
+
// Mark every currently-alive slot dirty so the initial upload
|
|
10163
|
+
// matches CPU state; subsequent frames only push deltas.
|
|
10164
|
+
for (let i = 0; i < this.liveCount; i++) {
|
|
10165
|
+
if (this.alive[i])
|
|
10166
|
+
this._gpuDirtySlots.add(i);
|
|
10167
|
+
}
|
|
10168
|
+
}
|
|
10169
|
+
_spawnCpu() {
|
|
10170
|
+
if (this.liveCount >= this.capacity) {
|
|
10171
|
+
return -1;
|
|
10172
|
+
}
|
|
10173
|
+
const slot = this.liveCount++;
|
|
10174
|
+
this.alive[slot] = 1;
|
|
10175
|
+
this.elapsed[slot] = 0;
|
|
10176
|
+
return slot;
|
|
10177
|
+
}
|
|
10178
|
+
_spawnGpu() {
|
|
10179
|
+
const capacity = this.capacity;
|
|
10180
|
+
const alive = this.alive;
|
|
10181
|
+
const start = this._spawnHint;
|
|
10182
|
+
// Search forward from hint, then wrap.
|
|
10183
|
+
for (let i = start; i < capacity; i++) {
|
|
10184
|
+
if (alive[i] === 0) {
|
|
10185
|
+
alive[i] = 1;
|
|
10186
|
+
this.elapsed[i] = 0;
|
|
10187
|
+
this._spawnHint = i + 1 === capacity ? 0 : i + 1;
|
|
10188
|
+
if (i >= this.liveCount)
|
|
10189
|
+
this.liveCount = i + 1;
|
|
10190
|
+
this._gpuDirtySlots.add(i);
|
|
10191
|
+
return i;
|
|
10192
|
+
}
|
|
10193
|
+
}
|
|
10194
|
+
for (let i = 0; i < start; i++) {
|
|
10195
|
+
if (alive[i] === 0) {
|
|
10196
|
+
alive[i] = 1;
|
|
10197
|
+
this.elapsed[i] = 0;
|
|
10198
|
+
this._spawnHint = i + 1;
|
|
10199
|
+
if (i >= this.liveCount)
|
|
10200
|
+
this.liveCount = i + 1;
|
|
10201
|
+
this._gpuDirtySlots.add(i);
|
|
10202
|
+
return i;
|
|
10203
|
+
}
|
|
10204
|
+
}
|
|
10205
|
+
return -1;
|
|
10206
|
+
}
|
|
10207
|
+
_updateCpu(dt) {
|
|
10208
|
+
const { posX, posY, velX, velY, rotations, rotationSpeeds, elapsed } = this;
|
|
10209
|
+
const liveCount = this.liveCount;
|
|
10210
|
+
for (let i = 0; i < liveCount; i++) {
|
|
10211
|
+
posX[i] += velX[i] * dt;
|
|
10212
|
+
posY[i] += velY[i] * dt;
|
|
10213
|
+
rotations[i] += rotationSpeeds[i] * dt;
|
|
10214
|
+
elapsed[i] += dt;
|
|
10215
|
+
}
|
|
10216
|
+
for (let i = 0; i < this._updateModules.length; i++) {
|
|
10217
|
+
this._updateModules[i].apply(this, dt);
|
|
10218
|
+
}
|
|
10219
|
+
// Compact: forward pass, fire death modules on expired, copy survivors down.
|
|
10220
|
+
const lifetime = this.lifetime;
|
|
10221
|
+
const alive = this.alive;
|
|
10222
|
+
const deathModules = this._deathModules;
|
|
10223
|
+
let writeIndex = 0;
|
|
10224
|
+
for (let readIndex = 0; readIndex < this.liveCount; readIndex++) {
|
|
10225
|
+
if (elapsed[readIndex] >= lifetime[readIndex]) {
|
|
10226
|
+
for (let m = 0; m < deathModules.length; m++) {
|
|
10227
|
+
deathModules[m].onDeath(this, readIndex);
|
|
10228
|
+
}
|
|
10229
|
+
alive[readIndex] = 0;
|
|
10230
|
+
continue;
|
|
10231
|
+
}
|
|
10232
|
+
if (writeIndex !== readIndex) {
|
|
10233
|
+
this._copySlot(readIndex, writeIndex);
|
|
10234
|
+
alive[writeIndex] = 1;
|
|
10235
|
+
}
|
|
10236
|
+
writeIndex++;
|
|
10237
|
+
}
|
|
10238
|
+
for (let i = writeIndex; i < this.liveCount; i++) {
|
|
10239
|
+
alive[i] = 0;
|
|
10240
|
+
}
|
|
10241
|
+
this.liveCount = writeIndex;
|
|
10242
|
+
}
|
|
10243
|
+
_updateGpu(dt) {
|
|
10244
|
+
// CPU advances its own copy of `elapsed` for expire detection only.
|
|
10245
|
+
// GPU's `timing[idx].x` is advanced independently inside the compute
|
|
10246
|
+
// shader; the two are never synced after spawn. They tick at the
|
|
10247
|
+
// same rate (both add `dt` per frame) so they stay equivalent in
|
|
10248
|
+
// practice (modulo numerical drift).
|
|
10249
|
+
const elapsed = this.elapsed;
|
|
10250
|
+
const lifetime = this.lifetime;
|
|
10251
|
+
const alive = this.alive;
|
|
10252
|
+
const deathModules = this._deathModules;
|
|
10253
|
+
const liveCount = this.liveCount;
|
|
10254
|
+
for (let i = 0; i < liveCount; i++) {
|
|
10255
|
+
if (alive[i] === 0)
|
|
10256
|
+
continue;
|
|
10257
|
+
elapsed[i] += dt;
|
|
10258
|
+
if (elapsed[i] >= lifetime[i]) {
|
|
10259
|
+
for (let m = 0; m < deathModules.length; m++) {
|
|
10260
|
+
deathModules[m].onDeath(this, i);
|
|
10261
|
+
}
|
|
10262
|
+
alive[i] = 0;
|
|
10263
|
+
lifetime[i] = -1; // sentinel — GPU shader skips
|
|
10264
|
+
this._gpuDirtySlots.add(i); // upload the sentinel so GPU sees the death
|
|
10265
|
+
}
|
|
10266
|
+
}
|
|
10267
|
+
// Trim trailing dead slots.
|
|
10268
|
+
let newLiveCount = this.liveCount;
|
|
10269
|
+
while (newLiveCount > 0 && alive[newLiveCount - 1] === 0) {
|
|
10270
|
+
newLiveCount--;
|
|
10271
|
+
}
|
|
10272
|
+
this.liveCount = newLiveCount;
|
|
10273
|
+
// Push dirty slots (new spawns + just-expired) to GPU. CPU is NOT
|
|
10274
|
+
// the source of truth for integrated position/velocity/etc. after
|
|
10275
|
+
// spawn — uploading the full live range every frame would wipe
|
|
10276
|
+
// out GPU's integrated state.
|
|
10277
|
+
if (this._gpuDirtySlots.size > 0) {
|
|
10278
|
+
this._gpuState.uploadDirty(this, this._gpuDirtySlots);
|
|
10279
|
+
this._gpuDirtySlots.clear();
|
|
10280
|
+
}
|
|
10281
|
+
this._gpuState.dispatch(this, dt);
|
|
10282
|
+
}
|
|
10283
|
+
_copySlot(from, to) {
|
|
10284
|
+
this.posX[to] = this.posX[from];
|
|
10285
|
+
this.posY[to] = this.posY[from];
|
|
10286
|
+
this.velX[to] = this.velX[from];
|
|
10287
|
+
this.velY[to] = this.velY[from];
|
|
10288
|
+
this.scaleX[to] = this.scaleX[from];
|
|
10289
|
+
this.scaleY[to] = this.scaleY[from];
|
|
10290
|
+
this.rotations[to] = this.rotations[from];
|
|
10291
|
+
this.rotationSpeeds[to] = this.rotationSpeeds[from];
|
|
10292
|
+
this.color[to] = this.color[from];
|
|
10293
|
+
this.elapsed[to] = this.elapsed[from];
|
|
10294
|
+
this.lifetime[to] = this.lifetime[from];
|
|
10295
|
+
this.textureIndex[to] = this.textureIndex[from];
|
|
10296
|
+
}
|
|
9454
10297
|
}
|
|
9455
10298
|
|
|
9456
10299
|
/**
|
|
@@ -11306,13 +12149,15 @@ var particleTexture: texture_2d<f32>;
|
|
|
11306
12149
|
@group(1) @binding(1)
|
|
11307
12150
|
var particleSampler: sampler;
|
|
11308
12151
|
|
|
11309
|
-
// Per-instance attributes (one entry per particle,
|
|
12152
|
+
// Per-instance attributes (one entry per particle, 40 bytes total).
|
|
11310
12153
|
struct VertexInput {
|
|
11311
12154
|
@location(0) unitPosition: vec2<f32>, // per-vertex (static unit quad)
|
|
11312
12155
|
@location(1) translation: vec2<f32>,
|
|
11313
12156
|
@location(2) scale: vec2<f32>,
|
|
11314
12157
|
@location(3) rotation: f32,
|
|
11315
12158
|
@location(4) color: vec4<f32>,
|
|
12159
|
+
@location(5) uvMin: vec2<f32>, // pre-resolved frame UV (top-left)
|
|
12160
|
+
@location(6) uvMax: vec2<f32>, // pre-resolved frame UV (bottom-right)
|
|
11316
12161
|
};
|
|
11317
12162
|
|
|
11318
12163
|
struct VertexOutput {
|
|
@@ -11325,8 +12170,6 @@ struct VertexOutput {
|
|
|
11325
12170
|
fn vertexMain(input: VertexInput) -> VertexOutput {
|
|
11326
12171
|
let quadMin = uniforms.localBounds.xy;
|
|
11327
12172
|
let quadSize = uniforms.localBounds.zw;
|
|
11328
|
-
let uvMin = uniforms.uvBounds.xy;
|
|
11329
|
-
let uvMax = uniforms.uvBounds.zw;
|
|
11330
12173
|
|
|
11331
12174
|
let localPosition = quadMin + (input.unitPosition * quadSize);
|
|
11332
12175
|
let radians = radians(input.rotation);
|
|
@@ -11340,7 +12183,7 @@ fn vertexMain(input: VertexInput) -> VertexOutput {
|
|
|
11340
12183
|
var output: VertexOutput;
|
|
11341
12184
|
|
|
11342
12185
|
output.position = uniforms.projection * uniforms.translation * vec4<f32>(rotated, 0.0, 1.0);
|
|
11343
|
-
output.texcoord = uvMin + ((uvMax - uvMin) * input.unitPosition);
|
|
12186
|
+
output.texcoord = input.uvMin + ((input.uvMax - input.uvMin) * input.unitPosition);
|
|
11344
12187
|
output.color = vec4(input.color.rgb * input.color.a, input.color.a);
|
|
11345
12188
|
|
|
11346
12189
|
return output;
|
|
@@ -11355,8 +12198,8 @@ fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
|
|
|
11355
12198
|
}
|
|
11356
12199
|
`;
|
|
11357
12200
|
const staticVertexStrideBytes = 8;
|
|
11358
|
-
const instanceWords =
|
|
11359
|
-
const instanceStrideBytes =
|
|
12201
|
+
const instanceWords = 10;
|
|
12202
|
+
const instanceStrideBytes = 40;
|
|
11360
12203
|
const indicesPerParticle = 6;
|
|
11361
12204
|
const uniformByteLength = 176;
|
|
11362
12205
|
const initialParticleCapacity = 1;
|
|
@@ -11397,7 +12240,7 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
|
|
|
11397
12240
|
|| texture.source === null
|
|
11398
12241
|
|| texture.width === 0
|
|
11399
12242
|
|| texture.height === 0
|
|
11400
|
-
|| system.
|
|
12243
|
+
|| system.liveCount === 0) {
|
|
11401
12244
|
return;
|
|
11402
12245
|
}
|
|
11403
12246
|
backend.setBlendMode(system.blendMode);
|
|
@@ -11459,7 +12302,7 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
|
|
|
11459
12302
|
for (let drawCallIndex = 0; drawCallIndex < this._drawCallCount; drawCallIndex++) {
|
|
11460
12303
|
const drawCall = this._drawCalls[drawCallIndex];
|
|
11461
12304
|
const system = drawCall.system;
|
|
11462
|
-
const particleCount = system.
|
|
12305
|
+
const particleCount = system.liveCount;
|
|
11463
12306
|
if (particleCount === 0) {
|
|
11464
12307
|
continue;
|
|
11465
12308
|
}
|
|
@@ -11475,10 +12318,21 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
|
|
|
11475
12318
|
resource: textureBinding.sampler,
|
|
11476
12319
|
}],
|
|
11477
12320
|
});
|
|
11478
|
-
this._ensureCapacity(particleCount);
|
|
11479
|
-
this._writeInstanceData(system.vertices, system.texCoords, system.particles);
|
|
11480
12321
|
this._writeUniformData(backend, system, drawCall.texture);
|
|
11481
|
-
|
|
12322
|
+
// GPU mode: the system's compute pipeline already wrote the
|
|
12323
|
+
// interleaved instance data into its own buffer. Bind it
|
|
12324
|
+
// directly — no CPU pack, no writeBuffer for instance data.
|
|
12325
|
+
// CPU mode: pack from CPU SoA into our owned instance buffer.
|
|
12326
|
+
let drawInstanceCount = particleCount;
|
|
12327
|
+
const instanceBuffer = (() => {
|
|
12328
|
+
if (system.gpuMode && system.gpuState !== null) {
|
|
12329
|
+
return system.gpuState.instanceBuffer;
|
|
12330
|
+
}
|
|
12331
|
+
this._ensureCapacity(particleCount);
|
|
12332
|
+
drawInstanceCount = this._writeInstanceData(system);
|
|
12333
|
+
device.queue.writeBuffer(this._instanceBuffer, 0, this._instanceData, 0, drawInstanceCount * instanceStrideBytes);
|
|
12334
|
+
return this._instanceBuffer;
|
|
12335
|
+
})();
|
|
11482
12336
|
device.queue.writeBuffer(uniformBuffer, 0, this._uniformData.buffer, this._uniformData.byteOffset, this._uniformData.byteLength);
|
|
11483
12337
|
const encoder = device.createCommandEncoder();
|
|
11484
12338
|
const pass = encoder.beginRenderPass({
|
|
@@ -11492,9 +12346,9 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
|
|
|
11492
12346
|
pass.setPipeline(pipeline);
|
|
11493
12347
|
pass.setBindGroup(1, textureBindGroup);
|
|
11494
12348
|
pass.setVertexBuffer(0, staticVertexBuffer);
|
|
11495
|
-
pass.setVertexBuffer(1,
|
|
12349
|
+
pass.setVertexBuffer(1, instanceBuffer);
|
|
11496
12350
|
pass.setIndexBuffer(indexBuffer, 'uint16');
|
|
11497
|
-
pass.drawIndexed(indicesPerParticle,
|
|
12351
|
+
pass.drawIndexed(indicesPerParticle, drawInstanceCount, 0, 0, 0);
|
|
11498
12352
|
backend.stats.batches++;
|
|
11499
12353
|
backend.stats.drawCalls++;
|
|
11500
12354
|
pass.end();
|
|
@@ -11637,17 +12491,82 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
|
|
|
11637
12491
|
uvMinX, uvMinY, uvMaxX, uvMaxY,
|
|
11638
12492
|
]);
|
|
11639
12493
|
}
|
|
11640
|
-
_writeInstanceData(
|
|
11641
|
-
|
|
11642
|
-
|
|
11643
|
-
|
|
11644
|
-
|
|
11645
|
-
|
|
11646
|
-
|
|
11647
|
-
|
|
11648
|
-
|
|
11649
|
-
|
|
11650
|
-
|
|
12494
|
+
_writeInstanceData(system) {
|
|
12495
|
+
const { posX, posY, scaleX, scaleY, rotations, color, textureIndex, alive, liveCount } = system;
|
|
12496
|
+
const f32 = this._float32View;
|
|
12497
|
+
const u32 = this._uint32View;
|
|
12498
|
+
const { uvMins, uvMaxs } = this._computeFrameUvs(system);
|
|
12499
|
+
const frameCount = uvMins.length / 2;
|
|
12500
|
+
let writeIndex = 0;
|
|
12501
|
+
for (let particleIndex = 0; particleIndex < liveCount; particleIndex++) {
|
|
12502
|
+
// Skip dead slots — present in GPU-mode systems where the live
|
|
12503
|
+
// range can carry holes filled in on next spawn.
|
|
12504
|
+
if (alive[particleIndex] === 0) {
|
|
12505
|
+
continue;
|
|
12506
|
+
}
|
|
12507
|
+
const targetIndex = writeIndex * instanceWords;
|
|
12508
|
+
const frame = textureIndex[particleIndex] < frameCount ? textureIndex[particleIndex] : 0;
|
|
12509
|
+
const uvBase = frame * 2;
|
|
12510
|
+
f32[targetIndex + 0] = posX[particleIndex];
|
|
12511
|
+
f32[targetIndex + 1] = posY[particleIndex];
|
|
12512
|
+
f32[targetIndex + 2] = scaleX[particleIndex];
|
|
12513
|
+
f32[targetIndex + 3] = scaleY[particleIndex];
|
|
12514
|
+
f32[targetIndex + 4] = rotations[particleIndex];
|
|
12515
|
+
u32[targetIndex + 5] = color[particleIndex];
|
|
12516
|
+
f32[targetIndex + 6] = uvMins[uvBase + 0];
|
|
12517
|
+
f32[targetIndex + 7] = uvMins[uvBase + 1];
|
|
12518
|
+
f32[targetIndex + 8] = uvMaxs[uvBase + 0];
|
|
12519
|
+
f32[targetIndex + 9] = uvMaxs[uvBase + 1];
|
|
12520
|
+
writeIndex++;
|
|
12521
|
+
}
|
|
12522
|
+
return writeIndex;
|
|
12523
|
+
}
|
|
12524
|
+
/**
|
|
12525
|
+
* Same atlas/UV-resolution as the WebGL2 path. Returns the per-frame
|
|
12526
|
+
* (uvMin, uvMax) pairs derived from `system.frames` (or fallback to
|
|
12527
|
+
* `system.textureFrame` when no atlas is declared), already flipY-
|
|
12528
|
+
* adjusted for the current texture.
|
|
12529
|
+
*/
|
|
12530
|
+
_uvMinsScratch = new Float32Array(2);
|
|
12531
|
+
_uvMaxsScratch = new Float32Array(2);
|
|
12532
|
+
_computeFrameUvs(system) {
|
|
12533
|
+
const frames = system.frames;
|
|
12534
|
+
const tex = system.texture;
|
|
12535
|
+
const texW = tex.width;
|
|
12536
|
+
const texH = tex.height;
|
|
12537
|
+
const flipY = tex.flipY;
|
|
12538
|
+
const count = frames.length === 0 ? 1 : frames.length;
|
|
12539
|
+
if (this._uvMinsScratch.length < count * 2) {
|
|
12540
|
+
this._uvMinsScratch = new Float32Array(count * 2);
|
|
12541
|
+
this._uvMaxsScratch = new Float32Array(count * 2);
|
|
12542
|
+
}
|
|
12543
|
+
const mins = this._uvMinsScratch;
|
|
12544
|
+
const maxs = this._uvMaxsScratch;
|
|
12545
|
+
if (frames.length === 0) {
|
|
12546
|
+
const f = system.textureFrame;
|
|
12547
|
+
const minU = f.left / texW;
|
|
12548
|
+
const maxU = f.right / texW;
|
|
12549
|
+
const topV = f.top / texH;
|
|
12550
|
+
const bottomV = f.bottom / texH;
|
|
12551
|
+
mins[0] = minU;
|
|
12552
|
+
mins[1] = flipY ? bottomV : topV;
|
|
12553
|
+
maxs[0] = maxU;
|
|
12554
|
+
maxs[1] = flipY ? topV : bottomV;
|
|
12555
|
+
return { uvMins: mins, uvMaxs: maxs };
|
|
12556
|
+
}
|
|
12557
|
+
for (let i = 0; i < frames.length; i++) {
|
|
12558
|
+
const f = frames[i];
|
|
12559
|
+
const o = i * 2;
|
|
12560
|
+
const minU = f.left / texW;
|
|
12561
|
+
const maxU = f.right / texW;
|
|
12562
|
+
const topV = f.top / texH;
|
|
12563
|
+
const bottomV = f.bottom / texH;
|
|
12564
|
+
mins[o + 0] = minU;
|
|
12565
|
+
mins[o + 1] = flipY ? bottomV : topV;
|
|
12566
|
+
maxs[o + 0] = maxU;
|
|
12567
|
+
maxs[o + 1] = flipY ? topV : bottomV;
|
|
12568
|
+
}
|
|
12569
|
+
return { uvMins: mins, uvMaxs: maxs };
|
|
11651
12570
|
}
|
|
11652
12571
|
_getPipeline(blendMode, format) {
|
|
11653
12572
|
const pipelineKey = `${blendMode}:${format}`;
|
|
@@ -11686,6 +12605,14 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
|
|
|
11686
12605
|
shaderLocation: 4,
|
|
11687
12606
|
offset: 20,
|
|
11688
12607
|
format: 'unorm8x4',
|
|
12608
|
+
}, {
|
|
12609
|
+
shaderLocation: 5,
|
|
12610
|
+
offset: 24,
|
|
12611
|
+
format: 'float32x2',
|
|
12612
|
+
}, {
|
|
12613
|
+
shaderLocation: 6,
|
|
12614
|
+
offset: 32,
|
|
12615
|
+
format: 'float32x2',
|
|
11689
12616
|
}],
|
|
11690
12617
|
}],
|
|
11691
12618
|
},
|
|
@@ -24309,7 +25236,7 @@ const buildRectangle = (x, y, width, height) => {
|
|
|
24309
25236
|
const buildStar = (centerX, centerY, points, radius, innerRadius = radius / 2, rotation = 0) => {
|
|
24310
25237
|
const startAngle = (Math.PI / -2) + rotation;
|
|
24311
25238
|
const length = points * 2;
|
|
24312
|
-
const delta = tau / length;
|
|
25239
|
+
const delta = tau$2 / length;
|
|
24313
25240
|
const path = [];
|
|
24314
25241
|
for (let i = 0; i < length; i++) {
|
|
24315
25242
|
const angle = startAngle + (i * delta);
|
|
@@ -25381,347 +26308,1438 @@ function* substepSweep(fromX, fromY, deltaX, deltaY, maxStepSize) {
|
|
|
25381
26308
|
}
|
|
25382
26309
|
|
|
25383
26310
|
/**
|
|
25384
|
-
*
|
|
25385
|
-
*
|
|
25386
|
-
*
|
|
25387
|
-
*
|
|
25388
|
-
*
|
|
26311
|
+
* Distribution that always returns the same value. Implements both
|
|
26312
|
+
* {@link Distribution} and {@link LifetimeFunction} so it can stand in
|
|
26313
|
+
* wherever a randomized or time-parameterised value is expected.
|
|
26314
|
+
*
|
|
26315
|
+
* For mutable types (Vector, Color, ...) the constant value is copied into
|
|
26316
|
+
* `out` if provided, otherwise the constructor's value is returned directly.
|
|
26317
|
+
* Mutating that returned reference mutates the distribution's source.
|
|
25389
26318
|
*/
|
|
25390
|
-
class
|
|
25391
|
-
|
|
25392
|
-
|
|
25393
|
-
|
|
25394
|
-
this._fromColor = fromColor.clone();
|
|
25395
|
-
this._toColor = toColor.clone();
|
|
25396
|
-
}
|
|
25397
|
-
get fromColor() {
|
|
25398
|
-
return this._fromColor;
|
|
26319
|
+
class Constant {
|
|
26320
|
+
value;
|
|
26321
|
+
constructor(value) {
|
|
26322
|
+
this.value = value;
|
|
25399
26323
|
}
|
|
25400
|
-
|
|
25401
|
-
this.
|
|
26324
|
+
sample(out) {
|
|
26325
|
+
return this._copyOrReturn(out);
|
|
25402
26326
|
}
|
|
25403
|
-
|
|
25404
|
-
return this.
|
|
26327
|
+
evaluate(_t, out) {
|
|
26328
|
+
return this._copyOrReturn(out);
|
|
25405
26329
|
}
|
|
25406
|
-
|
|
25407
|
-
|
|
25408
|
-
|
|
25409
|
-
|
|
25410
|
-
|
|
25411
|
-
|
|
25412
|
-
|
|
25413
|
-
|
|
25414
|
-
|
|
25415
|
-
return this;
|
|
25416
|
-
}
|
|
25417
|
-
/**
|
|
25418
|
-
* Sets `particle.tint` to the RGBA value interpolated between
|
|
25419
|
-
* {@link fromColor} and {@link toColor} at the particle's current
|
|
25420
|
-
* {@link Particle.elapsedRatio}. `delta` is unused but required by the
|
|
25421
|
-
* {@link ParticleAffector} contract.
|
|
25422
|
-
*/
|
|
25423
|
-
apply(particle, delta) {
|
|
25424
|
-
const ratio = particle.elapsedRatio;
|
|
25425
|
-
const { r: r1, g: g1, b: b1, a: a1 } = this._fromColor;
|
|
25426
|
-
const { r: r2, g: g2, b: b2, a: a2 } = this._toColor;
|
|
25427
|
-
particle.tint.set(((r2 - r1) * ratio) + r1, ((g2 - g1) * ratio) + g1, ((b2 - b1) * ratio) + b1, ((a2 - a1) * ratio) + a1);
|
|
25428
|
-
return this;
|
|
25429
|
-
}
|
|
25430
|
-
destroy() {
|
|
25431
|
-
this._fromColor.destroy();
|
|
25432
|
-
this._toColor.destroy();
|
|
26330
|
+
_copyOrReturn(out) {
|
|
26331
|
+
if (out === undefined || out === null) {
|
|
26332
|
+
return this.value;
|
|
26333
|
+
}
|
|
26334
|
+
const target = out;
|
|
26335
|
+
if (typeof target.copy === 'function') {
|
|
26336
|
+
target.copy(this.value);
|
|
26337
|
+
return out;
|
|
26338
|
+
}
|
|
26339
|
+
return this.value;
|
|
25433
26340
|
}
|
|
25434
26341
|
}
|
|
25435
26342
|
|
|
25436
26343
|
/**
|
|
25437
|
-
*
|
|
25438
|
-
*
|
|
25439
|
-
*
|
|
25440
|
-
* the system then integrates position from velocity in
|
|
25441
|
-
* {@link ParticleSystem.updateParticle}.
|
|
26344
|
+
* Uniform random number in `[min, max]`. Each `sample()` returns a fresh
|
|
26345
|
+
* roll; the bounds are inclusive on both ends (modulo the rounding bias
|
|
26346
|
+
* inherent to `Math.random()`).
|
|
25442
26347
|
*/
|
|
25443
|
-
class
|
|
25444
|
-
|
|
25445
|
-
|
|
25446
|
-
|
|
25447
|
-
|
|
25448
|
-
|
|
25449
|
-
return this._acceleration;
|
|
25450
|
-
}
|
|
25451
|
-
set acceleration(acceleration) {
|
|
25452
|
-
this.setAcceleration(acceleration);
|
|
25453
|
-
}
|
|
25454
|
-
setAcceleration(acceleration) {
|
|
25455
|
-
this._acceleration.copy(acceleration);
|
|
25456
|
-
return this;
|
|
25457
|
-
}
|
|
25458
|
-
/**
|
|
25459
|
-
* Adds `acceleration * delta.seconds` to `particle.velocity`, implementing
|
|
25460
|
-
* Euler integration for the configured force vector.
|
|
25461
|
-
*/
|
|
25462
|
-
apply(particle, delta) {
|
|
25463
|
-
particle.velocity.add(delta.seconds * this._acceleration.x, delta.seconds * this._acceleration.y);
|
|
25464
|
-
return this;
|
|
26348
|
+
class Range {
|
|
26349
|
+
min;
|
|
26350
|
+
max;
|
|
26351
|
+
constructor(min, max) {
|
|
26352
|
+
this.min = min;
|
|
26353
|
+
this.max = max;
|
|
25465
26354
|
}
|
|
25466
|
-
|
|
25467
|
-
this.
|
|
26355
|
+
sample() {
|
|
26356
|
+
return this.min + Math.random() * (this.max - this.min);
|
|
25468
26357
|
}
|
|
25469
26358
|
}
|
|
25470
26359
|
|
|
25471
26360
|
/**
|
|
25472
|
-
*
|
|
25473
|
-
*
|
|
25474
|
-
*
|
|
25475
|
-
*
|
|
26361
|
+
* Uniform random vector with each axis sampled independently in its own
|
|
26362
|
+
* `[min, max]` range. Each `sample()` writes into the provided `out` Vector
|
|
26363
|
+
* (or an internal scratch instance when `out` is omitted).
|
|
26364
|
+
*
|
|
26365
|
+
* @example
|
|
26366
|
+
* const knockback = new VectorRange(-300, 300, -800, -200); // any X, upward Y
|
|
26367
|
+
* knockback.sample(particle.velocity); // writes into existing instance, no alloc
|
|
25476
26368
|
*/
|
|
25477
|
-
class
|
|
25478
|
-
|
|
25479
|
-
|
|
25480
|
-
|
|
25481
|
-
|
|
25482
|
-
|
|
25483
|
-
|
|
25484
|
-
|
|
25485
|
-
|
|
25486
|
-
this.
|
|
25487
|
-
|
|
25488
|
-
|
|
25489
|
-
|
|
25490
|
-
|
|
25491
|
-
|
|
25492
|
-
/**
|
|
25493
|
-
* Adds `scaleFactor * delta.seconds` to `particle.scale` on both axes,
|
|
25494
|
-
* implementing linear scale drift for the configured rate.
|
|
25495
|
-
*/
|
|
25496
|
-
apply(particle, delta) {
|
|
25497
|
-
particle.scale.add(delta.seconds * this._scaleFactor.x, delta.seconds * this._scaleFactor.y);
|
|
25498
|
-
return this;
|
|
25499
|
-
}
|
|
25500
|
-
destroy() {
|
|
25501
|
-
this._scaleFactor.destroy();
|
|
26369
|
+
class VectorRange {
|
|
26370
|
+
minX;
|
|
26371
|
+
maxX;
|
|
26372
|
+
minY;
|
|
26373
|
+
maxY;
|
|
26374
|
+
_scratch = new Vector();
|
|
26375
|
+
constructor(minX, maxX, minY, maxY) {
|
|
26376
|
+
this.minX = minX;
|
|
26377
|
+
this.maxX = maxX;
|
|
26378
|
+
this.minY = minY;
|
|
26379
|
+
this.maxY = maxY;
|
|
26380
|
+
}
|
|
26381
|
+
sample(out = this._scratch) {
|
|
26382
|
+
out.set(this.minX + Math.random() * (this.maxX - this.minX), this.minY + Math.random() * (this.maxY - this.minY));
|
|
26383
|
+
return out;
|
|
25502
26384
|
}
|
|
25503
26385
|
}
|
|
25504
26386
|
|
|
26387
|
+
const tau$1 = Math.PI * 2;
|
|
25505
26388
|
/**
|
|
25506
|
-
*
|
|
25507
|
-
*
|
|
25508
|
-
* The
|
|
25509
|
-
*
|
|
25510
|
-
*
|
|
26389
|
+
* Random unit vector inside a cone, scaled by a speed magnitude.
|
|
26390
|
+
*
|
|
26391
|
+
* The cone is centred on `directionAngle` (in radians, 0 = +X, π/2 = +Y for
|
|
26392
|
+
* screen-down coordinates) and spans `±halfAngle` radians around it. Speed
|
|
26393
|
+
* is sampled uniformly in `[minSpeed, maxSpeed]`.
|
|
26394
|
+
*
|
|
26395
|
+
* Use for emission cones, explosions, fountain spread — anywhere the
|
|
26396
|
+
* direction has a preferred axis with bounded variance.
|
|
26397
|
+
*
|
|
26398
|
+
* @example
|
|
26399
|
+
* // Upward fountain with ±15° spread, 200-300 px/s:
|
|
26400
|
+
* const up = new ConeDirection(-Math.PI / 2, Math.PI / 12, 200, 300);
|
|
26401
|
+
* up.sample(particle.velocity);
|
|
25511
26402
|
*/
|
|
25512
|
-
class
|
|
25513
|
-
|
|
25514
|
-
|
|
25515
|
-
|
|
25516
|
-
|
|
25517
|
-
|
|
25518
|
-
|
|
25519
|
-
|
|
25520
|
-
|
|
25521
|
-
this.
|
|
25522
|
-
|
|
25523
|
-
|
|
25524
|
-
|
|
25525
|
-
|
|
25526
|
-
|
|
25527
|
-
|
|
25528
|
-
|
|
25529
|
-
|
|
25530
|
-
|
|
25531
|
-
|
|
25532
|
-
|
|
25533
|
-
return this;
|
|
25534
|
-
}
|
|
25535
|
-
destroy() {
|
|
25536
|
-
// no-op — pure value class, kept for Destroyable interface conformance
|
|
26403
|
+
class ConeDirection {
|
|
26404
|
+
directionAngle;
|
|
26405
|
+
halfAngle;
|
|
26406
|
+
minSpeed;
|
|
26407
|
+
maxSpeed;
|
|
26408
|
+
_scratch = new Vector();
|
|
26409
|
+
constructor(directionAngle, halfAngle, minSpeed, maxSpeed) {
|
|
26410
|
+
this.directionAngle = directionAngle;
|
|
26411
|
+
this.halfAngle = halfAngle;
|
|
26412
|
+
this.minSpeed = minSpeed;
|
|
26413
|
+
this.maxSpeed = maxSpeed;
|
|
26414
|
+
}
|
|
26415
|
+
sample(out = this._scratch) {
|
|
26416
|
+
const angle = this.directionAngle + (Math.random() * 2 - 1) * this.halfAngle;
|
|
26417
|
+
const speed = this.minSpeed + Math.random() * (this.maxSpeed - this.minSpeed);
|
|
26418
|
+
out.set(Math.cos(angle) * speed, Math.sin(angle) * speed);
|
|
26419
|
+
return out;
|
|
26420
|
+
}
|
|
26421
|
+
/** Convenience: omnidirectional emission (full circle, 0..2π). */
|
|
26422
|
+
static omni(minSpeed, maxSpeed) {
|
|
26423
|
+
return new ConeDirection(0, tau$1 / 2, minSpeed, maxSpeed);
|
|
25537
26424
|
}
|
|
25538
26425
|
}
|
|
25539
26426
|
|
|
26427
|
+
const tau = Math.PI * 2;
|
|
25540
26428
|
/**
|
|
25541
|
-
*
|
|
25542
|
-
*
|
|
25543
|
-
*
|
|
25544
|
-
*
|
|
25545
|
-
*
|
|
25546
|
-
*
|
|
26429
|
+
* Random point on or inside a circle of radius `radius`, centred at
|
|
26430
|
+
* `(centerX, centerY)`. With `mode: 'edge'` the result lies exactly on the
|
|
26431
|
+
* circumference; with `mode: 'volume'` (default) it lies anywhere in the
|
|
26432
|
+
* disk, with uniform area density (sqrt-of-random-radius distribution).
|
|
26433
|
+
*
|
|
26434
|
+
* Use as a spawn-position distribution for circular emitters.
|
|
25547
26435
|
*/
|
|
25548
|
-
class
|
|
25549
|
-
|
|
25550
|
-
|
|
25551
|
-
|
|
25552
|
-
|
|
25553
|
-
|
|
25554
|
-
|
|
25555
|
-
|
|
25556
|
-
|
|
25557
|
-
|
|
25558
|
-
|
|
25559
|
-
* Creates a new options snapshot. Any omitted field falls back to its
|
|
25560
|
-
* default value (1 s lifetime, zero position/velocity, unit scale,
|
|
25561
|
-
* zero rotation, white tint, texture index 0). All object values are
|
|
25562
|
-
* cloned so later mutations to the source objects do not affect this
|
|
25563
|
-
* instance.
|
|
25564
|
-
*/
|
|
25565
|
-
constructor(options = {}) {
|
|
25566
|
-
const { totalLifetime, elapsedLifetime, position, velocity, scale, rotation, rotationSpeed, textureIndex, tint, } = options;
|
|
25567
|
-
this._totalLifetime = (totalLifetime ?? Time.oneSecond).clone();
|
|
25568
|
-
this._elapsedLifetime = (elapsedLifetime ?? Time.zero).clone();
|
|
25569
|
-
this._position = (position ?? Vector.zero).clone();
|
|
25570
|
-
this._velocity = (velocity ?? Vector.zero).clone();
|
|
25571
|
-
this._scale = (scale ?? Vector.one).clone();
|
|
25572
|
-
this._tint = (tint ?? Color.white).clone();
|
|
25573
|
-
this._rotation = rotation ?? 0;
|
|
25574
|
-
this._rotationSpeed = rotationSpeed ?? 0;
|
|
25575
|
-
this._textureIndex = textureIndex ?? 0;
|
|
25576
|
-
}
|
|
25577
|
-
get totalLifetime() {
|
|
25578
|
-
return this._totalLifetime;
|
|
25579
|
-
}
|
|
25580
|
-
set totalLifetime(totalLifetime) {
|
|
25581
|
-
this._totalLifetime.copy(totalLifetime);
|
|
25582
|
-
}
|
|
25583
|
-
get elapsedLifetime() {
|
|
25584
|
-
return this._elapsedLifetime;
|
|
25585
|
-
}
|
|
25586
|
-
set elapsedLifetime(elapsedLifetime) {
|
|
25587
|
-
this._elapsedLifetime.copy(elapsedLifetime);
|
|
25588
|
-
}
|
|
25589
|
-
/**
|
|
25590
|
-
* Spawn position for particles emitted with these options, expressed in
|
|
25591
|
-
* the owning ParticleSystem's LOCAL coordinate space — the system's own
|
|
25592
|
-
* `getGlobalTransform()` is applied on top during rendering (both the
|
|
25593
|
-
* WebGL2 and WebGPU shaders do `projection * translation * rotated`).
|
|
25594
|
-
*
|
|
25595
|
-
* Setting a world-space value here (e.g. `system.x + offset`) will
|
|
25596
|
-
* double-translate the emitter because the shader will translate again.
|
|
25597
|
-
* For an emitter anchored at the system origin, use small offsets around
|
|
25598
|
-
* `(0, 0)` and position the system itself via `system.setPosition(...)`.
|
|
25599
|
-
*/
|
|
25600
|
-
get position() {
|
|
25601
|
-
return this._position;
|
|
25602
|
-
}
|
|
25603
|
-
set position(position) {
|
|
25604
|
-
this._position.copy(position);
|
|
25605
|
-
}
|
|
25606
|
-
get velocity() {
|
|
25607
|
-
return this._velocity;
|
|
25608
|
-
}
|
|
25609
|
-
set velocity(velocity) {
|
|
25610
|
-
this._velocity.copy(velocity);
|
|
25611
|
-
}
|
|
25612
|
-
get scale() {
|
|
25613
|
-
return this._scale;
|
|
25614
|
-
}
|
|
25615
|
-
set scale(scale) {
|
|
25616
|
-
this._scale.copy(scale);
|
|
25617
|
-
}
|
|
25618
|
-
get rotation() {
|
|
25619
|
-
return this._rotation;
|
|
25620
|
-
}
|
|
25621
|
-
set rotation(degrees) {
|
|
25622
|
-
this._rotation = trimRotation(degrees);
|
|
25623
|
-
}
|
|
25624
|
-
get rotationSpeed() {
|
|
25625
|
-
return this._rotationSpeed;
|
|
25626
|
-
}
|
|
25627
|
-
set rotationSpeed(rotationSpeed) {
|
|
25628
|
-
this._rotationSpeed = rotationSpeed;
|
|
25629
|
-
}
|
|
25630
|
-
get textureIndex() {
|
|
25631
|
-
return this._textureIndex;
|
|
25632
|
-
}
|
|
25633
|
-
set textureIndex(textureIndex) {
|
|
25634
|
-
this._textureIndex = textureIndex;
|
|
26436
|
+
class CircleArea {
|
|
26437
|
+
centerX;
|
|
26438
|
+
centerY;
|
|
26439
|
+
radius;
|
|
26440
|
+
mode;
|
|
26441
|
+
_scratch = new Vector();
|
|
26442
|
+
constructor(centerX, centerY, radius, mode = 'volume') {
|
|
26443
|
+
this.centerX = centerX;
|
|
26444
|
+
this.centerY = centerY;
|
|
26445
|
+
this.radius = radius;
|
|
26446
|
+
this.mode = mode;
|
|
25635
26447
|
}
|
|
25636
|
-
|
|
25637
|
-
|
|
26448
|
+
sample(out = this._scratch) {
|
|
26449
|
+
const angle = Math.random() * tau;
|
|
26450
|
+
const r = this.mode === 'edge' ? this.radius : this.radius * Math.sqrt(Math.random());
|
|
26451
|
+
out.set(this.centerX + Math.cos(angle) * r, this.centerY + Math.sin(angle) * r);
|
|
26452
|
+
return out;
|
|
25638
26453
|
}
|
|
25639
|
-
|
|
25640
|
-
|
|
26454
|
+
}
|
|
26455
|
+
|
|
26456
|
+
/**
|
|
26457
|
+
* Random point inside an axis-aligned box. With `mode: 'edge'` the result
|
|
26458
|
+
* lies on the perimeter (uniform along all four edges combined); with
|
|
26459
|
+
* `mode: 'volume'` (default) it's uniformly distributed across the area.
|
|
26460
|
+
*/
|
|
26461
|
+
class BoxArea {
|
|
26462
|
+
minX;
|
|
26463
|
+
maxX;
|
|
26464
|
+
minY;
|
|
26465
|
+
maxY;
|
|
26466
|
+
mode;
|
|
26467
|
+
_scratch = new Vector();
|
|
26468
|
+
constructor(minX, maxX, minY, maxY, mode = 'volume') {
|
|
26469
|
+
this.minX = minX;
|
|
26470
|
+
this.maxX = maxX;
|
|
26471
|
+
this.minY = minY;
|
|
26472
|
+
this.maxY = maxY;
|
|
26473
|
+
this.mode = mode;
|
|
26474
|
+
}
|
|
26475
|
+
sample(out = this._scratch) {
|
|
26476
|
+
if (this.mode === 'volume') {
|
|
26477
|
+
out.set(this.minX + Math.random() * (this.maxX - this.minX), this.minY + Math.random() * (this.maxY - this.minY));
|
|
26478
|
+
return out;
|
|
26479
|
+
}
|
|
26480
|
+
const w = this.maxX - this.minX;
|
|
26481
|
+
const h = this.maxY - this.minY;
|
|
26482
|
+
const perimeter = (w + h) * 2;
|
|
26483
|
+
const t = Math.random() * perimeter;
|
|
26484
|
+
if (t < w) {
|
|
26485
|
+
out.set(this.minX + t, this.minY);
|
|
26486
|
+
}
|
|
26487
|
+
else if (t < w + h) {
|
|
26488
|
+
out.set(this.maxX, this.minY + (t - w));
|
|
26489
|
+
}
|
|
26490
|
+
else if (t < w * 2 + h) {
|
|
26491
|
+
out.set(this.maxX - (t - w - h), this.maxY);
|
|
26492
|
+
}
|
|
26493
|
+
else {
|
|
26494
|
+
out.set(this.minX, this.maxY - (t - w * 2 - h));
|
|
26495
|
+
}
|
|
26496
|
+
return out;
|
|
25641
26497
|
}
|
|
25642
|
-
|
|
25643
|
-
|
|
25644
|
-
|
|
25645
|
-
|
|
25646
|
-
|
|
25647
|
-
|
|
25648
|
-
|
|
25649
|
-
|
|
26498
|
+
}
|
|
26499
|
+
|
|
26500
|
+
/**
|
|
26501
|
+
* Random point on a line segment between `(x0, y0)` and `(x1, y1)`. Uniform
|
|
26502
|
+
* parameter distribution: `t = Math.random()` then `lerp(start, end, t)`.
|
|
26503
|
+
*/
|
|
26504
|
+
class LineSegment {
|
|
26505
|
+
x0;
|
|
26506
|
+
y0;
|
|
26507
|
+
x1;
|
|
26508
|
+
y1;
|
|
26509
|
+
_scratch = new Vector();
|
|
26510
|
+
constructor(x0, y0, x1, y1) {
|
|
26511
|
+
this.x0 = x0;
|
|
26512
|
+
this.y0 = y0;
|
|
26513
|
+
this.x1 = x1;
|
|
26514
|
+
this.y1 = y1;
|
|
26515
|
+
}
|
|
26516
|
+
sample(out = this._scratch) {
|
|
26517
|
+
const t = Math.random();
|
|
26518
|
+
out.set(this.x0 + (this.x1 - this.x0) * t, this.y0 + (this.y1 - this.y0) * t);
|
|
26519
|
+
return out;
|
|
25650
26520
|
}
|
|
25651
26521
|
}
|
|
25652
26522
|
|
|
26523
|
+
const compareT$1 = (a, b) => a.t - b.t;
|
|
25653
26524
|
/**
|
|
25654
|
-
*
|
|
25655
|
-
*
|
|
25656
|
-
* and
|
|
25657
|
-
*
|
|
25658
|
-
*
|
|
26525
|
+
* Piecewise-linear keyframe curve sampled by lifetime ratio `t` in `[0, 1]`.
|
|
26526
|
+
*
|
|
26527
|
+
* Keyframes are stored sorted by `t` and clamped at the endpoints — sampling
|
|
26528
|
+
* outside the keyframe range returns the nearest endpoint value. The
|
|
26529
|
+
* implementation tracks the last accessed segment so monotonically advancing
|
|
26530
|
+
* `t` (the typical case for per-particle lifetime sampling) costs O(1)
|
|
26531
|
+
* amortized per call instead of O(log n).
|
|
25659
26532
|
*
|
|
25660
26533
|
* @example
|
|
25661
|
-
*
|
|
25662
|
-
*
|
|
26534
|
+
* // Scale pulses up then down over lifetime:
|
|
26535
|
+
* const sizeCurve = new Curve([
|
|
26536
|
+
* { t: 0, v: 0.2 },
|
|
26537
|
+
* { t: 0.4, v: 1.5 },
|
|
26538
|
+
* { t: 1, v: 0.0 },
|
|
26539
|
+
* ]);
|
|
26540
|
+
* scale.x = scale.y = sizeCurve.evaluate(particle.elapsedRatio);
|
|
25663
26541
|
*/
|
|
25664
|
-
class
|
|
25665
|
-
|
|
25666
|
-
|
|
25667
|
-
|
|
25668
|
-
|
|
25669
|
-
|
|
25670
|
-
|
|
25671
|
-
|
|
25672
|
-
|
|
25673
|
-
|
|
26542
|
+
class Curve {
|
|
26543
|
+
_keys;
|
|
26544
|
+
_lastSegment = 0;
|
|
26545
|
+
constructor(keys) {
|
|
26546
|
+
if (keys.length === 0) {
|
|
26547
|
+
throw new Error('Curve requires at least one keyframe.');
|
|
26548
|
+
}
|
|
26549
|
+
this._keys = [...keys].sort(compareT$1);
|
|
26550
|
+
}
|
|
26551
|
+
evaluate(t) {
|
|
26552
|
+
const keys = this._keys;
|
|
26553
|
+
const last = keys.length - 1;
|
|
26554
|
+
if (t <= keys[0].t)
|
|
26555
|
+
return keys[0].v;
|
|
26556
|
+
if (t >= keys[last].t)
|
|
26557
|
+
return keys[last].v;
|
|
26558
|
+
// Cache-friendly forward search: most callers sweep t monotonically.
|
|
26559
|
+
let segment = this._lastSegment;
|
|
26560
|
+
if (t < keys[segment].t) {
|
|
26561
|
+
segment = 0;
|
|
26562
|
+
}
|
|
26563
|
+
while (segment < last && t > keys[segment + 1].t) {
|
|
26564
|
+
segment++;
|
|
26565
|
+
}
|
|
26566
|
+
this._lastSegment = segment;
|
|
26567
|
+
const a = keys[segment];
|
|
26568
|
+
const b = keys[segment + 1];
|
|
26569
|
+
const ratio = (t - a.t) / (b.t - a.t);
|
|
26570
|
+
return a.v + (b.v - a.v) * ratio;
|
|
25674
26571
|
}
|
|
25675
|
-
|
|
25676
|
-
|
|
25677
|
-
|
|
25678
|
-
|
|
25679
|
-
|
|
25680
|
-
|
|
25681
|
-
|
|
25682
|
-
|
|
25683
|
-
|
|
25684
|
-
|
|
25685
|
-
|
|
25686
|
-
|
|
25687
|
-
|
|
25688
|
-
|
|
25689
|
-
|
|
25690
|
-
|
|
25691
|
-
|
|
25692
|
-
|
|
25693
|
-
|
|
25694
|
-
|
|
25695
|
-
|
|
26572
|
+
}
|
|
26573
|
+
|
|
26574
|
+
const compareT = (a, b) => a.t - b.t;
|
|
26575
|
+
/**
|
|
26576
|
+
* Piecewise-linear color gradient sampled by lifetime ratio `t` in `[0, 1]`.
|
|
26577
|
+
* Keyframes are stored sorted by `t`; sampling outside the range returns
|
|
26578
|
+
* the nearest endpoint color. Like {@link Curve}, the last accessed segment
|
|
26579
|
+
* is cached so monotonically advancing `t` is O(1) amortised.
|
|
26580
|
+
*
|
|
26581
|
+
* Two output paths:
|
|
26582
|
+
* - {@link evaluate} — writes into a `Color` instance (own scratch by default).
|
|
26583
|
+
* - {@link evaluateRgba} — returns the packed `0xAABBGGRR` u32 directly,
|
|
26584
|
+
* skipping the Color object. Use this in tight per-particle inner loops
|
|
26585
|
+
* that write into a `Uint32Array` instance buffer.
|
|
26586
|
+
*
|
|
26587
|
+
* @example
|
|
26588
|
+
* const fire = new Gradient([
|
|
26589
|
+
* { t: 0, color: new Color(1, 1, 1, 1) }, // white
|
|
26590
|
+
* { t: 0.3, color: new Color(1, 0.7, 0.1, 1) }, // orange
|
|
26591
|
+
* { t: 0.7, color: new Color(0.4, 0.1, 0, 0.6) },
|
|
26592
|
+
* { t: 1, color: new Color(0, 0, 0, 0) }, // transparent black
|
|
26593
|
+
* ]);
|
|
26594
|
+
*/
|
|
26595
|
+
class Gradient {
|
|
26596
|
+
_keys;
|
|
26597
|
+
_scratch = new Color();
|
|
26598
|
+
_lastSegment = 0;
|
|
26599
|
+
constructor(keys) {
|
|
26600
|
+
if (keys.length === 0) {
|
|
26601
|
+
throw new Error('Gradient requires at least one keyframe.');
|
|
26602
|
+
}
|
|
26603
|
+
this._keys = [...keys].map((k) => ({ t: k.t, color: k.color.clone() })).sort(compareT);
|
|
26604
|
+
}
|
|
26605
|
+
evaluate(t, out = this._scratch) {
|
|
26606
|
+
const keys = this._keys;
|
|
26607
|
+
const last = keys.length - 1;
|
|
26608
|
+
if (t <= keys[0].t) {
|
|
26609
|
+
out.copy(keys[0].color);
|
|
26610
|
+
return out;
|
|
26611
|
+
}
|
|
26612
|
+
if (t >= keys[last].t) {
|
|
26613
|
+
out.copy(keys[last].color);
|
|
26614
|
+
return out;
|
|
26615
|
+
}
|
|
26616
|
+
let segment = this._lastSegment;
|
|
26617
|
+
if (t < keys[segment].t) {
|
|
26618
|
+
segment = 0;
|
|
26619
|
+
}
|
|
26620
|
+
while (segment < last && t > keys[segment + 1].t) {
|
|
26621
|
+
segment++;
|
|
26622
|
+
}
|
|
26623
|
+
this._lastSegment = segment;
|
|
26624
|
+
const a = keys[segment].color;
|
|
26625
|
+
const b = keys[segment + 1].color;
|
|
26626
|
+
const ka = keys[segment].t;
|
|
26627
|
+
const kb = keys[segment + 1].t;
|
|
26628
|
+
const ratio = (t - ka) / (kb - ka);
|
|
26629
|
+
out.set(a.r + (b.r - a.r) * ratio, a.g + (b.g - a.g) * ratio, a.b + (b.b - a.b) * ratio, a.a + (b.a - a.a) * ratio);
|
|
26630
|
+
return out;
|
|
26631
|
+
}
|
|
26632
|
+
/**
|
|
26633
|
+
* Returns the gradient sample at `t` packed into a single 32-bit RGBA
|
|
26634
|
+
* integer (`0xAABBGGRR`). Avoids the {@link Color} object on the hot
|
|
26635
|
+
* path; suitable for direct write into a `Uint32Array` instance buffer.
|
|
26636
|
+
*/
|
|
26637
|
+
evaluateRgba(t) {
|
|
26638
|
+
return this.evaluate(t, this._scratch).toRgba();
|
|
25696
26639
|
}
|
|
25697
|
-
|
|
25698
|
-
|
|
25699
|
-
|
|
25700
|
-
|
|
25701
|
-
|
|
25702
|
-
|
|
25703
|
-
|
|
25704
|
-
|
|
26640
|
+
}
|
|
26641
|
+
|
|
26642
|
+
/**
|
|
26643
|
+
* Per-frame particle spawner. Subclasses decide how many particles to emit
|
|
26644
|
+
* each tick (rate-based, burst, on-demand) and write their initial values
|
|
26645
|
+
* directly into the system's typed-array slots.
|
|
26646
|
+
*
|
|
26647
|
+
* Implementation pattern:
|
|
26648
|
+
*
|
|
26649
|
+
* ```ts
|
|
26650
|
+
* apply(system, dt) {
|
|
26651
|
+
* const count = this.computeSpawnCount(dt);
|
|
26652
|
+
* for (let i = 0; i < count; i++) {
|
|
26653
|
+
* const slot = system.spawn();
|
|
26654
|
+
* if (slot < 0) break; // capacity exhausted
|
|
26655
|
+
* system.posX[slot] = ...;
|
|
26656
|
+
* system.velX[slot] = ...;
|
|
26657
|
+
* system.lifetime[slot] = ...;
|
|
26658
|
+
* // ...etc
|
|
26659
|
+
* }
|
|
26660
|
+
* }
|
|
26661
|
+
* ```
|
|
26662
|
+
*
|
|
26663
|
+
* Spawn modules run before integration each frame. Multiple modules can be
|
|
26664
|
+
* registered on one system and execute in registration order.
|
|
26665
|
+
*/
|
|
26666
|
+
class SpawnModule {
|
|
26667
|
+
/** Optional cleanup hook called from `ParticleSystem.destroy`. */
|
|
26668
|
+
destroy() { }
|
|
26669
|
+
}
|
|
26670
|
+
|
|
26671
|
+
/**
|
|
26672
|
+
* Per-frame, per-batch mutator. Operates on the system's SoA storage
|
|
26673
|
+
* directly — typically a single tight loop over `[0, system.liveCount)`
|
|
26674
|
+
* that reads/writes the relevant `Float32Array`s.
|
|
26675
|
+
*
|
|
26676
|
+
* Implementations must always provide a CPU `apply()`. To make a module
|
|
26677
|
+
* GPU-eligible (executed inside the system's composite compute shader on
|
|
26678
|
+
* WebGPU backends), additionally implement {@link wgsl} and
|
|
26679
|
+
* {@link writeUniforms}. Modules that declare a {@link WgslContribution}
|
|
26680
|
+
* may also opt to declare a 1D texture binding via the `textures` field
|
|
26681
|
+
* (used by `Curve` / `Gradient`-driven modules) — in which case
|
|
26682
|
+
* {@link uploadTextures} runs once at compile time to upload the data.
|
|
26683
|
+
*
|
|
26684
|
+
* Implementation pattern (CPU-only module):
|
|
26685
|
+
*
|
|
26686
|
+
* ```ts
|
|
26687
|
+
* class MyModule extends UpdateModule {
|
|
26688
|
+
* apply(system, dt) {
|
|
26689
|
+
* const { velX, velY, liveCount } = system;
|
|
26690
|
+
* for (let i = 0; i < liveCount; i++) { velX[i] *= 0.99; velY[i] *= 0.99; }
|
|
26691
|
+
* }
|
|
26692
|
+
* }
|
|
26693
|
+
* ```
|
|
26694
|
+
*
|
|
26695
|
+
* Implementation pattern (GPU-eligible module):
|
|
26696
|
+
*
|
|
26697
|
+
* ```ts
|
|
26698
|
+
* class MyForce extends UpdateModule {
|
|
26699
|
+
* constructor(public ax: number, public ay: number) { super(); }
|
|
26700
|
+
*
|
|
26701
|
+
* apply(system, dt) {
|
|
26702
|
+
* const { velX, velY, liveCount } = system;
|
|
26703
|
+
* for (let i = 0; i < liveCount; i++) { velX[i] += this.ax * dt; velY[i] += this.ay * dt; }
|
|
26704
|
+
* }
|
|
26705
|
+
*
|
|
26706
|
+
* wgsl(): WgslContribution {
|
|
26707
|
+
* return {
|
|
26708
|
+
* key: 'MyForce',
|
|
26709
|
+
* uniforms: [{ name: 'ax', type: 'f32' }, { name: 'ay', type: 'f32' }],
|
|
26710
|
+
* body: `velX[idx] += u_MyForce.ax * dt; velY[idx] += u_MyForce.ay * dt;`,
|
|
26711
|
+
* };
|
|
26712
|
+
* }
|
|
26713
|
+
*
|
|
26714
|
+
* writeUniforms(view, offset) {
|
|
26715
|
+
* view.setFloat32(offset + 0, this.ax, true);
|
|
26716
|
+
* view.setFloat32(offset + 4, this.ay, true);
|
|
26717
|
+
* }
|
|
26718
|
+
* }
|
|
26719
|
+
* ```
|
|
26720
|
+
*
|
|
26721
|
+
* If *any* registered update module on a system lacks `wgsl()`, the system
|
|
26722
|
+
* forces CPU mode regardless of backend — preserving the contract that
|
|
26723
|
+
* `apply()` is always honoured. Built-in modules ship both
|
|
26724
|
+
* implementations; custom modules can opt into GPU acceleration at their
|
|
26725
|
+
* authors' discretion.
|
|
26726
|
+
*
|
|
26727
|
+
* Update modules run after integration each frame. Multiple modules execute
|
|
26728
|
+
* in registration order; later modules see the effects of earlier ones.
|
|
26729
|
+
*/
|
|
26730
|
+
class UpdateModule {
|
|
26731
|
+
/** Optional cleanup hook called from `ParticleSystem.destroy`. */
|
|
26732
|
+
destroy() { }
|
|
26733
|
+
}
|
|
26734
|
+
|
|
26735
|
+
/**
|
|
26736
|
+
* Per-particle hook invoked exactly once when a particle expires, before
|
|
26737
|
+
* its slot is recycled by the compaction pass. The dying particle's data
|
|
26738
|
+
* is still readable at `system.posX[slot]` etc.
|
|
26739
|
+
*
|
|
26740
|
+
* Use for sub-emitters (spawn child particles where this one died), event
|
|
26741
|
+
* dispatch (trigger an audio cue, score event), or trail termination.
|
|
26742
|
+
*
|
|
26743
|
+
* Implementation pattern:
|
|
26744
|
+
*
|
|
26745
|
+
* ```ts
|
|
26746
|
+
* onDeath(system, slot) {
|
|
26747
|
+
* const x = system.posX[slot];
|
|
26748
|
+
* const y = system.posY[slot];
|
|
26749
|
+
* this._childSystem.spawnBurstAt(x, y, 8);
|
|
26750
|
+
* }
|
|
26751
|
+
* ```
|
|
26752
|
+
*/
|
|
26753
|
+
class DeathModule {
|
|
26754
|
+
/** Optional cleanup hook called from `ParticleSystem.destroy`. */
|
|
26755
|
+
destroy() { }
|
|
26756
|
+
}
|
|
26757
|
+
|
|
26758
|
+
/**
|
|
26759
|
+
* Continuous, rate-based spawner. Emits a {@link RateSpawnConfig.rate}
|
|
26760
|
+
* sample per second; sub-frame fractions accumulate so low rates (e.g.
|
|
26761
|
+
* 0.5 particles/s) remain accurate over time.
|
|
26762
|
+
*
|
|
26763
|
+
* Each property is independently randomised via its {@link Distribution}.
|
|
26764
|
+
* Every spawned particle gets a fresh sample for every configured field.
|
|
26765
|
+
*/
|
|
26766
|
+
class RateSpawn extends SpawnModule {
|
|
26767
|
+
config;
|
|
26768
|
+
_accumulator = 0;
|
|
26769
|
+
_vec = new Vector();
|
|
26770
|
+
_color = new Color();
|
|
26771
|
+
constructor(config) {
|
|
26772
|
+
super();
|
|
26773
|
+
this.config = config;
|
|
26774
|
+
}
|
|
26775
|
+
apply(system, dt) {
|
|
26776
|
+
const cfg = this.config;
|
|
26777
|
+
const rate = cfg.rate.sample();
|
|
26778
|
+
this._accumulator += rate * dt;
|
|
26779
|
+
const count = this._accumulator | 0;
|
|
26780
|
+
if (count <= 0) {
|
|
26781
|
+
return;
|
|
26782
|
+
}
|
|
26783
|
+
this._accumulator -= count;
|
|
26784
|
+
const v = this._vec;
|
|
26785
|
+
const c = this._color;
|
|
25705
26786
|
for (let i = 0; i < count; i++) {
|
|
25706
|
-
const
|
|
25707
|
-
|
|
25708
|
-
|
|
26787
|
+
const slot = system.spawn();
|
|
26788
|
+
if (slot < 0) {
|
|
26789
|
+
this._accumulator = 0;
|
|
26790
|
+
return;
|
|
26791
|
+
}
|
|
26792
|
+
system.lifetime[slot] = cfg.lifetime ? cfg.lifetime.sample() : 1;
|
|
26793
|
+
if (cfg.position) {
|
|
26794
|
+
cfg.position.sample(v);
|
|
26795
|
+
system.posX[slot] = v.x;
|
|
26796
|
+
system.posY[slot] = v.y;
|
|
26797
|
+
}
|
|
26798
|
+
else {
|
|
26799
|
+
system.posX[slot] = 0;
|
|
26800
|
+
system.posY[slot] = 0;
|
|
26801
|
+
}
|
|
26802
|
+
if (cfg.velocity) {
|
|
26803
|
+
cfg.velocity.sample(v);
|
|
26804
|
+
system.velX[slot] = v.x;
|
|
26805
|
+
system.velY[slot] = v.y;
|
|
26806
|
+
}
|
|
26807
|
+
else {
|
|
26808
|
+
system.velX[slot] = 0;
|
|
26809
|
+
system.velY[slot] = 0;
|
|
26810
|
+
}
|
|
26811
|
+
if (cfg.scale) {
|
|
26812
|
+
cfg.scale.sample(v);
|
|
26813
|
+
system.scaleX[slot] = v.x;
|
|
26814
|
+
system.scaleY[slot] = v.y;
|
|
26815
|
+
}
|
|
26816
|
+
else {
|
|
26817
|
+
system.scaleX[slot] = 1;
|
|
26818
|
+
system.scaleY[slot] = 1;
|
|
26819
|
+
}
|
|
26820
|
+
system.rotations[slot] = cfg.rotation ? cfg.rotation.sample() : 0;
|
|
26821
|
+
system.rotationSpeeds[slot] = cfg.rotationSpeed ? cfg.rotationSpeed.sample() : 0;
|
|
26822
|
+
system.color[slot] = cfg.tint ? cfg.tint.sample(c).toRgba() : 0xffffffff;
|
|
26823
|
+
system.textureIndex[slot] = cfg.textureIndex ? (cfg.textureIndex.sample() | 0) : 0;
|
|
25709
26824
|
}
|
|
25710
|
-
return this;
|
|
25711
|
-
}
|
|
25712
|
-
destroy() {
|
|
25713
|
-
this._particleOptions.destroy();
|
|
25714
26825
|
}
|
|
25715
26826
|
}
|
|
25716
26827
|
|
|
25717
26828
|
/**
|
|
25718
|
-
*
|
|
25719
|
-
*
|
|
25720
|
-
*
|
|
25721
|
-
*
|
|
25722
|
-
*
|
|
25723
|
-
*
|
|
25724
|
-
*
|
|
26829
|
+
* Discrete-burst spawner. Fires at scheduled times with a fixed count per
|
|
26830
|
+
* burst. Useful for explosions, hit-impacts, level-up effects.
|
|
26831
|
+
*
|
|
26832
|
+
* @example
|
|
26833
|
+
* new BurstSpawn({
|
|
26834
|
+
* schedule: [{ time: 0, count: 50 }, { time: 0.2, count: 25 }],
|
|
26835
|
+
* velocity: ConeDirection.omni(150, 350),
|
|
26836
|
+
* lifetime: new Range(0.4, 0.9),
|
|
26837
|
+
* });
|
|
26838
|
+
*/
|
|
26839
|
+
class BurstSpawn extends SpawnModule {
|
|
26840
|
+
config;
|
|
26841
|
+
_elapsed = 0;
|
|
26842
|
+
_nextIndex = 0;
|
|
26843
|
+
_vec = new Vector();
|
|
26844
|
+
_color = new Color();
|
|
26845
|
+
constructor(config) {
|
|
26846
|
+
super();
|
|
26847
|
+
this.config = config;
|
|
26848
|
+
}
|
|
26849
|
+
apply(system, dt) {
|
|
26850
|
+
const cfg = this.config;
|
|
26851
|
+
const schedule = cfg.schedule;
|
|
26852
|
+
if (schedule.length === 0) {
|
|
26853
|
+
return;
|
|
26854
|
+
}
|
|
26855
|
+
this._elapsed += dt;
|
|
26856
|
+
while (this._nextIndex < schedule.length && this._elapsed >= schedule[this._nextIndex].time) {
|
|
26857
|
+
this._spawnBurst(system, schedule[this._nextIndex].count);
|
|
26858
|
+
this._nextIndex++;
|
|
26859
|
+
}
|
|
26860
|
+
if (cfg.loop && this._nextIndex >= schedule.length) {
|
|
26861
|
+
this._elapsed = 0;
|
|
26862
|
+
this._nextIndex = 0;
|
|
26863
|
+
}
|
|
26864
|
+
}
|
|
26865
|
+
/** Restart the schedule from t=0. */
|
|
26866
|
+
reset() {
|
|
26867
|
+
this._elapsed = 0;
|
|
26868
|
+
this._nextIndex = 0;
|
|
26869
|
+
return this;
|
|
26870
|
+
}
|
|
26871
|
+
_spawnBurst(system, count) {
|
|
26872
|
+
const cfg = this.config;
|
|
26873
|
+
const v = this._vec;
|
|
26874
|
+
const c = this._color;
|
|
26875
|
+
for (let i = 0; i < count; i++) {
|
|
26876
|
+
const slot = system.spawn();
|
|
26877
|
+
if (slot < 0) {
|
|
26878
|
+
return;
|
|
26879
|
+
}
|
|
26880
|
+
system.lifetime[slot] = cfg.lifetime ? cfg.lifetime.sample() : 1;
|
|
26881
|
+
if (cfg.position) {
|
|
26882
|
+
cfg.position.sample(v);
|
|
26883
|
+
system.posX[slot] = v.x;
|
|
26884
|
+
system.posY[slot] = v.y;
|
|
26885
|
+
}
|
|
26886
|
+
else {
|
|
26887
|
+
system.posX[slot] = 0;
|
|
26888
|
+
system.posY[slot] = 0;
|
|
26889
|
+
}
|
|
26890
|
+
if (cfg.velocity) {
|
|
26891
|
+
cfg.velocity.sample(v);
|
|
26892
|
+
system.velX[slot] = v.x;
|
|
26893
|
+
system.velY[slot] = v.y;
|
|
26894
|
+
}
|
|
26895
|
+
else {
|
|
26896
|
+
system.velX[slot] = 0;
|
|
26897
|
+
system.velY[slot] = 0;
|
|
26898
|
+
}
|
|
26899
|
+
if (cfg.scale) {
|
|
26900
|
+
cfg.scale.sample(v);
|
|
26901
|
+
system.scaleX[slot] = v.x;
|
|
26902
|
+
system.scaleY[slot] = v.y;
|
|
26903
|
+
}
|
|
26904
|
+
else {
|
|
26905
|
+
system.scaleX[slot] = 1;
|
|
26906
|
+
system.scaleY[slot] = 1;
|
|
26907
|
+
}
|
|
26908
|
+
system.rotations[slot] = cfg.rotation ? cfg.rotation.sample() : 0;
|
|
26909
|
+
system.rotationSpeeds[slot] = cfg.rotationSpeed ? cfg.rotationSpeed.sample() : 0;
|
|
26910
|
+
system.color[slot] = cfg.tint ? cfg.tint.sample(c).toRgba() : 0xffffffff;
|
|
26911
|
+
system.textureIndex[slot] = cfg.textureIndex ? (cfg.textureIndex.sample() | 0) : 0;
|
|
26912
|
+
}
|
|
26913
|
+
}
|
|
26914
|
+
}
|
|
26915
|
+
|
|
26916
|
+
/**
|
|
26917
|
+
* Adds a constant 2D acceleration to every live particle's velocity each
|
|
26918
|
+
* frame. Use for gravity (`new ApplyForce(0, 980)`), wind, or any uniform
|
|
26919
|
+
* force field. Force is applied to all particles equally — for per-particle
|
|
26920
|
+
* variation, layer multiple ApplyForce modules with different gates.
|
|
26921
|
+
*
|
|
26922
|
+
* GPU-eligible: implements {@link UpdateModule.wgsl} so this module runs in
|
|
26923
|
+
* the system's compute shader on WebGPU backends with no CPU readback.
|
|
26924
|
+
*/
|
|
26925
|
+
class ApplyForce extends UpdateModule {
|
|
26926
|
+
accelerationX;
|
|
26927
|
+
accelerationY;
|
|
26928
|
+
constructor(accelerationX, accelerationY) {
|
|
26929
|
+
super();
|
|
26930
|
+
this.accelerationX = accelerationX;
|
|
26931
|
+
this.accelerationY = accelerationY;
|
|
26932
|
+
}
|
|
26933
|
+
apply(system, dt) {
|
|
26934
|
+
const { velX, velY, liveCount } = system;
|
|
26935
|
+
const ax = this.accelerationX * dt;
|
|
26936
|
+
const ay = this.accelerationY * dt;
|
|
26937
|
+
for (let i = 0; i < liveCount; i++) {
|
|
26938
|
+
velX[i] += ax;
|
|
26939
|
+
velY[i] += ay;
|
|
26940
|
+
}
|
|
26941
|
+
}
|
|
26942
|
+
wgsl() {
|
|
26943
|
+
return {
|
|
26944
|
+
key: 'ApplyForce',
|
|
26945
|
+
uniforms: [
|
|
26946
|
+
{ name: 'ax', type: 'f32' },
|
|
26947
|
+
{ name: 'ay', type: 'f32' },
|
|
26948
|
+
],
|
|
26949
|
+
body: `
|
|
26950
|
+
velocities[idx] = velocities[idx] + vec2<f32>(modules.u_ApplyForce.ax, modules.u_ApplyForce.ay) * dt;
|
|
26951
|
+
`,
|
|
26952
|
+
};
|
|
26953
|
+
}
|
|
26954
|
+
writeUniforms(view, offset) {
|
|
26955
|
+
view.setFloat32(offset + 0, this.accelerationX, true);
|
|
26956
|
+
view.setFloat32(offset + 4, this.accelerationY, true);
|
|
26957
|
+
}
|
|
26958
|
+
}
|
|
26959
|
+
|
|
26960
|
+
/**
|
|
26961
|
+
* Exponential velocity damping. Each frame multiplies every live particle's
|
|
26962
|
+
* velocity by `(1 - drag * dt)`, simulating linear air resistance.
|
|
26963
|
+
*
|
|
26964
|
+
* `drag = 0` is no damping; `drag = 1` halves velocity in ~1 second; higher
|
|
26965
|
+
* values slow particles faster. Negative values accelerate (don't do that
|
|
26966
|
+
* unless you mean it).
|
|
26967
|
+
*
|
|
26968
|
+
* GPU-eligible.
|
|
26969
|
+
*/
|
|
26970
|
+
class Drag extends UpdateModule {
|
|
26971
|
+
drag;
|
|
26972
|
+
constructor(drag) {
|
|
26973
|
+
super();
|
|
26974
|
+
this.drag = drag;
|
|
26975
|
+
}
|
|
26976
|
+
apply(system, dt) {
|
|
26977
|
+
const { velX, velY, liveCount } = system;
|
|
26978
|
+
const factor = 1 - this.drag * dt;
|
|
26979
|
+
for (let i = 0; i < liveCount; i++) {
|
|
26980
|
+
velX[i] *= factor;
|
|
26981
|
+
velY[i] *= factor;
|
|
26982
|
+
}
|
|
26983
|
+
}
|
|
26984
|
+
wgsl() {
|
|
26985
|
+
return {
|
|
26986
|
+
key: 'Drag',
|
|
26987
|
+
uniforms: [
|
|
26988
|
+
{ name: 'drag', type: 'f32' },
|
|
26989
|
+
],
|
|
26990
|
+
body: `
|
|
26991
|
+
let dragFactor = 1.0 - modules.u_Drag.drag * dt;
|
|
26992
|
+
velocities[idx] = velocities[idx] * dragFactor;
|
|
26993
|
+
`,
|
|
26994
|
+
};
|
|
26995
|
+
}
|
|
26996
|
+
writeUniforms(view, offset) {
|
|
26997
|
+
view.setFloat32(offset + 0, this.drag, true);
|
|
26998
|
+
}
|
|
26999
|
+
}
|
|
27000
|
+
|
|
27001
|
+
/// <reference types="@webgpu/types" />
|
|
27002
|
+
const lookupSize$4 = 256;
|
|
27003
|
+
/**
|
|
27004
|
+
* Per-frame, per-particle color sampler. Each live particle's tint is set
|
|
27005
|
+
* to the gradient evaluated at the particle's current `elapsed / lifetime`
|
|
27006
|
+
* ratio, packed RGBA. Replaces the per-particle blend of `ColorAffector`
|
|
27007
|
+
* (legacy) with a multi-keyframe gradient.
|
|
27008
|
+
*
|
|
27009
|
+
* GPU-eligible: the gradient is uploaded once as a 256-tap 1D RGBA8 texture
|
|
27010
|
+
* and sampled with linear filtering on the GPU.
|
|
27011
|
+
*/
|
|
27012
|
+
class ColorOverLifetime extends UpdateModule {
|
|
27013
|
+
gradient;
|
|
27014
|
+
constructor(gradient) {
|
|
27015
|
+
super();
|
|
27016
|
+
this.gradient = gradient;
|
|
27017
|
+
}
|
|
27018
|
+
apply(system, _dt) {
|
|
27019
|
+
const { color, elapsed, lifetime, liveCount } = system;
|
|
27020
|
+
const gradient = this.gradient;
|
|
27021
|
+
for (let i = 0; i < liveCount; i++) {
|
|
27022
|
+
const t = elapsed[i] / lifetime[i];
|
|
27023
|
+
color[i] = gradient.evaluateRgba(t);
|
|
27024
|
+
}
|
|
27025
|
+
}
|
|
27026
|
+
wgsl() {
|
|
27027
|
+
return {
|
|
27028
|
+
key: 'ColorOverLifetime',
|
|
27029
|
+
textures: [{ name: 'gradient', format: 'rgba8unorm' }],
|
|
27030
|
+
body: `
|
|
27031
|
+
let colorT = clamp(timing[idx].x / max(timing[idx].y, 0.000001), 0.0, 1.0);
|
|
27032
|
+
let colorSample = textureSampleLevel(u_ColorOverLifetime_gradient, u_ColorOverLifetime_gradient_sampler, colorT, 0.0);
|
|
27033
|
+
let r = u32(colorSample.r * 255.0) & 255u;
|
|
27034
|
+
let g = u32(colorSample.g * 255.0) & 255u;
|
|
27035
|
+
let b = u32(colorSample.b * 255.0) & 255u;
|
|
27036
|
+
let a = u32(colorSample.a * 255.0) & 255u;
|
|
27037
|
+
color[idx] = (a << 24u) | (b << 16u) | (g << 8u) | r;
|
|
27038
|
+
`,
|
|
27039
|
+
};
|
|
27040
|
+
}
|
|
27041
|
+
uploadTextures(device, textures) {
|
|
27042
|
+
const texture = textures.get('gradient');
|
|
27043
|
+
if (texture === undefined) {
|
|
27044
|
+
return;
|
|
27045
|
+
}
|
|
27046
|
+
const data = new Uint8Array(lookupSize$4 * 4);
|
|
27047
|
+
const scratch = new Color();
|
|
27048
|
+
for (let i = 0; i < lookupSize$4; i++) {
|
|
27049
|
+
const t = i / (lookupSize$4 - 1);
|
|
27050
|
+
this.gradient.evaluate(t, scratch);
|
|
27051
|
+
const o = i * 4;
|
|
27052
|
+
data[o + 0] = scratch.r & 255;
|
|
27053
|
+
data[o + 1] = scratch.g & 255;
|
|
27054
|
+
data[o + 2] = scratch.b & 255;
|
|
27055
|
+
data[o + 3] = ((scratch.a * 255) | 0) & 255;
|
|
27056
|
+
}
|
|
27057
|
+
device.queue.writeTexture({ texture }, data.buffer, { offset: 0, bytesPerRow: lookupSize$4 * 4, rowsPerImage: 1 }, { width: lookupSize$4, height: 1, depthOrArrayLayers: 1 });
|
|
27058
|
+
}
|
|
27059
|
+
}
|
|
27060
|
+
|
|
27061
|
+
/// <reference types="@webgpu/types" />
|
|
27062
|
+
const lookupSize$3 = 256;
|
|
27063
|
+
/**
|
|
27064
|
+
* Per-frame, per-particle color sampler driven by velocity magnitude rather
|
|
27065
|
+
* than lifetime ratio. Each live particle's tint is set to the gradient
|
|
27066
|
+
* evaluated at `clamp((|velocity| - minSpeed) / (maxSpeed - minSpeed), 0, 1)`.
|
|
27067
|
+
*
|
|
27068
|
+
* Use cases: heat-mapping (slow=blue, fast=red), velocity-tinted trails,
|
|
27069
|
+
* speed-gated highlights.
|
|
27070
|
+
*
|
|
27071
|
+
* GPU-eligible: gradient uploaded as a 256-tap 1D RGBA8 texture, sampled
|
|
27072
|
+
* with linear filtering. Replaces the full color word — pair with a
|
|
27073
|
+
* separate {@link AlphaFadeOverLifetime} after this module if you want to
|
|
27074
|
+
* keep alpha controlled by lifetime.
|
|
27075
|
+
*/
|
|
27076
|
+
class ColorOverSpeed extends UpdateModule {
|
|
27077
|
+
gradient;
|
|
27078
|
+
minSpeed;
|
|
27079
|
+
maxSpeed;
|
|
27080
|
+
constructor(gradient, minSpeed, maxSpeed) {
|
|
27081
|
+
super();
|
|
27082
|
+
this.gradient = gradient;
|
|
27083
|
+
this.minSpeed = minSpeed;
|
|
27084
|
+
this.maxSpeed = maxSpeed;
|
|
27085
|
+
}
|
|
27086
|
+
apply(system, _dt) {
|
|
27087
|
+
const { velX, velY, color, liveCount } = system;
|
|
27088
|
+
const gradient = this.gradient;
|
|
27089
|
+
const min = this.minSpeed;
|
|
27090
|
+
const span = Math.max(1e-5, this.maxSpeed - this.minSpeed);
|
|
27091
|
+
for (let i = 0; i < liveCount; i++) {
|
|
27092
|
+
const speed = Math.sqrt(velX[i] * velX[i] + velY[i] * velY[i]);
|
|
27093
|
+
const t = Math.max(0, Math.min(1, (speed - min) / span));
|
|
27094
|
+
color[i] = gradient.evaluateRgba(t);
|
|
27095
|
+
}
|
|
27096
|
+
}
|
|
27097
|
+
wgsl() {
|
|
27098
|
+
return {
|
|
27099
|
+
key: 'ColorOverSpeed',
|
|
27100
|
+
uniforms: [
|
|
27101
|
+
{ name: 'minSpeed', type: 'f32' },
|
|
27102
|
+
{ name: 'invSpan', type: 'f32' },
|
|
27103
|
+
],
|
|
27104
|
+
textures: [{ name: 'gradient', format: 'rgba8unorm' }],
|
|
27105
|
+
body: `
|
|
27106
|
+
let speedMag = length(velocities[idx]);
|
|
27107
|
+
let speedT = clamp((speedMag - modules.u_ColorOverSpeed.minSpeed) * modules.u_ColorOverSpeed.invSpan, 0.0, 1.0);
|
|
27108
|
+
let speedSample = textureSampleLevel(u_ColorOverSpeed_gradient, u_ColorOverSpeed_gradient_sampler, speedT, 0.0);
|
|
27109
|
+
let speedR = u32(speedSample.r * 255.0) & 255u;
|
|
27110
|
+
let speedG = u32(speedSample.g * 255.0) & 255u;
|
|
27111
|
+
let speedB = u32(speedSample.b * 255.0) & 255u;
|
|
27112
|
+
let speedA = u32(speedSample.a * 255.0) & 255u;
|
|
27113
|
+
color[idx] = (speedA << 24u) | (speedB << 16u) | (speedG << 8u) | speedR;
|
|
27114
|
+
`,
|
|
27115
|
+
};
|
|
27116
|
+
}
|
|
27117
|
+
writeUniforms(view, offset) {
|
|
27118
|
+
const span = Math.max(1e-5, this.maxSpeed - this.minSpeed);
|
|
27119
|
+
view.setFloat32(offset + 0, this.minSpeed, true);
|
|
27120
|
+
view.setFloat32(offset + 4, 1 / span, true);
|
|
27121
|
+
}
|
|
27122
|
+
uploadTextures(device, textures) {
|
|
27123
|
+
const texture = textures.get('gradient');
|
|
27124
|
+
if (texture === undefined) {
|
|
27125
|
+
return;
|
|
27126
|
+
}
|
|
27127
|
+
const data = new Uint8Array(lookupSize$3 * 4);
|
|
27128
|
+
const scratch = new Color();
|
|
27129
|
+
for (let i = 0; i < lookupSize$3; i++) {
|
|
27130
|
+
const t = i / (lookupSize$3 - 1);
|
|
27131
|
+
this.gradient.evaluate(t, scratch);
|
|
27132
|
+
const o = i * 4;
|
|
27133
|
+
data[o + 0] = scratch.r & 255;
|
|
27134
|
+
data[o + 1] = scratch.g & 255;
|
|
27135
|
+
data[o + 2] = scratch.b & 255;
|
|
27136
|
+
data[o + 3] = ((scratch.a * 255) | 0) & 255;
|
|
27137
|
+
}
|
|
27138
|
+
device.queue.writeTexture({ texture }, data.buffer, { offset: 0, bytesPerRow: lookupSize$3 * 4, rowsPerImage: 1 }, { width: lookupSize$3, height: 1, depthOrArrayLayers: 1 });
|
|
27139
|
+
}
|
|
27140
|
+
}
|
|
27141
|
+
|
|
27142
|
+
/// <reference types="@webgpu/types" />
|
|
27143
|
+
const lookupSize$2 = 256;
|
|
27144
|
+
/**
|
|
27145
|
+
* Sets every live particle's scale to a curve sampled at the particle's
|
|
27146
|
+
* current lifetime ratio. Both axes share one curve — for non-uniform
|
|
27147
|
+
* scaling layer two ScaleOverLifetime modules with separate `axis` filters
|
|
27148
|
+
* (or extend with a per-axis variant).
|
|
27149
|
+
*
|
|
27150
|
+
* Common patterns: shrink-to-zero (start at 1, end at 0), pulse (sine-like
|
|
27151
|
+
* curve up to peak then down), slow-grow (linear ramp).
|
|
27152
|
+
*
|
|
27153
|
+
* GPU-eligible: the curve is uploaded once as a 256-tap 1D R32F texture and
|
|
27154
|
+
* sampled with linear filtering on the GPU — no curve evaluation cost in
|
|
27155
|
+
* the inner loop.
|
|
27156
|
+
*/
|
|
27157
|
+
class ScaleOverLifetime extends UpdateModule {
|
|
27158
|
+
curve;
|
|
27159
|
+
constructor(curve) {
|
|
27160
|
+
super();
|
|
27161
|
+
this.curve = curve;
|
|
27162
|
+
}
|
|
27163
|
+
apply(system, _dt) {
|
|
27164
|
+
const { scaleX, scaleY, elapsed, lifetime, liveCount } = system;
|
|
27165
|
+
const curve = this.curve;
|
|
27166
|
+
for (let i = 0; i < liveCount; i++) {
|
|
27167
|
+
const t = elapsed[i] / lifetime[i];
|
|
27168
|
+
const s = curve.evaluate(t);
|
|
27169
|
+
scaleX[i] = s;
|
|
27170
|
+
scaleY[i] = s;
|
|
27171
|
+
}
|
|
27172
|
+
}
|
|
27173
|
+
wgsl() {
|
|
27174
|
+
return {
|
|
27175
|
+
key: 'ScaleOverLifetime',
|
|
27176
|
+
textures: [{ name: 'curve', format: 'r32float' }],
|
|
27177
|
+
body: `
|
|
27178
|
+
let scaleT = clamp(timing[idx].x / max(timing[idx].y, 0.000001), 0.0, 1.0);
|
|
27179
|
+
let scaleSample = textureSampleLevel(u_ScaleOverLifetime_curve, u_ScaleOverLifetime_curve_sampler, scaleT, 0.0).r;
|
|
27180
|
+
scales[idx] = vec2<f32>(scaleSample, scaleSample);
|
|
27181
|
+
`,
|
|
27182
|
+
};
|
|
27183
|
+
}
|
|
27184
|
+
uploadTextures(device, textures) {
|
|
27185
|
+
const texture = textures.get('curve');
|
|
27186
|
+
if (texture === undefined) {
|
|
27187
|
+
return;
|
|
27188
|
+
}
|
|
27189
|
+
const data = new Float32Array(lookupSize$2);
|
|
27190
|
+
for (let i = 0; i < lookupSize$2; i++) {
|
|
27191
|
+
data[i] = this.curve.evaluate(i / (lookupSize$2 - 1));
|
|
27192
|
+
}
|
|
27193
|
+
device.queue.writeTexture({ texture }, data.buffer, { offset: 0, bytesPerRow: lookupSize$2 * 4, rowsPerImage: 1 }, { width: lookupSize$2, height: 1, depthOrArrayLayers: 1 });
|
|
27194
|
+
}
|
|
27195
|
+
}
|
|
27196
|
+
|
|
27197
|
+
/**
|
|
27198
|
+
* Adds a constant angular acceleration to every live particle each frame
|
|
27199
|
+
* (analogous to the legacy `TorqueAffector`). The system's integrate pass
|
|
27200
|
+
* advances `rotation` from `rotationSpeed`; this module increments
|
|
27201
|
+
* `rotationSpeed` itself.
|
|
27202
|
+
*
|
|
27203
|
+
* Units: degrees per second². Negative values decelerate spin.
|
|
27204
|
+
*
|
|
27205
|
+
* GPU-eligible.
|
|
27206
|
+
*/
|
|
27207
|
+
class RotateOverLifetime extends UpdateModule {
|
|
27208
|
+
angularAcceleration;
|
|
27209
|
+
constructor(angularAcceleration) {
|
|
27210
|
+
super();
|
|
27211
|
+
this.angularAcceleration = angularAcceleration;
|
|
27212
|
+
}
|
|
27213
|
+
apply(system, dt) {
|
|
27214
|
+
const { rotationSpeeds, liveCount } = system;
|
|
27215
|
+
const delta = this.angularAcceleration * dt;
|
|
27216
|
+
for (let i = 0; i < liveCount; i++) {
|
|
27217
|
+
rotationSpeeds[i] += delta;
|
|
27218
|
+
}
|
|
27219
|
+
}
|
|
27220
|
+
wgsl() {
|
|
27221
|
+
return {
|
|
27222
|
+
key: 'RotateOverLifetime',
|
|
27223
|
+
uniforms: [
|
|
27224
|
+
{ name: 'angularAcceleration', type: 'f32' },
|
|
27225
|
+
],
|
|
27226
|
+
body: `
|
|
27227
|
+
rotInfo[idx].y = rotInfo[idx].y + modules.u_RotateOverLifetime.angularAcceleration * dt;
|
|
27228
|
+
`,
|
|
27229
|
+
};
|
|
27230
|
+
}
|
|
27231
|
+
writeUniforms(view, offset) {
|
|
27232
|
+
view.setFloat32(offset + 0, this.angularAcceleration, true);
|
|
27233
|
+
}
|
|
27234
|
+
}
|
|
27235
|
+
|
|
27236
|
+
/// <reference types="@webgpu/types" />
|
|
27237
|
+
const lookupSize$1 = 256;
|
|
27238
|
+
/**
|
|
27239
|
+
* Fades only the alpha channel over a particle's lifetime, leaving RGB
|
|
27240
|
+
* untouched. Pair with a spawn-time tint or a separate `ColorOverLifetime`
|
|
27241
|
+
* to keep the color layer stable while controlling opacity from a single
|
|
27242
|
+
* curve.
|
|
27243
|
+
*
|
|
27244
|
+
* The default curve `1 → 0` produces a linear fade-out. For a fade-in then
|
|
27245
|
+
* fade-out, pass a curve like `[0,0]→[0.5,1]→[1,0]`.
|
|
27246
|
+
*
|
|
27247
|
+
* GPU-eligible: uploads the curve as a 256-tap 1D R32F texture; alpha is
|
|
27248
|
+
* resampled per-particle in the compute shader and stitched into the
|
|
27249
|
+
* existing color word with a single mask + shift.
|
|
27250
|
+
*/
|
|
27251
|
+
class AlphaFadeOverLifetime extends UpdateModule {
|
|
27252
|
+
curve;
|
|
27253
|
+
constructor(curve) {
|
|
27254
|
+
super();
|
|
27255
|
+
this.curve = curve;
|
|
27256
|
+
}
|
|
27257
|
+
apply(system, _dt) {
|
|
27258
|
+
const { color, elapsed, lifetime, liveCount } = system;
|
|
27259
|
+
const curve = this.curve;
|
|
27260
|
+
for (let i = 0; i < liveCount; i++) {
|
|
27261
|
+
const t = elapsed[i] / lifetime[i];
|
|
27262
|
+
const a = curve.evaluate(t);
|
|
27263
|
+
const alphaByte = (Math.max(0, Math.min(1, a)) * 255) & 255;
|
|
27264
|
+
color[i] = (color[i] & 0x00ffffff) | (alphaByte << 24);
|
|
27265
|
+
}
|
|
27266
|
+
}
|
|
27267
|
+
wgsl() {
|
|
27268
|
+
return {
|
|
27269
|
+
key: 'AlphaFadeOverLifetime',
|
|
27270
|
+
textures: [{ name: 'curve', format: 'r32float' }],
|
|
27271
|
+
body: `
|
|
27272
|
+
let alphaT = clamp(timing[idx].x / max(timing[idx].y, 0.000001), 0.0, 1.0);
|
|
27273
|
+
let alphaSample = textureSampleLevel(u_AlphaFadeOverLifetime_curve, u_AlphaFadeOverLifetime_curve_sampler, alphaT, 0.0).r;
|
|
27274
|
+
let alphaByte = u32(clamp(alphaSample, 0.0, 1.0) * 255.0) & 255u;
|
|
27275
|
+
color[idx] = (color[idx] & 0x00ffffffu) | (alphaByte << 24u);
|
|
27276
|
+
`,
|
|
27277
|
+
};
|
|
27278
|
+
}
|
|
27279
|
+
uploadTextures(device, textures) {
|
|
27280
|
+
const texture = textures.get('curve');
|
|
27281
|
+
if (texture === undefined) {
|
|
27282
|
+
return;
|
|
27283
|
+
}
|
|
27284
|
+
const data = new Float32Array(lookupSize$1);
|
|
27285
|
+
for (let i = 0; i < lookupSize$1; i++) {
|
|
27286
|
+
data[i] = this.curve.evaluate(i / (lookupSize$1 - 1));
|
|
27287
|
+
}
|
|
27288
|
+
device.queue.writeTexture({ texture }, data.buffer, { offset: 0, bytesPerRow: lookupSize$1 * 4, rowsPerImage: 1 }, { width: lookupSize$1, height: 1, depthOrArrayLayers: 1 });
|
|
27289
|
+
}
|
|
27290
|
+
}
|
|
27291
|
+
|
|
27292
|
+
/// <reference types="@webgpu/types" />
|
|
27293
|
+
const lookupSize = 256;
|
|
27294
|
+
/**
|
|
27295
|
+
* Multiplies every live particle's velocity by a curve sampled at the
|
|
27296
|
+
* particle's current `elapsed / lifetime` ratio. The same scalar is applied
|
|
27297
|
+
* to both axes — the direction is preserved, only the magnitude scales.
|
|
27298
|
+
*
|
|
27299
|
+
* Common patterns:
|
|
27300
|
+
* - **Snappy spawn, slow drift:** `[0,1]→[1,0.1]` — fast initial motion
|
|
27301
|
+
* that decays over lifetime.
|
|
27302
|
+
* - **Late acceleration:** `[0,0.2]→[1,1]` — particles ease in.
|
|
27303
|
+
* - **Pulse:** sine-shaped curve for breathing-style motion.
|
|
27304
|
+
*
|
|
27305
|
+
* Note: the curve **replaces** velocity each frame relative to the previous
|
|
27306
|
+
* frame's velocity, so the effect is multiplicative. Pair with `ApplyForce`
|
|
27307
|
+
* if you want a constant external acceleration on top.
|
|
27308
|
+
*
|
|
27309
|
+
* GPU-eligible: uploads the curve as a 256-tap 1D R32F texture; sampled
|
|
27310
|
+
* once per particle per frame.
|
|
27311
|
+
*/
|
|
27312
|
+
class VelocityOverLifetime extends UpdateModule {
|
|
27313
|
+
curve;
|
|
27314
|
+
_prevSample = new Float32Array(0);
|
|
27315
|
+
constructor(curve) {
|
|
27316
|
+
super();
|
|
27317
|
+
this.curve = curve;
|
|
27318
|
+
}
|
|
27319
|
+
apply(system, _dt) {
|
|
27320
|
+
const { velX, velY, elapsed, lifetime, liveCount } = system;
|
|
27321
|
+
const curve = this.curve;
|
|
27322
|
+
// Resize per-particle previous-sample cache once.
|
|
27323
|
+
if (this._prevSample.length < system.capacity) {
|
|
27324
|
+
this._prevSample = new Float32Array(system.capacity);
|
|
27325
|
+
this._prevSample.fill(1);
|
|
27326
|
+
}
|
|
27327
|
+
const prev = this._prevSample;
|
|
27328
|
+
for (let i = 0; i < liveCount; i++) {
|
|
27329
|
+
const t = elapsed[i] / lifetime[i];
|
|
27330
|
+
const sample = curve.evaluate(t);
|
|
27331
|
+
const last = prev[i] === 0 ? 1 : prev[i];
|
|
27332
|
+
const delta = sample / last;
|
|
27333
|
+
velX[i] *= delta;
|
|
27334
|
+
velY[i] *= delta;
|
|
27335
|
+
prev[i] = sample === 0 ? 1e-6 : sample;
|
|
27336
|
+
}
|
|
27337
|
+
}
|
|
27338
|
+
wgsl() {
|
|
27339
|
+
// GPU path uses a different formulation: re-derive velocity from
|
|
27340
|
+
// initial speed at t=0 by storing nothing — instead, scale relative
|
|
27341
|
+
// to the previous sample stored in scales[idx] alpha? No — keep it
|
|
27342
|
+
// simple and stateless: scale the integrated velocity by the
|
|
27343
|
+
// *ratio* of (current sample) / (sample at previous frame's t).
|
|
27344
|
+
// We approximate the previous t with `(elapsed - dt) / lifetime`.
|
|
27345
|
+
return {
|
|
27346
|
+
key: 'VelocityOverLifetime',
|
|
27347
|
+
textures: [{ name: 'curve', format: 'r32float' }],
|
|
27348
|
+
body: `
|
|
27349
|
+
let velLifetime = max(timing[idx].y, 0.000001);
|
|
27350
|
+
let velTNow = clamp(timing[idx].x / velLifetime, 0.0, 1.0);
|
|
27351
|
+
let velTPrev = clamp((timing[idx].x - dt) / velLifetime, 0.0, 1.0);
|
|
27352
|
+
let velSampleNow = textureSampleLevel(u_VelocityOverLifetime_curve, u_VelocityOverLifetime_curve_sampler, velTNow, 0.0).r;
|
|
27353
|
+
let velSamplePrev = textureSampleLevel(u_VelocityOverLifetime_curve, u_VelocityOverLifetime_curve_sampler, velTPrev, 0.0).r;
|
|
27354
|
+
let velRatio = velSampleNow / max(velSamplePrev, 0.000001);
|
|
27355
|
+
velocities[idx] = velocities[idx] * velRatio;
|
|
27356
|
+
`,
|
|
27357
|
+
};
|
|
27358
|
+
}
|
|
27359
|
+
uploadTextures(device, textures) {
|
|
27360
|
+
const texture = textures.get('curve');
|
|
27361
|
+
if (texture === undefined) {
|
|
27362
|
+
return;
|
|
27363
|
+
}
|
|
27364
|
+
const data = new Float32Array(lookupSize);
|
|
27365
|
+
for (let i = 0; i < lookupSize; i++) {
|
|
27366
|
+
data[i] = this.curve.evaluate(i / (lookupSize - 1));
|
|
27367
|
+
}
|
|
27368
|
+
device.queue.writeTexture({ texture }, data.buffer, { offset: 0, bytesPerRow: lookupSize * 4, rowsPerImage: 1 }, { width: lookupSize, height: 1, depthOrArrayLayers: 1 });
|
|
27369
|
+
}
|
|
27370
|
+
}
|
|
27371
|
+
|
|
27372
|
+
/**
|
|
27373
|
+
* Pulls every live particle toward a fixed point in the system's local
|
|
27374
|
+
* coordinate space. Acceleration magnitude is `strength` (units / s²),
|
|
27375
|
+
* applied along the direction `(point − particle)`. The optional `falloff`
|
|
27376
|
+
* radius softens the pull near the center — particles within `falloff`
|
|
27377
|
+
* units lerp the strength linearly to zero, preventing the singularity at
|
|
27378
|
+
* `r = 0` from yielding infinite acceleration.
|
|
27379
|
+
*
|
|
27380
|
+
* Use cases: orbit anchors, pin emitters to a moving target, simulate a
|
|
27381
|
+
* "black hole" pickup. For repulsion, use {@link RepelFromPoint}; for
|
|
27382
|
+
* tangential motion, layer with {@link OrbitalForce}.
|
|
27383
|
+
*
|
|
27384
|
+
* GPU-eligible.
|
|
27385
|
+
*/
|
|
27386
|
+
class AttractToPoint extends UpdateModule {
|
|
27387
|
+
x;
|
|
27388
|
+
y;
|
|
27389
|
+
strength;
|
|
27390
|
+
falloff;
|
|
27391
|
+
constructor(x, y, strength, falloff = 0) {
|
|
27392
|
+
super();
|
|
27393
|
+
this.x = x;
|
|
27394
|
+
this.y = y;
|
|
27395
|
+
this.strength = strength;
|
|
27396
|
+
this.falloff = falloff;
|
|
27397
|
+
}
|
|
27398
|
+
apply(system, dt) {
|
|
27399
|
+
const { posX, posY, velX, velY, liveCount } = system;
|
|
27400
|
+
const { x, y, strength, falloff } = this;
|
|
27401
|
+
for (let i = 0; i < liveCount; i++) {
|
|
27402
|
+
const dx = x - posX[i];
|
|
27403
|
+
const dy = y - posY[i];
|
|
27404
|
+
const distSq = dx * dx + dy * dy;
|
|
27405
|
+
const dist = Math.sqrt(distSq);
|
|
27406
|
+
if (dist < 1e-5)
|
|
27407
|
+
continue;
|
|
27408
|
+
const k = falloff > 0 ? Math.min(1, dist / falloff) : 1;
|
|
27409
|
+
const a = (strength * k * dt) / dist;
|
|
27410
|
+
velX[i] += dx * a;
|
|
27411
|
+
velY[i] += dy * a;
|
|
27412
|
+
}
|
|
27413
|
+
}
|
|
27414
|
+
wgsl() {
|
|
27415
|
+
return {
|
|
27416
|
+
key: 'AttractToPoint',
|
|
27417
|
+
uniforms: [
|
|
27418
|
+
{ name: 'point', type: 'vec2<f32>' },
|
|
27419
|
+
{ name: 'strength', type: 'f32' },
|
|
27420
|
+
{ name: 'falloff', type: 'f32' },
|
|
27421
|
+
],
|
|
27422
|
+
body: `
|
|
27423
|
+
let attractDelta = modules.u_AttractToPoint.point - positions[idx];
|
|
27424
|
+
let attractDist = length(attractDelta);
|
|
27425
|
+
if (attractDist > 0.00001) {
|
|
27426
|
+
let attractK = select(1.0, min(1.0, attractDist / max(modules.u_AttractToPoint.falloff, 0.000001)), modules.u_AttractToPoint.falloff > 0.0);
|
|
27427
|
+
let attractAccel = (modules.u_AttractToPoint.strength * attractK * dt) / attractDist;
|
|
27428
|
+
velocities[idx] = velocities[idx] + attractDelta * attractAccel;
|
|
27429
|
+
}
|
|
27430
|
+
`,
|
|
27431
|
+
};
|
|
27432
|
+
}
|
|
27433
|
+
writeUniforms(view, offset) {
|
|
27434
|
+
view.setFloat32(offset + 0, this.x, true);
|
|
27435
|
+
view.setFloat32(offset + 4, this.y, true);
|
|
27436
|
+
view.setFloat32(offset + 8, this.strength, true);
|
|
27437
|
+
view.setFloat32(offset + 12, this.falloff, true);
|
|
27438
|
+
}
|
|
27439
|
+
}
|
|
27440
|
+
|
|
27441
|
+
/**
|
|
27442
|
+
* Pushes every live particle away from a fixed point in the system's local
|
|
27443
|
+
* coordinate space. Acceleration magnitude is `strength` (units / s²)
|
|
27444
|
+
* along the direction `(particle − point)`. The optional `radius` controls
|
|
27445
|
+
* the influence range — particles farther than `radius` are unaffected.
|
|
27446
|
+
* Setting `radius = 0` (default) means infinite range with no falloff.
|
|
27447
|
+
*
|
|
27448
|
+
* Use cases: shockwaves, explosion blast, mouse-cursor repel field.
|
|
27449
|
+
*
|
|
27450
|
+
* GPU-eligible.
|
|
27451
|
+
*/
|
|
27452
|
+
class RepelFromPoint extends UpdateModule {
|
|
27453
|
+
x;
|
|
27454
|
+
y;
|
|
27455
|
+
strength;
|
|
27456
|
+
radius;
|
|
27457
|
+
constructor(x, y, strength, radius = 0) {
|
|
27458
|
+
super();
|
|
27459
|
+
this.x = x;
|
|
27460
|
+
this.y = y;
|
|
27461
|
+
this.strength = strength;
|
|
27462
|
+
this.radius = radius;
|
|
27463
|
+
}
|
|
27464
|
+
apply(system, dt) {
|
|
27465
|
+
const { posX, posY, velX, velY, liveCount } = system;
|
|
27466
|
+
const { x, y, strength, radius } = this;
|
|
27467
|
+
const radiusSq = radius * radius;
|
|
27468
|
+
for (let i = 0; i < liveCount; i++) {
|
|
27469
|
+
const dx = posX[i] - x;
|
|
27470
|
+
const dy = posY[i] - y;
|
|
27471
|
+
const distSq = dx * dx + dy * dy;
|
|
27472
|
+
if (distSq < 1e-10)
|
|
27473
|
+
continue;
|
|
27474
|
+
if (radius > 0 && distSq > radiusSq)
|
|
27475
|
+
continue;
|
|
27476
|
+
const dist = Math.sqrt(distSq);
|
|
27477
|
+
const falloff = radius > 0 ? 1 - dist / radius : 1;
|
|
27478
|
+
const a = (strength * falloff * dt) / dist;
|
|
27479
|
+
velX[i] += dx * a;
|
|
27480
|
+
velY[i] += dy * a;
|
|
27481
|
+
}
|
|
27482
|
+
}
|
|
27483
|
+
wgsl() {
|
|
27484
|
+
return {
|
|
27485
|
+
key: 'RepelFromPoint',
|
|
27486
|
+
uniforms: [
|
|
27487
|
+
{ name: 'point', type: 'vec2<f32>' },
|
|
27488
|
+
{ name: 'strength', type: 'f32' },
|
|
27489
|
+
{ name: 'radius', type: 'f32' },
|
|
27490
|
+
],
|
|
27491
|
+
body: `
|
|
27492
|
+
let repelDelta = positions[idx] - modules.u_RepelFromPoint.point;
|
|
27493
|
+
let repelDistSq = dot(repelDelta, repelDelta);
|
|
27494
|
+
let repelRadius = modules.u_RepelFromPoint.radius;
|
|
27495
|
+
let repelInRange = (repelRadius <= 0.0) || (repelDistSq <= repelRadius * repelRadius);
|
|
27496
|
+
if (repelDistSq > 0.0000001 && repelInRange) {
|
|
27497
|
+
let repelDist = sqrt(repelDistSq);
|
|
27498
|
+
let repelFalloff = select(1.0, 1.0 - repelDist / max(repelRadius, 0.000001), repelRadius > 0.0);
|
|
27499
|
+
let repelAccel = (modules.u_RepelFromPoint.strength * repelFalloff * dt) / repelDist;
|
|
27500
|
+
velocities[idx] = velocities[idx] + repelDelta * repelAccel;
|
|
27501
|
+
}
|
|
27502
|
+
`,
|
|
27503
|
+
};
|
|
27504
|
+
}
|
|
27505
|
+
writeUniforms(view, offset) {
|
|
27506
|
+
view.setFloat32(offset + 0, this.x, true);
|
|
27507
|
+
view.setFloat32(offset + 4, this.y, true);
|
|
27508
|
+
view.setFloat32(offset + 8, this.strength, true);
|
|
27509
|
+
view.setFloat32(offset + 12, this.radius, true);
|
|
27510
|
+
}
|
|
27511
|
+
}
|
|
27512
|
+
|
|
27513
|
+
/**
|
|
27514
|
+
* Applies a tangential acceleration around a center point — perpendicular
|
|
27515
|
+
* to the radial vector `(particle − center)`. Combined with an attract /
|
|
27516
|
+
* repel module that controls the radial distance, this produces orbital,
|
|
27517
|
+
* spiral, or vortex motion.
|
|
27518
|
+
*
|
|
27519
|
+
* `angularSpeed` is the target angular velocity in radians/second. The
|
|
27520
|
+
* effective tangential acceleration scales with `radius * angularSpeed`,
|
|
27521
|
+
* so distant particles get pushed harder (matching uniform circular
|
|
27522
|
+
* motion). Positive values orbit counter-clockwise, negative clockwise.
|
|
27523
|
+
*
|
|
27524
|
+
* Use cases: galactic spirals, smoke vortices around an attractor, wind
|
|
27525
|
+
* eddies. Layer with {@link AttractToPoint} for stable orbits.
|
|
27526
|
+
*
|
|
27527
|
+
* GPU-eligible.
|
|
27528
|
+
*/
|
|
27529
|
+
class OrbitalForce extends UpdateModule {
|
|
27530
|
+
x;
|
|
27531
|
+
y;
|
|
27532
|
+
angularSpeed;
|
|
27533
|
+
constructor(x, y, angularSpeed) {
|
|
27534
|
+
super();
|
|
27535
|
+
this.x = x;
|
|
27536
|
+
this.y = y;
|
|
27537
|
+
this.angularSpeed = angularSpeed;
|
|
27538
|
+
}
|
|
27539
|
+
apply(system, dt) {
|
|
27540
|
+
const { posX, posY, velX, velY, liveCount } = system;
|
|
27541
|
+
const { x, y, angularSpeed } = this;
|
|
27542
|
+
const omega = angularSpeed * dt;
|
|
27543
|
+
for (let i = 0; i < liveCount; i++) {
|
|
27544
|
+
const dx = posX[i] - x;
|
|
27545
|
+
const dy = posY[i] - y;
|
|
27546
|
+
// Perpendicular vector: (-dy, dx) for counter-clockwise.
|
|
27547
|
+
velX[i] += -dy * omega;
|
|
27548
|
+
velY[i] += dx * omega;
|
|
27549
|
+
}
|
|
27550
|
+
}
|
|
27551
|
+
wgsl() {
|
|
27552
|
+
return {
|
|
27553
|
+
key: 'OrbitalForce',
|
|
27554
|
+
uniforms: [
|
|
27555
|
+
{ name: 'center', type: 'vec2<f32>' },
|
|
27556
|
+
{ name: 'angularSpeed', type: 'f32' },
|
|
27557
|
+
{ name: '_pad0', type: 'f32' },
|
|
27558
|
+
],
|
|
27559
|
+
body: `
|
|
27560
|
+
let orbitDelta = positions[idx] - modules.u_OrbitalForce.center;
|
|
27561
|
+
let orbitOmega = modules.u_OrbitalForce.angularSpeed * dt;
|
|
27562
|
+
velocities[idx] = velocities[idx] + vec2<f32>(-orbitDelta.y, orbitDelta.x) * orbitOmega;
|
|
27563
|
+
`,
|
|
27564
|
+
};
|
|
27565
|
+
}
|
|
27566
|
+
writeUniforms(view, offset) {
|
|
27567
|
+
view.setFloat32(offset + 0, this.x, true);
|
|
27568
|
+
view.setFloat32(offset + 4, this.y, true);
|
|
27569
|
+
view.setFloat32(offset + 8, this.angularSpeed, true);
|
|
27570
|
+
view.setFloat32(offset + 12, 0, true);
|
|
27571
|
+
}
|
|
27572
|
+
}
|
|
27573
|
+
|
|
27574
|
+
/**
|
|
27575
|
+
* Adds a smooth pseudo-random force field that animates over time.
|
|
27576
|
+
* Implemented as 2D value noise with cubic Hermite smoothing — sampled
|
|
27577
|
+
* twice per particle (offset to decorrelate x and y components) and scaled
|
|
27578
|
+
* by `strength`. The field evolves at `timeScale` units per second; lower
|
|
27579
|
+
* values produce slow-moving currents, higher values produce buzzy chaos.
|
|
27580
|
+
*
|
|
27581
|
+
* `frequency` controls the spatial granularity: small values (≈ 0.005)
|
|
27582
|
+
* yield broad swirls across the playfield, large values (≈ 0.1) produce
|
|
27583
|
+
* tight per-particle jitter.
|
|
27584
|
+
*
|
|
27585
|
+
* Use cases: smoke turbulence, organic swirls, wind eddies, dust haze.
|
|
27586
|
+
* Pair with {@link Drag} to keep particle velocities bounded.
|
|
27587
|
+
*
|
|
27588
|
+
* GPU-eligible. The noise function is identical on CPU and GPU so visual
|
|
27589
|
+
* results match across backends (modulo float precision).
|
|
27590
|
+
*/
|
|
27591
|
+
class Turbulence extends UpdateModule {
|
|
27592
|
+
strength;
|
|
27593
|
+
frequency;
|
|
27594
|
+
timeScale;
|
|
27595
|
+
_time = 0;
|
|
27596
|
+
constructor(strength, frequency = 0.01, timeScale = 1) {
|
|
27597
|
+
super();
|
|
27598
|
+
this.strength = strength;
|
|
27599
|
+
this.frequency = frequency;
|
|
27600
|
+
this.timeScale = timeScale;
|
|
27601
|
+
}
|
|
27602
|
+
apply(system, dt) {
|
|
27603
|
+
this._time += dt * this.timeScale;
|
|
27604
|
+
const t = this._time;
|
|
27605
|
+
const f = this.frequency;
|
|
27606
|
+
const s = this.strength * dt;
|
|
27607
|
+
const { posX, posY, velX, velY, liveCount } = system;
|
|
27608
|
+
for (let i = 0; i < liveCount; i++) {
|
|
27609
|
+
const x = posX[i] * f;
|
|
27610
|
+
const y = posY[i] * f;
|
|
27611
|
+
const nx = valueNoise2(x + t, y);
|
|
27612
|
+
const ny = valueNoise2(x, y + t + 17.31);
|
|
27613
|
+
velX[i] += (nx * 2 - 1) * s;
|
|
27614
|
+
velY[i] += (ny * 2 - 1) * s;
|
|
27615
|
+
}
|
|
27616
|
+
}
|
|
27617
|
+
wgsl() {
|
|
27618
|
+
return {
|
|
27619
|
+
key: 'Turbulence',
|
|
27620
|
+
uniforms: [
|
|
27621
|
+
{ name: 'strength', type: 'f32' },
|
|
27622
|
+
{ name: 'frequency', type: 'f32' },
|
|
27623
|
+
{ name: 'time', type: 'f32' },
|
|
27624
|
+
{ name: '_pad0', type: 'f32' },
|
|
27625
|
+
],
|
|
27626
|
+
prelude: `
|
|
27627
|
+
fn exojs_turbulence_hash21(p: vec2<f32>) -> f32 {
|
|
27628
|
+
let n = sin(dot(p, vec2<f32>(127.1, 311.7))) * 43758.5453;
|
|
27629
|
+
return fract(n);
|
|
27630
|
+
}
|
|
27631
|
+
|
|
27632
|
+
fn exojs_turbulence_valueNoise2(x: f32, y: f32) -> f32 {
|
|
27633
|
+
let xi = floor(x);
|
|
27634
|
+
let yi = floor(y);
|
|
27635
|
+
let xf = x - xi;
|
|
27636
|
+
let yf = y - yi;
|
|
27637
|
+
let u = xf * xf * (3.0 - 2.0 * xf);
|
|
27638
|
+
let v = yf * yf * (3.0 - 2.0 * yf);
|
|
27639
|
+
let a = exojs_turbulence_hash21(vec2<f32>(xi, yi));
|
|
27640
|
+
let b = exojs_turbulence_hash21(vec2<f32>(xi + 1.0, yi));
|
|
27641
|
+
let c = exojs_turbulence_hash21(vec2<f32>(xi, yi + 1.0));
|
|
27642
|
+
let d = exojs_turbulence_hash21(vec2<f32>(xi + 1.0, yi + 1.0));
|
|
27643
|
+
let ab = a + (b - a) * u;
|
|
27644
|
+
let cd = c + (d - c) * u;
|
|
27645
|
+
return ab + (cd - ab) * v;
|
|
27646
|
+
}
|
|
27647
|
+
`,
|
|
27648
|
+
body: `
|
|
27649
|
+
let turbF = modules.u_Turbulence.frequency;
|
|
27650
|
+
let turbT = modules.u_Turbulence.time;
|
|
27651
|
+
let turbS = modules.u_Turbulence.strength * dt;
|
|
27652
|
+
let turbX = positions[idx].x * turbF;
|
|
27653
|
+
let turbY = positions[idx].y * turbF;
|
|
27654
|
+
let turbNx = exojs_turbulence_valueNoise2(turbX + turbT, turbY);
|
|
27655
|
+
let turbNy = exojs_turbulence_valueNoise2(turbX, turbY + turbT + 17.31);
|
|
27656
|
+
velocities[idx] = velocities[idx] + vec2<f32>(turbNx * 2.0 - 1.0, turbNy * 2.0 - 1.0) * turbS;
|
|
27657
|
+
`,
|
|
27658
|
+
};
|
|
27659
|
+
}
|
|
27660
|
+
writeUniforms(view, offset, dt) {
|
|
27661
|
+
// GPU mode: apply() never runs, so advance _time here once per frame.
|
|
27662
|
+
// CPU mode: apply() already advances _time before update finishes,
|
|
27663
|
+
// and writeUniforms is not called → no double-advance.
|
|
27664
|
+
this._time += dt * this.timeScale;
|
|
27665
|
+
view.setFloat32(offset + 0, this.strength, true);
|
|
27666
|
+
view.setFloat32(offset + 4, this.frequency, true);
|
|
27667
|
+
view.setFloat32(offset + 8, this._time, true);
|
|
27668
|
+
view.setFloat32(offset + 12, 0, true);
|
|
27669
|
+
}
|
|
27670
|
+
}
|
|
27671
|
+
function hash21(x, y) {
|
|
27672
|
+
let n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
|
|
27673
|
+
n = n - Math.floor(n);
|
|
27674
|
+
return n;
|
|
27675
|
+
}
|
|
27676
|
+
function valueNoise2(x, y) {
|
|
27677
|
+
const xi = Math.floor(x);
|
|
27678
|
+
const yi = Math.floor(y);
|
|
27679
|
+
const xf = x - xi;
|
|
27680
|
+
const yf = y - yi;
|
|
27681
|
+
const u = xf * xf * (3 - 2 * xf);
|
|
27682
|
+
const v = yf * yf * (3 - 2 * yf);
|
|
27683
|
+
const a = hash21(xi, yi);
|
|
27684
|
+
const b = hash21(xi + 1, yi);
|
|
27685
|
+
const c = hash21(xi, yi + 1);
|
|
27686
|
+
const d = hash21(xi + 1, yi + 1);
|
|
27687
|
+
const ab = a + (b - a) * u;
|
|
27688
|
+
const cd = c + (d - c) * u;
|
|
27689
|
+
return ab + (cd - ab) * v;
|
|
27690
|
+
}
|
|
27691
|
+
|
|
27692
|
+
/**
|
|
27693
|
+
* Sub-emitter: triggers a child {@link SpawnModule} on a target system at
|
|
27694
|
+
* the dying particle's position. Use for explosion-on-impact, sparks at
|
|
27695
|
+
* end-of-life, multi-stage VFX.
|
|
27696
|
+
*
|
|
27697
|
+
* The child module receives a synthesized `dt` of 0 — it must spawn
|
|
27698
|
+
* immediately rather than rely on rate accumulation. {@link BurstSpawn}
|
|
27699
|
+
* works naturally; {@link RateSpawn} is the wrong fit here.
|
|
27700
|
+
*
|
|
27701
|
+
* Position is the only field forwarded — child distributions decide
|
|
27702
|
+
* everything else. To keep child particles riding the parent's velocity,
|
|
27703
|
+
* configure the child's velocity distribution to match.
|
|
27704
|
+
*/
|
|
27705
|
+
class SpawnOnDeath extends DeathModule {
|
|
27706
|
+
targetSystem;
|
|
27707
|
+
spawner;
|
|
27708
|
+
/** Number of times to invoke the spawner per dying particle. Default 1. */
|
|
27709
|
+
count;
|
|
27710
|
+
constructor(targetSystem, spawner, count = 1) {
|
|
27711
|
+
super();
|
|
27712
|
+
this.targetSystem = targetSystem;
|
|
27713
|
+
this.spawner = spawner;
|
|
27714
|
+
this.count = count;
|
|
27715
|
+
}
|
|
27716
|
+
onDeath(parent, slot) {
|
|
27717
|
+
const target = this.targetSystem;
|
|
27718
|
+
const x = parent.posX[slot];
|
|
27719
|
+
const y = parent.posY[slot];
|
|
27720
|
+
// Snapshot the target's pre-spawn count so we can apply the
|
|
27721
|
+
// position to whichever slots the spawner adds.
|
|
27722
|
+
const before = target.liveCount;
|
|
27723
|
+
for (let n = 0; n < this.count; n++) {
|
|
27724
|
+
this.spawner.apply(target, 0);
|
|
27725
|
+
}
|
|
27726
|
+
const added = target.liveCount - before;
|
|
27727
|
+
for (let i = 0; i < added; i++) {
|
|
27728
|
+
const dst = before + i;
|
|
27729
|
+
target.posX[dst] += x;
|
|
27730
|
+
target.posY[dst] += y;
|
|
27731
|
+
}
|
|
27732
|
+
}
|
|
27733
|
+
}
|
|
27734
|
+
|
|
27735
|
+
/**
|
|
27736
|
+
* Immediate-mode 2D shape API backed by {@link Mesh} children.
|
|
27737
|
+
*
|
|
27738
|
+
* Each draw call (e.g. `drawCircle`, `drawRectangle`, `drawLine`) appends a
|
|
27739
|
+
* new {@link Mesh} child colored with the current `fillColor` or `lineColor`.
|
|
27740
|
+
* The active `lineWidth` controls stroke thickness for path and outline draws.
|
|
27741
|
+
* Path commands (`moveTo`, `lineTo`, `quadraticCurveTo`, etc.) track a cursor
|
|
27742
|
+
* point and flush a Mesh on each segment.
|
|
25725
27743
|
*
|
|
25726
27744
|
* Call {@link clear} to remove all child meshes and reset pen state. Because
|
|
25727
27745
|
* each shape is a separate Mesh, `Graphics` inherits full filter, blend,
|
|
@@ -25850,10 +27868,10 @@ class Graphics extends Container {
|
|
|
25850
27868
|
}
|
|
25851
27869
|
let sweep = endAngle - startAngle;
|
|
25852
27870
|
if (!anticlockwise && sweep < 0) {
|
|
25853
|
-
sweep += tau;
|
|
27871
|
+
sweep += tau$2;
|
|
25854
27872
|
}
|
|
25855
27873
|
else if (anticlockwise && sweep > 0) {
|
|
25856
|
-
sweep -= tau;
|
|
27874
|
+
sweep -= tau$2;
|
|
25857
27875
|
}
|
|
25858
27876
|
if (sweep === 0) {
|
|
25859
27877
|
return this;
|
|
@@ -26013,109 +28031,6 @@ function upgradeFragmentShaderToGl300(source) {
|
|
|
26013
28031
|
return header + transformed;
|
|
26014
28032
|
}
|
|
26015
28033
|
|
|
26016
|
-
/**
|
|
26017
|
-
* Slices a single {@link Texture} into named frames and optional named
|
|
26018
|
-
* animation sequences.
|
|
26019
|
-
*
|
|
26020
|
-
* Each frame is stored as both a {@link Rectangle} (the pixel region) and a
|
|
26021
|
-
* pre-configured {@link Sprite} ready for direct rendering. Animations are
|
|
26022
|
-
* ordered lists of frame names that {@link AnimatedSprite.fromSpritesheet}
|
|
26023
|
-
* consumes to create playback clips.
|
|
26024
|
-
*/
|
|
26025
|
-
class Spritesheet {
|
|
26026
|
-
texture;
|
|
26027
|
-
frames = new Map();
|
|
26028
|
-
sprites = new Map();
|
|
26029
|
-
animations = new Map();
|
|
26030
|
-
constructor(texture, data) {
|
|
26031
|
-
this.texture = texture;
|
|
26032
|
-
this.parse(data);
|
|
26033
|
-
}
|
|
26034
|
-
/**
|
|
26035
|
-
* Parse a {@link SpritesheetData} descriptor, populating `frames`,
|
|
26036
|
-
* `sprites`, and `animations`. When `keepFrames` is `false` (default),
|
|
26037
|
-
* all existing frames and sprites are destroyed before parsing.
|
|
26038
|
-
*/
|
|
26039
|
-
parse(data, keepFrames = false) {
|
|
26040
|
-
if (!keepFrames) {
|
|
26041
|
-
this.clear();
|
|
26042
|
-
}
|
|
26043
|
-
for (const [name, frame] of Object.entries(data.frames)) {
|
|
26044
|
-
this.addFrame(name, frame);
|
|
26045
|
-
}
|
|
26046
|
-
if (data.animations) {
|
|
26047
|
-
for (const [animationName, frameNames] of Object.entries(data.animations)) {
|
|
26048
|
-
this.defineAnimation(animationName, frameNames);
|
|
26049
|
-
}
|
|
26050
|
-
}
|
|
26051
|
-
}
|
|
26052
|
-
/** Register a single frame by name, creating its {@link Rectangle} and pre-configured {@link Sprite}. */
|
|
26053
|
-
addFrame(name, data) {
|
|
26054
|
-
const { x, y, w, h } = data.frame;
|
|
26055
|
-
const frame = new Rectangle(x, y, w, h);
|
|
26056
|
-
const sprite = new Sprite(this.texture);
|
|
26057
|
-
sprite.setTextureFrame(frame);
|
|
26058
|
-
this.frames.set(name, frame);
|
|
26059
|
-
this.sprites.set(name, sprite);
|
|
26060
|
-
}
|
|
26061
|
-
/** Register an animation sequence as an ordered list of frame names. All referenced frames must already exist. */
|
|
26062
|
-
defineAnimation(name, frameNames) {
|
|
26063
|
-
if (name.trim().length === 0) {
|
|
26064
|
-
throw new Error('Spritesheet animation names must be non-empty strings.');
|
|
26065
|
-
}
|
|
26066
|
-
if (!Array.isArray(frameNames) || frameNames.length === 0) {
|
|
26067
|
-
throw new Error(`Spritesheet animation "${name}" must reference at least one frame.`);
|
|
26068
|
-
}
|
|
26069
|
-
for (const frameName of frameNames) {
|
|
26070
|
-
if (!this.frames.has(frameName)) {
|
|
26071
|
-
throw new Error(`Spritesheet animation "${name}" references missing frame "${frameName}".`);
|
|
26072
|
-
}
|
|
26073
|
-
}
|
|
26074
|
-
this.animations.set(name, [...frameNames]);
|
|
26075
|
-
return this;
|
|
26076
|
-
}
|
|
26077
|
-
/** Return the {@link Rectangle} for the named frame. Throws if the frame does not exist. */
|
|
26078
|
-
getFrame(name) {
|
|
26079
|
-
const frame = this.frames.get(name);
|
|
26080
|
-
if (!frame) {
|
|
26081
|
-
throw new Error(`Spritesheet frame named ${name} is not available!`);
|
|
26082
|
-
}
|
|
26083
|
-
return frame;
|
|
26084
|
-
}
|
|
26085
|
-
/** Return the ordered frame-name list for the named animation. Throws if the animation does not exist. */
|
|
26086
|
-
getAnimationFrameNames(name) {
|
|
26087
|
-
const frames = this.animations.get(name);
|
|
26088
|
-
if (!frames) {
|
|
26089
|
-
throw new Error(`Spritesheet animation named ${name} is not available!`);
|
|
26090
|
-
}
|
|
26091
|
-
return frames;
|
|
26092
|
-
}
|
|
26093
|
-
/** Return the pre-configured {@link Sprite} for the named frame. Throws if the frame does not exist. */
|
|
26094
|
-
getFrameSprite(name) {
|
|
26095
|
-
const sprite = this.sprites.get(name);
|
|
26096
|
-
if (!sprite) {
|
|
26097
|
-
throw new Error(`Spritesheet frame named ${name} is not available!`);
|
|
26098
|
-
}
|
|
26099
|
-
return sprite;
|
|
26100
|
-
}
|
|
26101
|
-
/** Destroy all registered frames, sprites, and animations, resetting the spritesheet to an empty state. */
|
|
26102
|
-
clear() {
|
|
26103
|
-
for (const frame of this.frames.values()) {
|
|
26104
|
-
frame.destroy();
|
|
26105
|
-
}
|
|
26106
|
-
this.frames.clear();
|
|
26107
|
-
for (const sprite of this.sprites.values()) {
|
|
26108
|
-
sprite.destroy();
|
|
26109
|
-
}
|
|
26110
|
-
this.sprites.clear();
|
|
26111
|
-
this.animations.clear();
|
|
26112
|
-
return this;
|
|
26113
|
-
}
|
|
26114
|
-
destroy() {
|
|
26115
|
-
this.clear();
|
|
26116
|
-
}
|
|
26117
|
-
}
|
|
26118
|
-
|
|
26119
28034
|
const defaultClipFps = 12;
|
|
26120
28035
|
/**
|
|
26121
28036
|
* A {@link Sprite} that advances through a sequence of texture-frame
|
|
@@ -28227,5 +30142,5 @@ class IndexedDbStore {
|
|
|
28227
30142
|
}
|
|
28228
30143
|
}
|
|
28229
30144
|
|
|
28230
|
-
export { AbstractAssetFactory, AbstractMedia, AbstractWebGl2BatchedRenderer, AbstractWebGl2Renderer, AbstractWebGpuRenderer, AnimatedSprite, Application, ApplicationStatus, ArcadeStickGamepadMapping, AudioAnalyser, AudioBus, AudioFilter, AudioListener, AudioManager, BeatDetector, BinaryFactory, BlendModes, BlurFilter, Bounds, BufferTypes, BufferUsage, BundleLoadError, CacheFirstStrategy, CallbackRenderPass, Capabilities, ChannelOffset, ChannelSize, ChorusFilter, Circle, Clock, CollisionType, Color,
|
|
30145
|
+
export { AbstractAssetFactory, AbstractMedia, AbstractWebGl2BatchedRenderer, AbstractWebGl2Renderer, AbstractWebGpuRenderer, AlphaFadeOverLifetime, AnimatedSprite, Application, ApplicationStatus, ApplyForce, ArcadeStickGamepadMapping, AttractToPoint, AudioAnalyser, AudioBus, AudioFilter, AudioListener, AudioManager, BeatDetector, BinaryFactory, BlendModes, BlurFilter, Bounds, BoxArea, BufferTypes, BufferUsage, BundleLoadError, BurstSpawn, CacheFirstStrategy, CallbackRenderPass, Capabilities, ChannelOffset, ChannelSize, ChorusFilter, Circle, CircleArea, Clock, CollisionType, Color, ColorFilter, ColorOverLifetime, ColorOverSpeed, CompressorFilter, ConeDirection, Constant, Container, Curve, DeathModule, DelayFilter, Drag, Drawable, DuckingFilter, DynamicGlyphAtlas, Ease, Ellipse, Envelope, EqualizerFilter, FactoryRegistry, Filter, Flags, FontFactory, GameCubeGamepadMapping, Gamepad, GamepadAxis, GamepadButton, GamepadMapping, GamepadMappingFamily, GamepadPromptLayouts, GenericDualAnalogGamepadMapping, Gradient, GranularFilter, Graphics, HighpassFilter, ImageFactory, IndexedDbDatabase, IndexedDbStore, InputBinding, InputManager, InteractionEvent, InteractionManager, Interval, JoyConLeftGamepadMapping, JoyConRightGamepadMapping, Json, JsonFactory, Keyboard, Line, LineSegment, Loader, LowpassFilter, Matrix, Mesh, Music, MusicFactory, NetworkOnlyStrategy, ObservableSize, ObservableVector, OrbitalForce, OscillatorSound, ParticleSystem, PitchShiftFilter, PlayStationGamepadMapping, Pointer, PointerState, PointerStateFlag, PolarVector, Polygon, Quadtree, Random, Range, RateSpawn, Rectangle, RenderBackendType, RenderNode, RenderTarget, RenderTargetPass, RenderTexture, RendererRegistry, RenderingPrimitives, RepelFromPoint, ReverbFilter, RotateOverLifetime, Sampler, ScaleModes, ScaleOverLifetime, Scene, SceneManager, SceneNode, Segment, Shader, ShaderAttribute, ShaderPrimitives, ShaderUniform, Signal, Size, Sound, SoundFactory, SoundPoolStrategy, SpawnModule, SpawnOnDeath, Sprite, SpriteFlags, Spritesheet, SteamControllerGamepadMapping, SteamDeckGamepadMapping, SvgAsset, SvgFactory, SwitchProGamepadMapping, Text, TextAsset, TextFactory, TextStyle, Texture, TextureFactory, Time, Timer, Turbulence, Tween, TweenManager, TweenState, UpdateModule, Vector, VectorRange, VelocityOverLifetime, Video, VideoFactory, View, ViewFlags, VocoderFilter, VoronoiRegion, VttAsset, VttFactory, WasmFactory, WebGl2Backend, WebGl2MeshRenderer, WebGl2ParticleRenderer, WebGl2RenderBuffer, WebGl2ShaderBlock, WebGl2ShaderFilter, WebGl2SpriteRenderer, WebGl2VertexArrayObject, WebGpuBackend, WebGpuMeshRenderer, WebGpuParticleRenderer, WebGpuShaderFilter, WebGpuSpriteRenderer, WorkletFilter, WrapModes, XboxGamepadMapping, bezierCurveTo, buildCircle, buildEllipse, buildLine, buildPath, buildPolygon, buildRectangle, buildStar, builtInGamepadDefinitions, canvasSourceToDataUrl, clamp, createRenderStats, createWebGl2ShaderProgram, crossFade, decodeAudioData, defineAssetManifest, degreesPerRadian, degreesToRadians, determineMimeType, emptyArrayBuffer, getAudioContext, getAudioManager, getCanvasSourceSize, getCollisionCircleCircle, getCollisionCircleRectangle, getCollisionEllipseCircle, getCollisionEllipseRectangle, getCollisionPolygonCircle, getCollisionRectangleRectangle, getCollisionSat, getDistance, getOfflineAudioContext, getPreciseTime, getTextureSourceSize, getVoronoiRegion$1 as getVoronoiRegion, getWebGpuBlendState, hours, inRange, intersectionCircleCircle, intersectionCircleEllipse, intersectionCirclePoly, intersectionEllipseEllipse, intersectionEllipsePoly, intersectionLineCircle, intersectionLineEllipse, intersectionLineLine, intersectionLinePoly, intersectionLineRect, intersectionPointCircle, intersectionPointEllipse, intersectionPointLine, intersectionPointPoint, intersectionPointPoly, intersectionPointRect, intersectionPolyPoly, intersectionRectCircle, intersectionRectEllipse, intersectionRectPoly, intersectionRectRect, intersectionSat, isAudioContextReady, isPowerOfTwo, layoutText, lerp, matchesIds, maxPointers, milliseconds, minutes, noop$1 as noop, onAudioContextReady, parseGamepadDescriptor, pointerSlotSize, quadraticCurveTo, radiansPerDegree, radiansToDegrees, rand, registerWorkletProcessor, removeArrayItems, resetRenderStats, resolveDefinition, resolveGamepadDefinition, seconds, sign, stopEvent, substepSweep, supportsCodec, supportsEventOptions, supportsIndexedDb, supportsPointerEvents, supportsTouchEvents, supportsWebAudio, sweepCircleAgainst, sweepCircleVsCircle, sweepCircleVsRectangle, sweepRectangle, sweepRectangleAgainst, tau$2 as tau, trimRotation, upgradeFragmentShaderToGl300, webGl2PrimitiveArrayConstructors, webGl2PrimitiveByteSizeMapping, webGl2PrimitiveTypeNames, wgslFieldLayout, wgslUniformByteSize };
|
|
28231
30146
|
//# sourceMappingURL=exo.esm.js.map
|