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.
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,117 @@ 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 residual = (system.rendererEmitterSettings as TrailSettings).followLocalOrigin
280
+ ? 0
281
+ : (system.simulationResidual ?? 0);
282
+ // Measured from the step the particle just took, not from its
283
+ // velocity: a trail pulled along by an orbit or by a plugin's own
284
+ // behavior has motion no velocity term describes.
285
+ const stepFraction = residual === 0 ? 0 : residual / (system.simulationStep ?? residual);
199
286
 
200
287
  for (let j = 0; j < particleNum; j++) {
201
288
  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;
289
+ const particleHistoryLength = particle.historyCount;
224
290
  if (particleHistoryLength === 0) {
225
291
  continue;
226
292
  }
293
+ const historyCapacity = particle.historyCapacity;
294
+ const historyPositions = particle.historyPositions;
295
+ const historySizes = particle.historySizes;
296
+ const historyColors = particle.historyColors;
227
297
  const col = particle.uvTile % vTileCount;
228
298
  const row = Math.floor(particle.uvTile / vTileCount + 0.001);
229
299
  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;
300
+ const me = (particleMatrix ?? emitterMatrix).elements;
301
+ const m00 = me[0],
302
+ m01 = me[1],
303
+ m02 = me[2],
304
+ m03 = me[3];
305
+ const m10 = me[4],
306
+ m11 = me[5],
307
+ m12 = me[6],
308
+ m13 = me[7];
309
+ const m20 = me[8],
310
+ m21 = me[9],
311
+ m22 = me[10],
312
+ m23 = me[11];
313
+ const m30 = me[12],
314
+ m31 = me[13],
315
+ m32 = me[14],
316
+ m33 = me[15];
317
+ const invHistoryLength = 1 / particleHistoryLength;
318
+
319
+ // Walk the ring buffer oldest-first with hand-rolled wrapping so the
320
+ // inner loop stays free of modulo operations.
321
+ let currentSlot = particle.getHistoryIndex(0);
322
+ let previousSlot = currentSlot;
238
323
 
239
324
  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
- }
325
+ const nextSlot =
326
+ i + 1 < particleHistoryLength
327
+ ? currentSlot + 1 === historyCapacity
328
+ ? 0
329
+ : currentSlot + 1
330
+ : currentSlot;
331
+
332
+ const currentPosIndex = currentSlot * 3;
333
+ const previousPosIndex = previousSlot * 3;
334
+ const nextPosIndex = nextSlot * 3;
335
+
336
+ let currentX = historyPositions[currentPosIndex];
337
+ let currentY = historyPositions[currentPosIndex + 1];
338
+ let currentZ = historyPositions[currentPosIndex + 2];
339
+ let previousX = historyPositions[previousPosIndex];
340
+ let previousY = historyPositions[previousPosIndex + 1];
341
+ let previousZ = historyPositions[previousPosIndex + 2];
342
+ let nextX = historyPositions[nextPosIndex];
343
+ let nextY = historyPositions[nextPosIndex + 1];
344
+ let nextZ = historyPositions[nextPosIndex + 2];
345
+
346
+ const currentSize = historySizes[currentSlot];
347
+ const currentColorIndex = currentSlot * 4;
348
+ const currentColorX = historyColors[currentColorIndex];
349
+ const currentColorY = historyColors[currentColorIndex + 1];
350
+ const currentColorZ = historyColors[currentColorIndex + 2];
351
+ const currentColorW = historyColors[currentColorIndex + 3];
352
+
353
+ previousSlot = currentSlot;
354
+ currentSlot = nextSlot;
302
355
 
303
356
  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;
