babylon.quarks 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@ import {Mesh} from '@babylonjs/core/Meshes/mesh';
7
7
  import {VertexData} from '@babylonjs/core/Meshes/mesh.vertexData';
8
8
  import {Scene} from '@babylonjs/core/scene';
9
9
  import {
10
+ IParticle,
10
11
  IParticleSystem,
11
12
  Matrix3,
12
13
  ParticleStore,
@@ -208,6 +209,10 @@ export class SpriteBatch extends VFXBatch {
208
209
  if (this.settings.renderMode === RenderMode.HorizontalBillBoard) {
209
210
  defines.push('HORIZONTAL');
210
211
  }
212
+ const pivot = SpriteBatch.takesPivotAndFlip(this.settings.renderMode) ? this.settings.pivot : undefined;
213
+ if (pivot) {
214
+ defines.push('PIVOT');
215
+ }
211
216
 
212
217
  const shaderLanguage = shaderLanguageFor(this.scene.getEngine());
213
218
  registerShaders(shaderName, vertexShader, fragmentShader, shaderLanguage);
@@ -246,6 +251,9 @@ export class SpriteBatch extends VFXBatch {
246
251
  if (this.settings.materialAlphaTest > 0) {
247
252
  uniforms.push('alphaTest');
248
253
  }
254
+ if (pivot) {
255
+ uniforms.push('pivot');
256
+ }
249
257
  if (this.settings.renderMode === RenderMode.Mesh) {
250
258
  uniforms.push('lightDirection');
251
259
  uniforms.push('lightColor');
@@ -311,6 +319,9 @@ export class SpriteBatch extends VFXBatch {
311
319
  if (this.settings.materialAlphaTest > 0) {
312
320
  mat.setFloat('alphaTest', this.settings.materialAlphaTest);
313
321
  }
322
+ if (pivot) {
323
+ mat.setVector3('pivot', new BVector3(pivot.x, pivot.y, pivot.z));
324
+ }
314
325
  if (this.settings.renderMode === RenderMode.Mesh) {
315
326
  mat.setVector3('lightDirection', new BVector3(0.4, -1, 0.6));
316
327
  mat.setVector3('lightColor', new BVector3(1, 1, 1));
@@ -343,6 +354,8 @@ export class SpriteBatch extends VFXBatch {
343
354
  private rotationMat_ = new Matrix3();
344
355
  private rotationMat2_ = new Matrix3();
345
356
  private lastStretchedSpeedFactor = Number.NaN;
357
+ /** Slot 0 holds the zero-alpha instance since the batch last went empty. */
358
+ private clearedInstanceUploaded = false;
346
359
 
347
360
  update(): void {
348
361
  let index = 0;
@@ -350,6 +363,7 @@ export class SpriteBatch extends VFXBatch {
350
363
  const renderMode = this.settings.renderMode;
351
364
  const isMeshRender = renderMode === RenderMode.Mesh;
352
365
  const isStretchedRender = renderMode === RenderMode.StretchedBillBoard;
366
+ const flipsSprites = SpriteBatch.takesPivotAndFlip(renderMode);
353
367
 
354
368
  const visibleSystems = this.getVisibleSystems();
355
369
  for (let i = 0; i < visibleSystems.length; i++) {
@@ -361,15 +375,34 @@ export class SpriteBatch extends VFXBatch {
361
375
 
362
376
  for (let s = 0; s < visibleSystems.length; s++) {
363
377
  const system = visibleSystems[s];
364
- const particles = system.particles;
365
378
  const particleNum = system.particleNum;
379
+ // A finished or between-bursts system adds nothing to the batch. With many effects
380
+ // loaded most systems are idle at any moment, so skip their per-system setup.
381
+ if (particleNum === 0) {
382
+ continue;
383
+ }
384
+ const particles = system.particles;
366
385
  const rotation = this.quaternion2_;
367
386
  const translation = this.vector2_;
368
387
  const scale = this.vector3_;
369
388
  const systemWorldSpace = system.worldSpace;
370
389
  const emitterMatrix = system.emitter.matrixWorld;
371
- emitterMatrix.decompose(translation, rotation, scale);
372
- this.rotationMat_.setFromMatrix4(emitterMatrix);
390
+ // Only a local-space mesh needs the emitter's turn as a quaternion, and only a
391
+ // local-space stretched billboard its rotation matrix. Everything else needs at most
392
+ // the scale, which the basis column lengths give without a full decompose.
393
+ if (isMeshRender && !systemWorldSpace) {
394
+ emitterMatrix.decompose(translation, rotation, scale);
395
+ } else {
396
+ const e = emitterMatrix.elements;
397
+ scale.set(
398
+ Math.sqrt(e[0] * e[0] + e[1] * e[1] + e[2] * e[2]),
399
+ Math.sqrt(e[4] * e[4] + e[5] * e[5] + e[6] * e[6]),
400
+ Math.sqrt(e[8] * e[8] + e[9] * e[9] + e[10] * e[10])
401
+ );
402
+ }
403
+ if (isStretchedRender && !systemWorldSpace) {
404
+ this.rotationMat_.setFromMatrix4(emitterMatrix);
405
+ }
373
406
  const absScaleX = Math.abs(scale.x);
374
407
  const absScaleY = Math.abs(scale.y);
375
408
  const absScaleZ = Math.abs(scale.z);
@@ -487,6 +520,7 @@ export class SpriteBatch extends VFXBatch {
487
520
  copiedPositionAndSize = true;
488
521
  }
489
522
 
523
+ const firstIndex = index;
490
524
  for (let j = 0; j < particleNum; j++, index++) {
491
525
  const particle = particles[j] as SpriteParticle;
492
526
 
@@ -600,6 +634,10 @@ export class SpriteBatch extends VFXBatch {
600
634
  this.velocityBuffer[vi + 3] = lengthFactor;
601
635
  }
602
636
  }
637
+
638
+ if (flipsSprites && system.flip !== undefined) {
639
+ SpriteBatch.applyFlip(this.sizeBuffer, firstIndex * 3, particles, particleNum);
640
+ }
603
641
  }
604
642
 
605
643
  if (isStretchedRender && visibleSystems.length > 0) {
@@ -611,9 +649,15 @@ export class SpriteBatch extends VFXBatch {
611
649
  this.mesh.forcedInstanceCount = index;
612
650
 
613
651
  if (index === 0) {
614
- this.uploadClearedInstance(isMeshRender, isStretchedRender);
652
+ // Slot 0 only needs clearing once per stretch of empty frames; re-uploading it every
653
+ // frame cost five buffer uploads per idle batch per frame.
654
+ if (!this.clearedInstanceUploaded) {
655
+ this.uploadClearedInstance(isMeshRender, isStretchedRender);
656
+ this.clearedInstanceUploaded = true;
657
+ }
615
658
  return;
616
659
  }
660
+ this.clearedInstanceUploaded = false;
617
661
 
618
662
  // Upload only the slots actually filled this frame: the buffers are sized
619
663
  // for the high-water mark and are usually far larger than the live count.
@@ -648,6 +692,33 @@ export class SpriteBatch extends VFXBatch {
648
692
  }
649
693
  }
650
694
 
695
+ /** The quads that take a pivot and flip: every billboard kind, stretched included, but not meshes. */
696
+ private static takesPivotAndFlip(renderMode: RenderMode): boolean {
697
+ return (
698
+ renderMode === RenderMode.BillBoard ||
699
+ renderMode === RenderMode.StretchedBillBoard ||
700
+ renderMode === RenderMode.VerticalBillBoard ||
701
+ renderMode === RenderMode.HorizontalBillBoard
702
+ );
703
+ }
704
+
705
+ /**
706
+ * Negates the size of each flipped particle along the axes it flips on,
707
+ * starting at `base` in `sizes`. The billboard shader scales the quad by
708
+ * the size, so a negative one mirrors it — and with it the texture, the
709
+ * quad being centred — the way Unity's renderer Flip mirrors U and V. The
710
+ * stretched shader reads the sign and mirrors the texture coordinates.
711
+ */
712
+ private static applyFlip(sizes: Float32Array, base: number, particles: IParticle[], count: number): void {
713
+ for (let j = 0, o = base; j < count; j++, o += 3) {
714
+ const flip = (particles[j] as SpriteParticle).flip;
715
+ if (flip !== 0) {
716
+ if (flip & 1) sizes[o] = -sizes[o];
717
+ if (flip & 2) sizes[o + 1] = -sizes[o + 1];
718
+ }
719
+ }
720
+ }
721
+
651
722
  /**
652
723
  * The fraction `f` of a turn, as a normalised lerp from no rotation toward
653
724
  * `step`, written into `out`.
package/src/TrailBatch.ts CHANGED
@@ -276,9 +276,13 @@ export class TrailBatch extends VFXBatch {
276
276
  // body still and the tip moving without that pulse. A trail pinned
277
277
  // to the emitter's origin is driven by the emitter's transform
278
278
  // rather than by the particle's velocity, so it is left alone.
279
- const residual = (system.rendererEmitterSettings as TrailSettings).followLocalOrigin
280
- ? 0
281
- : (system.simulationResidual ?? 0);
279
+ const trailSettings = system.rendererEmitterSettings as TrailSettings;
280
+ const uvFromHead = Boolean(trailSettings.uvFromHead);
281
+ const colorFromParticle = Boolean(trailSettings.colorFromParticle);
282
+ // Recorded samples already sit in world space; the live tip still comes
283
+ // from the particle's own (local) position.
284
+ const samplesInWorld = systemWorldSpace || Boolean(trailSettings.recordWorldSpace);
285
+ const residual = trailSettings.followLocalOrigin ? 0 : (system.simulationResidual ?? 0);
282
286
  // Measured from the step the particle just took, not from its
283
287
  // velocity: a trail pulled along by an orbit or by a plugin's own
284
288
  // behavior has motion no velocity term describes.
@@ -293,7 +297,10 @@ export class TrailBatch extends VFXBatch {
293
297
  const historyCapacity = particle.historyCapacity;
294
298
  const historyPositions = particle.historyPositions;
295
299
  const historySizes = particle.historySizes;
296
- const historyColors = particle.historyColors;
300
+ // Every sample takes its color from one place: its own recorded slot, or
301
+ // the particle's current row in the store.
302
+ const colors = colorFromParticle ? particle.store.color : particle.historyColors;
303
+ const particleColorIndex = particle.storeIndex * 4;
297
304
  const col = particle.uvTile % vTileCount;
298
305
  const row = Math.floor(particle.uvTile / vTileCount + 0.001);
299
306
  const particleMatrix = particle.parentMatrix as unknown as Matrix4 | undefined;
@@ -314,7 +321,10 @@ export class TrailBatch extends VFXBatch {
314
321
  m31 = me[13],
315
322
  m32 = me[14],
316
323
  m33 = me[15];
317
- const invHistoryLength = 1 / particleHistoryLength;
324
+ // The texture spans the ribbon end to end: up to the live tip when one is
325
+ // drawn, otherwise up to the newest recorded sample.
326
+ const invHistoryLength =
327
+ 1 / (stepFraction !== 0 ? particleHistoryLength : Math.max(1, particleHistoryLength - 1));
318
328
 
319
329
  // Walk the ring buffer oldest-first with hand-rolled wrapping so the
320
330
  // inner loop stays free of modulo operations.
@@ -344,16 +354,16 @@ export class TrailBatch extends VFXBatch {
344
354
  let nextZ = historyPositions[nextPosIndex + 2];
345
355
 
346
356
  const currentSize = historySizes[currentSlot];
347
- const currentColorIndex = currentSlot * 4;
348
- const currentColorX = historyColors[currentColorIndex];
349
- const currentColorY = historyColors[currentColorIndex + 1];
350
- const currentColorZ = historyColors[currentColorIndex + 2];
351
- const currentColorW = historyColors[currentColorIndex + 3];
357
+ const currentColorIndex = colorFromParticle ? particleColorIndex : currentSlot * 4;
358
+ const currentColorX = colors[currentColorIndex];
359
+ const currentColorY = colors[currentColorIndex + 1];
360
+ const currentColorZ = colors[currentColorIndex + 2];
361
+ const currentColorW = colors[currentColorIndex + 3];
352
362
 
353
363
  previousSlot = currentSlot;
354
364
  currentSlot = nextSlot;
355
365
 
356
- if (!systemWorldSpace) {
366
+ if (!samplesInWorld) {
357
367
  // Inlined point transforms — three per trail sample.
358
368
  let w = 1 / (m03 * currentX + m13 * currentY + m23 * currentZ + m33);
359
369
  let tx = (m00 * currentX + m10 * currentY + m20 * currentZ + m30) * w;
@@ -411,7 +421,8 @@ export class TrailBatch extends VFXBatch {
411
421
  }
412
422
 
413
423
  const ui = index * 2;
414
- const u = (i * invHistoryLength + col) * tileWidth;
424
+ const along = i * invHistoryLength;
425
+ const u = ((uvFromHead ? 1 - along : along) + col) * tileWidth;
415
426
  this.uvBuffer[ui] = u;
416
427
  this.uvBuffer[ui + 1] = (vTileCount - row - 1) * tileHeight;
417
428
  this.uvBuffer[ui + 2] = u;
@@ -507,7 +518,7 @@ export class TrailBatch extends VFXBatch {
507
518
  }
508
519
 
509
520
  const tipUi = index * 2;
510
- const tipU = (1 + col) * tileWidth;
521
+ const tipU = ((uvFromHead ? 0 : 1) + col) * tileWidth;
511
522
  this.uvBuffer[tipUi] = tipU;
512
523
  this.uvBuffer[tipUi + 1] = (vTileCount - row - 1) * tileHeight;
513
524
  this.uvBuffer[tipUi + 2] = tipU;
package/src/VFXBatch.ts CHANGED
@@ -2,7 +2,7 @@ import {BaseTexture} from '@babylonjs/core/Materials/Textures/baseTexture';
2
2
  import {ShaderMaterial} from '@babylonjs/core/Materials/shaderMaterial';
3
3
  import {Mesh} from '@babylonjs/core/Meshes/mesh';
4
4
  import {Scene} from '@babylonjs/core/scene';
5
- import {IParticleSystem} from 'quarks.core';
5
+ import {IParticleSystem, Vector3} from 'quarks.core';
6
6
  import {VFXBatchSettings} from './BatchedRenderer';
7
7
 
8
8
  export enum RenderMode {
@@ -38,6 +38,8 @@ export interface StoredBatchSettings {
38
38
  reflectionFaces: BaseTexture[] | null;
39
39
  reflectionAtlas: BaseTexture | null;
40
40
  layerMask: number;
41
+ /** Billboard pivot in particle-size units, see {@link VFXBatchSettings.pivot}. */
42
+ pivot?: Vector3;
41
43
  }
42
44
 
43
45
  export abstract class VFXBatch {
@@ -75,6 +77,7 @@ export abstract class VFXBatch {
75
77
  reflectionLevel: settings.reflectionLevel ?? 1,
76
78
  reflectionFaces: settings.reflectionFaces ?? null,
77
79
  reflectionAtlas: settings.reflectionAtlas ?? null,
80
+ pivot: settings.pivot?.clone(),
78
81
  layerMask: settings.layerMask,
79
82
  };
80
83
  this.mesh = new Mesh('vfxBatch', scene);
@@ -21,6 +21,11 @@ uniform float tileCountX;
21
21
  uniform float tileCountY;
22
22
  #endif
23
23
 
24
+ #ifdef PIVOT
25
+ // Quad offset in particle-size units: xy in the quad's plane, z toward the camera.
26
+ uniform vec3 pivot;
27
+ #endif
28
+
24
29
  // Varyings
25
30
  varying vec2 vUV;
26
31
  varying vec4 vColor;
@@ -37,7 +42,12 @@ void main() {
37
42
  float c = cos(rotation);
38
43
  float s = sin(rotation);
39
44
 
45
+ // A negative size mirrors the quad, which is how flipped particles arrive.
40
46
  vec2 alignedPosition = position.xy * size.xy;
47
+ #ifdef PIVOT
48
+ // The pivot turns with the particle but is not mirrored with it.
49
+ alignedPosition += pivot.xy * abs(size.xy);
50
+ #endif
41
51
  vec2 rotatedPosition;
42
52
  rotatedPosition.x = c * alignedPosition.x - s * alignedPosition.y;
43
53
  rotatedPosition.y = s * alignedPosition.x + c * alignedPosition.y;
@@ -46,6 +56,9 @@ void main() {
46
56
  vec4 mvPosition = world * vec4(offset, 1.0);
47
57
  mvPosition.x += rotatedPosition.x;
48
58
  mvPosition.z -= rotatedPosition.y;
59
+ #ifdef PIVOT
60
+ mvPosition.y += pivot.z * abs(size.x);
61
+ #endif
49
62
  mvPosition = view * mvPosition;
50
63
  #elif defined(VERTICAL)
51
64
  vec4 mvPosition = world * vec4(offset, 1.0);
@@ -55,6 +68,13 @@ void main() {
55
68
  #else
56
69
  vec4 mvPosition = view * world * vec4(offset, 1.0);
57
70
  mvPosition.xy += rotatedPosition;
71
+ #endif
72
+ #ifdef PIVOT
73
+ #ifndef HORIZONTAL
74
+ // Toward the camera along its view axis, whichever way the view space
75
+ // faces: anything in front of the camera has the sign of the far side.
76
+ mvPosition.z -= sign(mvPosition.z) * pivot.z * abs(size.x);
77
+ #endif
58
78
  #endif
59
79
 
60
80
  gl_Position = projection * mvPosition;
@@ -20,6 +20,11 @@ uniform tileCountX: f32;
20
20
  uniform tileCountY: f32;
21
21
  #endif
22
22
 
23
+ #ifdef PIVOT
24
+ // Quad offset in particle-size units: xy in the quad's plane, z toward the camera.
25
+ uniform pivot: vec3f;
26
+ #endif
27
+
23
28
  // Varyings
24
29
  varying vUV: vec2f;
25
30
  varying vColor: vec4f;
@@ -37,7 +42,12 @@ fn main(input: VertexInputs) -> FragmentInputs {
37
42
  let c = cos(vertexInputs.rotation);
38
43
  let s = sin(vertexInputs.rotation);
39
44
 
40
- let alignedPosition = vertexInputs.position.xy * vertexInputs.size.xy;
45
+ // A negative size mirrors the quad, which is how flipped particles arrive.
46
+ var alignedPosition = vertexInputs.position.xy * vertexInputs.size.xy;
47
+ #ifdef PIVOT
48
+ // The pivot turns with the particle but is not mirrored with it.
49
+ alignedPosition += uniforms.pivot.xy * abs(vertexInputs.size.xy);
50
+ #endif
41
51
  let rotatedPosition = vec2f(
42
52
  c * alignedPosition.x - s * alignedPosition.y,
43
53
  s * alignedPosition.x + c * alignedPosition.y);
@@ -46,6 +56,9 @@ fn main(input: VertexInputs) -> FragmentInputs {
46
56
  var mvPosition = uniforms.world * vec4f(vertexInputs.offset, 1.0);
47
57
  mvPosition.x += rotatedPosition.x;
48
58
  mvPosition.z -= rotatedPosition.y;
59
+ #ifdef PIVOT
60
+ mvPosition.y += uniforms.pivot.z * abs(vertexInputs.size.x);
61
+ #endif
49
62
  mvPosition = uniforms.view * mvPosition;
50
63
  #elif defined(VERTICAL)
51
64
  var mvPosition = uniforms.world * vec4f(vertexInputs.offset, 1.0);
@@ -56,6 +69,13 @@ fn main(input: VertexInputs) -> FragmentInputs {
56
69
  var mvPosition = uniforms.view * uniforms.world * vec4f(vertexInputs.offset, 1.0);
57
70
  mvPosition.x += rotatedPosition.x;
58
71
  mvPosition.y += rotatedPosition.y;
72
+ #endif
73
+ #ifdef PIVOT
74
+ #ifndef HORIZONTAL
75
+ // Toward the camera along its view axis, whichever way the view space
76
+ // faces: anything in front of the camera has the sign of the far side.
77
+ mvPosition.z -= sign(mvPosition.z) * uniforms.pivot.z * abs(vertexInputs.size.x);
78
+ #endif
59
79
  #endif
60
80
 
61
81
  let clipPosition = uniforms.projection * mvPosition;
@@ -22,6 +22,11 @@ uniform float tileCountX;
22
22
  uniform float tileCountY;
23
23
  #endif
24
24
 
25
+ #ifdef PIVOT
26
+ // Streak offset in particle-size units: x from head to tail, y across.
27
+ uniform vec3 pivot;
28
+ #endif
29
+
25
30
  // Varyings
26
31
  varying vec2 vUV;
27
32
  varying vec4 vColor;
@@ -36,7 +41,9 @@ varying float linearDepth;
36
41
 
37
42
  void main() {
38
43
  float lengthFactor = velocity.w;
39
- float avgSize = (size.x + size.y) * 0.5;
44
+ // A flipped particle arrives with a negative size: the streak keeps its
45
+ // extent and mirrors its texture instead (see quadUV below).
46
+ float avgSize = (abs(size.x) + abs(size.y)) * 0.5;
40
47
  vec4 mvPosition = view * world * vec4(offset, 1.0);
41
48
  vec3 viewVelocity = mat3(view * world) * velocity.xyz;
42
49
  float vlength = length(viewVelocity);
@@ -44,11 +51,17 @@ void main() {
44
51
  // Deriving it via normalize keeps the stretch aligned even when the speed contribution is ~0
45
52
  // (e.g. speedFactor 0), instead of collapsing the whole burst onto a fixed screen axis.
46
53
  vec3 vdir = vlength > 0.000001 ? viewVelocity / vlength : vec3(0.0, 0.0, 1.0);
47
- mvPosition.xyz += position.y * normalize(cross(mvPosition.xyz, vdir)) * avgSize;
54
+ vec3 side = normalize(cross(mvPosition.xyz, vdir));
55
+ mvPosition.xyz += position.y * side * avgSize;
48
56
  // Equivalent to viewVelocity * (1.0 + lengthFactor / vlength) for moving particles, but the
49
57
  // size-based term (vdir * lengthFactor) survives when the velocity contribution vanishes.
50
58
  mvPosition.xyz -= (position.x + 0.5) * (viewVelocity + vdir * lengthFactor) * avgSize;
59
+ #ifdef PIVOT
60
+ // In particle-size units along the quad's own axes: x from head to tail, y across.
61
+ mvPosition.xyz += (pivot.y * side - pivot.x * vdir) * avgSize;
62
+ #endif
51
63
  gl_Position = projection * mvPosition;
64
+ vec2 quadUV = vec2(size.x < 0.0 ? 1.0 - uv.x : uv.x, size.y < 0.0 ? 1.0 - uv.y : uv.y);
52
65
  #ifdef SOFT_PARTICLES
53
66
  projPosition = gl_Position;
54
67
  linearDepth = -mvPosition.z;
@@ -59,16 +72,16 @@ void main() {
59
72
  float baseTile = floor(uvTile);
60
73
  float tileU = mod(baseTile, tc.x) / tc.x;
61
74
  float tileV = 1.0 - floor(baseTile / tc.x) / tc.y - 1.0 / tc.y;
62
- vUV = uv / tc + vec2(tileU, tileV);
75
+ vUV = quadUV / tc + vec2(tileU, tileV);
63
76
  #ifdef TILE_BLEND
64
77
  float nextTile = ceil(uvTile);
65
78
  float nextU = mod(nextTile, tc.x) / tc.x;
66
79
  float nextV = 1.0 - floor(nextTile / tc.x) / tc.y - 1.0 / tc.y;
67
- vUV2 = uv / tc + vec2(nextU, nextV);
80
+ vUV2 = quadUV / tc + vec2(nextU, nextV);
68
81
  vTileBlend = fract(uvTile);
69
82
  #endif
70
83
  #else
71
- vUV = uv;
84
+ vUV = quadUV;
72
85
  #endif
73
86
 
74
87
  vColor = color;
@@ -22,6 +22,11 @@ uniform tileCountX: f32;
22
22
  uniform tileCountY: f32;
23
23
  #endif
24
24
 
25
+ #ifdef PIVOT
26
+ // Streak offset in particle-size units: x from head to tail, y across.
27
+ uniform pivot: vec3f;
28
+ #endif
29
+
25
30
  // Varyings
26
31
  varying vUV: vec2f;
27
32
  varying vColor: vec4f;
@@ -37,7 +42,9 @@ varying linearDepth: f32;
37
42
  @vertex
38
43
  fn main(input: VertexInputs) -> FragmentInputs {
39
44
  let lengthFactor = vertexInputs.velocity.w;
40
- let avgSize = (vertexInputs.size.x + vertexInputs.size.y) * 0.5;
45
+ // A flipped particle arrives with a negative size: the streak keeps its
46
+ // extent and mirrors its texture instead (see quadUV below).
47
+ let avgSize = (abs(vertexInputs.size.x) + abs(vertexInputs.size.y)) * 0.5;
41
48
  var mvPosition = uniforms.view * uniforms.world * vec4f(vertexInputs.offset, 1.0);
42
49
  let viewWorld = uniforms.view * uniforms.world;
43
50
  let viewWorld3 = mat3x3f(viewWorld[0].xyz, viewWorld[1].xyz, viewWorld[2].xyz);
@@ -50,17 +57,23 @@ fn main(input: VertexInputs) -> FragmentInputs {
50
57
  if (vlength > 0.000001) {
51
58
  vdir = viewVelocity / vlength;
52
59
  }
53
- let widthOffset = vertexInputs.position.y * normalize(cross(mvPosition.xyz, vdir)) * avgSize;
60
+ let side = normalize(cross(mvPosition.xyz, vdir));
61
+ let widthOffset = vertexInputs.position.y * side * avgSize;
54
62
  // Equivalent to viewVelocity * (1.0 + lengthFactor / vlength) for moving particles, but the
55
63
  // size-based term (vdir * lengthFactor) survives when the velocity contribution vanishes.
56
64
  let lengthOffset = (vertexInputs.position.x + 0.5) * (viewVelocity + vdir * lengthFactor) * avgSize;
57
- let stretched = mvPosition.xyz + widthOffset - lengthOffset;
65
+ var stretched = mvPosition.xyz + widthOffset - lengthOffset;
66
+ #ifdef PIVOT
67
+ // In particle-size units along the quad's own axes: x from head to tail, y across.
68
+ stretched += (uniforms.pivot.y * side - uniforms.pivot.x * vdir) * avgSize;
69
+ #endif
58
70
  mvPosition.x = stretched.x;
59
71
  mvPosition.y = stretched.y;
60
72
  mvPosition.z = stretched.z;
61
73
 
62
74
  let clipPosition = uniforms.projection * mvPosition;
63
75
  vertexOutputs.position = clipPosition;
76
+ let quadUV = select(vertexInputs.uv, 1.0 - vertexInputs.uv, vertexInputs.size.xy < vec2f(0.0));
64
77
  #ifdef SOFT_PARTICLES
65
78
  vertexOutputs.projPosition = clipPosition;
66
79
  vertexOutputs.linearDepth = -mvPosition.z;
@@ -71,16 +84,16 @@ fn main(input: VertexInputs) -> FragmentInputs {
71
84
  let baseTile = floor(vertexInputs.uvTile);
72
85
  let tileU = (baseTile % tc.x) / tc.x;
73
86
  let tileV = 1.0 - floor(baseTile / tc.x) / tc.y - 1.0 / tc.y;
74
- vertexOutputs.vUV = vertexInputs.uv / tc + vec2f(tileU, tileV);
87
+ vertexOutputs.vUV = quadUV / tc + vec2f(tileU, tileV);
75
88
  #ifdef TILE_BLEND
76
89
  let nextTile = ceil(vertexInputs.uvTile);
77
90
  let nextU = (nextTile % tc.x) / tc.x;
78
91
  let nextV = 1.0 - floor(nextTile / tc.x) / tc.y - 1.0 / tc.y;
79
- vertexOutputs.vUV2 = vertexInputs.uv / tc + vec2f(nextU, nextV);
92
+ vertexOutputs.vUV2 = quadUV / tc + vec2f(nextU, nextV);
80
93
  vertexOutputs.vTileBlend = fract(vertexInputs.uvTile);
81
94
  #endif
82
95
  #else
83
- vertexOutputs.vUV = vertexInputs.uv;
96
+ vertexOutputs.vUV = quadUV;
84
97
  #endif
85
98
 
86
99
  vertexOutputs.vColor = vertexInputs.color;