babylon.quarks 0.26.0 → 0.28.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 (83) hide show
  1. package/CHANGELOG.md +1194 -8
  2. package/README.md +13 -9
  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.cts +10108 -0
  10. package/dist/types/index.d.ts +5794 -998
  11. package/package.json +18 -7
  12. package/src/BatchedRenderer.ts +274 -156
  13. package/src/EffectPool.ts +94 -12
  14. package/src/MeshSurfaceEmitter.ts +50 -14
  15. package/src/ParticleEmitter.ts +75 -17
  16. package/src/ParticleSystem.ts +981 -2066
  17. package/src/QuarksLoader.ts +666 -194
  18. package/src/QuarksPrefab.ts +367 -73
  19. package/src/QuarksSerializer.ts +512 -0
  20. package/src/QuarksUtil.ts +77 -7
  21. package/src/SpriteBatch.ts +127 -252
  22. package/src/TrailBatch.ts +545 -348
  23. package/src/VFXBatch.ts +238 -596
  24. package/src/batchKey.ts +239 -0
  25. package/src/batchSettings.ts +546 -0
  26. package/src/effectDocumentJSON.ts +261 -0
  27. package/src/effectResources.ts +76 -0
  28. package/src/envAtlas.ts +126 -18
  29. package/src/geometryUtil.ts +1 -1
  30. package/src/index.ts +69 -35
  31. package/src/nodeId.ts +36 -0
  32. package/src/shaderProgram.ts +419 -0
  33. package/src/shaderVariants.ts +245 -0
  34. package/src/shaders/chunks/blend.ts +31 -0
  35. package/src/shaders/chunks/fog.ts +12 -5
  36. package/src/shaders/chunks/sheet.ts +111 -0
  37. package/src/shaders/chunks/soft.ts +85 -0
  38. package/src/shaders/chunks/surface.ts +12 -2
  39. package/src/shaders/local_particle_physics_vert.glsl.ts +14 -28
  40. package/src/shaders/local_particle_physics_vert.wgsl.ts +16 -29
  41. package/src/shaders/particle_frag.glsl.ts +5 -22
  42. package/src/shaders/particle_frag.wgsl.ts +5 -23
  43. package/src/shaders/particle_physics_frag.glsl.ts +16 -26
  44. package/src/shaders/particle_physics_frag.wgsl.ts +14 -26
  45. package/src/shaders/particle_vert.glsl.ts +5 -27
  46. package/src/shaders/particle_vert.wgsl.ts +5 -26
  47. package/src/shaders/stretched_bb_particle_vert.glsl.ts +7 -33
  48. package/src/shaders/stretched_bb_particle_vert.wgsl.ts +7 -33
  49. package/src/shaders/trail_frag.glsl.ts +13 -0
  50. package/src/shaders/trail_frag.wgsl.ts +13 -0
  51. package/src/shaders/trail_vert.glsl.ts +45 -36
  52. package/src/shaders/trail_vert.wgsl.ts +46 -40
  53. package/src/softDepth.ts +136 -0
  54. package/src/system/BehaviorPlan.ts +85 -0
  55. package/src/system/BoundsTracker.ts +181 -0
  56. package/src/system/EmissionController.ts +358 -0
  57. package/src/system/EventDispatcher.ts +60 -0
  58. package/src/system/ParticleSpawner.ts +274 -0
  59. package/src/system/SimulationClock.ts +130 -0
  60. package/src/system/geometry.ts +104 -0
  61. package/src/system/integrate.ts +428 -0
  62. package/src/system/materialResolve.ts +165 -0
  63. package/src/system/normalize.ts +100 -0
  64. package/src/system/serialization.ts +422 -0
  65. package/src/system/settingsSchema.ts +339 -0
  66. package/src/system/types.ts +449 -0
  67. package/src/textureSource.ts +30 -0
  68. package/src/trailSettings.ts +54 -0
  69. package/src/BatchedParticleRenderer.ts +0 -3
  70. package/src/materials/ParticleMaterials.ts +0 -23
  71. package/src/materials/index.ts +0 -1
  72. package/src/shaders/chunks/index.ts +0 -1
  73. package/src/shaders/chunks/register-shader-chunks.ts +0 -32
  74. package/src/shaders/chunks/soft_fragment.glsl.ts +0 -13
  75. package/src/shaders/chunks/soft_pars_fragment.glsl.ts +0 -20
  76. package/src/shaders/chunks/soft_pars_vertex.glsl.ts +0 -6
  77. package/src/shaders/chunks/soft_vertex.glsl.ts +0 -6
  78. package/src/shaders/chunks/tile_fragment.glsl.ts +0 -9
  79. package/src/shaders/chunks/tile_pars_fragment.glsl.ts +0 -9
  80. package/src/shaders/chunks/tile_pars_vertex.glsl.ts +0 -28
  81. package/src/shaders/chunks/tile_vertex.glsl.ts +0 -18
  82. package/src/shaders/index.ts +0 -9
  83. package/src/shaders/local_particle_vert.glsl.ts +0 -50
package/src/TrailBatch.ts CHANGED
@@ -1,19 +1,103 @@
1
1
  import {VertexBuffer} from '@babylonjs/core/Buffers/buffer';
2
2
  import {Constants} from '@babylonjs/core/Engines/constants';
3
3
  import {ShaderMaterial} from '@babylonjs/core/Materials/shaderMaterial';
4
- import {Vector2 as BVector2} from '@babylonjs/core/Maths/math.vector';
5
4
  import {Mesh} from '@babylonjs/core/Meshes/mesh';
6
5
  import {Scene} from '@babylonjs/core/scene';
7
6
  import {IParticleSystem, Matrix4, Quaternion, TrailParticle, TrailSettings, Vector3} from 'quarks.core';
8
7
  import {VFXBatchSettings} from './BatchedRenderer';
9
8
  import type {ParticleSystem} from './ParticleSystem';
9
+ import {shaderProgram} from './shaderProgram';
10
+ import {sheetCell} from './shaders/chunks/sheet';
10
11
  import {registerShaders, shaderLanguageFor} from './shaders/shaderLanguageSupport';
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';
12
+ import type {DepthBinding} from './softDepth';
13
+ import {
14
+ trailCurveTension,
15
+ trailMinVertexDistance,
16
+ trailTessellation,
17
+ trailTextureMode,
18
+ trailTextureScale,
19
+ } from './trailSettings';
15
20
  import {VFXBatch} from './VFXBatch';
16
21
 
