babylon.quarks 0.18.1 → 0.20.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 +57 -0
  2. package/README.md +38 -16
  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.ts +2121 -1157
  10. package/package.json +67 -67
  11. package/src/BatchedRenderer.ts +228 -15
  12. package/src/MeshSurfaceEmitter.ts +34 -23
  13. package/src/ParticleEmitter.ts +34 -10
  14. package/src/ParticleSystem.ts +558 -197
  15. package/src/QuarksLoader.ts +204 -18
  16. package/src/QuarksPrefab.ts +8 -11
  17. package/src/QuarksUtil.ts +1 -3
  18. package/src/SpriteBatch.ts +520 -98
  19. package/src/TrailBatch.ts +374 -166
  20. package/src/VFXBatch.ts +24 -5
  21. package/src/envAtlas.ts +136 -0
  22. package/src/index.ts +34 -29
  23. package/src/materials/ParticleMaterials.ts +1 -1
  24. package/src/shaders/index.ts +4 -4
  25. package/src/shaders/local_particle_physics_vert.wgsl.ts +81 -0
  26. package/src/shaders/particle_frag.wgsl.ts +64 -0
  27. package/src/shaders/particle_physics_frag.glsl.ts +56 -1
  28. package/src/shaders/particle_physics_frag.wgsl.ts +125 -0
  29. package/src/shaders/particle_vert.glsl.ts +20 -0
  30. package/src/shaders/particle_vert.wgsl.ts +107 -0
  31. package/src/shaders/shaderLanguageSupport.ts +54 -0
  32. package/src/shaders/stretched_bb_particle_vert.glsl.ts +18 -5
  33. package/src/shaders/stretched_bb_particle_vert.wgsl.ts +101 -0
  34. package/src/shaders/trail_frag.wgsl.ts +23 -0
  35. package/src/shaders/trail_vert.wgsl.ts +63 -0
@@ -1,28 +1,48 @@
1
- import {Mesh} from '@babylonjs/core/Meshes/mesh';
2
- import {VertexData} from '@babylonjs/core/Meshes/mesh.vertexData';
3
1
  import {VertexBuffer} from '@babylonjs/core/Buffers/buffer';
4
- import {Effect} from '@babylonjs/core/Materials/effect';
5
- import {ShaderMaterial} from '@babylonjs/core/Materials/shaderMaterial';
6
- import {Scene} from '@babylonjs/core/scene';
7
2
  import {Constants} from '@babylonjs/core/Engines/constants';
3
+ import {ShaderMaterial} from '@babylonjs/core/Materials/shaderMaterial';
4
+ import {RawTexture} from '@babylonjs/core/Materials/Textures/rawTexture';
8
5
  import {Vector2 as BVector2, Vector3 as BVector3, Vector4 as BVector4} from '@babylonjs/core/Maths/math.vector';
6
+ import {Mesh} from '@babylonjs/core/Meshes/mesh';
7
+ import {VertexData} from '@babylonjs/core/Meshes/mesh.vertexData';
8
+ import {Scene} from '@babylonjs/core/scene';
9
9
  import {
10
- Vector3,
11
- Vector4,
12
- Quaternion,
10
+ IParticle,
11
+ IParticleSystem,
13
12
  Matrix3,
13
+ ParticleStore,
14
+ Quaternion,
14
15
  SpriteParticle,
15
16
  StretchedBillBoardSettings,
17
+ Vector3,
16
18
  } from 'quarks.core';
17
- import {VFXBatch, RenderMode} from './VFXBatch';
18
19
  import {VFXBatchSettings} from './BatchedRenderer';
19
- import particle_vert from './shaders/particle_vert.glsl';
20
+ import local_particle_physics_vert from './shaders/local_particle_physics_vert.glsl';
21
+ import local_particle_physics_vert_wgsl from './shaders/local_particle_physics_vert.wgsl';
20
22
  import particle_frag from './shaders/particle_frag.glsl';
23
+ import particle_frag_wgsl from './shaders/particle_frag.wgsl';
21
24
  import particle_physics_frag from './shaders/particle_physics_frag.glsl';
25
+ import particle_physics_frag_wgsl from './shaders/particle_physics_frag.wgsl';
26
+ import particle_vert from './shaders/particle_vert.glsl';
27
+ import particle_vert_wgsl from './shaders/particle_vert.wgsl';
28
+ import {registerShaders, shaderLanguageFor, ShaderSources} from './shaders/shaderLanguageSupport';
22
29
  import stretched_bb_particle_vert from './shaders/stretched_bb_particle_vert.glsl';
23
- import local_particle_physics_vert from './shaders/local_particle_physics_vert.glsl';
30
+ import stretched_bb_particle_vert_wgsl from './shaders/stretched_bb_particle_vert.wgsl';
31
+ import {RenderMode, VFXBatch} from './VFXBatch';
24
32
 
