babylon.quarks 0.26.0 → 0.27.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +769 -8
  2. package/README.md +10 -8
  3. package/dist/babylon.quarks.cjs +1 -1
  4. package/dist/babylon.quarks.cjs.map +1 -1
  5. package/dist/babylon.quarks.esm.js +1 -1
  6. package/dist/babylon.quarks.esm.js.map +1 -1
  7. package/dist/babylon.quarks.umd.min.js +1 -1
  8. package/dist/babylon.quarks.umd.min.js.map +1 -1
  9. package/dist/types/index.d.cts +6683 -0
  10. package/dist/types/index.d.ts +1959 -588
  11. package/package.json +12 -6
  12. package/src/BatchedRenderer.ts +204 -101
  13. package/src/EffectPool.ts +4 -1
  14. package/src/MeshSurfaceEmitter.ts +29 -4
  15. package/src/ParticleEmitter.ts +45 -15
  16. package/src/ParticleSystem.ts +909 -2061
  17. package/src/QuarksLoader.ts +421 -67
  18. package/src/QuarksPrefab.ts +91 -4
  19. package/src/QuarksSerializer.ts +484 -0
  20. package/src/QuarksUtil.ts +15 -0
  21. package/src/SpriteBatch.ts +106 -246
  22. package/src/TrailBatch.ts +533 -342
  23. package/src/VFXBatch.ts +236 -136
  24. package/src/batchKey.ts +237 -0
  25. package/src/effectResources.ts +79 -0
  26. package/src/envAtlas.ts +119 -17
  27. package/src/index.ts +32 -33
  28. package/src/nodeId.ts +13 -0
  29. package/src/shaderProgram.ts +325 -0
  30. package/src/shaderVariants.ts +225 -0
  31. package/src/shaders/chunks/fog.ts +4 -2
  32. package/src/shaders/chunks/sheet.ts +111 -0
  33. package/src/shaders/chunks/soft.ts +84 -0
  34. package/src/shaders/chunks/surface.ts +10 -0
  35. package/src/shaders/local_particle_physics_vert.glsl.ts +14 -28
  36. package/src/shaders/local_particle_physics_vert.wgsl.ts +16 -29
  37. package/src/shaders/particle_frag.glsl.ts +3 -22
  38. package/src/shaders/particle_frag.wgsl.ts +3 -23
  39. package/src/shaders/particle_physics_frag.glsl.ts +12 -25
  40. package/src/shaders/particle_physics_frag.wgsl.ts +12 -26
  41. package/src/shaders/particle_vert.glsl.ts +5 -27
  42. package/src/shaders/particle_vert.wgsl.ts +5 -26
  43. package/src/shaders/stretched_bb_particle_vert.glsl.ts +7 -33
  44. package/src/shaders/stretched_bb_particle_vert.wgsl.ts +7 -33
  45. package/src/shaders/trail_frag.glsl.ts +11 -1
  46. package/src/shaders/trail_frag.wgsl.ts +11 -0
  47. package/src/shaders/trail_vert.glsl.ts +45 -36
  48. package/src/shaders/trail_vert.wgsl.ts +46 -40
  49. package/src/softDepth.ts +136 -0
  50. package/src/system/BehaviorPlan.ts +85 -0
  51. package/src/system/BoundsTracker.ts +181 -0
  52. package/src/system/EmissionController.ts +360 -0
  53. package/src/system/EventDispatcher.ts +44 -0
  54. package/src/system/ParticleSpawner.ts +274 -0
  55. package/src/system/SimulationClock.ts +130 -0
  56. package/src/system/geometry.ts +111 -0
  57. package/src/system/integrate.ts +428 -0
  58. package/src/system/materialResolve.ts +116 -0
  59. package/src/system/normalize.ts +95 -0
  60. package/src/system/serialization.ts +401 -0
  61. package/src/system/settingsSchema.ts +317 -0
  62. package/src/system/types.ts +410 -0
  63. package/src/textureSource.ts +30 -0
  64. package/src/trailSettings.ts +51 -0
  65. package/src/BatchedParticleRenderer.ts +0 -3
  66. package/src/materials/ParticleMaterials.ts +0 -23
  67. package/src/materials/index.ts +0 -1
  68. package/src/shaders/chunks/index.ts +0 -1
  69. package/src/shaders/chunks/register-shader-chunks.ts +0 -32
  70. package/src/shaders/chunks/soft_fragment.glsl.ts +0 -13
  71. package/src/shaders/chunks/soft_pars_fragment.glsl.ts +0 -20
  72. package/src/shaders/chunks/soft_pars_vertex.glsl.ts +0 -6
  73. package/src/shaders/chunks/soft_vertex.glsl.ts +0 -6
  74. package/src/shaders/chunks/tile_fragment.glsl.ts +0 -9
  75. package/src/shaders/chunks/tile_pars_fragment.glsl.ts +0 -9
  76. package/src/shaders/chunks/tile_pars_vertex.glsl.ts +0 -28
  77. package/src/shaders/chunks/tile_vertex.glsl.ts +0 -18
  78. package/src/shaders/index.ts +0 -9
  79. package/src/shaders/local_particle_vert.glsl.ts +0 -50
