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