25
33
  export class SpriteBatch extends VFXBatch {
34
+ private static whiteTextureByScene = new WeakMap<Scene, RawTexture>();
35
+
36
+ /** 1×1 white map so mesh batches always have a sampler2D (iOS WebKit). */
37
+ private static whiteTexture(scene: Scene): RawTexture {
38
+ let texture = SpriteBatch.whiteTextureByScene.get(scene);
39
+ if (!texture) {
40
+ texture = RawTexture.CreateRGBATexture(new Uint8Array([255, 255, 255, 255]), 1, 1, scene);
41
+ texture.name = 'quarksMeshWhite';
42
+ SpriteBatch.whiteTextureByScene.set(scene, texture);
43
+ }
44
+ return texture;
45
+ }
26
46
  private offsetBuffer!: Float32Array;
27
47
  private rotationBuffer!: Float32Array;
28
48
  private sizeBuffer!: Float32Array;
@@ -30,6 +50,9 @@ export class SpriteBatch extends VFXBatch {
30
50
  private uvTileBuffer!: Float32Array;
31
51
  private velocityBuffer?: Float32Array;
32
52
 
53
+ /** Concretely typed handle on the batch material, see syncStretchedSpeedFactor. */
54
+ private shaderMaterial: ShaderMaterial | null = null;
55
+
33
56
  private offsetVB!: VertexBuffer;
34
57
  private rotationVB!: VertexBuffer;
35
58
  private sizeVB!: VertexBuffer;
@@ -52,8 +75,14 @@ export class SpriteBatch extends VFXBatch {
52
75
  const vertexData = new VertexData();
53
76
  vertexData.positions = this.settings.instancingGeometry;
54
77
  vertexData.indices = this.settings.instancingIndices;
55
- if (this.settings.instancingUVs) {
56
- vertexData.uvs = this.settings.instancingUVs;
78
+ const vertexCount = this.settings.instancingGeometry.length / 3;
79
+ const uvs = this.settings.instancingUVs;
80
+ // WebGL (especially iOS) raises INVALID_OPERATION if the shader reads
81
+ // `attribute vec2 uv` but the buffer is missing or shorter than vertices.
82
+ if (uvs && uvs.length >= vertexCount * 2) {
83
+ vertexData.uvs = uvs;
84
+ } else {
85
+ vertexData.uvs = new Float32Array(vertexCount * 2);
57
86
  }
58
87
  if (this.settings.instancingNormals) {
59
88
  vertexData.normals = this.settings.instancingNormals;
@@ -63,6 +92,18 @@ export class SpriteBatch extends VFXBatch {
63
92
  this.buildExpandableBuffers();
64
93
  }
65
94
 
95
+ /** Releases the instance vertex buffers so a resize does not leak GPU memory. */
96
+ private disposeExpandableBuffers(): void {
97
+ this.offsetVB?.dispose();
98
+ this.colorVB?.dispose();
99
+ this.sizeVB?.dispose();
100
+ this.uvTileVB?.dispose();
101
+ this.rotationVB?.dispose();
102
+ this.velocityVB?.dispose();
103
+ this.velocityVB = undefined;
104
+ this.velocityBuffer = undefined;
105
+ }
106
+
66
107
  private buildExpandableBuffers(): void {
67
108
  const engine = this.scene.getEngine();
68
109
 
@@ -105,30 +146,51 @@ export class SpriteBatch extends VFXBatch {
105
146
  while (target >= this.maxParticles) {
106
147
  this.maxParticles *= 2;
107
148
  }
149
+ this.disposeExpandableBuffers();
108
150
  this.buildExpandableBuffers();
109
151
  }
110
152
 
111
153
  rebuildMaterial(): void {
112
- const shaderName = `quarksParticle_${this.settings.renderMode}_${Date.now()}`;
154
+ // The shader sources only depend on the render mode, so the store entry is
155
+ // registered once per mode. A unique name per rebuild would leak entries in
156
+ // Effect.ShadersStore and defeat Babylon's compiled-effect cache.
157
+ const shaderName = `quarksParticle_${this.settings.renderMode}`;
113
158
  this.lastStretchedSpeedFactor = Number.NaN;
114
- let vertexShader: string;
115
- let fragmentShader: string;
116
159
  const defines: string[] = [];
117
160
 
161
+ let vertexShader: ShaderSources;
162
+ let fragmentShader: ShaderSources;
118
163
  if (this.settings.renderMode === RenderMode.Mesh) {
119
- vertexShader = local_particle_physics_vert;
120
- fragmentShader = particle_physics_frag;
164
+ vertexShader = {glsl: local_particle_physics_vert, wgsl: local_particle_physics_vert_wgsl};
165
+ fragmentShader = {glsl: particle_physics_frag, wgsl: particle_physics_frag_wgsl};
121
166
  } else if (this.settings.renderMode === RenderMode.StretchedBillBoard) {
122
- vertexShader = stretched_bb_particle_vert;
123
- fragmentShader = particle_frag;
167
+ vertexShader = {glsl: stretched_bb_particle_vert, wgsl: stretched_bb_particle_vert_wgsl};
168
+ fragmentShader = {glsl: particle_frag, wgsl: particle_frag_wgsl};
124
169
  } else {
125
- vertexShader = particle_vert;
126
- fragmentShader = particle_frag;
170
+ vertexShader = {glsl: particle_vert, wgsl: particle_vert_wgsl};
171
+ fragmentShader = {glsl: particle_frag, wgsl: particle_frag_wgsl};
127
172
  }
128
173
 
129
- if (this.settings.texture) {
174
+ // Mesh batches without a diffuse map still need a sampler2D on iOS WebKit —
175
+ // a zero-sampler particle mesh effect often never becomes drawable there.
176
+ const mapTexture =
177
+ this.settings.texture ??
178
+ (this.settings.renderMode === RenderMode.Mesh ? SpriteBatch.whiteTexture(this.scene) : null);
179
+ if (mapTexture) {
130
180
  defines.push('USE_MAP');
131
181
  }
182
+ const atlas = this.settings.reflectionAtlas;
183
+ const atlasPending = this.settings.renderMode === RenderMode.Mesh && !!atlas && !atlas.isReady();
184
+ if (atlasPending && atlas) {
185
+ const onLoad = (atlas as {onLoadObservable?: {addOnce: (cb: () => void) => void}}).onLoadObservable;
186
+ onLoad?.addOnce(() => {
187
+ this.rebuildMaterial();
188
+ });
189
+ }
190
+ const useEnvAtlas = this.settings.renderMode === RenderMode.Mesh && !!atlas && atlas.isReady();
191
+ if (useEnvAtlas) {
192
+ defines.push('USE_ENVMAP_ATLAS');
193
+ }
132
194
  if (this.settings.uTileCount > 1 || this.settings.vTileCount > 1) {
133
195
  defines.push('UV_TILE');
134
196
  }
@@ -147,9 +209,13 @@ export class SpriteBatch extends VFXBatch {
147
209
  if (this.settings.renderMode === RenderMode.HorizontalBillBoard) {
148
210
  defines.push('HORIZONTAL');
149
211
  }
212
+ const pivot = SpriteBatch.takesPivotAndFlip(this.settings.renderMode) ? this.settings.pivot : undefined;
213
+ if (pivot) {
214
+ defines.push('PIVOT');
215
+ }
150
216
 
151
- Effect.ShadersStore[shaderName + 'VertexShader'] = vertexShader;
152
- Effect.ShadersStore[shaderName + 'FragmentShader'] = fragmentShader;
217
+ const shaderLanguage = shaderLanguageFor(this.scene.getEngine());
218
+ registerShaders(shaderName, vertexShader, fragmentShader, shaderLanguage);
153
219
 
154
220
  const attributes = ['position', 'uv', 'offset', 'color', 'size', 'rotation', 'uvTile'];
155
221
  if (this.settings.renderMode === RenderMode.Mesh) {
@@ -166,9 +232,14 @@ export class SpriteBatch extends VFXBatch {
166
232
  uniforms.push('tileCountX');
167
233
  uniforms.push('tileCountY');
168
234
  }
169
- if (this.settings.texture) {
235
+ if (mapTexture) {
170
236
  samplers.push('map');
171
237
  }
238
+ if (useEnvAtlas) {
239
+ samplers.push('envAtlas');
240
+ uniforms.push('eyePosition');
241
+ uniforms.push('reflectionLevel');
242
+ }
172
243
  if (this.settings.renderMode === RenderMode.StretchedBillBoard) {
173
244
  uniforms.push('speedFactor');
174
245
  }
@@ -180,13 +251,18 @@ export class SpriteBatch extends VFXBatch {
180
251
  if (this.settings.materialAlphaTest > 0) {
181
252
  uniforms.push('alphaTest');
182
253
  }
254
+ if (pivot) {
255
+ uniforms.push('pivot');
256
+ }
183
257
  if (this.settings.renderMode === RenderMode.Mesh) {
184
258
  uniforms.push('lightDirection');
185
259
  uniforms.push('lightColor');
186
260
  uniforms.push('ambientColor');
187
261
  }
188
262
 
189
- const mat = new ShaderMaterial(shaderName, this.scene,
263
+ const mat = new ShaderMaterial(
264
+ shaderName,
265
+ this.scene,
190
266
  {vertex: shaderName, fragment: shaderName},
191
267
  {
192
268
  attributes,
@@ -194,11 +270,25 @@ export class SpriteBatch extends VFXBatch {
194
270
  samplers,
195
271
  defines,
196
272
  needAlphaBlending: this.settings.materialTransparent,
273
+ shaderLanguage,
197
274
  }
198
275
  );
199
276
 
200
- if (this.settings.texture) {
201
- mat.setTexture('map', this.settings.texture);
277
+ if (mapTexture) {
278
+ mat.setTexture('map', mapTexture);
279
+ }
280
+ if (useEnvAtlas && atlas) {
281
+ mat.setTexture('envAtlas', atlas);
282
+ mat.setFloat('reflectionLevel', this.settings.reflectionLevel);
283
+ const eyePosition = new BVector3();
284
+ mat.onBindObservable.add(() => {
285
+ const camera = this.scene.activeCamera;
286
+ if (!camera) {
287
+ return;
288
+ }
289
+ eyePosition.copyFrom(camera.globalPosition);
290
+ mat.setVector3('eyePosition', eyePosition);
291
+ });
202
292
  }
203
293
  if (this.settings.uTileCount > 1 || this.settings.vTileCount > 1) {
204
294
  mat.setFloat('tileCountX', this.settings.uTileCount);
@@ -208,17 +298,30 @@ export class SpriteBatch extends VFXBatch {
208
298
  mat.setFloat('speedFactor', this.settings.softNearFade > 0 ? this.settings.softNearFade : 1.0);
209
299
  }
210
300
  if (this.settings.softParticles) {
211
- mat.setVector2('softParams', new BVector2(this.settings.softNearFade, 1.0 / Math.max(this.settings.softFarFade - this.settings.softNearFade, 0.0001)));
301
+ mat.setVector2(
302
+ 'softParams',
303
+ new BVector2(
304
+ this.settings.softNearFade,
305
+ 1.0 / Math.max(this.settings.softFarFade - this.settings.softNearFade, 0.0001)
306
+ )
307
+ );
308
+ // Reused across binds: this runs on every draw call.
309
+ const projParams = new BVector4(0, 0, 0, 0);
212
310
  mat.onBindObservable.add(() => {
213
311
  const camera = this.scene.activeCamera;
214
312
  if (camera) {
215
- mat.setVector4('projParams', new BVector4(camera.minZ, camera.maxZ, 0, 0));
313
+ projParams.x = camera.minZ;
314
+ projParams.y = camera.maxZ;
315
+ mat.setVector4('projParams', projParams);
216
316
  }
217
317
  });
218
318
  }
219
319
  if (this.settings.materialAlphaTest > 0) {
220
320
  mat.setFloat('alphaTest', this.settings.materialAlphaTest);
221
321
  }
322
+ if (pivot) {
323
+ mat.setVector3('pivot', new BVector3(pivot.x, pivot.y, pivot.z));
324
+ }
222
325
  if (this.settings.renderMode === RenderMode.Mesh) {
223
326
  mat.setVector3('lightDirection', new BVector3(0.4, -1, 0.6));
224
327
  mat.setVector3('lightColor', new BVector3(1, 1, 1));
@@ -237,6 +340,7 @@ export class SpriteBatch extends VFXBatch {
237
340
  mat.disableDepthWrite = !this.settings.materialDepthWrite;
238
341
 
239
342
  this.mesh.material = mat;
343
+ this.shaderMaterial = mat;
240
344
  }
241
345
 
242
346
  private vector_ = new Vector3();
@@ -245,9 +349,13 @@ export class SpriteBatch extends VFXBatch {
245
349
  private quaternion_ = new Quaternion();
246
350
  private quaternion2_ = new Quaternion();
247
351
  private quaternion3_ = new Quaternion();
352
+ private quaternion4_ = new Quaternion();
353
+ private quaternion5_ = new Quaternion();
248
354
  private rotationMat_ = new Matrix3();
249
355
  private rotationMat2_ = new Matrix3();
250
356
  private lastStretchedSpeedFactor = Number.NaN;
357
+ /** Slot 0 holds the zero-alpha instance since the batch last went empty. */
358
+ private clearedInstanceUploaded = false;
251
359
 
252
360
  update(): void {
253
361
  let index = 0;
@@ -255,35 +363,181 @@ export class SpriteBatch extends VFXBatch {
255
363
  const renderMode = this.settings.renderMode;
256
364
  const isMeshRender = renderMode === RenderMode.Mesh;
257
365
  const isStretchedRender = renderMode === RenderMode.StretchedBillBoard;
366
+ const flipsSprites = SpriteBatch.takesPivotAndFlip(renderMode);
258
367
 
259
368
  const visibleSystems = this.getVisibleSystems();
260
- for (const system of visibleSystems) {
261
- particleCount += system.particleNum;
369
+ for (let i = 0; i < visibleSystems.length; i++) {
370
+ particleCount += visibleSystems[i].particleNum;
262
371
  }
263
372
  if (particleCount > this.maxParticles) {
264
373
  this.expandBuffers(particleCount);
265
374
  }
266
375
 
267
- for (const system of visibleSystems) {
268
- const particles = system.particles;
376
+ for (let s = 0; s < visibleSystems.length; s++) {
377
+ const system = visibleSystems[s];
269
378
  const particleNum = system.particleNum;
379
+ // A finished or between-bursts system adds nothing to the batch. With many effects
380
+ // loaded most systems are idle at any moment, so skip their per-system setup.
381
+ if (particleNum === 0) {
382
+ continue;
383
+ }
384
+ const particles = system.particles;
270
385
  const rotation = this.quaternion2_;
271
386
  const translation = this.vector2_;
272
387
  const scale = this.vector3_;
273
388
  const systemWorldSpace = system.worldSpace;
274
- system.emitter.matrixWorld.decompose(translation, rotation, scale);
275
- this.rotationMat_.setFromMatrix4(system.emitter.matrixWorld);
389
+ const emitterMatrix = system.emitter.matrixWorld;
390
+ // Only a local-space mesh needs the emitter's turn as a quaternion, and only a
391
+ // local-space stretched billboard its rotation matrix. Everything else needs at most
392
+ // the scale, which the basis column lengths give without a full decompose.
393
+ if (isMeshRender && !systemWorldSpace) {
394
+ emitterMatrix.decompose(translation, rotation, scale);
395
+ } else {
396
+ const e = emitterMatrix.elements;
397
+ scale.set(
398
+ Math.sqrt(e[0] * e[0] + e[1] * e[1] + e[2] * e[2]),
399
+ Math.sqrt(e[4] * e[4] + e[5] * e[5] + e[6] * e[6]),
400
+ Math.sqrt(e[8] * e[8] + e[9] * e[9] + e[10] * e[10])
401
+ );
402
+ }
403
+ if (isStretchedRender && !systemWorldSpace) {
404
+ this.rotationMat_.setFromMatrix4(emitterMatrix);
405
+ }
276
406
  const absScaleX = Math.abs(scale.x);
277
407
  const absScaleY = Math.abs(scale.y);
278
408
  const absScaleZ = Math.abs(scale.z);
409
+ // Stretched-billboard settings are per system, not per particle.
410
+ const stretchedSettings = isStretchedRender
411
+ ? (system.rendererEmitterSettings as StretchedBillBoardSettings)
412
+ : undefined;
413
+ const speedFactor =
414
+ stretchedSettings === undefined || stretchedSettings.speedFactor === 0
415
+ ? 0.001
416
+ : stretchedSettings.speedFactor;
417
+ const lengthFactor = stretchedSettings?.lengthFactor ?? 0;
418
+
419
+ // Live particles occupy store rows [0, particleNum) in the same order
420
+ // this loop writes them, so attributes that need no per-particle
421
+ // transform can be copied as one range instead of gathered.
422
+ const store = system.store;
423
+ const copiedColor = store !== undefined;
424
+ // Particles only carry their own parent transform when the system
425
+ // emits through another one; otherwise the whole range shares the
426
+ // emitter's matrix and can be transformed in place after the copy.
427
+ const sharedTransform = store !== undefined && system.onlyUsedByOther !== true;
428
+ // The simulation runs on a fixed step that the display does not
429
+ // share, so the last step is usually in the past by the time the
430
+ // frame is drawn. Continuing the motion that step produced puts each
431
+ // particle where it belongs now; without it they visibly stutter on
432
+ // any display that is not exactly 60Hz and in phase.
433
+ //
434
+ // The motion is measured, not predicted from velocity: plenty of
435
+ // behaviors move a particle by writing its position — orbits, noise,
436
+ // turbulence, anything a plugin does — and a velocity based guess
437
+ // leaves every one of those stuttering exactly as before.
438
+ const residual = system.simulationResidual ?? 0;
439
+ const stepFraction = residual === 0 ? 0 : residual / (system.simulationStep ?? residual);
440
+ if (copiedColor) {
441
+ if (stepFraction !== 0 && particleNum > 0) {
442
+ SpriteBatch.continueColor(this.colorBuffer, index * 4, store!, particleNum, stepFraction);
443
+ } else {
444
+ this.colorBuffer.set(store!.color.subarray(0, particleNum * 4), index * 4);
445
+ }
446
+ }
447
+ let copiedPositionAndSize = false;
448
+ if (store !== undefined && systemWorldSpace) {
449
+ if (stepFraction !== 0 && particleNum > 0) {
450
+ SpriteBatch.extrapolate(this.offsetBuffer, index * 3, store, particleNum, stepFraction);
451
+ } else {
452
+ this.offsetBuffer.set(store.position.subarray(0, particleNum * 3), index * 3);
453
+ }
454
+ if (stepFraction !== 0 && particleNum > 0) {
455
+ SpriteBatch.continueVector3(
456
+ this.sizeBuffer,
457
+ index * 3,
458
+ store.size,
459
+ store.previousSize,
460
+ particleNum,
461
+ stepFraction
462
+ );
463
+ } else {
464
+ this.sizeBuffer.set(store.size.subarray(0, particleNum * 3), index * 3);
465
+ }
466
+ copiedPositionAndSize = true;
467
+ } else if (sharedTransform && particleNum > 0) {
468
+ const base = index * 3;
469
+ const end = base + particleNum * 3;
470
+ const offsets = this.offsetBuffer;
471
+ if (stepFraction !== 0) {
472
+ SpriteBatch.extrapolate(offsets, base, store!, particleNum, stepFraction);
473
+ } else {
474
+ offsets.set(store!.position.subarray(0, particleNum * 3), base);
475
+ }
476
+ const me = emitterMatrix.elements;
477
+ const m00 = me[0],
478
+ m01 = me[1],
479
+ m02 = me[2],
480
+ m03 = me[3];
481
+ const m10 = me[4],
482
+ m11 = me[5],
483
+ m12 = me[6],
484
+ m13 = me[7];
485
+ const m20 = me[8],
486
+ m21 = me[9],
487
+ m22 = me[10],
488
+ m23 = me[11];
489
+ const m30 = me[12],
490
+ m31 = me[13],
491
+ m32 = me[14],
492
+ m33 = me[15];
493
+ for (let o = base; o < end; o += 3) {
494
+ const px = offsets[o];
495
+ const py = offsets[o + 1];
496
+ const pz = offsets[o + 2];
497
+ const w = 1 / (m03 * px + m13 * py + m23 * pz + m33);
498
+ offsets[o] = (m00 * px + m10 * py + m20 * pz + m30) * w;
499
+ offsets[o + 1] = (m01 * px + m11 * py + m21 * pz + m31) * w;
500
+ offsets[o + 2] = (m02 * px + m12 * py + m22 * pz + m32) * w;
501
+ }
502
+ const sizes = this.sizeBuffer;
503
+ if (stepFraction !== 0) {
504
+ SpriteBatch.continueVector3(
505
+ sizes,
506
+ base,
507
+ store!.size,
508
+ store!.previousSize,
509
+ particleNum,
510
+ stepFraction
511
+ );
512
+ } else {
513
+ sizes.set(store!.size.subarray(0, particleNum * 3), base);
514
+ }
515
+ for (let o = base; o < end; o += 3) {
516
+ sizes[o] *= absScaleX;
517
+ sizes[o + 1] *= absScaleY;
518
+ sizes[o + 2] *= absScaleZ;
519
+ }
520
+ copiedPositionAndSize = true;
521
+ }
279
522
 
523
+ const firstIndex = index;
280
524
  for (let j = 0; j < particleNum; j++, index++) {
281
525
  const particle = particles[j] as SpriteParticle;
282
526
 
283
527
  if (isMeshRender) {
528
+ // Turning behaviors leave the last step's turn on the
529
+ // particle; a fraction of it carries the mesh the rest of
530
+ // the way to now.
531
+ let own = particle.rotation as Quaternion;
532
+ const step = particle.angularVelocity;
533
+ if (stepFraction !== 0 && step instanceof Quaternion) {
534
+ own = this.quaternion4_
535
+ .copy(own)
536
+ .multiply(SpriteBatch.partialTurn(step, stepFraction, this.quaternion5_));
537
+ }
284
538
  let q: Quaternion;
285
539
  if (systemWorldSpace) {
286
- q = particle.rotation as Quaternion;
540
+ q = own;
287
541
  } else {
288
542
  let parentQ: Quaternion;
289
543
  if (particle.parentMatrix) {
@@ -292,7 +546,7 @@ export class SpriteBatch extends VFXBatch {
292
546
  parentQ = rotation;
293
547
  }
294
548
  q = this.quaternion_;
295
- q.copy(parentQ).multiply(particle.rotation as Quaternion);
549
+ q.copy(parentQ).multiply(own);
296
550
  }
297
551
  const ri = index * 4;
298
552
  this.rotationBuffer[ri] = q.x;
@@ -300,65 +554,77 @@ export class SpriteBatch extends VFXBatch {
300
554
  this.rotationBuffer[ri + 2] = q.z;
301
555
  this.rotationBuffer[ri + 3] = q.w;
302
556
  } else {
303
- this.rotationBuffer[index] = particle.rotation as number;
557
+ const spin = particle.angularVelocity;
558
+ this.rotationBuffer[index] =
559
+ (particle.rotation as number) + (typeof spin === 'number' ? spin * residual : 0);
304
560
  }
305
561
 
306
- let vec: Vector3;
307
- if (systemWorldSpace) {
308
- vec = particle.position;
309
- } else {
310
- vec = this.vector_;
311
- if (particle.parentMatrix) {
312
- vec.copy(particle.position).applyMatrix4(particle.parentMatrix);
313
- } else {
314
- vec.copy(particle.position).applyMatrix4(system.emitter.matrixWorld);
562
+ if (!copiedPositionAndSize) {
563
+ const position = particle.position;
564
+ const previous = particle.previousPosition;
565
+ let px = position.x + (position.x - previous.x) * stepFraction;
566
+ let py = position.y + (position.y - previous.y) * stepFraction;
567
+ let pz = position.z + (position.z - previous.z) * stepFraction;
568
+ if (!systemWorldSpace) {
569
+ // Inlined point transform: this is the default (local space)
570
+ // path and runs for every particle every frame.
571
+ const me = (particle.parentMatrix ?? emitterMatrix).elements;
572
+ const w = 1 / (me[3] * px + me[7] * py + me[11] * pz + me[15]);
573
+ const tx = (me[0] * px + me[4] * py + me[8] * pz + me[12]) * w;
574
+ const ty = (me[1] * px + me[5] * py + me[9] * pz + me[13]) * w;
575
+ pz = (me[2] * px + me[6] * py + me[10] * pz + me[14]) * w;
576
+ px = tx;
577
+ py = ty;
315
578
  }
316
- }
317
579
 
318
- const oi = index * 3;
319
- this.offsetBuffer[oi] = vec.x;
320
- this.offsetBuffer[oi + 1] = vec.y;
321
- this.offsetBuffer[oi + 2] = vec.z;
322
-
323
- const ci = index * 4;
324
- this.colorBuffer[ci] = particle.color.x;
325
- this.colorBuffer[ci + 1] = particle.color.y;
326
- this.colorBuffer[ci + 2] = particle.color.z;
327
- this.colorBuffer[ci + 3] = particle.color.w;
328
-
329
- const si = index * 3;
330
- if (systemWorldSpace) {
331
- this.sizeBuffer[si] = particle.size.x;
332
- this.sizeBuffer[si + 1] = particle.size.y;
333
- this.sizeBuffer[si + 2] = particle.size.z;
334
- } else {
335
- if (particle.parentMatrix) {
336
- this.sizeBuffer[si] = particle.size.x;
337
- this.sizeBuffer[si + 1] = particle.size.y;
338
- this.sizeBuffer[si + 2] = particle.size.z;
580
+ const oi = index * 3;
581
+ this.offsetBuffer[oi] = px;
582
+ this.offsetBuffer[oi + 1] = py;
583
+ this.offsetBuffer[oi + 2] = pz;
584
+
585
+ const size = particle.size;
586
+ const si = index * 3;
587
+ // Particle size is already in world units when the system is in world
588
+ // space or the particle carries its own parent transform.
589
+ if (systemWorldSpace || particle.parentMatrix) {
590
+ this.sizeBuffer[si] = size.x;
591
+ this.sizeBuffer[si + 1] = size.y;
592
+ this.sizeBuffer[si + 2] = size.z;
339
593
  } else {
340
- this.sizeBuffer[si] = particle.size.x * absScaleX;
341
- this.sizeBuffer[si + 1] = particle.size.y * absScaleY;
342
- this.sizeBuffer[si + 2] = particle.size.z * absScaleZ;
594
+ this.sizeBuffer[si] = size.x * absScaleX;
595
+ this.sizeBuffer[si + 1] = size.y * absScaleY;
596
+ this.sizeBuffer[si + 2] = size.z * absScaleZ;
343
597
  }
344
598
  }
345
599
 
600
+ if (!copiedColor) {
601
+ const color = particle.color;
602
+ const ci = index * 4;
603
+ this.colorBuffer[ci] = color.x;
604
+ this.colorBuffer[ci + 1] = color.y;
605
+ this.colorBuffer[ci + 2] = color.z;
606
+ this.colorBuffer[ci + 3] = color.w;
607
+ }
608
+
346
609
  this.uvTileBuffer[index] = particle.uvTile;
347
610
 
348
611
  if (isStretchedRender && this.velocityBuffer) {
349
- let speedFactor = (system.rendererEmitterSettings as StretchedBillBoardSettings).speedFactor;
350
- if (speedFactor === 0) speedFactor = 0.001;
351
- const lengthFactor = (system.rendererEmitterSettings as StretchedBillBoardSettings).lengthFactor;
352
- let vel: Vector3;
353
- if (systemWorldSpace) {
354
- vel = particle.velocity;
355
- } else {
356
- vel = this.vector_;
612
+ // The streak points along the velocity and is as long as it,
613
+ // so a velocity frozen between steps makes the sprite's
614
+ // outline pop at the step rate while its position glides.
615
+ const velocity = particle.velocity;
616
+ const previousVelocity = particle.previousVelocity;
617
+ const vx = velocity.x + (velocity.x - previousVelocity.x) * stepFraction;
618
+ const vy = velocity.y + (velocity.y - previousVelocity.y) * stepFraction;
619
+ const vz = velocity.z + (velocity.z - previousVelocity.z) * stepFraction;
620
+ let vel: Vector3 = this.vector_;
621
+ vel.set(vx, vy, vz);
622
+ if (!systemWorldSpace) {
357
623
  if (particle.parentMatrix) {
358
624
  this.rotationMat2_.setFromMatrix4(particle.parentMatrix);
359
- vel.copy(particle.velocity).applyMatrix3(this.rotationMat2_);
625
+ vel.applyMatrix3(this.rotationMat2_);
360
626
  } else {
361
- vel.copy(particle.velocity).applyMatrix3(this.rotationMat_);
627
+ vel.applyMatrix3(this.rotationMat_);
362
628
  }
363
629
  }
364
630
  const vi = index * 4;
@@ -368,33 +634,189 @@ export class SpriteBatch extends VFXBatch {
368
634
  this.velocityBuffer[vi + 3] = lengthFactor;
369
635
  }
370
636
  }
371
- }
372
637
 
373
- if (isStretchedRender && this.mesh.material instanceof ShaderMaterial && visibleSystems.length > 0) {
374
- const speedFactor = (visibleSystems[0].rendererEmitterSettings as StretchedBillBoardSettings).speedFactor ?? 1.0;
375
- const clampedSpeedFactor = speedFactor === 0 ? 0.001 : speedFactor;
376
- if (clampedSpeedFactor !== this.lastStretchedSpeedFactor) {
377
- this.mesh.material.setFloat('speedFactor', clampedSpeedFactor);
378
- this.lastStretchedSpeedFactor = clampedSpeedFactor;
638
+ if (flipsSprites && system.flip !== undefined) {
639
+ SpriteBatch.applyFlip(this.sizeBuffer, firstIndex * 3, particles, particleNum);
379
640
  }
380
641
  }
381
642
 
643
+ if (isStretchedRender && visibleSystems.length > 0) {
644
+ this.syncStretchedSpeedFactor(visibleSystems[0]);
645
+ }
646
+
382
647
  // Keep the mesh enabled even when count is 0 — toggling visibility off for a
383
648
  // single frame (common with short lifetimes + low emission overlap) causes flicker.
384
649
  this.mesh.forcedInstanceCount = index;
385
650
 
386
651
  if (index === 0) {
387
- this.uploadClearedInstance(isMeshRender, isStretchedRender);
652
+ // Slot 0 only needs clearing once per stretch of empty frames; re-uploading it every
653
+ // frame cost five buffer uploads per idle batch per frame.
654
+ if (!this.clearedInstanceUploaded) {
655
+ this.uploadClearedInstance(isMeshRender, isStretchedRender);
656
+ this.clearedInstanceUploaded = true;
657
+ }
388
658
  return;
389
659
  }
660
+ this.clearedInstanceUploaded = false;
390
661
 
391
- this.offsetVB.update(this.offsetBuffer);
392
- this.sizeVB.update(this.sizeBuffer);
393
- this.colorVB.update(this.colorBuffer);
394
- this.uvTileVB.update(this.uvTileBuffer);
395
- this.rotationVB.update(this.rotationBuffer);
662
+ // Upload only the slots actually filled this frame: the buffers are sized
663
+ // for the high-water mark and are usually far larger than the live count.
664
+ this.offsetVB.update(this.offsetBuffer.subarray(0, index * 3));
665
+ this.sizeVB.update(this.sizeBuffer.subarray(0, index * 3));
666
+ this.colorVB.update(this.colorBuffer.subarray(0, index * 4));
667
+ this.uvTileVB.update(this.uvTileBuffer.subarray(0, index));
668
+ this.rotationVB.update(this.rotationBuffer.subarray(0, isMeshRender ? index * 4 : index));
396
669
  if (isStretchedRender && this.velocityVB && this.velocityBuffer) {
397
- this.velocityVB.update(this.velocityBuffer);
670
+ this.velocityVB.update(this.velocityBuffer.subarray(0, index * 4));
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Pushes the stretched-billboard speed factor to the shader when it changed.
676
+ *
677
+ * Deliberately kept out of `update`: touching the loosely typed
678
+ * `mesh.material` from inside that function gives V8 a generic property
679
+ * access it has no feedback for, which knocks the whole particle loop out of
680
+ * optimised code on every frame.
681
+ */
682
+ private syncStretchedSpeedFactor(system: IParticleSystem): void {
683
+ const material = this.shaderMaterial;
684
+ if (material === null) {
685
+ return;
686
+ }
687
+ const speedFactor = (system.rendererEmitterSettings as StretchedBillBoardSettings).speedFactor ?? 1.0;
688
+ const clampedSpeedFactor = speedFactor === 0 ? 0.001 : speedFactor;
689
+ if (clampedSpeedFactor !== this.lastStretchedSpeedFactor) {
690
+ material.setFloat('speedFactor', clampedSpeedFactor);
691
+ this.lastStretchedSpeedFactor = clampedSpeedFactor;
692
+ }
693
+ }
694
+
695
+ /** The quads that take a pivot and flip: every billboard kind, stretched included, but not meshes. */
696
+ private static takesPivotAndFlip(renderMode: RenderMode): boolean {
697
+ return (
698
+ renderMode === RenderMode.BillBoard ||
699
+ renderMode === RenderMode.StretchedBillBoard ||
700
+ renderMode === RenderMode.VerticalBillBoard ||
701
+ renderMode === RenderMode.HorizontalBillBoard
702
+ );
703
+ }
704
+
705
+ /**
706
+ * Negates the size of each flipped particle along the axes it flips on,
707
+ * starting at `base` in `sizes`. The billboard shader scales the quad by
708
+ * the size, so a negative one mirrors it — and with it the texture, the
709
+ * quad being centred — the way Unity's renderer Flip mirrors U and V. The
710
+ * stretched shader reads the sign and mirrors the texture coordinates.
711
+ */
712
+ private static applyFlip(sizes: Float32Array, base: number, particles: IParticle[], count: number): void {
713
+ for (let j = 0, o = base; j < count; j++, o += 3) {
714
+ const flip = (particles[j] as SpriteParticle).flip;
715
+ if (flip !== 0) {
716
+ if (flip & 1) sizes[o] = -sizes[o];
717
+ if (flip & 2) sizes[o + 1] = -sizes[o + 1];
718
+ }
719
+ }
720
+ }
721
+
722
+ /**
723
+ * The fraction `f` of a turn, as a normalised lerp from no rotation toward
724
+ * `step`, written into `out`.
725
+ *
726
+ * A true slerp would be exact, but `step` is one simulation step of turning
727
+ * and `f` is below 1, so the arc being split is small enough that the lerp
728
+ * is within a rounding error of it and costs a fraction as much.
729
+ */
730
+ private static partialTurn(step: Quaternion, f: number, out: Quaternion): Quaternion {
731
+ const x = step.x * f;
732
+ const y = step.y * f;
733
+ const z = step.z * f;
734
+ const w = 1 + (step.w - 1) * f;
735
+ const inverseLength = 1 / Math.sqrt(x * x + y * y + z * z + w * w);
736
+ out.x = x * inverseLength;
737
+ out.y = y * inverseLength;
738
+ out.z = z * inverseLength;
739
+ out.w = w * inverseLength;
740
+ return out;
741
+ }
742
+
743
+ /**
744
+ * Continues a three-component column by `fraction` of the step it just took,
745
+ * never below zero.
746
+ *
747
+ * Size fades to nothing at the end of a life, so the last step before death
748
+ * can point past zero; a negative size turns a sprite inside out.
749
+ */
750
+ private static continueVector3(
751
+ target: Float32Array,
752
+ base: number,
753
+ current: Float32Array,
754
+ previous: Float32Array,
755
+ count: number,
756
+ fraction: number
757
+ ): void {
758
+ for (let i = 0, o = 0; i < count; i++, o += 3) {
759
+ const x = current[o] + (current[o] - previous[o]) * fraction;
760
+ const y = current[o + 1] + (current[o + 1] - previous[o + 1]) * fraction;
761
+ const z = current[o + 2] + (current[o + 2] - previous[o + 2]) * fraction;
762
+ target[base + o] = x > 0 ? x : 0;
763
+ target[base + o + 1] = y > 0 ? y : 0;
764
+ target[base + o + 2] = z > 0 ? z : 0;
765
+ }
766
+ }
767
+
768
+ /** The same for colour, whose alpha runs out at the end of a life too. */
769
+ private static continueColor(
770
+ target: Float32Array,
771
+ base: number,
772
+ store: ParticleStore,
773
+ count: number,
774
+ fraction: number
775
+ ): void {
776
+ const current = store.color;
777
+ const previous = store.previousColor;
778
+ for (let i = 0, o = 0; i < count; i++, o += 4) {
779
+ const r = current[o] + (current[o] - previous[o]) * fraction;
780
+ const g = current[o + 1] + (current[o + 1] - previous[o + 1]) * fraction;
781
+ const b = current[o + 2] + (current[o + 2] - previous[o + 2]) * fraction;
782
+ const a = current[o + 3] + (current[o + 3] - previous[o + 3]) * fraction;
783
+ target[base + o] = r > 0 ? r : 0;
784
+ target[base + o + 1] = g > 0 ? g : 0;
785
+ target[base + o + 2] = b > 0 ? b : 0;
786
+ target[base + o + 3] = a > 0 ? a : 0;
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Writes rows `[0, count)` of the store's positions into `target` at `base`,
792
+ * each carried on by `fraction` of the step it just took.
793
+ *
794
+ * Continuing the measured last step covers every way a particle can move —
795
+ * velocity integration, an orbit, noise, a plugin's own behavior — because
796
+ * it reads what happened rather than predicting from one term of it. Over
797
+ * less than a step it is a straight line through a path that may curve,
798
+ * which for a sixtieth of a second is well under a pixel.
799
+ *
800
+ * A particle born during the step has the same position on both sides and
801
+ * so does not move at all, which is what puts it at the emitter rather than
802
+ * a step's travel away from it.
803
+ */
804
+ private static extrapolate(
805
+ target: Float32Array,
806
+ base: number,
807
+ store: ParticleStore,
808
+ count: number,
809
+ fraction: number
810
+ ): void {
811
+ const positions = store.position;
812
+ const previous = store.previousPosition;
813
+ for (let i = 0, o = 0; i < count; i++, o += 3) {
814
+ const x = positions[o];
815
+ const y = positions[o + 1];
816
+ const z = positions[o + 2];
817
+ target[base + o] = x + (x - previous[o]) * fraction;
818
+ target[base + o + 1] = y + (y - previous[o + 1]) * fraction;
819
+ target[base + o + 2] = z + (z - previous[o + 2]) * fraction;
398
820
  }
399
821
  }
400
822