babylon.quarks 0.28.0 → 0.29.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 (39) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/dist/babylon.quarks.cjs +1 -1
  3. package/dist/babylon.quarks.cjs.map +1 -1
  4. package/dist/babylon.quarks.esm.js +1 -1
  5. package/dist/babylon.quarks.esm.js.map +1 -1
  6. package/dist/babylon.quarks.umd.min.js +1 -1
  7. package/dist/babylon.quarks.umd.min.js.map +1 -1
  8. package/dist/types/index.d.cts +602 -190
  9. package/dist/types/index.d.ts +602 -190
  10. package/package.json +1 -1
  11. package/src/BatchedRenderer.ts +13 -1
  12. package/src/EffectPool.ts +5 -4
  13. package/src/ParticleEmitter.ts +89 -24
  14. package/src/ParticleSystem.ts +213 -30
  15. package/src/QuarksLoader.ts +9 -0
  16. package/src/QuarksSerializer.ts +1 -1
  17. package/src/QuarksUtil.ts +24 -2
  18. package/src/SpriteBatch.ts +3 -0
  19. package/src/TrailBatch.ts +8 -1
  20. package/src/VFXBatch.ts +112 -0
  21. package/src/batchKey.ts +2 -0
  22. package/src/batchSettings.ts +2 -0
  23. package/src/index.ts +2 -0
  24. package/src/shaderProgram.ts +103 -0
  25. package/src/shaderVariants.ts +102 -5
  26. package/src/shaders/chunks/blend.ts +15 -7
  27. package/src/shaders/chunks/imageProcessing.ts +143 -0
  28. package/src/shaders/particle_frag.glsl.ts +2 -0
  29. package/src/shaders/particle_frag.wgsl.ts +2 -0
  30. package/src/shaders/particle_physics_frag.glsl.ts +2 -0
  31. package/src/shaders/particle_physics_frag.wgsl.ts +2 -0
  32. package/src/shaders/trail_frag.glsl.ts +2 -0
  33. package/src/shaders/trail_frag.wgsl.ts +2 -0
  34. package/src/system/EmissionController.ts +21 -16
  35. package/src/system/ParticleSpawner.ts +96 -19
  36. package/src/system/integrate.ts +61 -4
  37. package/src/system/normalize.ts +28 -1
  38. package/src/system/settingsSchema.ts +25 -7
  39. package/src/system/types.ts +83 -14
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The scene's image processing for the particle shaders, in both shader languages.
3
+ *
4
+ * Babylon's own includes do the work (`imageProcessingDeclaration`, `helperFunctions`,
5
+ * `imageProcessingFunctions`), so the particles are tone mapped, graded and vignetted exactly as
6
+ * the scene's standard and PBR materials and its built-in particle systems are. The defines come
7
+ * from `ImageProcessingConfiguration.prepareDefines`, see `imageProcessingDefines` in
8
+ * `shaderProgram.ts`:
9
+ *
10
+ * - `IMAGEPROCESSING`: the material applies the configuration. The colour, authored in gamma
11
+ * space, is brought to linear space and `applyImageProcessing` returns it to gamma space with
12
+ * the configuration's effects on the way.
13
+ * - `IMAGEPROCESSINGPOSTPROCESS`: a post process applies it later, to the frame; the material only
14
+ * leaves its colour in the linear space that post process expects.
15
+ *
16
+ * The step runs on the straight colour, after the fog and the soft fade and before the blend
17
+ * resolve (`blend.ts`), so the resolve's meaning of alpha survives it: a premultiplied colour is
18
+ * the processed colour times its coverage. Particles that multiply the frame (`BLEND_MULTIPLY`)
19
+ * are a factor, not light, so `shaderProgram` compiles no in-material processing for them, and
20
+ * under a post process their resolved factor is brought to linear space after the resolve: white,
21
+ * which leaves the frame as it is, stays white.
22
+ */
23
+
24
+ import {ShaderStore} from '@babylonjs/core/Engines/shaderStore';
25
+ import '@babylonjs/core/Shaders/ShadersInclude/helperFunctions';
26
+ import '@babylonjs/core/Shaders/ShadersInclude/imageProcessingDeclaration';
27
+ import '@babylonjs/core/Shaders/ShadersInclude/imageProcessingFunctions';
28
+ import '@babylonjs/core/ShadersWGSL/ShadersInclude/helperFunctions';
29
+ import '@babylonjs/core/ShadersWGSL/ShadersInclude/imageProcessingDeclaration';
30
+ import '@babylonjs/core/ShadersWGSL/ShadersInclude/imageProcessingFunctions';
31
+
32
+ /**
33
+ * Babylon's WGSL `imageProcessingFunctions` samples a 2D colour grading texture (a LUT laid out as
34
+ * slices) with a `textureSample` overload WGSL does not have, so it does not compile; its GLSL
35
+ * reads the slices through a `sampleTexture3D` polyfill. This is that polyfill in WGSL, and the
36
+ * include with the call routed through it, registered as `quarksImageProcessingFunctions`. When a
37
+ * Babylon release fixes the call, the include is registered as it comes.
38
+ */
39
+ const SAMPLE_TEXTURE_3D_WGSL = /* wgsl */ `
40
+ #if defined(COLORGRADING) && !defined(COLORGRADING3D)
41
+ fn quarksSampleTexture3D(colorTransform: texture_2d<f32>, colorTransformSampler: sampler, colorIn: vec3f, sampler3dSetting: vec2f) -> vec3f {
42
+ let sliceSize = 2.0 * sampler3dSetting.x;
43
+ #ifdef SAMPLER3DGREENDEPTH
44
+ let sliceContinuous = (colorIn.g - sampler3dSetting.x) * sampler3dSetting.y;
45
+ var sliceUV = colorIn.rb;
46
+ #else
47
+ let sliceContinuous = (colorIn.b - sampler3dSetting.x) * sampler3dSetting.y;
48
+ var sliceUV = colorIn.rg;
49
+ #endif
50
+ let sliceInteger = floor(sliceContinuous);
51
+ let sliceFraction = sliceContinuous - sliceInteger;
52
+ sliceUV.x = sliceUV.x * sliceSize + sliceInteger * sliceSize;
53
+ let slice0Color = textureSample(colorTransform, colorTransformSampler, clamp(sliceUV, vec2f(0.0), vec2f(1.0)));
54
+ let slice1Color = textureSample(colorTransform, colorTransformSampler, clamp(sliceUV + vec2f(sliceSize, 0.0), vec2f(0.0), vec2f(1.0)));
55
+ let result = mix(slice0Color.rgb, slice1Color.rgb, sliceFraction);
56
+ #ifdef SAMPLER3DBGRMAP
57
+ return result.rgb;
58
+ #else
59
+ return result.bgr;
60
+ #endif
61
+ }
62
+ #endif
63
+ `;
64
+
65
+ const BROKEN_2D_SAMPLE =
66
+ 'textureSample(txColorTransform,txColorTransformSampler,colorTransformInput,uniforms.colorTransformSettings.yz).rgb';
67
+
68
+ const babylonFunctionsWGSL = ShaderStore.IncludesShadersStoreWGSL['imageProcessingFunctions'];
69
+
70
+ /**
71
+ * Whether Babylon's WGSL include still has the broken call, and so is registered patched. The
72
+ * tests pin this for the Babylon version the library is built against, and the shader validation
73
+ * compiles 2D colour grading in WGSL, so a Babylon release that changes the include is noticed.
74
+ * @internal
75
+ */
76
+ export const PATCHES_IMAGE_PROCESSING_WGSL = babylonFunctionsWGSL.includes(BROKEN_2D_SAMPLE);
77
+
78
+ ShaderStore.IncludesShadersStoreWGSL['quarksImageProcessingFunctions'] = PATCHES_IMAGE_PROCESSING_WGSL
79
+ ? SAMPLE_TEXTURE_3D_WGSL +
80
+ babylonFunctionsWGSL.replace(
81
+ BROKEN_2D_SAMPLE,
82
+ 'quarksSampleTexture3D(txColorTransform,txColorTransformSampler,colorTransformInput,uniforms.colorTransformSettings.yz)'
83
+ )
84
+ : babylonFunctionsWGSL;
85
+
86
+ /** Fragment-side declaration, GLSL. */
87
+ export const imageProcessingDeclarationGLSL = /* glsl */ `
88
+ #if defined(IMAGEPROCESSING) || defined(IMAGEPROCESSINGPOSTPROCESS)
89
+ #include<imageProcessingDeclaration>
90
+ #include<helperFunctions>
91
+ #include<imageProcessingFunctions>
92
+ #endif
93
+ `;
94
+
95
+ /** Processes the straight vec4 lvalue `color`, before the blend resolve, GLSL. */
96
+ export function imageProcessingFragmentGLSL(color: string): string {
97
+ return /* glsl */ `
98
+ #if defined(IMAGEPROCESSINGPOSTPROCESS) && !defined(BLEND_MULTIPLY)
99
+ ${color}.rgb = toLinearSpace(${color}.rgb);
100
+ #elif defined(IMAGEPROCESSING)
101
+ ${color}.rgb = toLinearSpace(${color}.rgb);
102
+ ${color} = applyImageProcessing(${color});
103
+ #endif
104
+ `;
105
+ }
106
+
107
+ /** Brings a resolved multiply factor in `color` to linear space for a post process, GLSL. */
108
+ export function imageProcessingResolvedGLSL(color: string): string {
109
+ return /* glsl */ `
110
+ #if defined(IMAGEPROCESSINGPOSTPROCESS) && defined(BLEND_MULTIPLY)
111
+ ${color}.rgb = toLinearSpace(${color}.rgb);
112
+ #endif
113
+ `;
114
+ }
115
+
116
+ /** Fragment-side declaration, WGSL. */
117
+ export const imageProcessingDeclarationWGSL = /* wgsl */ `
118
+ #if defined(IMAGEPROCESSING) || defined(IMAGEPROCESSINGPOSTPROCESS)
119
+ #include<imageProcessingDeclaration>
120
+ #include<helperFunctions>
121
+ #include<quarksImageProcessingFunctions>
122
+ #endif
123
+ `;
124
+
125
+ /** Processes the straight `var` vec4 `color`, before the blend resolve, WGSL. */
126
+ export function imageProcessingFragmentWGSL(color: string): string {
127
+ return /* wgsl */ `
128
+ #if defined(IMAGEPROCESSINGPOSTPROCESS) && !defined(BLEND_MULTIPLY)
129
+ ${color} = vec4f(toLinearSpaceVec3(${color}.rgb), ${color}.a);
130
+ #elif defined(IMAGEPROCESSING)
131
+ ${color} = applyImageProcessing(vec4f(toLinearSpaceVec3(${color}.rgb), ${color}.a));
132
+ #endif
133
+ `;
134
+ }
135
+
136
+ /** Brings a resolved multiply factor in `color` to linear space for a post process, WGSL. */
137
+ export function imageProcessingResolvedWGSL(color: string): string {
138
+ return /* wgsl */ `
139
+ #if defined(IMAGEPROCESSINGPOSTPROCESS) && defined(BLEND_MULTIPLY)
140
+ ${color} = vec4f(toLinearSpaceVec3(${color}.rgb), ${color}.a);
141
+ #endif
142
+ `;
143
+ }
@@ -1,5 +1,6 @@
1
1
  import {blendFragmentGLSL} from './chunks/blend';
