@cyberluke/three-particles 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms-full.txt ADDED
@@ -0,0 +1,1005 @@
1
+ # @cyberluke/three-particles ??? Full API Reference
2
+
3
+ > Three.js-based high-performance particle system library
4
+ > License: MIT | Author: CyberLuke
5
+ > Repository: https://github.com/cyberluke/three-particles
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install @cyberluke/three-particles three
13
+ ```
14
+
15
+ Peer dependency: `three` ^0.182.0
16
+
17
+ ---
18
+
19
+ ## Main API
20
+
21
+ ### createParticleSystem(config: ParticleSystemConfig): ParticleSystem
22
+
23
+ Creates a new particle system and registers it for global updates.
24
+
25
+ ```typescript
26
+ import { createParticleSystem } from "@cyberluke/three-particles";
27
+
28
+ const system = createParticleSystem({
29
+ duration: 5,
30
+ looping: true,
31
+ maxParticles: 200,
32
+ startLifetime: 3,
33
+ startSpeed: 2,
34
+ startSize: 0.5,
35
+ emission: { rateOverTime: 30 },
36
+ shape: { shape: Shape.CONE, cone: { angle: 0.5, radius: 1 } },
37
+ });
38
+
39
+ scene.add(system.instance);
40
+ ```
41
+
42
+ ### updateParticleSystems(cycleData: CycleData): void
43
+
44
+ Updates all registered particle systems. Call this in your animation loop.
45
+
46
+ ```typescript
47
+ import { updateParticleSystems } from "@cyberluke/three-particles";
48
+
49
+ function animate() {
50
+ updateParticleSystems({
51
+ now: performance.now(),
52
+ delta: clock.getDelta(),
53
+ elapsed: clock.getElapsedTime(),
54
+ });
55
+ requestAnimationFrame(animate);
56
+ }
57
+ ```
58
+
59
+ ---
60
+
61
+ ## ParticleSystem (returned object)
62
+
63
+ | Property/Method | Type | Description |
64
+ |-----------------|------|-------------|
65
+ | `instance` | `THREE.Points \| THREE.Mesh` | The renderable object ??? add to scene (Mesh when using RendererType.INSTANCED or RendererType.MESH) |
66
+ | `pauseEmitter()` | `() => void` | Stop emitting new particles |
67
+ | `resumeEmitter()` | `() => void` | Resume emitting particles |
68
+ | `dispose()` | `() => void` | Destroy system, free resources |
69
+ | `update(cycleData)` | `(CycleData) => void` | Update this system individually |
70
+ | `updateConfig(config)` | `(Partial<ParticleSystemConfig>) => void` | Update configuration at runtime without recreating the system. System-level properties (gravity, force fields, noise, emission, color/size/opacity/rotation/velocity over lifetime) take effect immediately on the CPU backend. Per-particle spawn properties (startColor, startSize, etc.) affect only newly emitted particles. GPU backend limitation: modifier flags/curves are baked at creation and cannot change live. |
71
+ | `getActiveParticleCount()` | `() => number` | Number of currently alive particles. O(1) ??? derived from the internal free list. |
72
+ | `computeNode` | `unknown \| null` | GPU compute node for WebGPU dispatch. Non-null when GPU compute is active. Must be dispatched via `renderer.compute(system.computeNode)` every frame before `renderer.render()`. Null when CPU simulation is used. |
73
+
74
+ ---
75
+
76
+ ## CycleData
77
+
78
+ ```typescript
79
+ type CycleData = {
80
+ now: number; // Current timestamp in milliseconds (performance.now())
81
+ delta: number; // Time since last frame in seconds
82
+ elapsed: number; // Total elapsed time in seconds
83
+ };
84
+ ```
85
+
86
+ ---
87
+
88
+ ## ParticleSystemConfig ??? Complete Reference
89
+
90
+ ### General Properties
91
+
92
+ | Property | Type | Default | Description |
93
+ |----------|------|---------|-------------|
94
+ | `transform` | `Transform` | origin | Position, rotation, scale of emitter |
95
+ | `duration` | `number` | 5.0 | System duration in seconds |
96
+ | `looping` | `boolean` | true | Loop after duration ends |
97
+ | `startDelay` | `number \| {min,max}` | 0.0 | Delay before first emission |
98
+ | `maxParticles` | `number` | 100 | Maximum concurrent particles |
99
+ | `gravity` | `number` | 0.0 | Downward acceleration |
100
+ | `simulationSpace` | `SimulationSpace` | LOCAL | LOCAL or WORLD coordinate space |
101
+ | `simulationBackend` | `SimulationBackend` | AUTO | Simulation backend: AUTO (GPU if WebGPU available, else CPU), CPU, or GPU |
102
+
103
+ ### Start Properties
104
+
105
+ All start properties support three value types:
106
+ - **Constant**: `number` ??? fixed value
107
+ - **Random range**: `{ min: number, max: number }` ??? random between min and max
108
+ - **Curve**: `BezierCurve | EasingCurve` ??? value based on system lifetime
109
+
110
+ | Property | Default | Description |
111
+ |----------|---------|-------------|
112
+ | `startLifetime` | 5.0 | Particle lifetime in seconds |
113
+ | `startSpeed` | 1.0 | Initial particle speed |
114
+ | `startSize` | 1.0 | Initial particle size |
115
+ | `startOpacity` | 1.0 | Initial particle opacity (0-1) |
116
+ | `startRotation` | 0.0 | Initial rotation in degrees |
117
+ | `startColor` | white | `MinMaxColor` ??? color range for randomization |
118
+
119
+ ### startColor ??? MinMaxColor
120
+
121
+ ```typescript
122
+ type MinMaxColor = {
123
+ min?: Rgb; // { r?: number, g?: number, b?: number } ??? values 0.0 to 1.0
124
+ max?: Rgb;
125
+ };
126
+
127
+ // Example: random orange-to-yellow
128
+ startColor: {
129
+ min: { r: 1.0, g: 0.3, b: 0.0 },
130
+ max: { r: 1.0, g: 1.0, b: 0.0 },
131
+ }
132
+ ```
133
+
134
+ ---
135
+
136
+ ## Transform
137
+
138
+ ```typescript
139
+ type Transform = {
140
+ position?: THREE.Vector3;
141
+ rotation?: THREE.Vector3; // Radians per axis
142
+ scale?: THREE.Vector3;
143
+ };
144
+ ```
145
+
146
+ ---
147
+
148
+ ## Emission
149
+
150
+ ```typescript
151
+ type Emission = {
152
+ rateOverTime?: number | { min, max } | LifetimeCurve; // Particles per second (default: 10)
153
+ rateOverDistance?: number | { min, max } | LifetimeCurve; // Particles per unit moved (default: 0)
154
+ bursts?: Burst[]; // Instantaneous emissions
155
+ };
156
+ ```
157
+
158
+ ### Burst
159
+
160
+ ```typescript
161
+ type Burst = {
162
+ time: number; // Trigger time in seconds
163
+ count: number | { min: number, max: number }; // Particle count
164
+ cycles?: number; // Repeat count (default: 1)
165
+ interval?: number; // Seconds between cycles
166
+ probability?: number; // 0-1 chance of firing (default: 1)
167
+ };
168
+ ```
169
+
170
+ **Example ??? explosion with aftershocks:**
171
+ ```typescript
172
+ emission: {
173
+ rateOverTime: 0,
174
+ bursts: [
175
+ { time: 0, count: 100 }, // Main explosion
176
+ { time: 0.3, count: { min: 20, max: 40 } }, // First aftershock
177
+ { time: 0.5, count: 15, cycles: 4, interval: 0.1 }, // Debris pulses
178
+ ],
179
+ }
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Shape Configuration
185
+
186
+ ```typescript
187
+ type ShapeConfig = {
188
+ shape?: Shape; // SPHERE | CONE | CIRCLE | RECTANGLE | BOX
189
+ sphere?: Sphere;
190
+ cone?: Cone;
191
+ circle?: Circle;
192
+ rectangle?: Rectangle;
193
+ box?: Box;
194
+ };
195
+ ```
196
+
197
+ ### Shape.SPHERE
198
+
199
+ ```typescript
200
+ type Sphere = {
201
+ radius?: number; // Default: 1
202
+ radiusThickness?: number; // 0-1, where 1 = solid sphere (default: 1)
203
+ arc?: number; // Radians, partial sphere (default: 2??)
204
+ };
205
+ ```
206
+
207
+ ### Shape.CONE
208
+
209
+ ```typescript
210
+ type Cone = {
211
+ angle?: number; // Cone angle in radians (default: ~0.44)
212
+ radius?: number; // Base radius (default: 1)
213
+ radiusThickness?: number; // 0-1 (default: 1)
214
+ arc?: number; // Radians (default: 2??)
215
+ };
216
+ ```
217
+
218
+ ### Shape.CIRCLE
219
+
220
+ ```typescript
221
+ type Circle = {
222
+ radius?: number; // Default: 1
223
+ radiusThickness?: number; // 0-1 (default: 1)
224
+ arc?: number; // Radians (default: 2??)
225
+ };
226
+ ```
227
+
228
+ ### Shape.RECTANGLE
229
+
230
+ ```typescript
231
+ type Rectangle = {
232
+ rotation?: Point3D; // { x?, y?, z? } in radians
233
+ scale?: Point3D; // { x?, y?, z? } dimensions
234
+ };
235
+ ```
236
+
237
+ ### Shape.BOX
238
+
239
+ ```typescript
240
+ type Box = {
241
+ scale?: Point3D; // { x?, y?, z? } dimensions
242
+ emitFrom?: EmitFrom; // VOLUME | SHELL | EDGE
243
+ };
244
+ ```
245
+
246
+ ---
247
+
248
+ ## Lifetime Modifiers
249
+
250
+ ### velocityOverLifetime
251
+
252
+ Modifies particle velocity over its lifetime. Supports linear (directional) and orbital (rotational) velocity.
253
+
254
+ ```typescript
255
+ type VelocityOverLifetime = {
256
+ isActive: boolean;
257
+ linear: {
258
+ x?: number | { min, max } | LifetimeCurve;
259
+ y?: number | { min, max } | LifetimeCurve;
260
+ z?: number | { min, max } | LifetimeCurve;
261
+ };
262
+ orbital: { // Values in degrees
263
+ x?: number | { min, max } | LifetimeCurve;
264
+ y?: number | { min, max } | LifetimeCurve;
265
+ z?: number | { min, max } | LifetimeCurve;
266
+ };
267
+ };
268
+ ```
269
+
270
+ ### sizeOverLifetime
271
+
272
+ ```typescript
273
+ sizeOverLifetime: {
274
+ isActive: boolean;
275
+ lifetimeCurve: LifetimeCurve; // Multiplier on startSize
276
+ }
277
+ ```
278
+
279
+ ### opacityOverLifetime
280
+
281
+ ```typescript
282
+ opacityOverLifetime: {
283
+ isActive: boolean;
284
+ lifetimeCurve: LifetimeCurve; // Multiplier on startOpacity
285
+ }
286
+ ```
287
+
288
+ ### colorOverLifetime
289
+
290
+ Each RGB channel is a separate curve acting as a multiplier (0-1) on startColor.
291
+ **Important**: Set startColor to white `{ r:1, g:1, b:1 }` for full color transitions.
292
+
293
+ ```typescript
294
+ colorOverLifetime: {
295
+ isActive: boolean;
296
+ r: LifetimeCurve;
297
+ g: LifetimeCurve;
298
+ b: LifetimeCurve;
299
+ }
300
+ ```
301
+
302
+ ### rotationOverLifetime
303
+
304
+ ```typescript
305
+ rotationOverLifetime: {
306
+ isActive: boolean;
307
+ min?: number; // Minimum rotation speed
308
+ max?: number; // Maximum rotation speed
309
+ }
310
+ ```
311
+
312
+ ---
313
+
314
+ ## Noise
315
+
316
+ FBM (Fractal Brownian Motion) noise affecting particles dynamically.
317
+
318
+ ```typescript
319
+ type NoiseConfig = {
320
+ isActive: boolean;
321
+ useRandomOffset: boolean; // Randomize noise per particle
322
+ strength: number; // Overall noise strength (default: 1)
323
+ frequency: number; // Noise frequency (default: 0.5)
324
+ octaves: number; // FBM octaves (default: 1)
325
+ positionAmount: number; // Position displacement (default: 1)
326
+ rotationAmount: number; // Rotation displacement (default: 0)
327
+ sizeAmount: number; // Size displacement (default: 0)
328
+ };
329
+ ```
330
+
331
+ ---
332
+
333
+ ## Renderer
334
+
335
+ ```typescript
336
+ type Renderer = {
337
+ blending: THREE.Blending; // Default: NormalBlending
338
+ discardBackgroundColor: boolean; // Default: false
339
+ backgroundColorTolerance: number; // Default: 1.0
340
+ backgroundColor: Rgb; // Default: { r:0, g:0, b:0 }
341
+ transparent: boolean; // Default: true
342
+ depthTest: boolean; // Default: true
343
+ depthWrite: boolean; // Default: false
344
+ rendererType?: RendererType; // Default: RendererType.POINTS
345
+ trail?: TrailConfig; // Only for RendererType.TRAIL
346
+ mesh?: MeshConfig; // Only for RendererType.MESH
347
+ };
348
+
349
+ enum RendererType {
350
+ POINTS = 'POINTS', // Classic point sprites (THREE.Points). Default.
351
+ INSTANCED = 'INSTANCED', // Camera-facing quads via InstancedBufferGeometry.
352
+ // Removes gl_PointSize hardware limit, supports large particles.
353
+ // Recommended for 10 000+ particles or large on-screen sizes.
354
+ TRAIL = 'TRAIL', // Ribbon trails behind particles. Each particle records a position
355
+ // history and the renderer builds a camera-facing triangle-strip
356
+ // ribbon through those samples.
357
+ MESH = 'MESH', // 3D mesh particles via GPU instancing. Each particle is rendered
358
+ // as a full 3D mesh with quaternion-based rotation, normals, and
359
+ // directional lighting. Any THREE.BufferGeometry can be used.
360
+ }
361
+
362
+ type MeshConfig = {
363
+ geometry: THREE.BufferGeometry; // The geometry to render for each particle
364
+ };
365
+ ```
366
+
367
+ Common blending modes: `THREE.NormalBlending`, `THREE.AdditiveBlending`, `THREE.SubtractiveBlending`
368
+
369
+ ### Mesh Particle Configuration
370
+
371
+ When using `RendererType.MESH`, configure mesh-specific properties via `renderer.mesh`:
372
+
373
+ ```typescript
374
+ renderer: {
375
+ rendererType: RendererType.MESH,
376
+ blending: THREE.NormalBlending,
377
+ transparent: true,
378
+ depthTest: true,
379
+ depthWrite: true,
380
+ mesh: {
381
+ geometry: new THREE.BoxGeometry(1, 1, 1),
382
+ },
383
+ }
384
+ ```
385
+
386
+ Mesh particle features:
387
+ - GPU instancing (`InstancedBufferGeometry`) ??? one draw call for all particles
388
+ - Quaternion-based 3D rotation (particles rotate in all 3 axes)
389
+ - Normals preserved from the source geometry, simple directional lighting from camera
390
+ - Any `THREE.BufferGeometry` works: `BoxGeometry`, `SphereGeometry`, `IcosahedronGeometry`, custom meshes, etc.
391
+ - All modifiers work: sizeOverLifetime, colorOverLifetime, opacityOverLifetime, rotationOverLifetime, noise, force fields, sub-emitters
392
+ - Default texture: solid white 1??1 (preserves mesh shape); point/billboard renderers default to a circle texture
393
+ - Sub-emitter note: sub-emitters do not inherit `RendererType.MESH` or `RendererType.TRAIL` from the parent because mesh geometry and trail config cannot be passed through; sub-emitters fall back to their own `rendererType` or `POINTS` by default
394
+ - Note: `mesh.geometry` is a runtime object (not serializable to JSON); provide it programmatically
395
+
396
+ ### Trail / Ribbon Configuration
397
+
398
+ When using `RendererType.TRAIL`, configure trail-specific properties via `renderer.trail`:
399
+
400
+ ```typescript
401
+ type TrailConfig = {
402
+ length?: number; // Position history samples per particle. Default: 20
403
+ width?: number; // Base ribbon width in world units. Default: 1.0
404
+ widthOverTrail?: LifetimeCurve; // Width taper from head (0) to tail (1)
405
+ opacityOverTrail?: LifetimeCurve; // Opacity taper from head (0) to tail (1)
406
+ colorOverTrail?: { // Optional per-channel color multiplier curves
407
+ isActive: boolean;
408
+ r: LifetimeCurve; // Red channel multiplier (0=head, 1=tail)
409
+ g: LifetimeCurve; // Green channel multiplier
410
+ b: LifetimeCurve; // Blue channel multiplier
411
+ };
412
+ };
413
+ ```
414
+
415
+ Trail features:
416
+ - GPU billboard rendering (vertex shader computes perpendicular offset from camera direction)
417
+ - Soft-edge fade on ribbon borders (no hard edges)
418
+ - Optional texture mapping (texture modulates brightness via luminance)
419
+ - `colorOverTrail` multiplies the particle's current color at each trail position ??? use white `startColor` for full color transitions
420
+ - Works with all particle features: colorOverLifetime, noise, gravity, force fields
421
+
422
+ Example ??? comet trail with color shift:
423
+
424
+ ```typescript
425
+ renderer: {
426
+ rendererType: RendererType.TRAIL,
427
+ blending: THREE.AdditiveBlending,
428
+ transparent: true,
429
+ depthWrite: false,
430
+ trail: {
431
+ length: 60,
432
+ width: 0.5,
433
+ widthOverTrail: {
434
+ type: LifeTimeCurve.BEZIER,
435
+ bezierPoints: [
436
+ { x: 0, y: 1, percentage: 0 },
437
+ { x: 0.5, y: 0.4 },
438
+ { x: 1, y: 0, percentage: 1 },
439
+ ],
440
+ },
441
+ colorOverTrail: {
442
+ isActive: true,
443
+ r: { type: LifeTimeCurve.BEZIER, bezierPoints: [{ x: 0, y: 0.2, percentage: 0 }, { x: 0.5, y: 0.5 }, { x: 1, y: 0.8, percentage: 1 }] },
444
+ g: { type: LifeTimeCurve.BEZIER, bezierPoints: [{ x: 0, y: 1.0, percentage: 0 }, { x: 0.5, y: 0.5 }, { x: 1, y: 0.1, percentage: 1 }] },
445
+ b: { type: LifeTimeCurve.BEZIER, bezierPoints: [{ x: 0, y: 0.4, percentage: 0 }, { x: 0.5, y: 0.9 }, { x: 1, y: 1.0, percentage: 1 }] },
446
+ },
447
+ },
448
+ }
449
+ ```
450
+
451
+ ---
452
+
453
+ ## Texture Sheet Animation
454
+
455
+ Animate sprite sheets over particle lifetime.
456
+
457
+ ```typescript
458
+ type TextureSheetAnimation = {
459
+ tiles?: THREE.Vector2; // Grid dimensions (default: 1x1)
460
+ timeMode?: TimeMode; // LIFETIME or FPS (default: LIFETIME)
461
+ fps?: number; // Frames per second (default: 30)
462
+ startFrame?: number | { min, max }; // Starting frame (default: 0)
463
+ };
464
+ ```
465
+
466
+ ---
467
+
468
+ ## Texture (map)
469
+
470
+ ```typescript
471
+ const texture = new THREE.TextureLoader().load("particle.png");
472
+ const system = createParticleSystem({
473
+ map: texture,
474
+ // ...
475
+ });
476
+ ```
477
+
478
+ ---
479
+
480
+ ## Curve Types
481
+
482
+ ### BezierCurve
483
+
484
+ ```typescript
485
+ {
486
+ type: LifeTimeCurve.BEZIER,
487
+ scale: 1, // Optional multiplier
488
+ bezierPoints: [
489
+ { x: 0, y: 0, percentage: 0 }, // Start point
490
+ { x: 0.3, y: 0.8 }, // Control point
491
+ { x: 0.7, y: 0.2 }, // Control point
492
+ { x: 1, y: 1, percentage: 1 }, // End point
493
+ ],
494
+ }
495
+ ```
496
+
497
+ ### EasingCurve
498
+
499
+ ```typescript
500
+ {
501
+ type: LifeTimeCurve.EASING,
502
+ scale: 1, // Optional multiplier
503
+ curveFunction: (time: number) => number, // 0???1 input, any output
504
+ }
505
+ ```
506
+
507
+ ---
508
+
509
+ ## Enums
510
+
511
+ ### Shape
512
+ `SPHERE` | `CONE` | `CIRCLE` | `RECTANGLE` | `BOX`
513
+
514
+ ### EmitFrom
515
+ `VOLUME` | `SHELL` | `EDGE`
516
+
517
+ ### SimulationSpace
518
+ `LOCAL` | `WORLD`
519
+
520
+ - **`LOCAL`** (default): particles are children of the emitter in the scene
521
+ graph and move/rotate with it. The buffer stores positions in the
522
+ emitter's local frame.
523
+ - **`WORLD`**: particles are decoupled from the emitter once spawned. The
524
+ buffer stores world coordinates directly and `instance.matrixWorld` is
525
+ held at identity. New particles spawn from the emitter's current world
526
+ pose (parent chain + `instance.position` / `instance.rotation`), so
527
+ moving the emitter only affects future emissions, not existing
528
+ particles. The shape-emission offset is scaled by the emitter's world
529
+ scale at spawn time (matching Unity's Shape module with `Scaling Mode =
530
+ Local/Hierarchy`); live particles are unaffected by later scale
531
+ changes. Matches Unity's world simulation space.
532
+
533
+ ### TimeMode
534
+ `LIFETIME` | `FPS`
535
+
536
+ ### LifeTimeCurve
537
+ `BEZIER` | `EASING`
538
+
539
+ ### RendererType
540
+ `POINTS` (default ??? classic point sprites) | `INSTANCED` (GPU instanced quads, no gl_PointSize limit) | `TRAIL` (ribbon trails behind particles) | `MESH` (3D mesh particles via GPU instancing)
541
+
542
+ ### CollisionPlaneMode
543
+ `KILL` (deactivate particle immediately) | `CLAMP` (stop particle at the plane surface) | `BOUNCE` (reflect velocity with optional dampen)
544
+
545
+ ### SimulationBackend
546
+ `AUTO` (default ??? GPU compute when the WebGPU path is registered via `enableWebGPU()`, else CPU) | `CPU` (always JavaScript update loop) | `GPU` (same as AUTO ??? GPU compute requires the registered WebGPU path)
547
+
548
+ ---
549
+
550
+ ## Callbacks
551
+
552
+ ```typescript
553
+ createParticleSystem({
554
+ // Called every frame
555
+ onUpdate: ({ particleSystem, delta, elapsed, lifetime, iterationCount }) => {
556
+ // particleSystem: THREE.Points or THREE.Mesh instance
557
+ // delta: seconds since last frame
558
+ // elapsed: total seconds
559
+ // lifetime: system duration
560
+ // iterationCount: number of completed loops
561
+ },
562
+
563
+ // Called when a non-looping system completes, or each loop iteration ends
564
+ onComplete: () => {
565
+ console.log("Particle system iteration complete");
566
+ },
567
+ });
568
+ ```
569
+
570
+ ---
571
+
572
+ ## Complete Example ??? Fire Effect
573
+
574
+ ```typescript
575
+ import * as THREE from "three";
576
+ import {
577
+ createParticleSystem,
578
+ updateParticleSystems,
579
+ Shape,
580
+ LifeTimeCurve,
581
+ SimulationSpace,
582
+ } from "@cyberluke/three-particles";
583
+
584
+ const fireTexture = new THREE.TextureLoader().load("fire-particle.png");
585
+
586
+ const fire = createParticleSystem({
587
+ duration: 5,
588
+ looping: true,
589
+ maxParticles: 200,
590
+ startLifetime: { min: 0.5, max: 1.5 },
591
+ startSpeed: { min: 1, max: 3 },
592
+ startSize: { min: 0.3, max: 0.8 },
593
+ startColor: {
594
+ min: { r: 1.0, g: 0.2, b: 0.0 },
595
+ max: { r: 1.0, g: 0.8, b: 0.0 },
596
+ },
597
+ gravity: -1,
598
+ simulationSpace: SimulationSpace.WORLD,
599
+ emission: { rateOverTime: 50 },
600
+ shape: {
601
+ shape: Shape.CONE,
602
+ cone: { angle: 0.2, radius: 0.3, radiusThickness: 1 },
603
+ },
604
+ map: fireTexture,
605
+ renderer: {
606
+ blending: THREE.AdditiveBlending,
607
+ transparent: true,
608
+ depthTest: true,
609
+ depthWrite: false,
610
+ discardBackgroundColor: false,
611
+ backgroundColorTolerance: 1,
612
+ backgroundColor: { r: 0, g: 0, b: 0 },
613
+ },
614
+ sizeOverLifetime: {
615
+ isActive: true,
616
+ lifetimeCurve: {
617
+ type: LifeTimeCurve.BEZIER,
618
+ scale: 1,
619
+ bezierPoints: [
620
+ { x: 0, y: 1, percentage: 0 },
621
+ { x: 0.5, y: 0.5 },
622
+ { x: 1, y: 0, percentage: 1 },
623
+ ],
624
+ },
625
+ },
626
+ opacityOverLifetime: {
627
+ isActive: true,
628
+ lifetimeCurve: {
629
+ type: LifeTimeCurve.BEZIER,
630
+ scale: 1,
631
+ bezierPoints: [
632
+ { x: 0, y: 1, percentage: 0 },
633
+ { x: 0.7, y: 0.8 },
634
+ { x: 1, y: 0, percentage: 1 },
635
+ ],
636
+ },
637
+ },
638
+ });
639
+
640
+ scene.add(fire.instance);
641
+ ```
642
+
643
+ ---
644
+
645
+ ## Sub-Emitters
646
+
647
+ Trigger child particle systems when particles are born or die.
648
+
649
+ ```typescript
650
+ type SubEmitter = {
651
+ trigger: SubEmitterTrigger; // BIRTH or DEATH
652
+ config: ParticleSystemConfig; // The child particle system config
653
+ };
654
+
655
+ // Usage
656
+ createParticleSystem({
657
+ // ... main system config
658
+ subEmitters: [
659
+ { trigger: SubEmitterTrigger.BIRTH, config: sparkConfig },
660
+ { trigger: SubEmitterTrigger.DEATH, config: explosionConfig },
661
+ ],
662
+ });
663
+ ```
664
+
665
+ ### SubEmitterTrigger
666
+ - `BIRTH` ??? Spawns child system at the position of each newly created particle
667
+ - `DEATH` ??? Spawns child system at the position of each dying particle
668
+
669
+ ---
670
+
671
+ ## Force Fields
672
+
673
+ Apply forces to particles dynamically. Supports point attraction/repulsion and directional wind.
674
+
675
+ ```typescript
676
+ type ForceField = {
677
+ type: ForceFieldType; // POINT or DIRECTIONAL
678
+ position?: Point3D; // For POINT type ??? center of force
679
+ direction?: Point3D; // For DIRECTIONAL type ??? force direction
680
+ strength: number; // Force strength (negative = repulsion for POINT)
681
+ radius?: number; // For POINT type ??? area of effect
682
+ falloff?: ForceFieldFalloff; // NONE, LINEAR, or QUADRATIC
683
+ };
684
+ ```
685
+
686
+ ### ForceFieldType
687
+ - `POINT` ??? Attracts (positive strength) or repels (negative strength) particles toward/from a point
688
+ - `DIRECTIONAL` ??? Applies a constant force in a direction (e.g., wind)
689
+
690
+ ### ForceFieldFalloff
691
+ - `NONE` ??? Constant force regardless of distance
692
+ - `LINEAR` ??? Force decreases linearly with distance
693
+ - `QUADRATIC` ??? Force decreases with the square of distance (realistic gravity-like falloff)
694
+
695
+ **Example ??? Vortex with wind:**
696
+ ```typescript
697
+ createParticleSystem({
698
+ // ... particle config
699
+ forceFields: [
700
+ {
701
+ type: ForceFieldType.POINT,
702
+ position: { x: 0, y: 2, z: 0 },
703
+ strength: 5,
704
+ radius: 3,
705
+ falloff: ForceFieldFalloff.LINEAR,
706
+ },
707
+ {
708
+ type: ForceFieldType.DIRECTIONAL,
709
+ direction: { x: 1, y: 0, z: 0 },
710
+ strength: 2,
711
+ },
712
+ ],
713
+ });
714
+ ```
715
+
716
+ ---
717
+
718
+ ## Collision Planes
719
+
720
+ Infinite planes that constrain particle positions. When a particle crosses from the front side (positive normal direction) to the back side, the configured response mode is triggered.
721
+
722
+ ```typescript
723
+ type CollisionPlaneConfig = {
724
+ isActive?: boolean; // Whether this plane is active (default: true)
725
+ position?: Point3D; // A point on the plane surface (default: {x:0, y:0, z:0})
726
+ normal?: Point3D; // Plane normal vector, defines the "front" side (default: {x:0, y:1, z:0})
727
+ mode?: CollisionPlaneMode; // Response mode (default: KILL)
728
+ dampen?: number; // Velocity dampen factor for BOUNCE mode, 0-1 (default: 0)
729
+ lifetimeLoss?: number; // Fraction of start lifetime to subtract on collision, 0-1 (default: 0)
730
+ };
731
+ ```
732
+
733
+ ### CollisionPlaneMode
734
+ - `KILL` ??? Deactivate the particle immediately when it crosses the plane
735
+ - `CLAMP` ??? Stop the particle at the plane surface (zero velocity toward plane)
736
+ - `BOUNCE` ??? Reflect the velocity vector off the plane normal with optional dampen
737
+
738
+ **Example ??? Bouncy floor and kill ceiling:**
739
+ ```typescript
740
+ createParticleSystem({
741
+ // ... particle config
742
+ collisionPlanes: [
743
+ {
744
+ position: { x: 0, y: 0, z: 0 },
745
+ normal: { x: 0, y: 1, z: 0 },
746
+ mode: CollisionPlaneMode.BOUNCE,
747
+ dampen: 0.6,
748
+ },
749
+ {
750
+ position: { x: 0, y: 10, z: 0 },
751
+ normal: { x: 0, y: -1, z: 0 },
752
+ mode: CollisionPlaneMode.KILL,
753
+ },
754
+ ],
755
+ });
756
+ ```
757
+
758
+ ---
759
+
760
+ ## Serialization
761
+
762
+ Save and load particle system configurations as JSON.
763
+
764
+ ```typescript
765
+ import {
766
+ serializeParticleSystemConfig,
767
+ deserializeParticleSystemConfig,
768
+ } from "@cyberluke/three-particles";
769
+
770
+ // Serialize (config ??? JSON-safe object)
771
+ const json = serializeParticleSystemConfig(config);
772
+ const jsonString = JSON.stringify(json);
773
+
774
+ // Deserialize (JSON-safe object ??? config)
775
+ const restored = deserializeParticleSystemConfig(JSON.parse(jsonString));
776
+ const system = createParticleSystem(restored);
777
+ ```
778
+
779
+ ---
780
+
781
+ ## Usage with React Three Fiber
782
+
783
+ The library works with [React Three Fiber](https://github.com/pmndrs/react-three-fiber) without any additional wrapper package. Use `createParticleSystem` directly with React hooks:
784
+
785
+ ```tsx
786
+ import { useRef, useEffect } from "react";
787
+ import { useFrame } from "@react-three/fiber";
788
+ import {
789
+ createParticleSystem,
790
+ Shape,
791
+ type ParticleSystem,
792
+ } from "@cyberluke/three-particles";
793
+ import * as THREE from "three";
794
+
795
+ function FireEffect({ config }: { config?: Record<string, unknown> }) {
796
+ const groupRef = useRef<THREE.Group>(null);
797
+ const systemRef = useRef<ParticleSystem | null>(null);
798
+
799
+ useEffect(() => {
800
+ const system = createParticleSystem({
801
+ duration: 5,
802
+ looping: true,
803
+ maxParticles: 200,
804
+ startLifetime: { min: 0.5, max: 1.5 },
805
+ startSpeed: { min: 1, max: 3 },
806
+ startSize: { min: 0.3, max: 0.8 },
807
+ startColor: {
808
+ min: { r: 1, g: 0.2, b: 0 },
809
+ max: { r: 1, g: 0.8, b: 0 },
810
+ },
811
+ gravity: -1,
812
+ emission: { rateOverTime: 50 },
813
+ shape: { shape: Shape.CONE, cone: { angle: 0.2, radius: 0.3 } },
814
+ renderer: {
815
+ blending: THREE.AdditiveBlending,
816
+ transparent: true,
817
+ depthWrite: false,
818
+ },
819
+ ...config,
820
+ });
821
+
822
+ systemRef.current = system;
823
+ groupRef.current?.add(system.instance);
824
+
825
+ return () => {
826
+ system.dispose();
827
+ };
828
+ }, [config]);
829
+
830
+ useFrame((_, delta) => {
831
+ systemRef.current?.update({
832
+ now: performance.now(),
833
+ delta,
834
+ elapsed: 0,
835
+ });
836
+ });
837
+
838
+ return <group ref={groupRef} />;
839
+ }
840
+ ```
841
+
842
+ ### Key Integration Points
843
+
844
+ - **`useEffect`** ??? Create the particle system on mount, dispose on unmount
845
+ - **`useFrame`** ??? Drive per-frame updates using R3F's render loop (use `system.update()` for individual system control instead of the global `updateParticleSystems()`)
846
+ - **`<group ref>`** ??? Attach `system.instance` to a group so R3F manages the scene graph
847
+ - **Config changes** ??? Pass `config` as a dependency to `useEffect` to recreate on changes
848
+ - **No wrapper package needed** ??? The imperative API integrates naturally with React hooks
849
+
850
+ ---
851
+
852
+ ## WebGPU Compute Support
853
+
854
+ Optional GPU-accelerated particle simulation via Three.js WebGPU renderer and TSL (Three Shading Language). Offloads all per-particle physics and modifiers to GPU compute shaders, enabling **50K-350K+ particles** at interactive frame rates.
855
+
856
+ **Requirements:** Three.js r182+ with WebGPU build (`three/webgpu`), browser with WebGPU support (Chrome 113+, Edge 113+, Firefox Nightly). No breaking changes ??? all existing WebGL code works unchanged.
857
+
858
+ ### Setup
859
+
860
+ ```typescript
861
+ // 1. Create a WebGPU renderer
862
+ import * as THREE from "three/webgpu";
863
+ const renderer = new THREE.WebGPURenderer({ antialias: true });
864
+ await renderer.init();
865
+
866
+ // 2. Enable WebGPU support (once, before creating any particle system).
867
+ // Passing the renderer enables capability detection ??? with a renderer that
868
+ // cannot run compute shaders, registration is skipped with a warning.
869
+ import { enableWebGPU } from "@cyberluke/three-particles/webgpu";
870
+ const gpuEnabled = enableWebGPU(renderer); // returns boolean
871
+ // No outputColorSpace override needed ??? the library uses the standard
872
+ // three.js linear workflow and works with the default (SRGBColorSpace).
873
+
874
+ // 3. Create a GPU-accelerated particle system
875
+ import { createParticleSystem, SimulationBackend } from "@cyberluke/three-particles";
876
+ const system = createParticleSystem({
877
+ simulationBackend: SimulationBackend.AUTO, // GPU if WebGPU available, else CPU
878
+ maxParticles: 100000,
879
+ // ... rest of config (same API as CPU)
880
+ });
881
+
882
+ scene.add(system.instance);
883
+
884
+ // 4. In your render loop ??? dispatch compute before rendering
885
+ function animate() {
886
+ system.update({ now: performance.now(), delta, elapsed });
887
+
888
+ if (system.computeNode) {
889
+ renderer.compute(system.computeNode);
890
+ }
891
+ renderer.render(scene, camera);
892
+ }
893
+ ```
894
+
895
+ For fine-grained control (e.g., selective registration of individual WebGPU functions), you can also use `registerTSLMaterialFactory()` directly ??? see the API reference below.
896
+
897
+ ### SimulationBackend
898
+
899
+ ```typescript
900
+ enum SimulationBackend {
901
+ AUTO = 'AUTO', // GPU compute when the WebGPU path is registered, else CPU (default)
902
+ CPU = 'CPU', // Always JavaScript update loop (works with any renderer)
903
+ GPU = 'GPU', // Same as AUTO ??? GPU compute requires the registered WebGPU path
904
+ }
905
+ ```
906
+
907
+ | Value | WebGPU path registered (`enableWebGPU()`) | Not registered |
908
+ |-------|------------------------------------------|----------------|
909
+ | `AUTO` | GPU compute | CPU (JavaScript) |
910
+ | `CPU` | CPU (JavaScript) | CPU (JavaScript) |
911
+ | `GPU` | GPU compute | CPU (JavaScript) |
912
+
913
+ Note: the library never inspects the renderer on its own ??? call
914
+ `enableWebGPU(renderer)` (returns boolean) to skip registration automatically
915
+ when the renderer cannot dispatch compute shaders (e.g. `WebGLRenderer`).
916
+
917
+ ### What Runs on GPU
918
+
919
+ - **Core physics:** gravity, velocity integration, position update, lifetime tracking, death detection
920
+ - **All 7 modifiers:** size over lifetime, opacity over lifetime, color over lifetime (per-channel RGB curves), rotation over lifetime, linear velocity over lifetime (per-axis X/Y/Z curves), orbital velocity, noise (3D simplex FBM)
921
+ - **Force fields:** point attractors/repulsors and directional forces with all falloff modes (NONE, LINEAR, QUADRATIC), up to 16 per system
922
+ - **Collision planes:** kill/clamp/bounce modes with dampen and lifetime loss, encoded as packed uniform buffer
923
+ - **Curves:** baked into 256-sample Float32Array lookup tables at system creation for fast GPU evaluation (<0.4% interpolation error)
924
+
925
+ ### What Stays on CPU
926
+
927
+ - **Emission:** particle activation, burst scheduling, rate-over-time, rate-over-distance
928
+ - **Sub-emitters:** birth/death trigger spawning (sub-emitters are always forced to `SimulationBackend.CPU`)
929
+ - **Configuration changes:** `updateConfig()` applies on the next frame
930
+ - **Trail renderer:** `RendererType.TRAIL` always uses CPU simulation (POINTS, INSTANCED, and MESH work with GPU compute)
931
+
932
+ ### ParticleSystem.computeNode
933
+
934
+ When GPU compute is active, the returned `ParticleSystem` object exposes a `computeNode` property. This must be dispatched every frame **before** `renderer.render()`:
935
+
936
+ ```typescript
937
+ const system = createParticleSystem({ simulationBackend: SimulationBackend.GPU, ... });
938
+
939
+ // In render loop:
940
+ if (system.computeNode) {
941
+ renderer.compute(system.computeNode); // Dispatch GPU compute
942
+ }
943
+ renderer.render(scene, camera);
944
+ ```
945
+
946
+ When CPU simulation is active (no WebGPU, or `simulationBackend: 'CPU'`), `computeNode` is `null`.
947
+
948
+ ### Fallback Behavior
949
+
950
+ WebGPU is fully opt-in and non-breaking:
951
+ - If `enableWebGPU()` not called (or no TSL factory registered via `registerTSLMaterialFactory()`), the library uses GLSL shaders (existing WebGL path) and CPU simulation
952
+ - If `enableWebGPU(renderer)` is called with a renderer that lacks compute support, registration is skipped with a console warning and everything stays on the CPU/GLSL path
953
+ - The same `ParticleSystemConfig` produces the same visuals on both backends. GPU limitation: modifier flags and lifetime curves (sizeOverLifetime, opacityOverLifetime, colorOverLifetime, rotationOverLifetime, velocityOverLifetime, noise.isActive) are baked into the compute kernel at creation ??? `updateConfig()` cannot change them live (a console warning is logged); recreate the system instead. On the CPU backend these update live.
954
+ - Renderer detection is duck-typed (checks for `.compute()` and `.hasFeature()` methods), not class-based
955
+
956
+ ### WebGPU with React Three Fiber
957
+
958
+ ```tsx
959
+ import { useRef, useEffect } from "react";
960
+ import { useFrame, useThree } from "@react-three/fiber";
961
+ import { createParticleSystem, SimulationBackend } from "@cyberluke/three-particles";
962
+ import { enableWebGPU } from "@cyberluke/three-particles/webgpu";
963
+
964
+ // Enable once at module level
965
+ enableWebGPU();
966
+
967
+ function GPUParticleEffect({ config }) {
968
+ const groupRef = useRef(null);
969
+ const systemRef = useRef(null);
970
+ const { gl: renderer } = useThree();
971
+
972
+ useEffect(() => {
973
+ const system = createParticleSystem({
974
+ simulationBackend: SimulationBackend.AUTO,
975
+ ...config,
976
+ });
977
+ systemRef.current = system;
978
+ groupRef.current?.add(system.instance);
979
+ return () => system.dispose();
980
+ }, [config]);
981
+
982
+ useFrame((_, delta) => {
983
+ const system = systemRef.current;
984
+ if (!system) return;
985
+ system.update({ now: performance.now(), delta, elapsed: 0 });
986
+ if (system.computeNode) {
987
+ renderer.compute(system.computeNode);
988
+ }
989
+ });
990
+
991
+ return <group ref={groupRef} />;
992
+ }
993
+ ```
994
+
995
+ **Important:** R3F must be configured to use `WebGPURenderer` (via the `gl` prop on `<Canvas>`) for GPU compute to activate.
996
+
997
+ ---
998
+
999
+ ## Links
1000
+
1001
+ - Repository: https://github.com/cyberluke/three-particles
1002
+ - API docs: shipped TypeScript definitions (dist/index.d.ts, webgpu.d.ts)
1003
+ - Visual Editor: https://github.com/cyberluke/three-particles-editor
1004
+ - Editor: @cyberluke/three-particles-editor
1005
+ - npm: https://www.npmjs.com/package/@cyberluke/three-particles