@@ -7,43 +7,31 @@ import {
7
7
  Behavior,
8
8
  BehaviorFromJSON,
9
9
  ColorGenerator,
10
- ColorGeneratorFromJSON,
11
10
  ConstantColor,
12
11
  ConstantValue,
13
12
  createSeededRandom,
14
13
  EmissionState,
15
14
  EmitSubParticleSystem,
16
- EmitterFromJSON,
17
15
  EmitterShape,
18
16
  FunctionColorGenerator,
19
- FunctionJSON,
20
17
  FunctionValueGenerator,
21
- FusionStep,
22
- GeneratorFromJSON,
23
- GeneratorMemory,
24
18
  IParticleSystem,
25
- Matrix3,
26
19
  Matrix4,
27
20
  Particle,
28
21
  ParticleStore,
29
22
  ParticleSystemEvent,
30
23
  ParticleSystemEventType,
31
- planBehaviorFusion,
32
24
  Quaternion,
33
- random,
34
25
  RandomSource,
35
26
  RendererEmitterSettings,
36
27
  RotationGenerator,
37
28
  SerializationOptions,
38
29
  setRandomSource,
39
30
  SphereEmitter,
40
- SpriteParticle,
41
31
  StretchedBillBoardSettings,
42
- SubEmitterInheritMask,
43
32
  TrailParticle,
44
33
  TrailSettings,
45
34
  ValueGenerator,
46
- ValueGeneratorFromJSON,
47
35
  Vector2,
48
36
  Vector3,
49
37
  Vector3Generator,
@@ -52,7 +40,54 @@ import {
52
40
  import {BatchedRenderer, VFXBatchSettings} from './BatchedRenderer';
53
41
  import {ensureEnvAtlasFromCube, getCachedEnvAtlas} from './envAtlas';
54
42
  import {ensureTriangleIndices} from './geometryUtil';
55
- import {ParticleEmitter} from './ParticleEmitter';
43
+ import {disposeEmitterForSystem, ParticleEmitter} from './ParticleEmitter';
44
+ import {BehaviorPlan} from './system/BehaviorPlan';
45
+ import {BoundsTracker} from './system/BoundsTracker';
46
+ import {burstInterval, burstWaveCount, EmissionController} from './system/EmissionController';
47
+ import {EventDispatcher} from './system/EventDispatcher';
48
+ import {
49
+ DEFAULT_INDICES,
50
+ DEFAULT_POSITIONS,
51
+ DEFAULT_UVS,
52
+ fallbackNormalsForPositions,
53
+ fallbackUVsForPositions,
54
+ isFallbackNormals,
55
+ isFallbackUVs,
56
+ } from './system/geometry';
57
+ import {
58
+ collapseTrailNewborns,
59
+ integrateSprites,
60
+ moveTrails,
61
+ recordTrailHistory,
62
+ removeDeadTrails,
63
+ runBehaviorSteps,
64
+ snapshotPreviousState,
65
+ } from './system/integrate';
66
+ import {liveAtlas, MaterialOverrides, resolveMaterialSettings} from './system/materialResolve';
67
+ import {
68
+ DEFAULT_SOFT_FAR_FADE,
69
+ nonZeroFlip,
70
+ nonZeroPivot,
71
+ normalizeBurst,
72
+ normalizeBursts,
73
+ validAlphaTest,
74
+ validCullingMode,
75
+ validLighting,
76
+ validMaxParticles,
77
+ validSoftFade,
78
+ validSortMode,
79
+ validTileCount,
80
+ } from './system/normalize';
81
+ import {ParticleSpawner} from './system/ParticleSpawner';
82
+ import {
83
+ particleSystemParametersFromJSON,
84
+ particleSystemToJSON,
85
+ trailCurve,
86
+ trailFlags,
87
+ trailSampling,
88
+ } from './system/serialization';
89
+ import {settingsForClone} from './system/settingsSchema';
90
+ import {SIMULATION_STEP, SimulationClock} from './system/SimulationClock';
56
91
  import {
57
92
  MeshLighting,
58
93
  MeshRim,
@@ -69,340 +104,119 @@ import {
69
104
  RenderMode,
70
105
  SortMode,
71
106
  TextureUV,
72
- textureUVToJSON,
73
- UV_DISTORTION_DEFAULT_UV,
74
107
  } from './VFXBatch';
75
108
 
76
- export interface BurstParameters {
77
- time: number;
78
- count: ValueGenerator | FunctionValueGenerator;
79
- cycle: number;
80
- interval: number;
81
- probability: number;
82
- }
109
+ import type {
110
+ BabylonMetaData,
111
+ BeamEnd,
112
+ BurstParameters,
113
+ CullingMode,
114
+ ParticleSystemJSONParameters,
115
+ ParticleSystemParameters,
116
+ ScalingMode,
117
+ } from './system/types';
118
+
119
+ export type {
120
+ BabylonMetaData,
121
+ BeamEnd,
122
+ BurstParameters,
123
+ BurstParametersJSON,
124
+ CullingMode,
125
+ ParticleSystemJSONParameters,
126
+ ParticleSystemParameters,
127
+ ScalingMode,
128
+ } from './system/types';
129
+
130
+ // Scratch for the per-step emitter matrix of a frame that interpolates, see `update`.
131
+ const fromPosition = new Vector3();
132
+ const fromRotation = new Quaternion();
133
+ const fromScale = new Vector3();
134
+ const toPosition = new Vector3();
135
+ const toRotation = new Quaternion();
136
+ const toScale = new Vector3();
137
+ const stepPosition = new Vector3();
138
+ const stepRotation = new Quaternion();
139
+ const stepScale = new Vector3();
83
140
 
84
141
  /**
85
- * What a system does while no camera can see its particles (Unity's Culling
86
- * Mode). The {@link BatchedRenderer} tests each system's bounds against the
87
- * active cameras' frusta every frame.
88
- *
89
- * - `'always'` keeps simulating off screen, so an effect is exactly where it
90
- * would have been when it comes back into view. The default, and the only
91
- * safe choice for an emitter whose particles reach the screen from off it.
92
- * - `'pause'` freezes the system off screen and resumes it where it left off.
93
- * - `'pauseAndCatchUp'` freezes it too, then runs the missed time (up to a few
94
- * seconds) within the renderer's catch-up budget when it is seen again.
142
+ * A particle system, the counterpart of a Unity Shuriken system or a Niagara emitter: it spawns
143
+ * particles from an {@link emitterShape}, gives them start values, runs its {@link behaviors} on
144
+ * them every step and hands them to a {@link BatchedRenderer} to draw.
95
145
  *
96
- * An empty system is never culled, so it can start emitting and grow the
97
- * bounds that decide its visibility. A system another one emits into is never
98
- * culled either; its parent decides.
99
- */
100
- export type CullingMode = 'always' | 'pause' | 'pauseAndCatchUp';
101
-
102
- /**
103
- * What the emitter's world scale does to a world-space system (Unity's
104
- * Scaling Mode, as far as it differs here). A local-space system lives in its
105
- * emitter's space, so its scale reaches everything either way.
146
+ * The system lives at its {@link emitter}, a Babylon node: move, parent or disable that to place
147
+ * the effect. Add the system to a renderer with `renderer.addSystem(system)`; the renderer
148
+ * advances it every frame on a fixed 60 Hz step and batches it with systems that draw alike.
106
149
  *
107
- * - `'shape'` (default): the scale sizes the emission shape and the
108
- * particles; speeds, forces and speed limits stay in world units, so a
109
- * scaled-down effect moves as far as the full-size one.
110
- * - `'hierarchy'`: the scale also multiplies start speeds (per axis),
111
- * `ForceOverLife` and `LimitSpeedOverLife` (by the mean of the axes), so the
112
- * whole effect, motion included, scales like a model would.
150
+ * Curves and random values are quarks.core generators (`ConstantValue`, `IntervalValue`,
151
+ * `PiecewiseBezier`, `Gradient`, ...); a curve's x is the system's time over its duration for
152
+ * start values and emission, and the particle's age over its life inside behaviors.
113
153
  */
114
- export type ScalingMode = 'shape' | 'hierarchy';
154
+ export class ParticleSystem implements IParticleSystem {
155
+ /** Dispose the system, its emitter and its batch slot when it finishes. Default false. */
156
+ autoDestroy: boolean;
157
+ /**
158
+ * Start a looping system as if it had already run one full cycle (Unity's Prewarm), so a fire
159
+ * or a mist is full from its first frame. Default false.
160
+ */
161
+ prewarm: boolean;
115
162
 
116
- /**
117
- * Where a {@link RenderMode.Beam} system's quads end: a point in world space,
118
- * or anything with a world position, such as a Babylon `TransformNode`, read
119
- * every frame.
120
- */
121
- export type BeamEnd = {x: number; y: number; z: number} | {getAbsolutePosition(): {x: number; y: number; z: number}};
122
-
123
- /** Particle backed by a {@link ParticleStore} row, as this system always creates. */
124
- type StoreBackedParticle = Particle & {storeIndex: number; setStoreIndex(index: number): void};
125
-
126
- const UP = new Vector3(0, 0, 1);
127
- const tempQ = new Quaternion();
128
- const IDENTITY_QUATERNION = new Quaternion();
129
- const tempV = new Vector3();
130
- const tempV2 = new Vector3();
131
- const PREWARM_FPS = 60;
132
- const SIMULATION_STEP = 1 / PREWARM_FPS;
133
- const MAX_SIMULATION_STEPS_PER_FRAME = 8;
134
- /** Most off-screen time a `'pauseAndCatchUp'` system replays on coming back into view. */
135
- const MAX_CATCH_UP_SECONDS = 2;
136
- /** Steps between full measurements of a system's bounds; see simulateStep. */
137
- const BOUNDS_REFRESH_STEPS = 4;
138
- // Math.round() breaks an exact tie (accumulator sitting at precisely half a
139
- // step) by rounding up. At exactly 120Hz that tie is not a corner case — it is
140
- // every single frame — and rounding it up would run a step on every one of
141
- // them instead of every other one. Nudging the tie down costs nothing on a
142
- // real display (real frame times essentially never land on the tie exactly)
143
- // and keeps the well-tested "half a step is no step yet" behavior.
144
- const ROUND_TIE_BIAS = 1e-9;
145
-
146
- /** Built-in behaviors that write a particle's size / colour, and those that touch neither. */
147
- const SIZE_BEHAVIORS = new Set(['SizeOverLife', 'SizeBySpeed']);
148
- const COLOR_BEHAVIORS = new Set(['ColorOverLife', 'ColorBySpeed']);
149
- const INERT_FOR_SIZE_AND_COLOR = new Set([
150
- 'ApplyForce',
151
- 'Noise',
152
- 'TurbulenceField',
153
- 'GravityForce',
154
- 'RotationOverLife',
155
- 'Rotation3DOverLife',
156
- 'RotationBySpeed',
157
- 'SpeedOverLife',
158
- 'FrameOverLife',
159
- 'ForceOverLife',
160
- 'VelocityOverLife',
161
- 'InheritVelocity',
162
- 'OrbitOverLife',
163
- 'WidthOverLength',
164
- 'ChangeEmitDirection',
165
- 'EmitSubParticleSystem',
166
- 'LimitSpeedOverLife',
167
- 'ApplyCollision',
168
- ]);
169
-
170
- const DEFAULT_POSITIONS = new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]);
171
- const DEFAULT_UVS = new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]);
172
-
173
- /** Zero UVs sized to a position buffer — missing/mismatched uv attrs → GL_INVALID_OPERATION on iOS. */
174
- const fallbackUVs = new WeakMap<Float32Array, Float32Array>();
175
- const fallbackNormals = new WeakMap<Float32Array, Float32Array>();
176
- /**
177
- * Zero UVs for a geometry given without any, one array per geometry: batches
178
- * are keyed by the UV array too, so systems sharing a geometry share it.
179
- */
180
- function fallbackUVsForPositions(positions: Float32Array): Float32Array {
181
- let uvs = fallbackUVs.get(positions);
182
- if (uvs === undefined || uvs.length !== (positions.length / 3) * 2) {
183
- uvs = new Float32Array((positions.length / 3) * 2);
184
- fallbackUVs.set(positions, uvs);
163
+ /**
164
+ * How much of the cycle {@link prewarm} simulates before the first frame, in seconds
165
+ * (Niagara's Warmup Time), on a looping system like `prewarm` itself. 0, the default, warms up a whole cycle, as Unity's Prewarm does; a
166
+ * shorter time warms up the cycle's last part only, with the bursts before it counted as
167
+ * fired, which is enough for an effect whose particles live shorter than its cycle and keeps
168
+ * a long cycle's first frame cheap. Longer than the cycle or than
169
+ * {@link maxPrewarmSeconds}, it is cut to them. Kept at 0 or above.
170
+ */
171
+ get prewarmTime(): number {
172
+ return this.prewarmTimeValue;
185
173
  }
186
- return uvs;
187
- }
188
- const DEFAULT_INDICES = new Uint32Array([0, 1, 2, 0, 2, 3]);
189
-
190
- /** The optional trail flags that are switched on, so JSON and clones only carry the ones in use. */
191
- function trailFlags(settings: TrailSettings | undefined): Partial<TrailSettings> {
192
- const flags: Partial<TrailSettings> = {};
193
- if (settings?.uvFromHead) flags.uvFromHead = true;
194
- if (settings?.colorFromParticle) flags.colorFromParticle = true;
195
- if (settings?.dieWithParticle) flags.dieWithParticle = true;
196
- if (settings?.recordWorldSpace) flags.recordWorldSpace = true;
197
- return flags;
198
- }
199
-
200
- /** A trail's side axis as JSON `[x, y, z]`, or nothing when unset. */
201
- function trailSideAxisJSON(settings: TrailSettings | undefined): {sideAxis?: number[]} {
202
- const axis = settings?.sideAxis;
203
- return axis ? {sideAxis: [axis.x, axis.y, axis.z]} : {};
204
- }
205
174
 
206
- /** A trail's side axis from JSON `[x, y, z]` (or an object with x, y, z), or nothing. */
207
- function trailSideAxisFromJSON(value: unknown): {sideAxis?: Vector3} {
208
- if (Array.isArray(value)) {
209
- return {sideAxis: new Vector3(Number(value[0]) || 0, Number(value[1]) || 0, Number(value[2]) || 0)};
175
+ set prewarmTime(seconds: number) {
176
+ this.prewarmTimeValue = seconds > 0 ? seconds : 0;
210
177
  }
211
- if (value && typeof value === 'object') {
212
- const v = value as {x?: number; y?: number; z?: number};
213
- return {sideAxis: new Vector3(v.x ?? 0, v.y ?? 0, v.z ?? 0)};
214
- }
215
- return {};
216
- }
217
-
218
- export interface ParticleSystemParameters {
219
- autoDestroy?: boolean;
220
- looping?: boolean;
221
- prewarm?: boolean;
222
- duration?: number;
223
- startDelay?: ValueGenerator | FunctionValueGenerator;
224
- shape?: EmitterShape;
225
- startLife?: ValueGenerator | FunctionValueGenerator;
226
- startSpeed?: ValueGenerator | FunctionValueGenerator;
227
- startRotation?: ValueGenerator | FunctionValueGenerator | RotationGenerator;
228
- startSize?: ValueGenerator | FunctionValueGenerator | Vector3Generator;
229
- startLength?: ValueGenerator | FunctionValueGenerator;
230
- startColor?: ColorGenerator | FunctionColorGenerator;
231
- emissionOverTime?: ValueGenerator | FunctionValueGenerator;
232
- emissionOverDistance?: ValueGenerator | FunctionValueGenerator;
233
- emissionBursts?: Array<BurstParameters>;
234
- /** Most particles alive at once; emission past it is dropped. Leave unset for no limit. */
235
- maxParticles?: number;
236
- onlyUsedByOther?: boolean;
237
- behaviors?: Array<Behavior>;
238
- instancingGeometry?: Float32Array;
239
- instancingIndices?: Uint32Array | Uint16Array;
240
- instancingUVs?: Float32Array;
241
- /**
242
- * A second UV set for the mesh (glTF `TEXCOORD_1`, Unity's `uv2`), what
243
- * textures on UV channel 1 read on a mesh particle; see `UVChannel`.
244
- * Default none: channel 1 reads the first set.
245
- */
246
- instancingUVs2?: Float32Array;
247
- instancingNormals?: Float32Array;
248
- renderMode?: RenderMode;
249
- rendererEmitterSettings?: RendererEmitterSettings;
250
- speedFactor?: number;
251
- material?: any;
252
- texture?: Texture | null;
253
- startTileIndex?: ValueGenerator;
254
- uTileCount?: number;
255
- vTileCount?: number;
256
- blendTiles?: boolean;
257
- softParticles?: boolean;
258
- softFarFade?: number;
259
- softNearFade?: number;
260
- renderOrder?: number;
261
- worldSpace?: boolean;
262
- blendMode?: number;
263
- transparent?: boolean;
264
- depthTest?: boolean;
265
- depthWrite?: boolean;
266
- alphaTest?: number;
267
- layerMask?: number;
268
- /** Billboard pivot in particle-size units, see {@link VFXBatchSettings.pivot}. */
269
- pivot?: Vector3;
270
- /** Fraction of billboard particles mirrored along U (x) and V (y), see {@link ParticleSystem.flip}. */
271
- flip?: Vector2;
272
- /** How mesh particles are lit, see {@link ParticleSystem.lighting}. Default `'fixed'`. */
273
- lighting?: MeshLighting;
274
- /** Rim (Fresnel) term on mesh particles, see {@link ParticleSystem.rim}. Default none. */
275
- rim?: Partial<MeshRim>;
276
- /** The main map's layout, see {@link ParticleSystem.mapUV}. Default as drawn. */
277
- mapUV?: Partial<TextureUV>;
278
- /** Colour ramp for the map, see {@link ParticleSystem.gradientMap}. Default none. */
279
- gradientMap?: Partial<ParticleGradientMap>;
280
- /** Alpha erosion, see {@link ParticleSystem.erosion}. Default none. */
281
- erosion?: ParticleErosionOptions;
282
- /** Map UV distortion, see {@link ParticleSystem.uvDistortion}. Default none. */
283
- uvDistortion?: ParticleUVDistortionOptions;
284
- /** Multiplier on the time the system is advanced by, see {@link ParticleSystem.playbackSpeed}. Default 1. */
285
- playbackSpeed?: number;
286
- /** What the system does while off screen, see {@link CullingMode}. Default `'always'`. */
287
- cullingMode?: CullingMode;
288
- /** What the emitter's scale does to a world-space system, see {@link ScalingMode}. Default `'shape'`. */
289
- scalingMode?: ScalingMode;
290
- /** Seed for this system's own random generator, see {@link ParticleSystem.seed}. Default none. */
291
- seed?: number;
292
- /** Draw order within the batch, see {@link ParticleSystem.sortMode}. Default `'none'`. */
293
- sortMode?: SortMode;
294
- /** Whether particles start turned along their direction of travel, see {@link ParticleSystem.alignToDirection}. Default false. */
295
- alignToDirection?: boolean;
296
- /** Smallest on-screen size, as a fraction of the viewport height, see {@link ParticleSystem.minParticleSize}. Default 0. */
297
- minParticleSize?: number;
298
- /** Largest on-screen size, as a fraction of the viewport height, see {@link ParticleSystem.maxParticleSize}. Default unlimited. */
299
- maxParticleSize?: number;
300
- /** Babylon rendering group of the batch, see {@link ParticleSystem.renderingGroupId}. Default 0. */
301
- renderingGroupId?: number;
302
- /** Whether the scene's fog applies, see {@link ParticleSystem.applyFog}. Default true. */
303
- applyFog?: boolean;
304
- scene?: Scene;
305
- }
306
-
307
- export interface BurstParametersJSON {
308
- time: number;
309
- count: FunctionJSON | number;
310
- cycle: number;
311
- interval: number;
312
- probability: number;
313
- }
314
178
 
315
- export interface ParticleSystemJSONParameters {
316
- version: string;
317
- autoDestroy: boolean;
179
+ private prewarmTimeValue = 0;
180
+ /** Start the cycle again when {@link duration} ends instead of stopping emission. Default true. */
318
181
  looping: boolean;
319
- prewarm: boolean;
320
- duration: number;
321
- startDelay?: FunctionJSON;
322
- shape: any;
323
- startLife: FunctionJSON;
324
- startSpeed: FunctionJSON;
325
- startRotation: FunctionJSON;
326
- startSize: FunctionJSON;
327
- startColor: FunctionJSON;
328
- emissionOverTime: FunctionJSON;
329
- emissionOverDistance: FunctionJSON;
330
- emissionBursts?: Array<BurstParametersJSON>;
331
- maxParticles?: number;
332
- onlyUsedByOther: boolean;
333
- rendererEmitterSettings: RendererEmitterSettings;
334
- instancingGeometry?: any;
335
- renderMode: number;
336
- renderOrder?: number;
337
- /** Written only when not 0. */
338
- renderingGroupId?: number;
339
- /** Written only when false. */
340
- applyFog?: boolean;
341
- /** Written only when not 1. */
342
- playbackSpeed?: number;
343
- /** Written only when not `'always'`. */
344
- cullingMode?: CullingMode;
345
- /** Written only when not `'shape'`. */
346
- scalingMode?: ScalingMode;
347
- /** Written only when set. */
348
- seed?: number;
349
- /** Written only when not `'none'`. */
350
- sortMode?: SortMode;
351
- /** Written only when true. */
352
- alignToDirection?: boolean;
353
- /** Written only when not 0. */
354
- minParticleSize?: number;
355
- /** Written only when limited. */
356
- maxParticleSize?: number;
357
- speedFactor?: number;
358
- texture?: string;
359
- material?: string;
360
- layers?: number;
361
- startTileIndex: FunctionJSON | number;
362
- uTileCount: number;
363
- vTileCount: number;
364
- blendTiles?: boolean;
365
- softParticles?: boolean;
366
- softFarFade?: number;
367
- softNearFade?: number;
368
- blending?: number;
369
- transparent?: boolean;
370
- depthTest?: boolean;
371
- depthWrite?: boolean;
372
- alphaTest?: number;
373
- behaviors: Array<any>;
374
- worldSpace: boolean;
375
- /** `[x, y, z]`; written only when non-zero. */
376
- pivot?: number[];
377
- /** `[u, v]`; written only when non-zero. */
378
- flip?: number[];
379
- }
380
-
381
- export interface BabylonMetaData {
382
- textures: {[uuid: string]: Texture | null};
383
- materials: {[uuid: string]: any};
384
- geometries: {[uuid: string]: any};
385
- images?: {[uuid: string]: any};
386
- shapes?: {[uuid: string]: any};
387
- skeletons?: {[uuid: string]: any};
388
- animations?: {[uuid: string]: any};
389
- nodes?: {[uuid: string]: any};
390
- }
182
+ /**
183
+ * Length of one emission cycle in seconds; curves over the system's time run over it. Default
184
+ * 1, and never shorter than one simulation step: a zero, negative or NaN duration is read as
185
+ * one step, so bursts at time 0 fire and the cycle ends.
186
+ */
187
+ get duration(): number {
188
+ return this.durationValue;
189
+ }
391
190
 
392
- export class ParticleSystem implements IParticleSystem {
393
- private static serializationCounter = 0;
191
+ set duration(seconds: number) {
192
+ this.durationValue = seconds > SIMULATION_STEP ? seconds : SIMULATION_STEP;
193
+ }
394
194
 
395
- autoDestroy: boolean;
396
- prewarm: boolean;
397
- looping: boolean;
398
- duration: number;
195
+ private durationValue = 1;
196
+ /** Seconds before the system first emits, rolled on each restart. Default 0. */
399
197
  startDelay: ValueGenerator | FunctionValueGenerator;
198
+ /** Seconds each particle lives, rolled at birth. Default 5. */
400
199
  startLife: ValueGenerator | FunctionValueGenerator;
200
+ /** Speed each particle starts with along the direction its shape gives it, in units per second. Default 0. */
401
201
  startSpeed: ValueGenerator | FunctionValueGenerator;
202
+ /**
203
+ * Rotation each particle starts with: an angle in radians around the view axis for billboards,
204
+ * or a rotation generator (`AxisAngleGenerator`, `EulerGenerator`, `RandomQuatGenerator`) for
205
+ * mesh particles. Default 0.
206
+ */
402
207
  startRotation: ValueGenerator | FunctionValueGenerator | RotationGenerator;
208
+ /** Size each particle starts with in world units; a `Vector3Generator` sizes each axis (Unity's 3D Start Size). Default 1. */
403
209
  startSize: ValueGenerator | FunctionValueGenerator | Vector3Generator;
210
+ /** Colour and alpha each particle starts with, multiplied with the texture. Default opaque white. */
404
211
  startColor: ColorGenerator | FunctionColorGenerator;
212
+ /** Flipbook cell each particle starts on, counted across rows, see {@link uTileCount}. Default 0. */
405
213
  startTileIndex: ValueGenerator;
214
+ /**
215
+ * The render mode's own settings: `TrailSettings` for a trail (`startLength` and the trail
216
+ * flags), `StretchedBillBoardSettings` for a stretched billboard (`speedFactor`,
217
+ * `lengthFactor`), `MeshSettings` or nothing otherwise. Setting {@link renderMode} replaces it
218
+ * with that mode's defaults.
219
+ */
406
220
  rendererEmitterSettings: RendererEmitterSettings;
407
221
  /**
408
222
  * Fraction of billboard particles mirrored along U (x) and V (y), each
@@ -410,98 +224,121 @@ export class ParticleSystem implements IParticleSystem {
410
224
  * Undefined: nothing is mirrored. Meshes ignore it.
411
225
  */
412
226
  flip?: Vector2;
227
+ /** Particles emitted per second, read over the system's time. Default 10. */
413
228
  emissionOverTime: ValueGenerator | FunctionValueGenerator;
229
+ /** Particles emitted per world unit the emitter moves (Unity's Rate over Distance). Default 0. */
414
230
  emissionOverDistance: ValueGenerator | FunctionValueGenerator;
231
+ /** Bursts of particles at set times in each cycle. Default none. */
415
232
  emissionBursts: Array<BurstParameters>;
416
- /** Most particles alive at once (Unity's Max Particles); emission past it is dropped. Undefined means no limit. */
233
+ /**
234
+ * Most particles alive at once (Unity's Max Particles); emission past it is dropped. Undefined
235
+ * means no limit, as does a negative or NaN count given to the constructor; a fraction is
236
+ * rounded down.
237
+ */
417
238
  maxParticles?: number;
239
+ /**
240
+ * The system emits nothing of its own and only spawns what an `EmitSubParticleSystem` on
241
+ * another system sends it (a Unity sub emitter). Default false.
242
+ */
418
243
  onlyUsedByOther: boolean;
244
+ /**
245
+ * Simulate in world space (Unity's Simulation Space: World): particles stay where they were
246
+ * born when the emitter moves. False keeps them in the emitter's space, so they move with it.
247
+ * Default false.
248
+ */
419
249
  worldSpace: boolean;
250
+ /** How many of {@link particles} are alive. Read only. */
420
251
  particleNum: number;
252
+ /** Set by {@link pause}: the renderer skips the system's simulation while true. */
421
253
  paused: boolean;
254
+ /** The particle pool; the first {@link particleNum} are alive. Read only. */
422
255
  particles: Array<Particle>;
423
- /** Column storage shared by this system's particles. */
256
+ /** Column storage shared by this system's particles; behaviors read and write it. */
424
257
  readonly store: ParticleStore = new ParticleStore(64);
258
+ /** Where particles are born and which way they start (Unity's Shape module). Default a `SphereEmitter`. */
425
259
  emitterShape: EmitterShape;
260
+ /** The node the system lives at; move, parent or disable it to place or hide the effect. */
426
261
  emitter: ParticleEmitter;
262
+ /** What the renderer batches on; read it with {@link getRendererSettings}. @internal */
427
263
  rendererSettings: VFXBatchSettings;
264
+ /**
265
+ * Set when a render setting changed, so the renderer moves the system to a matching batch.
266
+ * The system's setters set it; set it yourself after changing {@link getRendererSettings} directly.
267
+ */
428
268
  neededToUpdateRender: boolean;
269
+ /** Modules run on every particle each step, in order (`ColorOverLife`, `SizeOverLife`, forces, ...). */
429
270
  behaviors: Array<Behavior>;
271
+ /** Emission progress: time in the cycle, burst and distance counters. */
430
272
  emissionState: EmissionState;
273
+ /** The emitter's velocity over the last step, which `InheritVelocity` reads. */
431
274
  emitterVelocity: Vector3 = new Vector3();
432
275
 
433
276
  private prewarmed: boolean;
434
277
  private emitEnded: boolean;
435
- private markForDestroy: boolean;
436
- private finishedEventFired = false;
437
- private startDelayTimeLeft = 0;
278
+ /**
279
+ * Whether `finished` fired for this play; the spawner clears it when a sub emission refills an
280
+ * empty target.
281
+ * @internal
282
+ */
283
+ finishedEventFired = false;
284
+ /** The fixed-step clock: whole steps per frame, start delay and catch-up time. */
285
+ private readonly clock = new SimulationClock();
438
286
  private previousEmitterPos?: Vector3;
439
287
  private tempEmitterPos: Vector3 = new Vector3();
440
- private temp: Vector3 = new Vector3();
441
- /** Emitter matrix moved along the step's path, see spawnAlongPath. */
442
- private pathMatrix: Matrix4 = new Matrix4();
443
- private normalMatrix: Matrix3 = new Matrix3();
444
- private memory: GeneratorMemory = [];
445
- private listeners: {[event: string]: Array<(event: ParticleSystemEvent) => void>} = {};
288
+ /** The emitter matrix the last simulated step used, to interpolate from; see {@link update}. */
289
+ private previousEmitterMatrix?: Matrix4;
290
+ /** The emitter matrix handed to each step of a frame, moved along the emitter's path. */
291
+ private readonly stepMatrix = new Matrix4();
292
+ /** Makes the newborn particles, see {@link ParticleSpawner}. */
293
+ private readonly spawner = new ParticleSpawner(this);
294
+ /** Decides how many particles each step owes, see {@link EmissionController}. */
295
+ private readonly emission = new EmissionController(this, this.spawner);
296
+ private readonly events = new EventDispatcher();
446
297
  private readonly layerMaskProxy: {mask: number};
447
298
  private materialRef: any = null;
448
299
  private sceneRef: Scene | undefined;
449
300
  private qualityFactor = 1;
450
- private simulationAccumulator = 0;
451
301
  private destroyed = false;
452
- /** Behaviors the cached plan was built from, compared by identity. */
453
- private fusionPlan?: Array<Behavior>;
454
- private fusionSteps: Array<FusionStep> = [];
455
- /**
456
- * Whether any behavior in the plan can change a particle's size or colour
457
- * after birth. Only then does a step have to snapshot them for the renderer.
458
- */
459
- private planChangesSize = true;
460
- private planChangesColor = true;
302
+ /** How the behaviors run each step, rebuilt when the list changes. */
303
+ private readonly behaviorPlan = new BehaviorPlan();
461
304
  /** @internal **/
462
305
  _renderer?: BatchedRenderer;
463
306
 
307
+ /**
308
+ * Seconds into the current emission cycle. Setting it jumps emission to that point, as Unity's
309
+ * `Simulate(t)` does for the emission clock: the bursts before it count as fired, the ones
310
+ * after are due again; particles alive stay, and behaviors are not reset. A value that is not
311
+ * a finite, non-negative number is 0; one past the duration wraps on a looping system and is
312
+ * the duration otherwise. A system whose emission has ended stays ended: {@link restart} plays
313
+ * it again.
314
+ */
464
315
  set time(time: number) {
465
- this.emissionState.time = time;
316
+ const duration = this.duration;
317
+ let seconds = Number.isFinite(time) && time > 0 ? time : 0;
318
+ if (seconds >= duration) {
319
+ seconds = this.looping ? seconds % duration : duration;
320
+ }
321
+ const state = this.emissionState;
322
+ state.time = seconds;
323
+ state.isBursting = false;
324
+ state.burstIndex = 0;
325
+ state.burstWaveIndex = 0;
326
+ state.burstWaves = undefined;
327
+ this.markBurstsBefore(seconds);
466
328
  }
467
329
 
468
330
  get time(): number {
469
331
  return this.emissionState.time;
470
332
  }
471
333
 
334
+ /** Whether {@link endEmit} ran, or a non-looping system reached the end of its duration. */
472
335
  get isEmitEnded(): boolean {
473
336
  return this.emitEnded;
474
337
  }
475
338
 
476
339
  /** Whether continuous or queued emission can still spawn particles before the system is done. */
477
340
  hasPendingEmission(): boolean {
478
- if (this.looping) {
479
- return true;
480
- }
481
- if (this.emitEnded) {
482
- return false;
483
- }
484
- if (this.onlyUsedByOther) {
485
- return false;
486
- }
487
- const state = this.emissionState;
488
- if (state.waitEmiting > 0) {
489
- return true;
490
- }
491
- if (state.burstIndex < this.emissionBursts.length) {
492
- return true;
493
- }
494
- if (this.hasPendingBurstWaves(state)) {
495
- return true;
496
- }
497
- if (state.time >= this.duration) {
498
- return false;
499
- }
500
- const timeRatio = state.time / this.duration;
501
- if (this.emissionOverTime.genValue(this.memory, timeRatio) > 0) {
502
- return true;
503
- }
504
- return this.emissionOverDistance.genValue(this.memory, timeRatio) > 0;
341
+ return this.emission.hasPendingEmission();
505
342
  }
506
343
 
507
344
  /**
@@ -524,11 +361,24 @@ export class ParticleSystem implements IParticleSystem {
524
361
  return !this.hasPendingEmission();
525
362
  }
526
363
 
527
- get layers() {
364
+ /**
365
+ * The camera layer mask the particles draw for (Babylon's `mesh.layerMask`), as
366
+ * `system.layers.mask`. Default `0x0fffffff`, every camera's default mask. Systems that differ
367
+ * are batched apart.
368
+ */
369
+ get layers(): {
370
+ /** The mask; setting it moves the system to a matching batch. */
371
+ mask: number;
372
+ } {
528
373
  return this.layerMaskProxy;
529
374
  }
530
375
 
531
- get texture() {
376
+ /**
377
+ * The particles' main texture, multiplied with their colour; a flipbook when
378
+ * {@link uTileCount} or {@link vTileCount} is above 1. Null draws the colour alone. Systems
379
+ * with different textures are batched apart.
380
+ */
381
+ get texture(): Texture | null {
532
382
  return this.rendererSettings.texture;
533
383
  }
534
384
 
@@ -537,7 +387,15 @@ export class ParticleSystem implements IParticleSystem {
537
387
  this.neededToUpdateRender = true;
538
388
  }
539
389
 
540
- get material() {
390
+ /**
391
+ * A Babylon material the system takes its draw settings from (on a loaded system, the material
392
+ * record of its file): its texture (`albedoTexture`,
393
+ * `diffuseTexture`, `emissiveTexture` or `opacityTexture`), alpha mode, depth flags, alpha test
394
+ * and reflection texture. The material itself
395
+ * is not drawn with; particles always use quarks' own shaders. Null leaves the settings as they
396
+ * are.
397
+ */
398
+ get material(): any {
541
399
  return this.materialRef;
542
400
  }
543
401
 
@@ -546,25 +404,37 @@ export class ParticleSystem implements IParticleSystem {
546
404
  this.applyMaterialSettings(material);
547
405
  }
548
406
 
549
- get uTileCount() {
407
+ /**
408
+ * Columns of the flipbook in {@link texture} (Unity's Texture Sheet Animation tiles). With
409
+ * {@link vTileCount}, a particle draws one cell, picked by {@link startTileIndex} and
410
+ * `FrameOverLife`. A whole number of at least 1; anything else is 1. Default 1. Trails take
411
+ * their cell on the CPU, so a trail's texture is cut the same way.
412
+ */
413
+ get uTileCount(): number {
550
414
  return this.rendererSettings.uTileCount;
551
415
  }
552
416
 
553
417
  set uTileCount(u: number) {
554
- this.rendererSettings.uTileCount = u;
418
+ this.rendererSettings.uTileCount = validTileCount(u);
555
419
  this.neededToUpdateRender = true;
556
420
  }
557
421
 
558
- get vTileCount() {
422
+ /** Rows of the flipbook in {@link texture}, see {@link uTileCount}. Default 1. */
423
+ get vTileCount(): number {
559
424
  return this.rendererSettings.vTileCount;
560
425
  }
561
426
 
562
427
  set vTileCount(v: number) {
563
- this.rendererSettings.vTileCount = v;
428
+ this.rendererSettings.vTileCount = validTileCount(v);
564
429
  this.neededToUpdateRender = true;
565
430
  }
566
431
 
567
- get blendTiles() {
432
+ /**
433
+ * Cross-fade between a flipbook's cells instead of cutting from one to the next (Unity's
434
+ * Flip-Book Blending); the last cell fades into the first. Default false. Needs a sheet
435
+ * (`uTileCount` or `vTileCount` above 1); trails cut their cell on the CPU and do not blend.
436
+ */
437
+ get blendTiles(): boolean {
568
438
  return this.rendererSettings.blendTiles;
569
439
  }
570
440
 
@@ -573,7 +443,13 @@ export class ParticleSystem implements IParticleSystem {
573
443
  this.neededToUpdateRender = true;
574
444
  }
575
445
 
576
- get softParticles() {
446
+ /**
447
+ * Fade particles out as they near the scene behind them (Unity's Soft Particles, Unreal's
448
+ * Depth Fade), between {@link softNearFade} and {@link softFarFade}. Needs a depth texture on
449
+ * the renderer, see `BatchedRenderer.setDepthTexture`; without one particles draw hard.
450
+ * Default false.
451
+ */
452
+ get softParticles(): boolean {
577
453
  return this.rendererSettings.softParticles;
578
454
  }
579
455
 
@@ -582,11 +458,6 @@ export class ParticleSystem implements IParticleSystem {
582
458
  this.neededToUpdateRender = true;
583
459
  }
584
460
 
585
- /**
586
- * Billboard pivot in particle-size units (Unity's renderer Pivot), see
587
- * {@link VFXBatchSettings.pivot}. Assign a new vector to change it: editing
588
- * the returned one in place does not reach the renderer.
589
- */
590
461
  /**
591
462
  * The order this system's batch draws its particles in (Unity's Sort
592
463
  * Mode), `'none'` by default. Alpha blended smoke wants `'distance'`;
@@ -594,11 +465,11 @@ export class ParticleSystem implements IParticleSystem {
594
465
  * with different modes are batched apart. See {@link SortMode}.
595
466
  */
596
467
  get sortMode(): SortMode {
597
- return this.rendererSettings.sortMode;
468
+ return this.rendererSettings.sortMode ?? 'none';
598
469
  }
599
470
 
600
471
  set sortMode(mode: SortMode) {
601
- this.rendererSettings.sortMode = ParticleSystem.validSortMode(mode);
472
+ this.rendererSettings.sortMode = validSortMode(mode);
602
473
  this.neededToUpdateRender = true;
603
474
  }
604
475
 
@@ -608,7 +479,7 @@ export class ParticleSystem implements IParticleSystem {
608
479
  * default. Keeps distant sparks from vanishing. Meshes and trails ignore it.
609
480
  */
610
481
  get minParticleSize(): number {
611
- return this.rendererSettings.minParticleSize;
482
+ return this.rendererSettings.minParticleSize ?? 0;
612
483
  }
613
484
 
614
485
  set minParticleSize(size: number) {
@@ -623,7 +494,7 @@ export class ParticleSystem implements IParticleSystem {
623
494
  * trails ignore it.
624
495
  */
625
496
  get maxParticleSize(): number {
626
- return this.rendererSettings.maxParticleSize;
497
+ return this.rendererSettings.maxParticleSize ?? Infinity;
627
498
  }
628
499
 
629
500
  set maxParticleSize(size: number) {
@@ -631,12 +502,17 @@ export class ParticleSystem implements IParticleSystem {
631
502
  this.neededToUpdateRender = true;
632
503
  }
633
504
 
505
+ /**
506
+ * Billboard pivot in particle-size units (Unity's renderer Pivot), see
507
+ * {@link VFXBatchSettings.pivot}. Assign a new vector to change it: editing
508
+ * the returned one in place does not reach the renderer.
509
+ */
634
510
  get pivot(): Vector3 | undefined {
635
511
  return this.rendererSettings.pivot;
636
512
  }
637
513
 
638
514
  set pivot(v: Vector3 | undefined) {
639
- this.rendererSettings.pivot = ParticleSystem.nonZeroPivot(v);
515
+ this.rendererSettings.pivot = nonZeroPivot(v);
640
516
  this.neededToUpdateRender = true;
641
517
  }
642
518
 
@@ -652,7 +528,7 @@ export class ParticleSystem implements IParticleSystem {
652
528
  }
653
529
 
654
530
  set lighting(lighting: MeshLighting) {
655
- this.rendererSettings.lighting = ParticleSystem.validLighting(lighting);
531
+ this.rendererSettings.lighting = validLighting(lighting);
656
532
  this.neededToUpdateRender = true;
657
533
  }
658
534
 
@@ -749,16 +625,17 @@ export class ParticleSystem implements IParticleSystem {
749
625
  * before every step; what `'hierarchy'` multiplies start speeds by. Taken
750
626
  * from the system's own emitter rather than the emission matrix, whose
751
627
  * columns carry a parent particle's velocity for a sub emitter.
628
+ * @internal
752
629
  */
753
- private readonly hierarchyScale = new Vector3(1, 1, 1);
630
+ readonly hierarchyScale = new Vector3(1, 1, 1);
754
631
 
755
632
  /** The mean world scale of the emitter for a world-space `'hierarchy'` system, else 1. */
756
- private measureMotionScale(): number {
633
+ private measureMotionScale(emitterMatrix: Matrix4): number {
757
634
  if (this.scalingMode !== 'hierarchy' || !this.worldSpace) {
758
635
  this.hierarchyScale.set(1, 1, 1);
759
636
  return 1;
760
637
  }
761
- const e = this.emitter.matrixWorld.elements;
638
+ const e = emitterMatrix.elements;
762
639
  const sx = Math.sqrt(e[0] * e[0] + e[1] * e[1] + e[2] * e[2]);
763
640
  const sy = Math.sqrt(e[4] * e[4] + e[5] * e[5] + e[6] * e[6]);
764
641
  const sz = Math.sqrt(e[8] * e[8] + e[9] * e[9] + e[10] * e[10]);
@@ -766,76 +643,109 @@ export class ParticleSystem implements IParticleSystem {
766
643
  return (sx + sy + sz) / 3;
767
644
  }
768
645
 
769
- /** `lighting` if it is one of the modes, else the default. */
770
- private static validLighting(lighting: unknown): MeshLighting {
771
- return lighting === 'unlit' || lighting === 'scene' ? lighting : 'fixed';
772
- }
773
-
774
- private static validSortMode(mode: unknown): SortMode {
775
- return mode === 'distance' || mode === 'oldestInFront' || mode === 'youngestInFront' ? mode : 'none';
776
- }
777
-
778
- private static validCullingMode(mode: unknown): CullingMode {
779
- return mode === 'pause' || mode === 'pauseAndCatchUp' ? mode : 'always';
780
- }
781
-
782
- private static nonZeroPivot(v: Vector3 | undefined): Vector3 | undefined {
783
- return v && (v.x !== 0 || v.y !== 0 || v.z !== 0) ? v.clone() : undefined;
784
- }
785
-
786
- private static nonZeroFlip(v: Vector2 | undefined): Vector2 | undefined {
787
- return v && (v.x > 0 || v.y > 0) ? v.clone() : undefined;
788
- }
789
-
790
- get softNearFade() {
646
+ /**
647
+ * Distance in world units in front of the scene at which a soft particle is fully faded out,
648
+ * at least 0. Default 0. A live parameter: changing it on a system alone in its batch keeps
649
+ * the batch and its material (see {@link VFXBatchSettings}).
650
+ */
651
+ get softNearFade(): number {
791
652
  return this.rendererSettings.softNearFade;
792
653
  }
793
654
 
794
655
  set softNearFade(v: number) {
795
- this.rendererSettings.softNearFade = v;
656
+ this.rendererSettings.softNearFade = validSoftFade(v, 0);
796
657
  this.neededToUpdateRender = true;
797
658
  }
798
659
 
799
- get softFarFade() {
660
+ /**
661
+ * Distance in world units in front of the scene at which a soft particle is fully opaque, at
662
+ * least 0. Default 1 (Unreal's Depth Fade distance): the particle fades over the last unit
663
+ * before the scene. A value at or below {@link softNearFade} cuts the particle off at that
664
+ * distance instead of fading it. A live parameter, like `softNearFade`.
665
+ */
666
+ get softFarFade(): number {
800
667
  return this.rendererSettings.softFarFade;
801
668
  }
802
669
 
803
670
  set softFarFade(v: number) {
804
- this.rendererSettings.softFarFade = v;
671
+ this.rendererSettings.softFarFade = validSoftFade(v, DEFAULT_SOFT_FAR_FADE);
805
672
  this.neededToUpdateRender = true;
806
673
  }
807
674
 
675
+ /**
676
+ * The mesh particles' positions, three values per vertex.
677
+ * Setting it restarts the system. A new geometry with a different vertex
678
+ * count drops UV sets, normals and indices that no longer fit it: the first UV set
679
+ * falls back to zeros, the second to none, the normals to +Z, the indices to the vertex
680
+ * sequence. Assign {@link instancingUVs}, {@link instancingUVs2} and {@link instancingIndices}
681
+ * after it.
682
+ */
808
683
  get instancingGeometry(): Float32Array {
809
684
  return this.rendererSettings.instancingGeometry;
810
685
  }
811
686
 
812
- /**
813
- * The mesh particles' positions. A new geometry with a different vertex
814
- * count drops UV sets and normals that no longer fit it: the first UV set
815
- * falls back to zeros, the second to none, the normals to +Z. Assign {@link instancingUVs} and
816
- * {@link instancingUVs2} after it.
817
- */
818
687
  set instancingGeometry(geometry: Float32Array) {
819
- this.restart();
820
- this.particles.length = 0;
688
+ this.restartKeepingPause();
821
689
  const settings = this.rendererSettings;
822
690
  const uvLength = (geometry.length / 3) * 2;
823
691
  const previous = settings.instancingGeometry;
824
692
  settings.instancingGeometry = geometry;
693
+ // The indices belong to the geometry they were set with: a geometry with another vertex
694
+ // count, or one the indices point past, takes its own sequence. Assign
695
+ // {@link instancingIndices} after it for an indexed mesh.
696
+ if (geometry.length !== previous.length || !indicesFit(settings.instancingIndices, geometry.length / 3)) {
697
+ settings.instancingIndices = ensureTriangleIndices(geometry);
698
+ }
825
699
  // Sets that no longer fit, or that were the old geometry's fallbacks, move to the new one's.
826
- if (settings.instancingUVs?.length !== uvLength || settings.instancingUVs === fallbackUVs.get(previous)) {
700
+ if (settings.instancingUVs?.length !== uvLength || isFallbackUVs(settings.instancingUVs, previous)) {
827
701
  settings.instancingUVs = fallbackUVsForPositions(geometry);
828
702
  }
829
703
  if (
830
704
  settings.instancingNormals &&
831
705
  (settings.instancingNormals.length !== geometry.length ||
832
- settings.instancingNormals === fallbackNormals.get(previous))
706
+ isFallbackNormals(settings.instancingNormals, previous))
833
707
  ) {
834
- settings.instancingNormals = ParticleSystem.createFallbackNormals(geometry);
708
+ settings.instancingNormals = fallbackNormalsForPositions(geometry);
835
709
  }
836
710
  if (settings.instancingUVs2 && settings.instancingUVs2.length !== uvLength) {
837
711
  settings.instancingUVs2 = undefined;
838
712
  }
713
+ this.ensureMeshNormals();
714
+ this.neededToUpdateRender = true;
715
+ }
716
+
717
+ /**
718
+ * Gives a mesh system normals when it has none, +Z per vertex, so the mesh shader always has
719
+ * the attribute it reads. Called on construction and when the render mode or geometry changes.
720
+ */
721
+ private ensureMeshNormals(): void {
722
+ const settings = this.rendererSettings;
723
+ if (settings.renderMode === RenderMode.Mesh && !settings.instancingNormals) {
724
+ settings.instancingNormals = fallbackNormalsForPositions(settings.instancingGeometry);
725
+ }
726
+ }
727
+
728
+ /**
729
+ * {@link restart} for a setting that rebuilds the particles: the system is cleared and rewound,
730
+ * but a paused system stays paused, and a stopped one stays stopped.
731
+ */
732
+ private restartKeepingPause(): void {
733
+ const paused = this.paused;
734
+ this.restart();
735
+ this.particles.length = 0;
736
+ this.paused = paused;
737
+ }
738
+
739
+ /**
740
+ * The mesh particles' triangle indices into {@link instancingGeometry}. Mesh systems that share
741
+ * positions but not indices (sub-meshes of one vertex buffer) are batched apart.
742
+ */
743
+ get instancingIndices(): Uint32Array | Uint16Array {
744
+ return this.rendererSettings.instancingIndices;
745
+ }
746
+
747
+ set instancingIndices(indices: Uint32Array | Uint16Array) {
748
+ this.rendererSettings.instancingIndices = indices;
839
749
  this.neededToUpdateRender = true;
840
750
  }
841
751
 
@@ -859,7 +769,12 @@ export class ParticleSystem implements IParticleSystem {
859
769
  this.neededToUpdateRender = true;
860
770
  }
861
771
 
862
- get blending() {
772
+ /**
773
+ * Babylon alpha mode the particles blend with: `Constants.ALPHA_ADD` (the default) for glows,
774
+ * `Constants.ALPHA_COMBINE` for smoke and anything alpha blended. Systems that differ are
775
+ * batched apart.
776
+ */
777
+ get blending(): number {
863
778
  return this.rendererSettings.materialBlendMode;
864
779
  }
865
780
 
@@ -868,6 +783,70 @@ export class ParticleSystem implements IParticleSystem {
868
783
  this.neededToUpdateRender = true;
869
784
  }
870
785
 
786
+ /**
787
+ * Whether the particles draw in the transparent pass, blended by {@link blending}; false
788
+ * draws them opaque, with blending off. Default true, or the material's `needAlphaBlending`.
789
+ * Systems that differ are batched apart.
790
+ */
791
+ get transparent(): boolean {
792
+ return this.rendererSettings.materialTransparent;
793
+ }
794
+
795
+ set transparent(transparent: boolean) {
796
+ this.rendererSettings.materialTransparent = transparent;
797
+ this.neededToUpdateRender = true;
798
+ }
799
+
800
+ /**
801
+ * Whether particles are hidden behind nearer geometry; false draws them over everything
802
+ * (Unity's `ZTest Always`), for overlays and effects seen through walls. Default true, or the
803
+ * material's `disableDepthTest` inverted. Systems that differ are batched apart.
804
+ */
805
+ get depthTest(): boolean {
806
+ return this.rendererSettings.materialDepthTest;
807
+ }
808
+
809
+ set depthTest(depthTest: boolean) {
810
+ this.rendererSettings.materialDepthTest = depthTest;
811
+ this.neededToUpdateRender = true;
812
+ }
813
+
814
+ /**
815
+ * Whether particles write depth, so later draws are hidden behind them. Default false, or the
816
+ * material's `disableDepthWrite` inverted. Systems that differ are batched apart.
817
+ */
818
+ get depthWrite(): boolean {
819
+ return this.rendererSettings.materialDepthWrite;
820
+ }
821
+
822
+ set depthWrite(depthWrite: boolean) {
823
+ this.rendererSettings.materialDepthWrite = depthWrite;
824
+ this.neededToUpdateRender = true;
825
+ }
826
+
827
+ /**
828
+ * Alpha below which a fragment is discarded (Unity's Alpha Clip Threshold), 0…1; 0 (the
829
+ * default, or the material's `alphaCutOff`) discards nothing but fragments under 0.01, which no
830
+ * blend mode can show except additive ones with a bright colour. Switching it on or off
831
+ * rebuilds the batch's material; a change between non-zero cutoffs on a system alone in its
832
+ * batch is written to the material in place.
833
+ */
834
+ get alphaTest(): number {
835
+ return this.rendererSettings.materialAlphaTest;
836
+ }
837
+
838
+ set alphaTest(cutoff: number) {
839
+ this.rendererSettings.materialAlphaTest = validAlphaTest(cutoff);
840
+ this.neededToUpdateRender = true;
841
+ }
842
+
843
+ /**
844
+ * How each particle is drawn (Unity's Render Mode): a camera-facing billboard, a stretched
845
+ * billboard, a horizontal or vertical billboard, a mesh, a trail or a beam, see
846
+ * {@link RenderMode}. Changing it gives {@link rendererEmitterSettings} the new mode's
847
+ * defaults, and switching to or from a trail restarts the system. Switching to a mesh makes
848
+ * {@link startRotation} an `AxisAngleGenerator` about Y, and switching away from one sets it to 0.
849
+ */
871
850
  get renderMode(): RenderMode {
872
851
  return this.rendererSettings.renderMode;
873
852
  }
@@ -902,13 +881,18 @@ export class ParticleSystem implements IParticleSystem {
902
881
  }
903
882
  this.rendererSettings.renderMode = renderMode;
904
883
  if (needRestart) {
905
- this.restart();
906
- this.particles.length = 0;
884
+ this.restartKeepingPause();
907
885
  }
886
+ this.ensureMeshNormals();
908
887
  this.neededToUpdateRender = true;
909
888
  }
910
889
  }
911
890
 
891
+ /**
892
+ * The batch mesh's `alphaIndex`: among the transparent meshes of one rendering group, lower
893
+ * draws first, whatever the distance (Unity's Order in Layer). Default 0. Systems that differ
894
+ * are batched apart.
895
+ */
912
896
  get renderOrder(): number {
913
897
  return this.rendererSettings.renderOrder;
914
898
  }
@@ -949,18 +933,36 @@ export class ParticleSystem implements IParticleSystem {
949
933
  /**
950
934
  * Multiplier on the time this system is advanced by (Unity's Simulation
951
935
  * Speed): 2 runs the effect twice as fast, 0 freezes it without pausing the
952
- * renderer. 1 by default; saved in the effect JSON only when it differs.
936
+ * renderer. 1 by default; saved in the effect JSON only when it differs. A
937
+ * negative or NaN speed is read as 0, and the clock does not run backwards.
953
938
  */
954
- playbackSpeed = 1;
939
+ get playbackSpeed(): number {
940
+ return this.playbackSpeedValue;
941
+ }
942
+
943
+ set playbackSpeed(speed: number) {
944
+ this.playbackSpeedValue = speed > 0 && speed < Infinity ? speed : 0;
945
+ }
946
+
947
+ private playbackSpeedValue = 1;
955
948
 
956
949
  /**
957
950
  * Runtime multiplier on how many particles the system emits: bursts,
958
951
  * emission over time and over distance alike. Unlike
959
952
  * {@link setQualityFactor}, which only ever thins an effect, it may go
960
953
  * above 1 to make a stronger version of the same effect. The two multiply.
961
- * 1 by default; not saved in the effect JSON.
954
+ * 1 by default; a negative or NaN value counts as 0. Not saved in the effect JSON; copied by
955
+ * {@link clone}.
962
956
  */
963
- emissionMultiplier = 1;
957
+ get emissionMultiplier(): number {
958
+ return this.emissionMultiplierValue;
959
+ }
960
+
961
+ set emissionMultiplier(multiplier: number) {
962
+ this.emissionMultiplierValue = Number.isFinite(multiplier) && multiplier >= 0 ? multiplier : 0;
963
+ }
964
+
965
+ private emissionMultiplierValue = 1;
964
966
 
965
967
  /** What this system does while off screen, see {@link CullingMode}. `'always'` by default. */
966
968
  cullingMode: CullingMode = 'always';
@@ -997,9 +999,9 @@ export class ParticleSystem implements IParticleSystem {
997
999
  * multiplayer and reproducible tests. Systems this one emits into roll on
998
1000
  * its generator too. Unset (the default), rolls come from the global source
999
1001
  * (`setRandomSource` in quarks.core). Saved in the effect JSON when set.
1000
- *
1001
- * Not covered: randomness outside the step, such as a `TurbulenceField`'s
1002
- * offsets, which are rolled when the behavior is created.
1002
+ * The behaviors roll on the same generator, and the ones with their own
1003
+ * randomness (a `TurbulenceField`'s noise) are built from their own seed,
1004
+ * so the whole effect is covered.
1003
1005
  */
1004
1006
  get seed(): number | undefined {
1005
1007
  return this.seedValue;
@@ -1008,6 +1010,12 @@ export class ParticleSystem implements IParticleSystem {
1008
1010
  set seed(seed: number | undefined) {
1009
1011
  this.seedValue = seed;
1010
1012
  this.rng = seed === undefined ? undefined : createSeededRandom(seed);
1013
+ // Seeded before it has played: roll the system-level values on the new generator, so the
1014
+ // first play matches every restart. (The constructor sets the seed before they exist.)
1015
+ if (this.emissionState !== undefined && this.emissionState.time === 0 && this.particleNum === 0) {
1016
+ this.emission.memory.length = 0;
1017
+ this.initSystemGenerators();
1018
+ }
1011
1019
  }
1012
1020
 
1013
1021
  /**
@@ -1017,13 +1025,6 @@ export class ParticleSystem implements IParticleSystem {
1017
1025
  */
1018
1026
  culled = false;
1019
1027
 
1020
- /**
1021
- * Time a `'pauseAndCatchUp'` system has spent off screen and not yet
1022
- * simulated, in seconds. Capped, so an effect hidden for a minute does not
1023
- * replay the minute.
1024
- */
1025
- private pendingCatchUp = 0;
1026
-
1027
1028
  /**
1028
1029
  * Time per frame a system coming back into view may spend on the steps it
1029
1030
  * missed, in milliseconds, when it is advanced without a renderer. The
@@ -1032,20 +1033,18 @@ export class ParticleSystem implements IParticleSystem {
1032
1033
  */
1033
1034
  static catchUpBudgetMs = 2;
1034
1035
 
1035
- /** Axis-aligned bounds of the live particles, in simulation space. See {@link getWorldBounds}. */
1036
- private readonly boundsMin = new Vector3();
1037
- private readonly boundsMax = new Vector3();
1038
- /** How far a particle's quad, mesh or stretch can reach past its position, in simulation units. */
1039
- private boundsPadding = 0;
1040
- private boundsValid = false;
1041
- /** Steps since the last full measurement; between them the bounds are carried and padded. */
1042
- private stepsSinceBounds = 0;
1043
- private boundsMaxSize = 0;
1044
- private boundsMaxSpeedSq = 0;
1045
- /** True when the bounds are already in world space (a world-space system, or bounds the renderer measured). */
1046
- private boundsInWorld = false;
1047
- private geometryRadiusFor?: Float32Array;
1048
- private geometryRadius = 1;
1036
+ /**
1037
+ * A performance guard on {@link prewarm}, for every system: the most of a cycle it simulates
1038
+ * before the first frame, in seconds. A longer cycle is warmed up over its last
1039
+ * `maxPrewarmSeconds` only, with the burst waves before that counted as fired, so a long
1040
+ * looping effect does not stall its first frame. 10 by default; raise it in an app whose
1041
+ * effects have longer cycles and particles that live longer than that. It is not an effect's
1042
+ * own warm-up time, which is {@link prewarmTime}, and is not saved with one.
1043
+ */
1044
+ static maxPrewarmSeconds = 10;
1045
+
1046
+ /** What the system draws covers, for culling. See {@link getWorldBounds}. */
1047
+ private readonly bounds = new BoundsTracker();
1049
1048
 
1050
1049
  constructor(parameters: ParticleSystemParameters) {
1051
1050
  this.sceneRef = parameters.scene;
@@ -1064,6 +1063,7 @@ export class ParticleSystem implements IParticleSystem {
1064
1063
  this.startDelay = parameters.startDelay ?? new ConstantValue(0);
1065
1064
  this.looping = parameters.looping ?? true;
1066
1065
  this.prewarm = parameters.prewarm ?? false;
1066
+ this.prewarmTime = parameters.prewarmTime ?? 0;
1067
1067
  this.startLife = parameters.startLife ?? new ConstantValue(5);
1068
1068
  this.startSpeed = parameters.startSpeed ?? new ConstantValue(0);
1069
1069
  this.startRotation = parameters.startRotation ?? new ConstantValue(0);
@@ -1071,16 +1071,25 @@ export class ParticleSystem implements IParticleSystem {
1071
1071
  this.startColor = parameters.startColor ?? new ConstantColor(new Vector4(1, 1, 1, 1));
1072
1072
  this.emissionOverTime = parameters.emissionOverTime ?? new ConstantValue(10);
1073
1073
  this.emissionOverDistance = parameters.emissionOverDistance ?? new ConstantValue(0);
1074
- this.emissionBursts = parameters.emissionBursts ?? [];
1075
- this.maxParticles = parameters.maxParticles;
1074
+ this.emissionBursts = normalizeBursts(parameters.emissionBursts);
1075
+ this.maxParticles = validMaxParticles(parameters.maxParticles);
1076
1076
  this.onlyUsedByOther = parameters.onlyUsedByOther ?? false;
1077
1077
  this.emitterShape = parameters.shape ?? new SphereEmitter();
1078
1078
  this.behaviors = parameters.behaviors ?? [];
1079
1079
  this.worldSpace = parameters.worldSpace ?? false;
1080
1080
  this.rendererEmitterSettings = parameters.rendererEmitterSettings ?? {};
1081
1081
 
1082
+ if (parameters.renderMode === RenderMode.Trail) {
1083
+ // The defaults the renderMode setter gives a trail, for settings left out, on a copy so
1084
+ // the caller's object is left as it was.
1085
+ const settings = {...this.rendererEmitterSettings} as TrailSettings;
1086
+ this.rendererEmitterSettings = settings;
1087
+ settings.startLength = settings.startLength ?? new ConstantValue(30);
1088
+ settings.followLocalOrigin = settings.followLocalOrigin ?? false;
1089
+ }
1082
1090
  if (parameters.renderMode === RenderMode.StretchedBillBoard) {
1083
- const settings = this.rendererEmitterSettings as StretchedBillBoardSettings;
1091
+ const settings = {...this.rendererEmitterSettings} as StretchedBillBoardSettings;
1092
+ this.rendererEmitterSettings = settings;
1084
1093
  if (parameters.speedFactor !== undefined) {
1085
1094
  settings.speedFactor = parameters.speedFactor;
1086
1095
  }
@@ -1098,47 +1107,42 @@ export class ParticleSystem implements IParticleSystem {
1098
1107
  instancingNormals: parameters.instancingNormals,
1099
1108
  renderMode: parameters.renderMode ?? RenderMode.BillBoard,
1100
1109
  renderOrder: parameters.renderOrder ?? 0,
1101
- uTileCount: parameters.uTileCount ?? 1,
1102
- vTileCount: parameters.vTileCount ?? 1,
1110
+ uTileCount: validTileCount(parameters.uTileCount),
1111
+ vTileCount: validTileCount(parameters.vTileCount),
1103
1112
  blendTiles: parameters.blendTiles ?? false,
1104
1113
  softParticles: parameters.softParticles ?? false,
1105
- softNearFade: parameters.softNearFade ?? 0,
1106
- softFarFade: parameters.softFarFade ?? 0,
1114
+ softNearFade: validSoftFade(parameters.softNearFade, 0),
1115
+ softFarFade: validSoftFade(parameters.softFarFade, DEFAULT_SOFT_FAR_FADE),
1107
1116
  materialBlendMode: parameters.blendMode ?? Constants.ALPHA_ADD,
1108
1117
  materialTransparent: parameters.transparent ?? true,
1109
1118
  materialDepthTest: parameters.depthTest ?? true,
1110
1119
  materialDepthWrite: parameters.depthWrite ?? false,
1111
- materialAlphaTest: parameters.alphaTest ?? 0,
1120
+ materialAlphaTest: validAlphaTest(parameters.alphaTest),
1112
1121
  texture: parameters.texture ?? null,
1113
1122
  reflectionTexture: null,
1114
1123
  reflectionLevel: 1,
1115
- reflectionFaces: null,
1116
1124
  reflectionAtlas: null,
1117
1125
  layerMask: parameters.layerMask ?? 0x0fffffff,
1118
- pivot: ParticleSystem.nonZeroPivot(parameters.pivot),
1119
- sortMode: ParticleSystem.validSortMode(parameters.sortMode),
1126
+ pivot: nonZeroPivot(parameters.pivot),
1127
+ sortMode: validSortMode(parameters.sortMode),
1120
1128
  minParticleSize: Math.max(0, parameters.minParticleSize ?? 0),
1121
1129
  maxParticleSize: parameters.maxParticleSize ?? Infinity,
1122
1130
  renderingGroupId: parameters.renderingGroupId ?? 0,
1123
1131
  applyFog: parameters.applyFog ?? true,
1124
- lighting: ParticleSystem.validLighting(parameters.lighting),
1132
+ lighting: validLighting(parameters.lighting),
1125
1133
  rim: normalizeMeshRim(parameters.rim),
1126
1134
  mapUV: normalizeMapUV(parameters.mapUV),
1127
1135
  gradientMap: normalizeGradientMap(parameters.gradientMap),
1128
1136
  erosion: normalizeErosion(parameters.erosion),
1129
1137
  uvDistortion: normalizeUVDistortion(parameters.uvDistortion),
1130
1138
  };
1131
- this.flip = ParticleSystem.nonZeroFlip(parameters.flip);
1139
+ this.flip = nonZeroFlip(parameters.flip);
1132
1140
  this.playbackSpeed = parameters.playbackSpeed ?? 1;
1133
- this.cullingMode = parameters.cullingMode ?? 'always';
1141
+ this.cullingMode = validCullingMode(parameters.cullingMode);
1134
1142
  this.scalingMode = parameters.scalingMode === 'hierarchy' ? 'hierarchy' : 'shape';
1135
1143
  this.alignToDirection = parameters.alignToDirection === true;
1136
1144
  this.seed = parameters.seed;
1137
- if (this.rendererSettings.renderMode === RenderMode.Mesh && !this.rendererSettings.instancingNormals) {
1138
- this.rendererSettings.instancingNormals = ParticleSystem.createFallbackNormals(
1139
- this.rendererSettings.instancingGeometry
1140
- );
1141
- }
1145
+ this.ensureMeshNormals();
1142
1146
 
1143
1147
  this.materialRef = parameters.material ?? null;
1144
1148
  this.applyMaterialSettings(this.materialRef, {
@@ -1169,398 +1173,220 @@ export class ParticleSystem implements IParticleSystem {
1169
1173
  travelDistance: 0,
1170
1174
  };
1171
1175
 
1172
- this.emissionBursts.forEach((burst) => burst.count.startGen(this.memory));
1173
- this.emissionOverDistance.startGen(this.memory);
1174
- this.startDelay.startGen(this.memory);
1175
- this.startDelayTimeLeft = this.startDelay.genValue(this.memory, 0);
1176
+ this.initSystemGenerators();
1176
1177
 
1177
1178
  this.emitEnded = false;
1178
- this.markForDestroy = false;
1179
1179
  this.prewarmed = false;
1180
1180
  }
1181
1181
 
1182
- private applyMaterialSettings(
1183
- material: any,
1184
- overrides: {
1185
- blendMode?: number;
1186
- transparent?: boolean;
1187
- depthTest?: boolean;
1188
- depthWrite?: boolean;
1189
- alphaTest?: number;
1190
- texture?: Texture | null;
1191
- reflectionAtlas?: BaseTexture | null;
1192
- layerMask?: number;
1193
- } = {}
1194
- ) {
1182
+ /**
1183
+ * When the env atlas this system draws with is disposed (its cube was, or `disposeEnvAtlas`
1184
+ * ran) while the system still uses it, drops it and builds a new one if the cube is still alive,
1185
+ * so the batch never samples a disposed texture.
1186
+ */
1187
+ private watchEnvAtlas(atlas: BaseTexture | null, material: any): void {
1188
+ atlas?.onDisposeObservable.addOnce(() => {
1189
+ if (this.destroyed || this.rendererSettings.reflectionAtlas !== atlas) {
1190
+ return;
1191
+ }
1192
+ this.rendererSettings.reflectionAtlas = null;
1193
+ if (material?.reflectionAtlas === atlas) {
1194
+ material.reflectionAtlas = null;
1195
+ }
1196
+ this.neededToUpdateRender = true;
1197
+ const cube = this.rendererSettings.reflectionTexture;
1198
+ if (cube?.isCube && cube.getScene() && this.sceneRef) {
1199
+ ensureEnvAtlasFromCube(cube, this.sceneRef, (rebuilt) => {
1200
+ if (this.destroyed || this.rendererSettings.reflectionTexture !== cube) {
1201
+ return;
1202
+ }
1203
+ this.rendererSettings.reflectionAtlas = rebuilt;
1204
+ if (material && material.reflectionAtlas == null) {
1205
+ material.reflectionAtlas = rebuilt;
1206
+ }
1207
+ this.watchEnvAtlas(rebuilt, material);
1208
+ this.neededToUpdateRender = true;
1209
+ });
1210
+ }
1211
+ });
1212
+ }
1213
+
1214
+ private applyMaterialSettings(material: any, overrides: MaterialOverrides = {}) {
1195
1215
  /* istanbul ignore if -- rendererSettings is always assigned in the constructor before any code path can call this private method */
1196
1216
  if (!this.rendererSettings) {
1197
1217
  return;
1198
1218
  }
1219
+ const resolved = resolveMaterialSettings(material, overrides, this.rendererSettings);
1199
1220
 
1200
- const resolvedTexture =
1201
- overrides.texture !== undefined
1202
- ? overrides.texture
1203
- : (material?.albedoTexture ??
1204
- material?.diffuseTexture ??
1205
- material?.emissiveTexture ??
1206
- material?.opacityTexture ??
1207
- material?.baseTexture ??
1208
- this.rendererSettings.texture ??
1209
- null);
1210
- const resolvedReflection =
1211
- material?.reflectionTexture ??
1212
- material?.environmentTexture ??
1213
- this.rendererSettings.reflectionTexture ??
1214
- null;
1215
- const resolvedReflectionLevel = typeof resolvedReflection?.level === 'number' ? resolvedReflection.level : 1;
1216
- const resolvedBlendMode =
1217
- overrides.blendMode ??
1218
- (typeof material?.alphaMode === 'number' ? material.alphaMode : this.rendererSettings.materialBlendMode);
1219
- const resolvedTransparent =
1220
- overrides.transparent ??
1221
- (typeof material?.needAlphaBlending === 'function'
1222
- ? material.needAlphaBlending()
1223
- : typeof material?.alpha === 'number'
1224
- ? material.alpha < 1
1225
- : this.rendererSettings.materialTransparent);
1226
- const resolvedDepthTest =
1227
- overrides.depthTest ??
1228
- (typeof material?.disableDepthTest === 'boolean'
1229
- ? !material.disableDepthTest
1230
- : this.rendererSettings.materialDepthTest);
1231
- const resolvedDepthWrite =
1232
- overrides.depthWrite ??
1233
- (typeof material?.disableDepthWrite === 'boolean'
1234
- ? !material.disableDepthWrite
1235
- : typeof material?.forceDepthWrite === 'boolean'
1236
- ? material.forceDepthWrite
1237
- : this.rendererSettings.materialDepthWrite);
1238
- const resolvedAlphaTest =
1239
- overrides.alphaTest ??
1240
- (() => {
1241
- // StandardMaterial always has alphaCutOff (default 0.4). Only treat it as
1242
- // particle alpha-test when the material is actually in an alpha-test mode —
1243
- // otherwise Mesh Material demos wrongly enable USE_ALPHATEST.
1244
- const mode = material?.transparencyMode;
1245
- const alphaTestMode = mode === 1 || mode === 3;
1246
- if (alphaTestMode && typeof material?.alphaCutOff === 'number') {
1247
- return material.alphaCutOff;
1248
- }
1249
- if (alphaTestMode && typeof material?.alphaCutOffValue === 'number') {
1250
- return material.alphaCutOffValue;
1251
- }
1252
- if (material) {
1253
- return 0;
1254
- }
1255
- return this.rendererSettings.materialAlphaTest;
1256
- })();
1257
-
1258
- this.rendererSettings.texture = resolvedTexture;
1259
- this.rendererSettings.reflectionTexture = resolvedReflection;
1260
- this.rendererSettings.reflectionLevel =
1261
- typeof material?.reflectionLevel === 'number' ? material.reflectionLevel : resolvedReflectionLevel;
1221
+ this.rendererSettings.texture = resolved.texture;
1222
+ this.rendererSettings.reflectionTexture = resolved.reflectionTexture;
1223
+ this.rendererSettings.reflectionLevel = resolved.reflectionLevel;
1262
1224
  // Prefer an explicit atlas; otherwise build a 3×2 atlas from CubeTexture
1263
1225
  // face URLs (iOS-safe single sampler2D — not samplerCube / six faces).
1264
- let resolvedAtlas =
1265
- material?.reflectionAtlas ?? overrides.reflectionAtlas ?? this.rendererSettings.reflectionAtlas ?? null;
1226
+ let resolvedAtlas = resolved.reflectionAtlas;
1227
+ const resolvedReflection = resolved.reflectionTexture;
1266
1228
  if (!resolvedAtlas && resolvedReflection?.isCube) {
1267
- resolvedAtlas = getCachedEnvAtlas(resolvedReflection);
1229
+ resolvedAtlas = getCachedEnvAtlas(resolvedReflection, this.sceneRef);
1268
1230
  if (!resolvedAtlas && this.sceneRef) {
1269
1231
  ensureEnvAtlasFromCube(resolvedReflection, this.sceneRef, (atlas) => {
1270
1232
  if (this.rendererSettings.reflectionAtlas === atlas) {
1271
1233
  return;
1272
1234
  }
1273
1235
  this.rendererSettings.reflectionAtlas = atlas;
1274
- if (material && material.reflectionAtlas == null) {
1236
+ if (material && !liveAtlas(material.reflectionAtlas)) {
1275
1237
  material.reflectionAtlas = atlas;
1276
1238
  }
1239
+ this.watchEnvAtlas(atlas, material);
1277
1240
  this.neededToUpdateRender = true;
1278
1241
  });
1279
- resolvedAtlas = getCachedEnvAtlas(resolvedReflection);
1242
+ resolvedAtlas = getCachedEnvAtlas(resolvedReflection, this.sceneRef);
1280
1243
  }
1281
1244
  }
1282
1245
  this.rendererSettings.reflectionAtlas = resolvedAtlas;
1283
- // Do not expand cube files into six face textures anymore — that path
1284
- // still trips GL_INVALID_OPERATION on iOS. Prefer a single atlas.
1285
- this.rendererSettings.reflectionFaces =
1286
- material?.reflectionFaces?.length === 6 ? material.reflectionFaces : null;
1287
- this.rendererSettings.materialBlendMode = resolvedBlendMode;
1288
- this.rendererSettings.materialTransparent = resolvedTransparent;
1289
- this.rendererSettings.materialDepthTest = resolvedDepthTest;
1290
- this.rendererSettings.materialDepthWrite = resolvedDepthWrite;
1291
- this.rendererSettings.materialAlphaTest = resolvedAlphaTest;
1246
+ this.watchEnvAtlas(resolvedAtlas, material);
1247
+ this.rendererSettings.materialBlendMode = resolved.blendMode;
1248
+ this.rendererSettings.materialTransparent = resolved.transparent;
1249
+ this.rendererSettings.materialDepthTest = resolved.depthTest;
1250
+ this.rendererSettings.materialDepthWrite = resolved.depthWrite;
1251
+ this.rendererSettings.materialAlphaTest = validAlphaTest(resolved.alphaTest);
1292
1252
  if (overrides.layerMask !== undefined) {
1293
1253
  this.rendererSettings.layerMask = overrides.layerMask;
1294
1254
  }
1295
1255
  this.neededToUpdateRender = true;
1296
1256
  }
1297
1257
 
1298
- pause() {
1258
+ /** Freezes the system where it is: particles stay and nothing is simulated until {@link play}. */
1259
+ pause(): void {
1299
1260
  this.paused = true;
1300
1261
  }
1301
- play() {
1262
+
1263
+ /**
1264
+ * Resumes a paused system. It only resumes: a system that has finished ({@link isFinished})
1265
+ * stays finished, since nothing is left to emit; {@link restart} plays it again from the start.
1266
+ */
1267
+ play(): void {
1302
1268
  this.paused = false;
1269
+ // A move made while paused is a placement, not motion to spread the next frame over.
1270
+ this.previousEmitterMatrix = undefined;
1303
1271
  }
1304
- stop() {
1272
+
1273
+ /** Clears every particle, rewinds to the start ({@link restart}) and pauses until {@link play}. */
1274
+ stop(): void {
1305
1275
  this.restart();
1306
1276
  this.pause();
1307
1277
  }
1308
1278
 
1309
- setQualityFactor(qualityFactor: number) {
1279
+ /**
1280
+ * Removes every particle alive and nothing else (Unity's `Clear`): emission goes on from where
1281
+ * it is, paused stays paused, and the bursts and the random generator are not rewound. The
1282
+ * sub emissions the removed particles were driving end with them; the child systems' own
1283
+ * particles stay, and `particleDied` is not fired.
1284
+ */
1285
+ clear(): void {
1286
+ this.particleNum = 0;
1287
+ this.bounds.valid = false;
1288
+ for (const behavior of this.behaviors) {
1289
+ if (behavior instanceof EmitSubParticleSystem) {
1290
+ behavior.clearEmissions();
1291
+ }
1292
+ }
1293
+ }
1294
+
1295
+ /**
1296
+ * Drops every trail's recorded samples and keeps the particles (Unity's `TrailRenderer.Clear`):
1297
+ * each ribbon starts over from where its particle is now. For a teleport: {@link pause}, move
1298
+ * the emitter, {@link play} (so the move is not spread over the next frame's steps as motion)
1299
+ * and call this, and no ribbon is drawn across the jump. Does nothing on the other render modes.
1300
+ */
1301
+ clearTrails(): void {
1302
+ if (this.rendererSettings.renderMode !== RenderMode.Trail) {
1303
+ return;
1304
+ }
1305
+ for (let i = 0; i < this.particleNum; i++) {
1306
+ (this.particles[i] as TrailParticle).resetHistory();
1307
+ }
1308
+ }
1309
+
1310
+ /**
1311
+ * Scales how many particles the system emits, from 0.1 to 1 (values outside are clamped), for
1312
+ * lower quality settings or a crowded frame; the renderer's adaptive performance sets it. It
1313
+ * multiplies with {@link emissionMultiplier}. Default 1.
1314
+ */
1315
+ setQualityFactor(qualityFactor: number): void {
1310
1316
  this.qualityFactor = Math.max(0.1, Math.min(1, qualityFactor));
1311
1317
  }
1312
1318
 
1313
- /** Quality factor times {@link emissionMultiplier}: what emission counts are scaled by. */
1314
- private emissionFactor(): number {
1315
- const multiplier = this.emissionMultiplier;
1316
- return multiplier >= 0 ? this.qualityFactor * multiplier : this.qualityFactor;
1319
+ /**
1320
+ * Quality factor times {@link emissionMultiplier}: what emission counts are scaled by.
1321
+ * @internal
1322
+ */
1323
+ emissionFactor(): number {
1324
+ return this.qualityFactor * this.emissionMultiplierValue;
1317
1325
  }
1318
1326
 
1319
1327
  /**
1320
- * Appends one particle to the pool, giving it the next row of the store.
1321
- *
1322
- * Growing the store replaces its arrays, so every particle already bound to
1323
- * it has to be re-pointed at the new ones.
1328
+ * Stops emission and lets the particles alive die out (Unity's `Stop` with
1329
+ * `StopEmitting`). The system fires `emitEnd`, then `finished` when the last particle has
1330
+ * died, and is disposed then if {@link autoDestroy} is set. {@link restart} starts it again.
1324
1331
  */
1325
- /** Points every particle at the store's columns again, after any were replaced. */
1326
- private rebindParticles(): void {
1327
- const particles = this.particles;
1328
- for (let i = 0; i < particles.length; i++) {
1329
- (particles[i] as SpriteParticle | TrailParticle).rebind();
1330
- }
1332
+ endEmit(): void {
1333
+ this.emitEnded = true;
1334
+ this.events.fire({type: 'emitEnd', particleSystem: this});
1331
1335
  }
1332
1336
 
1333
- private growParticlePool(isTrailMode: boolean): void {
1334
- const index = this.particles.length;
1335
- if (this.store.ensureCapacity(index + 1)) {
1336
- this.rebindParticles();
1337
- }
1338
- this.particles.push(isTrailMode ? new TrailParticle(this.store, index) : new SpriteParticle(this.store, index));
1337
+ /** Whether {@link dispose} has run. A disposed system emits nothing and cannot be restarted. */
1338
+ get isDisposed(): boolean {
1339
+ return this.destroyed;
1339
1340
  }
1340
1341
 
1341
- private spawn(count: number, emissionState: EmissionState, matrix: Matrix4) {
1342
- const translation = tempV;
1343
- const scale = tempV2;
1344
- // decompose writes tempQ, so no separate setFromRotationMatrix is needed.
1345
- matrix.decompose(translation, tempQ, scale);
1346
- const behaviors = this.behaviors;
1347
- const behaviorCount = behaviors.length;
1348
- const renderMode = this.rendererSettings.renderMode;
1349
- const isTrailMode = renderMode === RenderMode.Trail;
1350
- const isSpriteMode = !isTrailMode;
1351
- const isMeshMode = renderMode === RenderMode.Mesh;
1352
- const startRotationIsRotation = this.startRotation.type === 'rotation';
1353
- const startSizeIsVector3 = this.startSize.type === 'vec3function';
1354
- const timeRatio = emissionState.time / this.duration;
1355
- const trailSettings = isTrailMode ? (this.rendererEmitterSettings as TrailSettings) : undefined;
1356
- const followLocalOrigin = Boolean(trailSettings?.followLocalOrigin);
1357
- if (this.maxParticles !== undefined) {
1358
- // Like Unity, a full system skips the particles it has no room for rather than queueing them.
1359
- count = Math.min(count, Math.max(0, this.maxParticles - this.particleNum));
1360
- }
1361
-
1362
- for (let i = 0; i < count; i++) {
1363
- emissionState.burstParticleIndex = i;
1364
- this.particleNum++;
1365
- if (this.particles.length < this.particleNum) {
1366
- this.growParticlePool(isTrailMode);
1367
- }
1368
- const particle = this.particles[this.particleNum - 1];
1369
- particle.reset();
1370
- particle.speedModifier = 1;
1371
- // Parent EmitSub can refill an onlyUsedByOther system after it had
1372
- // already finished empty; clear the flag so update does not treat it
1373
- // as done while it has live particles again.
1374
- this.finishedEventFired = false;
1375
- this.startColor.startGen(particle.memory);
1376
- this.startColor.genColor(particle.memory, particle.startColor, this.emissionState.time);
1377
- particle.color.copy(particle.startColor);
1378
- this.startSpeed.startGen(particle.memory);
1379
- particle.startSpeed = this.startSpeed.genValue(particle.memory, timeRatio);
1380
- this.startLife.startGen(particle.memory);
1381
- particle.life = this.startLife.genValue(particle.memory, timeRatio);
1382
- particle.age = 0;
1383
- this.startSize.startGen(particle.memory);
1384
- if (startSizeIsVector3) {
1385
- (this.startSize as Vector3Generator).genValue(particle.memory, particle.startSize, timeRatio);
1386
- } else {
1387
- const size = (this.startSize as FunctionValueGenerator).genValue(particle.memory, timeRatio);
1388
- particle.startSize.set(size, size, size);
1389
- }
1390
- this.startTileIndex.startGen(particle.memory);
1391
- particle.uvTile = this.startTileIndex.genValue(particle.memory);
1392
- particle.size.copy(particle.startSize);
1393
-
1394
- if (isSpriteMode) {
1395
- const sprite = particle as SpriteParticle;
1396
- this.startRotation.startGen(particle.memory);
1397
- if (isMeshMode) {
1398
- if (!(sprite.rotation instanceof Quaternion)) {
1399
- // Assigning a quaternion gives the particle a row in the
1400
- // store's quaternion column; the value is overwritten below.
1401
- sprite.rotation = IDENTITY_QUATERNION;
1402
- }
1403
- // A recycled particle can still carry the turn of whatever
1404
- // it was last time; the renderer would draw a fraction of it
1405
- // before any behavior has had a chance to say otherwise.
1406
- if (sprite.angularVelocity instanceof Quaternion) {
1407
- sprite.angularVelocity.set(0, 0, 0, 1);
1408
- }
1409
- if (startRotationIsRotation) {
1410
- (this.startRotation as RotationGenerator).genValue(
1411
- particle.memory,
1412
- sprite.rotation as Quaternion,
1413
- 1,
1414
- timeRatio
1415
- );
1416
- } else {
1417
- (sprite.rotation as Quaternion).setFromAxisAngle(
1418
- UP,
1419
- (this.startRotation as FunctionValueGenerator).genValue(sprite.memory, timeRatio)
1420
- );
1421
- }
1422
- } else {
1423
- if (startRotationIsRotation) {
1424
- sprite.rotation = 0;
1425
- } else {
1426
- sprite.rotation = (this.startRotation as FunctionValueGenerator).genValue(
1427
- sprite.memory,
1428
- timeRatio
1429
- );
1430
- }
1431
- // Set until a turning behavior says otherwise, so the
1432
- // renderer never carries a recycled particle's old spin.
1433
- sprite.angularVelocity = 0;
1434
- const flip = this.flip;
1435
- sprite.flip = flip === undefined ? 0 : (random() < flip.x ? 1 : 0) | (random() < flip.y ? 2 : 0);
1436
- }
1437
- } else if (trailSettings) {
1438
- const trail = particle as TrailParticle;
1439
- trailSettings.startLength.startGen(trail.memory);
1440
- trail.length = trailSettings.startLength.genValue(trail.memory, timeRatio);
1441
- trail.ensureHistoryCapacity(Math.max(1, Math.ceil(trail.length)));
1442
- trail.resetHistory();
1443
- }
1444
-
1445
- this.emitterShape.initialize(particle, emissionState);
1446
-
1447
- if (this.alignToDirection && isSpriteMode) {
1448
- this.alignParticleToDirection(particle as SpriteParticle, isMeshMode);
1449
- }
1450
-
1451
- const parent = emissionState.particle;
1452
- const inherit = emissionState.inherit;
1453
- if (parent !== undefined && inherit !== undefined && inherit !== 0) {
1454
- this.inheritFromParent(particle, parent, inherit, isSpriteMode);
1455
- }
1456
-
1457
- if (isTrailMode && followLocalOrigin) {
1458
- const trail = particle as TrailParticle;
1459
- if (!trail.localPosition) {
1460
- trail.localPosition = new Vector3();
1461
- }
1462
- trail.localPosition.copy(trail.position);
1463
- }
1464
-
1465
- if (this.worldSpace) {
1466
- particle.position.applyMatrix4(matrix);
1467
- particle.startSize.multiply(scale).abs();
1468
- particle.size.copy(particle.startSize);
1469
- if (this.scalingMode === 'hierarchy') {
1470
- // Scaled per axis by the emitter's world scale, then turned: the speed scales with it.
1471
- particle.velocity.multiply(this.hierarchyScale).applyQuaternion(tempQ);
1472
- } else {
1473
- particle.velocity.multiply(scale).applyMatrix3(this.normalMatrix);
1474
- }
1475
- if (particle.rotation && particle.rotation instanceof Quaternion) {
1476
- particle.rotation.multiplyQuaternions(tempQ, particle.rotation);
1477
- }
1478
- } else {
1479
- if (this.onlyUsedByOther) {
1480
- particle.parentMatrix = matrix;
1481
- }
1482
- }
1483
-
1484
- for (let j = 0; j < behaviorCount; j++) {
1485
- behaviors[j].initialize(particle, this);
1486
- }
1487
- // Every generator has claimed its slot by now; give the compiled
1488
- // behavior passes a column copy of the samples.
1489
- this.store.recordMemory((particle as StoreBackedParticle).storeIndex, particle.memory);
1490
- }
1491
- }
1492
-
1493
- /** Turns a newborn so that it faces the way its shape sent it; see {@link alignToDirection}. */
1494
- private alignParticleToDirection(sprite: SpriteParticle, isMeshMode: boolean): void {
1495
- const velocity = sprite.velocity;
1496
- const speedSq = velocity.x * velocity.x + velocity.y * velocity.y + velocity.z * velocity.z;
1497
- if (speedSq === 0) {
1342
+ /**
1343
+ * Removes the system from its renderer, disposes its emitter node (and the nodes under it) and
1344
+ * fires `destroy`. A disposed system cannot be played again. Calling it twice is harmless.
1345
+ */
1346
+ dispose(): void {
1347
+ if (this.destroyed) {
1498
1348
  return;
1499
1349
  }
1500
- if (isMeshMode) {
1501
- // +Z along the velocity, with the start rotation applied first.
1502
- const rotation = sprite.rotation as Quaternion;
1503
- tempV.copy(velocity).divideScalar(Math.sqrt(speedSq));
1504
- tempQ.setFromUnitVectors(UP, tempV);
1505
- rotation.multiplyQuaternions(tempQ, rotation);
1506
- } else {
1507
- // The quad's top (its +Y) toward the velocity in the emitter's XY plane.
1508
- if (velocity.x !== 0 || velocity.y !== 0) {
1509
- sprite.rotation = (sprite.rotation as number) + Math.atan2(-velocity.x, velocity.y);
1510
- }
1511
- }
1350
+ this.destroyed = true;
1351
+ // Nothing updates a disposed system again, so its particles would never die.
1352
+ this.particleNum = 0;
1353
+ if (this._renderer) this._renderer.deleteSystem(this);
1354
+ disposeEmitterForSystem(this.emitter);
1355
+ this.events.fire({type: 'destroy', particleSystem: this});
1512
1356
  }
1513
1357
 
1514
- /** Applies the parent particle's inherited properties to a sub emitted newborn; see `EmitSubParticleSystem.inherit`. */
1515
- private inheritFromParent(particle: Particle, parent: Particle, inherit: number, isSpriteMode: boolean): void {
1516
- if (inherit & SubEmitterInheritMask.color) {
1517
- particle.startColor.multiply(parent.color);
1518
- particle.color.copy(particle.startColor);
1519
- }
1520
- if (inherit & SubEmitterInheritMask.size) {
1521
- particle.startSize.multiply(parent.size);
1522
- particle.size.copy(particle.startSize);
1523
- }
1524
- if (inherit & SubEmitterInheritMask.rotation && isSpriteMode) {
1525
- const sprite = particle as SpriteParticle;
1526
- const parentRotation = parent.rotation;
1527
- if (typeof sprite.rotation === 'number') {
1528
- if (typeof parentRotation === 'number') {
1529
- sprite.rotation += parentRotation;
1530
- }
1531
- } else if (parentRotation instanceof Quaternion) {
1532
- (sprite.rotation as Quaternion).multiplyQuaternions(parentRotation, sprite.rotation as Quaternion);
1358
+ /**
1359
+ * Rolls the system-level generators into the system's memory. Called on construction and on
1360
+ * every restart, which empties the memory: a generator that keeps a slot index into it, such
1361
+ * as `IntervalValue`, would otherwise read past the end and return NaN. A seeded system rolls
1362
+ * them on its own generator, so a restart replays the same rates, counts and delay.
1363
+ */
1364
+ private initSystemGenerators(): void {
1365
+ const rng = this.rng;
1366
+ const previous = rng === undefined ? undefined : setRandomSource(rng);
1367
+ try {
1368
+ this.clock.setStartDelay(this.emission.rollGenerators());
1369
+ } finally {
1370
+ if (rng !== undefined) {
1371
+ setRandomSource(previous ?? null);
1533
1372
  }
1534
1373
  }
1535
- if (inherit & SubEmitterInheritMask.lifetime && parent.life > 0) {
1536
- particle.life *= Math.max(0, 1 - parent.age / parent.life);
1537
- }
1538
- }
1539
-
1540
- endEmit() {
1541
- this.emitEnded = true;
1542
- if (this.autoDestroy) {
1543
- this.markForDestroy = true;
1544
- }
1545
- this.fire({type: 'emitEnd', particleSystem: this});
1546
1374
  }
1547
1375
 
1548
- dispose() {
1376
+ /**
1377
+ * Clears every particle and starts the system from the beginning: emission time 0, bursts and
1378
+ * the start delay rolled again, behaviors reset, and a seeded system's generator restarted, so
1379
+ * it replays the same effect. Also unpauses it.
1380
+ */
1381
+ restart(): void {
1549
1382
  if (this.destroyed) {
1550
1383
  return;
1551
1384
  }
1552
- this.destroyed = true;
1553
- if (this._renderer) this._renderer.deleteSystem(this);
1554
- this.emitter.dispose();
1555
- this.fire({type: 'destroy', particleSystem: this});
1556
- }
1557
-
1558
- restart() {
1559
- this.memory.length = 0;
1385
+ this.emission.memory.length = 0;
1560
1386
  this.paused = false;
1561
1387
  this.particleNum = 0;
1562
- this.boundsValid = false;
1563
- this.pendingCatchUp = 0;
1388
+ this.bounds.valid = false;
1389
+ this.clock.reset();
1564
1390
  if (this.seedValue !== undefined) {
1565
1391
  // The same seed again, so a restart replays the same effect.
1566
1392
  this.rng = createSeededRandom(this.seedValue);
@@ -1571,19 +1397,30 @@ export class ParticleSystem implements IParticleSystem {
1571
1397
  this.emissionState.burstWaves = undefined;
1572
1398
  this.emissionState.time = 0;
1573
1399
  this.emissionState.waitEmiting = 0;
1574
- this.behaviors.forEach((behavior) => behavior.reset());
1400
+ // Forget where the emitter was: a restarted (or pooled and moved) system must not spawn
1401
+ // emission-over-distance particles along the jump from its previous position.
1402
+ this.emissionState.travelDistance = 0;
1403
+ this.emissionState.previousWorldPos = undefined;
1404
+ this.emissionState.burstParticleIndex = 0;
1405
+ this.emissionState.burstParticleCount = 0;
1406
+ // Bursts edited in place since the last play are put back in time order and in range.
1407
+ this.emissionBursts.forEach((burst) => Object.assign(burst, normalizeBurst(burst)));
1408
+ this.emissionBursts.sort((a, b) => a.time - b.time);
1409
+ this.behaviors.forEach((behavior) => {
1410
+ behavior.reset();
1411
+ if (behavior instanceof EmitSubParticleSystem) {
1412
+ behavior.clearEmissions();
1413
+ }
1414
+ });
1415
+ // A sweeping shape (Loop, PingPong) starts its arc over, so the replay is the same.
1416
+ this.emitterShape.reset?.();
1575
1417
  this.emitEnded = false;
1576
- this.markForDestroy = false;
1577
1418
  this.finishedEventFired = false;
1578
1419
  this.prewarmed = false;
1579
- this.emissionBursts.forEach((burst) => burst.count.startGen(this.memory));
1580
- this.emissionOverDistance.startGen(this.memory);
1581
- this.startDelay.startGen(this.memory);
1582
- this.startDelayTimeLeft = this.startDelay.genValue(this.memory, 0);
1420
+ this.initSystemGenerators();
1583
1421
  this.previousEmitterPos = undefined;
1422
+ this.previousEmitterMatrix = undefined;
1584
1423
  this.emitterVelocity.set(0, 0, 0);
1585
- this.simulationAccumulator = 0;
1586
- this.destroyed = false;
1587
1424
  }
1588
1425
 
1589
1426
  private firstTimeUpdate = true;
@@ -1601,19 +1438,26 @@ export class ParticleSystem implements IParticleSystem {
1601
1438
  * @internal
1602
1439
  */
1603
1440
  update(delta: number, stepDeadline = Infinity, catchUpDeadline?: number) {
1604
- if (this.paused) return;
1605
- delta *= this.playbackSpeed;
1441
+ if (this.paused || this.destroyed) return;
1442
+ const speed = this.playbackSpeed;
1443
+ delta = SimulationClock.frameSeconds(delta, speed);
1606
1444
 
1607
1445
  if (this.culled) {
1608
1446
  // Out of every camera's view. A paused system holds its particles
1609
1447
  // where they are; a catching-up one also remembers the time it missed.
1610
1448
  if (this.cullingMode === 'pauseAndCatchUp') {
1611
- this.pendingCatchUp = Math.min(MAX_CATCH_UP_SECONDS, this.pendingCatchUp + delta);
1449
+ this.clock.deferCatchUp(delta);
1612
1450
  }
1451
+ // Where the emitter goes while out of view is not motion to spread the frame of its
1452
+ // return over: that frame starts from wherever it is then.
1453
+ this.previousEmitterMatrix = undefined;
1613
1454
  return;
1614
1455
  }
1615
- if (this.pendingCatchUp > 0) {
1456
+ if (this.clock.owesCatchUp) {
1616
1457
  this.catchUp(catchUpDeadline ?? performance.now() + ParticleSystem.catchUpBudgetMs);
1458
+ if (this.paused || this.destroyed) return;
1459
+ // The caught-up steps ran where the emitter is now; so does this frame.
1460
+ (this.previousEmitterMatrix ??= new Matrix4()).copy(this.emitter.matrixWorld);
1617
1461
  }
1618
1462
 
1619
1463
  if (this.firstTimeUpdate) {
@@ -1627,27 +1471,13 @@ export class ParticleSystem implements IParticleSystem {
1627
1471
  // accumulator here desyncs it from the parent, so the next particles
1628
1472
  // the parent emits into it sit at age 0 / startColor until the phase
1629
1473
  // catches up — white flashes scattered wherever the parent was.
1630
- if (delta > 0.1) {
1631
- delta = 0.1;
1632
- }
1633
- this.simulationAccumulator += delta;
1634
- const stepsToRun = Math.min(
1635
- MAX_SIMULATION_STEPS_PER_FRAME,
1636
- Math.max(0, Math.round(this.simulationAccumulator / SIMULATION_STEP - ROUND_TIE_BIAS))
1637
- );
1638
- this.simulationAccumulator -= stepsToRun * SIMULATION_STEP;
1474
+ this.clock.accrue(delta, speed);
1639
1475
  return;
1640
1476
  }
1641
1477
 
1642
1478
  if (this.looping && this.prewarm && !this.prewarmed) {
1643
- this.prewarmed = true;
1644
- for (let i = 0; i < this.duration * PREWARM_FPS; i++) {
1645
- this.update(1.0 / PREWARM_FPS);
1646
- }
1647
- }
1648
-
1649
- if (delta > 0.1) {
1650
- delta = 0.1;
1479
+ this.runPrewarm();
1480
+ if (this.paused || this.destroyed) return;
1651
1481
  }
1652
1482
 
1653
1483
  if (this.neededToUpdateRender) {
@@ -1663,37 +1493,64 @@ export class ParticleSystem implements IParticleSystem {
1663
1493
  (this.previousEmitterPos ??= new Vector3()).copy(this.tempEmitterPos);
1664
1494
 
1665
1495
  // Start delay holds back emission once per play/restart (ignored with
1666
- // prewarm, matching Unity). Carry the remainder so a large frame does
1667
- // not lose time.
1668
- if (this.startDelayTimeLeft > 0 && !(this.prewarm && this.looping)) {
1669
- this.startDelayTimeLeft -= delta;
1670
- if (this.startDelayTimeLeft >= 0) return;
1671
- delta = Math.min(-this.startDelayTimeLeft, 0.1);
1672
- this.startDelayTimeLeft = 0;
1673
- }
1674
-
1675
- this.simulationAccumulator += delta;
1676
- const stepsToRun = Math.min(
1677
- MAX_SIMULATION_STEPS_PER_FRAME,
1678
- Math.max(0, Math.round(this.simulationAccumulator / SIMULATION_STEP - ROUND_TIE_BIAS))
1679
- );
1496
+ // prewarm, matching Unity). The clock carries the remainder of the
1497
+ // frame that ends it, so a large frame does not lose time.
1498
+ if (this.clock.delaying && !(this.prewarm && this.looping)) {
1499
+ delta = this.clock.consumeStartDelay(delta);
1500
+ if (delta === 0) return;
1501
+ }
1502
+
1503
+ const stepsToRun = this.clock.accrue(delta, speed);
1504
+ if (stepsToRun === 0) return;
1505
+ // A frame that runs several steps hands each of them the emitter where it was that far
1506
+ // along the frame, between the matrix of the last simulated step and the current one
1507
+ // (position, rotation and scale apart, as Niagara's interpolated spawning does): spawns
1508
+ // land along the emitter's path and world-space trail samples follow it, instead of all
1509
+ // sitting at the frame's end. A single step, the first frame after play, restart, a
1510
+ // catch-up or a spell out of view, and a still emitter use the current matrix as it is.
1511
+ // A node disabled and moved while disabled is not seen here; place it with `restart()`.
1512
+ const current = this.emitter.matrixWorld;
1513
+ const previous = this.previousEmitterMatrix;
1514
+ const interpolate = stepsToRun > 1 && previous !== undefined && !previous.equals(current);
1515
+ if (interpolate) {
1516
+ previous.decompose(fromPosition, fromRotation, fromScale);
1517
+ current.decompose(toPosition, toRotation, toScale);
1518
+ }
1519
+ let stepped = current;
1680
1520
  for (let i = 0; i < stepsToRun; i++) {
1681
- this.simulateStep(SIMULATION_STEP);
1521
+ // The last step takes the current matrix itself, shear and all: decompose/compose
1522
+ // only approximates a sheared matrix, and the step a still emitter's frame ends on
1523
+ // must match a single-step frame exactly.
1524
+ if (interpolate && i + 1 < stepsToRun) {
1525
+ const t = (i + 1) / stepsToRun;
1526
+ stepPosition.copy(fromPosition).lerp(toPosition, t);
1527
+ stepRotation.copy(fromRotation).slerp(toRotation, t);
1528
+ stepScale.copy(fromScale).lerp(toScale, t);
1529
+ stepped = this.stepMatrix.compose(stepPosition, stepRotation, stepScale);
1530
+ } else {
1531
+ stepped = current;
1532
+ }
1533
+ this.simulateStep(SIMULATION_STEP, stepped);
1534
+ // A listener (finished, particleDied) may have stopped, paused or disposed the system
1535
+ // mid-frame; the remaining steps would emit into a stopped system.
1536
+ if (this.paused || this.destroyed) break;
1682
1537
  if (stepDeadline !== Infinity && i + 1 < stepsToRun && performance.now() >= stepDeadline) {
1683
1538
  // Over the frame's simulation budget: the remaining steps' time
1684
- // is consumed below without being simulated.
1539
+ // was taken off the clock without being simulated.
1685
1540
  break;
1686
1541
  }
1687
1542
  }
1688
- this.simulationAccumulator -= stepsToRun * SIMULATION_STEP;
1543
+ if (!this.destroyed) {
1544
+ (this.previousEmitterMatrix ??= new Matrix4()).copy(stepped);
1545
+ }
1689
1546
  }
1690
1547
 
1691
1548
  /** Runs the steps a system missed while culled, as many as fit before `deadline`. */
1692
1549
  private catchUp(deadline: number): void {
1693
- let steps = Math.floor(this.pendingCatchUp / SIMULATION_STEP);
1694
- this.pendingCatchUp = 0;
1550
+ let steps = this.clock.takeCatchUpSteps();
1695
1551
  while (steps-- > 0) {
1696
1552
  this.simulateStep(SIMULATION_STEP);
1553
+ if (this.paused || this.destroyed) break;
1697
1554
  if (steps > 0 && performance.now() >= deadline) {
1698
1555
  break;
1699
1556
  }
@@ -1701,67 +1558,53 @@ export class ParticleSystem implements IParticleSystem {
1701
1558
  }
1702
1559
 
1703
1560
  /**
1704
- * Records the bounds the sprite step measured over the live particles.
1705
- * Trails are measured by their renderer instead, since their ribbons live
1706
- * outside the store.
1561
+ * Counts the bursts, and the waves of the one in progress, due before `time` seconds into the
1562
+ * cycle as fired, so a jump of the emission clock does not fire them late. The waves are
1563
+ * counted with the arithmetic the emission uses, so none is lost to rounding.
1707
1564
  */
1708
- private storeBounds(
1709
- count: number,
1710
- measured: boolean,
1711
- minX: number,
1712
- minY: number,
1713
- minZ: number,
1714
- maxX: number,
1715
- maxY: number,
1716
- maxZ: number,
1717
- maxSize: number,
1718
- maxSpeedSq: number
1719
- ): void {
1720
- if (count === 0) {
1721
- this.boundsValid = false;
1722
- return;
1723
- }
1724
- this.stepsSinceBounds = measured ? 0 : this.stepsSinceBounds + 1;
1725
- this.boundsMaxSize = maxSize;
1726
- this.boundsMaxSpeedSq = maxSpeedSq;
1727
- const maxSpeed = Math.sqrt(maxSpeedSq);
1728
- const renderMode = this.rendererSettings.renderMode;
1729
- // How far past its position a particle can be drawn: half a quad's
1730
- // diagonal plus its pivot, a mesh's radius, or a stretched quad's tail.
1731
- // Plus the motion since the last measurement, and the step the renderer
1732
- // moves particles on along their velocity.
1733
- let padding = maxSpeed * SIMULATION_STEP * (this.stepsSinceBounds + 1);
1734
- if (renderMode === RenderMode.Mesh) {
1735
- padding += maxSize * this.meshGeometryRadius();
1736
- } else if (renderMode === RenderMode.StretchedBillBoard) {
1737
- const settings = this.rendererEmitterSettings as StretchedBillBoardSettings;
1738
- padding += maxSize * (1 + (settings.lengthFactor ?? 0) + maxSpeed * (settings.speedFactor ?? 0));
1739
- } else {
1740
- const pivot = this.rendererSettings.pivot;
1741
- const pivotReach = pivot === undefined ? 0 : Math.abs(pivot.x) + Math.abs(pivot.y) + Math.abs(pivot.z);
1742
- padding += maxSize * (0.75 + pivotReach);
1743
- }
1744
- this.boundsMin.set(minX, minY, minZ);
1745
- this.boundsMax.set(maxX, maxY, maxZ);
1746
- this.boundsPadding = padding;
1747
- this.boundsInWorld = this.worldSpace;
1748
- this.boundsValid = true;
1749
- }
1750
-
1751
- /** Distance of the farthest vertex of the mesh particles' geometry from its origin. */
1752
- private meshGeometryRadius(): number {
1753
- const geometry = this.rendererSettings.instancingGeometry;
1754
- if (geometry !== this.geometryRadiusFor) {
1755
- let radiusSq = 0;
1756
- for (let o = 0; o + 2 < geometry.length; o += 3) {
1757
- const d =
1758
- geometry[o] * geometry[o] + geometry[o + 1] * geometry[o + 1] + geometry[o + 2] * geometry[o + 2];
1759
- if (d > radiusSq) radiusSq = d;
1565
+ private markBurstsBefore(time: number): void {
1566
+ const state = this.emissionState;
1567
+ const bursts = this.emissionBursts;
1568
+ const waves = (state.burstWaves ??= []);
1569
+ while (state.burstIndex < bursts.length && bursts[state.burstIndex].time < time) {
1570
+ const burst = bursts[state.burstIndex];
1571
+ const cycles = burstWaveCount(burst);
1572
+ const interval = burstInterval(burst);
1573
+ let wave = 0;
1574
+ if (interval > 0) {
1575
+ while (wave < cycles && burst.time + wave * interval < time) {
1576
+ wave++;
1577
+ }
1578
+ } else {
1579
+ wave = cycles;
1760
1580
  }
1761
- this.geometryRadius = Math.sqrt(radiusSq);
1762
- this.geometryRadiusFor = geometry;
1581
+ waves[state.burstIndex] = wave;
1582
+ state.burstIndex++;
1583
+ }
1584
+ }
1585
+
1586
+ /**
1587
+ * Simulates the end of a cycle before the first frame, on the fixed step directly: no frame
1588
+ * clamp, culling or emitter velocity in the way. The cycle's last {@link prewarmTime} seconds
1589
+ * (the whole cycle when that is 0), and at most {@link maxPrewarmSeconds}, with the bursts
1590
+ * before that counted as fired.
1591
+ */
1592
+ private runPrewarm(): void {
1593
+ this.prewarmed = true;
1594
+ const wanted = this.prewarmTime > 0 ? this.prewarmTime : this.duration;
1595
+ const warm = Math.min(this.duration, wanted, Math.max(0, ParticleSystem.maxPrewarmSeconds));
1596
+ const steps = Math.round(warm / SIMULATION_STEP);
1597
+ if (warm < this.duration) {
1598
+ const state = this.emissionState;
1599
+ state.time = this.duration - steps * SIMULATION_STEP;
1600
+ // The waves due before the warm-up count as fired, so the first step does not fire
1601
+ // them late; the waves inside it still fire as the time comes.
1602
+ this.markBurstsBefore(state.time);
1603
+ }
1604
+ for (let i = 0; i < steps; i++) {
1605
+ this.simulateStep(SIMULATION_STEP);
1606
+ if (this.paused || this.destroyed) return;
1763
1607
  }
1764
- return this.geometryRadius;
1765
1608
  }
1766
1609
 
1767
1610
  /**
@@ -1773,7 +1616,7 @@ export class ParticleSystem implements IParticleSystem {
1773
1616
  * frame behind the display.
1774
1617
  */
1775
1618
  getWorldBounds(min: Vector3, max: Vector3): boolean {
1776
- if (!this.measureWorldBounds(min, max)) {
1619
+ if (!this.bounds.toWorld(min, max, this.emitter)) {
1777
1620
  return false;
1778
1621
  }
1779
1622
  if (this.rendererSettings.renderMode === RenderMode.Beam) {
@@ -1783,7 +1626,7 @@ export class ParticleSystem implements IParticleSystem {
1783
1626
  if (!this.getBeamEnd(end)) {
1784
1627
  return true;
1785
1628
  }
1786
- const pad = this.boundsInWorld ? this.boundsPadding : this.boundsPadding * this.emitterMaxScale();
1629
+ const pad = this.bounds.worldPadding(this.emitter);
1787
1630
  min.set(Math.min(min.x, end.x - pad), Math.min(min.y, end.y - pad), Math.min(min.z, end.z - pad));
1788
1631
  max.set(Math.max(max.x, end.x + pad), Math.max(max.y, end.y + pad), Math.max(max.z, end.z + pad));
1789
1632
  }
@@ -1793,7 +1636,8 @@ export class ParticleSystem implements IParticleSystem {
1793
1636
  /**
1794
1637
  * Where a {@link RenderMode.Beam} system's quads end, see {@link BeamEnd}.
1795
1638
  * Unset (the default), a beam has no length and draws nothing. Not saved
1796
- * in the effect JSON: it is where this instance is aimed at runtime.
1639
+ * in the effect JSON: it is where this instance is aimed at runtime. Copied by
1640
+ * {@link clone}: a point as a copy, a node as the same node.
1797
1641
  */
1798
1642
  beamEnd?: BeamEnd;
1799
1643
 
@@ -1830,67 +1674,6 @@ export class ParticleSystem implements IParticleSystem {
1830
1674
  return true;
1831
1675
  }
1832
1676
 
1833
- /** The largest axis of the emitter's world scale. */
1834
- private emitterMaxScale(): number {
1835
- const e = this.emitter.matrixWorld.elements;
1836
- return Math.sqrt(
1837
- Math.max(
1838
- e[0] * e[0] + e[1] * e[1] + e[2] * e[2],
1839
- e[4] * e[4] + e[5] * e[5] + e[6] * e[6],
1840
- e[8] * e[8] + e[9] * e[9] + e[10] * e[10]
1841
- )
1842
- );
1843
- }
1844
-
1845
- private measureWorldBounds(min: Vector3, max: Vector3): boolean {
1846
- if (!this.boundsValid) {
1847
- return false;
1848
- }
1849
- const bmin = this.boundsMin;
1850
- const bmax = this.boundsMax;
1851
- if (this.boundsInWorld) {
1852
- const pad = this.boundsPadding;
1853
- min.set(bmin.x - pad, bmin.y - pad, bmin.z - pad);
1854
- max.set(bmax.x + pad, bmax.y + pad, bmax.z + pad);
1855
- return true;
1856
- }
1857
- // Local bounds: the box of the eight transformed corners, padded by the
1858
- // reach scaled by the emitter's largest axis.
1859
- const e = this.emitter.matrixWorld.elements;
1860
- const scale = Math.sqrt(
1861
- Math.max(
1862
- e[0] * e[0] + e[1] * e[1] + e[2] * e[2],
1863
- e[4] * e[4] + e[5] * e[5] + e[6] * e[6],
1864
- e[8] * e[8] + e[9] * e[9] + e[10] * e[10]
1865
- )
1866
- );
1867
- const pad = this.boundsPadding * scale;
1868
- let minX = Infinity;
1869
- let minY = Infinity;
1870
- let minZ = Infinity;
1871
- let maxX = -Infinity;
1872
- let maxY = -Infinity;
1873
- let maxZ = -Infinity;
1874
- for (let corner = 0; corner < 8; corner++) {
1875
- const x = corner & 1 ? bmax.x : bmin.x;
1876
- const y = corner & 2 ? bmax.y : bmin.y;
1877
- const z = corner & 4 ? bmax.z : bmin.z;
1878
- const w = 1 / (e[3] * x + e[7] * y + e[11] * z + e[15]);
1879
- const wx = (e[0] * x + e[4] * y + e[8] * z + e[12]) * w;
1880
- const wy = (e[1] * x + e[5] * y + e[9] * z + e[13]) * w;
1881
- const wz = (e[2] * x + e[6] * y + e[10] * z + e[14]) * w;
1882
- if (wx < minX) minX = wx;
1883
- if (wx > maxX) maxX = wx;
1884
- if (wy < minY) minY = wy;
1885
- if (wy > maxY) maxY = wy;
1886
- if (wz < minZ) minZ = wz;
1887
- if (wz > maxZ) maxZ = wz;
1888
- }
1889
- min.set(minX - pad, minY - pad, minZ - pad);
1890
- max.set(maxX + pad, maxY + pad, maxZ + pad);
1891
- return true;
1892
- }
1893
-
1894
1677
  /**
1895
1678
  * Records bounds a renderer measured in world space, for systems whose
1896
1679
  * drawn extent the store does not hold (trail ribbons).
@@ -1905,891 +1688,172 @@ export class ParticleSystem implements IParticleSystem {
1905
1688
  maxZ: number,
1906
1689
  padding: number
1907
1690
  ) {
1908
- this.boundsMin.set(minX, minY, minZ);
1909
- this.boundsMax.set(maxX, maxY, maxZ);
1910
- this.boundsPadding = padding;
1911
- this.boundsInWorld = true;
1912
- this.boundsValid = true;
1691
+ this.bounds.setWorld(minX, minY, minZ, maxX, maxY, maxZ, padding);
1913
1692
  }
1914
1693
 
1915
- /**
1916
- * Time the display is ahead of (positive) or still ahead of the simulation
1917
- * (negative — the simulation ran a hair early), in seconds. Magnitude never
1918
- * exceeds one step.
1919
- *
1920
- * The simulation runs on a fixed 1/60 step so that emission and lifetimes
1921
- * stay in phase — without it a slow emitter flickers when the frame rate
1922
- * wobbles. But frames do not arrive on that grid: a display running at
1923
- * anything other than exactly 60Hz leaves some frames with no step at all
1924
- * (half of them at 120Hz, three in five at 144Hz) and others with two, so
1925
- * the particles are drawn stuttering even while the frame rate is perfect.
1926
- *
1927
- * The steps-per-frame count below is chosen by *rounding* the accumulated
1928
- * time to the nearest step, not flooring it. Flooring always leaves the
1929
- * accumulator positive, and when the real frame rate sits very close to
1930
- * 60Hz (as most displays do) that leftover creeps toward a full step for
1931
- * many consecutive frames before tipping over into a frame that runs two
1932
- * steps at once and resets it to near zero — a visible lurch, every particle
1933
- * in the system at once, with a perfectly steady frame rate the whole time.
1934
- * Rounding lets the leftover go negative instead, which keeps it centered
1935
- * near zero and stops it from ever building up toward that reset.
1936
- *
1937
- * Renderers close the gap by advancing what they draw along each particle's
1938
- * velocity by this much. That reproduces the straight-line part of the
1939
- * motion exactly — it is the same term the next step would integrate — so
1940
- * the simulation stays fixed-step while the picture moves continuously.
1941
- */
1942
1694
  /** Length of one simulation step, in seconds. See {@link simulationResidual}. */
1943
1695
  get simulationStep(): number {
1944
1696
  return SIMULATION_STEP;
1945
1697
  }
1946
1698
 
1947
- get simulationResidual(): number {
1948
- if (this.paused) {
1949
- return 0;
1950
- }
1951
- // Magnitude never more than a step, even if a frame long enough to hit
1952
- // the per-frame step cap left the accumulator with more than that.
1953
- if (this.simulationAccumulator > SIMULATION_STEP) return SIMULATION_STEP;
1954
- if (this.simulationAccumulator < -SIMULATION_STEP) return -SIMULATION_STEP;
1955
- return this.simulationAccumulator;
1956
- }
1957
-
1958
1699
  /**
1959
- * Execution plan for this system's behaviors, rebuilt when the list changes.
1700
+ * Time the display is ahead of (positive) or still ahead of the simulation
1701
+ * (negative: the simulation ran a hair early), in seconds; 0 while paused.
1702
+ * Its magnitude never exceeds one step.
1960
1703
  *
1961
- * Consecutive behaviors that can be compiled into one loop are merged, so
1962
- * the particles are walked once instead of once per behavior. The plan is
1963
- * keyed on the behavior instances, not their types: swapping one out has to
1964
- * change the generated code's indices.
1704
+ * The simulation runs on a fixed 1/60 step so that emission and lifetimes
1705
+ * stay in phase; without it a slow emitter flickers when the frame rate
1706
+ * wobbles. But frames do not arrive on that grid: a display running at
1707
+ * anything other than exactly 60 Hz leaves some frames with no step at all
1708
+ * (half of them at 120 Hz, three in five at 144 Hz) and others with two, so
1709
+ * the particles would be drawn stuttering even while the frame rate is
1710
+ * perfect. Renderers close the gap by advancing what they draw along each
1711
+ * particle's velocity by this much. That reproduces the straight-line part
1712
+ * of the motion exactly, the same term the next step would integrate, so
1713
+ * the simulation stays fixed-step while the picture moves continuously.
1965
1714
  */
1966
- private behaviorSteps(): Array<FusionStep> {
1967
- const behaviors = this.behaviors;
1968
- const cached = this.fusionPlan;
1969
- if (cached !== undefined && cached.length === behaviors.length) {
1970
- let same = true;
1971
- for (let i = 0; i < behaviors.length; i++) {
1972
- if (cached[i] !== behaviors[i]) {
1973
- same = false;
1974
- break;
1975
- }
1976
- }
1977
- if (same) {
1978
- return this.fusionSteps;
1979
- }
1980
- }
1981
- this.fusionPlan = behaviors.slice();
1982
- this.fusionSteps = planBehaviorFusion(behaviors);
1983
- let changesSize = false;
1984
- let changesColor = false;
1985
- for (let i = 0; i < behaviors.length; i++) {
1986
- const type = behaviors[i].type;
1987
- if (SIZE_BEHAVIORS.has(type)) {
1988
- changesSize = true;
1989
- } else if (COLOR_BEHAVIORS.has(type)) {
1990
- changesColor = true;
1991
- } else if (!INERT_FOR_SIZE_AND_COLOR.has(type)) {
1992
- // Unknown to this list, so it may write anything.
1993
- changesSize = true;
1994
- changesColor = true;
1995
- }
1996
- }
1997
- this.planChangesSize = changesSize;
1998
- this.planChangesColor = changesColor;
1999
- return this.fusionSteps;
1715
+ get simulationResidual(): number {
1716
+ return this.paused ? 0 : this.clock.residual;
2000
1717
  }
2001
1718
 
2002
1719
  /**
2003
- * Advances emission, behaviors, motion and culling by one fixed timestep,
2004
- * on the system's own random generator when it has a {@link seed}.
1720
+ * Advances emission, behaviors, motion and culling by one fixed timestep, on the system's own
1721
+ * random generator when it has a {@link seed}, with the emitter at `emitterMatrix`: the
1722
+ * current one, unless a frame interpolates (see {@link update}).
2005
1723
  */
2006
- private simulateStep(delta: number): void {
1724
+ private simulateStep(delta: number, emitterMatrix: Matrix4 = this.emitter.matrixWorld): void {
2007
1725
  const rng = this.rng;
2008
1726
  if (rng === undefined) {
2009
- this.stepOnce(delta);
1727
+ this.stepOnce(delta, emitterMatrix);
2010
1728
  return;
2011
1729
  }
2012
1730
  const previous = setRandomSource(rng);
2013
1731
  try {
2014
- this.stepOnce(delta);
1732
+ this.stepOnce(delta, emitterMatrix);
2015
1733
  } finally {
2016
1734
  setRandomSource(previous);
2017
1735
  }
2018
1736
  }
2019
1737
 
2020
- private stepOnce(delta: number): void {
2021
- this.motionScale = this.measureMotionScale();
1738
+ private stepOnce(delta: number, emitterMatrix: Matrix4): void {
1739
+ if (this.destroyed) return;
1740
+ this.motionScale = this.measureMotionScale(emitterMatrix);
2022
1741
  if (!this.onlyUsedByOther) {
2023
- this.emit(delta, this.emissionState, this.emitter.matrixWorld);
1742
+ this.emission.emit(delta, this.emissionState, emitterMatrix);
2024
1743
  }
2025
1744
 
2026
1745
  const particles = this.particles;
2027
1746
  const behaviors = this.behaviors;
2028
- const behaviorCount = behaviors.length;
2029
1747
  const particleCount = this.particleNum;
2030
- const isTrailMode = this.rendererSettings.renderMode === RenderMode.Trail;
2031
- // Only stretched billboards draw from velocity, and only they pay for
2032
- // keeping the previous one.
2033
- const stretched = this.rendererSettings.renderMode === RenderMode.StretchedBillBoard;
2034
-
2035
- // Remember where everything starts this step, so the renderer can carry
2036
- // the motion the step produces on into the part of the frame that comes
2037
- // after it. Taken after emission on purpose: a particle born this step
2038
- // then starts from where it was born rather than from whatever the row
2039
- // held before, and so is drawn at the emitter instead of jumping.
2040
- const columns = this.store;
2041
- const vector3Rows = particleCount * 3;
2042
- const steps = this.behaviorSteps();
2043
- // Size and colour are only snapshotted when some behavior can change
2044
- // them; otherwise the columns stay what spawn wrote and the renderer
2045
- // reads them as they are.
2046
- const snapshotSize = this.planChangesSize;
2047
- const snapshotColor = this.planChangesColor;
2048
- columns.useSizeSnapshot(snapshotSize);
2049
- columns.useColorSnapshot(snapshotColor);
2050
- columns.previousPosition.set(columns.position.subarray(0, vector3Rows));
2051
- if (snapshotSize) {
2052
- columns.previousSize.set(columns.size.subarray(0, vector3Rows));
2053
- }
2054
- if (stretched) {
2055
- if (columns.keepPreviousVelocity()) {
2056
- this.rebindParticles();
2057
- }
2058
- columns.previousVelocity.set(columns.velocity.subarray(0, vector3Rows));
2059
- }
2060
- if (snapshotColor) {
2061
- columns.previousColor.set(columns.color.subarray(0, particleCount * 4));
2062
- }
1748
+ const store = this.store;
1749
+ const renderMode = this.rendererSettings.renderMode;
1750
+ const isTrailMode = renderMode === RenderMode.Trail;
1751
+
1752
+ // Taken after emission on purpose: a particle born this step then starts from where it
1753
+ // was born rather than from whatever the row held before, and so is drawn at the emitter
1754
+ // instead of jumping.
1755
+ const steps = this.behaviorPlan.steps(behaviors);
1756
+ const snapshotSize = this.behaviorPlan.changesSize;
1757
+ const snapshotColor = this.behaviorPlan.changesColor;
1758
+ snapshotPreviousState(
1759
+ store,
1760
+ particleCount,
1761
+ snapshotSize,
1762
+ snapshotColor,
1763
+ renderMode === RenderMode.StretchedBillBoard,
1764
+ this.rebindParticles
1765
+ );
2063
1766
 
2064
1767
  this.emitterShape.update(this, delta);
2065
- for (let s = 0; s < steps.length; s++) {
2066
- const step = steps[s];
2067
- const stepBehaviors = step.behaviors;
2068
- for (let j = 0; j < stepBehaviors.length; j++) {
2069
- stepBehaviors[j].frameUpdate(delta);
2070
- }
2071
- // Nothing to update: skip, matching what the per-particle loop did.
2072
- // A behavior may set up state before its loop that is only valid
2073
- // once it has seen a particle.
2074
- if (particleCount === 0) {
2075
- continue;
2076
- }
2077
- if (step.run !== undefined) {
2078
- step.run(behaviors, particles, particleCount, delta, this.store);
2079
- continue;
2080
- }
2081
- const behavior = stepBehaviors[0];
2082
- if (behavior.updateAll !== undefined) {
2083
- behavior.updateAll(particles, particleCount, delta);
2084
- continue;
2085
- }
2086
- for (let i = 0; i < particleCount; i++) {
2087
- const particle = particles[i];
2088
- // Inlined `died` — this runs once per behavior per particle.
2089
- if (particle.age < particle.life) {
2090
- behavior.update(particle, delta);
2091
- }
2092
- }
2093
- }
1768
+ runBehaviorSteps(steps, behaviors, particles, particleCount, delta, store);
2094
1769
 
2095
- const notifyDeaths = this.hasListeners('particleDied');
2096
- const store = this.store;
2097
- const scalars = store.scalars;
2098
- const stride = ParticleStore.SCALAR_STRIDE;
1770
+ const onDied = this.events.has('particleDied') ? this.fireParticleDied : undefined;
1771
+ // Read again: a behavior (a sub emitter aimed at this system) may have spawned more.
2099
1772
  let liveParticleCount = this.particleNum;
2100
-
2101
1773
  if (!isTrailMode) {
2102
- // One pass over the columns does three things the sprite path needs,
2103
- // none of which touches a particle object until a death:
2104
- //
2105
- // Newborns were snapshotted above with their spawn size and colour,
2106
- // before SizeOverLife / ColorOverLife ran. That false jump would be
2107
- // extrapolated by the renderer when the residual is negative (common
2108
- // with the rounded fixed step) and flash the start appearance at the
2109
- // emitter, so the snapshot is collapsed onto the curves' t=0 value.
2110
- // Age is still 0 at that point; the same iteration then ages it.
2111
- //
2112
- // Position is integrated along the velocity.
2113
- //
2114
- // A particle whose age reached its life is swapped with the last live
2115
- // one, rows included, so slot k always owns row k and the live
2116
- // particles stay one contiguous range the renderer copies as is. The
2117
- // survivor moved into the slot has not been visited yet, so the slot
2118
- // is processed again.
2119
- const positions = store.position;
2120
- const velocities = store.velocity;
2121
- const prevSize = store.previousSize;
2122
- const curSize = store.size;
2123
- const prevColor = store.previousColor;
2124
- const curColor = store.color;
2125
- const collapseSize = snapshotSize;
2126
- const collapseColor = snapshotColor;
2127
- // The bounds fall out of the same pass. Measuring every particle on
2128
- // every step costs about as much as the integration itself, so the
2129
- // full measurement runs every few steps; in between, newborns extend
2130
- // the last bounds and the fastest particle seen since pads them for
2131
- // the motion, which keeps them conservative.
2132
- const measure = this.stepsSinceBounds >= BOUNDS_REFRESH_STEPS || !this.boundsValid;
2133
- const carriedMin = this.boundsMin;
2134
- const carriedMax = this.boundsMax;
2135
- let minX = measure ? Infinity : carriedMin.x;
2136
- let minY = measure ? Infinity : carriedMin.y;
2137
- let minZ = measure ? Infinity : carriedMin.z;
2138
- let maxX = measure ? -Infinity : carriedMax.x;
2139
- let maxY = measure ? -Infinity : carriedMax.y;
2140
- let maxZ = measure ? -Infinity : carriedMax.z;
2141
- let maxSize = measure ? 0 : this.boundsMaxSize;
2142
- let maxSpeedSq = measure ? 0 : this.boundsMaxSpeedSq;
2143
- for (let i = 0; i < liveParticleCount; i++) {
2144
- const scalar = i * stride;
2145
- let age = scalars[scalar + ParticleStore.AGE];
2146
- const offset = i * 3;
2147
- const track = measure || age === 0;
2148
- if (age === 0) {
2149
- if (collapseSize) {
2150
- prevSize[offset] = curSize[offset];
2151
- prevSize[offset + 1] = curSize[offset + 1];
2152
- prevSize[offset + 2] = curSize[offset + 2];
2153
- }
2154
- if (collapseColor) {
2155
- const o4 = i * 4;
2156
- prevColor[o4] = curColor[o4];
2157
- prevColor[o4 + 1] = curColor[o4 + 1];
2158
- prevColor[o4 + 2] = curColor[o4 + 2];
2159
- prevColor[o4 + 3] = curColor[o4 + 3];
2160
- }
2161
- }
2162
- const step = delta * scalars[scalar + ParticleStore.SPEED_MODIFIER];
2163
- const vx = velocities[offset];
2164
- const vy = velocities[offset + 1];
2165
- const vz = velocities[offset + 2];
2166
- const x = positions[offset] + vx * step;
2167
- const y = positions[offset + 1] + vy * step;
2168
- const z = positions[offset + 2] + vz * step;
2169
- positions[offset] = x;
2170
- positions[offset + 1] = y;
2171
- positions[offset + 2] = z;
2172
- if (track) {
2173
- minX = Math.min(minX, x);
2174
- maxX = Math.max(maxX, x);
2175
- minY = Math.min(minY, y);
2176
- maxY = Math.max(maxY, y);
2177
- minZ = Math.min(minZ, z);
2178
- maxZ = Math.max(maxZ, z);
2179
- maxSize = Math.max(
2180
- maxSize,
2181
- Math.max(curSize[offset], Math.max(curSize[offset + 1], curSize[offset + 2]))
2182
- );
2183
- }
2184
- // Speed is tracked every step: it bounds how far the particles
2185
- // can have moved since the last measurement.
2186
- maxSpeedSq = Math.max(maxSpeedSq, vx * vx + vy * vy + vz * vz);
2187
- age += delta;
2188
- scalars[scalar + ParticleStore.AGE] = age;
2189
- if (age >= scalars[scalar + ParticleStore.LIFE]) {
2190
- const last = liveParticleCount - 1;
2191
- const particle = particles[i];
2192
- const survivor = particles[last] as StoreBackedParticle;
2193
- particles[i] = survivor;
2194
- particles[last] = particle;
2195
- if (i !== last) {
2196
- store.swapRows(i, last);
2197
- survivor.setStoreIndex(i);
2198
- (particle as StoreBackedParticle).setStoreIndex(last);
2199
- }
2200
- liveParticleCount--;
2201
- i--;
2202
- if (notifyDeaths) {
2203
- this.fire({type: 'particleDied', particleSystem: this, particle: particle});
2204
- }
2205
- }
2206
- }
2207
- this.particleNum = liveParticleCount;
2208
- this.storeBounds(liveParticleCount, measure, minX, minY, minZ, maxX, maxY, maxZ, maxSize, maxSpeedSq);
2209
- this.notifyFinished();
2210
- return;
2211
- }
2212
-
2213
- // Trails: the newborn collapse, then motion, then the trail history,
2214
- // then removal, each over the particle objects the history lives on.
2215
- if (particleCount > 0) {
2216
- const prevSize = columns.previousSize;
2217
- const curSize = columns.size;
2218
- const prevColor = columns.previousColor;
2219
- const curColor = columns.color;
2220
- for (let i = 0, o3 = 0, o4 = 0, s = 0; i < particleCount; i++, o3 += 3, o4 += 4, s += stride) {
2221
- if (scalars[s + ParticleStore.AGE] !== 0) {
2222
- continue;
2223
- }
2224
- if (snapshotSize) {
2225
- prevSize[o3] = curSize[o3];
2226
- prevSize[o3 + 1] = curSize[o3 + 1];
2227
- prevSize[o3 + 2] = curSize[o3 + 2];
2228
- }
2229
- if (snapshotColor) {
2230
- prevColor[o4] = curColor[o4];
2231
- prevColor[o4 + 1] = curColor[o4 + 1];
2232
- prevColor[o4 + 2] = curColor[o4 + 2];
2233
- prevColor[o4 + 3] = curColor[o4 + 3];
2234
- }
2235
- }
2236
- }
2237
-
2238
- const followLocalOrigin = (this.rendererEmitterSettings as TrailSettings).followLocalOrigin;
2239
- if (followLocalOrigin) {
2240
- const emitterMatrix = this.emitter.matrixWorld;
2241
- for (let i = 0; i < particleCount; i++) {
2242
- const particle = particles[i];
2243
- if ((particle as TrailParticle).localPosition) {
2244
- particle.position.copy((particle as TrailParticle).localPosition!);
2245
- particle.position.applyMatrix4(particle.parentMatrix ?? emitterMatrix);
2246
- } else {
2247
- particle.position.addScaledVector(particle.velocity, delta * particle.speedModifier);
2248
- }
2249
- particle.age += delta;
2250
- }
1774
+ liveParticleCount = integrateSprites(
1775
+ delta,
1776
+ particles,
1777
+ store,
1778
+ liveParticleCount,
1779
+ snapshotSize,
1780
+ snapshotColor,
1781
+ this.bounds,
1782
+ this,
1783
+ onDied
1784
+ );
2251
1785
  } else {
2252
- const positions = store.position;
2253
- const velocities = store.velocity;
2254
- for (let i = 0, offset = 0, scalar = 0; i < particleCount; i++, offset += 3, scalar += stride) {
2255
- const step = delta * scalars[scalar + ParticleStore.SPEED_MODIFIER];
2256
- positions[offset] += velocities[offset] * step;
2257
- positions[offset + 1] += velocities[offset + 1] * step;
2258
- positions[offset + 2] += velocities[offset + 2] * step;
2259
- scalars[scalar + ParticleStore.AGE] += delta;
2260
- }
2261
- }
2262
-
2263
- {
1786
+ // Trails: the newborn collapse, then motion, then the trail history,
1787
+ // then removal, each over the particle objects the history lives on.
2264
1788
  const trailSettings = this.rendererEmitterSettings as TrailSettings;
2265
- const dieWithParticle = trailSettings.dieWithParticle;
2266
- // A world-space system records world positions already.
2267
- const recordWorldSpace = trailSettings.recordWorldSpace && !this.worldSpace;
2268
- const emitterMatrix = this.emitter.matrixWorld;
2269
- // A swept trail records its side as the emitter holds it, in the
2270
- // space its positions are recorded in.
2271
- const sideAxis = trailSettings.sideAxis;
2272
- const sidesInWorld = this.worldSpace || Boolean(trailSettings.recordWorldSpace);
2273
- for (let i = 0; i < particleCount; i++) {
2274
- const trail = particles[i] as TrailParticle;
2275
- // Emptying the history lets the removal pass below take the
2276
- // particle this same step.
2277
- if (dieWithParticle && trail.age >= trail.life) {
2278
- trail.resetHistory();
2279
- continue;
2280
- }
2281
- trail.update();
2282
- // update() wrote a sample only while the particle is alive.
2283
- if (recordWorldSpace && trail.age <= trail.life) {
2284
- const offset = ((trail.historyHead + trail.historyCapacity - 1) % trail.historyCapacity) * 3;
2285
- const positions = trail.historyPositions;
2286
- tempV.set(positions[offset], positions[offset + 1], positions[offset + 2]);
2287
- tempV.applyMatrix4(trail.parentMatrix ?? emitterMatrix);
2288
- positions[offset] = tempV.x;
2289
- positions[offset + 1] = tempV.y;
2290
- positions[offset + 2] = tempV.z;
2291
- }
2292
- if (sideAxis && trail.age <= trail.life) {
2293
- trail.ensureSideHistory();
2294
- const offset = ((trail.historyHead + trail.historyCapacity - 1) % trail.historyCapacity) * 3;
2295
- const sides = trail.historySides;
2296
- let x = sideAxis.x;
2297
- let y = sideAxis.y;
2298
- let z = sideAxis.z;
2299
- if (sidesInWorld) {
2300
- const m = (trail.parentMatrix ?? emitterMatrix).elements;
2301
- const tx = m[0] * x + m[4] * y + m[8] * z;
2302
- const ty = m[1] * x + m[5] * y + m[9] * z;
2303
- z = m[2] * x + m[6] * y + m[10] * z;
2304
- x = tx;
2305
- y = ty;
2306
- }
2307
- sides[offset] = x;
2308
- sides[offset + 1] = y;
2309
- sides[offset + 2] = z;
2310
- }
2311
- }
1789
+ collapseTrailNewborns(store, particleCount, snapshotSize, snapshotColor);
1790
+ moveTrails(delta, particles, store, particleCount, Boolean(trailSettings.followLocalOrigin), emitterMatrix);
1791
+ recordTrailHistory(particles, particleCount, trailSettings, this.worldSpace, emitterMatrix);
1792
+ liveParticleCount = removeDeadTrails(particles, store, liveParticleCount, onDied);
2312
1793
  }
2313
-
2314
- for (let i = 0; i < liveParticleCount; i++) {
2315
- const particle = particles[i] as TrailParticle;
2316
- const scalar = i * stride;
2317
- const dead = scalars[scalar + ParticleStore.AGE] >= scalars[scalar + ParticleStore.LIFE];
2318
- if (dead && particle.historyCount === 0) {
2319
- const last = liveParticleCount - 1;
2320
- const survivor = particles[last] as StoreBackedParticle;
2321
- particles[i] = survivor;
2322
- particles[last] = particle;
2323
- if (i !== last) {
2324
- store.swapRows(i, last);
2325
- survivor.setStoreIndex(i);
2326
- (particle as StoreBackedParticle).setStoreIndex(last);
2327
- }
2328
- liveParticleCount--;
2329
- i--;
2330
- if (notifyDeaths) {
2331
- this.fire({type: 'particleDied', particleSystem: this, particle: particle});
2332
- }
2333
- }
2334
- }
2335
- this.particleNum = liveParticleCount;
1794
+ // A particleDied listener may have disposed the system, which drops every particle.
1795
+ this.particleNum = this.destroyed ? 0 : liveParticleCount;
2336
1796
  this.notifyFinished();
2337
1797
  }
2338
1798
 
1799
+ /** Re-points every particle at the store, for {@link snapshotPreviousState}. */
1800
+ private readonly rebindParticles = (): void => this.spawner.rebind();
1801
+
1802
+ /** Fires `particleDied` for a particle the step retired. */
1803
+ private readonly fireParticleDied = (particle: Particle): void => {
1804
+ this.events.fire({type: 'particleDied', particleSystem: this, particle});
1805
+ };
1806
+
2339
1807
  private notifyFinished(): void {
2340
1808
  if (!this.isFinished()) {
2341
1809
  return;
2342
1810
  }
2343
1811
  if (!this.finishedEventFired) {
2344
1812
  this.finishedEventFired = true;
2345
- this.fire({type: 'finished', particleSystem: this});
1813
+ this.events.fire({type: 'finished', particleSystem: this});
2346
1814
  }
2347
1815
  if (this.autoDestroy) {
2348
- this.markForDestroy = true;
2349
1816
  this.dispose();
2350
1817
  }
2351
1818
  }
2352
1819
 
2353
- /** Waves a burst fires per loop: its `cycle`, and never fewer than one. */
2354
- private static burstWaveCount(burst: BurstParameters): number {
2355
- return burst.cycle >= 1 ? Math.floor(burst.cycle) : 1;
2356
- }
2357
-
2358
- /** Seconds between a burst's waves; a missing or negative interval fires them all at once. */
2359
- private static burstInterval(burst: BurstParameters): number {
2360
- return burst.interval > 0 ? burst.interval : 0;
2361
- }
2362
-
2363
1820
  /**
2364
- * Fires the burst waves that are due by `emissionState.time`.
2365
- *
2366
- * Wave `w` of a burst is due at `burst.time + w * burst.interval`, and the
2367
- * burst fires `cycle` of them, each rolling `probability` on its own — the
2368
- * way Unity repeats a burst. Bursts start in time order, as they always
2369
- * have; their repeats are tracked per burst so a burst that is still
2370
- * repeating never holds back a later one. Repeats falling past the
2371
- * system's duration are dropped (a looping system starts them over on its
2372
- * next loop), matching Unity, which never emits a burst cycle beyond the
2373
- * end of the duration.
2374
- */
2375
- private emitBursts(emissionState: EmissionState, emitterMatrix: Matrix4): void {
2376
- const bursts = this.emissionBursts;
2377
- const burstCount = bursts.length;
2378
- const time = emissionState.time;
2379
- const waves = (emissionState.burstWaves ??= []);
2380
- while (emissionState.burstIndex < burstCount && bursts[emissionState.burstIndex].time <= time) {
2381
- waves[emissionState.burstIndex] = 0;
2382
- emissionState.burstIndex++;
2383
- }
2384
- const started = emissionState.burstIndex;
2385
- for (let b = 0; b < started; b++) {
2386
- const burst = bursts[b];
2387
- const cycles = ParticleSystem.burstWaveCount(burst);
2388
- let wave = waves[b] ?? 0;
2389
- if (wave >= cycles) {
2390
- continue;
2391
- }
2392
- const interval = ParticleSystem.burstInterval(burst);
2393
- while (wave < cycles) {
2394
- const waveTime = burst.time + wave * interval;
2395
- if (wave > 0 && waveTime > this.duration) {
2396
- wave = cycles;
2397
- break;
2398
- }
2399
- if (waveTime > time) {
2400
- break;
2401
- }
2402
- emissionState.burstWaveIndex = wave;
2403
- this.fireBurstWave(burst, emissionState, emitterMatrix);
2404
- wave++;
2405
- }
2406
- waves[b] = wave;
2407
- }
2408
- }
2409
-
2410
- /** Spawns one wave of `burst`, if its probability roll passes. */
2411
- private fireBurstWave(burst: BurstParameters, emissionState: EmissionState, emitterMatrix: Matrix4): void {
2412
- if (random() >= burst.probability) {
2413
- return;
2414
- }
2415
- const rawCount = burst.count.genValue(this.memory, this.time);
2416
- const factor = this.emissionFactor();
2417
- // Rounded at random, so a fraction is not always lost: three at 1.5 is four or five, on average 4.5.
2418
- const count = Math.abs(factor - 1) < 0.001 ? rawCount : Math.floor(rawCount * factor + random());
2419
- emissionState.isBursting = true;
2420
- emissionState.burstParticleCount = count;
2421
- this.spawn(count, emissionState, emitterMatrix);
2422
- emissionState.isBursting = false;
2423
- }
2424
-
2425
- /** True while a burst that has started still has a wave due within the duration. */
2426
- private hasPendingBurstWaves(state: EmissionState): boolean {
2427
- const waves = state.burstWaves;
2428
- if (waves === undefined) {
2429
- return false;
2430
- }
2431
- const bursts = this.emissionBursts;
2432
- for (let b = 0; b < state.burstIndex && b < bursts.length; b++) {
2433
- const burst = bursts[b];
2434
- const wave = waves[b] ?? 0;
2435
- if (
2436
- wave < ParticleSystem.burstWaveCount(burst) &&
2437
- (wave === 0 || burst.time + wave * ParticleSystem.burstInterval(burst) <= this.duration)
2438
- ) {
2439
- return true;
2440
- }
2441
- }
2442
- return false;
1821
+ * Spawns what this step's emission calls for. Called by the simulation step, and by
1822
+ * `EmitSubParticleSystem` on a sub emitter target with the parent particle's state and matrix.
1823
+ */
1824
+ emit(delta: number, emissionState: EmissionState, emitterMatrix: Matrix4): void {
1825
+ this.emission.emit(delta, emissionState, emitterMatrix);
2443
1826
  }
2444
1827
 
2445
1828
  /**
2446
- * Spawns `count` particles owed to emission over distance, each where the
2447
- * emitter crossed its metre mark on the way from `from` to where
2448
- * `emitterMatrix` has it now, rather than all of them at the end of the
2449
- * path: a fast emitter then leaves a continuous trail instead of one clump
2450
- * per step.
2451
- *
2452
- * `carried` is how far the emitter had already gone past its last mark
2453
- * before this step, so the marks keep their spacing across steps. Only the
2454
- * position is interpolated; the emitter's current rotation and scale are
2455
- * used for every particle.
2456
- */
2457
- private spawnAlongPath(
2458
- count: number,
2459
- emissionState: EmissionState,
2460
- emitterMatrix: Matrix4,
2461
- from: Vector3,
2462
- carried: number,
2463
- spacing: number,
2464
- length: number
2465
- ): void {
2466
- const matrix = this.pathMatrix.copy(emitterMatrix);
2467
- const target = emitterMatrix.elements;
2468
- const elements = matrix.elements;
2469
- const tx = target[12];
2470
- const ty = target[13];
2471
- const tz = target[14];
2472
- for (let k = 1; k <= count; k++) {
2473
- const travelled = k * spacing - carried;
2474
- const f = travelled <= 0 ? 0 : travelled >= length ? 1 : travelled / length;
2475
- elements[12] = from.x + (tx - from.x) * f;
2476
- elements[13] = from.y + (ty - from.y) * f;
2477
- elements[14] = from.z + (tz - from.z) * f;
2478
- this.spawn(1, emissionState, matrix);
2479
- }
2480
- }
2481
-
2482
- emit(delta: number, emissionState: EmissionState, emitterMatrix: Matrix4) {
2483
- // Ended before this step (endEmit, or a past step's end of duration):
2484
- // no more bursts either. A looping system would otherwise keep firing
2485
- // them every loop after endEmit and never finish. Only the system's
2486
- // own emission: a sub emitter's bursts belong to its parent particles,
2487
- // which still go off as they die after the effect was told to end.
2488
- const endedBefore = this.emitEnded && emissionState === this.emissionState;
2489
- if (emissionState.time > this.duration) {
2490
- if (this.looping) {
2491
- emissionState.time -= this.duration;
2492
- emissionState.burstIndex = 0;
2493
- if (emissionState.burstWaves !== undefined) {
2494
- emissionState.burstWaves.length = 0;
2495
- }
2496
- for (let i = 0; i < this.behaviors.length; i++) {
2497
- this.behaviors[i].reset();
2498
- }
2499
- } else {
2500
- if (!this.emitEnded && !this.onlyUsedByOther) {
2501
- this.endEmit();
2502
- }
2503
- }
2504
- }
2505
-
2506
- // Only spawns in world space consume the normal matrix, and computing it
2507
- // inverts a 4x4 — skip it for local-space systems.
2508
- if (this.worldSpace) {
2509
- this.normalMatrix.getNormalMatrix(emitterMatrix);
2510
- }
2511
- const emissionBurstCount = this.emissionBursts.length;
2512
- const qualityFactor = this.emissionFactor();
2513
-
2514
- if (emissionBurstCount > 0 && !endedBefore) {
2515
- this.emitBursts(emissionState, emitterMatrix);
2516
- }
2517
-
2518
- // Particles owed to the distance travelled this step, spawned along it.
2519
- let pathSpawn = 0;
2520
- let pathSpacing = 0;
2521
- let pathCarried = 0;
2522
- let pathLength = 0;
2523
- const previousWorldPos = emissionState.previousWorldPos;
2524
- if (!this.emitEnded) {
2525
- const timeRatio = emissionState.time / this.duration;
2526
- emissionState.waitEmiting += delta * this.emissionOverTime.genValue(this.memory, timeRatio) * qualityFactor;
2527
-
2528
- if (previousWorldPos !== undefined) {
2529
- this.temp.set(emitterMatrix.elements[12], emitterMatrix.elements[13], emitterMatrix.elements[14]);
2530
- pathLength = previousWorldPos.distanceTo(this.temp);
2531
- pathCarried = emissionState.travelDistance;
2532
- emissionState.travelDistance += pathLength;
2533
- const emitPerMeter = this.emissionOverDistance.genValue(this.memory, timeRatio) * qualityFactor;
2534
- if (emissionState.travelDistance * emitPerMeter > 0) {
2535
- const count = Math.floor(emissionState.travelDistance * emitPerMeter);
2536
- emissionState.travelDistance -= count / emitPerMeter;
2537
- // A world-space particle stays where it is born, so it is born
2538
- // where the emitter passed its mark. A local-space one moves
2539
- // with the emitter and has no path to be spread along.
2540
- if (this.worldSpace && pathLength > 0) {
2541
- pathSpawn = count;
2542
- pathSpacing = 1 / emitPerMeter;
2543
- } else {
2544
- emissionState.waitEmiting += count;
2545
- }
2546
- }
2547
- }
2548
- }
2549
-
2550
- // Accrue before spawning so replacement particles can appear on the same
2551
- // frame an old one dies (rate * lifetime ≈ 1 would otherwise flicker).
2552
- const totalSpawn = Math.floor(emissionState.waitEmiting);
2553
- if (totalSpawn > 0) {
2554
- this.spawn(totalSpawn, emissionState, emitterMatrix);
2555
- emissionState.waitEmiting -= totalSpawn;
2556
- }
2557
- if (pathSpawn > 0) {
2558
- this.spawnAlongPath(
2559
- pathSpawn,
2560
- emissionState,
2561
- emitterMatrix,
2562
- previousWorldPos!,
2563
- pathCarried,
2564
- pathSpacing,
2565
- pathLength
2566
- );
2567
- }
2568
-
2569
- if (emissionState.previousWorldPos === undefined) emissionState.previousWorldPos = new Vector3();
2570
- emissionState.previousWorldPos.set(
2571
- emitterMatrix.elements[12],
2572
- emitterMatrix.elements[13],
2573
- emitterMatrix.elements[14]
2574
- );
2575
- emissionState.time += delta;
2576
- }
2577
-
1829
+ * Writes this system's settings. Its geometry, material and textures go into `metaData` as
1830
+ * entries the result names by uuid; give the same `metaData` to every system of a document so
1831
+ * shared ones are written once. The material entry holds live Babylon objects, so to write a
1832
+ * file use `serializeEffect`, which turns them into plain data. Runtime-only state
1833
+ * (`tint`, `beamEnd`, the playing time) is not written.
1834
+ */
2578
1835
  toJSON(metaData?: BabylonMetaData | string, _options: SerializationOptions = {}): ParticleSystemJSONParameters {
2579
- const isRootObject = metaData === undefined || typeof metaData === 'string';
2580
- const meta: BabylonMetaData = isRootObject
2581
- ? {
2582
- geometries: {},
2583
- materials: {},
2584
- textures: {},
2585
- images: {},
2586
- shapes: {},
2587
- skeletons: {},
2588
- animations: {},
2589
- nodes: {},
2590
- }
2591
- : metaData;
2592
-
2593
- const geometryUUID = this.ensureGeometryMeta(meta);
2594
- const materialUUID = this.ensureMaterialMeta(meta);
2595
- const rendererEmitterSettingsJSON = this.toRendererSettingsJSON();
2596
-
2597
- return {
2598
- version: '3.0',
2599
- autoDestroy: this.autoDestroy,
2600
- looping: this.looping,
2601
- prewarm: this.prewarm,
2602
- duration: this.duration,
2603
- startDelay: this.startDelay.toJSON(),
2604
- shape: this.emitterShape.toJSON(),
2605
- startLife: this.startLife.toJSON(),
2606
- startSpeed: this.startSpeed.toJSON(),
2607
- startRotation: this.startRotation.toJSON(),
2608
- startSize: this.startSize.toJSON(),
2609
- startColor: this.startColor.toJSON(),
2610
- emissionOverTime: this.emissionOverTime.toJSON(),
2611
- emissionOverDistance: this.emissionOverDistance.toJSON(),
2612
- emissionBursts: this.emissionBursts.map((burst) => ({
2613
- time: burst.time,
2614
- count: burst.count.toJSON(),
2615
- probability: burst.probability,
2616
- interval: burst.interval,
2617
- cycle: burst.cycle,
2618
- })),
2619
- ...(this.maxParticles !== undefined ? {maxParticles: this.maxParticles} : {}),
2620
- onlyUsedByOther: this.onlyUsedByOther,
2621
- instancingGeometry: geometryUUID,
2622
- renderMode: this.renderMode,
2623
- renderOrder: this.renderOrder,
2624
- ...(this.renderingGroupId !== 0 ? {renderingGroupId: this.renderingGroupId} : {}),
2625
- ...(this.applyFog ? {} : {applyFog: false}),
2626
- ...(this.playbackSpeed !== 1 ? {playbackSpeed: this.playbackSpeed} : {}),
2627
- ...(this.cullingMode !== 'always' ? {cullingMode: this.cullingMode} : {}),
2628
- ...(this.scalingMode !== 'shape' ? {scalingMode: this.scalingMode} : {}),
2629
- ...(this.seedValue !== undefined ? {seed: this.seedValue} : {}),
2630
- ...(this.sortMode !== 'none' ? {sortMode: this.sortMode} : {}),
2631
- ...(this.alignToDirection ? {alignToDirection: true} : {}),
2632
- ...(this.minParticleSize > 0 ? {minParticleSize: this.minParticleSize} : {}),
2633
- ...(this.maxParticleSize !== Infinity ? {maxParticleSize: this.maxParticleSize} : {}),
2634
- rendererEmitterSettings: rendererEmitterSettingsJSON,
2635
- material: materialUUID,
2636
- layers: this.rendererSettings.layerMask,
2637
- startTileIndex: this.startTileIndex.toJSON(),
2638
- uTileCount: this.rendererSettings.uTileCount,
2639
- vTileCount: this.rendererSettings.vTileCount,
2640
- blendTiles: this.rendererSettings.blendTiles,
2641
- softParticles: this.rendererSettings.softParticles,
2642
- softFarFade: this.rendererSettings.softFarFade,
2643
- softNearFade: this.rendererSettings.softNearFade,
2644
- blending: this.rendererSettings.materialBlendMode,
2645
- transparent: this.rendererSettings.materialTransparent,
2646
- depthTest: this.rendererSettings.materialDepthTest,
2647
- depthWrite: this.rendererSettings.materialDepthWrite,
2648
- alphaTest: this.rendererSettings.materialAlphaTest,
2649
- behaviors: this.behaviors.map((b) => b.toJSON()),
2650
- worldSpace: this.worldSpace,
2651
- ...(this.pivot ? {pivot: [this.pivot.x, this.pivot.y, this.pivot.z]} : {}),
2652
- ...(this.flip ? {flip: [this.flip.x, this.flip.y]} : {}),
2653
- };
1836
+ return particleSystemToJSON(this, metaData);
2654
1837
  }
2655
1838
 
1839
+ /**
1840
+ * Builds a system from {@link toJSON} output, resolving its geometry, material and texture
1841
+ * uuids through `meta`. A setting the file leaves out takes the constructor's default; an
1842
+ * unknown shape, behavior or curve type warns and takes its default. Sub emitter targets stay
1843
+ * uuids until `QuarksLoader` links them.
1844
+ */
2656
1845
  static fromJSON(
2657
1846
  json: ParticleSystemJSONParameters,
2658
1847
  meta: BabylonMetaData,
2659
1848
  dependencies: {[uuid: string]: Behavior} = {},
2660
1849
  scene?: Scene
2661
1850
  ): ParticleSystem {
2662
- const shape = EmitterFromJSON(json.shape, meta as any);
2663
- let rendererEmitterSettings: RendererEmitterSettings;
2664
- if (json.renderMode === RenderMode.Trail) {
2665
- const trailSettings = json.rendererEmitterSettings as TrailSettings;
2666
- rendererEmitterSettings = {
2667
- startLength:
2668
- trailSettings?.startLength !== undefined
2669
- ? ValueGeneratorFromJSON(trailSettings.startLength)
2670
- : new ConstantValue(30),
2671
- followLocalOrigin: trailSettings?.followLocalOrigin ?? false,
2672
- ...trailFlags(trailSettings),
2673
- ...trailSideAxisFromJSON((trailSettings as {sideAxis?: unknown} | undefined)?.sideAxis),
2674
- };
2675
- } else if (json.renderMode === RenderMode.StretchedBillBoard) {
2676
- rendererEmitterSettings = {...(json.rendererEmitterSettings ?? {})};
2677
- if (json.speedFactor !== undefined) {
2678
- (rendererEmitterSettings as StretchedBillBoardSettings).speedFactor = json.speedFactor;
2679
- }
2680
- } else {
2681
- rendererEmitterSettings = {};
2682
- }
2683
-
2684
- const materialMeta = json.material ? meta.materials?.[json.material] : undefined;
2685
- const materialTextureRef = materialMeta?.texture ?? json.texture;
2686
- const texture =
2687
- typeof materialTextureRef === 'string'
2688
- ? (meta.textures?.[materialTextureRef] ?? null)
2689
- : (materialTextureRef ?? null);
2690
- // Texture effects reference textures by uuid in a serialised meta, or hold them once a loader resolved them.
2691
- const resolveTexture = (ref: unknown): BaseTexture | null =>
2692
- typeof ref === 'string' ? (meta.textures?.[ref] ?? null) : ((ref as BaseTexture | null) ?? null);
2693
- const erosionMeta = materialMeta?.erosion;
2694
- const gradientMeta = materialMeta?.gradientMap;
2695
- const distortionMeta = materialMeta?.uvDistortion;
2696
- const resolvedGeometryEntry =
2697
- typeof json.instancingGeometry === 'string'
2698
- ? meta.geometries?.[json.instancingGeometry]
2699
- : json.instancingGeometry;
2700
- const resolvedGeometry = ParticleSystem.resolveGeometryData(resolvedGeometryEntry);
2701
-
2702
- const ps = new ParticleSystem({
2703
- scene,
2704
- autoDestroy: json.autoDestroy,
2705
- looping: json.looping,
2706
- prewarm: json.prewarm,
2707
- duration: json.duration,
2708
- startDelay: json.startDelay ? ValueGeneratorFromJSON(json.startDelay) : undefined,
2709
- shape,
2710
- startLife: ValueGeneratorFromJSON(json.startLife),
2711
- startSpeed: ValueGeneratorFromJSON(json.startSpeed),
2712
- startRotation: GeneratorFromJSON(json.startRotation) as
2713
- | RotationGenerator
2714
- | ValueGenerator
2715
- | FunctionValueGenerator,
2716
- startSize: GeneratorFromJSON(json.startSize) as Vector3Generator | ValueGenerator | FunctionValueGenerator,
2717
- startColor: ColorGeneratorFromJSON(json.startColor) as ColorGenerator,
2718
- emissionOverTime: ValueGeneratorFromJSON(json.emissionOverTime),
2719
- emissionOverDistance: ValueGeneratorFromJSON(json.emissionOverDistance),
2720
- emissionBursts: json.emissionBursts?.map((burst: any) => ({
2721
- time: burst.time,
2722
- count:
2723
- typeof burst.count === 'number'
2724
- ? new ConstantValue(burst.count)
2725
- : ValueGeneratorFromJSON(burst.count),
2726
- probability: burst.probability ?? 1,
2727
- interval: burst.interval ?? 0.1,
2728
- cycle: burst.cycle ?? burst.cycleCount ?? 1,
2729
- })),
2730
- maxParticles: typeof json.maxParticles === 'number' ? json.maxParticles : undefined,
2731
- onlyUsedByOther: json.onlyUsedByOther,
2732
- instancingGeometry: resolvedGeometry.positions,
2733
- instancingIndices: resolvedGeometry.indices,
2734
- instancingUVs: resolvedGeometry.uvs,
2735
- instancingUVs2: resolvedGeometry.uvs2,
2736
- instancingNormals: resolvedGeometry.normals,
2737
- renderMode: json.renderMode,
2738
- rendererEmitterSettings,
2739
- renderOrder: json.renderOrder,
2740
- renderingGroupId: typeof json.renderingGroupId === 'number' ? json.renderingGroupId : undefined,
2741
- applyFog: json.applyFog !== false,
2742
- playbackSpeed: typeof json.playbackSpeed === 'number' ? json.playbackSpeed : undefined,
2743
- cullingMode: ParticleSystem.validCullingMode(json.cullingMode),
2744
- scalingMode: json.scalingMode === 'hierarchy' ? 'hierarchy' : 'shape',
2745
- seed: typeof json.seed === 'number' ? json.seed : undefined,
2746
- sortMode: ParticleSystem.validSortMode(json.sortMode),
2747
- alignToDirection: json.alignToDirection === true,
2748
- minParticleSize: typeof json.minParticleSize === 'number' ? json.minParticleSize : undefined,
2749
- maxParticleSize: typeof json.maxParticleSize === 'number' ? json.maxParticleSize : undefined,
2750
- texture,
2751
- material: materialMeta?.sourceMaterial ?? materialMeta,
2752
- blendMode: materialMeta?.alphaMode ?? json.blending ?? Constants.ALPHA_ADD,
2753
- transparent: materialMeta?.transparent ?? json.transparent ?? true,
2754
- depthTest: materialMeta?.depthTest ?? json.depthTest ?? true,
2755
- depthWrite: materialMeta?.depthWrite ?? json.depthWrite ?? false,
2756
- alphaTest: materialMeta?.alphaTest ?? json.alphaTest ?? 0,
2757
- startTileIndex:
2758
- typeof json.startTileIndex === 'number'
2759
- ? new ConstantValue(json.startTileIndex)
2760
- : (ValueGeneratorFromJSON(json.startTileIndex) as ValueGenerator),
2761
- uTileCount: json.uTileCount,
2762
- vTileCount: json.vTileCount,
2763
- blendTiles: json.blendTiles,
2764
- softParticles: json.softParticles,
2765
- softFarFade: json.softFarFade,
2766
- softNearFade: json.softNearFade,
2767
- behaviors: [],
2768
- worldSpace: json.worldSpace,
2769
- layerMask: json.layers,
2770
- pivot: json.pivot ? new Vector3(json.pivot[0] ?? 0, json.pivot[1] ?? 0, json.pivot[2] ?? 0) : undefined,
2771
- flip: json.flip ? new Vector2(json.flip[0] ?? 0, json.flip[1] ?? 0) : undefined,
2772
- lighting: materialMeta?.lighting,
2773
- rim: materialMeta?.rim,
2774
- mapUV: materialMeta?.mapUV,
2775
- gradientMap:
2776
- gradientMeta && typeof gradientMeta === 'object'
2777
- ? {...gradientMeta, ramp: resolveTexture(gradientMeta.ramp)}
2778
- : undefined,
2779
- erosion:
2780
- erosionMeta && typeof erosionMeta === 'object'
2781
- ? {...erosionMeta, mask: resolveTexture(erosionMeta.mask)}
2782
- : undefined,
2783
- uvDistortion:
2784
- distortionMeta && typeof distortionMeta === 'object'
2785
- ? {...distortionMeta, map: resolveTexture(distortionMeta.map)}
2786
- : undefined,
2787
- });
1851
+ const ps = new ParticleSystem(particleSystemParametersFromJSON(json, meta, scene));
2788
1852
  ps.behaviors = (json.behaviors ?? [])
2789
1853
  .map((behaviorJson) => {
2790
1854
  const behavior = BehaviorFromJSON(behaviorJson, ps);
2791
1855
  if (behavior && behavior.type === 'EmitSubParticleSystem') {
2792
- dependencies[(behaviorJson as any).subParticleSystem] = behavior;
1856
+ dependencies[(behaviorJson as {subParticleSystem: string}).subParticleSystem] = behavior;
2793
1857
  }
2794
1858
  return behavior;
2795
1859
  })
@@ -2797,253 +1861,59 @@ export class ParticleSystem implements IParticleSystem {
2797
1861
  return ps;
2798
1862
  }
2799
1863
 
2800
- private static nextSerializationId(prefix: string): string {
2801
- ParticleSystem.serializationCounter += 1;
2802
- return `${prefix}_${ParticleSystem.serializationCounter}`;
2803
- }
2804
-
2805
- /** Normals facing +Z, one array per geometry so systems sharing it still batch together. */
2806
- private static createFallbackNormals(positions: Float32Array): Float32Array {
2807
- let normals = fallbackNormals.get(positions);
2808
- if (!normals) {
2809
- normals = new Float32Array(positions.length);
2810
- for (let i = 2; i < normals.length; i += 3) {
2811
- normals[i] = 1;
2812
- }
2813
- fallbackNormals.set(positions, normals);
2814
- }
2815
- return normals;
2816
- }
2817
-
2818
- private toRendererSettingsJSON(): RendererEmitterSettings {
2819
- if (this.renderMode === RenderMode.Trail) {
2820
- return {
2821
- startLength: (this.rendererEmitterSettings as TrailSettings).startLength.toJSON(),
2822
- followLocalOrigin: (this.rendererEmitterSettings as TrailSettings).followLocalOrigin,
2823
- ...trailFlags(this.rendererEmitterSettings as TrailSettings),
2824
- ...(trailSideAxisJSON(this.rendererEmitterSettings as TrailSettings) as {sideAxis?: any}),
2825
- };
2826
- }
2827
- if (this.renderMode === RenderMode.StretchedBillBoard) {
2828
- return {
2829
- speedFactor: (this.rendererEmitterSettings as StretchedBillBoardSettings).speedFactor,
2830
- lengthFactor: (this.rendererEmitterSettings as StretchedBillBoardSettings).lengthFactor,
2831
- };
2832
- }
2833
- return {};
2834
- }
2835
-
2836
- private ensureGeometryMeta(meta: BabylonMetaData): string {
2837
- const geometryUUID = ParticleSystem.nextSerializationId('quarks_geometry');
2838
- meta.geometries[geometryUUID] = {
2839
- uuid: geometryUUID,
2840
- type: 'QuarksGeometry',
2841
- positions: Array.from(this.rendererSettings.instancingGeometry),
2842
- indices: Array.from(this.rendererSettings.instancingIndices),
2843
- uvs: this.rendererSettings.instancingUVs ? Array.from(this.rendererSettings.instancingUVs) : undefined,
2844
- ...(this.rendererSettings.instancingUVs2 ? {uvs2: Array.from(this.rendererSettings.instancingUVs2)} : {}),
2845
- normals: this.rendererSettings.instancingNormals
2846
- ? Array.from(this.rendererSettings.instancingNormals)
2847
- : undefined,
2848
- };
2849
- return geometryUUID;
2850
- }
2851
-
2852
- private ensureMaterialMeta(meta: BabylonMetaData): string {
2853
- const texture = this.rendererSettings.texture;
2854
- let textureUUID: string | undefined;
2855
- if (texture) {
2856
- textureUUID = ParticleSystem.nextSerializationId('quarks_texture');
2857
- meta.textures[textureUUID] = texture;
2858
- }
2859
-
2860
- const reflectionAtlas = this.rendererSettings.reflectionAtlas;
2861
- let reflectionAtlasUUID: string | undefined;
2862
- if (reflectionAtlas instanceof Texture) {
2863
- reflectionAtlasUUID = ParticleSystem.nextSerializationId('quarks_texture');
2864
- meta.textures[reflectionAtlasUUID] = reflectionAtlas;
2865
- }
2866
-
2867
- const textureRef = (value: BaseTexture | null | undefined): string | undefined => {
2868
- if (!(value instanceof Texture)) {
2869
- return undefined;
2870
- }
2871
- const uuid = ParticleSystem.nextSerializationId('quarks_texture');
2872
- meta.textures[uuid] = value;
2873
- return uuid;
2874
- };
2875
- const erosion = this.erosion;
2876
- const distortion = this.uvDistortion;
2877
- const gradientMap = this.gradientMap;
2878
- const optional = <T>(key: string, value: T | undefined) => (value !== undefined ? {[key]: value} : {});
2879
-
2880
- const materialUUID = ParticleSystem.nextSerializationId('quarks_material');
2881
- meta.materials[materialUUID] = {
2882
- uuid: materialUUID,
2883
- type: 'QuarksMaterial',
2884
- transparent: this.rendererSettings.materialTransparent,
2885
- alphaMode: this.rendererSettings.materialBlendMode,
2886
- depthTest: this.rendererSettings.materialDepthTest,
2887
- depthWrite: this.rendererSettings.materialDepthWrite,
2888
- alphaTest: this.rendererSettings.materialAlphaTest,
2889
- texture: textureUUID,
2890
- reflectionAtlas: reflectionAtlasUUID,
2891
- reflectionLevel: this.rendererSettings.reflectionLevel,
2892
- // Written only when not the default, so existing effects serialise as before.
2893
- ...(this.lighting !== 'fixed' ? {lighting: this.lighting} : {}),
2894
- ...(this.rim ? {rim: {...this.rim, color: [...this.rim.color]}} : {}),
2895
- ...optional('mapUV', this.mapUV ? textureUVToJSON(this.mapUV) : undefined),
2896
- ...(gradientMap
2897
- ? {
2898
- gradientMap: {
2899
- ramp: textureRef(gradientMap.ramp),
2900
- ...(gradientMap.channel !== 'r' ? {channel: gradientMap.channel} : {}),
2901
- },
2902
- }
2903
- : {}),
2904
- ...(erosion
2905
- ? {
2906
- erosion: {
2907
- mask: textureRef(erosion.mask),
2908
- ...optional('maskUV', textureUVToJSON(erosion.maskUV)),
2909
- softness: erosion.softness,
2910
- edgeWidth: erosion.edgeWidth,
2911
- edgeColor: [...erosion.edgeColor],
2912
- },
2913
- }
2914
- : {}),
2915
- ...(distortion
2916
- ? {
2917
- uvDistortion: {
2918
- map: textureRef(distortion.map),
2919
- ...optional('mapUV', textureUVToJSON(distortion.mapUV, UV_DISTORTION_DEFAULT_UV)),
2920
- strength: distortion.strength,
2921
- },
2922
- }
2923
- : {}),
2924
- sourceMaterial: this.materialRef ?? undefined,
2925
- };
2926
- return materialUUID;
2927
- }
2928
-
2929
- private static resolveGeometryData(entry: any): {
2930
- positions: Float32Array;
2931
- indices: Uint32Array | Uint16Array;
2932
- uvs?: Float32Array;
2933
- uvs2?: Float32Array;
2934
- normals?: Float32Array;
2935
- } {
2936
- if (entry?.positions) {
2937
- const positions =
2938
- entry.positions instanceof Float32Array ? entry.positions : new Float32Array(entry.positions);
2939
- const rawIndices =
2940
- entry.indices instanceof Uint16Array || entry.indices instanceof Uint32Array
2941
- ? entry.indices
2942
- : entry.indices
2943
- ? new Uint32Array(entry.indices)
2944
- : undefined;
2945
- return {
2946
- positions,
2947
- indices: ensureTriangleIndices(positions, rawIndices),
2948
- uvs: entry.uvs
2949
- ? entry.uvs instanceof Float32Array
2950
- ? entry.uvs
2951
- : new Float32Array(entry.uvs)
2952
- : undefined,
2953
- uvs2: entry.uvs2
2954
- ? entry.uvs2 instanceof Float32Array
2955
- ? entry.uvs2
2956
- : new Float32Array(entry.uvs2)
2957
- : undefined,
2958
- normals: entry.normals
2959
- ? entry.normals instanceof Float32Array
2960
- ? entry.normals
2961
- : new Float32Array(entry.normals)
2962
- : undefined,
2963
- };
2964
- }
2965
-
2966
- const data = entry?.data ?? entry;
2967
- if (data?.attributes?.position?.array) {
2968
- const positions = new Float32Array(data.attributes.position.array);
2969
- let indices: Uint32Array | Uint16Array | undefined;
2970
- if (data.index?.array) {
2971
- const indexType = data.index.type === 'Uint16Array' ? Uint16Array : Uint32Array;
2972
- indices = new indexType(data.index.array);
2973
- }
2974
- return {
2975
- positions,
2976
- indices: ensureTriangleIndices(positions, indices),
2977
- uvs: data.attributes.uv?.array ? new Float32Array(data.attributes.uv.array) : undefined,
2978
- // three.js names the second set `uv1` (r151 on), `uv2` before.
2979
- uvs2: (data.attributes.uv1 ?? data.attributes.uv2)?.array
2980
- ? new Float32Array((data.attributes.uv1 ?? data.attributes.uv2).array)
2981
- : undefined,
2982
- normals: data.attributes.normal?.array ? new Float32Array(data.attributes.normal.array) : undefined,
2983
- };
2984
- }
2985
-
2986
- return {
2987
- positions: DEFAULT_POSITIONS,
2988
- indices: DEFAULT_INDICES,
2989
- uvs: DEFAULT_UVS,
2990
- };
2991
- }
2992
-
2993
- addBehavior(behavior: Behavior) {
1864
+ /** Appends a behavior to {@link behaviors}; it runs on existing particles from the next step. */
1865
+ addBehavior(behavior: Behavior): void {
2994
1866
  this.behaviors.push(behavior);
2995
1867
  // A sub emitter added here changes which system has to update first.
2996
1868
  this._renderer?._invalidateUpdateOrder();
2997
1869
  }
2998
1870
 
1871
+ /**
1872
+ * The settings the renderer batches this system on. Prefer the system's setters, which move it
1873
+ * to a matching batch; after changing a field here directly, set {@link neededToUpdateRender}.
1874
+ */
2999
1875
  getRendererSettings(): VFXBatchSettings {
3000
1876
  return this.rendererSettings;
3001
1877
  }
3002
1878
 
1879
+ /**
1880
+ * Calls `callback` on each `event`: `'emitEnd'` when emission ends, `'finished'` when the last
1881
+ * particle of an ended system dies, `'particleDied'` per particle (with `event.particle`), and
1882
+ * `'destroy'` on dispose.
1883
+ */
3003
1884
  addEventListener(event: ParticleSystemEventType, callback: (event: ParticleSystemEvent) => void): void {
3004
- if (!this.listeners[event]) this.listeners[event] = [];
3005
- this.listeners[event].push(callback);
1885
+ this.events.add(event, callback);
3006
1886
  }
3007
1887
 
1888
+ /** Removes every callback registered for `event`. */
3008
1889
  removeAllEventListeners(event: ParticleSystemEventType): void {
3009
- if (this.listeners[event]) this.listeners[event] = [];
1890
+ this.events.removeAll(event);
3010
1891
  }
3011
1892
 
1893
+ /** Removes one callback added with {@link addEventListener}. */
3012
1894
  removeEventListener(event: ParticleSystemEventType, callback: (event: ParticleSystemEvent) => void): void {
3013
- if (this.listeners[event]) {
3014
- const index = this.listeners[event].indexOf(callback);
3015
- if (index !== -1) this.listeners[event].splice(index, 1);
3016
- }
3017
- }
3018
-
3019
- /** True when at least one callback is registered for the event type. */
3020
- private hasListeners(event: ParticleSystemEventType): boolean {
3021
- const callbacks = this.listeners[event];
3022
- return callbacks !== undefined && callbacks.length > 0;
3023
- }
3024
-
3025
- private fire(event: ParticleSystemEvent) {
3026
- const callbacks = this.listeners[event.type];
3027
- if (callbacks === undefined) {
3028
- return;
3029
- }
3030
- for (let i = 0; i < callbacks.length; i++) {
3031
- callbacks[i](event);
3032
- }
1895
+ this.events.remove(event, callback);
3033
1896
  }
3034
1897
 
1898
+ /**
1899
+ * A new system with the same settings, behaviors and generators (copied, so the two roll
1900
+ * independently), on a new emitter node with no parent. It shares the texture, material and
1901
+ * geometry. It is not added to a renderer: call `renderer.addSystem` on it, or use
1902
+ * `QuarksUtil.instantiate` to copy a whole effect tree.
1903
+ */
3035
1904
  clone(): ParticleSystem {
3036
1905
  const newBehaviors: Array<Behavior> = this.behaviors.map((b) => b.clone());
3037
1906
  let rendererEmitterSettings: RendererEmitterSettings;
3038
1907
 
3039
1908
  if (this.renderMode === RenderMode.Trail) {
1909
+ const settings = this.rendererEmitterSettings as TrailSettings;
3040
1910
  rendererEmitterSettings = {
3041
- startLength: (this.rendererEmitterSettings as TrailSettings).startLength.clone(),
3042
- followLocalOrigin: (this.rendererEmitterSettings as TrailSettings).followLocalOrigin,
3043
- ...trailFlags(this.rendererEmitterSettings as TrailSettings),
3044
- ...((this.rendererEmitterSettings as TrailSettings).sideAxis
3045
- ? {sideAxis: (this.rendererEmitterSettings as TrailSettings).sideAxis!.clone()}
3046
- : {}),
1911
+ startLength: settings.startLength.clone(),
1912
+ followLocalOrigin: Boolean(settings.followLocalOrigin),
1913
+ ...trailFlags(settings),
1914
+ ...trailCurve(settings),
1915
+ ...trailSampling(settings),
1916
+ ...(settings.sideAxis ? {sideAxis: settings.sideAxis.clone()} : {}),
3047
1917
  };
3048
1918
  } else if (this.renderMode === RenderMode.StretchedBillBoard) {
3049
1919
  rendererEmitterSettings = {
@@ -3055,59 +1925,17 @@ export class ParticleSystem implements IParticleSystem {
3055
1925
  }
3056
1926
 
3057
1927
  const copy = new ParticleSystem({
3058
- autoDestroy: this.autoDestroy,
3059
- looping: this.looping,
3060
- prewarm: this.prewarm,
3061
- duration: this.duration,
3062
- startDelay: this.startDelay.clone(),
1928
+ ...settingsForClone(this),
3063
1929
  shape: this.emitterShape.clone(),
3064
- startLife: this.startLife.clone(),
3065
- startSpeed: this.startSpeed.clone(),
3066
- startRotation: this.startRotation.clone(),
3067
- startSize: this.startSize.clone(),
3068
- startColor: this.startColor.clone(),
3069
- emissionOverTime: this.emissionOverTime.clone(),
3070
- emissionOverDistance: this.emissionOverDistance.clone(),
3071
- emissionBursts: this.emissionBursts.map((b) => ({...b})),
3072
- maxParticles: this.maxParticles,
3073
- onlyUsedByOther: this.onlyUsedByOther,
3074
1930
  instancingGeometry: this.rendererSettings.instancingGeometry,
3075
1931
  instancingIndices: this.rendererSettings.instancingIndices,
3076
1932
  instancingUVs: this.rendererSettings.instancingUVs,
3077
1933
  instancingUVs2: this.rendererSettings.instancingUVs2,
3078
1934
  instancingNormals: this.rendererSettings.instancingNormals,
3079
- renderMode: this.renderMode,
3080
- renderOrder: this.renderOrder,
3081
- renderingGroupId: this.renderingGroupId,
3082
- applyFog: this.applyFog,
3083
- playbackSpeed: this.playbackSpeed,
3084
- cullingMode: this.cullingMode,
3085
- scalingMode: this.scalingMode,
3086
- seed: this.seedValue,
3087
- sortMode: this.sortMode,
3088
- alignToDirection: this.alignToDirection,
3089
- minParticleSize: this.minParticleSize,
3090
- maxParticleSize: this.maxParticleSize,
3091
1935
  rendererEmitterSettings,
3092
1936
  material: this.materialRef,
3093
1937
  texture: this.rendererSettings.texture,
3094
- startTileIndex: this.startTileIndex,
3095
- uTileCount: this.rendererSettings.uTileCount,
3096
- vTileCount: this.rendererSettings.vTileCount,
3097
- blendTiles: this.rendererSettings.blendTiles,
3098
- softParticles: this.rendererSettings.softParticles,
3099
- softFarFade: this.rendererSettings.softFarFade,
3100
- softNearFade: this.rendererSettings.softNearFade,
3101
1938
  behaviors: newBehaviors,
3102
- worldSpace: this.worldSpace,
3103
- blendMode: this.rendererSettings.materialBlendMode,
3104
- transparent: this.rendererSettings.materialTransparent,
3105
- depthTest: this.rendererSettings.materialDepthTest,
3106
- depthWrite: this.rendererSettings.materialDepthWrite,
3107
- alphaTest: this.rendererSettings.materialAlphaTest,
3108
- layerMask: this.rendererSettings.layerMask,
3109
- pivot: this.pivot,
3110
- flip: this.flip,
3111
1939
  lighting: this.lighting,
3112
1940
  rim: this.rim,
3113
1941
  mapUV: this.mapUV,
@@ -3126,6 +1954,26 @@ export class ParticleSystem implements IParticleSystem {
3126
1954
  if (this.tint) {
3127
1955
  copy.tint = this.tint.clone();
3128
1956
  }
1957
+ copy.emissionMultiplier = this.emissionMultiplier;
1958
+ const beamEnd = this.beamEnd;
1959
+ if (beamEnd) {
1960
+ // A node the beam follows stays the same node. A point is copied by value: a Babylon
1961
+ // Vector3 keeps x/y/z behind getters, which a spread would drop.
1962
+ copy.beamEnd =
1963
+ typeof (beamEnd as {getAbsolutePosition?: unknown}).getAbsolutePosition === 'function'
1964
+ ? beamEnd
1965
+ : {x: (beamEnd as Vector3).x, y: (beamEnd as Vector3).y, z: (beamEnd as Vector3).z};
1966
+ }
3129
1967
  return copy;
3130
1968
  }
3131
1969
  }
1970
+
1971
+ /** Whether every index points at one of `vertexCount` vertices. */
1972
+ function indicesFit(indices: Uint32Array | Uint16Array, vertexCount: number): boolean {
1973
+ for (let i = 0; i < indices.length; i++) {
1974
+ if (indices[i] >= vertexCount) {
1975
+ return false;
1976
+ }
1977
+ }
1978
+ return true;
1979
+ }