2
2
  import {fogFragmentDeclarationGLSL, fogFragmentGLSL} from './chunks/fog';
3
+ import {imageProcessingDeclarationGLSL} from './chunks/imageProcessing';
3
4
  import {softFragmentDeclarationGLSL, softFragmentGLSL} from './chunks/soft';
4
5
  import {surfaceColorGLSL, surfaceFragmentDeclarationGLSL} from './chunks/surface';
5
6
 
@@ -25,6 +26,7 @@ uniform float alphaTest;
25
26
  #endif
26
27
 
27
28
  ${fogFragmentDeclarationGLSL}
29
+ ${imageProcessingDeclarationGLSL}
28
30
  ${softFragmentDeclarationGLSL}
29
31
  ${surfaceFragmentDeclarationGLSL}
30
32
  void main() {
@@ -1,5 +1,6 @@
1
1
  import {blendFragmentWGSL} from './chunks/blend';
2
2
  import {fogFragmentDeclarationWGSL, fogFragmentWGSL} from './chunks/fog';
3
+ import {imageProcessingDeclarationWGSL} from './chunks/imageProcessing';
3
4
  import {softFragmentDeclarationWGSL, softFragmentWGSL} from './chunks/soft';
4
5
  import {surfaceColorWGSL, surfaceFragmentDeclarationWGSL} from './chunks/surface';
5
6
 
@@ -26,6 +27,7 @@ uniform alphaTest: f32;
26
27
  #endif
27
28
 
28
29
  ${fogFragmentDeclarationWGSL}
30
+ ${imageProcessingDeclarationWGSL}
29
31
  ${softFragmentDeclarationWGSL}
30
32
  ${surfaceFragmentDeclarationWGSL}
31
33
  @fragment
@@ -1,5 +1,6 @@
1
1
  import {blendFragmentGLSL} from './chunks/blend';
2
2
  import {fogFragmentDeclarationGLSL, fogFragmentGLSL} from './chunks/fog';
3
+ import {imageProcessingDeclarationGLSL} from './chunks/imageProcessing';
3
4
  import {softFragmentDeclarationGLSL, softFragmentGLSL} from './chunks/soft';
4
5
  import {surfaceColorGLSL, surfaceFragmentDeclarationGLSL} from './chunks/surface';
5
6
 
@@ -87,6 +88,7 @@ uniform float alphaTest;
87
88
  #endif
88
89
 
89
90
  ${fogFragmentDeclarationGLSL}
91
+ ${imageProcessingDeclarationGLSL}
90
92
  ${softFragmentDeclarationGLSL}
91
93
  ${surfaceFragmentDeclarationGLSL}
92
94
  void main() {
@@ -1,5 +1,6 @@
1
1
  import {blendFragmentWGSL} from './chunks/blend';
2
2
  import {fogFragmentDeclarationWGSL, fogFragmentWGSL} from './chunks/fog';
3
+ import {imageProcessingDeclarationWGSL} from './chunks/imageProcessing';
3
4
  import {softFragmentDeclarationWGSL, softFragmentWGSL} from './chunks/soft';
4
5
  import {surfaceColorWGSL, surfaceFragmentDeclarationWGSL} from './chunks/surface';
5
6
 
@@ -86,6 +87,7 @@ uniform alphaTest: f32;
86
87
  #endif
87
88
 
88
89
  ${fogFragmentDeclarationWGSL}
90
+ ${imageProcessingDeclarationWGSL}
89
91
  ${softFragmentDeclarationWGSL}
90
92
  ${surfaceFragmentDeclarationWGSL}
91
93
  @fragment
@@ -1,5 +1,6 @@
1
1
  import {blendFragmentGLSL} from './chunks/blend';
2
2
  import {fogFragmentDeclarationGLSL, fogFragmentGLSL} from './chunks/fog';
3
+ import {imageProcessingDeclarationGLSL} from './chunks/imageProcessing';
3
4
  import {softFragmentDeclarationGLSL, softFragmentGLSL} from './chunks/soft';
4
5
  import {surfaceColorGLSL, surfaceFragmentDeclarationGLSL} from './chunks/surface';
5
6
 
@@ -19,6 +20,7 @@ uniform float alphaTest;
19
20
  #endif
20
21
 
21
22
  ${fogFragmentDeclarationGLSL}
23
+ ${imageProcessingDeclarationGLSL}
22
24
  ${softFragmentDeclarationGLSL}
23
25
  ${surfaceFragmentDeclarationGLSL}
24
26
  void main() {
@@ -1,5 +1,6 @@
1
1
  import {blendFragmentWGSL} from './chunks/blend';
2
2
  import {fogFragmentDeclarationWGSL, fogFragmentWGSL} from './chunks/fog';
3
+ import {imageProcessingDeclarationWGSL} from './chunks/imageProcessing';
3
4
  import {softFragmentDeclarationWGSL, softFragmentWGSL} from './chunks/soft';
4
5
  import {surfaceColorWGSL, surfaceFragmentDeclarationWGSL} from './chunks/surface';
5
6
 
@@ -20,6 +21,7 @@ uniform alphaTest: f32;
20
21
  #endif
21
22
 
22
23
  ${fogFragmentDeclarationWGSL}
24
+ ${imageProcessingDeclarationWGSL}
23
25
  ${softFragmentDeclarationWGSL}
24
26
  ${surfaceFragmentDeclarationWGSL}
25
27
  @fragment
@@ -5,6 +5,7 @@ import {
5
5
  Matrix4,
6
6
  PiecewiseBezier,
7
7
  random,
8
+ RandomBetweenCurves,
8
9
  ValueGenerator,
9
10
  Vector3,
10
11
  } from 'quarks.core';
@@ -65,19 +66,24 @@ export class EmissionController {
65
66
  return delay;
66
67
  }
67
68
 
68
- /** Whether continuous or queued emission can still spawn particles before the system is done. */
69
- hasPendingEmission(): boolean {
69
+ /**
70
+ * Whether continuous or queued emission can still spawn particles before the system is done:
71
+ * its own, or with `state` the emission a sub emitter runs into it for one parent particle.
72
+ */
73
+ hasPendingEmission(state?: EmissionState): boolean {
70
74
  const system = this.system;
71
- if (system.isEmitEnded) {
72
- return false;
73
- }
74
- if (system.looping) {
75
- return true;
76
- }
77
- if (system.onlyUsedByOther) {
78
- return false;
75
+ if (state === undefined || state === system.emissionState) {
76
+ if (system.isEmitEnded) {
77
+ return false;
78
+ }
79
+ if (system.looping) {
80
+ return true;
81
+ }
82
+ if (system.onlyUsedByOther) {
83
+ return false;
84
+ }
85
+ state = system.emissionState;
79
86
  }
80
- const state = system.emissionState;
81
87
  if (state.waitEmiting > 0) {
82
88
  return true;
83
89
  }
@@ -108,6 +114,10 @@ export class EmissionController {
108
114
  if (rate.type !== 'function') {
109
115
  return false;
110
116
  }
117
+ if (rate instanceof RandomBetweenCurves) {
118
+ // Every mix lies between the two curves, so it emits later if either does.
119
+ return this.emitsFrom(rate.curve1, timeRatio) || this.emitsFrom(rate.curve2, timeRatio);
120
+ }
111
121
  if (rate instanceof PiecewiseBezier) {
112
122
  // A Bezier stays within its control points, so this is exact however short a pulse.
113
123
  for (let i = 0; i < rate.functions.length; i++) {
@@ -176,11 +186,6 @@ export class EmissionController {
176
186
  }
177
187
  }
178
188
 
179
- // Only spawns in world space consume the normal matrix, and computing it
180
- // inverts a 4x4 — skip it for local-space systems.
181
- if (system.worldSpace) {
182
- this.spawner.normalMatrix.getNormalMatrix(emitterMatrix);
183
- }
184
189
  const qualityFactor = system.emissionFactor();
185
190
 
186
191
  if (system.emissionBursts.length > 0 && !endedBefore) {
@@ -18,13 +18,14 @@ import type {ParticleSystem} from '../ParticleSystem';
18
18
  import {RenderMode} from '../VFXBatch';
19
19
 
20
20
  /** Particle backed by a `ParticleStore` row, as a system always creates. */
21
- type StoreBackedParticle = Particle & {storeIndex: number};
21
+ type StoreBackedParticle = Particle & {storeIndex: number; setStoreIndex(index: number): void};
22
22
 
23
23
  const UP = new Vector3(0, 0, 1);
24
24
  const IDENTITY_QUATERNION = new Quaternion();
25
25
  const tempQ = new Quaternion();
26
26
  const tempV = new Vector3();
27
27
  const tempV2 = new Vector3();
28
+ const tempM3 = new Matrix3();
28
29
 
29
30
  /**
30
31
  * Gives a system its newborn particles: grows the pool and the store they live in, rolls each
@@ -33,13 +34,64 @@ const tempV2 = new Vector3();
33
34
  * spawn and when, this decides what each one starts as.
34
35
  */
35
36
  export class ParticleSpawner {
37
+ constructor(private readonly system: ParticleSystem) {}
38
+
36
39
  /**
37
- * The emitter matrix's normal matrix, for turning a world-space newborn's velocity. The
38
- * emission controller refreshes it before it spawns from a world-space system.
40
+ * The row a full ring buffer replaces next, the oldest. Particles of a ring buffer never die,
41
+ * so they stay in the order they were born: the oldest is row 0 when the system fills up, and
42
+ * the cursor walks on from there.
39
43
  */
40
- readonly normalMatrix = new Matrix3();
44
+ private ringCursor = 0;
41
45
 
42
- constructor(private readonly system: ParticleSystem) {}
46
+ /** Swaps rows `a` and `b`: the particle objects and their store rows. */
47
+ private swapRows(a: number, b: number): void {
48
+ const particles = this.system.particles;
49
+ const first = particles[a] as StoreBackedParticle;
50
+ const second = particles[b] as StoreBackedParticle;
51
+ particles[a] = second;
52
+ particles[b] = first;
53
+ this.system.store.swapRows(a, b);
54
+ second.setStoreIndex(a);
55
+ first.setStoreIndex(b);
56
+ }
57
+
58
+ /** Reverses rows `from` to `to`, inclusive. */
59
+ private reverseRows(from: number, to: number): void {
60
+ for (; from < to; from++, to--) {
61
+ this.swapRows(from, to);
62
+ }
63
+ }
64
+
65
+ /** Moves the first `by` of the `count` live rows to the end, keeping the order of both parts. */
66
+ private rotateRows(by: number, count: number): void {
67
+ if (by <= 0 || by >= count) {
68
+ return;
69
+ }
70
+ this.reverseRows(0, by - 1);
71
+ this.reverseRows(by, count - 1);
72
+ this.reverseRows(0, count - 1);
73
+ }
74
+
75
+ /**
76
+ * Keeps a ring buffer within `maxParticles` once it was lowered: its particles never die, so
77
+ * the oldest over the limit are dropped, and the rest stay in birth order from row 0. Called
78
+ * at the start of every step; costs nothing while the system is within its limit.
79
+ */
80
+ fitRing(): void {
81
+ const system = this.system;
82
+ const max = system.maxParticles;
83
+ const count = system.particleNum;
84
+ if (max === undefined || count <= max || system.ringBufferMode === 'disabled') {
85
+ return;
86
+ }
87
+ if (system.rendererSettings.renderMode === RenderMode.Trail) {
88
+ return;
89
+ }
90
+ // Oldest first from the cursor: the survivors start `count - max` rows after it.
91
+ this.rotateRows((this.ringCursor + count - max) % count, count);
92
+ system.particleNum = max;
93
+ this.ringCursor = 0;
94
+ }
43
95
 
44
96
  /** Points every particle at the store's columns again, after any were replaced. */
45
97
  rebind(): void {
@@ -75,6 +127,7 @@ export class ParticleSpawner {
75
127
  const scale = tempV2;
76
128
  // decompose writes tempQ, so no separate setFromRotationMatrix is needed.
77
129
  matrix.decompose(translation, tempQ, scale);
130
+ const linear = tempM3.setFromMatrix4(matrix);
78
131
  const behaviors = system.behaviors;
79
132
  const behaviorCount = behaviors.length;
80
133
  const renderMode = system.rendererSettings.renderMode;
@@ -89,20 +142,43 @@ export class ParticleSpawner {
89
142
  const trailSettings = isTrailMode ? (system.rendererEmitterSettings as TrailSettings) : undefined;
90
143
  const followLocalOrigin = Boolean(trailSettings?.followLocalOrigin);
91
144
  const worldSpace = system.worldSpace;
92
- const hierarchy = system.scalingMode === 'hierarchy';
145
+ // The emitter's matrix carries the scale the scaling mode applies (see
146
+ // `ParticleEmitter.matrixWorld`); 'shape' scales the shape alone, here.
147
+ const shapeScale = system.scalingMode === 'shape' ? system.emitter.shapeScale : undefined;
93
148
  const particles = system.particles;
94
- if (system.maxParticles !== undefined) {
95
- // Like Unity, a full system skips the particles it has no room for rather than queueing them.
96
- count = Math.min(count, Math.max(0, system.maxParticles - system.particleNum));
149
+ const maxParticles = system.maxParticles;
150
+ // A ring buffer replaces the oldest particles of a full system (see `ringBufferMode`).
151
+ const ring = maxParticles !== undefined && system.ringBufferMode !== 'disabled' && !isTrailMode;
152
+ if (maxParticles !== undefined) {
153
+ // Like Unity, a full system skips the particles it has no room for rather than
154
+ // queueing them; a ring buffer replaces at most every particle once.
155
+ count = ring
156
+ ? Math.min(count, maxParticles)
157
+ : Math.min(count, Math.max(0, maxParticles - system.particleNum));
97
158
  }
98
159
 
99
160
  for (let i = 0; i < count; i++) {
100
161
  emissionState.burstParticleIndex = i;
101
- system.particleNum++;
102
- if (particles.length < system.particleNum) {
103
- this.grow(isTrailMode);
162
+ let particle: Particle;
163
+ if (ring && system.particleNum >= maxParticles) {
164
+ if (this.ringCursor >= system.particleNum) {
165
+ this.ringCursor = 0;
166
+ }
167
+ particle = particles[this.ringCursor];
168
+ this.ringCursor = (this.ringCursor + 1) % system.particleNum;
169
+ } else {
170
+ if (ring && this.ringCursor !== 0) {
171
+ // A ring that wrapped and was given room (a higher limit): put its oldest at
172
+ // row 0 again, so the newborn appended after the newest stays in birth order.
173
+ this.rotateRows(this.ringCursor, system.particleNum);
174
+ this.ringCursor = 0;
175
+ }
176
+ system.particleNum++;
177
+ if (particles.length < system.particleNum) {
178
+ this.grow(isTrailMode);
179
+ }
180
+ particle = particles[system.particleNum - 1];
104
181
  }
105
- const particle = particles[system.particleNum - 1];
106
182
  particle.reset();
107
183
  particle.serial = (particle.serial ?? 0) + 1;
108
184
  particle.speedModifier = 1;
@@ -178,6 +254,9 @@ export class ParticleSpawner {
178
254
  }
179
255
 
180
256
  system.emitterShape.initialize(particle, emissionState);
257
+ if (shapeScale !== undefined) {
258
+ particle.position.multiply(shapeScale);
259
+ }
181
260
 
182
261
  if (system.alignToDirection && isSpriteMode) {
183
262
  alignParticleToDirection(particle as SpriteParticle, isMeshMode);
@@ -201,12 +280,10 @@ export class ParticleSpawner {
201
280
  particle.position.applyMatrix4(matrix);
202
281
  particle.startSize.multiply(scale).abs();
203
282
  particle.size.copy(particle.startSize);
204
- if (hierarchy) {
205
- // Scaled per axis by the emitter's world scale, then turned: the speed scales with it.
206
- particle.velocity.multiply(system.hierarchyScale).applyQuaternion(tempQ);
207
- } else {
208
- particle.velocity.multiply(scale).applyMatrix3(this.normalMatrix);
209
- }
283
+ // Through the matrix's own axes, as the position is: scaled, turned and mirrored
284
+ // with it. For a sub emission that is the parent particle's frame, the same one
285
+ // its start size takes.
286
+ particle.velocity.applyMatrix3(linear);
210
287
  if (particle.rotation && particle.rotation instanceof Quaternion) {
211
288
  particle.rotation.multiplyQuaternions(tempQ, particle.rotation);
212
289
  }
@@ -129,6 +129,34 @@ function retire(particles: Particle[], store: ParticleStore, i: number, last: nu
129
129
  return particle;
130
130
  }
131
131
 
132
+ /**
133
+ * What a ring buffer does to a particle at the end of its life (see `RingBufferMode`): `pause`
134
+ * holds it frozen at its end, else it goes back to `loopStart` (a fraction of its life) once it
135
+ * reaches `loopEnd`.
136
+ */
137
+ export interface RingBufferStep {
138
+ /** Freeze at the end of life rather than loop. */
139
+ pause: boolean;
140
+ /** Where a looping particle goes back to, a fraction of its life, 0 to 1. */
141
+ loopStart: number;
142
+ /**
143
+ * Where a looping particle goes back from, a fraction of its life, `loopStart` to 1. Equal to
144
+ * `loopStart`, the particle is held there.
145
+ */
146
+ loopEnd: number;
147
+ }
148
+
149
+ /** The least age a held particle keeps, as a fraction of its life: past 0, a newborn's age. */
150
+ const HELD_AGE = 1e-6;
151
+
152
+ /**
153
+ * The age a looping ring particle is held at, or Infinity when it loops: a loop range of no
154
+ * length holds it at that point of its life, as a pause does at the end.
155
+ */
156
+ function holdAge(ring: RingBufferStep, life: number): number {
157
+ return ring.loopEnd > ring.loopStart ? Infinity : Math.max(ring.loopEnd, HELD_AGE) * life;
158
+ }
159
+
132
160
  /**
133
161
  * The sprite path's step after the behaviors, as one pass over the columns that does three
134
162
  * things, none of which touches a particle object until a death. Returns the live count.
@@ -142,7 +170,8 @@ function retire(particles: Particle[], store: ParticleStore, i: number, last: nu
142
170
  * Position is integrated along the velocity.
143
171
  *
144
172
  * 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.
173
+ * survivor moved into the slot has not been visited yet, so the slot is processed again. With a
174
+ * `ring` buffer no particle dies: it is frozen at the end of its life, or sent back into it.
146
175
  *
147
176
  * The bounds fall out of the same pass. Measuring every particle on every step costs about as
148
177
  * much as the integration itself, so the full measurement runs every few steps; in between,
@@ -158,9 +187,11 @@ export function integrateSprites(
158
187
  collapseColor: boolean,
159
188
  bounds: BoundsTracker,
160
189
  settings: BoundsSettings,
161
- onDied: DeathListener | undefined
190
+ onDied: DeathListener | undefined,
191
+ ring?: RingBufferStep
162
192
  ): number {
163
193
  const scalars = store.scalars;
194
+ const pause = ring !== undefined && ring.pause;
164
195
  const stride = ParticleStore.SCALAR_STRIDE;
165
196
  const positions = store.position;
166
197
  const velocities = store.velocity;
@@ -198,7 +229,12 @@ export function integrateSprites(
198
229
  prevColor[o4 + 3] = curColor[o4 + 3];
199
230
  }
200
231
  }
201
- const step = delta * scalars[scalar + ParticleStore.SPEED_MODIFIER];
232
+ const life = scalars[scalar + ParticleStore.LIFE];
233
+ // Frozen until a newborn replaces it, no motion and no ageing: at the end of its life when
234
+ // paused, or at a loop range of no length, held a hair past 0 so Birth fires only once.
235
+ const hold = ring === undefined ? Infinity : pause ? life : holdAge(ring, life);
236
+ const frozen = age >= hold;
237
+ const step = frozen ? 0 : delta * scalars[scalar + ParticleStore.SPEED_MODIFIER];
202
238
  const vx = velocities[offset];
203
239
  const vy = velocities[offset + 1];
204
240
  const vz = velocities[offset + 2];
@@ -219,10 +255,31 @@ export function integrateSprites(
219
255
  }
220
256
  // Speed is tracked every step: it bounds how far the particles
221
257
  // can have moved since the last measurement.
258
+ if (frozen) {
259
+ continue;
260
+ }
222
261
  maxSpeedSq = Math.max(maxSpeedSq, vx * vx + vy * vy + vz * vz);
223
262
  age += delta;
224
263
  scalars[scalar + ParticleStore.AGE] = age;
225
- if (age >= scalars[scalar + ParticleStore.LIFE]) {
264
+ if (ring !== undefined) {
265
+ if (age > hold) {
266
+ scalars[scalar + ParticleStore.AGE] = hold;
267
+ } else if (!pause && hold === Infinity && age >= ring.loopEnd * life) {
268
+ const loopStart = ring.loopStart * life;
269
+ const wrapped = loopStart + ((age - ring.loopEnd * life) % ((ring.loopEnd - ring.loopStart) * life));
270
+ // Never exactly 0, which is a newborn's age: Birth sub emitters would fire again.
271
+ scalars[scalar + ParticleStore.AGE] = wrapped > 0 ? wrapped : HELD_AGE * life;
272
+ // The jump back is not motion: the renderer must not extrapolate size or colour across it.
273
+ prevSize[offset] = curSize[offset];
274
+ prevSize[offset + 1] = curSize[offset + 1];
275
+ prevSize[offset + 2] = curSize[offset + 2];
276
+ const o4 = i * 4;
277
+ prevColor[o4] = curColor[o4];
278
+ prevColor[o4 + 1] = curColor[o4 + 1];
279
+ prevColor[o4 + 2] = curColor[o4 + 2];
280
+ prevColor[o4 + 3] = curColor[o4 + 3];
281
+ }
282
+ } else if (age >= life) {
226
283
  const particle = retire(particles, store, i, liveParticleCount - 1);
227
284
  liveParticleCount--;
228
285
  i--;
@@ -1,6 +1,6 @@
1
1
  import {ConstantValue, FunctionValueGenerator, ValueGenerator, Vector2, Vector3} from 'quarks.core';
2
2
  import type {MeshLighting, RenderFace, SortMode} from '../batchSettings';
3
- import type {BurstParameters, CullingMode} from './types';
3
+ import type {BurstParameters, CullingMode, RingBufferMode, ScalingMode, StopAction} from './types';
4
4
 
5
5
  // Normalisers shared by the ParticleSystem constructor, its setters and its JSON reader, so a
6
6
  // value read from a file and one set in code end up the same.
@@ -25,6 +25,33 @@ export function validCullingMode(mode: unknown): CullingMode {
25
25
  return mode === 'pause' || mode === 'pauseAndCatchUp' ? mode : 'always';
26
26
  }
27
27
 
28
+ /** `action` if it is one of the stop actions, else `'none'`. */
29
+ export function validStopAction(action: unknown): StopAction {
30
+ return action === 'disable' || action === 'destroy' ? action : 'none';
31
+ }
32
+
33
+ /** `mode` if it is one of the ring buffer modes, else `'disabled'`. */
34
+ export function validRingBufferMode(mode: unknown): RingBufferMode {
35
+ return mode === 'pauseUntilReplaced' || mode === 'loopUntilReplaced' ? mode : 'disabled';
36
+ }
37
+
38
+ /**
39
+ * A ring buffer loop range within 0..1, start no later than end; a missing or non-finite bound
40
+ * takes its default (0 or 1).
41
+ */
42
+ export function validLoopRange(range: {x: number; y: number} | undefined): Vector2 {
43
+ const clamp = (value: number | undefined, fallback: number) =>
44
+ typeof value === 'number' && Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
45
+ const start = clamp(range?.x, 0);
46
+ const end = clamp(range?.y, 1);
47
+ return new Vector2(Math.min(start, end), Math.max(start, end));
48
+ }
49
+
50
+ /** `mode` if it is one of the scaling modes, else `'local'`. */
51
+ export function validScalingMode(mode: unknown): ScalingMode {
52
+ return mode === 'hierarchy' || mode === 'shape' ? mode : 'local';
53
+ }
54
+
28
55
  /** `count` as a whole number of particles, or undefined (no limit) when it is unset, NaN or negative. */
29
56
  export function validMaxParticles(count: unknown): number | undefined {
30
57
  return typeof count === 'number' && count >= 0 && count < Infinity ? Math.floor(count) : undefined;