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
@@ -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
+ }
@@ -0,0 +1,116 @@
1
+ import type {BaseTexture} from '@babylonjs/core/Materials/Textures/baseTexture';
2
+ import type {Texture} from '@babylonjs/core/Materials/Textures/texture';
3
+ import type {VFXBatchSettings} from '../BatchedRenderer';
4
+
5
+ /** Settings given next to a material, which win over what is read from it. */
6
+ export interface MaterialOverrides {
7
+ blendMode?: number;
8
+ transparent?: boolean;
9
+ depthTest?: boolean;
10
+ depthWrite?: boolean;
11
+ alphaTest?: number;
12
+ texture?: Texture | null;
13
+ reflectionAtlas?: BaseTexture | null;
14
+ layerMask?: number;
15
+ }
16
+
17
+ /** The draw settings a {@link ParticleSystem} takes from a material, before the env atlas is built. */
18
+ export interface ResolvedMaterialSettings {
19
+ texture: Texture | null;
20
+ reflectionTexture: BaseTexture | null;
21
+ reflectionLevel: number;
22
+ /** A live atlas the material, the overrides or the current settings already have; null if none. */
23
+ reflectionAtlas: BaseTexture | null;
24
+ blendMode: number;
25
+ transparent: boolean;
26
+ depthTest: boolean;
27
+ depthWrite: boolean;
28
+ alphaTest: number;
29
+ }
30
+
31
+ /** `atlas` unless it has been disposed (it no longer has a scene). */
32
+ export function liveAtlas<T extends BaseTexture>(atlas: T | null | undefined): T | null {
33
+ return atlas?.getScene() ? atlas : null;
34
+ }
35
+
36
+ /**
37
+ * Reads the draw settings from a Babylon material, or from the material record of a loaded file:
38
+ * its texture, reflection, alpha mode, depth flags and alpha test. `overrides` win; a setting the
39
+ * material does not have keeps its `current` value.
40
+ */
41
+ export function resolveMaterialSettings(
42
+ material: any,
43
+ overrides: MaterialOverrides,
44
+ current: VFXBatchSettings
45
+ ): ResolvedMaterialSettings {
46
+ const texture =
47
+ overrides.texture !== undefined
48
+ ? overrides.texture
49
+ : (material?.albedoTexture ??
50
+ material?.diffuseTexture ??
51
+ material?.emissiveTexture ??
52
+ material?.opacityTexture ??
53
+ material?.baseTexture ??
54
+ current.texture ??
55
+ null);
56
+ const reflectionTexture =
57
+ material?.reflectionTexture ?? material?.environmentTexture ?? current.reflectionTexture ?? null;
58
+ const reflectionTextureLevel = typeof reflectionTexture?.level === 'number' ? reflectionTexture.level : 1;
59
+ const blendMode =
60
+ overrides.blendMode ??
61
+ (typeof material?.alphaMode === 'number' ? material.alphaMode : current.materialBlendMode);
62
+ const transparent =
63
+ overrides.transparent ??
64
+ (typeof material?.needAlphaBlending === 'function'
65
+ ? material.needAlphaBlending()
66
+ : typeof material?.alpha === 'number'
67
+ ? material.alpha < 1
68
+ : current.materialTransparent);
69
+ const depthTest =
70
+ overrides.depthTest ??
71
+ (typeof material?.disableDepthTest === 'boolean' ? !material.disableDepthTest : current.materialDepthTest);
72
+ const depthWrite =
73
+ overrides.depthWrite ??
74
+ (typeof material?.disableDepthWrite === 'boolean'
75
+ ? !material.disableDepthWrite
76
+ : typeof material?.forceDepthWrite === 'boolean'
77
+ ? material.forceDepthWrite
78
+ : current.materialDepthWrite);
79
+ const alphaTest =
80
+ overrides.alphaTest ??
81
+ (() => {
82
+ // StandardMaterial always has alphaCutOff (default 0.4). Only treat it as
83
+ // particle alpha-test when the material is actually in an alpha-test mode —
84
+ // otherwise Mesh Material demos wrongly enable USE_ALPHATEST.
85
+ const mode = material?.transparencyMode;
86
+ const alphaTestMode = mode === 1 || mode === 3;
87
+ if (alphaTestMode && typeof material?.alphaCutOff === 'number') {
88
+ return material.alphaCutOff;
89
+ }
90
+ if (alphaTestMode && typeof material?.alphaCutOffValue === 'number') {
91
+ return material.alphaCutOffValue;
92
+ }
93
+ if (material) {
94
+ return 0;
95
+ }
96
+ return current.materialAlphaTest;
97
+ })();
98
+ return {
99
+ texture,
100
+ reflectionTexture,
101
+ reflectionLevel:
102
+ typeof material?.reflectionLevel === 'number' ? material.reflectionLevel : reflectionTextureLevel,
103
+ // A disposed atlas (its cube was disposed, or disposeEnvAtlas ran) is skipped, so a new one is built.
104
+ reflectionAtlas:
105
+ liveAtlas(material?.reflectionAtlas) ??
106
+ liveAtlas(overrides.reflectionAtlas) ??
107
+ liveAtlas(current.reflectionAtlas),
108
+ // Do not expand cube files into six face textures anymore — that path
109
+ // still trips GL_INVALID_OPERATION on iOS. Prefer a single atlas.
110
+ blendMode,
111
+ transparent,
112
+ depthTest,
113
+ depthWrite,
114
+ alphaTest,
115
+ };
116
+ }
@@ -0,0 +1,95 @@
1
+ import {ConstantValue, FunctionValueGenerator, ValueGenerator, Vector2, Vector3} from 'quarks.core';
2
+ import type {MeshLighting, SortMode} from '../VFXBatch';
3
+ import type {BurstParameters, CullingMode} from './types';
4
+
5
+ // Normalisers shared by the ParticleSystem constructor, its setters and its JSON reader, so a
6
+ // value read from a file and one set in code end up the same.
7
+
8
+ /** `lighting` if it is one of the modes, else the default. */
9
+ export function validLighting(lighting: unknown): MeshLighting {
10
+ return lighting === 'unlit' || lighting === 'scene' ? lighting : 'fixed';
11
+ }
12
+
13
+ /** `mode` if it is one of the sort modes, else `'none'`. */
14
+ export function validSortMode(mode: unknown): SortMode {
15
+ return mode === 'distance' || mode === 'oldestInFront' || mode === 'youngestInFront' ? mode : 'none';
16
+ }
17
+
18
+ /** `mode` if it is one of the culling modes, else `'always'`. */
19
+ export function validCullingMode(mode: unknown): CullingMode {
20
+ return mode === 'pause' || mode === 'pauseAndCatchUp' ? mode : 'always';
21
+ }
22
+
23
+ /** `count` as a whole number of particles, or undefined (no limit) when it is unset, NaN or negative. */
24
+ export function validMaxParticles(count: unknown): number | undefined {
25
+ return typeof count === 'number' && count >= 0 && count < Infinity ? Math.floor(count) : undefined;
26
+ }
27
+
28
+ /** A copy of `v`, or undefined when it is unset or zero, which is the same as no pivot. */
29
+ export function nonZeroPivot(v: Vector3 | undefined): Vector3 | undefined {
30
+ return v && (v.x !== 0 || v.y !== 0 || v.z !== 0) ? v.clone() : undefined;
31
+ }
32
+
33
+ /** A copy of `v`, or undefined when neither axis flips. */
34
+ export function nonZeroFlip(v: Vector2 | undefined): Vector2 | undefined {
35
+ return v && (v.x > 0 || v.y > 0) ? v.clone() : undefined;
36
+ }
37
+
38
+ /** `cutoff` kept to 0…1, or 0 (no alpha test) when it is unset, NaN or negative. */
39
+ export function validAlphaTest(cutoff: unknown): number {
40
+ return typeof cutoff === 'number' && cutoff > 0 ? Math.min(cutoff, 1) : 0;
41
+ }
42
+
43
+ /** A flipbook's columns or rows: a whole number of cells, at least 1; anything else is 1. */
44
+ export function validTileCount(count: unknown): number {
45
+ return typeof count === 'number' && Number.isFinite(count) && count >= 1 ? Math.floor(count) : 1;
46
+ }
47
+
48
+ /** World units in front of the scene over which a soft particle is fully faded by default. */
49
+ export const DEFAULT_SOFT_FAR_FADE = 1;
50
+
51
+ /** A soft-particle fade distance: finite and at least 0, else `fallback`. */
52
+ export function validSoftFade(distance: unknown, fallback: number): number {
53
+ return typeof distance === 'number' && Number.isFinite(distance) && distance >= 0 ? distance : fallback;
54
+ }
55
+
56
+ /** Seconds between a burst's waves when a burst leaves `interval` out, in code and in a file. */
57
+ export const DEFAULT_BURST_INTERVAL = 0.1;
58
+
59
+ /**
60
+ * A burst with every field present and in range: a `time` that is a finite number of seconds
61
+ * (else 0), a `count` generator (a number becomes a constant, nothing becomes 0), `cycle` at least
62
+ * 1, `interval` {@link DEFAULT_BURST_INTERVAL} when left out and never negative, `probability`
63
+ * within 0..1 (1 when left out). The same rules apply to a burst given to the constructor and one
64
+ * read from a file, so both fire alike.
65
+ */
66
+ export function normalizeBurst(burst: Partial<BurstParameters> & {count?: unknown}): BurstParameters {
67
+ const count = burst.count;
68
+ const time = typeof burst.time === 'number' && Number.isFinite(burst.time) && burst.time > 0 ? burst.time : 0;
69
+ const cycle = typeof burst.cycle === 'number' && Number.isFinite(burst.cycle) && burst.cycle >= 1 ? burst.cycle : 1;
70
+ const interval =
71
+ typeof burst.interval === 'number' && Number.isFinite(burst.interval)
72
+ ? Math.max(0, burst.interval)
73
+ : DEFAULT_BURST_INTERVAL;
74
+ const probability =
75
+ typeof burst.probability === 'number' && Number.isFinite(burst.probability)
76
+ ? Math.min(1, Math.max(0, burst.probability))
77
+ : 1;
78
+ return {
79
+ time,
80
+ count:
81
+ typeof count === 'number'
82
+ ? new ConstantValue(count)
83
+ : count !== undefined && count !== null
84
+ ? (count as ValueGenerator | FunctionValueGenerator)
85
+ : new ConstantValue(0),
86
+ cycle,
87
+ interval,
88
+ probability,
89
+ };
90
+ }
91
+
92
+ /** The bursts normalised (see {@link normalizeBurst}) and in time order, which emission relies on. */
93
+ export function normalizeBursts(bursts: ReadonlyArray<Partial<BurstParameters>> | undefined): BurstParameters[] {
94
+ return (bursts ?? []).map(normalizeBurst).sort((a, b) => a.time - b.time);
95
+ }