babylon.quarks 0.26.0 → 0.28.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 (83) hide show
  1. package/CHANGELOG.md +1194 -8
  2. package/README.md +13 -9
  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 +10108 -0
  10. package/dist/types/index.d.ts +5794 -998
  11. package/package.json +18 -7
  12. package/src/BatchedRenderer.ts +274 -156
  13. package/src/EffectPool.ts +94 -12
  14. package/src/MeshSurfaceEmitter.ts +50 -14
  15. package/src/ParticleEmitter.ts +75 -17
  16. package/src/ParticleSystem.ts +981 -2066
  17. package/src/QuarksLoader.ts +666 -194
  18. package/src/QuarksPrefab.ts +367 -73
  19. package/src/QuarksSerializer.ts +512 -0
  20. package/src/QuarksUtil.ts +77 -7
  21. package/src/SpriteBatch.ts +127 -252
  22. package/src/TrailBatch.ts +545 -348
  23. package/src/VFXBatch.ts +238 -596
  24. package/src/batchKey.ts +239 -0
  25. package/src/batchSettings.ts +546 -0
  26. package/src/effectDocumentJSON.ts +261 -0
  27. package/src/effectResources.ts +76 -0
  28. package/src/envAtlas.ts +126 -18
  29. package/src/geometryUtil.ts +1 -1
  30. package/src/index.ts +69 -35
  31. package/src/nodeId.ts +36 -0
  32. package/src/shaderProgram.ts +419 -0
  33. package/src/shaderVariants.ts +245 -0
  34. package/src/shaders/chunks/blend.ts +31 -0
  35. package/src/shaders/chunks/fog.ts +12 -5
  36. package/src/shaders/chunks/sheet.ts +111 -0
  37. package/src/shaders/chunks/soft.ts +85 -0
  38. package/src/shaders/chunks/surface.ts +12 -2
  39. package/src/shaders/local_particle_physics_vert.glsl.ts +14 -28
  40. package/src/shaders/local_particle_physics_vert.wgsl.ts +16 -29
  41. package/src/shaders/particle_frag.glsl.ts +5 -22
  42. package/src/shaders/particle_frag.wgsl.ts +5 -23
  43. package/src/shaders/particle_physics_frag.glsl.ts +16 -26
  44. package/src/shaders/particle_physics_frag.wgsl.ts +14 -26
  45. package/src/shaders/particle_vert.glsl.ts +5 -27
  46. package/src/shaders/particle_vert.wgsl.ts +5 -26
  47. package/src/shaders/stretched_bb_particle_vert.glsl.ts +7 -33
  48. package/src/shaders/stretched_bb_particle_vert.wgsl.ts +7 -33
  49. package/src/shaders/trail_frag.glsl.ts +13 -0
  50. package/src/shaders/trail_frag.wgsl.ts +13 -0
  51. package/src/shaders/trail_vert.glsl.ts +45 -36
  52. package/src/shaders/trail_vert.wgsl.ts +46 -40
  53. package/src/softDepth.ts +136 -0
  54. package/src/system/BehaviorPlan.ts +85 -0
  55. package/src/system/BoundsTracker.ts +181 -0
  56. package/src/system/EmissionController.ts +358 -0
  57. package/src/system/EventDispatcher.ts +60 -0
  58. package/src/system/ParticleSpawner.ts +274 -0
  59. package/src/system/SimulationClock.ts +130 -0
  60. package/src/system/geometry.ts +104 -0
  61. package/src/system/integrate.ts +428 -0
  62. package/src/system/materialResolve.ts +165 -0
  63. package/src/system/normalize.ts +100 -0
  64. package/src/system/serialization.ts +422 -0
  65. package/src/system/settingsSchema.ts +339 -0
  66. package/src/system/types.ts +449 -0
  67. package/src/textureSource.ts +30 -0
  68. package/src/trailSettings.ts +54 -0
  69. package/src/BatchedParticleRenderer.ts +0 -3
  70. package/src/materials/ParticleMaterials.ts +0 -23
  71. package/src/materials/index.ts +0 -1
  72. package/src/shaders/chunks/index.ts +0 -1
  73. package/src/shaders/chunks/register-shader-chunks.ts +0 -32
  74. package/src/shaders/chunks/soft_fragment.glsl.ts +0 -13
  75. package/src/shaders/chunks/soft_pars_fragment.glsl.ts +0 -20
  76. package/src/shaders/chunks/soft_pars_vertex.glsl.ts +0 -6
  77. package/src/shaders/chunks/soft_vertex.glsl.ts +0 -6
  78. package/src/shaders/chunks/tile_fragment.glsl.ts +0 -9
  79. package/src/shaders/chunks/tile_pars_fragment.glsl.ts +0 -9
  80. package/src/shaders/chunks/tile_pars_vertex.glsl.ts +0 -28
  81. package/src/shaders/chunks/tile_vertex.glsl.ts +0 -18
  82. package/src/shaders/index.ts +0 -9
  83. package/src/shaders/local_particle_vert.glsl.ts +0 -50