357
+ // Inlined point transforms — three per trail sample.
358
+ let w = 1 / (m03 * currentX + m13 * currentY + m23 * currentZ + m33);
359
+ let tx = (m00 * currentX + m10 * currentY + m20 * currentZ + m30) * w;
360
+ let ty = (m01 * currentX + m11 * currentY + m21 * currentZ + m31) * w;
361
+ currentZ = (m02 * currentX + m12 * currentY + m22 * currentZ + m32) * w;
362
+ currentX = tx;
363
+ currentY = ty;
364
+
365
+ w = 1 / (m03 * previousX + m13 * previousY + m23 * previousZ + m33);
366
+ tx = (m00 * previousX + m10 * previousY + m20 * previousZ + m30) * w;
367
+ ty = (m01 * previousX + m11 * previousY + m21 * previousZ + m31) * w;
368
+ previousZ = (m02 * previousX + m12 * previousY + m22 * previousZ + m32) * w;
369
+ previousX = tx;
370
+ previousY = ty;
371
+
372
+ w = 1 / (m03 * nextX + m13 * nextY + m23 * nextZ + m33);
373
+ tx = (m00 * nextX + m10 * nextY + m20 * nextZ + m30) * w;
374
+ ty = (m01 * nextX + m11 * nextY + m21 * nextZ + m31) * w;
375
+ nextZ = (m02 * nextX + m12 * nextY + m22 * nextZ + m32) * w;
376
+ nextX = tx;
377
+ nextY = ty;
316
378
  }
317
379
 
318
380
  const pi = index * 3;
@@ -349,9 +411,10 @@ export class TrailBatch extends VFXBatch {
349
411
  }
350
412
 
351
413
  const ui = index * 2;
352
- this.uvBuffer[ui] = (i / particleHistoryLength + col) * tileWidth;
414
+ const u = (i * invHistoryLength + col) * tileWidth;
415
+ this.uvBuffer[ui] = u;
353
416
  this.uvBuffer[ui + 1] = (vTileCount - row - 1) * tileHeight;
354
- this.uvBuffer[ui + 2] = (i / particleHistoryLength + col) * tileWidth;
417
+ this.uvBuffer[ui + 2] = u;
355
418
  this.uvBuffer[ui + 3] = (vTileCount - row) * tileHeight;
356
419
 
357
420
  const cci = index * 4;
@@ -374,39 +437,173 @@ export class TrailBatch extends VFXBatch {
374
437
  this.indexBuffer[triangles * 3 + 2] = index + 3;
375
438
  triangles++;
376
439
  }
440
+ }
377
441
 