22
+ /** The texture modes as the vertex loop tells them apart, see `TrailSettings.textureMode`. */
23
+ const enum TextureMode {
24
+ Stretch,
25
+ Tile,
26
+ DistributePerSegment,
27
+ RepeatPerSegment,
28
+ }
29
+
30
+ /**
31
+ * One trail's points for a frame, in world space: where each lies, its side
32
+ * when the trail is swept, its width, its color and its distance along the
33
+ * trail. Grown as needed and kept, so drawing a trail allocates nothing.
34
+ */
35
+ class TrailSamples {
36
+ position = new Float32Array(0);
37
+ side = new Float32Array(0);
38
+ size = new Float32Array(0);
39
+ color = new Float32Array(0);
40
+ /** Distance along the trail, as recorded (`TrailParticle.historyDistances`). */
41
+ distance = new Float32Array(0);
42
+
43
+ reserve(count: number): this {
44
+ if (count > this.size.length) {
45
+ const capacity = Math.max(count, this.size.length * 2, 64);
46
+ this.position = new Float32Array(capacity * 3);
47
+ this.side = new Float32Array(capacity * 3);
48
+ this.size = new Float32Array(capacity);
49
+ this.color = new Float32Array(capacity * 4);
50
+ this.distance = new Float32Array(capacity);
51
+ }
52
+ return this;
53
+ }
54
+
55
+ /** Sample `i` at the point, carried by the matrix `m` (column-major elements) when given. */
56
+ writePoint(i: number, x: number, y: number, z: number, m: ArrayLike<number> | null): void {
57
+ const o = i * 3;
58
+ if (m) {
59
+ const w = 1 / (m[3] * x + m[7] * y + m[11] * z + m[15]);
60
+ this.position[o] = (m[0] * x + m[4] * y + m[8] * z + m[12]) * w;
61
+ this.position[o + 1] = (m[1] * x + m[5] * y + m[9] * z + m[13]) * w;
62
+ this.position[o + 2] = (m[2] * x + m[6] * y + m[10] * z + m[14]) * w;
63
+ } else {
64
+ this.position[o] = x;
65
+ this.position[o + 1] = y;
66
+ this.position[o + 2] = z;
67
+ }
68
+ }
69
+
70
+ /** Sample `i`'s side, a direction turned (and scaled) by `m` when given. */
71
+ writeSide(i: number, x: number, y: number, z: number, m: ArrayLike<number> | null): void {
72
+ const o = i * 3;
73
+ if (m) {
74
+ this.side[o] = m[0] * x + m[4] * y + m[8] * z;
75
+ this.side[o + 1] = m[1] * x + m[5] * y + m[9] * z;
76
+ this.side[o + 2] = m[2] * x + m[6] * y + m[10] * z;
77
+ } else {
78
+ this.side[o] = x;
79
+ this.side[o + 1] = y;
80
+ this.side[o + 2] = z;
81
+ }
82
+ }
83
+
84
+ writeColor(i: number, r: number, g: number, b: number, a: number): void {
85
+ const o = i * 4;
86
+ this.color[o] = r;
87
+ this.color[o + 1] = g;
88
+ this.color[o + 2] = b;
89
+ this.color[o + 3] = a;
90
+ }
91
+ }
92
+
93
+ /** Knot spacing of a centripetal spline: the square root of the length between two points of `p`. */
94
+ function knot(p: Float32Array, from: number, to: number): number {
95
+ return Math.sqrt(
96
+ Math.sqrt((p[to] - p[from]) ** 2 + (p[to + 1] - p[from + 1]) ** 2 + (p[to + 2] - p[from + 2]) ** 2)
97
+ );
98
+ }
99
+
100
+ /** The batch of trail particles. @internal */
17
101
  export class TrailBatch extends VFXBatch {
18
102
  private positionBuffer!: Float32Array;
19
103
  private previousBuffer!: Float32Array;
@@ -32,33 +116,36 @@ export class TrailBatch extends VFXBatch {
32
116
  private sideVB!: VertexBuffer;
33
117
  private widthVB!: VertexBuffer;
34
118
 
35
- constructor(settings: VFXBatchSettings, scene: Scene) {
36
- super(settings, scene);
37
- // Trail buffers hold two vertices per recorded sample and are the largest
38
- // per-batch allocation in the library, so start small and let
39
- // expandBuffers grow to whatever the batch actually needs.
40
- this.maxParticles = 1024;
119
+ constructor(settings: VFXBatchSettings, scene: Scene, depth: DepthBinding | null = null) {
120
+ super(settings, scene, depth);
121
+ // For a trail batch `maxParticles` counts vertices, two per drawn point: the buffers are
122
+ // the largest per-batch allocation in the library, so start small and let expandBuffers
123
+ // grow to whatever the batch actually needs.
124
+ this.maxParticles = this.initialCapacity;
41
125
  this.setupBuffers();
42
126
  this.rebuildMaterial();
43
127
  }
44
128
 
129
+ /** @internal */
45
130
  setupBuffers(): void {
46
- this.mesh.dispose();
131
+ this.mesh?.dispose();
47
132
  this.mesh = new Mesh('trailBatch', this.scene);
48
133
  this.configureMesh();
49
134
  this.buildGeometryBuffers();
50
135
  }
51
136
 
52
137
  private buildGeometryBuffers(): void {
53
- this.positionBuffer = new Float32Array(this.maxParticles * 6);
54
- this.previousBuffer = new Float32Array(this.maxParticles * 6);
55
- this.nextBuffer = new Float32Array(this.maxParticles * 6);
56
- this.uvBuffer = new Float32Array(this.maxParticles * 4);
57
- this.uv1Buffer = this.usesUV1() ? new Float32Array(this.maxParticles * 4) : null;
58
- this.sideBuffer = new Float32Array(this.maxParticles * 2);
59
- this.widthBuffer = new Float32Array(this.maxParticles * 2);
60
- this.colorBuffer = new Float32Array(this.maxParticles * 8);
61
- this.indexBuffer = new Uint32Array(this.maxParticles * 6);
138
+ // `maxParticles` vertices; a ribbon of n points is 2n vertices and 6(n - 1) indices.
139
+ const vertices = this.maxParticles;
140
+ this.positionBuffer = new Float32Array(vertices * 3);
141
+ this.previousBuffer = new Float32Array(vertices * 3);
142
+ this.nextBuffer = new Float32Array(vertices * 3);
143
+ this.uvBuffer = new Float32Array(vertices * 2);
144
+ this.uv1Buffer = this.usesUV1() ? new Float32Array(vertices * 2) : null;
145
+ this.sideBuffer = new Float32Array(vertices);
146
+ this.widthBuffer = new Float32Array(vertices);
147
+ this.colorBuffer = new Float32Array(vertices * 4);
148
+ this.indexBuffer = new Uint32Array(vertices * 3);
62
149
  // Vertices come in pairs, one per side of the ribbon, so the side never
63
150
  // changes: written once here and never uploaded again.
64
151
  for (let i = 0; i < this.sideBuffer.length; i += 2) {
@@ -123,12 +210,21 @@ export class TrailBatch extends VFXBatch {
123
210
  }
124
211
  }
125
212
 
213
+ /** @internal */
214
+ protected readonly initialCapacity = 1024;
215
+
216
+ /** Grows the buffers to hold more than `target` vertices. @internal */
126
217
  expandBuffers(target: number): void {
127
218
  while (target >= this.maxParticles) {
128
219
  this.maxParticles *= 2;
129
220
  }
130
- // Release the previous custom attribute buffers; setVerticesBuffer only
131
- // replaces the reference, so skipping this leaks GPU memory on every grow.
221
+ this.resizeBuffers();
222
+ }
223
+
224
+ /** @internal */
225
+ protected resizeBuffers(): void {
226
+ // `Geometry.setVerticesBuffer` disposes the buffer it replaces; these custom attributes
227
+ // are not in the geometry's map, so they are released here.
132
228
  this.previousVB?.dispose();
133
229
  this.nextVB?.dispose();
134
230
  this.sideVB?.dispose();
@@ -136,45 +232,15 @@ export class TrailBatch extends VFXBatch {
136
232
  this.buildGeometryBuffers();
137
233
  }
138
234
 
235
+ /** @internal */
139
236
  rebuildMaterial(): void {
237
+ const program = shaderProgram(this.settings, {depth: this.depth, envAtlasReady: false, meshUV2: false});
238
+ const {defines, attributes, uniforms, samplers} = program;
140
239
  // Stable name: the trail shader source never varies, so registering it once
141
240
  // keeps Effect.ShadersStore bounded and lets Babylon reuse compiled effects.
142
- const shaderName = 'quarksTrail';
143
- const defines: string[] = this.fogDefines();
144
-
145
- if (this.settings.texture) {
146
- defines.push('USE_MAP');
147
- }
148
- defines.push(...this.surfaceDefines());
149
-
241
+ const shaderName = program.name;
150
242
  const shaderLanguage = shaderLanguageFor(this.scene.getEngine());
151
- registerShaders(
152
- shaderName,
153
- {glsl: trail_vert, wgsl: trail_vert_wgsl},
154
- {glsl: trail_frag, wgsl: trail_frag_wgsl},
155
- shaderLanguage
156
- );
157
-
158
- const attributes = ['position', 'previous', 'next', 'side', 'width', 'uv', 'color'];
159
- if (this.uv1Buffer) {
160
- attributes.push('uv2');
161
- }
162
- const uniforms = [
163
- 'world',
164
- 'view',
165
- 'projection',
166
- 'worldViewProjection',
167
- 'lineWidth',
168
- 'resolution',
169
- 'sizeAttenuation',
170
- ...VFXBatch.FOG_UNIFORMS,
171
- ];
172
- const samplers: string[] = [];
173
-
174
- if (this.settings.texture) {
175
- samplers.push('map');
176
- }
177
- this.surfaceUniforms(uniforms, samplers);
243
+ registerShaders(shaderName, program.vertex, program.fragment, shaderLanguage);
178
244
 
179
245
  const mat = new ShaderMaterial(
180
246
  shaderName,
@@ -193,24 +259,8 @@ export class TrailBatch extends VFXBatch {
193
259
  if (this.settings.texture) {
194
260
  mat.setTexture('map', this.settings.texture);
195
261
  }
196
- const engine = this.scene.getEngine();
197
- mat.setFloat('lineWidth', 1);
198
- // Reused across binds, and only re-uploaded when the render target resizes.
199
- const resolution = new BVector2(engine.getRenderWidth(), engine.getRenderHeight());
200
- mat.setVector2('resolution', resolution);
201
- mat.setFloat('sizeAttenuation', 1);
202
- mat.onBindObservable.add(() => {
203
- const renderEngine = this.scene.getEngine();
204
- const width = renderEngine.getRenderWidth();
205
- const height = renderEngine.getRenderHeight();
206
- if (resolution.x !== width || resolution.y !== height) {
207
- resolution.x = width;
208
- resolution.y = height;
209
- mat.setVector2('resolution', resolution);
210
- }
211
- });
212
-
213
262
  this.bindFog(mat);
263
+ this.bindSoft(mat);
214
264
  this.bindSurface(mat);
215
265
  mat.backFaceCulling = false;
216
266
 
@@ -219,27 +269,198 @@ export class TrailBatch extends VFXBatch {
219
269
  } else {
220
270
  mat.alphaMode = Constants.ALPHA_DISABLE;
221
271
  }
222
- mat.needDepthPrePass = this.settings.materialDepthWrite;
272
+ // A transparent surface that writes depth is drawn into the depth buffer first, so its own
273
+ // overlapping pieces do not fight; an opaque one writes depth as it draws.
274
+ mat.needDepthPrePass = this.settings.materialDepthWrite && this.settings.materialTransparent;
223
275
  mat.forceDepthWrite = this.settings.materialDepthWrite;
224
276
  mat.disableDepthWrite = !this.settings.materialDepthWrite;
277
+ // 0 keeps the engine's depth function (LEQUAL); ALWAYS draws over everything, like ZTest Always.
278
+ mat.depthFunction = this.settings.materialDepthTest ? 0 : Constants.ALWAYS;
225
279
 
280
+ // A rebuild (a depth texture attached or removed) replaces the material and its bind
281
+ // observers. Textures belong to the settings, so they stay.
282
+ (this.mesh.material as ShaderMaterial | null)?.dispose(false, false);
226
283
  this.mesh.material = mat;
227
284
  }
228
285
 
229
- private vector_ = new Vector3();
230
286
  private vector2_ = new Vector3();
231
287
  private vector3_ = new Vector3();
232
288
  private quaternion_ = new Quaternion();
233
289
 
290
+ private readonly samples_ = new TrailSamples();
291
+ private readonly curve_ = new TrailSamples();
292
+
293
+ /** The scratch a trail's samples are gathered into, room for `count`. */
294
+ private trailSamples(count: number): TrailSamples {
295
+ return this.samples_.reserve(count);
296
+ }
297
+
234
298
  /**
235
- * Brings the emitter's world matrix up to date when its transform changed.
236
- *
237
- * Babylon only recomputes a world matrix once per render id, so a frame that
238
- * moves the emitter after that (or a step driven without rendering) would
239
- * draw the ribbon against the old one. Recomputing unconditionally every
240
- * frame costs a matrix compose per system; checking the transform cache
241
- * first makes a still emitter free.
299
+ * Shortens the last segment of a drawn ribbon to `keep` of its length, along the points already
300
+ * drawn for it, so it stays a prefix of that same curve. Every channel follows.
242
301
  */
302
+ private cutLastSegment(drawn: TrailSamples, count: number, perSegment: number, keep: number, swept: boolean): void {
303
+ const first = count - 1 - perSegment;
304
+ const scratch = this.cutScratch_.reserve(perSegment + 1);
305
+ scratch.position.set(drawn.position.subarray(first * 3, count * 3));
306
+ scratch.side.set(drawn.side.subarray(first * 3, count * 3));
307
+ scratch.size.set(drawn.size.subarray(first, count));
308
+ scratch.color.set(drawn.color.subarray(first * 4, count * 4));
309
+ scratch.distance.set(drawn.distance.subarray(first, count));
310
+ for (let j = 1; j <= perSegment; j++) {
311
+ const at = keep * j;
312
+ const i = Math.min(perSegment - 1, Math.floor(at));
313
+ const t = at - i;
314
+ const d = first + j;
315
+ for (let c = 0; c < 3; c++) {
316
+ drawn.position[d * 3 + c] = scratch.position[i * 3 + c] * (1 - t) + scratch.position[i * 3 + 3 + c] * t;
317
+ if (swept) {
318
+ drawn.side[d * 3 + c] = scratch.side[i * 3 + c] * (1 - t) + scratch.side[i * 3 + 3 + c] * t;
319
+ }
320
+ }
321
+ drawn.size[d] = scratch.size[i] * (1 - t) + scratch.size[i + 1] * t;
322
+ drawn.distance[d] = scratch.distance[i] * (1 - t) + scratch.distance[i + 1] * t;
323
+ for (let c = 0; c < 4; c++) {
324
+ drawn.color[d * 4 + c] = scratch.color[i * 4 + c] * (1 - t) + scratch.color[i * 4 + 4 + c] * t;
325
+ }
326
+ }
327
+ }
328
+
329
+ private readonly cutScratch_ = new TrailSamples();
330
+
331
+ /**
332
+ * The curve through a trail's `count` samples, each segment drawn as
333
+ * `steps + 1`: through the first `curved` (the recorded samples) a
334
+ * centripetal cardinal spline (Catmull-Rom at tension 0, straight at 1),
335
+ * the same through the sides of a swept trail with each side's length
336
+ * eased between its ends'; past them (the live tip) a straight segment,
337
+ * so the recorded part does not move as the tip does. Widths, colors and
338
+ * distances are eased along. The curve passes through every sample, so
339
+ * the trail keeps its shape and only loses its corners; centripetal
340
+ * spacing keeps it from overshooting where samples lie unevenly apart.
341
+ */
342
+ private tessellate(
343
+ samples: TrailSamples,
344
+ curved: number,
345
+ count: number,
346
+ steps: number,
347
+ tension: number,
348
+ swept: boolean
349
+ ): TrailSamples {
350
+ const out = this.curve_.reserve((count - 1) * (steps + 1) + 1);
351
+ const p = samples.position;
352
+ const sd = samples.side;
353
+ const op = out.position;
354
+ const os = out.side;
355
+ const tangent = 1 - tension;
356
+ let k = 0;
357
+ for (let i = 0; i + 1 < count; i++) {
358
+ const a = i * 3;
359
+ const b = a + 3;
360
+ // Each end's tangent, as weights of the neighbouring differences
361
+ // (Yuksel et al., parameterization of Catmull-Rom curves).
362
+ let w1a = 0;
363
+ let w1b = 0;
364
+ let w1c = 0;
365
+ let w2a = 0;
366
+ let w2b = 0;
367
+ let w2c = 0;
368
+ let before = a;
369
+ let after = b;
370
+ if (i + 1 < curved) {
371
+ before = (i > 0 ? i - 1 : i) * 3;
372
+ after = (i + 2 < curved ? i + 2 : i + 1) * 3;
373
+ const t12 = knot(p, a, b);
374
+ if (t12 > 1e-6) {
375
+ const t01 = knot(p, before, a);
376
+ const t23 = knot(p, b, after);
377
+ w1c = tangent;
378
+ w2a = tangent;
379
+ if (t01 > 1e-6) {
380
+ w1a = (tangent * t12) / t01;
381
+ w1b = (-tangent * t12) / (t01 + t12);
382
+ }
383
+ if (t23 > 1e-6) {
384
+ w2b = (-tangent * t12) / (t12 + t23);
385
+ w2c = (tangent * t12) / t23;
386
+ }
387
+ }
388
+ }
389
+ // m1 = w1a (A - Before) + w1b (B - Before) + w1c (B - A);
390
+ // m2 = w2a (B - A) + w2b (After - A) + w2c (After - B).
391
+ // With no tangents (the tip's segment, or tension 1) the curve is the straight segment
392
+ // itself: walk it evenly, as the widths, colors and distances are, rather than easing.
393
+ const straight = w1a === 0 && w1b === 0 && w1c === 0 && w2a === 0 && w2b === 0 && w2c === 0;
394
+ const la = swept ? Math.sqrt(sd[a] * sd[a] + sd[a + 1] * sd[a + 1] + sd[a + 2] * sd[a + 2]) : 0;
395
+ const lb = swept ? Math.sqrt(sd[b] * sd[b] + sd[b + 1] * sd[b + 1] + sd[b + 2] * sd[b + 2]) : 0;
396
+ for (let step = 0; step <= steps; step++, k++) {
397
+ const t = step / (steps + 1);
398
+ const t2 = t * t;
399
+ const t3 = t2 * t;
400
+ const h00 = straight ? 1 - t : 2 * t3 - 3 * t2 + 1;
401
+ const h10 = t3 - 2 * t2 + t;
402
+ const h01 = straight ? t : -2 * t3 + 3 * t2;
403
+ const h11 = t3 - t2;
404
+ const o = k * 3;
405
+ for (let c = 0; c < 3; c++) {
406
+ const A = p[a + c];
407
+ const B = p[b + c];
408
+ const m1 = w1a * (A - p[before + c]) + w1b * (B - p[before + c]) + w1c * (B - A);
409
+ const m2 = w2a * (B - A) + w2b * (p[after + c] - A) + w2c * (p[after + c] - B);
410
+ op[o + c] = h00 * A + h10 * m1 + h01 * B + h11 * m2;
411
+ }
412
+ if (swept) {
413
+ let x = 0;
414
+ let y = 0;
415
+ let z = 0;
416
+ for (let c = 0; c < 3; c++) {
417
+ const A = sd[a + c];
418
+ const B = sd[b + c];
419
+ const m1 = w1a * (A - sd[before + c]) + w1b * (B - sd[before + c]) + w1c * (B - A);
420
+ const m2 = w2a * (B - A) + w2b * (sd[after + c] - A) + w2c * (sd[after + c] - B);
421
+ const v = h00 * A + h10 * m1 + h01 * B + h11 * m2;
422
+ if (c === 0) x = v;
423
+ else if (c === 1) y = v;
424
+ else z = v;
425
+ }
426
+ // A side turning with the blade keeps its length on the way round.
427
+ let length = Math.sqrt(x * x + y * y + z * z);
428
+ if (length <= 1e-6 * Math.max(la, lb, 1e-6)) {
429
+ // Turned half round in one step: no way round to follow, so
430
+ // hold the nearer end's side rather than pinch to nothing.
431
+ const from = t < 0.5 ? a : b;
432
+ x = sd[from];
433
+ y = sd[from + 1];
434
+ z = sd[from + 2];
435
+ length = t < 0.5 ? la : lb;
436
+ }
437
+ const fit = length > 0 ? (la + (lb - la) * t) / length : 0;
438
+ os[o] = x * fit;
439
+ os[o + 1] = y * fit;
440
+ os[o + 2] = z * fit;
441
+ }
442
+ out.size[k] = samples.size[i] + (samples.size[i + 1] - samples.size[i]) * t;
443
+ out.distance[k] = samples.distance[i] + (samples.distance[i + 1] - samples.distance[i]) * t;
444
+ const ca = i * 4;
445
+ const co = k * 4;
446
+ for (let c = 0; c < 4; c++) {
447
+ out.color[co + c] = samples.color[ca + c] + (samples.color[ca + 4 + c] - samples.color[ca + c]) * t;
448
+ }
449
+ }
450
+ }
451
+ // The last sample, as it is.
452
+ const last = (count - 1) * 3;
453
+ const o = k * 3;
454
+ for (let c = 0; c < 3; c++) {
455
+ op[o + c] = p[last + c];
456
+ os[o + c] = sd[last + c];
457
+ }
458
+ out.size[k] = samples.size[count - 1];
459
+ out.distance[k] = samples.distance[count - 1];
460
+ for (let c = 0; c < 4; c++) out.color[k * 4 + c] = samples.color[(count - 1) * 4 + c];
461
+ return out;
462
+ }
463
+
243
464
  /**
244
465
  * Writes a swept ribbon's vertex pair at `pi`: the sample at (x, y, z)
245
466
  * pushed `half` widths along its side (sx, sy, sz) one way for the first
@@ -264,6 +485,15 @@ export class TrailBatch extends VFXBatch {
264
485
  positions[pi + 5] = z - sz * half;
265
486
  }
266
487
 
488
+ /**
489
+ * Brings the emitter's world matrix up to date when its transform changed.
490
+ *
491
+ * Babylon only recomputes a world matrix once per render id, so a frame that
492
+ * moves the emitter after that (or a step driven without rendering) would
493
+ * draw the ribbon against the old one. Recomputing unconditionally every
494
+ * frame costs a matrix compose per system; checking the transform cache
495
+ * first makes a still emitter free.
496
+ */
267
497
  private static ensureWorldMatrix(system: IParticleSystem): void {
268
498
  const emitter = system.emitter as {
269
499
  computeWorldMatrix?: (force: boolean) => void;
@@ -274,6 +504,7 @@ export class TrailBatch extends VFXBatch {
274
504
  }
275
505
  }
276
506
 
507
+ /** @internal */
277
508
  update(): void {
278
509
  let index = 0;
279
510
  let triangles = 0;
@@ -283,19 +514,19 @@ export class TrailBatch extends VFXBatch {
283
514
  for (let s = 0; s < visibleSystems.length; s++) {
284
515
  const system = visibleSystems[s];
285
516
  const particles = system.particles;
517
+ const tessellation = trailTessellation(system.rendererEmitterSettings as TrailSettings);
286
518
  for (let j = 0; j < system.particleNum; j++) {
287
519
  const historyCount = (particles[j] as TrailParticle).historyCount;
288
520
  if (historyCount === 0) {
289
521
  continue;
290
522
  }
291
- // +2 reserves the live tip vertex pair that frames between
292
- // simulation steps append ahead of the recorded head.
293
- particleCount += historyCount * 2 + 2;
523
+ // +1 reserves the live tip that frames between simulation
524
+ // steps append ahead of the recorded head; a tessellated trail
525
+ // draws each segment as several.
526
+ particleCount += (historyCount * (tessellation + 1) + 1) * 2;
294
527
  }
295
528
  }
296
- if (particleCount > this.maxParticles) {
297
- this.expandBuffers(particleCount);
298
- }
529
+ this.fitBuffers(particleCount);
299
530
 
300
531
  for (let s = 0; s < visibleSystems.length; s++) {
301
532
  const system = visibleSystems[s];
@@ -342,7 +573,25 @@ export class TrailBatch extends VFXBatch {
342
573
  // Measured from the step the particle just took, not from its
343
574
  // velocity: a trail pulled along by an orbit or by a plugin's own
344
575
  // behavior has motion no velocity term describes.
345
- const stepFraction = residual === 0 ? 0 : residual / (system.simulationStep ?? residual);
576
+ const rawFraction = residual === 0 ? 0 : residual / (system.simulationStep ?? residual);
577
+ // A few ten-thousandths of a step are float noise left in the accumulator, not time: a
578
+ // tip there would sit on the head as a segment of no length.
579
+ const stepFraction = Math.abs(rawFraction) < 1e-4 ? 0 : rawFraction;
580
+
581
+ const tessellation = trailTessellation(trailSettings);
582
+ const curveTension = trailCurveTension(trailSettings);
583
+ const textureModeName = trailTextureMode(trailSettings);
584
+ const textureMode =
585
+ textureModeName === 'stretch'
586
+ ? TextureMode.Stretch
587
+ : textureModeName === 'tile'
588
+ ? TextureMode.Tile
589
+ : textureModeName === 'repeatPerSegment'
590
+ ? TextureMode.RepeatPerSegment
591
+ : TextureMode.DistributePerSegment;
592
+ const textureScale = trailTextureScale(trailSettings);
593
+ // With a minimum vertex distance the last recorded segment is not one step of travel.
594
+ const samplesFollow = trailMinVertexDistance(trailSettings) > 0;
346
595
 
347
596
  for (let j = 0; j < particleNum; j++) {
348
597
  const particle = particles[j] as TrailParticle;
@@ -353,160 +602,239 @@ export class TrailBatch extends VFXBatch {
353
602
  const historyCapacity = particle.historyCapacity;
354
603
  const historyPositions = particle.historyPositions;
355
604
  const historySizes = particle.historySizes;
605
+ const historyDistances = particle.historyDistances;
356
606
  const historySides = particle.historySides;
357
- const swept = sideAxis !== undefined && historySides.length === historyCapacity * 3;
607
+ const swept = sideAxis !== undefined && historySides.length >= historyCapacity * 3;
358
608
  // Every sample takes its color from one place: its own recorded slot, or
359
609
  // the particle's current row in the store.
360
610
  const colors = colorFromParticle ? particle.store.color : particle.historyColors;
361
611
  const particleColorIndex = particle.storeIndex * 4;
362
- const col = particle.uvTile % vTileCount;
363
- const row = Math.floor(particle.uvTile / vTileCount + 0.001);
612
+ // The same cell as the sprite shader, wrapped into the sheet the same way.
613
+ const {column: col, row} = sheetCell(particle.uvTile, uTileCount, vTileCount);
364
614
  const particleMatrix = particle.parentMatrix as unknown as Matrix4 | undefined;
365
615
  const me = (particleMatrix ?? emitterMatrix).elements;
366
- const m00 = me[0],
367
- m01 = me[1],
368
- m02 = me[2],
369
- m03 = me[3];
370
- const m10 = me[4],
371
- m11 = me[5],
372
- m12 = me[6],
373
- m13 = me[7];
374
- const m20 = me[8],
375
- m21 = me[9],
376
- m22 = me[10],
377
- m23 = me[11];
378
- const m30 = me[12],
379
- m31 = me[13],
380
- m32 = me[14],
381
- m33 = me[15];
382
- // The texture spans the ribbon end to end: up to the live tip when one is
383
- // drawn, otherwise up to the newest recorded sample.
384
- const invHistoryLength =
385
- 1 / (stepFraction !== 0 ? particleHistoryLength : Math.max(1, particleHistoryLength - 1));
386
-
387
- // Walk the ring buffer oldest-first with hand-rolled wrapping so the
388
- // inner loop stays free of modulo operations. Each sample is brought
389
- // into world space once: the point written as `next` on one
390
- // iteration is `current` on the following one and `previous` on
391
- // the one after that.
392
- let currentSlot = particle.getHistoryIndex(0);
393
- let currentX = historyPositions[currentSlot * 3];
394
- let currentY = historyPositions[currentSlot * 3 + 1];
395
- let currentZ = historyPositions[currentSlot * 3 + 2];
396
- if (!samplesInWorld) {
397
- const w = 1 / (m03 * currentX + m13 * currentY + m23 * currentZ + m33);
398
- const tx = (m00 * currentX + m10 * currentY + m20 * currentZ + m30) * w;
399
- const ty = (m01 * currentX + m11 * currentY + m21 * currentZ + m31) * w;
400
- currentZ = (m02 * currentX + m12 * currentY + m22 * currentZ + m32) * w;
401
- currentX = tx;
402
- currentY = ty;
403
- }
404
- let previousX = currentX;
405
- let previousY = currentY;
406
- let previousZ = currentZ;
407
-
408
- for (let i = 0; i < particleHistoryLength; i++, index += 2) {
409
- let nextX = currentX;
410
- let nextY = currentY;
411
- let nextZ = currentZ;
412
- let nextSlot = currentSlot;
413
- if (i + 1 < particleHistoryLength) {
414
- nextSlot = currentSlot + 1 === historyCapacity ? 0 : currentSlot + 1;
415
- const nextPosIndex = nextSlot * 3;
416
- nextX = historyPositions[nextPosIndex];
417
- nextY = historyPositions[nextPosIndex + 1];
418
- nextZ = historyPositions[nextPosIndex + 2];
419
- if (!samplesInWorld) {
420
- const w = 1 / (m03 * nextX + m13 * nextY + m23 * nextZ + m33);
421
- const tx = (m00 * nextX + m10 * nextY + m20 * nextZ + m30) * w;
422
- const ty = (m01 * nextX + m11 * nextY + m21 * nextZ + m31) * w;
423
- nextZ = (m02 * nextX + m12 * nextY + m22 * nextZ + m32) * w;
424
- nextX = tx;
425
- nextY = ty;
426
- }
616
+ const widthScale = systemWorldSpace || particle.parentMatrix ? 1 : objectScale;
617
+ // Recorded distances are world lengths once the samples are, a local trail's scale with the emitter.
618
+ const distanceScale = samplesInWorld || particle.parentMatrix ? 1 : objectScale;
619
+ // A particle at the end of its life records nothing more (TrailParticle.update), so
620
+ // its trail ends at the last sample: a tip would follow the particle on as a growing spike.
621
+ const recording = particle.age < particle.life;
622
+ // The display runs ahead of the last step: draw a tip that far past the head.
623
+ const tipAhead = recording && stepFraction > 0;
624
+ const sampleCount = particleHistoryLength + (tipAhead ? 1 : 0);
625
+ const samples = this.trailSamples(sampleCount);
626
+
627
+ // The recorded samples, oldest first, brought into world space once.
628
+ let slot = particle.getHistoryIndex(0);
629
+ for (let i = 0; i < particleHistoryLength; i++) {
630
+ const p = historyPositions;
631
+ samples.writePoint(i, p[slot * 3], p[slot * 3 + 1], p[slot * 3 + 2], samplesInWorld ? null : me);
632
+ if (swept) {
633
+ const s = historySides;
634
+ samples.writeSide(i, s[slot * 3], s[slot * 3 + 1], s[slot * 3 + 2], samplesInWorld ? null : me);
427
635
  }
636
+ samples.size[i] = historySizes[slot];
637
+ samples.distance[i] = historyDistances[slot];
638
+ const ci = colorFromParticle ? particleColorIndex : slot * 4;
639
+ samples.writeColor(i, colors[ci], colors[ci + 1], colors[ci + 2], colors[ci + 3]);
640
+ slot = slot + 1 === historyCapacity ? 0 : slot + 1;
641
+ }
428
642
 
429
- const currentSize = historySizes[currentSlot];
430
- const currentColorIndex = colorFromParticle ? particleColorIndex : currentSlot * 4;
431
- const currentColorX = colors[currentColorIndex];
432
- const currentColorY = colors[currentColorIndex + 1];
433
- const currentColorZ = colors[currentColorIndex + 2];
434
- const currentColorW = colors[currentColorIndex + 3];
435
-
436
- const pi = index * 3;
643
+ // Live tip: same place a sprite would be drawn this frame, past
644
+ // the last recorded sample. The recorded ribbon stays put.
645
+ if (tipAhead) {
646
+ const previousHead = particle.previousPosition;
647
+ const position = particle.position;
648
+ const tip = particleHistoryLength;
649
+ let tx = position.x + (position.x - previousHead.x) * stepFraction;
650
+ let ty = position.y + (position.y - previousHead.y) * stepFraction;
651
+ let tz = position.z + (position.z - previousHead.z) * stepFraction;
652
+ samples.writePoint(tip, tx, ty, tz, systemWorldSpace ? null : me);
653
+ // The tip's distance along the trail, measured in the space the samples are
654
+ // recorded in, as theirs are.
655
+ if (samplesInWorld && !systemWorldSpace) {
656
+ const w = 1 / (me[3] * tx + me[7] * ty + me[11] * tz + me[15]);
657
+ const wx = (me[0] * tx + me[4] * ty + me[8] * tz + me[12]) * w;
658
+ const wy = (me[1] * tx + me[5] * ty + me[9] * tz + me[13]) * w;
659
+ tz = (me[2] * tx + me[6] * ty + me[10] * tz + me[14]) * w;
660
+ tx = wx;
661
+ ty = wy;
662
+ }
663
+ const headSlot = particle.getHistoryIndex(particleHistoryLength - 1) * 3;
664
+ const hx = tx - historyPositions[headSlot];
665
+ const hy = ty - historyPositions[headSlot + 1];
666
+ const hz = tz - historyPositions[headSlot + 2];
667
+ samples.distance[tip] = samples.distance[tip - 1] + Math.sqrt(hx * hx + hy * hy + hz * hz);
437
668
  if (swept) {
438
- const si = currentSlot * 3;
439
- let sx = historySides[si];
440
- let sy = historySides[si + 1];
441
- let sz = historySides[si + 2];
442
- if (!samplesInWorld) {
443
- const tx = m00 * sx + m10 * sy + m20 * sz;
444
- const ty = m01 * sx + m11 * sy + m21 * sz;
445
- sz = m02 * sx + m12 * sy + m22 * sz;
446
- sx = tx;
447
- sy = ty;
448
- }
449
- this.writeSweptPair(pi, currentX, currentY, currentZ, sx, sy, sz, currentSize * 0.5);
450
- } else {
451
- this.positionBuffer[pi] = currentX;
452
- this.positionBuffer[pi + 1] = currentY;
453
- this.positionBuffer[pi + 2] = currentZ;
454
- this.positionBuffer[pi + 3] = currentX;
455
- this.positionBuffer[pi + 4] = currentY;
456
- this.positionBuffer[pi + 5] = currentZ;
669
+ // The side as the emitter holds it now.
670
+ samples.writeSide(tip, sideAxis!.x, sideAxis!.y, sideAxis!.z, me);
457
671
  }
672
+ // As wide as the recorded head, which WidthOverLength has already scaled.
673
+ samples.size[tip] = samples.size[tip - 1];
674
+ const tipColor = particle.color;
675
+ samples.writeColor(tip, tipColor.x, tipColor.y, tipColor.z, tipColor.w);
676
+ }
458
677
 
459
- this.previousBuffer[pi] = previousX;
460
- this.previousBuffer[pi + 1] = previousY;
461
- this.previousBuffer[pi + 2] = previousZ;
462
- this.previousBuffer[pi + 3] = previousX;
463
- this.previousBuffer[pi + 4] = previousY;
464
- this.previousBuffer[pi + 5] = previousZ;
465
-
466
- this.nextBuffer[pi] = nextX;
467
- this.nextBuffer[pi + 1] = nextY;
468
- this.nextBuffer[pi + 2] = nextZ;
469
- this.nextBuffer[pi + 3] = nextX;
470
- this.nextBuffer[pi + 4] = nextY;
471
- this.nextBuffer[pi + 5] = nextZ;
678
+ // Curved through the samples (see TrailSettings.tessellation),
679
+ // or drawn through them as they are.
680
+ const drawn =
681
+ tessellation > 0
682
+ ? this.tessellate(
683
+ samples,
684
+ particleHistoryLength,
685
+ sampleCount,
686
+ tessellation,
687
+ curveTension,
688
+ swept
689
+ )
690
+ : samples;
691
+ const drawnCount = tessellation > 0 ? (sampleCount - 1) * (tessellation + 1) + 1 : sampleCount;
692
+ const perSegment = tessellation + 1;
693
+ // The last step ran ahead of the display: the head is drawn where it was at display
694
+ // time, back along the drawn last segment, rather than a tip folding back over it.
695
+ // Cut from the drawn curve, so the rest of the ribbon is the same as on any frame.
696
+ const pullBack = recording && stepFraction < 0 && particleHistoryLength > 1;
697
+ // How much of the last drawn segment is kept: all but the step's share that ran
698
+ // ahead. The segment is one step of travel, unless the head has been following the
699
+ // particle (minVertexDistance), when it is that step's share of its length.
700
+ let keep = 1 + stepFraction;
701
+ if (pullBack && samplesFollow) {
702
+ const dd = drawn.distance;
703
+ const segmentLength = dd[drawnCount - 1] - dd[drawnCount - 1 - perSegment];
704
+ if (segmentLength > 1e-9) {
705
+ const previousHead = particle.previousPosition;
706
+ const position = particle.position;
707
+ let sx = position.x - previousHead.x;
708
+ let sy = position.y - previousHead.y;
709
+ let sz = position.z - previousHead.z;
710
+ if (samplesInWorld && !systemWorldSpace) {
711
+ const wx = me[0] * sx + me[4] * sy + me[8] * sz;
712
+ const wy = me[1] * sx + me[5] * sy + me[9] * sz;
713
+ sz = me[2] * sx + me[6] * sy + me[10] * sz;
714
+ sx = wx;
715
+ sy = wy;
716
+ }
717
+ const stepTravel = Math.sqrt(sx * sx + sy * sy + sz * sz);
718
+ keep = 1 + stepFraction * Math.min(1, stepTravel / segmentLength);
719
+ }
720
+ }
721
+ if (pullBack) {
722
+ this.cutLastSegment(drawn, drawnCount, perSegment, keep, swept);
723
+ }
724
+ // The per-segment texture modes span the ribbon in samples: the recorded ones, plus
725
+ // the part of a step the tip runs ahead or the head was drawn back, so a sample's U
726
+ // changes smoothly from frame to frame whichever way the residual falls. The others
727
+ // read each point's distance along the trail, tile mode in world units.
728
+ const spanFraction = tipAhead ? stepFraction : pullBack ? keep - 1 : 0;
729
+ const spanSamples = particleHistoryLength - 1 + spanFraction;
730
+ const invLength = 1 / Math.max(1e-3, spanSamples);
731
+ const lastSegment = (sampleCount - 2) * perSegment;
732
+ const dp = drawn.position;
733
+ const dd = drawn.distance;
734
+ const tailDistance = dd[0];
735
+ const headDistance = dd[drawnCount - 1];
736
+ // A ribbon that has not gone anywhere has no distance to stretch over: lay the
737
+ // texture out per segment instead, which at least keeps its ends where they belong.
738
+ const stretch = textureMode === TextureMode.Stretch && headDistance - tailDistance > 1e-6;
739
+ const invSpanDistance = 1 / Math.max(1e-6, headDistance - tailDistance);
740
+ // Tile mode: U in repeats of the texture along the trail, anchored where the particle
741
+ // was born, less the whole repeats behind the tail so U stays small (a texture that
742
+ // wraps cannot tell) however far the particle has travelled.
743
+ const tileScale = distanceScale * textureScale;
744
+ const tileOffset = Math.floor(tailDistance * tileScale);
745
+ const vTop = (vTileCount - row - 1) * tileHeight;
746
+ const vBottom = (vTileCount - row) * tileHeight;
747
+
748
+ for (let k = 0; k < drawnCount; k++, index += 2) {
749
+ const o = k * 3;
750
+ const prev = k > 0 ? o - 3 : o;
751
+ const next = k + 1 < drawnCount ? o + 3 : o;
752
+ const x = dp[o];
753
+ const y = dp[o + 1];
754
+ const z = dp[o + 2];
755
+ const size = drawn.size[k];
472
756
 
757
+ const pi = index * 3;
473
758
  if (swept) {
759
+ const ds = drawn.side;
760
+ this.writeSweptPair(pi, x, y, z, ds[o], ds[o + 1], ds[o + 2], size * 0.5);
761
+ // A zero width keeps the shader from pushing the pair.
474
762
  this.widthBuffer[index] = 0;
475
763
  this.widthBuffer[index + 1] = 0;
476
- } else if (systemWorldSpace || particle.parentMatrix) {
477
- this.widthBuffer[index] = currentSize;
478
- this.widthBuffer[index + 1] = currentSize;
479
764
  } else {
480
- this.widthBuffer[index] = currentSize * objectScale;
481
- this.widthBuffer[index + 1] = currentSize * objectScale;
765
+ this.positionBuffer[pi] = x;
766
+ this.positionBuffer[pi + 1] = y;
767
+ this.positionBuffer[pi + 2] = z;
768
+ this.positionBuffer[pi + 3] = x;
769
+ this.positionBuffer[pi + 4] = y;
770
+ this.positionBuffer[pi + 5] = z;
771
+ this.widthBuffer[index] = size * widthScale;
772
+ this.widthBuffer[index + 1] = size * widthScale;
482
773
  }
483
774
 
775
+ this.previousBuffer[pi] = dp[prev];
776
+ this.previousBuffer[pi + 1] = dp[prev + 1];
777
+ this.previousBuffer[pi + 2] = dp[prev + 2];
778
+ this.previousBuffer[pi + 3] = dp[prev];
779
+ this.previousBuffer[pi + 4] = dp[prev + 1];
780
+ this.previousBuffer[pi + 5] = dp[prev + 2];
781
+
782
+ this.nextBuffer[pi] = dp[next];
783
+ this.nextBuffer[pi + 1] = dp[next + 1];
784
+ this.nextBuffer[pi + 2] = dp[next + 2];
785
+ this.nextBuffer[pi + 3] = dp[next];
786
+ this.nextBuffer[pi + 4] = dp[next + 1];
787
+ this.nextBuffer[pi + 5] = dp[next + 2];
788
+
484
789
  const ui = index * 2;
485
- const along = i * invHistoryLength;
486
- const u = ((uvFromHead ? 1 - along : along) + col) * tileWidth;
790
+ let u1: number;
791
+ if (stretch) {
792
+ const along = (dd[k] - tailDistance) * invSpanDistance;
793
+ u1 = (uvFromHead ? 1 - along : along) * textureScale;
794
+ } else if (textureMode === TextureMode.Tile) {
795
+ // A sample's U holds while it lives; from the particle back with uvFromHead.
796
+ u1 = uvFromHead ? (headDistance - dd[k]) * tileScale : dd[k] * tileScale - tileOffset;
797
+ } else {
798
+ // Where this point sits along the ribbon, in samples.
799
+ let at = k / perSegment;
800
+ if (k > lastSegment && (tipAhead || pullBack)) {
801
+ const into = (k - lastSegment) / perSegment;
802
+ at = tipAhead
803
+ ? particleHistoryLength - 1 + stepFraction * into
804
+ : particleHistoryLength - 2 + keep * into;
805
+ }
806
+ if (textureMode !== TextureMode.RepeatPerSegment) {
807
+ const along = Math.min(1, at * invLength);
808
+ u1 = (uvFromHead ? 1 - along : along) * textureScale;
809
+ } else {
810
+ u1 = (uvFromHead ? spanSamples - at : at) * textureScale;
811
+ }
812
+ }
813
+ const u = (u1 + col) * tileWidth;
487
814
  this.uvBuffer[ui] = u;
488
- this.uvBuffer[ui + 1] = (vTileCount - row - 1) * tileHeight;
815
+ this.uvBuffer[ui + 1] = vTop;
489
816
  this.uvBuffer[ui + 2] = u;
490
- this.uvBuffer[ui + 3] = (vTileCount - row) * tileHeight;
817
+ this.uvBuffer[ui + 3] = vBottom;
491
818
  if (uv1) {
492
- const u1 = uvFromHead ? 1 - along : along;
493
819
  uv1[ui] = u1;
494
820
  uv1[ui + 1] = 0;
495
821
  uv1[ui + 2] = u1;
496
822
  uv1[ui + 3] = 1;
497
823
  }
498
824
 
825
+ const dc = drawn.color;
826
+ const c = k * 4;
499
827
  const cci = index * 4;
500
- this.colorBuffer[cci] = currentColorX;
501
- this.colorBuffer[cci + 1] = currentColorY;
502
- this.colorBuffer[cci + 2] = currentColorZ;
503
- this.colorBuffer[cci + 3] = currentColorW;
504
- this.colorBuffer[cci + 4] = currentColorX;
505
- this.colorBuffer[cci + 5] = currentColorY;
506
- this.colorBuffer[cci + 6] = currentColorZ;
507
- this.colorBuffer[cci + 7] = currentColorW;
508
-
509
- if (i + 1 < particleHistoryLength) {
828
+ this.colorBuffer[cci] = dc[c];
829
+ this.colorBuffer[cci + 1] = dc[c + 1];
830
+ this.colorBuffer[cci + 2] = dc[c + 2];
831
+ this.colorBuffer[cci + 3] = dc[c + 3];
832
+ this.colorBuffer[cci + 4] = dc[c];
833
+ this.colorBuffer[cci + 5] = dc[c + 1];
834
+ this.colorBuffer[cci + 6] = dc[c + 2];
835
+ this.colorBuffer[cci + 7] = dc[c + 3];
836
+
837
+ if (k + 1 < drawnCount) {
510
838
  this.indexBuffer[triangles * 3] = index;
511
839
  this.indexBuffer[triangles * 3 + 1] = index + 1;
512
840
  this.indexBuffer[triangles * 3 + 2] = index + 2;
@@ -516,138 +844,6 @@ export class TrailBatch extends VFXBatch {
516
844
  this.indexBuffer[triangles * 3 + 2] = index + 3;
517
845
  triangles++;
518
846
  }
519
-
520
- previousX = currentX;
521
- previousY = currentY;
522
- previousZ = currentZ;
523
- currentX = nextX;
524
- currentY = nextY;
525
- currentZ = nextZ;
526
- currentSlot = nextSlot;
527
- }
528
-
529
- // Live tip: same place a sprite would be drawn this frame, past
530
- // the last recorded sample. The recorded ribbon stays put.
531
- if (stepFraction !== 0) {
532
- const previousHead = particle.previousPosition;
533
- const position = particle.position;
534
- let tipX = position.x + (position.x - previousHead.x) * stepFraction;
535
- let tipY = position.y + (position.y - previousHead.y) * stepFraction;
536
- let tipZ = position.z + (position.z - previousHead.z) * stepFraction;
537
- const headIndex = index - 2;
538
- // The pair's midpoint: a swept pair lies to either side of its sample.
539
- const headX = (this.positionBuffer[headIndex * 3] + this.positionBuffer[headIndex * 3 + 3]) * 0.5;
540
- const headY =
541
- (this.positionBuffer[headIndex * 3 + 1] + this.positionBuffer[headIndex * 3 + 4]) * 0.5;
542
- const headZ =
543
- (this.positionBuffer[headIndex * 3 + 2] + this.positionBuffer[headIndex * 3 + 5]) * 0.5;
544
-
545
- if (!systemWorldSpace) {
546
- const w = 1 / (m03 * tipX + m13 * tipY + m23 * tipZ + m33);
547
- const tx = (m00 * tipX + m10 * tipY + m20 * tipZ + m30) * w;
548
- const ty = (m01 * tipX + m11 * tipY + m21 * tipZ + m31) * w;
549
- tipZ = (m02 * tipX + m12 * tipY + m22 * tipZ + m32) * w;
550
- tipX = tx;
551
- tipY = ty;
552
- }
553
-
554
- const tipPi = index * 3;
555
- const tipSize = particle.size.x;
556
- if (swept) {
557
- // The side as the emitter holds it now.
558
- const ax = sideAxis!.x;
559
- const ay = sideAxis!.y;
560
- const az = sideAxis!.z;
561
- this.writeSweptPair(
562
- tipPi,
563
- tipX,
564
- tipY,
565
- tipZ,
566
- m00 * ax + m10 * ay + m20 * az,
567
- m01 * ax + m11 * ay + m21 * az,
568
- m02 * ax + m12 * ay + m22 * az,
569
- tipSize * 0.5
570
- );
571
- } else {
572
- this.positionBuffer[tipPi] = tipX;
573
- this.positionBuffer[tipPi + 1] = tipY;
574
- this.positionBuffer[tipPi + 2] = tipZ;
575
- this.positionBuffer[tipPi + 3] = tipX;
576
- this.positionBuffer[tipPi + 4] = tipY;
577
- this.positionBuffer[tipPi + 5] = tipZ;
578
- }
579
-
580
- this.previousBuffer[tipPi] = headX;
581
- this.previousBuffer[tipPi + 1] = headY;
582
- this.previousBuffer[tipPi + 2] = headZ;
583
- this.previousBuffer[tipPi + 3] = headX;
584
- this.previousBuffer[tipPi + 4] = headY;
585
- this.previousBuffer[tipPi + 5] = headZ;
586
-
587
- this.nextBuffer[tipPi] = tipX;
588
- this.nextBuffer[tipPi + 1] = tipY;
589
- this.nextBuffer[tipPi + 2] = tipZ;
590
- this.nextBuffer[tipPi + 3] = tipX;
591
- this.nextBuffer[tipPi + 4] = tipY;
592
- this.nextBuffer[tipPi + 5] = tipZ;
593
-
594
- // The recorded head's `next` was itself; point it at the tip
595
- // so the shader builds the live segment from one version of
596
- // each endpoint.
597
- const headPi = headIndex * 3;
598
- this.nextBuffer[headPi] = tipX;
599
- this.nextBuffer[headPi + 1] = tipY;
600
- this.nextBuffer[headPi + 2] = tipZ;
601
- this.nextBuffer[headPi + 3] = tipX;
602
- this.nextBuffer[headPi + 4] = tipY;
603
- this.nextBuffer[headPi + 5] = tipZ;
604
-
605
- if (swept) {
606
- this.widthBuffer[index] = 0;
607
- this.widthBuffer[index + 1] = 0;
608
- } else if (systemWorldSpace || particle.parentMatrix) {
609
- this.widthBuffer[index] = tipSize;
610
- this.widthBuffer[index + 1] = tipSize;
611
- } else {
612
- this.widthBuffer[index] = tipSize * objectScale;
613
- this.widthBuffer[index + 1] = tipSize * objectScale;
614
- }
615
-
616
- const tipUi = index * 2;
617
- const tipU = ((uvFromHead ? 0 : 1) + col) * tileWidth;
618
- this.uvBuffer[tipUi] = tipU;
619
- this.uvBuffer[tipUi + 1] = (vTileCount - row - 1) * tileHeight;
620
- this.uvBuffer[tipUi + 2] = tipU;
621
- this.uvBuffer[tipUi + 3] = (vTileCount - row) * tileHeight;
622
- if (uv1) {
623
- const tipU1 = uvFromHead ? 0 : 1;
624
- uv1[tipUi] = tipU1;
625
- uv1[tipUi + 1] = 0;
626
- uv1[tipUi + 2] = tipU1;
627
- uv1[tipUi + 3] = 1;
628
- }
629
-
630
- const tipColor = particle.color;
631
- const tipCi = index * 4;
632
- this.colorBuffer[tipCi] = tipColor.x;
633
- this.colorBuffer[tipCi + 1] = tipColor.y;
634
- this.colorBuffer[tipCi + 2] = tipColor.z;
635
- this.colorBuffer[tipCi + 3] = tipColor.w;
636
- this.colorBuffer[tipCi + 4] = tipColor.x;
637
- this.colorBuffer[tipCi + 5] = tipColor.y;
638
- this.colorBuffer[tipCi + 6] = tipColor.z;
639
- this.colorBuffer[tipCi + 7] = tipColor.w;
640
-
641
- this.indexBuffer[triangles * 3] = headIndex;
642
- this.indexBuffer[triangles * 3 + 1] = headIndex + 1;
643
- this.indexBuffer[triangles * 3 + 2] = index;
644
- triangles++;
645
- this.indexBuffer[triangles * 3] = index;
646
- this.indexBuffer[triangles * 3 + 1] = headIndex + 1;
647
- this.indexBuffer[triangles * 3 + 2] = index + 1;
648
- triangles++;
649
-
650
- index += 2;
651
847
  }
652
848
  }
653
849
  const tint = (system as ParticleSystem).tint;
@@ -779,10 +975,11 @@ export class TrailBatch extends VFXBatch {
779
975
  private adoptMeshBounds(): void {
780
976
  const meshBoundingInfo = this.mesh.getBoundingInfo();
781
977
  for (const subMesh of this.mesh.subMeshes) {
782
- (subMesh as any)._boundingInfo = meshBoundingInfo;
978
+ subMesh.setBoundingInfo(meshBoundingInfo);
783
979
  }
784
980
  }
785
981
 
982
+ /** @internal */
786
983
  dispose(): void {
787
984
  if (this.mesh.material) {
788
985
  this.mesh.material.dispose();