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.
package/src/VFXBatch.ts CHANGED
@@ -3,7 +3,7 @@ import {Constants} from '@babylonjs/core/Engines/constants';
3
3
  import {BaseTexture} from '@babylonjs/core/Materials/Textures/baseTexture';
4
4
  import {ShaderMaterial} from '@babylonjs/core/Materials/shaderMaterial';
5
5
  import {Color3} from '@babylonjs/core/Maths/math.color';
6
- import {Vector3 as BVector3} from '@babylonjs/core/Maths/math.vector';
6
+ import {Vector2 as BVector2, Vector3 as BVector3, Vector4 as BVector4} from '@babylonjs/core/Maths/math.vector';
7
7
  import {Mesh} from '@babylonjs/core/Meshes/mesh';
8
8
  import {Scene} from '@babylonjs/core/scene';
9
9
  import {IParticleSystem, Vector3} from 'quarks.core';
@@ -30,6 +30,13 @@ export enum RenderMode {
30
30
  Trail = 3,
31
31
  HorizontalBillBoard = 4,
32
32
  VerticalBillBoard = 5,
33
+ /**
34
+ * A quad laid from each particle to the system's {@link ParticleSystem.beamEnd}
35
+ * and turned about its length to face the camera (a line renderer): U runs
36
+ * from the particle (0) to the end (1), V across, the particle's size is
37
+ * the width. Lightning, tethers, lasers.
38
+ */
39
+ Beam = 6,
33
40
  }
34
41
 
35
42
  /**
@@ -80,9 +87,12 @@ export interface MeshRim {
80
87
  strength: number;
81
88
  /**
82
89
  * `'add'` (default) adds the colour, for a glowing edge; `'mix'` blends the
83
- * surface toward it, for an outline (a dark ink line with a dark colour).
90
+ * surface toward it, for an outline (a dark ink line with a dark colour);
91
+ * `'fade'` leaves the colour and fades the faces turned to the camera by
92
+ * `strength` instead, alpha times `1 - strength * (1 - edge)`: a wall of
93
+ * light that reads thick along its silhouette and thin across its face.
84
94
  */
85
- mode: 'add' | 'mix';
95
+ mode: 'add' | 'mix' | 'fade';
86
96
  }
87
97
 