378
- if (!hasFastHistory) {
379
- previousNode = currentNode;
380
- currentNode = nextNode;
381
- nextNode = nextNode?.next ?? nextNode;
442
+ // Live tip: same place a sprite would be drawn this frame, past
443
+ // the last recorded sample. The recorded ribbon stays put.
444
+ if (stepFraction !== 0) {
445
+ const previousHead = particle.previousPosition;
446
+ const position = particle.position;
447
+ let tipX = position.x + (position.x - previousHead.x) * stepFraction;
448
+ let tipY = position.y + (position.y - previousHead.y) * stepFraction;
449
+ let tipZ = position.z + (position.z - previousHead.z) * stepFraction;
450
+ const headIndex = index - 2;
451
+ const headX = this.positionBuffer[headIndex * 3];
452
+ const headY = this.positionBuffer[headIndex * 3 + 1];
453
+ const headZ = this.positionBuffer[headIndex * 3 + 2];
454
+
455
+ if (!systemWorldSpace) {
456
+ const w = 1 / (m03 * tipX + m13 * tipY + m23 * tipZ + m33);
457
+ const tx = (m00 * tipX + m10 * tipY + m20 * tipZ + m30) * w;
458
+ const ty = (m01 * tipX + m11 * tipY + m21 * tipZ + m31) * w;
459
+ tipZ = (m02 * tipX + m12 * tipY + m22 * tipZ + m32) * w;
460
+ tipX = tx;
461
+ tipY = ty;
382
462
  }
463
+
464
+ const tipPi = index * 3;
465
+ this.positionBuffer[tipPi] = tipX;
466
+ this.positionBuffer[tipPi + 1] = tipY;
467
+ this.positionBuffer[tipPi + 2] = tipZ;
468
+ this.positionBuffer[tipPi + 3] = tipX;
469
+ this.positionBuffer[tipPi + 4] = tipY;
470
+ this.positionBuffer[tipPi + 5] = tipZ;
471
+
472
+ this.previousBuffer[tipPi] = headX;
473
+ this.previousBuffer[tipPi + 1] = headY;
474
+ this.previousBuffer[tipPi + 2] = headZ;
475
+ this.previousBuffer[tipPi + 3] = headX;
476
+ this.previousBuffer[tipPi + 4] = headY;
477
+ this.previousBuffer[tipPi + 5] = headZ;
478
+
479
+ this.nextBuffer[tipPi] = tipX;
480
+ this.nextBuffer[tipPi + 1] = tipY;
481
+ this.nextBuffer[tipPi + 2] = tipZ;
482
+ this.nextBuffer[tipPi + 3] = tipX;
483
+ this.nextBuffer[tipPi + 4] = tipY;
484
+ this.nextBuffer[tipPi + 5] = tipZ;
485
+
486
+ // The recorded head's `next` was itself; point it at the tip
487
+ // so the shader builds the live segment from one version of
488
+ // each endpoint.
489
+ const headPi = headIndex * 3;
490
+ this.nextBuffer[headPi] = tipX;
491
+ this.nextBuffer[headPi + 1] = tipY;
492
+ this.nextBuffer[headPi + 2] = tipZ;
493
+ this.nextBuffer[headPi + 3] = tipX;
494
+ this.nextBuffer[headPi + 4] = tipY;
495
+ this.nextBuffer[headPi + 5] = tipZ;
496
+
497
+ this.sideBuffer[index] = 1;
498
+ this.sideBuffer[index + 1] = -1;
499
+
500
+ const tipSize = particle.size.x;
501
+ if (systemWorldSpace || particle.parentMatrix) {
502
+ this.widthBuffer[index] = tipSize;
503
+ this.widthBuffer[index + 1] = tipSize;
504
+ } else {
505
+ this.widthBuffer[index] = tipSize * objectScale;
506
+ this.widthBuffer[index + 1] = tipSize * objectScale;
507
+ }
508
+
509
+ const tipUi = index * 2;
510
+ const tipU = (1 + col) * tileWidth;
511
+ this.uvBuffer[tipUi] = tipU;
512
+ this.uvBuffer[tipUi + 1] = (vTileCount - row - 1) * tileHeight;
513
+ this.uvBuffer[tipUi + 2] = tipU;
514
+ this.uvBuffer[tipUi + 3] = (vTileCount - row) * tileHeight;
515
+
516
+ const tipColor = particle.color;
517
+ const tipCi = index * 4;
518
+ this.colorBuffer[tipCi] = tipColor.x;
519
+ this.colorBuffer[tipCi + 1] = tipColor.y;
520
+ this.colorBuffer[tipCi + 2] = tipColor.z;
521
+ this.colorBuffer[tipCi + 3] = tipColor.w;
522
+ this.colorBuffer[tipCi + 4] = tipColor.x;
523
+ this.colorBuffer[tipCi + 5] = tipColor.y;
524
+ this.colorBuffer[tipCi + 6] = tipColor.z;
525
+ this.colorBuffer[tipCi + 7] = tipColor.w;
526
+
527
+ this.indexBuffer[triangles * 3] = headIndex;
528
+ this.indexBuffer[triangles * 3 + 1] = headIndex + 1;
529
+ this.indexBuffer[triangles * 3 + 2] = index;
530
+ triangles++;
531
+ this.indexBuffer[triangles * 3] = index;
532
+ this.indexBuffer[triangles * 3 + 1] = headIndex + 1;
533
+ this.indexBuffer[triangles * 3 + 2] = index + 1;
534
+ triangles++;
535
+
536
+ index += 2;
383
537
  }
384
538
  }
385
539
  }
386
540
 
