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