babylon.quarks 0.24.1 → 0.26.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.
@@ -75,6 +75,7 @@ interface ParsedGeometry {
75
75
  positions: Float32Array;
76
76
  indices: Uint32Array | Uint16Array;
77
77
  uvs?: Float32Array;
78
+ uvs2?: Float32Array;
78
79
  normals?: Float32Array;
79
80
  }
80
81
 
@@ -101,6 +102,7 @@ function geometryKey(geometry: ParsedGeometry): string {
101
102
  geometry.positions,
102
103
  geometry.indices,
103
104
  geometry.uvs,
105
+ geometry.uvs2,
104
106
  geometry.normals,
105
107
  ];
106
108
  let lengths = '';
@@ -313,6 +315,7 @@ export class QuarksLoader {
313
315
  positions: new Float32Array(geom.positions ?? []),
314
316
  indices: geom.indices ? new Uint32Array(geom.indices) : new Uint32Array(0),
315
317
  uvs: geom.uvs ? new Float32Array(geom.uvs) : undefined,
318
+ uvs2: geom.uvs2 ? new Float32Array(geom.uvs2) : undefined,
316
319
  normals: geom.normals ? new Float32Array(geom.normals) : undefined,
317
320
  });
318
321
  }
@@ -427,7 +430,10 @@ export class QuarksLoader {
427
430
  let positions = new Float32Array(0);
428
431
  let indices: Uint32Array | Uint16Array = new Uint32Array(0);
429
432
  let uvs: Float32Array | undefined;
433
+ let uvs2: Float32Array | undefined;
430
434
  let normals: Float32Array | undefined;
435
+ // three.js names the second UV set `uv1` (r151 on), `uv2` before.
436
+ const uv2Attribute = data.attributes?.uv1 ?? data.attributes?.uv2;
431
437
 
432
438
  if (data.interleavedBuffers && data.arrayBuffers) {
433
439
  const buffers: {[uuid: string]: {data: Float32Array; stride: number}} = {};
@@ -453,6 +459,16 @@ export class QuarksLoader {
453
459
  const buf = buffers[attr.data];
454
460
  if (buf) uvs = this.extractInterleavedAttribute(buf.data, buf.stride, attr.offset, attr.itemSize);
455
461
  }
462
+ if (uv2Attribute?.isInterleavedBufferAttribute) {
463
+ const buf = buffers[uv2Attribute.data];
464
+ if (buf)
465
+ uvs2 = this.extractInterleavedAttribute(
466
+ buf.data,
467
+ buf.stride,
468
+ uv2Attribute.offset,
469
+ uv2Attribute.itemSize
470
+ );
471
+ }
456
472
  if (data.attributes.normal?.isInterleavedBufferAttribute) {
457
473
  const attr = data.attributes.normal;
458
474
  const buf = buffers[attr.data];
@@ -467,6 +483,9 @@ export class QuarksLoader {
467
483
  if (data.attributes.uv?.array) {
468
484
  uvs = new Float32Array(data.attributes.uv.array);
469
485
  }
486
+ if (uv2Attribute?.array) {
487
+ uvs2 = new Float32Array(uv2Attribute.array);
488
+ }
470
489
  if (data.attributes.normal?.array) {
471
490
  normals = new Float32Array(data.attributes.normal.array);
472
491
  }
@@ -482,7 +501,7 @@ export class QuarksLoader {
482
501
 
483
502
  indices = ensureTriangleIndices(positions, indices);
484
503
 
485
- return {positions, indices, uvs, normals};
504
+ return {positions, indices, uvs, uvs2, normals};
486
505
  }
487
506
 
488
507
  private extractInterleavedAttribute(buffer: Float32Array, stride: number, offset: number, itemSize: number): any {
@@ -666,7 +685,7 @@ export class QuarksLoader {
666
685
  const data = await loadGeometry(geom.url);
667
686
  // Copy arrays so a shared fetch-cache document is not aliased across
668
687
  // effects — mutating one effect's inlined geometry must not corrupt others.
669
- for (const key of ['positions', 'indices', 'uvs', 'normals']) {
688
+ for (const key of ['positions', 'indices', 'uvs', 'uvs2', 'normals']) {
670
689
  const value = data?.[key];
671
690
  if (value === undefined) {
672
691
  continue;
@@ -824,6 +843,32 @@ export class QuarksLoader {
824
843
  if (matDef.rim && typeof matDef.rim === 'object') {
825
844
  matInfo.rim = matDef.rim;
826
845
  }
846
+ // Texture effects (QuarksMaterial): mapUV {channel, tiling, offset, scroll},
847
+ // gradientMap {ramp (texture uuid), channel},
848
+ // erosion {mask (texture uuid), maskUV {channel, tiling, offset, scroll}, softness, edgeWidth,
849
+ // edgeColor}.
850
+ if (matDef.mapUV && typeof matDef.mapUV === 'object') {
851
+ matInfo.mapUV = matDef.mapUV;
852
+ }
853
+ if (matDef.gradientMap && typeof matDef.gradientMap === 'object') {
854
+ matInfo.gradientMap = {
855
+ ...matDef.gradientMap,
856
+ ramp: matDef.gradientMap.ramp ? meta.textures[matDef.gradientMap.ramp] || null : null,
857
+ };
858
+ }
859
+ if (matDef.erosion && typeof matDef.erosion === 'object') {
860
+ matInfo.erosion = {
861
+ ...matDef.erosion,
862
+ mask: matDef.erosion.mask ? meta.textures[matDef.erosion.mask] || null : null,
863
+ };
864
+ }
865
+ // uvDistortion {map (texture uuid), mapUV {channel, tiling, offset, scroll}, strength}.
866
+ if (matDef.uvDistortion && typeof matDef.uvDistortion === 'object') {
867
+ matInfo.uvDistortion = {
868
+ ...matDef.uvDistortion,
869
+ map: matDef.uvDistortion.map ? meta.textures[matDef.uvDistortion.map] || null : null,
870
+ };
871
+ }
827
872
 
828
873
  // QuarksMaterial (what ParticleSystem.ensureMaterialMeta emits) writes `alphaMode`
829
874
  // directly as a Babylon alpha-mode constant; three.js materials (MeshBasicMaterial
@@ -873,6 +918,9 @@ export class QuarksLoader {
873
918
  if (geometry.uvs && geometry.uvs.length > 0) {
874
919
  vertexData.uvs = geometry.uvs;
875
920
  }
921
+ if (geometry.uvs2 && geometry.uvs2.length > 0) {
922
+ vertexData.uvs2 = geometry.uvs2;
923
+ }
876
924
  if (geometry.normals && geometry.normals.length > 0) {
877
925
  vertexData.normals = geometry.normals;
878
926
  }
package/src/QuarksUtil.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
2
2
  import {Node} from '@babylonjs/core/node';
3
- import {EmitSubParticleSystem} from 'quarks.core';
3
+ import {ConstantValue, EmitSubParticleSystem, FunctionValueGenerator, IntervalValue, ValueGenerator} from 'quarks.core';
4
4
  import {BatchedRenderer} from './BatchedRenderer';
5
5
  import {ParticleEmitter} from './ParticleEmitter';
6
6
  import {ParticleSystem} from './ParticleSystem';
@@ -120,6 +120,102 @@ export class QuarksUtil {
120
120
  return copy;
121
121
  }
122
122
 
123
+ /**
124
+ * Seconds until the effect under `root` is over when it plays from the
125
+ * start: for each system its start delay, its duration and its longest
126
+ * particle life, divided by its playback speed; a sub emitter target goes
127
+ * on emitting for its own duration after the last of its parents' particles
128
+ * and ends a particle life after that, wherever it sits in the scene.
129
+ * Infinity when a system loops, since such an effect only ends on
130
+ * {@link endEmit}, or when one is frozen at playback speed 0.
131
+ *
132
+ * Random values count at their maximum, so this is an upper bound; curves
133
+ * are sampled. Particles cut short by collisions or culling only make the
134
+ * real effect shorter.
135
+ */
136
+ static getDuration(root: Node): number {
137
+ const systems: ParticleSystem[] = [];
138
+ QuarksUtil.runOnAllParticleEmitters(root, (emitter) => {
139
+ systems.push(emitter.system as unknown as ParticleSystem);
140
+ });
141
+ const parents = new Map<ParticleSystem, ParticleSystem[]>();
142
+ // Grows while it is walked: a target outside `root` is part of the effect too.
143
+ for (let i = 0; i < systems.length; i++) {
144
+ const system = systems[i];
145
+ for (const behavior of system.behaviors) {
146
+ if (!(behavior instanceof EmitSubParticleSystem)) {
147
+ continue;
148
+ }
149
+ const target = (behavior.subParticleSystem as unknown as ParticleEmitter | undefined)?.system as
150
+ | ParticleSystem
151
+ | undefined;
152
+ if (target !== undefined && typeof target === 'object') {
153
+ if (!systems.includes(target)) {
154
+ systems.push(target);
155
+ }
156
+ const list = parents.get(target) ?? [];
157
+ list.push(system);
158
+ parents.set(target, list);
159
+ }
160
+ }
161
+ }
162
+ const ends = new Map<ParticleSystem, number>();
163
+ const endOf = (system: ParticleSystem): number => {
164
+ const known = ends.get(system);
165
+ if (known !== undefined) {
166
+ return known;
167
+ }
168
+ // Marks the system while it is resolved, so a sub emitter cycle loops forever rather than the stack.
169
+ ends.set(system, Infinity);
170
+ const speed = system.playbackSpeed;
171
+ const life = QuarksUtil.maxOf(system.startLife) / speed;
172
+ let end: number;
173
+ if (!(speed > 0)) {
174
+ end = Infinity;
175
+ } else if (system.onlyUsedByOther) {
176
+ end = 0;
177
+ for (const parent of parents.get(system) ?? []) {
178
+ end = Math.max(end, endOf(parent));
179
+ }
180
+ end += system.duration / speed + life;
181
+ } else if (system.looping) {
182
+ end = Infinity;
183
+ } else {
184
+ // The delay is drawn once, at the start.
185
+ end = (QuarksUtil.maxOf(system.startDelay, 0) + system.duration) / speed + life;
186
+ }
187
+ ends.set(system, end);
188
+ return end;
189
+ };
190
+ let duration = 0;
191
+ for (const system of systems) {
192
+ duration = Math.max(duration, endOf(system));
193
+ }
194
+ return duration;
195
+ }
196
+
197
+ /** The largest value `generator` gives: exact for constants and intervals, sampled otherwise. */
198
+ private static maxOf(generator: ValueGenerator | FunctionValueGenerator, at?: number): number {
199
+ if (generator instanceof ConstantValue) {
200
+ return generator.value;
201
+ }
202
+ if (generator instanceof IntervalValue) {
203
+ return Math.max(generator.a, generator.b);
204
+ }
205
+ let max = -Infinity;
206
+ const samples = 32;
207
+ for (let i = 0; i <= samples; i++) {
208
+ const memory: number[] = [];
209
+ generator.startGen(memory);
210
+ const value =
211
+ generator.type === 'function'
212
+ ? generator.genValue(memory, at ?? i / samples)
213
+ : generator.genValue(memory);
214
+ max = Math.max(max, value);
215
+ }
216
+ return max;
217
+ }
218
+
123
219
  private static traverseNode(node: Node, callback: (node: Node) => void): void {
124
220
  callback(node);
125
221
  const children = node.getChildren();
@@ -15,9 +15,11 @@ import {
15
15
  SpriteParticle,
16
16
  StretchedBillBoardSettings,
17
17
  Vector3,
18
+ Vector4,
18
19
  } from 'quarks.core';
19
20
  import {VFXBatchSettings} from './BatchedRenderer';
20
21
  import {gather, sortByKey, SortScratch} from './instanceSort';
22
+ import type {ParticleSystem} from './ParticleSystem';
21
23
  import local_particle_physics_vert from './shaders/local_particle_physics_vert.glsl';
22
24
  import local_particle_physics_vert_wgsl from './shaders/local_particle_physics_vert.wgsl';
23
25
  import particle_frag from './shaders/particle_frag.glsl';
@@ -90,6 +92,9 @@ export class SpriteBatch extends VFXBatch {
90
92
  } else {
91
93
  vertexData.uvs = new Float32Array(vertexCount * 2);
92
94
  }
95
+ if (this.meshUV2()) {
96
+ vertexData.uvs2 = this.settings.instancingUVs2!;
97
+ }
93
98
  if (this.settings.instancingNormals) {
94
99
  vertexData.normals = this.settings.instancingNormals;
95
100
  }
@@ -98,6 +103,33 @@ export class SpriteBatch extends VFXBatch {
98
103
  this.buildExpandableBuffers();
99
104
  }
100
105
 
106
+ /**
107
+ * True when a mesh batch reads UV channel 1 and has a second UV set to
108
+ * read it from, two values per vertex. A set of any other length is left
109
+ * out with a warning, and channel 1 then reads the first set.
110
+ */
111
+ private warnedUV2 = false;
112
+
113
+ private meshUV2(): boolean {
114
+ const uvs2 = this.settings.instancingUVs2;
115
+ if (this.settings.renderMode !== RenderMode.Mesh || !uvs2 || !this.usesUV1()) {
116
+ return false;
117
+ }
118
+ const expected = (this.settings.instancingGeometry.length / 3) * 2;
119
+ if (uvs2.length !== expected) {
120
+ if (this.warnedUV2) {
121
+ return false;
122
+ }
123
+ this.warnedUV2 = true;
124
+ console.warn(
125
+ `[babylon.quarks] instancingUVs2 has ${uvs2.length} values for ${expected / 2} vertices ` +
126
+ '(two per vertex expected); UV channel 1 reads the first UV set instead.'
127
+ );
128
+ return false;
129
+ }
130
+ return true;
131
+ }
132
+
101
133
  /** Releases the instance vertex buffers so a resize does not leak GPU memory. */
102
134
  private disposeExpandableBuffers(): void {
103
135
  this.offsetVB?.dispose();
@@ -138,7 +170,7 @@ export class SpriteBatch extends VFXBatch {
138
170
  }
139
171
  this.mesh.setVerticesBuffer(this.rotationVB);
140
172
 
141
- if (this.settings.renderMode === RenderMode.StretchedBillBoard) {
173
+ if (SpriteBatch.drawsAlongVelocity(this.settings.renderMode)) {
142
174
  this.velocityBuffer = new Float32Array(this.maxParticles * 4);
143
175
  this.velocityVB = new VertexBuffer(engine, this.velocityBuffer, 'velocity', true, false, 4, true);
144
176
  this.mesh.setVerticesBuffer(this.velocityVB);
@@ -276,7 +308,7 @@ export class SpriteBatch extends VFXBatch {
276
308
  if (this.settings.renderMode === RenderMode.Mesh) {
277
309
  vertexShader = {glsl: local_particle_physics_vert, wgsl: local_particle_physics_vert_wgsl};
278
310
  fragmentShader = {glsl: particle_physics_frag, wgsl: particle_physics_frag_wgsl};
279
- } else if (this.settings.renderMode === RenderMode.StretchedBillBoard) {
311
+ } else if (SpriteBatch.drawsAlongVelocity(this.settings.renderMode)) {
280
312
  vertexShader = {glsl: stretched_bb_particle_vert, wgsl: stretched_bb_particle_vert_wgsl};
281
313
  fragmentShader = {glsl: particle_frag, wgsl: particle_frag_wgsl};
282
314
  } else {
@@ -322,6 +354,9 @@ export class SpriteBatch extends VFXBatch {
322
354
  if (this.settings.renderMode === RenderMode.HorizontalBillBoard) {
323
355
  defines.push('HORIZONTAL');
324
356
  }
357
+ if (this.settings.renderMode === RenderMode.Beam) {
358
+ defines.push('BEAM');
359
+ }
325
360
  const pivot = SpriteBatch.takesPivotAndFlip(this.settings.renderMode) ? this.settings.pivot : undefined;
326
361
  if (pivot) {
327
362
  defines.push('PIVOT');
@@ -339,11 +374,14 @@ export class SpriteBatch extends VFXBatch {
339
374
  const rim = isMesh ? this.settings.rim : undefined;
340
375
  if (rim) {
341
376
  defines.push('MESH_RIM');
342
- if (rim.mode === 'mix') {
377
+ if (rim.mode === 'fade') {
378
+ defines.push('MESH_RIM_FADE');
379
+ } else if (rim.mode === 'mix') {
343
380
  defines.push('MESH_RIM_MIX');
344
381
  }
345
382
  }
346
383
  const needsEyePosition = useEnvAtlas || !!rim;
384
+ defines.push(...this.surfaceDefines());
347
385
 
348
386
  const shaderLanguage = shaderLanguageFor(this.scene.getEngine());
349
387
  registerShaders(shaderName, vertexShader, fragmentShader, shaderLanguage);
@@ -352,7 +390,11 @@ export class SpriteBatch extends VFXBatch {
352
390
  if (this.settings.renderMode === RenderMode.Mesh) {
353
391
  attributes.push('normal');
354
392
  }
355
- if (this.settings.renderMode === RenderMode.StretchedBillBoard) {
393
+ if (this.usesUV1() && this.meshUV2()) {
394
+ defines.push('MESH_UV2');
395
+ attributes.push('uv2');
396
+ }
397
+ if (SpriteBatch.drawsAlongVelocity(this.settings.renderMode)) {
356
398
  attributes.push('velocity');
357
399
  }
358
400
 
@@ -377,7 +419,8 @@ export class SpriteBatch extends VFXBatch {
377
419
  uniforms.push('rimColor');
378
420
  uniforms.push('rimParams');
379
421
  }
380
- if (this.settings.renderMode === RenderMode.StretchedBillBoard) {
422
+ this.surfaceUniforms(uniforms, samplers);
423
+ if (SpriteBatch.drawsAlongVelocity(this.settings.renderMode)) {
381
424
  uniforms.push('speedFactor');
382
425
  }
383
426
  if (this.settings.softParticles) {
@@ -441,7 +484,7 @@ export class SpriteBatch extends VFXBatch {
441
484
  mat.setFloat('tileCountX', this.settings.uTileCount);
442
485
  mat.setFloat('tileCountY', this.settings.vTileCount);
443
486
  }
444
- if (this.settings.renderMode === RenderMode.StretchedBillBoard) {
487
+ if (SpriteBatch.drawsAlongVelocity(this.settings.renderMode)) {
445
488
  mat.setFloat('speedFactor', this.settings.softNearFade > 0 ? this.settings.softNearFade : 1.0);
446
489
  }
447
490
  if (this.settings.softParticles) {
@@ -491,6 +534,7 @@ export class SpriteBatch extends VFXBatch {
491
534
  }
492
535
 
493
536
  this.bindFog(mat);
537
+ this.bindSurface(mat);
494
538
  mat.backFaceCulling = false;
495
539
 
496
540
  if (this.settings.materialTransparent) {
@@ -584,6 +628,8 @@ export class SpriteBatch extends VFXBatch {
584
628
  const renderMode = this.settings.renderMode;
585
629
  const isMeshRender = renderMode === RenderMode.Mesh;
586
630
  const isStretchedRender = renderMode === RenderMode.StretchedBillBoard;
631
+ const isBeamRender = renderMode === RenderMode.Beam;
632
+ const usesVelocity = isStretchedRender || isBeamRender;
587
633
  const flipsSprites = SpriteBatch.takesPivotAndFlip(renderMode);
588
634
 
589
635
  const visibleSystems = this.getVisibleSystems();
@@ -904,6 +950,10 @@ export class SpriteBatch extends VFXBatch {
904
950
  }
905
951
  }
906
952
 
953
+ const tint = (system as ParticleSystem).tint;
954
+ if (tint !== undefined && index > firstIndex) {
955
+ SpriteBatch.applyTint(this.colorBuffer, firstIndex * 4, index - firstIndex, tint);
956
+ }
907
957
  if (flipsSprites && system.flip !== undefined) {
908
958
  if (store !== undefined) {
909
959
  SpriteBatch.applyFlip(this.sizeBuffer, firstIndex * 3, store.flip, particleNum);
@@ -911,6 +961,9 @@ export class SpriteBatch extends VFXBatch {
911
961
  SpriteBatch.applyFlipFromParticles(this.sizeBuffer, firstIndex * 3, particles, particleNum);
912
962
  }
913
963
  }
964
+ if (isBeamRender && this.velocityBuffer) {
965
+ this.writeBeams(system as ParticleSystem, firstIndex, index - firstIndex);
966
+ }
914
967
  }
915
968
 
916
969
  if (isStretchedRender && visibleSystems.length > 0) {
@@ -926,7 +979,7 @@ export class SpriteBatch extends VFXBatch {
926
979
  // Slot 0 only needs clearing once per stretch of empty frames; re-uploading it every
927
980
  // frame cost five buffer uploads per idle batch per frame.
928
981
  if (!this.clearedInstanceUploaded) {
929
- this.uploadClearedInstance(isMeshRender, isStretchedRender);
982
+ this.uploadClearedInstance(isMeshRender, usesVelocity);
930
983
  this.clearedInstanceUploaded = true;
931
984
  }
932
985
  return;
@@ -934,7 +987,7 @@ export class SpriteBatch extends VFXBatch {
934
987
  this.clearedInstanceUploaded = false;
935
988
 
936
989
  if (this.settings.sortMode !== 'none') {
937
- this.sortInstances(index, isMeshRender, isStretchedRender);
990
+ this.sortInstances(index, isMeshRender, usesVelocity);
938
991
  }
939
992
 
940
993
  // Upload only the slots actually filled this frame: the buffers are sized
@@ -944,7 +997,7 @@ export class SpriteBatch extends VFXBatch {
944
997
  this.colorVB.update(this.colorBuffer.subarray(0, index * 4));
945
998
  this.uvTileVB.update(this.uvTileBuffer.subarray(0, index));
946
999
  this.rotationVB.update(this.rotationBuffer.subarray(0, isMeshRender ? index * 4 : index));
947
- if (isStretchedRender && this.velocityVB && this.velocityBuffer) {
1000
+ if (usesVelocity && this.velocityVB && this.velocityBuffer) {
948
1001
  this.velocityVB.update(this.velocityBuffer.subarray(0, index * 4));
949
1002
  }
950
1003
  }
@@ -970,7 +1023,35 @@ export class SpriteBatch extends VFXBatch {
970
1023
  }
971
1024
  }
972
1025
 
973
- /** The quads that take a pivot and flip: every billboard kind, stretched included, but not meshes. */
1026
+ /** Stretched billboards and beams: drawn along a per-instance vector, by the stretched shader. */
1027
+ private static drawsAlongVelocity(renderMode: RenderMode): boolean {
1028
+ return renderMode === RenderMode.StretchedBillBoard || renderMode === RenderMode.Beam;
1029
+ }
1030
+
1031
+ private readonly beamEnd_ = new Vector3();
1032
+
1033
+ /**
1034
+ * Writes, for the `count` instances from `first`, the world vector from
1035
+ * each one's drawn position to the system's beam end into the velocity
1036
+ * buffer, which the stretched shader under `BEAM` lays the quad along. A
1037
+ * beam without an end gets no length.
1038
+ */
1039
+ private writeBeams(system: ParticleSystem, first: number, count: number): void {
1040
+ const velocities = this.velocityBuffer!;
1041
+ const offsets = this.offsetBuffer;
1042
+ const end = this.beamEnd_;
1043
+ const hasEnd = typeof system.getBeamEnd === 'function' && system.getBeamEnd(end);
1044
+ for (let i = first; i < first + count; i++) {
1045
+ const o = i * 3;
1046
+ const v = i * 4;
1047
+ velocities[v] = hasEnd ? end.x - offsets[o] : 0;
1048
+ velocities[v + 1] = hasEnd ? end.y - offsets[o + 1] : 0;
1049
+ velocities[v + 2] = hasEnd ? end.z - offsets[o + 2] : 0;
1050
+ velocities[v + 3] = 0;
1051
+ }
1052
+ }
1053
+
1054
+ /** The quads that take a pivot and flip: every billboard kind, stretched included, but not meshes or beams. */
974
1055
  private static takesPivotAndFlip(renderMode: RenderMode): boolean {
975
1056
  return (
976
1057
  renderMode === RenderMode.BillBoard ||
@@ -980,6 +1061,16 @@ export class SpriteBatch extends VFXBatch {
980
1061
  );
981
1062
  }
982
1063
 
1064
+ /** Multiplies `count` RGBA colours starting at `base` by a system's {@link ParticleSystem.tint}. */
1065
+ private static applyTint(colors: Float32Array, base: number, count: number, tint: Vector4): void {
1066
+ for (let j = 0, o = base; j < count; j++, o += 4) {
1067
+ colors[o] *= tint.x;
1068
+ colors[o + 1] *= tint.y;
1069
+ colors[o + 2] *= tint.z;
1070
+ colors[o + 3] *= tint.w;
1071
+ }
1072
+ }
1073
+
983
1074
  /**
984
1075
  * Negates the size of each flipped particle along the axes it flips on,
985
1076
  * starting at `base` in `sizes`. The billboard shader scales the quad by