babylon.quarks 0.23.0 → 0.24.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 (35) hide show
  1. package/CHANGELOG.md +101 -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.ts +529 -20
  9. package/package.json +2 -2
  10. package/src/BatchedRenderer.ts +146 -2
  11. package/src/EffectPool.ts +165 -0
  12. package/src/MeshSurfaceEmitter.ts +4 -4
  13. package/src/ParticleEmitter.ts +2 -1
  14. package/src/ParticleSystem.ts +592 -15
  15. package/src/QuarksUtil.ts +55 -7
  16. package/src/SpriteBatch.ts +127 -3
  17. package/src/TrailBatch.ts +49 -12
  18. package/src/VFXBatch.ts +154 -3
  19. package/src/index.ts +4 -1
  20. package/src/instanceSort.ts +121 -0
  21. package/src/shaders/chunks/fog.ts +127 -0
  22. package/src/shaders/local_particle_physics_vert.glsl.ts +4 -0
  23. package/src/shaders/local_particle_physics_vert.wgsl.ts +4 -0
  24. package/src/shaders/particle_frag.glsl.ts +4 -0
  25. package/src/shaders/particle_frag.wgsl.ts +4 -0
  26. package/src/shaders/particle_physics_frag.glsl.ts +4 -0
  27. package/src/shaders/particle_physics_frag.wgsl.ts +4 -0
  28. package/src/shaders/particle_vert.glsl.ts +23 -4
  29. package/src/shaders/particle_vert.wgsl.ts +23 -4
  30. package/src/shaders/stretched_bb_particle_vert.glsl.ts +17 -0
  31. package/src/shaders/stretched_bb_particle_vert.wgsl.ts +18 -1
  32. package/src/shaders/trail_frag.glsl.ts +4 -0
  33. package/src/shaders/trail_frag.wgsl.ts +4 -0
  34. package/src/shaders/trail_vert.glsl.ts +4 -0
  35. package/src/shaders/trail_vert.wgsl.ts +4 -0
package/src/QuarksUtil.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
1
2
  import {Node} from '@babylonjs/core/node';
3
+ import {EmitSubParticleSystem} from 'quarks.core';
2
4
  import {BatchedRenderer} from './BatchedRenderer';
3
5
  import {ParticleEmitter} from './ParticleEmitter';
4
6
  import {ParticleSystem} from './ParticleSystem';
@@ -54,24 +56,70 @@ export class QuarksUtil {
54
56
  });
55
57
  }
56
58
 