541
+ this.uploadGeometry(index, triangles);
542
+ }
543
+
544
+ /**
545
+ * Uploads the vertex data written by `update` and resizes the sub mesh.
546
+ *
547
+ * Split out of `update` on purpose: the Babylon mesh/sub-mesh plumbing here
548
+ * runs a handful of times per frame, and folding it into the per-vertex loop
549
+ * costs that loop its optimised code.
550
+ */
551
+ private uploadGeometry(index: number, triangles: number): void {
387
552
  if (index > 0 && triangles > 0) {
388
- this.mesh.updateVerticesData(VertexBuffer.PositionKind, this.positionBuffer);
389
- this.mesh.updateVerticesData(VertexBuffer.UVKind, this.uvBuffer);
553
+ // Upload only the vertices written this frame; the buffers are sized for
554
+ // the high-water mark of every system in the batch.
555
+ this.mesh.updateVerticesData(VertexBuffer.PositionKind, this.positionBuffer.subarray(0, index * 3));
556
+ this.mesh.updateVerticesData(VertexBuffer.UVKind, this.uvBuffer.subarray(0, index * 2));
390
557
  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);
558
+ this.previousVB.update(this.previousBuffer.subarray(0, index * 3));
559
+ this.nextVB.update(this.nextBuffer.subarray(0, index * 3));
560
+ this.sideVB.update(this.sideBuffer.subarray(0, index));
561
+ this.widthVB.update(this.widthBuffer.subarray(0, index));
395
562
 
396
563
  const colorVB = this.mesh.getVertexBuffer(VertexBuffer.ColorKind);
397
- if (colorVB) colorVB.update(this.colorBuffer);
564
+ if (colorVB) colorVB.update(this.colorBuffer.subarray(0, index * 4));
398
565
 
399
566
  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
- }
567
+ this.adoptMeshBounds();
404
568
  this.mesh.subMeshes[0].indexCount = triangles * 3;
405
569
  this.mesh.subMeshes[0].verticesCount = index;
406
570
  }
571
+ this.drawsNothing = false;
407
572
  } else if (this.mesh.subMeshes && this.mesh.subMeshes.length > 0) {
408
- this.mesh.subMeshes[0].indexCount = 0;
409
- this.mesh.subMeshes[0].verticesCount = 0;
573
+ // Nothing to draw. Leaving the count at zero still submits the draw,
574
+ // and WebGPU complains about it once per frame for as long as the
575
+ // batch stays empty — which, for a looping effect, is every gap
576
+ // between passes. Disabling the mesh instead costs a frame coming
577
+ // back, the flicker the sprite batch already works around.
578
+ //
579
+ // So draw a triangle whose corners are all the same vertex: it is a
580
+ // legal draw that rasterises nothing, whatever stale geometry is
581
+ // still in the buffers. Only written on the way into empty, not on
582
+ // every empty frame.
583
+ if (!this.drawsNothing) {
584
+ this.indexBuffer.fill(0, 0, 6);
585
+ this.mesh.updateIndices(this.indexBuffer.subarray(0, 6));
586
+ this.adoptMeshBounds();
587
+ this.drawsNothing = true;
588
+ }
589
+ this.mesh.subMeshes[0].indexCount = 6;
590
+ this.mesh.subMeshes[0].verticesCount = 1;
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Points every sub mesh at the mesh's own bounds.
596
+ *
597
+ * Updating indices rebuilds the sub meshes, and a fresh one computes its
598
+ * bounds from the vertex data — which this batch keeps deliberately stale
599
+ * beyond the range it draws. Transparent sorting reads those bounds every
600
+ * frame, so they have to be replaced again after every index update, not
601
+ * only when the geometry is built.
602
+ */
603
+ private adoptMeshBounds(): void {
604
+ const meshBoundingInfo = this.mesh.getBoundingInfo();
605
+ for (const subMesh of this.mesh.subMeshes) {
606
+ (subMesh as any)._boundingInfo = meshBoundingInfo;
410
607
  }
411
608
  }
412
609