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
package/src/TrailBatch.ts CHANGED
@@ -1,17 +1,18 @@
1
- import {Mesh} from '@babylonjs/core/Meshes/mesh';
2
1
  import {VertexBuffer} from '@babylonjs/core/Buffers/buffer';
3
- import {Effect} from '@babylonjs/core/Materials/effect';
4
- import {ShaderMaterial} from '@babylonjs/core/Materials/shaderMaterial';
5
- import {Scene} from '@babylonjs/core/scene';
6
2
  import {BoundingInfo} from '@babylonjs/core/Culling/boundingInfo';
7
- import {Vector3 as BVector3} from '@babylonjs/core/Maths/math.vector';
8
- import {Vector2 as BVector2} from '@babylonjs/core/Maths/math.vector';
9
3
  import {Constants} from '@babylonjs/core/Engines/constants';
10
- import {Matrix4, Quaternion, RecordState, TrailParticle, Vector3} from 'quarks.core';
11
- import {VFXBatch, RenderMode} from './VFXBatch';
4
+ import {ShaderMaterial} from '@babylonjs/core/Materials/shaderMaterial';
5
+ import {Vector2 as BVector2, Vector3 as BVector3} from '@babylonjs/core/Maths/math.vector';
6
+ import {Mesh} from '@babylonjs/core/Meshes/mesh';
7
+ import {Scene} from '@babylonjs/core/scene';
8
+ import {IParticleSystem, Matrix4, Quaternion, TrailParticle, TrailSettings, Vector3} from 'quarks.core';
12
9
  import {VFXBatchSettings} from './BatchedRenderer';
13
- import trail_vert from './shaders/trail_vert.glsl';
10
+ import {registerShaders, shaderLanguageFor} from './shaders/shaderLanguageSupport';
14
11
  import trail_frag from './shaders/trail_frag.glsl';
12
+ import trail_frag_wgsl from './shaders/trail_frag.wgsl';
13
+ import trail_vert from './shaders/trail_vert.glsl';
14
+ import trail_vert_wgsl from './shaders/trail_vert.wgsl';
15
+ import {VFXBatch} from './VFXBatch';
15
16
 
