babylon.quarks 0.27.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 (55) hide show
  1. package/CHANGELOG.md +585 -0
  2. package/README.md +3 -1
  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 +4811 -974
  10. package/dist/types/index.d.ts +4811 -974
  11. package/package.json +7 -2
  12. package/src/BatchedRenderer.ts +106 -79
  13. package/src/EffectPool.ts +96 -16
  14. package/src/MeshSurfaceEmitter.ts +24 -13
  15. package/src/ParticleEmitter.ts +119 -26
  16. package/src/ParticleSystem.ts +305 -55
  17. package/src/QuarksLoader.ts +298 -171
  18. package/src/QuarksPrefab.ts +301 -94
  19. package/src/QuarksSerializer.ts +44 -16
  20. package/src/QuarksUtil.ts +88 -11
  21. package/src/SpriteBatch.ts +24 -6
  22. package/src/TrailBatch.ts +20 -7
  23. package/src/VFXBatch.ts +182 -528
  24. package/src/batchKey.ts +6 -2
  25. package/src/batchSettings.ts +548 -0
  26. package/src/effectDocumentJSON.ts +261 -0
  27. package/src/effectResources.ts +21 -24
  28. package/src/envAtlas.ts +8 -2
  29. package/src/geometryUtil.ts +1 -1
  30. package/src/index.ts +41 -4
  31. package/src/nodeId.ts +25 -2
  32. package/src/shaderProgram.ts +213 -16
  33. package/src/shaderVariants.ts +124 -7
  34. package/src/shaders/chunks/blend.ts +39 -0
  35. package/src/shaders/chunks/fog.ts +8 -3
  36. package/src/shaders/chunks/imageProcessing.ts +143 -0
  37. package/src/shaders/chunks/soft.ts +6 -5
  38. package/src/shaders/chunks/surface.ts +2 -2
  39. package/src/shaders/particle_frag.glsl.ts +5 -1
  40. package/src/shaders/particle_frag.wgsl.ts +4 -0
  41. package/src/shaders/particle_physics_frag.glsl.ts +7 -2
  42. package/src/shaders/particle_physics_frag.wgsl.ts +4 -0
  43. package/src/shaders/trail_frag.glsl.ts +6 -1
  44. package/src/shaders/trail_frag.wgsl.ts +4 -0
  45. package/src/system/EmissionController.ts +23 -20
  46. package/src/system/EventDispatcher.ts +26 -10
  47. package/src/system/ParticleSpawner.ts +96 -19
  48. package/src/system/geometry.ts +22 -29
  49. package/src/system/integrate.ts +61 -4
  50. package/src/system/materialResolve.ts +56 -7
  51. package/src/system/normalize.ts +34 -2
  52. package/src/system/serialization.ts +54 -33
  53. package/src/system/settingsSchema.ts +57 -17
  54. package/src/system/types.ts +150 -42
  55. package/src/trailSettings.ts +9 -6
@@ -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
+ }
@@ -4,7 +4,8 @@
4
4
  * The fragment reads the scene depth behind it from `depthTexture`, turns it into view depth with
5
5
  * the coefficients in `projParams` (see `softDepthParams`), and fades out over the distance in
6
6
  * `softParams` as it nears the scene, as Unity's soft particles and Unreal's Depth Fade do. The
7
- * fade runs last, after fog, so both languages produce the same colour.
7
+ * fade lowers alpha only and runs after fog and before the blend resolve (`blend.ts`), so it reads
8
+ * as coverage in every blend mode and both languages produce the same colour.
8
9
  *
9
10
  * `SOFT_DEPTH_PACKED` reads a depth packed into four 8-bit channels, as a Babylon
10
11
  * `DepthRenderer` of type `UNSIGNED_BYTE` writes it.