57
- /** True when every emitter under `root` is a finished non-looping system (or looping). */
59
+ /**
60
+ * True when every system under `root` is finished: non-looping ones that
61
+ * ran their course, looping ones whose emission was ended (see
62
+ * {@link endEmit}) and whose particles are gone. A looping system still
63
+ * emitting keeps the effect unfinished.
64
+ */
58
65
  static isEffectFinished(root: Node): boolean {
59
66
  let found = false;
60
67
  let finished = true;
61
68
  QuarksUtil.runOnAllParticleEmitters(root, (emitter) => {
62
69
  found = true;
63
- const system = emitter.system as unknown as ParticleSystem;
64
- if (system.looping) {
65
- finished = false;
66
- return;
67
- }
68
- if (!system.isFinished()) {
70
+ if (!(emitter.system as unknown as ParticleSystem).isFinished()) {
69
71
  finished = false;
70
72
  }
71
73
  });
72
74
  return found && finished;
73
75
  }
74
76
 
77
+ /**
78
+ * A deep copy of an effect: the node tree under `root` with every particle
79
+ * system cloned, sub emitter links pointing at the copies, and the same
80
+ * transforms and enabled state. The copy is not added to any renderer.
81
+ * Nodes that are neither transform nodes nor emitters are skipped.
82
+ */
83
+ static instantiate(root: TransformNode, name?: string): TransformNode {
84
+ const copies = new Map<ParticleEmitter, ParticleEmitter>();
85
+ const copyNode = (node: Node, parent: TransformNode | null, copyName?: string): TransformNode | null => {
86
+ let copy: TransformNode;
87
+ if (node instanceof ParticleEmitter) {
88
+ copy = node.clone(copyName);
89
+ copies.set(node, copy as ParticleEmitter);
90
+ } else if (node instanceof TransformNode) {
91
+ copy = new TransformNode(copyName ?? node.name, node.getScene());
92
+ copy.position.copyFrom(node.position);
93
+ copy.rotation.copyFrom(node.rotation);
94
+ copy.scaling.copyFrom(node.scaling);
95
+ if (node.rotationQuaternion) {
96
+ copy.rotationQuaternion = node.rotationQuaternion.clone();
97
+ }
98
+ copy.setEnabled(node.isEnabled(false));
99
+ } else {
100
+ return null;
101
+ }
102
+ copy.parent = parent;
103
+ for (const child of node.getChildren()) {
104
+ copyNode(child, copy);
105
+ }
106
+ return copy;
107
+ };
108
+ const copy = copyNode(root, null, name) as TransformNode;
109
+ // Sub emitter behaviors on the copies still target the original emitters.
110
+ for (const emitter of copies.values()) {
111
+ for (const behavior of (emitter.system as unknown as ParticleSystem).behaviors) {
112
+ if (behavior instanceof EmitSubParticleSystem) {
113
+ const target = copies.get(behavior.subParticleSystem as unknown as ParticleEmitter);
114
+ if (target !== undefined) {
115
+ behavior.subParticleSystem = target as unknown as typeof behavior.subParticleSystem;
116
+ }
117
+ }
118
+ }
119
+ }
120
+ return copy;
121
+ }
122
+
75
123
  private static traverseNode(node: Node, callback: (node: Node) => void): void {
76
124
  callback(node);
77
125
  const children = node.getChildren();
@@ -17,6 +17,7 @@ import {
17
17
  Vector3,
18
18
  } from 'quarks.core';
19
19
  import {VFXBatchSettings} from './BatchedRenderer';
20
+ import {gather, sortByKey, SortScratch} from './instanceSort';
20
21
  import local_particle_physics_vert from './shaders/local_particle_physics_vert.glsl';
21
22
  import local_particle_physics_vert_wgsl from './shaders/local_particle_physics_vert.wgsl';
22
23
  import particle_frag from './shaders/particle_frag.glsl';
@@ -75,7 +76,7 @@ export class SpriteBatch extends VFXBatch {
75
76
  setupBuffers(): void {
76
77
  this.mesh.dispose();
77
78
  this.mesh = new Mesh('spriteBatch', this.scene);
78
- this.mesh.alwaysSelectAsActiveMesh = true;
79
+ this.configureMesh();
79
80
 
80
81
  const vertexData = new VertexData();
81
82
  vertexData.positions = this.settings.instancingGeometry;
@@ -153,6 +154,113 @@ export class SpriteBatch extends VFXBatch {
153
154
  }
154
155
  this.disposeExpandableBuffers();
155
156
  this.buildExpandableBuffers();
157
+ this.sortScratch = undefined;
158
+ this.gatherScratch.length = 0;
159
+ }
160
+
161
+ /** The screen-size limits as a uniform value, or null when the batch has none that apply. */
162
+ private sizeLimits(): BVector2 | null {
163
+ const {minParticleSize, maxParticleSize} = this.settings;
164
+ if (!SpriteBatch.takesPivotAndFlip(this.settings.renderMode)) {
165
+ return null;
166
+ }
167
+ if (minParticleSize <= 0 && maxParticleSize === Infinity) {
168
+ return null;
169
+ }
170
+ // The shader clamps; an unlimited maximum is just a very large one.
171
+ return new BVector2(Math.max(0, minParticleSize), Math.min(maxParticleSize, 1e9));
172
+ }
173
+
174
+ private sortScratch?: SortScratch;
175
+ /** One spare array per attribute buffer, gathered into and swapped in; indexed by buffer slot. */
176
+ private readonly gatherScratch: Float32Array[] = [];
177
+
178
+ /**
179
+ * Reorders the `count` filled instances by the batch's sort mode, so that
180
+ * the later (covering) draws are the nearer or the chosen-age particles.
181
+ * The order is computed once as a permutation and every attribute buffer
182
+ * is gathered through it into a spare array that then takes its place.
183
+ */
184
+ private sortInstances(count: number, isMeshRender: boolean, isStretchedRender: boolean): void {
185
+ if (count < 2) {
186
+ return;
187
+ }
188
+ const scratch = (this.sortScratch ??= new SortScratch(this.maxParticles));
189
+ scratch.ensure(count);
190
+ const keys = scratch.keys;
191
+ const mode = this.settings.sortMode;
192
+ if (mode === 'distance') {
193
+ const camera = this.scene.activeCameras?.[0] ?? this.scene.activeCamera;
194
+ if (!camera) {
195
+ return;
196
+ }
197
+ const eye = camera.globalPosition;
198
+ const ex = eye.x;
199
+ const ey = eye.y;
200
+ const ez = eye.z;
201
+ const offsets = this.offsetBuffer;
202
+ // Farthest first: the nearer particle is drawn last and covers.
203
+ for (let i = 0, o = 0; i < count; i++, o += 3) {
204
+ const dx = offsets[o] - ex;
205
+ const dy = offsets[o + 1] - ey;
206
+ const dz = offsets[o + 2] - ez;
207
+ keys[i] = scratch.floatKey(dx * dx + dy * dy + dz * dz, true);
208
+ }
209
+ } else {
210
+ // Ages in the order the systems were filled, which is the instance order.
211
+ const youngestInFront = mode === 'youngestInFront';
212
+ const systems = this.visibleSystems;
213
+ let i = 0;
214
+ for (let s = 0; s < systems.length; s++) {
215
+ const system = systems[s];
216
+ const particleNum = system.particleNum;
217
+ const store = system.store;
218
+ if (particleNum === 0) {
219
+ continue;
220
+ }
221
+ if (store === undefined) {
222
+ const particles = system.particles;
223
+ for (let j = 0; j < particleNum; j++) {
224
+ keys[i++] = scratch.floatKey(particles[j].age, youngestInFront);
225
+ }
226
+ continue;
227
+ }
228
+ const scalars = store.scalars;
229
+ const stride = ParticleStore.SCALAR_STRIDE;
230
+ for (let j = 0, a = ParticleStore.AGE; j < particleNum; j++, a += stride) {
231
+ keys[i++] = scratch.floatKey(scalars[a], youngestInFront);
232
+ }
233
+ }
234
+ if (i !== count) {
235
+ return;
236
+ }
237
+ }
238
+ const order = sortByKey(scratch, count);
239
+ this.offsetBuffer = this.gatherInto(0, this.offsetBuffer, order, count, 3);
240
+ this.sizeBuffer = this.gatherInto(1, this.sizeBuffer, order, count, 3);
241
+ this.colorBuffer = this.gatherInto(2, this.colorBuffer, order, count, 4);
242
+ this.uvTileBuffer = this.gatherInto(3, this.uvTileBuffer, order, count, 1);
243
+ this.rotationBuffer = this.gatherInto(4, this.rotationBuffer, order, count, isMeshRender ? 4 : 1);
244
+ if (isStretchedRender && this.velocityBuffer) {
245
+ this.velocityBuffer = this.gatherInto(5, this.velocityBuffer, order, count, 4);
246
+ }
247
+ }
248
+
249
+ /** Gathers `source` in `order` into the spare array of `slot` and returns it; the source becomes the spare. */
250
+ private gatherInto(
251
+ slot: number,
252
+ source: Float32Array,
253
+ order: Uint32Array,
254
+ count: number,
255
+ stride: number
256
+ ): Float32Array {
257
+ let spare = this.gatherScratch[slot];
258
+ if (spare === undefined || spare.length !== source.length) {
259
+ spare = new Float32Array(source.length);
260
+ }
261
+ gather(source, spare, order, count, stride);
262
+ this.gatherScratch[slot] = source;
263
+ return spare;
156
264
  }
157
265
 
158
266
  rebuildMaterial(): void {
@@ -161,7 +269,7 @@ export class SpriteBatch extends VFXBatch {
161
269
  // Effect.ShadersStore and defeat Babylon's compiled-effect cache.
162
270
  const shaderName = `quarksParticle_${this.settings.renderMode}`;
163
271
  this.lastStretchedSpeedFactor = Number.NaN;
164
- const defines: string[] = [];
272
+ const defines: string[] = this.fogDefines();
165
273
 
166
274
  let vertexShader: ShaderSources;
167
275
  let fragmentShader: ShaderSources;
@@ -218,6 +326,10 @@ export class SpriteBatch extends VFXBatch {
218
326
  if (pivot) {
219
327
  defines.push('PIVOT');
220
328
  }
329
+ const sizeLimits = this.sizeLimits();
330
+ if (sizeLimits) {
331
+ defines.push('SIZE_LIMITS');
332
+ }
221
333
  const isMesh = this.settings.renderMode === RenderMode.Mesh;
222
334
  const lighting = this.settings.lighting ?? 'fixed';
223
335
  const lit = isMesh && lighting !== 'unlit';
@@ -244,7 +356,7 @@ export class SpriteBatch extends VFXBatch {
244
356
  attributes.push('velocity');
245
357
  }
246
358
 
247
- const uniforms = ['world', 'view', 'projection', 'worldView', 'worldViewProjection'];
359
+ const uniforms = ['world', 'view', 'projection', 'worldView', 'worldViewProjection', ...VFXBatch.FOG_UNIFORMS];
248
360
  const samplers: string[] = [];
249
361
 
250
362
  if (this.settings.uTileCount > 1 || this.settings.vTileCount > 1) {
@@ -279,6 +391,9 @@ export class SpriteBatch extends VFXBatch {
279
391
  if (pivot) {
280
392
  uniforms.push('pivot');
281
393
  }
394
+ if (sizeLimits) {
395
+ uniforms.push('sizeLimits');
396
+ }
282
397
  if (lit) {
283
398
  uniforms.push('lightDirection');
284
399
  uniforms.push('lightColor');
@@ -354,6 +469,9 @@ export class SpriteBatch extends VFXBatch {
354
469
  if (pivot) {
355
470
  mat.setVector3('pivot', new BVector3(pivot.x, pivot.y, pivot.z));
356
471
  }
472
+ if (sizeLimits) {
473
+ mat.setVector2('sizeLimits', sizeLimits);
474
+ }
357
475
  if (lit) {
358
476
  this.writeFixedLighting(mat);
359
477
  if (lighting === 'scene') {
@@ -372,6 +490,7 @@ export class SpriteBatch extends VFXBatch {
372
490
  }
373
491
  }
374
492
 
493
+ this.bindFog(mat);
375
494
  mat.backFaceCulling = false;
376
495
 
377
496
  if (this.settings.materialTransparent) {
@@ -801,6 +920,7 @@ export class SpriteBatch extends VFXBatch {
801
920
  // Keep the mesh enabled even when count is 0 — toggling visibility off for a
802
921
  // single frame (common with short lifetimes + low emission overlap) causes flicker.
803
922
  this.mesh.forcedInstanceCount = index;
923
+ this.refreshBoundingInfo();
804
924
 
805
925
  if (index === 0) {
806
926
  // Slot 0 only needs clearing once per stretch of empty frames; re-uploading it every
@@ -813,6 +933,10 @@ export class SpriteBatch extends VFXBatch {
813
933
  }
814
934
  this.clearedInstanceUploaded = false;
815
935
 
936
+ if (this.settings.sortMode !== 'none') {
937
+ this.sortInstances(index, isMeshRender, isStretchedRender);
938
+ }
939
+
816
940
  // Upload only the slots actually filled this frame: the buffers are sized
817
941
  // for the high-water mark and are usually far larger than the live count.
818
942
  this.offsetVB.update(this.offsetBuffer.subarray(0, index * 3));
package/src/TrailBatch.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import {VertexBuffer} from '@babylonjs/core/Buffers/buffer';
2
- import {BoundingInfo} from '@babylonjs/core/Culling/boundingInfo';
3
2
  import {Constants} from '@babylonjs/core/Engines/constants';
4
3
  import {ShaderMaterial} from '@babylonjs/core/Materials/shaderMaterial';
5
- import {Vector2 as BVector2, Vector3 as BVector3} from '@babylonjs/core/Maths/math.vector';
4
+ import {Vector2 as BVector2} from '@babylonjs/core/Maths/math.vector';
6
5
  import {Mesh} from '@babylonjs/core/Meshes/mesh';
7
6
  import {Scene} from '@babylonjs/core/scene';
8
7
  import {IParticleSystem, Matrix4, Quaternion, TrailParticle, TrailSettings, Vector3} from 'quarks.core';
9
8
  import {VFXBatchSettings} from './BatchedRenderer';
9
+ import type {ParticleSystem} from './ParticleSystem';
10
10
  import {registerShaders, shaderLanguageFor} from './shaders/shaderLanguageSupport';
11
11
  import trail_frag from './shaders/trail_frag.glsl';
12
12
  import trail_frag_wgsl from './shaders/trail_frag.wgsl';
@@ -43,7 +43,7 @@ export class TrailBatch extends VFXBatch {
43
43
  setupBuffers(): void {
44
44
  this.mesh.dispose();
45
45
  this.mesh = new Mesh('trailBatch', this.scene);
46
- this.mesh.alwaysSelectAsActiveMesh = true;
46
+ this.configureMesh();
47
47
  this.buildGeometryBuffers();
48
48
  }
49
49
 
@@ -110,15 +110,10 @@ export class TrailBatch extends VFXBatch {
110
110
  this.mesh.setVerticesBuffer(this.sideVB);
111
111
  this.mesh.setVerticesBuffer(this.widthVB);
112
112
 
113
- // Disable bounding info recomputation
114
- this.mesh.doNotSyncBoundingInfo = true;
115
- const min = new BVector3(-10000, -10000, -10000);
116
- const max = new BVector3(10000, 10000, 10000);
117
- this.mesh.setBoundingInfo(new BoundingInfo(min, max));
113
+ // The bounds come from the ribbons each fill writes (configureMesh set
114
+ // the placeholder); the sub meshes have to share them.
118
115
  if (this.mesh.subMeshes) {
119
- for (const sub of this.mesh.subMeshes) {
120
- (sub as any)._boundingInfo = this.mesh.getBoundingInfo();
121
- }
116
+ this.adoptMeshBounds();
122
117
  }
123
118
  }
124
119
 
@@ -139,7 +134,7 @@ export class TrailBatch extends VFXBatch {
139
134
  // Stable name: the trail shader source never varies, so registering it once
140
135
  // keeps Effect.ShadersStore bounded and lets Babylon reuse compiled effects.
141
136
  const shaderName = 'quarksTrail';
142
- const defines: string[] = [];
137
+ const defines: string[] = this.fogDefines();
143
138
 
144
139
  if (this.settings.texture) {
145
140
  defines.push('USE_MAP');
@@ -162,6 +157,7 @@ export class TrailBatch extends VFXBatch {
162
157
  'lineWidth',
163
158
  'resolution',
164
159
  'sizeAttenuation',
160
+ ...VFXBatch.FOG_UNIFORMS,
165
161
  ];
166
162
  const samplers: string[] = [];
167
163
 
@@ -203,6 +199,7 @@ export class TrailBatch extends VFXBatch {
203
199
  }
204
200
  });
205
201
 
202
+ this.bindFog(mat);
206
203
  mat.backFaceCulling = false;
207
204
 
208
205
  if (this.settings.materialTransparent) {
@@ -266,6 +263,7 @@ export class TrailBatch extends VFXBatch {
266
263
 
267
264
  for (let s = 0; s < visibleSystems.length; s++) {
268
265
  const system = visibleSystems[s];
266
+ const firstVertex = index;
269
267
  TrailBatch.ensureWorldMatrix(system);
270
268
  const rotation = this.quaternion_;
271
269
  const translation = this.vector2_;
@@ -554,11 +552,50 @@ export class TrailBatch extends VFXBatch {
554
552
  index += 2;
555
553
  }
556
554
  }
555
+ this.measureSystemBounds(system as ParticleSystem, firstVertex, index);
557
556
  }
558
557
 
558
+ this.refreshBoundingInfo();
559
559
  this.uploadGeometry(index, triangles);
560
560
  }
561
561
 
562
+ /**
563
+ * Records a system's world bounds from the ribbon vertices just written for
564
+ * it, padded by the widest ribbon. The store only holds each trail's head,
565
+ * so this is where a trail's extent is known.
566
+ */
567
+ private measureSystemBounds(system: ParticleSystem, fromVertex: number, toVertex: number): void {
568
+ if (toVertex <= fromVertex || typeof system.setWorldBounds !== 'function') {
569
+ return;
570
+ }
571
+ const positions = this.positionBuffer;
572
+ let minX = Infinity;
573
+ let minY = Infinity;
574
+ let minZ = Infinity;
575
+ let maxX = -Infinity;
576
+ let maxY = -Infinity;
577
+ let maxZ = -Infinity;
578
+ // Each sample writes its position twice (both ribbon edges); one of the pair is enough.
579
+ for (let o = fromVertex * 3, end = toVertex * 3; o < end; o += 6) {
580
+ const x = positions[o];
581
+ const y = positions[o + 1];
582
+ const z = positions[o + 2];
583
+ if (x < minX) minX = x;
584
+ if (x > maxX) maxX = x;
585
+ if (y < minY) minY = y;
586
+ if (y > maxY) maxY = y;
587
+ if (z < minZ) minZ = z;
588
+ if (z > maxZ) maxZ = z;
589
+ }
590
+ const widths = this.widthBuffer;
591
+ let maxWidth = 0;
592
+ for (let v = fromVertex; v < toVertex; v += 2) {
593
+ const w = widths[v];
594
+ if (w > maxWidth) maxWidth = w;
595
+ }
596
+ system.setWorldBounds(minX, minY, minZ, maxX, maxY, maxZ, maxWidth);
597
+ }
598
+
562
599
  /**
563
600
  * Uploads the vertex data written by `update` and resizes the sub mesh.
564
601
  *
package/src/VFXBatch.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import {BoundingInfo} from '@babylonjs/core/Culling/boundingInfo';
2
+ import {Constants} from '@babylonjs/core/Engines/constants';
1
3
  import {BaseTexture} from '@babylonjs/core/Materials/Textures/baseTexture';
2
4
  import {ShaderMaterial} from '@babylonjs/core/Materials/shaderMaterial';
3
5
  import {Color3} from '@babylonjs/core/Maths/math.color';
@@ -6,6 +8,20 @@ import {Mesh} from '@babylonjs/core/Meshes/mesh';
6
8
  import {Scene} from '@babylonjs/core/scene';
7
9
  import {IParticleSystem, Vector3} from 'quarks.core';
8
10
  import {VFXBatchSettings} from './BatchedRenderer';
11
+ import type {ParticleSystem} from './ParticleSystem';
12
+
13
+ /**
14
+ * The order the particles of a batch are drawn in (Unity's Sort Mode). Alpha
15
+ * blended particles cover what was drawn before them, so the order shows.
16
+ *
17
+ * - `'none'`: as they are, system by system. Right for additive blending.
18
+ * - `'distance'`: farthest from the active camera first, so nearer particles
19
+ * cover farther ones.
20
+ * - `'oldestInFront'` / `'youngestInFront'`: by age.
21
+ *
22
+ * Sorting runs over the whole batch after each fill. Trails are not sorted.
23
+ */
24
+ export type SortMode = 'none' | 'distance' | 'oldestInFront' | 'youngestInFront';
9
25
 
10
26
  export enum RenderMode {
11
27
  BillBoard = 0,
@@ -130,8 +146,18 @@ export interface StoredBatchSettings {
130
146
  reflectionFaces: BaseTexture[] | null;
131
147
  reflectionAtlas: BaseTexture | null;
132
148
  layerMask: number;
149
+ /** Babylon rendering group the batch mesh draws in, see {@link VFXBatchSettings.renderingGroupId}. */
150
+ renderingGroupId: number;
151
+ /** Whether the scene's fog is applied, see {@link VFXBatchSettings.applyFog}. */
152
+ applyFog: boolean;
133
153
  /** Billboard pivot in particle-size units, see {@link VFXBatchSettings.pivot}. */
134
154
  pivot?: Vector3;
155
+ /** Draw order within the batch, see {@link SortMode}. */
156
+ sortMode: SortMode;
157
+ /** Smallest size a particle may have on screen, see {@link VFXBatchSettings.minParticleSize}. */
158
+ minParticleSize: number;
159
+ /** Largest size a particle may have on screen, see {@link VFXBatchSettings.maxParticleSize}. */
160
+ maxParticleSize: number;
135
161
  /** Mesh particle lighting, see {@link VFXBatchSettings.lighting}. */
136
162
  lighting?: MeshLighting;
137
163
  /** Mesh particle rim, see {@link VFXBatchSettings.rim}. */
@@ -144,7 +170,7 @@ export abstract class VFXBatch {
144
170
  settings: StoredBatchSettings;
145
171
  protected maxParticles: number;
146
172
  protected scene: Scene;
147
- private readonly visibleSystems: IParticleSystem[] = [];
173
+ protected readonly visibleSystems: IParticleSystem[] = [];
148
174
 
149
175
  protected constructor(settings: VFXBatchSettings, scene: Scene) {
150
176
  this.scene = scene;
@@ -174,12 +200,135 @@ export abstract class VFXBatch {
174
200
  reflectionFaces: settings.reflectionFaces ?? null,
175
201
  reflectionAtlas: settings.reflectionAtlas ?? null,
176
202
  pivot: settings.pivot?.clone(),
203
+ sortMode: settings.sortMode ?? 'none',
204
+ minParticleSize: settings.minParticleSize ?? 0,
205
+ maxParticleSize: settings.maxParticleSize ?? Infinity,
177
206
  layerMask: settings.layerMask,
207
+ renderingGroupId: settings.renderingGroupId ?? 0,
208
+ applyFog: settings.applyFog ?? true,
178
209
  lighting: settings.lighting,
179
210
  rim: normalizeMeshRim(settings.rim),
180
211
  };
181
212
  this.mesh = new Mesh('vfxBatch', scene);
182
- this.mesh.alwaysSelectAsActiveMesh = true;
213
+ this.configureMesh();
214
+ }
215
+
216
+ /**
217
+ * Applies the batch-level scene settings to {@link mesh}. Called on every
218
+ * mesh the batch creates, since the buffers rebuild it.
219
+ *
220
+ * The bounds of a batch mesh are not kept, so Babylon cannot sort it among
221
+ * the scene's transparent meshes by distance; `alphaIndex` from the
222
+ * system's render order puts it where the author said, and the rendering
223
+ * group and layer mask reach the mesh at all (they used to stop at the
224
+ * settings).
225
+ */
226
+ protected configureMesh(): void {
227
+ const mesh = this.mesh;
228
+ // Until the first fill measures the particles, the batch has no bounds
229
+ // to cull by and is drawn regardless. The instance data is already in
230
+ // world space, so the bounds never need the mesh's own matrix.
231
+ mesh.alwaysSelectAsActiveMesh = true;
232
+ mesh.doNotSyncBoundingInfo = true;
233
+ mesh.setBoundingInfo(new BoundingInfo(BVector3.Zero(), BVector3.Zero()));
234
+ mesh.layerMask = this.settings.layerMask;
235
+ mesh.renderingGroupId = this.settings.renderingGroupId;
236
+ mesh.alphaIndex = this.settings.renderOrder;
237
+ // The shaders fog themselves; Babylon's own fog pass does not apply to a ShaderMaterial.
238
+ mesh.applyFog = false;
239
+ }
240
+
241
+ private readonly boundsMin_ = new Vector3();
242
+ private readonly boundsMax_ = new Vector3();
243
+
244
+ /**
245
+ * Sets the batch mesh's bounds to the union of its filled systems' world
246
+ * bounds, so Babylon skips the draw when none of them is in view. Called
247
+ * after each fill, over the systems {@link getVisibleSystems} last returned.
248
+ * A batch whose systems have no measured bounds stays always drawn.
249
+ */
250
+ protected refreshBoundingInfo(): void {
251
+ const min = this.boundsMin_;
252
+ const max = this.boundsMax_;
253
+ let minX = Infinity;
254
+ let minY = Infinity;
255
+ let minZ = Infinity;
256
+ let maxX = -Infinity;
257
+ let maxY = -Infinity;
258
+ let maxZ = -Infinity;
259
+ let any = false;
260
+ const systems = this.visibleSystems;
261
+ for (let i = 0; i < systems.length; i++) {
262
+ const system = systems[i] as ParticleSystem;
263
+ if (system.particleNum === 0 || typeof system.getWorldBounds !== 'function') {
264
+ continue;
265
+ }
266
+ if (!system.getWorldBounds(min, max)) {
267
+ // Drawn but not measured: the batch cannot be culled this frame.
268
+ this.mesh.alwaysSelectAsActiveMesh = true;
269
+ return;
270
+ }
271
+ any = true;
272
+ if (min.x < minX) minX = min.x;
273
+ if (min.y < minY) minY = min.y;
274
+ if (min.z < minZ) minZ = min.z;
275
+ if (max.x > maxX) maxX = max.x;
276
+ if (max.y > maxY) maxY = max.y;
277
+ if (max.z > maxZ) maxZ = max.z;
278
+ }
279
+ if (!any) {
280
+ return;
281
+ }
282
+ const info = this.mesh.getBoundingInfo();
283
+ const bmin = info.minimum;
284
+ const bmax = info.maximum;
285
+ if (
286
+ bmin.x !== minX ||
287
+ bmin.y !== minY ||
288
+ bmin.z !== minZ ||
289
+ bmax.x !== maxX ||
290
+ bmax.y !== maxY ||
291
+ bmax.z !== maxZ
292
+ ) {
293
+ bmin.set(minX, minY, minZ);
294
+ bmax.set(maxX, maxY, maxZ);
295
+ info.reConstruct(bmin, bmax);
296
+ }
297
+ this.mesh.alwaysSelectAsActiveMesh = false;
298
+ }
299
+
300
+ /**
301
+ * Fog defines and uniforms for a material of this batch, and the per-bind
302
+ * upload of the scene's current fog. Mode 0 (none) when the scene has fog
303
+ * disabled, so toggling fog at runtime needs no recompile.
304
+ */
305
+ protected static readonly FOG_UNIFORMS = ['vFogInfos', 'vFogColor'];
306
+
307
+ protected fogDefines(): string[] {
308
+ if (!this.settings.applyFog) {
309
+ return [];
310
+ }
311
+ const additive =
312
+ this.settings.materialTransparent &&
313
+ (this.settings.materialBlendMode === Constants.ALPHA_ADD ||
314
+ this.settings.materialBlendMode === Constants.ALPHA_ONEONE);
315
+ return additive ? ['FOG', 'FOG_ADDITIVE'] : ['FOG'];
316
+ }
317
+
318
+ protected bindFog(material: ShaderMaterial): void {
319
+ if (!this.settings.applyFog) {
320
+ return;
321
+ }
322
+ const scene = this.scene;
323
+ material.onBindObservable.add(() => {
324
+ const effect = material.getEffect();
325
+ if (!effect) {
326
+ return;
327
+ }
328
+ const mode = scene.fogEnabled ? scene.fogMode : Scene.FOGMODE_NONE;
329
+ effect.setFloat4('vFogInfos', mode, scene.fogStart, scene.fogEnd, scene.fogDensity);
330
+ effect.setColor3('vFogColor', scene.fogColor);
331
+ });
183
332
  }
184
333
 
185
334
  addSystem(system: IParticleSystem) {
@@ -199,7 +348,9 @@ export abstract class VFXBatch {
199
348
  const visibleSystems = this.visibleSystems;
200
349
  let count = 0;
201
350
  for (const system of this.systems) {
202
- if (system.emitter.visible) {
351
+ // A culled system's particles are off every camera's screen; filling
352
+ // them in would only cost the copy and widen the batch's bounds.
353
+ if (system.emitter.visible && (system as ParticleSystem).culled !== true) {
203
354
  visibleSystems[count++] = system;
204
355
  }
205
356
  }
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ export * from 'quarks.core';
6
6
  export {BatchedParticleRenderer} from './BatchedParticleRenderer';
7
7
  export {BatchedRenderer} from './BatchedRenderer';
8
8
  export type {AdaptivePerformanceOptions, AdaptivePerformanceState, VFXBatchSettings} from './BatchedRenderer';
9
+ export {EffectPool} from './EffectPool';
10
+ export type {EffectPoolOptions} from './EffectPool';
9
11
  export {
10
12
  cacheEnvAtlas,
11
13
  createEnvAtlasFromFaceUrls,
@@ -21,6 +23,7 @@ export type {
21
23
  BabylonMetaData,
22
24
  BurstParameters,
23
25
  BurstParametersJSON,
26
+ CullingMode,
24
27
  ParticleSystemJSONParameters,
25
28
  ParticleSystemParameters,
26
29
  } from './ParticleSystem';
@@ -39,7 +42,7 @@ export * from './shaders/';
39
42
  export {SpriteBatch} from './SpriteBatch';
40
43
  export {TrailBatch} from './TrailBatch';
41
44
  export {defaultMeshLighting, meshRimEqual, normalizeMeshRim, RenderMode, VFXBatch} from './VFXBatch';
42
- export type {MeshLighting, MeshLightingSettings, MeshRim, StoredBatchSettings} from './VFXBatch';
45
+ export type {MeshLighting, MeshLightingSettings, MeshRim, SortMode, StoredBatchSettings} from './VFXBatch';
43
46
 
44
47
  // Re-export quarks.core types (interfaces - type-only)
45
48
  export type {