16
17
  export class TrailBatch extends VFXBatch {
17
18
  private positionBuffer!: Float32Array;
@@ -22,23 +23,19 @@ export class TrailBatch extends VFXBatch {
22
23
  private widthBuffer!: Float32Array;
23
24
  private colorBuffer!: Float32Array;
24
25
  private indexBuffer!: Uint32Array;
26
+ /** True while the batch is parked on the degenerate draw, see uploadGeometry. */
27
+ private drawsNothing = false;
25
28
  private previousVB!: VertexBuffer;
26
29
  private nextVB!: VertexBuffer;
27
30
  private sideVB!: VertexBuffer;
28
31
  private widthVB!: VertexBuffer;
29
32
 
30
- private getTrailHistoryCount(particle: TrailParticle): number {
31
- const trailAny = particle as any;
32
- const fastHistoryCount = trailAny._trailHistoryCount;
33
- if (typeof fastHistoryCount === 'number') {
34
- return fastHistoryCount;
35
- }
36
- return particle.previous.length;
37
- }
38
-
39
33
  constructor(settings: VFXBatchSettings, scene: Scene) {
40
34
  super(settings, scene);
41
- this.maxParticles = 10000;
35
+ // Trail buffers hold two vertices per recorded sample and are the largest
36
+ // per-batch allocation in the library, so start small and let
37
+ // expandBuffers grow to whatever the batch actually needs.
38
+ this.maxParticles = 1024;
42
39
  this.setupBuffers();
43
40
  this.rebuildMaterial();
44
41
  }
@@ -61,18 +58,42 @@ export class TrailBatch extends VFXBatch {
61
58
  this.indexBuffer = new Uint32Array(this.maxParticles * 6);
62
59
 
63
60
  // Initialize with full-capacity buffers (updatable) and a dummy triangle
64
- this.positionBuffer[0] = 0; this.positionBuffer[1] = 0; this.positionBuffer[2] = 0;
65
- this.positionBuffer[3] = 1; this.positionBuffer[4] = 0; this.positionBuffer[5] = 0;
66
- this.positionBuffer[6] = 0; this.positionBuffer[7] = 1; this.positionBuffer[8] = 0;
67
- this.positionBuffer[9] = 1; this.positionBuffer[10] = 1; this.positionBuffer[11] = 0;
68
- this.indexBuffer[0] = 0; this.indexBuffer[1] = 1; this.indexBuffer[2] = 2;
69
- this.indexBuffer[3] = 1; this.indexBuffer[4] = 3; this.indexBuffer[5] = 2;
61
+ this.positionBuffer[0] = 0;
62
+ this.positionBuffer[1] = 0;
63
+ this.positionBuffer[2] = 0;
64
+ this.positionBuffer[3] = 1;
65
+ this.positionBuffer[4] = 0;
66
+ this.positionBuffer[5] = 0;
67
+ this.positionBuffer[6] = 0;
68
+ this.positionBuffer[7] = 1;
69
+ this.positionBuffer[8] = 0;
70
+ this.positionBuffer[9] = 1;
71
+ this.positionBuffer[10] = 1;
72
+ this.positionBuffer[11] = 0;
73
+ this.indexBuffer[0] = 0;
74
+ this.indexBuffer[1] = 1;
75
+ this.indexBuffer[2] = 2;
76
+ this.indexBuffer[3] = 1;
77
+ this.indexBuffer[4] = 3;
78
+ this.indexBuffer[5] = 2;
70
79
 
71
80
  // Set standard vertex data with full buffer capacity (updatable)
72
81
  this.mesh.setVerticesData(VertexBuffer.PositionKind, this.positionBuffer, true);
73
82
  this.mesh.setVerticesData(VertexBuffer.UVKind, this.uvBuffer, true);
74
83
  this.mesh.setVerticesData(VertexBuffer.ColorKind, this.colorBuffer, true, 4);
75
- this.mesh.setIndices(this.indexBuffer.subarray(0, 6), null, true);
84
+ // The whole array, not just the dummy triangle: this call is what sizes
85
+ // the GPU index buffer, and updateIndices later writes into it without
86
+ // resizing it. Handing it six indices here left a 24 byte buffer that
87
+ // every later frame overran — WebGL tolerated it, WebGPU rejects the
88
+ // draw outright ("index range does not fit in index buffer size").
89
+ // The vertex buffers above are already created at full capacity; this
90
+ // was the one that was not.
91
+ this.mesh.setIndices(this.indexBuffer, null, true);
92
+ this.drawsNothing = false;
93
+ if (this.mesh.subMeshes && this.mesh.subMeshes.length > 0) {
94
+ // Until the first update, only the dummy triangle is real.
95
+ this.mesh.subMeshes[0].indexCount = 6;
96
+ }
76
97
  const engine = this.scene.getEngine();
77
98
  this.previousVB = new VertexBuffer(engine, this.previousBuffer, 'previous', true, false, 3, false);
78
99
  this.nextVB = new VertexBuffer(engine, this.nextBuffer, 'next', true, false, 3, false);
@@ -99,29 +120,52 @@ export class TrailBatch extends VFXBatch {
99
120
  while (target >= this.maxParticles) {
100
121
  this.maxParticles *= 2;
101
122
  }
123
+ // Release the previous custom attribute buffers; setVerticesBuffer only
124
+ // replaces the reference, so skipping this leaks GPU memory on every grow.
125
+ this.previousVB?.dispose();
126
+ this.nextVB?.dispose();
127
+ this.sideVB?.dispose();
128
+ this.widthVB?.dispose();
102
129
  this.buildGeometryBuffers();
103
130
  }
104
131
 
105
132
  rebuildMaterial(): void {
106
- const shaderName = `quarksTrail_${Date.now()}`;
133
+ // Stable name: the trail shader source never varies, so registering it once
134
+ // keeps Effect.ShadersStore bounded and lets Babylon reuse compiled effects.
135
+ const shaderName = 'quarksTrail';
107
136
  const defines: string[] = [];
108
137
 
109
138
  if (this.settings.texture) {
110
139
  defines.push('USE_MAP');
111
140
  }
112
141
 
113
- Effect.ShadersStore[shaderName + 'VertexShader'] = trail_vert;
114
- Effect.ShadersStore[shaderName + 'FragmentShader'] = trail_frag;
142
+ const shaderLanguage = shaderLanguageFor(this.scene.getEngine());
143
+ registerShaders(
144
+ shaderName,
145
+ {glsl: trail_vert, wgsl: trail_vert_wgsl},
146
+ {glsl: trail_frag, wgsl: trail_frag_wgsl},
147
+ shaderLanguage
148
+ );
115
149
 
116
150
  const attributes = ['position', 'previous', 'next', 'side', 'width', 'uv', 'color'];
117
- const uniforms = ['world', 'view', 'projection', 'worldViewProjection', 'lineWidth', 'resolution', 'sizeAttenuation'];
151
+ const uniforms = [
152
+ 'world',
153
+ 'view',
154
+ 'projection',
155
+ 'worldViewProjection',
156
+ 'lineWidth',
157
+ 'resolution',
158
+ 'sizeAttenuation',
159
+ ];
118
160
  const samplers: string[] = [];
119
161
 
120
162
  if (this.settings.texture) {
121
163
  samplers.push('map');
122
164
  }
123
165
 
124
- const mat = new ShaderMaterial(shaderName, this.scene,
166
+ const mat = new ShaderMaterial(
167
+ shaderName,
168
+ this.scene,
125
169
  {vertex: shaderName, fragment: shaderName},
126
170
  {
127
171
  attributes,
@@ -129,6 +173,7 @@ export class TrailBatch extends VFXBatch {
129
173
  samplers,
130
174
  defines,
131
175
  needAlphaBlending: this.settings.materialTransparent,
176
+ shaderLanguage,
132
177
  }
133
178
  );
134
179
 
@@ -137,11 +182,19 @@ export class TrailBatch extends VFXBatch {
137
182
  }
138
183
  const engine = this.scene.getEngine();
139
184
  mat.setFloat('lineWidth', 1);
140
- mat.setVector2('resolution', new BVector2(engine.getRenderWidth(), engine.getRenderHeight()));
185
+ // Reused across binds, and only re-uploaded when the render target resizes.
186
+ const resolution = new BVector2(engine.getRenderWidth(), engine.getRenderHeight());
187
+ mat.setVector2('resolution', resolution);
141
188
  mat.setFloat('sizeAttenuation', 1);
142
189
  mat.onBindObservable.add(() => {
143
190
  const renderEngine = this.scene.getEngine();
144
- mat.setVector2('resolution', new BVector2(renderEngine.getRenderWidth(), renderEngine.getRenderHeight()));
191
+ const width = renderEngine.getRenderWidth();
192
+ const height = renderEngine.getRenderHeight();
193
+ if (resolution.x !== width || resolution.y !== height) {
194
+ resolution.x = width;
195
+ resolution.y = height;
196
+ mat.setVector2('resolution', resolution);
197
+ }
145
198
  });
146
199
 
147
200
  mat.backFaceCulling = false;
@@ -163,30 +216,45 @@ export class TrailBatch extends VFXBatch {
163
216
  private vector3_ = new Vector3();
164
217
  private quaternion_ = new Quaternion();
165
218
 
219
+ /** Forces the emitter's world matrix to be current before sampling it. */
220
+ private static ensureWorldMatrix(system: IParticleSystem): void {
221
+ const emitter = system.emitter as {computeWorldMatrix?: (force: boolean) => void};
222
+ if (emitter.computeWorldMatrix) {
223
+ emitter.computeWorldMatrix(true);
224
+ }
225
+ }
226
+
166
227
  update(): void {
167
228
  let index = 0;
168
229
  let triangles = 0;
169
230
 
170
231
  let particleCount = 0;
171
232
  const visibleSystems = this.getVisibleSystems();
172
- for (const system of visibleSystems) {
233
+ for (let s = 0; s < visibleSystems.length; s++) {
234
+ const system = visibleSystems[s];
235
+ const particles = system.particles;
173
236
  for (let j = 0; j < system.particleNum; j++) {
174
- particleCount += this.getTrailHistoryCount(system.particles[j] as TrailParticle) * 2;
237
+ const historyCount = (particles[j] as TrailParticle).historyCount;
238
+ if (historyCount === 0) {
239
+ continue;
240
+ }
241
+ // +2 reserves the live tip vertex pair that frames between
242
+ // simulation steps append ahead of the recorded head.
243
+ particleCount += historyCount * 2 + 2;
175
244
  }
176
245
  }
177
246
  if (particleCount > this.maxParticles) {
178
247
  this.expandBuffers(particleCount);
179
248
  }
180
249
 
181
- for (const system of visibleSystems) {
182
- // Ensure world matrix is up to date
183
- if ((system.emitter as any).computeWorldMatrix) {
184
- (system.emitter as any).computeWorldMatrix(true);
185
- }
250
+ for (let s = 0; s < visibleSystems.length; s++) {
251
+ const system = visibleSystems[s];
252
+ TrailBatch.ensureWorldMatrix(system);
186
253
  const rotation = this.quaternion_;
187
254
  const translation = this.vector2_;
188
255
  const scale = this.vector3_;
189
- system.emitter.matrixWorld.decompose(translation, rotation, scale);
256
+ const emitterMatrix = system.emitter.matrixWorld;
257
+ emitterMatrix.decompose(translation, rotation, scale);
190
258
 
191
259
  const particles = system.particles;
192
260
  const particleNum = system.particleNum;
@@ -196,123 +264,127 @@ export class TrailBatch extends VFXBatch {
196
264
  const tileHeight = 1 / vTileCount;
197
265
  const systemWorldSpace = system.worldSpace;
198
266
  const objectScale = (Math.abs(scale.x) + Math.abs(scale.y) + Math.abs(scale.z)) / 3;
267
+ // A ribbon's samples were recorded at past steps and stay where they
268
+ // were. The particle itself has moved on since the step that wrote
269
+ // the newest sample, so frames that run no step would freeze the
270
+ // whole trail if that head were left behind. Moving the recorded
271
+ // head forward to catch up stretches the last segment from one
272
+ // step of travel to almost two, then snaps it back when the next
273
+ // step lands — a rubber-band the eye reads as lost smoothness,
274
+ // especially at 120Hz where every other frame is between steps.
275
+ // Appending a live tip ahead of the recorded samples keeps the
276
+ // body still and the tip moving without that pulse. A trail pinned
277
+ // to the emitter's origin is driven by the emitter's transform
278
+ // rather than by the particle's velocity, so it is left alone.
279
+ const trailSettings = system.rendererEmitterSettings as TrailSettings;
280
+ const uvFromHead = Boolean(trailSettings.uvFromHead);
281
+ const colorFromParticle = Boolean(trailSettings.colorFromParticle);
282
+ // Recorded samples already sit in world space; the live tip still comes
283
+ // from the particle's own (local) position.
284
+ const samplesInWorld = systemWorldSpace || Boolean(trailSettings.recordWorldSpace);
285
+ const residual = trailSettings.followLocalOrigin ? 0 : (system.simulationResidual ?? 0);
286
+ // Measured from the step the particle just took, not from its
287
+ // velocity: a trail pulled along by an orbit or by a plugin's own
288
+ // behavior has motion no velocity term describes.
289
+ const stepFraction = residual === 0 ? 0 : residual / (system.simulationStep ?? residual);
199
290
 
200
291
  for (let j = 0; j < particleNum; j++) {
201
292
  const particle = particles[j] as TrailParticle;
202
- const particleAny = particle as any;
203
- const fastHistoryCount = particleAny._trailHistoryCount as number | undefined;
204
- const fastHistoryCapacity = particleAny._trailHistoryCapacity as number | undefined;
205
- const fastHistoryHead = particleAny._trailHistoryHead as number | undefined;
206
- const fastHistoryPositions = particleAny._trailHistoryPositions as Float32Array | undefined;
207
- const fastHistorySizes = particleAny._trailHistorySizes as Float32Array | undefined;
208
- const fastHistoryColors = particleAny._trailHistoryColors as Float32Array | undefined;
209
- const hasFastHistory =
210
- typeof fastHistoryCount === 'number' &&
211
- fastHistoryCount > 0 &&
212
- typeof fastHistoryCapacity === 'number' &&
213
- typeof fastHistoryHead === 'number' &&
214
- fastHistoryPositions instanceof Float32Array &&
215
- fastHistorySizes instanceof Float32Array &&
216
- fastHistoryColors instanceof Float32Array;
217
- const historyCapacity = hasFastHistory ? (fastHistoryCapacity as number) : 0;
218
- const historyHead = hasFastHistory ? (fastHistoryHead as number) : 0;
219
- const historyPositions = fastHistoryPositions as Float32Array;
220
- const historySizes = fastHistorySizes as Float32Array;
221
- const historyColors = fastHistoryColors as Float32Array;
222
- const particleHistory = particle.previous;
223
- const particleHistoryLength = hasFastHistory ? fastHistoryCount : particleHistory.length;
293
+ const particleHistoryLength = particle.historyCount;
224
294
  if (particleHistoryLength === 0) {
225
295
  continue;
226
296
  }
297
+ const historyCapacity = particle.historyCapacity;
298
+ const historyPositions = particle.historyPositions;
299
+ const historySizes = particle.historySizes;
300
+ // Every sample takes its color from one place: its own recorded slot, or
301
+ // the particle's current row in the store.
302
+ const colors = colorFromParticle ? particle.store.color : particle.historyColors;
303
+ const particleColorIndex = particle.storeIndex * 4;
227
304
  const col = particle.uvTile % vTileCount;
228
305
  const row = Math.floor(particle.uvTile / vTileCount + 0.001);
229
306
  const particleMatrix = particle.parentMatrix as unknown as Matrix4 | undefined;
230
- const matrixToUse = particleMatrix ?? system.emitter.matrixWorld;
231
-
232
- let previousNode = particleHistory.head;
233
- let currentNode = previousNode;
234
- let nextNode = currentNode?.next ?? currentNode;
235
- const oldestFastHistoryIndex = hasFastHistory
236
- ? (historyHead - particleHistoryLength + historyCapacity) % historyCapacity
237
- : 0;
307
+ const me = (particleMatrix ?? emitterMatrix).elements;
308
+ const m00 = me[0],
309
+ m01 = me[1],
310
+ m02 = me[2],
311
+ m03 = me[3];
312
+ const m10 = me[4],
313
+ m11 = me[5],
314
+ m12 = me[6],
315
+ m13 = me[7];
316
+ const m20 = me[8],
317
+ m21 = me[9],
318
+ m22 = me[10],
319
+ m23 = me[11];
320
+ const m30 = me[12],
321
+ m31 = me[13],
322
+ m32 = me[14],
323
+ m33 = me[15];
324
+ // The texture spans the ribbon end to end: up to the live tip when one is
325
+ // drawn, otherwise up to the newest recorded sample.
326
+ const invHistoryLength =
327
+ 1 / (stepFraction !== 0 ? particleHistoryLength : Math.max(1, particleHistoryLength - 1));
328
+
329
+ // Walk the ring buffer oldest-first with hand-rolled wrapping so the
330
+ // inner loop stays free of modulo operations.
331
+ let currentSlot = particle.getHistoryIndex(0);
332
+ let previousSlot = currentSlot;
238
333
 
239
334
  for (let i = 0; i < particleHistoryLength; i++, index += 2) {
240
- let currentX: number;
241
- let currentY: number;
242
- let currentZ: number;
243
- let currentSize: number;
244
- let currentColorX: number;
245
- let currentColorY: number;
246
- let currentColorZ: number;
247
- let currentColorW: number;
248
- let previousX: number;
249
- let previousY: number;
250
- let previousZ: number;
251
- let nextX: number;
252
- let nextY: number;
253
- let nextZ: number;
254
-
255
- if (hasFastHistory) {
256
- const currentHistoryIndex = (oldestFastHistoryIndex + i) % historyCapacity;
257
- const previousHistoryIndex = i === 0 ? currentHistoryIndex : (oldestFastHistoryIndex + i - 1) % historyCapacity;
258
- const nextHistoryIndex =
259
- i + 1 < particleHistoryLength
260
- ? (oldestFastHistoryIndex + i + 1) % historyCapacity
261
- : currentHistoryIndex;
262
-
263
- const currentPosIndex = currentHistoryIndex * 3;
264
- const previousPosIndex = previousHistoryIndex * 3;
265
- const nextPosIndex = nextHistoryIndex * 3;
266
-
267
- currentX = historyPositions[currentPosIndex];
268
- currentY = historyPositions[currentPosIndex + 1];
269
- currentZ = historyPositions[currentPosIndex + 2];
270
- previousX = historyPositions[previousPosIndex];
271
- previousY = historyPositions[previousPosIndex + 1];
272
- previousZ = historyPositions[previousPosIndex + 2];
273
- nextX = historyPositions[nextPosIndex];
274
- nextY = historyPositions[nextPosIndex + 1];
275
- nextZ = historyPositions[nextPosIndex + 2];
276
-
277
- currentSize = historySizes[currentHistoryIndex];
278
- const currentColorIndex = currentHistoryIndex * 4;
279
- currentColorX = historyColors[currentColorIndex];
280
- currentColorY = historyColors[currentColorIndex + 1];
281
- currentColorZ = historyColors[currentColorIndex + 2];
282
- currentColorW = historyColors[currentColorIndex + 3];
283
- } else {
284
- const previous = previousNode!.data as RecordState;
285
- const current = currentNode!.data as RecordState;
286
- const next = nextNode!.data as RecordState;
287
- currentX = current.position.x;
288
- currentY = current.position.y;
289
- currentZ = current.position.z;
290
- previousX = previous.position.x;
291
- previousY = previous.position.y;
292
- previousZ = previous.position.z;
293
- nextX = next.position.x;
294
- nextY = next.position.y;
295
- nextZ = next.position.z;
296
- currentSize = current.size;
297
- currentColorX = current.color.x;
298
- currentColorY = current.color.y;
299
- currentColorZ = current.color.z;
300
- currentColorW = current.color.w;
301
- }
302
-
303
- if (!systemWorldSpace) {
304
- this.vector_.set(currentX, currentY, currentZ).applyMatrix4(matrixToUse);
305
- currentX = this.vector_.x;
306
- currentY = this.vector_.y;
307
- currentZ = this.vector_.z;
308
- this.vector_.set(previousX, previousY, previousZ).applyMatrix4(matrixToUse);
309
- previousX = this.vector_.x;
310
- previousY = this.vector_.y;
311
- previousZ = this.vector_.z;
312
- this.vector_.set(nextX, nextY, nextZ).applyMatrix4(matrixToUse);
313
- nextX = this.vector_.x;
314
- nextY = this.vector_.y;
315
- nextZ = this.vector_.z;
335
+ const nextSlot =
336
+ i + 1 < particleHistoryLength
337
+ ? currentSlot + 1 === historyCapacity
338
+ ? 0
339
+ : currentSlot + 1
340
+ : currentSlot;
341
+
342
+ const currentPosIndex = currentSlot * 3;
343
+ const previousPosIndex = previousSlot * 3;
344
+ const nextPosIndex = nextSlot * 3;
345
+
346
+ let currentX = historyPositions[currentPosIndex];
347
+ let currentY = historyPositions[currentPosIndex + 1];
348
+ let currentZ = historyPositions[currentPosIndex + 2];
349
+ let previousX = historyPositions[previousPosIndex];
350
+ let previousY = historyPositions[previousPosIndex + 1];
351
+ let previousZ = historyPositions[previousPosIndex + 2];
352
+ let nextX = historyPositions[nextPosIndex];
353
+ let nextY = historyPositions[nextPosIndex + 1];
354
+ let nextZ = historyPositions[nextPosIndex + 2];
355
+
356
+ const currentSize = historySizes[currentSlot];
357
+ const currentColorIndex = colorFromParticle ? particleColorIndex : currentSlot * 4;
358
+ const currentColorX = colors[currentColorIndex];
359
+ const currentColorY = colors[currentColorIndex + 1];
360
+ const currentColorZ = colors[currentColorIndex + 2];
361
+ const currentColorW = colors[currentColorIndex + 3];
362
+
363
+ previousSlot = currentSlot;
364
+ currentSlot = nextSlot;
365
+
366
+ if (!samplesInWorld) {
367
+ // Inlined point transforms — three per trail sample.
368
+ let w = 1 / (m03 * currentX + m13 * currentY + m23 * currentZ + m33);
369
+ let tx = (m00 * currentX + m10 * currentY + m20 * currentZ + m30) * w;
370
+ let ty = (m01 * currentX + m11 * currentY + m21 * currentZ + m31) * w;
371
+ currentZ = (m02 * currentX + m12 * currentY + m22 * currentZ + m32) * w;
372
+ currentX = tx;
373
+ currentY = ty;
374
+
375
+ w = 1 / (m03 * previousX + m13 * previousY + m23 * previousZ + m33);
376
+ tx = (m00 * previousX + m10 * previousY + m20 * previousZ + m30) * w;
377
+ ty = (m01 * previousX + m11 * previousY + m21 * previousZ + m31) * w;
378
+ previousZ = (m02 * previousX + m12 * previousY + m22 * previousZ + m32) * w;
379
+ previousX = tx;
380
+ previousY = ty;
381
+
382
+ w = 1 / (m03 * nextX + m13 * nextY + m23 * nextZ + m33);
383
+ tx = (m00 * nextX + m10 * nextY + m20 * nextZ + m30) * w;
384
+ ty = (m01 * nextX + m11 * nextY + m21 * nextZ + m31) * w;
385
+ nextZ = (m02 * nextX + m12 * nextY + m22 * nextZ + m32) * w;
386
+ nextX = tx;
387
+ nextY = ty;
316
388
  }
317
389
 
318
390
  const pi = index * 3;
@@ -349,9 +421,11 @@ export class TrailBatch extends VFXBatch {
349
421
  }
350
422
 
351
423
  const ui = index * 2;
352
- this.uvBuffer[ui] = (i / particleHistoryLength + col) * tileWidth;
424
+ const along = i * invHistoryLength;
425
+ const u = ((uvFromHead ? 1 - along : along) + col) * tileWidth;
426
+ this.uvBuffer[ui] = u;
353
427
  this.uvBuffer[ui + 1] = (vTileCount - row - 1) * tileHeight;
354
- this.uvBuffer[ui + 2] = (i / particleHistoryLength + col) * tileWidth;
428
+ this.uvBuffer[ui + 2] = u;
355
429
  this.uvBuffer[ui + 3] = (vTileCount - row) * tileHeight;
356
430
 
357
431
  const cci = index * 4;
@@ -374,39 +448,173 @@ export class TrailBatch extends VFXBatch {
374
448
  this.indexBuffer[triangles * 3 + 2] = index + 3;
375
449
  triangles++;
376
450
  }
451
+ }
452
+
453
+ // Live tip: same place a sprite would be drawn this frame, past
454
+ // the last recorded sample. The recorded ribbon stays put.
455
+ if (stepFraction !== 0) {
456
+ const previousHead = particle.previousPosition;
457
+ const position = particle.position;
458
+ let tipX = position.x + (position.x - previousHead.x) * stepFraction;
459
+ let tipY = position.y + (position.y - previousHead.y) * stepFraction;
460
+ let tipZ = position.z + (position.z - previousHead.z) * stepFraction;
461
+ const headIndex = index - 2;
462
+ const headX = this.positionBuffer[headIndex * 3];
463
+ const headY = this.positionBuffer[headIndex * 3 + 1];
464
+ const headZ = this.positionBuffer[headIndex * 3 + 2];
465
+
466
+ if (!systemWorldSpace) {
467
+ const w = 1 / (m03 * tipX + m13 * tipY + m23 * tipZ + m33);
468
+ const tx = (m00 * tipX + m10 * tipY + m20 * tipZ + m30) * w;
469
+ const ty = (m01 * tipX + m11 * tipY + m21 * tipZ + m31) * w;
470
+ tipZ = (m02 * tipX + m12 * tipY + m22 * tipZ + m32) * w;
471
+ tipX = tx;
472
+ tipY = ty;
473
+ }
474
+
475
+ const tipPi = index * 3;
476
+ this.positionBuffer[tipPi] = tipX;
477
+ this.positionBuffer[tipPi + 1] = tipY;
478
+ this.positionBuffer[tipPi + 2] = tipZ;
479
+ this.positionBuffer[tipPi + 3] = tipX;
480
+ this.positionBuffer[tipPi + 4] = tipY;
481
+ this.positionBuffer[tipPi + 5] = tipZ;
482
+
483
+ this.previousBuffer[tipPi] = headX;
484
+ this.previousBuffer[tipPi + 1] = headY;
485
+ this.previousBuffer[tipPi + 2] = headZ;
486
+ this.previousBuffer[tipPi + 3] = headX;
487
+ this.previousBuffer[tipPi + 4] = headY;
488
+ this.previousBuffer[tipPi + 5] = headZ;
489
+
490
+ this.nextBuffer[tipPi] = tipX;
491
+ this.nextBuffer[tipPi + 1] = tipY;
492
+ this.nextBuffer[tipPi + 2] = tipZ;
493
+ this.nextBuffer[tipPi + 3] = tipX;
494
+ this.nextBuffer[tipPi + 4] = tipY;
495
+ this.nextBuffer[tipPi + 5] = tipZ;
496
+
497
+ // The recorded head's `next` was itself; point it at the tip
498
+ // so the shader builds the live segment from one version of
499
+ // each endpoint.
500
+ const headPi = headIndex * 3;
501
+ this.nextBuffer[headPi] = tipX;
502
+ this.nextBuffer[headPi + 1] = tipY;
503
+ this.nextBuffer[headPi + 2] = tipZ;
504
+ this.nextBuffer[headPi + 3] = tipX;
505
+ this.nextBuffer[headPi + 4] = tipY;
506
+ this.nextBuffer[headPi + 5] = tipZ;
507
+
508
+ this.sideBuffer[index] = 1;
509
+ this.sideBuffer[index + 1] = -1;
377
510
 
378
- if (!hasFastHistory) {
379
- previousNode = currentNode;
380
- currentNode = nextNode;
381
- nextNode = nextNode?.next ?? nextNode;
511
+ const tipSize = particle.size.x;
512
+ if (systemWorldSpace || particle.parentMatrix) {
513
+ this.widthBuffer[index] = tipSize;
514
+ this.widthBuffer[index + 1] = tipSize;
515
+ } else {
516
+ this.widthBuffer[index] = tipSize * objectScale;
517
+ this.widthBuffer[index + 1] = tipSize * objectScale;
382
518
  }
519
+
520
+ const tipUi = index * 2;
521
+ const tipU = ((uvFromHead ? 0 : 1) + col) * tileWidth;
522
+ this.uvBuffer[tipUi] = tipU;
523
+ this.uvBuffer[tipUi + 1] = (vTileCount - row - 1) * tileHeight;
524
+ this.uvBuffer[tipUi + 2] = tipU;
525
+ this.uvBuffer[tipUi + 3] = (vTileCount - row) * tileHeight;
526
+
527
+ const tipColor = particle.color;
528
+ const tipCi = index * 4;
529
+ this.colorBuffer[tipCi] = tipColor.x;
530
+ this.colorBuffer[tipCi + 1] = tipColor.y;
531
+ this.colorBuffer[tipCi + 2] = tipColor.z;
532
+ this.colorBuffer[tipCi + 3] = tipColor.w;
533
+ this.colorBuffer[tipCi + 4] = tipColor.x;
534
+ this.colorBuffer[tipCi + 5] = tipColor.y;
535
+ this.colorBuffer[tipCi + 6] = tipColor.z;
536
+ this.colorBuffer[tipCi + 7] = tipColor.w;
537
+
538
+ this.indexBuffer[triangles * 3] = headIndex;
539
+ this.indexBuffer[triangles * 3 + 1] = headIndex + 1;
540
+ this.indexBuffer[triangles * 3 + 2] = index;
541
+ triangles++;
542
+ this.indexBuffer[triangles * 3] = index;
543
+ this.indexBuffer[triangles * 3 + 1] = headIndex + 1;
544
+ this.indexBuffer[triangles * 3 + 2] = index + 1;
545
+ triangles++;
546
+
547
+ index += 2;
383
548
  }
384
549
  }
385
550
  }
386
551
 
552
+ this.uploadGeometry(index, triangles);
553
+ }
554
+
555
+ /**
556
+ * Uploads the vertex data written by `update` and resizes the sub mesh.
557
+ *
558
+ * Split out of `update` on purpose: the Babylon mesh/sub-mesh plumbing here
559
+ * runs a handful of times per frame, and folding it into the per-vertex loop
560
+ * costs that loop its optimised code.
561
+ */
562
+ private uploadGeometry(index: number, triangles: number): void {
387
563
  if (index > 0 && triangles > 0) {
388
- this.mesh.updateVerticesData(VertexBuffer.PositionKind, this.positionBuffer);
389
- this.mesh.updateVerticesData(VertexBuffer.UVKind, this.uvBuffer);
564
+ // Upload only the vertices written this frame; the buffers are sized for
565
+ // the high-water mark of every system in the batch.
566
+ this.mesh.updateVerticesData(VertexBuffer.PositionKind, this.positionBuffer.subarray(0, index * 3));
567
+ this.mesh.updateVerticesData(VertexBuffer.UVKind, this.uvBuffer.subarray(0, index * 2));
390
568
  this.mesh.updateIndices(this.indexBuffer.subarray(0, triangles * 3));
391
- this.previousVB.update(this.previousBuffer);
392
- this.nextVB.update(this.nextBuffer);
393
- this.sideVB.update(this.sideBuffer);
394
- this.widthVB.update(this.widthBuffer);
569
+ this.previousVB.update(this.previousBuffer.subarray(0, index * 3));
570
+ this.nextVB.update(this.nextBuffer.subarray(0, index * 3));
571
+ this.sideVB.update(this.sideBuffer.subarray(0, index));
572
+ this.widthVB.update(this.widthBuffer.subarray(0, index));
395
573
 
396
574
  const colorVB = this.mesh.getVertexBuffer(VertexBuffer.ColorKind);
397
- if (colorVB) colorVB.update(this.colorBuffer);
575
+ if (colorVB) colorVB.update(this.colorBuffer.subarray(0, index * 4));
398
576
 
399
577
  if (this.mesh.subMeshes && this.mesh.subMeshes.length > 0) {
400
- const meshBoundingInfo = this.mesh.getBoundingInfo();
401
- for (const subMesh of this.mesh.subMeshes) {
402
- (subMesh as any)._boundingInfo = meshBoundingInfo;
403
- }
578
+ this.adoptMeshBounds();
404
579
  this.mesh.subMeshes[0].indexCount = triangles * 3;
405
580
  this.mesh.subMeshes[0].verticesCount = index;
406
581
  }
582
+ this.drawsNothing = false;
407
583
  } else if (this.mesh.subMeshes && this.mesh.subMeshes.length > 0) {
408
- this.mesh.subMeshes[0].indexCount = 0;
409
- this.mesh.subMeshes[0].verticesCount = 0;
584
+ // Nothing to draw. Leaving the count at zero still submits the draw,
585
+ // and WebGPU complains about it once per frame for as long as the
586
+ // batch stays empty — which, for a looping effect, is every gap
587
+ // between passes. Disabling the mesh instead costs a frame coming
588
+ // back, the flicker the sprite batch already works around.
589
+ //
590
+ // So draw a triangle whose corners are all the same vertex: it is a
591
+ // legal draw that rasterises nothing, whatever stale geometry is
592
+ // still in the buffers. Only written on the way into empty, not on
593
+ // every empty frame.
594
+ if (!this.drawsNothing) {
595
+ this.indexBuffer.fill(0, 0, 6);
596
+ this.mesh.updateIndices(this.indexBuffer.subarray(0, 6));
597
+ this.adoptMeshBounds();
598
+ this.drawsNothing = true;
599
+ }
600
+ this.mesh.subMeshes[0].indexCount = 6;
601
+ this.mesh.subMeshes[0].verticesCount = 1;
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Points every sub mesh at the mesh's own bounds.
607
+ *
608
+ * Updating indices rebuilds the sub meshes, and a fresh one computes its
609
+ * bounds from the vertex data — which this batch keeps deliberately stale
610
+ * beyond the range it draws. Transparent sorting reads those bounds every
611
+ * frame, so they have to be replaced again after every index update, not
612
+ * only when the geometry is built.
613
+ */
614
+ private adoptMeshBounds(): void {
615
+ const meshBoundingInfo = this.mesh.getBoundingInfo();
616
+ for (const subMesh of this.mesh.subMeshes) {
617
+ (subMesh as any)._boundingInfo = meshBoundingInfo;
410
618
  }
411
619
  }
412
620