88
98
  /**
@@ -100,7 +110,7 @@ export function normalizeMeshRim(rim: Partial<MeshRim> | null | undefined): Mesh
100
110
  color: [channel(color[0], 1), channel(color[1], 1), channel(color[2], 1)],
101
111
  power: Math.max(0.01, channel(rim.power, 2)),
102
112
  strength: Math.max(0, channel(rim.strength, 1)),
103
- mode: rim.mode === 'mix' ? 'mix' : 'add',
113
+ mode: rim.mode === 'mix' || rim.mode === 'fade' ? rim.mode : 'add',
104
114
  };
105
115
  }
106
116
 
@@ -122,10 +132,304 @@ export function meshRimEqual(a: MeshRim | undefined, b: MeshRim | undefined): bo
122
132
  );
123
133
  }
124
134
 
135
+ /**
136
+ * Which texture coordinates a texture is sampled with, as Unity's UV channels
137
+ * and Niagara's ribbon UV0 / UV1:
138
+ *
139
+ * - `0`: the particle's first UV, with the sprite sheet's cell applied: on a
140
+ * sheet the current frame's cell, on a trail the cell's stretch along it, on
141
+ * a mesh its first UV set. Read before the map's own layout and distortion,
142
+ * so a texture on it does not move with them.
143
+ * - `1`: the particle's own UV, never moved by the sheet. The whole quad on a
144
+ * billboard, a stretched billboard or a beam (mirrored with the quad when it
145
+ * flips); on a trail U along it the way the map's runs (from the tail, or
146
+ * from the head with `uvFromHead`) and V across it; on a mesh its second UV
147
+ * set (`instancingUVs2`), or its first when it has none.
148
+ *
149
+ * A mesh can then repeat its picture along UV0 while a mask on UV1 runs once
150
+ * across its whole length, as a lightmap UV does in a game engine.
151
+ */
152
+ export type UVChannel = 0 | 1;
153
+
154
+ /**
155
+ * How a texture is laid over the particle: its UV channel, then tiling and
156
+ * offset as in Unity's material Tiling / Offset, then a scroll on the
157
+ * renderer's clock as Unreal's Panner. Sampled at
158
+ * `uv(channel) * tiling + offset + scroll * time`; the offset is used as
159
+ * given, so a clamped texture can be moved off its edge, and only the
160
+ * scroll's share is wrapped (over two repeats, so a mirrored texture stays
161
+ * seamless). A texture that scrolls or tiles should repeat.
162
+ */
163
+ export interface TextureUV {
164
+ /** See {@link UVChannel}. Default 0. */
165
+ channel: UVChannel;
166
+ /** Repeats across the channel's 0…1, `[u, v]`. Default `[1, 1]`. */
167
+ tiling: [number, number];
168
+ /** Added after tiling, in texture UV units. Default `[0, 0]`. */
169
+ offset: [number, number];
170
+ /** UV units per second on the renderer's clock. Default `[0, 0]`. */
171
+ scroll: [number, number];
172
+ }
173
+
174
+ /** A complete {@link TextureUV} from a partial one, with `defaults` under the fields left out. */
175
+ export function normalizeTextureUV(
176
+ uv: Partial<TextureUV> | null | undefined,
177
+ defaults: Partial<TextureUV> = {}
178
+ ): TextureUV {
179
+ const source = uv && typeof uv === 'object' ? uv : {};
180
+ const num = (value: unknown, fallback: number) =>
181
+ typeof value === 'number' && Number.isFinite(value) ? value : fallback;
182
+ const pair = (value: unknown, fallback: [number, number]): [number, number] =>
183
+ Array.isArray(value) ? [num(value[0], fallback[0]), num(value[1], fallback[1])] : [fallback[0], fallback[1]];
184
+ const channel = source.channel ?? defaults.channel;
185
+ return {
186
+ channel: channel === 1 ? 1 : 0,
187
+ tiling: pair(source.tiling, defaults.tiling ?? [1, 1]),
188
+ offset: pair(source.offset, defaults.offset ?? [0, 0]),
189
+ scroll: pair(source.scroll, defaults.scroll ?? [0, 0]),
190
+ };
191
+ }
192
+
193
+ /** True when both lay a texture the same way. */
194
+ export function textureUVEqual(a: TextureUV, b: TextureUV): boolean {
195
+ return (
196
+ a === b ||
197
+ (a.channel === b.channel &&
198
+ a.tiling[0] === b.tiling[0] &&
199
+ a.tiling[1] === b.tiling[1] &&
200
+ a.offset[0] === b.offset[0] &&
201
+ a.offset[1] === b.offset[1] &&
202
+ a.scroll[0] === b.scroll[0] &&
203
+ a.scroll[1] === b.scroll[1])
204
+ );
205
+ }
206
+
207
+ /** The fields of `uv` that differ from `defaults`, for JSON written only when set. */
208
+ export function textureUVToJSON(uv: TextureUV, defaults: Partial<TextureUV> = {}): Partial<TextureUV> | undefined {
209
+ const base = normalizeTextureUV(undefined, defaults);
210
+ const json: Partial<TextureUV> = {};
211
+ if (uv.channel !== base.channel) json.channel = uv.channel;
212
+ if (uv.tiling[0] !== base.tiling[0] || uv.tiling[1] !== base.tiling[1]) json.tiling = [...uv.tiling];
213
+ if (uv.offset[0] !== base.offset[0] || uv.offset[1] !== base.offset[1]) json.offset = [...uv.offset];
214
+ if (uv.scroll[0] !== base.scroll[0] || uv.scroll[1] !== base.scroll[1]) json.scroll = [...uv.scroll];
215
+ return Object.keys(json).length > 0 ? json : undefined;
216
+ }
217
+
218
+ /**
219
+ * Which value of the map's texel picks the colour along a gradient map's
220
+ * ramp: one channel, so a texture can pack several masks (Unreal's and
221
+ * Unity's channel-packed textures), or the Rec. 709 luminance of its colour.
222
+ */
223
+ export type GradientMapChannel = 'r' | 'g' | 'b' | 'a' | 'luminance';
224
+
225
+ /**
226
+ * A gradient map (colour ramp): the map is recoloured by looking its
227
+ * {@link channel} up along U of {@link ramp}. See
228
+ * {@link VFXBatchSettings.gradientMap}.
229
+ */
230
+ export interface ParticleGradientMap {
231
+ /**
232
+ * The ramp, read along U at mid V: low values on the left. Make it without
233
+ * mipmaps and clamped. Required: without one there is no gradient map.
234
+ */
235
+ ramp: BaseTexture | null;
236
+ /**
237
+ * The texel value the ramp is looked up by. Default `'r'`. With `'a'` the
238
+ * ramp's alpha becomes the texel's alpha, so a soft shape can glow at its
239
+ * fringe and stay solid at its heart; with any other channel the ramp's
240
+ * alpha multiplies the map's.
241
+ */
242
+ channel: GradientMapChannel;
243
+ }
244
+
245
+ /** The weights a texel is dotted with to look up the ramp, per {@link GradientMapChannel}. */
246
+ export const GRADIENT_MAP_WEIGHTS: Readonly<Record<GradientMapChannel, readonly [number, number, number, number]>> = {
247
+ r: [1, 0, 0, 0],
248
+ g: [0, 1, 0, 0],
249
+ b: [0, 0, 1, 0],
250
+ a: [0, 0, 0, 1],
251
+ luminance: [0.2126, 0.7152, 0.0722, 0],
252
+ };
253
+
254
+ /** A complete gradient map, or undefined when unset or without a ramp. */
255
+ export function normalizeGradientMap(
256
+ gradientMap: Partial<ParticleGradientMap> | null | undefined
257
+ ): ParticleGradientMap | undefined {
258
+ if (!gradientMap || typeof gradientMap !== 'object' || !gradientMap.ramp) {
259
+ return undefined;
260
+ }
261
+ const channel = gradientMap.channel;
262
+ return {
263
+ ramp: gradientMap.ramp,
264
+ channel: channel !== undefined && channel in GRADIENT_MAP_WEIGHTS ? channel : 'r',
265
+ };
266
+ }
267
+
268
+ /** True when both recolour the same way (or both not at all). */
269
+ export function gradientMapEqual(a: ParticleGradientMap | undefined, b: ParticleGradientMap | undefined): boolean {
270
+ return a === b || (!!a && !!b && a.ramp === b.ramp && a.channel === b.channel);
271
+ }
272
+
273
+ /**
274
+ * Alpha erosion: the particle's alpha decides how much of its shape is left
275
+ * instead of how transparent it is, so `ColorOverLife` fading alpha out
276
+ * dissolves (noise mask) or wipes (gradient mask) the shape away. See
277
+ * {@link VFXBatchSettings.erosion}.
278
+ */
279
+ export interface ParticleErosion {
280
+ /**
281
+ * Mask whose red channel orders what goes first, laid by {@link maskUV}.
282
+ * Null: the map's own alpha, so a soft shape shrinks from its edges.
283
+ */
284
+ mask: BaseTexture | null;
285
+ /**
286
+ * How the mask is laid over the particle, see {@link TextureUV}. On UV
287
+ * channel 1 a gradient mask wipes a whole trail or mesh however its map
288
+ * repeats. Default channel 0, untiled, still.
289
+ */
290
+ maskUV: TextureUV;
291
+ /** Width of the soft transition at the cut, in mask units. Default 0.05. */
292
+ softness: number;
293
+ /** Width of the glowing band above the cut, in mask units; 0 (default) draws none. */
294
+ edgeWidth: number;
295
+ /** Colour added in the edge band, linear RGB, may exceed 1. Default `[1, 1, 1]`. */
296
+ edgeColor: [number, number, number];
297
+ }
298
+ // Limits worth knowing: erosion only changes alpha (one-one and premultiplied
299
+ // blending show a hard cut), and with neither a mask nor a map there is
300
+ // nothing to order the cut by, so the shape goes all at once near alpha 0.
301
+ // Scrolling runs on the renderer's clock, not a system's playback speed.
302
+
303
+ /** {@link ParticleErosion} as given: any field may be left out, the mask's layout too. */
304
+ export type ParticleErosionOptions = Partial<Omit<ParticleErosion, 'maskUV'>> & {maskUV?: Partial<TextureUV>};
305
+
306
+ /** A complete erosion from a partial one, every field defaulted, or undefined when `erosion` is unset. */
307
+ export function normalizeErosion(erosion: ParticleErosionOptions | null | undefined): ParticleErosion | undefined {
308
+ if (!erosion || typeof erosion !== 'object') {
309
+ return undefined;
310
+ }
311
+ const num = (value: unknown, fallback: number) =>
312
+ typeof value === 'number' && Number.isFinite(value) ? value : fallback;
313
+ const color = Array.isArray(erosion.edgeColor) ? erosion.edgeColor : [1, 1, 1];
314
+ return {
315
+ mask: erosion.mask ?? null,
316
+ maskUV: normalizeTextureUV(erosion.maskUV),
317
+ softness: Math.max(0.0001, num(erosion.softness, 0.05)),
318
+ edgeWidth: Math.max(0, num(erosion.edgeWidth, 0)),
319
+ edgeColor: [num(color[0], 1), num(color[1], 1), num(color[2], 1)],
320
+ };
321
+ }
322
+
323
+ /** True when both describe the same erosion (or both none). */
324
+ export function erosionEqual(a: ParticleErosion | undefined, b: ParticleErosion | undefined): boolean {
325
+ if (a === b) {
326
+ return true;
327
+ }
328
+ if (!a || !b) {
329
+ return false;
330
+ }
331
+ return (
332
+ a.mask === b.mask &&
333
+ textureUVEqual(a.maskUV, b.maskUV) &&
334
+ a.softness === b.softness &&
335
+ a.edgeWidth === b.edgeWidth &&
336
+ a.edgeColor[0] === b.edgeColor[0] &&
337
+ a.edgeColor[1] === b.edgeColor[1] &&
338
+ a.edgeColor[2] === b.edgeColor[2]
339
+ );
340
+ }
341
+
342
+ /** The distortion noise's default layout: drifting up half a repeat a second. */
343
+ export const UV_DISTORTION_DEFAULT_UV: Partial<TextureUV> = {scroll: [0, 0.5]};
344
+
345
+ /**
346
+ * UV distortion: the map's UV pushed about by a noise texture, for heat
347
+ * shimmer, licking flames and breathing auras. See
348
+ * {@link VFXBatchSettings.uvDistortion}.
349
+ */
350
+ export interface ParticleUVDistortion {
351
+ /**
352
+ * Noise whose red and green channels, around 0.5, push U and V. It should
353
+ * wrap (repeat). Required: without one there is no distortion.
354
+ */
355
+ map: BaseTexture | null;
356
+ /**
357
+ * How the noise is laid over the particle, see {@link TextureUV}. Default
358
+ * channel 0, untiled, scrolling `[0, 0.5]`.
359
+ */
360
+ mapUV: TextureUV;
361
+ /** How far the map's UV is pushed at most, in UV units. Default 0.05. */
362
+ strength: number;
363
+ }
364
+
365
+ /** {@link ParticleUVDistortion} as given: any field may be left out, the noise's layout too. */
366
+ export type ParticleUVDistortionOptions = Partial<Omit<ParticleUVDistortion, 'mapUV'>> & {mapUV?: Partial<TextureUV>};
367
+
368
+ /** A complete distortion from a partial one, or undefined when unset or without a map. */
369
+ export function normalizeUVDistortion(
370
+ distortion: ParticleUVDistortionOptions | null | undefined
371
+ ): ParticleUVDistortion | undefined {
372
+ if (!distortion || typeof distortion !== 'object' || !distortion.map) {
373
+ return undefined;
374
+ }
375
+ const strength = distortion.strength;
376
+ return {
377
+ map: distortion.map,
378
+ mapUV: normalizeTextureUV(distortion.mapUV, UV_DISTORTION_DEFAULT_UV),
379
+ strength: Math.max(0, typeof strength === 'number' && Number.isFinite(strength) ? strength : 0.05),
380
+ };
381
+ }
382
+
383
+ /** True when both describe the same distortion (or both none). */
384
+ export function uvDistortionEqual(a: ParticleUVDistortion | undefined, b: ParticleUVDistortion | undefined): boolean {
385
+ if (a === b) {
386
+ return true;
387
+ }
388
+ if (!a || !b) {
389
+ return false;
390
+ }
391
+ return a.map === b.map && a.strength === b.strength && textureUVEqual(a.mapUV, b.mapUV);
392
+ }
393
+
394
+ /**
395
+ * The main map's layout, or undefined when it is the identity (channel 0,
396
+ * untiled, unmoved, still), which costs nothing in the shader.
397
+ */
398
+ export function normalizeMapUV(uv: Partial<TextureUV> | null | undefined): TextureUV | undefined {
399
+ if (!uv || typeof uv !== 'object') {
400
+ return undefined;
401
+ }
402
+ const normalized = normalizeTextureUV(uv);
403
+ return textureUVToJSON(normalized) === undefined ? undefined : normalized;
404
+ }
405
+
406
+ /** True when both lay the map the same way (or both as drawn). */
407
+ export function mapUVEqual(a: TextureUV | undefined, b: TextureUV | undefined): boolean {
408
+ return a === b || (!!a && !!b && textureUVEqual(a, b));
409
+ }
410
+
411
+ /**
412
+ * True when a texture effect of `settings` samples UV channel 1, see
413
+ * {@link UVChannel}: the map, the distortion noise or an erosion mask laid
414
+ * on it.
415
+ */
416
+ export function readsUV1(settings: {
417
+ mapUV?: TextureUV;
418
+ uvDistortion?: ParticleUVDistortion;
419
+ erosion?: ParticleErosion;
420
+ }): boolean {
421
+ const {mapUV, uvDistortion, erosion} = settings;
422
+ return (
423
+ mapUV?.channel === 1 || uvDistortion?.mapUV.channel === 1 || (!!erosion?.mask && erosion.maskUV.channel === 1)
424
+ );
425
+ }
426
+
125
427
  export interface StoredBatchSettings {
126
428
  instancingGeometry: Float32Array;
127
429
  instancingIndices: Uint32Array | Uint16Array;
128
430
  instancingUVs?: Float32Array;
431
+ /** The mesh's second UV set, see {@link UVChannel}. */
432
+ instancingUVs2?: Float32Array;
129
433
  instancingNormals?: Float32Array;
130
434
  renderMode: RenderMode;
131
435
  renderOrder: number;
@@ -162,6 +466,14 @@ export interface StoredBatchSettings {
162
466
  lighting?: MeshLighting;
163
467
  /** Mesh particle rim, see {@link VFXBatchSettings.rim}. */
164
468
  rim?: MeshRim;
469
+ /** The main map's layout, see {@link VFXBatchSettings.mapUV}. */
470
+ mapUV?: TextureUV;
471
+ /** Map UV distortion, see {@link VFXBatchSettings.uvDistortion}. */
472
+ uvDistortion?: ParticleUVDistortion;
473
+ /** Colour ramp for the map, see {@link VFXBatchSettings.gradientMap}. */
474
+ gradientMap?: ParticleGradientMap;
475
+ /** Alpha erosion, see {@link VFXBatchSettings.erosion}. */
476
+ erosion?: ParticleErosion;
165
477
  }
166
478
 
167
479
  export abstract class VFXBatch {
@@ -180,6 +492,7 @@ export abstract class VFXBatch {
180
492
  instancingGeometry: settings.instancingGeometry,
181
493
  instancingIndices: settings.instancingIndices,
182
494
  instancingUVs: settings.instancingUVs,
495
+ instancingUVs2: settings.instancingUVs2,
183
496
  instancingNormals: settings.instancingNormals,
184
497
  renderMode: settings.renderMode,
185
498
  renderOrder: settings.renderOrder,
@@ -208,6 +521,10 @@ export abstract class VFXBatch {
208
521
  applyFog: settings.applyFog ?? true,
209
522
  lighting: settings.lighting,
210
523
  rim: normalizeMeshRim(settings.rim),
524
+ mapUV: normalizeMapUV(settings.mapUV),
525
+ uvDistortion: normalizeUVDistortion(settings.uvDistortion),
526
+ gradientMap: normalizeGradientMap(settings.gradientMap),
527
+ erosion: normalizeErosion(settings.erosion),
211
528
  };
212
529
  this.mesh = new Mesh('vfxBatch', scene);
213
530
  this.configureMesh();
@@ -331,6 +648,150 @@ export abstract class VFXBatch {
331
648
  });
332
649
  }
333
650
 
651
+ /**
652
+ * Seconds of renderer time, set by the renderer before each fill; what UV
653
+ * and erosion mask scrolling advance by.
654
+ */
655
+ time = 0;
656
+
657
+ /** True when a texture effect of this batch samples UV channel 1, see {@link UVChannel}. */
658
+ protected usesUV1(): boolean {
659
+ return readsUV1(this.settings);
660
+ }
661
+
662
+ /** Defines of the texture effects (see `shaders/chunks/surface.ts`) this batch uses. */
663
+ protected surfaceDefines(): string[] {
664
+ const defines: string[] = [];
665
+ const {mapUV, uvDistortion, gradientMap, erosion} = this.settings;
666
+ if (this.usesUV1()) {
667
+ defines.push('UV1');
668
+ }
669
+ if (mapUV) {
670
+ defines.push('MAP_TRANSFORM');
671
+ if (mapUV.channel === 1) {
672
+ defines.push('MAP_UV1');
673
+ }
674
+ }
675
+ if (uvDistortion) {
676
+ defines.push('UV_DISTORTION');
677
+ if (uvDistortion.mapUV.channel === 1) {
678
+ defines.push('UV_DISTORTION_UV1');
679
+ }
680
+ }
681
+ if (gradientMap) {
682
+ defines.push('GRADIENT_MAP');
683
+ }
684
+ if (erosion) {
685
+ defines.push('EROSION');
686
+ if (erosion.mask) {
687
+ defines.push('EROSION_MASK');
688
+ if (erosion.maskUV.channel === 1) {
689
+ defines.push('EROSION_MASK_UV1');
690
+ }
691
+ }
692
+ }
693
+ return defines;
694
+ }
695
+
696
+ /** Adds the uniforms and samplers of the texture effects this batch uses. */
697
+ protected surfaceUniforms(uniforms: string[], samplers: string[]): void {
698
+ const {mapUV, uvDistortion, gradientMap, erosion} = this.settings;
699
+ if (mapUV) {
700
+ uniforms.push('mapTransform');
701
+ }
702
+ if (uvDistortion) {
703
+ samplers.push('distortionMap');
704
+ uniforms.push('distortionTransform', 'distortionStrength');
705
+ }
706
+ if (gradientMap) {
707
+ samplers.push('gradientMap');
708
+ uniforms.push('gradientMapRange', 'gradientMapChannel');
709
+ }
710
+ if (erosion) {
711
+ uniforms.push('erosionParams', 'erosionEdgeColor');
712
+ if (erosion.mask) {
713
+ samplers.push('erosionMask');
714
+ uniforms.push('erosionMaskTransform');
715
+ }
716
+ }
717
+ }
718
+
719
+ /** Sets the texture effects' textures and constants, and advances their scrolling on every bind. */
720
+ protected bindSurface(material: ShaderMaterial): void {
721
+ const {mapUV, uvDistortion, gradientMap, erosion} = this.settings;
722
+ if (uvDistortion?.map) {
723
+ material.setTexture('distortionMap', uvDistortion.map);
724
+ material.setFloat('distortionStrength', uvDistortion.strength);
725
+ }
726
+ if (gradientMap?.ramp) {
727
+ material.setTexture('gradientMap', gradientMap.ramp);
728
+ const [r, g, b, a] = GRADIENT_MAP_WEIGHTS[gradientMap.channel];
729
+ material.setVector4('gradientMapChannel', new BVector4(r, g, b, a));
730
+ }
731
+ if (erosion) {
732
+ if (erosion.mask) {
733
+ material.setTexture('erosionMask', erosion.mask);
734
+ }
735
+ material.setVector2('erosionParams', new BVector2(erosion.softness, erosion.edgeWidth));
736
+ material.setVector3(
737
+ 'erosionEdgeColor',
738
+ new BVector3(erosion.edgeColor[0], erosion.edgeColor[1], erosion.edgeColor[2])
739
+ );
740
+ }
741
+ if (!mapUV && !uvDistortion && !erosion?.mask && !gradientMap) {
742
+ return;
743
+ }
744
+ // Wrapped on the CPU: a raw speed × time grows until float32 UVs lose their fraction.
745
+ // Over two repeats, not one: a mirrored texture repeats every two, and
746
+ // wrapped over one it jumped to its mirror image at every wrap.
747
+ const wrap = (value: number) => value - 2 * Math.floor(value / 2);
748
+ const mapTransform = new BVector4();
749
+ const distortionTransform = new BVector4();
750
+ const maskTransform = new BVector4();
751
+ const gradientRange = new BVector2(0, 1);
752
+ // Only the scroll is wrapped: an offset is kept as given, so a clamped
753
+ // texture moved off its edge stays moved.
754
+ const transform = (uv: TextureUV, time: number, out: BVector4) =>
755
+ out.set(
756
+ uv.tiling[0],
757
+ uv.tiling[1],
758
+ uv.offset[0] + wrap(uv.scroll[0] * time),
759
+ uv.offset[1] + wrap(uv.scroll[1] * time)
760
+ );
761
+ // Written to the material for the next bind and to the bound effect for
762
+ // this one: ShaderMaterial uploads its values before the bind observers run.
763
+ const write = () => {
764
+ const time = this.time;
765
+ const effect = material.getEffect();
766
+ if (mapUV) {
767
+ transform(mapUV, time, mapTransform);
768
+ material.setVector4('mapTransform', mapTransform);
769
+ effect?.setVector4('mapTransform', mapTransform);
770
+ }
771
+ if (uvDistortion) {
772
+ transform(uvDistortion.mapUV, time, distortionTransform);
773
+ material.setVector4('distortionTransform', distortionTransform);
774
+ effect?.setVector4('distortionTransform', distortionTransform);
775
+ }
776
+ if (erosion?.mask) {
777
+ transform(erosion.maskUV, time, maskTransform);
778
+ material.setVector4('erosionMaskTransform', maskTransform);
779
+ effect?.setVector4('erosionMaskTransform', maskTransform);
780
+ }
781
+ if (gradientMap?.ramp) {
782
+ // Texel centres of the first and last texel, so a repeating
783
+ // ramp does not blend its two ends at black and white.
784
+ const width = gradientMap.ramp.getSize().width;
785
+ const half = width > 0 ? 0.5 / width : 0;
786
+ gradientRange.set(half, 1 - half);
787
+ material.setVector2('gradientMapRange', gradientRange);
788
+ effect?.setVector2('gradientMapRange', gradientRange);
789
+ }
790
+ };
791
+ write();
792
+ material.onBindObservable.add(write);
793
+ }
794
+
334
795
  addSystem(system: IParticleSystem) {
335
796
  this.systems.add(system);
336
797
  }
package/src/index.ts CHANGED
@@ -21,11 +21,13 @@ export {ParticleEmitter} from './ParticleEmitter';
21
21
  export {ParticleSystem} from './ParticleSystem';
22
22
  export type {
23
23
  BabylonMetaData,
24
+ BeamEnd,
24
25
  BurstParameters,
25
26
  BurstParametersJSON,
26
27
  CullingMode,
27
28
  ParticleSystemJSONParameters,
28
29
  ParticleSystemParameters,
30
+ ScalingMode,
29
31
  } from './ParticleSystem';
30
32
  export {QuarksLoader} from './QuarksLoader';
31
33
  export type {
@@ -41,8 +43,42 @@ export {QuarksUtil} from './QuarksUtil';
41
43
  export * from './shaders/';
42
44
  export {SpriteBatch} from './SpriteBatch';
43
45
  export {TrailBatch} from './TrailBatch';
44
- export {defaultMeshLighting, meshRimEqual, normalizeMeshRim, RenderMode, VFXBatch} from './VFXBatch';
45
- export type {MeshLighting, MeshLightingSettings, MeshRim, SortMode, StoredBatchSettings} from './VFXBatch';
46
+ export {
47
+ defaultMeshLighting,
48
+ erosionEqual,
49
+ GRADIENT_MAP_WEIGHTS,
50
+ gradientMapEqual,
51
+ mapUVEqual,
52
+ meshRimEqual,
53
+ normalizeErosion,
54
+ normalizeGradientMap,
55
+ normalizeMapUV,
56
+ normalizeMeshRim,
57
+ normalizeTextureUV,
58
+ normalizeUVDistortion,
59
+ readsUV1,
60
+ RenderMode,
61
+ textureUVEqual,
62
+ textureUVToJSON,
63
+ UV_DISTORTION_DEFAULT_UV,
64
+ uvDistortionEqual,
65
+ VFXBatch,
66
+ } from './VFXBatch';
67
+ export type {
68
+ GradientMapChannel,
69
+ MeshLighting,
70
+ MeshLightingSettings,
71
+ MeshRim,
72
+ ParticleErosion,
73
+ ParticleErosionOptions,
74
+ ParticleGradientMap,
75
+ ParticleUVDistortion,
76
+ ParticleUVDistortionOptions,
77
+ SortMode,
78
+ StoredBatchSettings,
79
+ TextureUV,
80
+ UVChannel,
81
+ } from './VFXBatch';
46
82
 
47
83
  // Re-export quarks.core types (interfaces - type-only)
48
84
  export type {