@@ -33,14 +34,14 @@ float quarksSceneDepth(vec4 texel) {
33
34
  #endif
34
35
  `;
35
36
 
36
- /** Fades `target` as the fragment nears the scene behind it, GLSL. */
37
+ /** Fades the alpha of the vec4 lvalue `target` as the fragment nears the scene behind it, GLSL. */
37
38
  export function softFragmentGLSL(target: string): string {
38
39
  return /* glsl */ `
39
40
  #ifdef SOFT_PARTICLES
40
41
  {
41
42
  vec2 softUV = 0.5 * (projPosition.xy / projPosition.w) + 0.5;
42
43
  float sceneDepth = quarksSceneDepth(texture2D(depthTexture, softUV));
43
- ${target} *= clamp(softParams.y * ((sceneDepth - softParams.x) - linearDepth), 0.0, 1.0);
44
+ ${target}.a *= clamp(softParams.y * ((sceneDepth - softParams.x) - linearDepth), 0.0, 1.0);
44
45
  }
45
46
  #endif
46
47
  `;
@@ -70,14 +71,14 @@ fn quarksSceneDepth(texel: vec4f) -> f32 {
70
71
  #endif
71
72
  `;
72
73
 
73
- /** Fades `target` as the fragment nears the scene behind it, WGSL. */
74
+ /** Fades the alpha of the `var` vec4 `target` as the fragment nears the scene behind it, WGSL. */
74
75
  export function softFragmentWGSL(target: string): string {
75
76
  return /* wgsl */ `
76
77
  #ifdef SOFT_PARTICLES
77
78
  {
78
79
  let softUV = 0.5 * (fragmentInputs.projPosition.xy / fragmentInputs.projPosition.w) + 0.5;
79
80
  let sceneDepth = quarksSceneDepth(textureSample(depthTexture, depthTextureSampler, softUV));
80
- ${target} *= clamp(uniforms.softParams.y * ((sceneDepth - uniforms.softParams.x) - fragmentInputs.linearDepth), 0.0, 1.0);
81
+ ${target}.a *= clamp(uniforms.softParams.y * ((sceneDepth - uniforms.softParams.x) - fragmentInputs.linearDepth), 0.0, 1.0);
81
82
  }
82
83
  #endif
83
84
  `;
@@ -35,8 +35,8 @@
35
35
  * on the way down the shape eats away from its lowest mask values first — a
36
36
  * noise mask dissolves it, a gradient mask wipes it. `erosionParams` is
37
37
  * (softness, edge width); the edge band just above the cut glows in
38
- * `erosionEdgeColor`. Erosion only changes alpha, so blend modes that
39
- * ignore alpha (one-one, premultiplied) show a hard cut.
38
+ * `erosionEdgeColor`. Erosion only changes alpha, which the blend resolve
39
+ * (`blend.ts`) turns into coverage in every blend mode.
40
40
  */
41
41
 
42
42
  /** Fragment-side declarations, GLSL. */
@@ -1,4 +1,6 @@
1
+ import {blendFragmentGLSL} from './chunks/blend';
1
2
  import {fogFragmentDeclarationGLSL, fogFragmentGLSL} from './chunks/fog';
3
+ import {imageProcessingDeclarationGLSL} from './chunks/imageProcessing';
2
4
  import {softFragmentDeclarationGLSL, softFragmentGLSL} from './chunks/soft';
3
5
  import {surfaceColorGLSL, surfaceFragmentDeclarationGLSL} from './chunks/surface';
4
6
 
@@ -24,6 +26,7 @@ uniform float alphaTest;
24
26
  #endif
25
27
 
26
28
  ${fogFragmentDeclarationGLSL}
29
+ ${imageProcessingDeclarationGLSL}
27
30
  ${softFragmentDeclarationGLSL}
28
31
  ${surfaceFragmentDeclarationGLSL}
29
32
  void main() {
@@ -35,7 +38,8 @@ ${surfaceColorGLSL(true)}
35
38
  #endif
36
39
 
37
40
  ${fogFragmentGLSL('baseColor.rgb')}
41
+ ${softFragmentGLSL('baseColor')}
42
+ ${blendFragmentGLSL('baseColor')}
38
43
  gl_FragColor = baseColor;
39
- ${softFragmentGLSL('gl_FragColor')}
40
44
  }
41
45
  `;
@@ -1,4 +1,6 @@
1
+ import {blendFragmentWGSL} from './chunks/blend';
1
2
  import {fogFragmentDeclarationWGSL, fogFragmentWGSL} from './chunks/fog';
3
+ import {imageProcessingDeclarationWGSL} from './chunks/imageProcessing';
2
4
  import {softFragmentDeclarationWGSL, softFragmentWGSL} from './chunks/soft';
3
5
  import {surfaceColorWGSL, surfaceFragmentDeclarationWGSL} from './chunks/surface';
4
6
 
@@ -25,6 +27,7 @@ uniform alphaTest: f32;
25
27
  #endif
26
28
 
27
29
  ${fogFragmentDeclarationWGSL}
30
+ ${imageProcessingDeclarationWGSL}
28
31
  ${softFragmentDeclarationWGSL}
29
32
  ${surfaceFragmentDeclarationWGSL}
30
33
  @fragment
@@ -38,6 +41,7 @@ ${surfaceColorWGSL(true)}
38
41
 
39
42
  ${fogFragmentWGSL('baseColor')}
40
43
  ${softFragmentWGSL('baseColor')}
44
+ ${blendFragmentWGSL('baseColor')}
41
45
  fragmentOutputs.color = baseColor;
42
46
  }
43
47
  `;
@@ -1,4 +1,6 @@
1
+ import {blendFragmentGLSL} from './chunks/blend';
1
2
  import {fogFragmentDeclarationGLSL, fogFragmentGLSL} from './chunks/fog';
3
+ import {imageProcessingDeclarationGLSL} from './chunks/imageProcessing';
2
4
  import {softFragmentDeclarationGLSL, softFragmentGLSL} from './chunks/soft';
3
5
  import {surfaceColorGLSL, surfaceFragmentDeclarationGLSL} from './chunks/surface';
4
6
 
@@ -86,6 +88,7 @@ uniform float alphaTest;
86
88
  #endif
87
89
 
88
90
  ${fogFragmentDeclarationGLSL}
91
+ ${imageProcessingDeclarationGLSL}
89
92
  ${softFragmentDeclarationGLSL}
90
93
  ${surfaceFragmentDeclarationGLSL}
91
94
  void main() {
@@ -130,7 +133,9 @@ ${surfaceColorGLSL(true)}
130
133
  #endif
131
134
 
132
135
  ${fogFragmentGLSL('color')}
133
- gl_FragColor = vec4(color, baseColor.a);
134
- ${softFragmentGLSL('gl_FragColor')}
136
+ vec4 finalColor = vec4(color, baseColor.a);
137
+ ${softFragmentGLSL('finalColor')}
138
+ ${blendFragmentGLSL('finalColor')}
139
+ gl_FragColor = finalColor;
135
140
  }
136
141
  `;
@@ -1,4 +1,6 @@
1
+ import {blendFragmentWGSL} from './chunks/blend';
1
2
  import {fogFragmentDeclarationWGSL, fogFragmentWGSL} from './chunks/fog';
3
+ import {imageProcessingDeclarationWGSL} from './chunks/imageProcessing';
2
4
  import {softFragmentDeclarationWGSL, softFragmentWGSL} from './chunks/soft';
3
5
  import {surfaceColorWGSL, surfaceFragmentDeclarationWGSL} from './chunks/surface';
4
6
 
@@ -85,6 +87,7 @@ uniform alphaTest: f32;
85
87
  #endif
86
88
 
87
89
  ${fogFragmentDeclarationWGSL}
90
+ ${imageProcessingDeclarationWGSL}
88
91
  ${softFragmentDeclarationWGSL}
89
92
  ${surfaceFragmentDeclarationWGSL}
90
93
  @fragment
@@ -133,6 +136,7 @@ ${surfaceColorWGSL(true)}
133
136
 
134
137
  ${fogFragmentWGSL('finalColor')}
135
138
  ${softFragmentWGSL('finalColor')}
139
+ ${blendFragmentWGSL('finalColor')}
136
140
  fragmentOutputs.color = finalColor;
137
141
  }
138
142
  `;
@@ -1,4 +1,6 @@
1
+ import {blendFragmentGLSL} from './chunks/blend';
1
2
  import {fogFragmentDeclarationGLSL, fogFragmentGLSL} from './chunks/fog';
3
+ import {imageProcessingDeclarationGLSL} from './chunks/imageProcessing';
2
4
  import {softFragmentDeclarationGLSL, softFragmentGLSL} from './chunks/soft';
3
5
  import {surfaceColorGLSL, surfaceFragmentDeclarationGLSL} from './chunks/surface';
4
6
 
@@ -18,6 +20,7 @@ uniform float alphaTest;
18
20
  #endif
19
21
 
20
22
  ${fogFragmentDeclarationGLSL}
23
+ ${imageProcessingDeclarationGLSL}
21
24
  ${softFragmentDeclarationGLSL}
22
25
  ${surfaceFragmentDeclarationGLSL}
23
26
  void main() {
@@ -29,6 +32,8 @@ ${surfaceColorGLSL(false)}
29
32
  #endif
30
33
 
31
34
  ${fogFragmentGLSL('baseColor.rgb')}
35
+ ${softFragmentGLSL('baseColor')}
36
+ ${blendFragmentGLSL('baseColor')}
32
37
  gl_FragColor = baseColor;
33
- ${softFragmentGLSL('gl_FragColor')}}
38
+ }
34
39
  `;
@@ -1,4 +1,6 @@
1
+ import {blendFragmentWGSL} from './chunks/blend';
1
2
  import {fogFragmentDeclarationWGSL, fogFragmentWGSL} from './chunks/fog';
3
+ import {imageProcessingDeclarationWGSL} from './chunks/imageProcessing';
2
4
  import {softFragmentDeclarationWGSL, softFragmentWGSL} from './chunks/soft';
3
5
  import {surfaceColorWGSL, surfaceFragmentDeclarationWGSL} from './chunks/surface';
4
6
 
@@ -19,6 +21,7 @@ uniform alphaTest: f32;
19
21
  #endif
20
22
 
21
23
  ${fogFragmentDeclarationWGSL}
24
+ ${imageProcessingDeclarationWGSL}
22
25
  ${softFragmentDeclarationWGSL}
23
26
  ${surfaceFragmentDeclarationWGSL}
24
27
  @fragment
@@ -32,6 +35,7 @@ ${surfaceColorWGSL(false)}
32
35
 
33
36
  ${fogFragmentWGSL('baseColor')}
34
37
  ${softFragmentWGSL('baseColor')}
38
+ ${blendFragmentWGSL('baseColor')}
35
39
  fragmentOutputs.color = baseColor;
36
40
  }
37
41
  `;
@@ -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++) {
@@ -167,10 +177,8 @@ export class EmissionController {
167
177
  if (emissionState.burstWaves !== undefined) {
168
178
  emissionState.burstWaves.length = 0;
169
179
  }
170
- const behaviors = system.behaviors;
171
- for (let i = 0; i < behaviors.length; i++) {
172
- behaviors[i].reset();
173
- }
180
+ // Behaviors carry on across the loop (a noise field keeps scrolling, sub emissions
181
+ // of live particles keep going); they reset only when the system restarts.
174
182
  } else {
175
183
  if (!system.isEmitEnded && !system.onlyUsedByOther) {
176
184
  system.endEmit();
@@ -178,11 +186,6 @@ export class EmissionController {
178
186
  }
179
187
  }
180
188
 
181
- // Only spawns in world space consume the normal matrix, and computing it
182
- // inverts a 4x4 — skip it for local-space systems.
183
- if (system.worldSpace) {
184
- this.spawner.normalMatrix.getNormalMatrix(emitterMatrix);
185
- }
186
189
  const qualityFactor = system.emissionFactor();
187
190
 
188
191
  if (system.emissionBursts.length > 0 && !endedBefore) {
@@ -2,26 +2,41 @@ import type {ParticleSystemEvent, ParticleSystemEventType} from 'quarks.core';
2
2
 
3
3
  type Listener = (event: ParticleSystemEvent) => void;
4
4
 
5
- /** The callbacks registered on a {@link ParticleSystem}, by event type. */
5
+ /**
6
+ * The callbacks registered on a {@link ParticleSystem}, by event type.
7
+ *
8
+ * The lists are copied on write, not on fire: adding or removing a callback while an event is
9
+ * being fired (a listener removing itself, say) changes the next firing and never the one in
10
+ * progress, so every callback registered when the event fired is called once.
11
+ */
6
12
  export class EventDispatcher {
7
- private readonly listeners: {[event: string]: Listener[]} = {};
13
+ private readonly listeners: {[event: string]: readonly Listener[]} = {};
8
14
 
9
15
  /** Registers `callback` for `event`. */
10
16
  add(event: ParticleSystemEventType, callback: Listener): void {
11
- if (!this.listeners[event]) this.listeners[event] = [];
12
- this.listeners[event].push(callback);
17
+ this.listeners[event] = [...(this.listeners[event] ?? []), callback];
13
18
  }
14
19
 
15
- /** Drops every callback for `event`. */
16
- removeAll(event: ParticleSystemEventType): void {
17
- if (this.listeners[event]) this.listeners[event] = [];
20
+ /** Drops every callback for `event`, or for every event when it is left out. */
21
+ removeAll(event?: ParticleSystemEventType): void {
22
+ if (event === undefined) {
23
+ for (const key of Object.keys(this.listeners)) {
24
+ delete this.listeners[key];
25
+ }
26
+ return;
27
+ }
28
+ delete this.listeners[event];
18
29
  }
19
30
 
20
31
  /** Drops one callback for `event`. */
21
32
  remove(event: ParticleSystemEventType, callback: Listener): void {
22
- if (this.listeners[event]) {
23
- const index = this.listeners[event].indexOf(callback);
24
- if (index !== -1) this.listeners[event].splice(index, 1);
33
+ const callbacks = this.listeners[event];
34
+ if (callbacks === undefined) {
35
+ return;
36
+ }
37
+ const index = callbacks.indexOf(callback);
38
+ if (index !== -1) {
39
+ this.listeners[event] = callbacks.filter((_, i) => i !== index);
25
40
  }
26
41
  }
27
42
 
@@ -33,6 +48,7 @@ export class EventDispatcher {
33
48
 
34
49
  /** Calls the callbacks registered for the event's type, in the order they were added. */
35
50
  fire(event: ParticleSystemEvent): void {
51
+ // The list as it is now: a callback added or removed by a listener does not shift it.
36
52
  const callbacks = this.listeners[event.type];
37
53
  if (callbacks === undefined) {
38
54
  return;
@@ -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
  }