@@ -0,0 +1,130 @@
1
+ /** Length of one simulation step, in seconds: the simulation runs at a fixed 60 Hz. */
2
+ export const SIMULATION_STEP = 1 / 60;
3
+
4
+ /** Most steps one host frame runs at playback speed 1; a faster speed raises the cap in proportion. */
5
+ export const MAX_STEPS_PER_FRAME = 8;
6
+
7
+ /** Most host time one frame simulates, in seconds, so a stall or a tab in the background is not replayed. */
8
+ export const MAX_FRAME_SECONDS = 0.1;
9
+
10
+ /** Most off-screen time a `'pauseAndCatchUp'` system replays on coming back into view, in seconds. */
11
+ export const MAX_CATCH_UP_SECONDS = 2;
12
+
13
+ /**
14
+ * Math.round() breaks an exact tie (the accumulator sitting at precisely half a step) by rounding
15
+ * up. At exactly 120 Hz that tie is not a corner case, it is every frame, and rounding it up would
16
+ * run a step on every one of them instead of every other one. Nudging the tie down costs nothing on
17
+ * a real display (real frame times essentially never land on the tie exactly) and keeps the
18
+ * well-tested "half a step is no step yet" behaviour.
19
+ */
20
+ const ROUND_TIE_BIAS = 1e-9;
21
+
22
+ /**
23
+ * The fixed-step clock of a particle system: turns the host's uneven frames into whole 60 Hz
24
+ * steps, and keeps the start delay and the time a culled system still owes. Pure numbers, so the
25
+ * frame-rate rules can be tested without a scene.
26
+ *
27
+ * The steps a frame runs are chosen by *rounding* the accumulated time to the nearest step, not
28
+ * flooring it. Flooring always leaves the accumulator positive, and when the real frame rate sits
29
+ * very close to 60 Hz (as most displays do) that leftover creeps toward a full step for many frames
30
+ * before tipping over into a frame that runs two steps at once and resets it to near zero: a
31
+ * visible lurch, every particle at once, with a perfectly steady frame rate. Rounding lets the
32
+ * leftover go negative instead, which keeps it centred near zero. The renderer closes the gap by
33
+ * drawing each particle advanced along its velocity by {@link residual}.
34
+ */
35
+ export class SimulationClock {
36
+ /** Simulated time not yet covered by a whole step, in seconds; negative when a step ran early. */
37
+ private accumulator = 0;
38
+ /** Off-screen time a `'pauseAndCatchUp'` system has not simulated yet, in seconds. */
39
+ private pendingCatchUp = 0;
40
+ /** Start delay still to wait out, in seconds. */
41
+ private delayLeft = 0;
42
+
43
+ /** Back to the start of a play: nothing accumulated, nothing owed. The start delay is set apart. */
44
+ reset(): void {
45
+ this.accumulator = 0;
46
+ this.pendingCatchUp = 0;
47
+ }
48
+
49
+ /** Sets the start delay to wait out before the first step; a negative or NaN delay is none. */
50
+ setStartDelay(seconds: number): void {
51
+ this.delayLeft = seconds > 0 ? seconds : 0;
52
+ }
53
+
54
+ /** Whether a start delay is still being waited out. */
55
+ get delaying(): boolean {
56
+ return this.delayLeft > 0;
57
+ }
58
+
59
+ /**
60
+ * The simulated seconds a host frame of `hostDelta` seconds stands for at `playbackSpeed`. The
61
+ * host time is clamped to {@link MAX_FRAME_SECONDS} first, so a fast playback speed advances
62
+ * its full share of a normal frame (speed 10 at 60 fps simulates 10 seconds per second) while a
63
+ * stalled frame is still not replayed. A negative or NaN frame is no time at all.
64
+ */
65
+ static frameSeconds(hostDelta: number, playbackSpeed: number): number {
66
+ if (!(hostDelta > 0)) return 0;
67
+ const clamped = hostDelta > MAX_FRAME_SECONDS ? MAX_FRAME_SECONDS : hostDelta;
68
+ return clamped * playbackSpeed;
69
+ }
70
+
71
+ /**
72
+ * Takes `delta` seconds off the start delay and returns what is left over for the simulation:
73
+ * 0 while the delay runs, the remainder of the frame that ends it (at most one frame's worth, so
74
+ * a long frame does not burst the first steps), and `delta` itself once there is no delay.
75
+ */
76
+ consumeStartDelay(delta: number): number {
77
+ if (this.delayLeft <= 0) {
78
+ return delta;
79
+ }
80
+ this.delayLeft -= delta;
81
+ if (this.delayLeft >= 0) {
82
+ return 0;
83
+ }
84
+ const left = Math.min(-this.delayLeft, MAX_FRAME_SECONDS);
85
+ this.delayLeft = 0;
86
+ return left;
87
+ }
88
+
89
+ /**
90
+ * Adds `delta` simulated seconds and returns how many whole steps to run for them, at most
91
+ * {@link MAX_STEPS_PER_FRAME} scaled by `playbackSpeed` above 1. The steps' time is taken off
92
+ * the accumulator whether or not the caller runs them all, so a frame that cannot afford its
93
+ * steps drops their time rather than carrying it into the next frame.
94
+ */
95
+ accrue(delta: number, playbackSpeed = 1): number {
96
+ this.accumulator += delta;
97
+ const cap = playbackSpeed > 1 ? Math.ceil(MAX_STEPS_PER_FRAME * playbackSpeed) : MAX_STEPS_PER_FRAME;
98
+ const steps = Math.min(cap, Math.max(0, Math.round(this.accumulator / SIMULATION_STEP - ROUND_TIE_BIAS)));
99
+ this.accumulator -= steps * SIMULATION_STEP;
100
+ return steps;
101
+ }
102
+
103
+ /**
104
+ * Time the display is ahead of the simulation (positive) or behind it (negative, the simulation
105
+ * ran a hair early), in seconds. Its magnitude never exceeds one step, even when the per-frame
106
+ * step cap left more than that accumulated.
107
+ */
108
+ get residual(): number {
109
+ if (this.accumulator > SIMULATION_STEP) return SIMULATION_STEP;
110
+ if (this.accumulator < -SIMULATION_STEP) return -SIMULATION_STEP;
111
+ return this.accumulator;
112
+ }
113
+
114
+ /** Remembers `delta` simulated seconds a culled system missed, up to {@link MAX_CATCH_UP_SECONDS}. */
115
+ deferCatchUp(delta: number): void {
116
+ this.pendingCatchUp = Math.min(MAX_CATCH_UP_SECONDS, this.pendingCatchUp + delta);
117
+ }
118
+
119
+ /** Whether a culled system still owes steps. */
120
+ get owesCatchUp(): boolean {
121
+ return this.pendingCatchUp > 0;
122
+ }
123
+
124
+ /** The whole steps a system owes from its time off screen, and forgets them. */
125
+ takeCatchUpSteps(): number {
126
+ const steps = Math.floor(this.pendingCatchUp / SIMULATION_STEP);
127
+ this.pendingCatchUp = 0;
128
+ return steps;
129
+ }
130
+ }
@@ -0,0 +1,104 @@
1
+ import type {BufferGeometryDataJSON, GeometryArrays, GeometryEntry} from '../effectDocumentJSON';
2
+ import {ensureTriangleIndices} from '../geometryUtil';
3
+
4
+ /** Positions of the unit quad billboards are drawn with, and the default mesh. */
5
+ export 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]);
6
+ /** UVs of the default quad. */
7
+ export const DEFAULT_UVS = new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]);
8
+ /** Indices of the default quad's two triangles. */
9
+ export const DEFAULT_INDICES = new Uint32Array([0, 1, 2, 0, 2, 3]);
10
+
11
+ /** Zero UVs sized to a position buffer — missing/mismatched uv attrs → GL_INVALID_OPERATION on iOS. */
12
+ const fallbackUVs = new WeakMap<Float32Array, Float32Array>();
13
+ const fallbackNormals = new WeakMap<Float32Array, Float32Array>();
14
+
15
+ /**
16
+ * Zero UVs for a geometry given without any, one array per geometry: batches
17
+ * are keyed by the UV array too, so systems sharing a geometry share it.
18
+ */
19
+ export function fallbackUVsForPositions(positions: Float32Array): Float32Array {
20
+ let uvs = fallbackUVs.get(positions);
21
+ if (uvs === undefined || uvs.length !== (positions.length / 3) * 2) {
22
+ uvs = new Float32Array((positions.length / 3) * 2);
23
+ fallbackUVs.set(positions, uvs);
24
+ }
25
+ return uvs;
26
+ }
27
+
28
+ /** Whether `uvs` are the ones {@link fallbackUVsForPositions} made for `positions`. */
29
+ export function isFallbackUVs(uvs: Float32Array | undefined, positions: Float32Array): boolean {
30
+ return uvs !== undefined && uvs === fallbackUVs.get(positions);
31
+ }
32
+
33
+ /** Normals facing +Z, one array per geometry so systems sharing it still batch together. */
34
+ export function fallbackNormalsForPositions(positions: Float32Array): Float32Array {
35
+ let normals = fallbackNormals.get(positions);
36
+ if (!normals) {
37
+ normals = new Float32Array(positions.length);
38
+ for (let i = 2; i < normals.length; i += 3) {
39
+ normals[i] = 1;
40
+ }
41
+ fallbackNormals.set(positions, normals);
42
+ }
43
+ return normals;
44
+ }
45
+
46
+ /** Whether `normals` are the ones {@link fallbackNormalsForPositions} made for `positions`. */
47
+ export function isFallbackNormals(normals: Float32Array | undefined, positions: Float32Array): boolean {
48
+ return normals !== undefined && normals === fallbackNormals.get(positions);
49
+ }
50
+
51
+ /** `values` as a Float32Array: itself when it is one, else a copy. */
52
+ function floats(values: number[] | Float32Array | undefined): Float32Array | undefined {
53
+ return values ? (values instanceof Float32Array ? values : new Float32Array(values)) : undefined;
54
+ }
55
+
56
+ /**
57
+ * Typed arrays from a geometry entry of an effect document: quarks' own `{positions, indices, uvs,
58
+ * uvs2, normals}`, or a three.js `BufferGeometry` JSON or its `data`. Anything else gives the
59
+ * default quad.
60
+ */
61
+ export function resolveGeometryData(entry: GeometryEntry | BufferGeometryDataJSON | undefined): GeometryArrays {
62
+ if (entry && 'positions' in entry && entry.positions) {
63
+ const positions = floats(entry.positions)!;
64
+ const rawIndices =
65
+ entry.indices instanceof Uint16Array || entry.indices instanceof Uint32Array
66
+ ? entry.indices
67
+ : entry.indices
68
+ ? new Uint32Array(entry.indices)
69
+ : undefined;
70
+ return {
71
+ positions,
72
+ indices: ensureTriangleIndices(positions, rawIndices),
73
+ uvs: floats(entry.uvs),
74
+ uvs2: floats(entry.uvs2),
75
+ normals: floats(entry.normals),
76
+ };
77
+ }
78
+
79
+ const data = entry && 'data' in entry ? entry.data : entry;
80
+ const attributes = data && 'attributes' in data ? data.attributes : undefined;
81
+ const position = attributes?.position?.array;
82
+ if (data && 'attributes' in data && attributes && position) {
83
+ const positions = new Float32Array(position);
84
+ let indices: Uint32Array | Uint16Array | undefined;
85
+ if (data.index?.array) {
86
+ const indexType = data.index.type === 'Uint16Array' ? Uint16Array : Uint32Array;
87
+ indices = new indexType(data.index.array);
88
+ }
89
+ return {
90
+ positions,
91
+ indices: ensureTriangleIndices(positions, indices),
92
+ uvs: floats(attributes.uv?.array),
93
+ // three.js names the second set `uv1` (r151 on), `uv2` before.
94
+ uvs2: floats((attributes.uv1 ?? attributes.uv2)?.array),
95
+ normals: floats(attributes.normal?.array),
96
+ };
97
+ }
98
+
99
+ return {
100
+ positions: DEFAULT_POSITIONS,
101
+ indices: DEFAULT_INDICES,
102
+ uvs: DEFAULT_UVS,
103
+ };
104
+ }
@@ -0,0 +1,428 @@
1
+ import {
2
+ Behavior,
3
+ FusionStep,
4
+ Matrix4,
5
+ Particle,
6
+ ParticleStore,
7
+ RendererEmitterSettings,
8
+ TrailParticle,
9
+ TrailSettings,
10
+ Vector3,
11
+ } from 'quarks.core';
12
+ import type {VFXBatchSettings} from '../BatchedRenderer';
13
+ import {trailLifetime, trailMinVertexDistance} from '../trailSettings';
14
+ import type {BoundsTracker} from './BoundsTracker';
15
+ import {SIMULATION_STEP} from './SimulationClock';
16
+
17
+ // The passes of one simulation step after emission: the snapshot the renderer extrapolates
18
+ // from, the behaviors, then the motion, ageing and removal of the sprite path (one pass over
19
+ // the store's columns) or of the trail path (over the particle objects their history lives on).
20
+ // Plain functions over the store and the particle array, so the hot loops stay monomorphic.
21
+
22
+ /** Particle backed by a {@link ParticleStore} row, as a system always creates. */
23
+ type StoreBackedParticle = Particle & {storeIndex: number; setStoreIndex(index: number): void};
24
+
25
+ /** Called with each particle that died this step, when the system has `particleDied` listeners. */
26
+ export type DeathListener = (particle: Particle) => void;
27
+
28
+ /** Settings the bounds measurement reads, see {@link BoundsTracker.store}. */
29
+ export interface BoundsSettings {
30
+ /** The render settings, for the particle size the bounds are padded by. */
31
+ rendererSettings: VFXBatchSettings;
32
+ /** The render mode's settings, for a stretched billboard's length. */
33
+ rendererEmitterSettings: RendererEmitterSettings;
34
+ /** Whether the positions are in world space already. */
35
+ worldSpace: boolean;
36
+ }
37
+
38
+ const tempV = new Vector3();
39
+
40
+ /**
41
+ * Remembers where everything starts this step, so the renderer can carry the motion the step
42
+ * produces on into the part of the frame that comes after it. Size and colour are only
43
+ * snapshotted when some behavior can change them; otherwise the columns stay what spawn wrote
44
+ * and the renderer reads them as they are. Only stretched billboards draw from velocity, and only
45
+ * they pay for keeping the previous one; `rebind` is called when keeping it first allocates its
46
+ * column, since every particle then has to be re-pointed at the store.
47
+ */
48
+ export function snapshotPreviousState(
49
+ store: ParticleStore,
50
+ particleCount: number,
51
+ snapshotSize: boolean,
52
+ snapshotColor: boolean,
53
+ keepVelocity: boolean,
54
+ rebind: () => void
55
+ ): void {
56
+ const vector3Rows = particleCount * 3;
57
+ store.useSizeSnapshot(snapshotSize);
58
+ store.useColorSnapshot(snapshotColor);
59
+ store.previousPosition.set(store.position.subarray(0, vector3Rows));
60
+ if (snapshotSize) {
61
+ store.previousSize.set(store.size.subarray(0, vector3Rows));
62
+ }
63
+ if (keepVelocity) {
64
+ if (store.keepPreviousVelocity()) {
65
+ rebind();
66
+ }
67
+ store.previousVelocity.set(store.velocity.subarray(0, vector3Rows));
68
+ }
69
+ if (snapshotColor) {
70
+ store.previousColor.set(store.color.subarray(0, particleCount * 4));
71
+ }
72
+ }
73
+
74
+ /** Runs the behaviors' frame updates and then each step of the plan over the live particles. */
75
+ export function runBehaviorSteps(
76
+ steps: ReadonlyArray<FusionStep>,
77
+ behaviors: Behavior[],
78
+ particles: Particle[],
79
+ particleCount: number,
80
+ delta: number,
81
+ store: ParticleStore
82
+ ): void {
83
+ for (let s = 0; s < steps.length; s++) {
84
+ const step = steps[s];
85
+ const stepBehaviors = step.behaviors;
86
+ for (let j = 0; j < stepBehaviors.length; j++) {
87
+ stepBehaviors[j].frameUpdate(delta);
88
+ }
89
+ // Nothing to update: skip, matching what the per-particle loop did.
90
+ // A behavior may set up state before its loop that is only valid
91
+ // once it has seen a particle.
92
+ if (particleCount === 0) {
93
+ continue;
94
+ }
95
+ if (step.run !== undefined) {
96
+ step.run(behaviors, particles, particleCount, delta, store);
97
+ continue;
98
+ }
99
+ const behavior = stepBehaviors[0];
100
+ if (behavior.updateAll !== undefined) {
101
+ behavior.updateAll(particles, particleCount, delta);
102
+ continue;
103
+ }
104
+ for (let i = 0; i < particleCount; i++) {
105
+ const particle = particles[i];
106
+ // Inlined `died` — this runs once per behavior per particle.
107
+ if (particle.age < particle.life) {
108
+ behavior.update(particle, delta);
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Moves a dead particle's slot and row to the end of the live range, so slot k always owns
116
+ * row k and the live particles stay one contiguous range the renderer copies as is. Returns
117
+ * the particle that died.
118
+ */
119
+ function retire(particles: Particle[], store: ParticleStore, i: number, last: number): Particle {
120
+ const particle = particles[i];
121
+ const survivor = particles[last] as StoreBackedParticle;
122
+ particles[i] = survivor;
123
+ particles[last] = particle;
124
+ if (i !== last) {
125
+ store.swapRows(i, last);
126
+ survivor.setStoreIndex(i);
127
+ (particle as StoreBackedParticle).setStoreIndex(last);
128
+ }
129
+ return particle;
130
+ }
131
+
132
+ /**
133
+ * The sprite path's step after the behaviors, as one pass over the columns that does three
134
+ * things, none of which touches a particle object until a death. Returns the live count.
135
+ *
136
+ * Newborns were snapshotted with their spawn size and colour, before SizeOverLife and
137
+ * ColorOverLife ran. That false jump would be extrapolated by the renderer when the residual is
138
+ * negative (common with the rounded fixed step) and flash the start appearance at the emitter,
139
+ * so the snapshot is collapsed onto the curves' t=0 value. Age is still 0 at that point; the
140
+ * same iteration then ages it.
141
+ *
142
+ * Position is integrated along the velocity.
143
+ *
144
+ * A particle whose age reached its life is swapped with the last live one, rows included. The
145
+ * survivor moved into the slot has not been visited yet, so the slot is processed again.
146
+ *
147
+ * The bounds fall out of the same pass. Measuring every particle on every step costs about as
148
+ * much as the integration itself, so the full measurement runs every few steps; in between,
149
+ * newborns extend the last bounds and the fastest particle seen since pads them for the motion,
150
+ * which keeps them conservative.
151
+ */
152
+ export function integrateSprites(
153
+ delta: number,
154
+ particles: Particle[],
155
+ store: ParticleStore,
156
+ liveParticleCount: number,
157
+ collapseSize: boolean,
158
+ collapseColor: boolean,
159
+ bounds: BoundsTracker,
160
+ settings: BoundsSettings,
161
+ onDied: DeathListener | undefined
162
+ ): number {
163
+ const scalars = store.scalars;
164
+ const stride = ParticleStore.SCALAR_STRIDE;
165
+ const positions = store.position;
166
+ const velocities = store.velocity;
167
+ const prevSize = store.previousSize;
168
+ const curSize = store.size;
169
+ const prevColor = store.previousColor;
170
+ const curColor = store.color;
171
+ const measure = bounds.due;
172
+ const carriedMin = bounds.min;
173
+ const carriedMax = bounds.max;
174
+ let minX = measure ? Infinity : carriedMin.x;
175
+ let minY = measure ? Infinity : carriedMin.y;
176
+ let minZ = measure ? Infinity : carriedMin.z;
177
+ let maxX = measure ? -Infinity : carriedMax.x;
178
+ let maxY = measure ? -Infinity : carriedMax.y;
179
+ let maxZ = measure ? -Infinity : carriedMax.z;
180
+ let maxSize = measure ? 0 : bounds.maxSize;
181
+ let maxSpeedSq = measure ? 0 : bounds.maxSpeedSq;
182
+ for (let i = 0; i < liveParticleCount; i++) {
183
+ const scalar = i * stride;
184
+ let age = scalars[scalar + ParticleStore.AGE];
185
+ const offset = i * 3;
186
+ const track = measure || age === 0;
187
+ if (age === 0) {
188
+ if (collapseSize) {
189
+ prevSize[offset] = curSize[offset];
190
+ prevSize[offset + 1] = curSize[offset + 1];
191
+ prevSize[offset + 2] = curSize[offset + 2];
192
+ }
193
+ if (collapseColor) {
194
+ const o4 = i * 4;
195
+ prevColor[o4] = curColor[o4];
196
+ prevColor[o4 + 1] = curColor[o4 + 1];
197
+ prevColor[o4 + 2] = curColor[o4 + 2];
198
+ prevColor[o4 + 3] = curColor[o4 + 3];
199
+ }
200
+ }
201
+ const step = delta * scalars[scalar + ParticleStore.SPEED_MODIFIER];
202
+ const vx = velocities[offset];
203
+ const vy = velocities[offset + 1];
204
+ const vz = velocities[offset + 2];
205
+ const x = positions[offset] + vx * step;
206
+ const y = positions[offset + 1] + vy * step;
207
+ const z = positions[offset + 2] + vz * step;
208
+ positions[offset] = x;
209
+ positions[offset + 1] = y;
210
+ positions[offset + 2] = z;
211
+ if (track) {
212
+ minX = Math.min(minX, x);
213
+ maxX = Math.max(maxX, x);
214
+ minY = Math.min(minY, y);
215
+ maxY = Math.max(maxY, y);
216
+ minZ = Math.min(minZ, z);
217
+ maxZ = Math.max(maxZ, z);
218
+ maxSize = Math.max(maxSize, Math.max(curSize[offset], Math.max(curSize[offset + 1], curSize[offset + 2])));
219
+ }
220
+ // Speed is tracked every step: it bounds how far the particles
221
+ // can have moved since the last measurement.
222
+ maxSpeedSq = Math.max(maxSpeedSq, vx * vx + vy * vy + vz * vz);
223
+ age += delta;
224
+ scalars[scalar + ParticleStore.AGE] = age;
225
+ if (age >= scalars[scalar + ParticleStore.LIFE]) {
226
+ const particle = retire(particles, store, i, liveParticleCount - 1);
227
+ liveParticleCount--;
228
+ i--;
229
+ if (onDied !== undefined) {
230
+ onDied(particle);
231
+ }
232
+ }
233
+ }
234
+ bounds.store(
235
+ liveParticleCount,
236
+ measure,
237
+ minX,
238
+ minY,
239
+ minZ,
240
+ maxX,
241
+ maxY,
242
+ maxZ,
243
+ maxSize,
244
+ maxSpeedSq,
245
+ settings.rendererSettings,
246
+ settings.rendererEmitterSettings,
247
+ settings.worldSpace,
248
+ SIMULATION_STEP
249
+ );
250
+ return liveParticleCount;
251
+ }
252
+
253
+ /** The newborn collapse of the trail path, see {@link integrateSprites}; trails keep their history on the objects, so it is its own pass. */
254
+ export function collapseTrailNewborns(
255
+ store: ParticleStore,
256
+ particleCount: number,
257
+ snapshotSize: boolean,
258
+ snapshotColor: boolean
259
+ ): void {
260
+ const scalars = store.scalars;
261
+ const stride = ParticleStore.SCALAR_STRIDE;
262
+ const prevSize = store.previousSize;
263
+ const curSize = store.size;
264
+ const prevColor = store.previousColor;
265
+ const curColor = store.color;
266
+ for (let i = 0, o3 = 0, o4 = 0, s = 0; i < particleCount; i++, o3 += 3, o4 += 4, s += stride) {
267
+ if (scalars[s + ParticleStore.AGE] !== 0) {
268
+ continue;
269
+ }
270
+ if (snapshotSize) {
271
+ prevSize[o3] = curSize[o3];
272
+ prevSize[o3 + 1] = curSize[o3 + 1];
273
+ prevSize[o3 + 2] = curSize[o3 + 2];
274
+ }
275
+ if (snapshotColor) {
276
+ prevColor[o4] = curColor[o4];
277
+ prevColor[o4 + 1] = curColor[o4 + 1];
278
+ prevColor[o4 + 2] = curColor[o4 + 2];
279
+ prevColor[o4 + 3] = curColor[o4 + 3];
280
+ }
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Moves and ages the trail particles: a trail that follows its local origin is placed by its
286
+ * emitter (or parent) matrix every step, any other along its velocity.
287
+ */
288
+ export function moveTrails(
289
+ delta: number,
290
+ particles: Particle[],
291
+ store: ParticleStore,
292
+ particleCount: number,
293
+ followLocalOrigin: boolean,
294
+ emitterMatrix: Matrix4
295
+ ): void {
296
+ if (followLocalOrigin) {
297
+ for (let i = 0; i < particleCount; i++) {
298
+ const particle = particles[i];
299
+ if ((particle as TrailParticle).localPosition) {
300
+ particle.position.copy((particle as TrailParticle).localPosition!);
301
+ particle.position.applyMatrix4(particle.parentMatrix ?? emitterMatrix);
302
+ } else {
303
+ particle.position.addScaledVector(particle.velocity, delta * particle.speedModifier);
304
+ }
305
+ particle.age += delta;
306
+ }
307
+ return;
308
+ }
309
+ const scalars = store.scalars;
310
+ const stride = ParticleStore.SCALAR_STRIDE;
311
+ const positions = store.position;
312
+ const velocities = store.velocity;
313
+ for (let i = 0, offset = 0, scalar = 0; i < particleCount; i++, offset += 3, scalar += stride) {
314
+ const step = delta * scalars[scalar + ParticleStore.SPEED_MODIFIER];
315
+ positions[offset] += velocities[offset] * step;
316
+ positions[offset + 1] += velocities[offset + 1] * step;
317
+ positions[offset + 2] += velocities[offset + 2] * step;
318
+ scalars[scalar + ParticleStore.AGE] += delta;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Records this step's sample into each trail's history: its position (in world space when the
324
+ * trail asks for it and the system is not already there) and, for a swept trail, the side axis
325
+ * as the emitter holds it, in the space the positions are recorded in. With a minimum vertex
326
+ * distance the newest sample follows the particle until it is that far from the sample before;
327
+ * samples older than the trail's lifetime are dropped from the tail. A trail that dies with its
328
+ * particle has its history emptied instead, so the removal pass takes it this same step.
329
+ */
330
+ export function recordTrailHistory(
331
+ particles: Particle[],
332
+ particleCount: number,
333
+ trailSettings: TrailSettings,
334
+ worldSpace: boolean,
335
+ emitterMatrix: Matrix4
336
+ ): void {
337
+ const dieWithParticle = trailSettings.dieWithParticle;
338
+ // A world-space system records world positions already.
339
+ const recordWorldSpace = trailSettings.recordWorldSpace && !worldSpace;
340
+ const sideAxis = trailSettings.sideAxis;
341
+ const sidesInWorld = worldSpace || Boolean(trailSettings.recordWorldSpace);
342
+ const minDistance = trailMinVertexDistance(trailSettings);
343
+ const lifetime = trailLifetime(trailSettings);
344
+ for (let i = 0; i < particleCount; i++) {
345
+ const trail = particles[i] as TrailParticle;
346
+ // A particle at the end of its life is dead: it records nothing more and its trail fades.
347
+ const alive = trail.age < trail.life;
348
+ if (dieWithParticle && !alive) {
349
+ trail.resetHistory();
350
+ continue;
351
+ }
352
+ // The newest sample follows the particle until it is the minimum distance from the sample
353
+ // before it; then it is fixed where it is and this step's sample starts a new one.
354
+ let replaceHead = false;
355
+ if (minDistance > 0 && alive && trail.historyCount > 1) {
356
+ const distances = trail.historyDistances;
357
+ const count = trail.historyCount;
358
+ replaceHead =
359
+ distances[trail.getHistoryIndex(count - 1)] - distances[trail.getHistoryIndex(count - 2)] < minDistance;
360
+ }
361
+ trail.update(replaceHead, !recordWorldSpace);
362
+ // update() wrote a sample only while the particle is alive.
363
+ if (recordWorldSpace && alive) {
364
+ const offset = ((trail.historyHead + trail.historyCapacity - 1) % trail.historyCapacity) * 3;
365
+ const positions = trail.historyPositions;
366
+ tempV.set(positions[offset], positions[offset + 1], positions[offset + 2]);
367
+ tempV.applyMatrix4(trail.parentMatrix ?? emitterMatrix);
368
+ positions[offset] = tempV.x;
369
+ positions[offset + 1] = tempV.y;
370
+ positions[offset + 2] = tempV.z;
371
+ trail.measureHeadDistance();
372
+ }
373
+ if (lifetime !== Infinity) {
374
+ const ages = trail.historyAges;
375
+ const age = trail.age;
376
+ while (trail.historyCount > 0 && age - ages[trail.getHistoryIndex(0)] > lifetime) {
377
+ trail.dropOldest();
378
+ }
379
+ }
380
+ if (sideAxis && alive) {
381
+ trail.ensureSideHistory();
382
+ const offset = ((trail.historyHead + trail.historyCapacity - 1) % trail.historyCapacity) * 3;
383
+ const sides = trail.historySides;
384
+ let x = sideAxis.x;
385
+ let y = sideAxis.y;
386
+ let z = sideAxis.z;
387
+ if (sidesInWorld) {
388
+ const m = (trail.parentMatrix ?? emitterMatrix).elements;
389
+ const tx = m[0] * x + m[4] * y + m[8] * z;
390
+ const ty = m[1] * x + m[5] * y + m[9] * z;
391
+ z = m[2] * x + m[6] * y + m[10] * z;
392
+ x = tx;
393
+ y = ty;
394
+ }
395
+ sides[offset] = x;
396
+ sides[offset + 1] = y;
397
+ sides[offset + 2] = z;
398
+ }
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Retires each trail particle that has died and whose ribbon has faded out (an empty history),
404
+ * the same swap as the sprite path. Returns the live count.
405
+ */
406
+ export function removeDeadTrails(
407
+ particles: Particle[],
408
+ store: ParticleStore,
409
+ liveParticleCount: number,
410
+ onDied: DeathListener | undefined
411
+ ): number {
412
+ const scalars = store.scalars;
413
+ const stride = ParticleStore.SCALAR_STRIDE;
414
+ for (let i = 0; i < liveParticleCount; i++) {
415
+ const particle = particles[i] as TrailParticle;
416
+ const scalar = i * stride;
417
+ const dead = scalars[scalar + ParticleStore.AGE] >= scalars[scalar + ParticleStore.LIFE];
418
+ if (dead && particle.historyCount === 0) {
419
+ retire(particles, store, i, liveParticleCount - 1);
420
+ liveParticleCount--;
421
+ i--;
422
+ if (onDied !== undefined) {
423
+ onDied(particle);
424
+ }
425
+ }
426
+ }
427
+ return liveParticleCount;
428
+ }