babylon.quarks 0.27.0 → 0.29.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 (55) hide show
  1. package/CHANGELOG.md +585 -0
  2. package/README.md +3 -1
  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 +4811 -974
  10. package/dist/types/index.d.ts +4811 -974
  11. package/package.json +7 -2
  12. package/src/BatchedRenderer.ts +106 -79
  13. package/src/EffectPool.ts +96 -16
  14. package/src/MeshSurfaceEmitter.ts +24 -13
  15. package/src/ParticleEmitter.ts +119 -26
  16. package/src/ParticleSystem.ts +305 -55
  17. package/src/QuarksLoader.ts +298 -171
  18. package/src/QuarksPrefab.ts +301 -94
  19. package/src/QuarksSerializer.ts +44 -16
  20. package/src/QuarksUtil.ts +88 -11
  21. package/src/SpriteBatch.ts +24 -6
  22. package/src/TrailBatch.ts +20 -7
  23. package/src/VFXBatch.ts +182 -528
  24. package/src/batchKey.ts +6 -2
  25. package/src/batchSettings.ts +548 -0
  26. package/src/effectDocumentJSON.ts +261 -0
  27. package/src/effectResources.ts +21 -24
  28. package/src/envAtlas.ts +8 -2
  29. package/src/geometryUtil.ts +1 -1
  30. package/src/index.ts +41 -4
  31. package/src/nodeId.ts +25 -2
  32. package/src/shaderProgram.ts +213 -16
  33. package/src/shaderVariants.ts +124 -7
  34. package/src/shaders/chunks/blend.ts +39 -0
  35. package/src/shaders/chunks/fog.ts +8 -3
  36. package/src/shaders/chunks/imageProcessing.ts +143 -0
  37. package/src/shaders/chunks/soft.ts +6 -5
  38. package/src/shaders/chunks/surface.ts +2 -2
  39. package/src/shaders/particle_frag.glsl.ts +5 -1
  40. package/src/shaders/particle_frag.wgsl.ts +4 -0
  41. package/src/shaders/particle_physics_frag.glsl.ts +7 -2
  42. package/src/shaders/particle_physics_frag.wgsl.ts +4 -0
  43. package/src/shaders/trail_frag.glsl.ts +6 -1
  44. package/src/shaders/trail_frag.wgsl.ts +4 -0
  45. package/src/system/EmissionController.ts +23 -20
  46. package/src/system/EventDispatcher.ts +26 -10
  47. package/src/system/ParticleSpawner.ts +96 -19
  48. package/src/system/geometry.ts +22 -29
  49. package/src/system/integrate.ts +61 -4
  50. package/src/system/materialResolve.ts +56 -7
  51. package/src/system/normalize.ts +34 -2
  52. package/src/system/serialization.ts +54 -33
  53. package/src/system/settingsSchema.ts +57 -17
  54. package/src/system/types.ts +150 -42
  55. package/src/trailSettings.ts +9 -6
package/src/VFXBatch.ts CHANGED
@@ -1,538 +1,22 @@
1
1
  import {BoundingInfo} from '@babylonjs/core/Culling/boundingInfo';
2
2
  import {BaseTexture} from '@babylonjs/core/Materials/Textures/baseTexture';
3
3
  import type {Effect} from '@babylonjs/core/Materials/effect';
4
+ import type {ImageProcessingConfiguration} from '@babylonjs/core/Materials/imageProcessingConfiguration';
4
5
  import {ShaderMaterial} from '@babylonjs/core/Materials/shaderMaterial';
5
- import {Color3} from '@babylonjs/core/Maths/math.color';
6
- import {Vector2 as BVector2, Vector3 as BVector3, Vector4 as BVector4} from '@babylonjs/core/Maths/math.vector';
6
+ import {Vector2 as BVector2, Vector3 as BVector3, Vector4 as BVector4, Matrix} from '@babylonjs/core/Maths/math.vector';
7
7
  import {Mesh} from '@babylonjs/core/Meshes/mesh';
8
+ import type {Observer} from '@babylonjs/core/Misc/observable';
9
+ import type {Node} from '@babylonjs/core/node';
8
10
  import {Scene} from '@babylonjs/core/scene';
9
11
  import {IParticleSystem, Vector3} from 'quarks.core';
10
12
  import {VFXBatchSettings} from './BatchedRenderer';
11
13
  import type {ParticleSystem} from './ParticleSystem';
12
14
  import {storedBatchSettings} from './batchKey';
15
+ import {GRADIENT_MAP_WEIGHTS, MeshLightingSettings, readsUV1, StoredBatchSettings, TextureUV} from './batchSettings';
16
+ import {imageProcessingParts, ShaderImageProcessing} from './shaderProgram';
13
17
  import {DepthBinding, softDepthParams} from './softDepth';
14
18
 
15
- /**
16
- * The order the particles of a batch are drawn in (Unity's Sort Mode). Alpha
17
- * blended particles cover what was drawn before them, so the order shows.
18
- *
19
- * - `'none'`: as they are, system by system. Right for additive blending.
20
- * - `'distance'`: farthest from the active camera first, so nearer particles
21
- * cover farther ones.
22
- * - `'oldestInFront'` / `'youngestInFront'`: by age.
23
- *
24
- * Sorting runs over the whole batch after each fill. Trails are not sorted.
25
- */
26
- export type SortMode = 'none' | 'distance' | 'oldestInFront' | 'youngestInFront';
27
-
28
- /** How a system draws its particles (Unity's Render Mode), see {@link ParticleSystem.renderMode}. */
29
- export enum RenderMode {
30
- /** A quad facing the camera (Unity's Billboard). The default. */
31
- BillBoard = 0,
32
- /**
33
- * A quad facing the camera and stretched along the particle's velocity (Unity's Stretched
34
- * Billboard), by `speedFactor` and `lengthFactor` in the system's `rendererEmitterSettings`.
35
- */
36
- StretchedBillBoard = 1,
37
- /** The system's mesh, one instance per particle (Unity's Mesh), see {@link ParticleSystem.instancingGeometry}. */
38
- Mesh = 2,
39
- /**
40
- * A ribbon drawn through the particle's past positions (Unity's Trails module, Niagara's
41
- * ribbon), facing the camera; its settings are `TrailSettings` in `rendererEmitterSettings`.
42
- */
43
- Trail = 3,
44
- /** A quad lying flat in the world's XZ plane, facing up (Unity's Horizontal Billboard). */
45
- HorizontalBillBoard = 4,
46
- /** A quad standing upright and turning only about the world's Y axis to face the camera (Unity's Vertical Billboard). */
47
- VerticalBillBoard = 5,
48
- /**
49
- * A quad laid from each particle to the system's {@link ParticleSystem.beamEnd}
50
- * and turned about its length to face the camera (a line renderer): U runs
51
- * from the particle (0) to the end (1), V across, the particle's size is
52
- * the width. Lightning, tethers, lasers.
53
- */
54
- Beam = 6,
55
- }
56
-
57
- /**
58
- * How mesh particles are lit.
59
- *
60
- * - `'fixed'` (default): one directional light plus ambient, the same for every
61
- * mesh batch of a renderer, set with {@link BatchedRenderer.setMeshLighting}.
62
- * - `'unlit'`: no lighting term — the texture times the particle colour, for
63
- * emissive meshes (fire, energy, glowing blades).
64
- * - `'scene'`: follows the scene's first enabled `DirectionalLight` or
65
- * `HemisphericLight` every frame, falling back to the fixed light when there
66
- * is none.
67
- *
68
- * Only mesh particles are lit; other render modes ignore it.
69
- */
70
- export type MeshLighting = 'unlit' | 'fixed' | 'scene';
71
-
72
- /** The light {@link MeshLighting} `'fixed'` mesh particles are lit by. */
73
- export interface MeshLightingSettings {
74
- /** Direction the light travels in, world space. Default `(0.4, -1, 0.6)`. */
75
- direction: BVector3;
76
- /** Light colour, intensity folded in. Default white. */
77
- color: Color3;
78
- /** Light every face gets regardless of its direction. Default `(0.35, 0.35, 0.35)`. */
79
- ambient: Color3;
80
- }
81
-
82
- /** The light mesh particles have always been lit by. */
83
- export function defaultMeshLighting(): MeshLightingSettings {
84
- return {
85
- direction: new BVector3(0.4, -1, 0.6),
86
- color: new Color3(1, 1, 1),
87
- ambient: new Color3(0.35, 0.35, 0.35),
88
- };
89
- }
90
-
91
- /**
92
- * A rim (Fresnel) term on mesh particles: a colour that grows where the surface
93
- * turns away from the camera, `pow(1 - |N·V|, power) * strength`. Unset: no rim.
94
- * Only mesh particles draw it.
95
- */
96
- export interface MeshRim {
97
- /** Edge colour, linear RGB. Default `[1, 1, 1]`. */
98
- color: [number, number, number];
99
- /** Falloff exponent; higher keeps the rim to a thinner edge. Default 2. */
100
- power: number;
101
- /** How much of the rim is applied, usually 0…1. Default 1. */
102
- strength: number;
103
- /**
104
- * `'add'` (default) adds the colour, for a glowing edge; `'mix'` blends the
105
- * surface toward it, for an outline (a dark ink line with a dark colour);
106
- * `'fade'` leaves the colour and fades the faces turned to the camera by
107
- * `strength` instead, alpha times `1 - strength * (1 - edge)`: a wall of
108
- * light that reads thick along its silhouette and thin across its face.
109
- */
110
- mode: 'add' | 'mix' | 'fade';
111
- }
112
-
113
- /**
114
- * A complete rim from a partial one, every field defaulted and kept in range,
115
- * or undefined when `rim` is unset.
116
- */
117
- export function normalizeMeshRim(rim: Partial<MeshRim> | null | undefined): MeshRim | undefined {
118
- if (!rim || typeof rim !== 'object') {
119
- return undefined;
120
- }
121
- const color = Array.isArray(rim.color) ? rim.color : [1, 1, 1];
122
- const channel = (value: unknown, fallback: number) =>
123
- typeof value === 'number' && Number.isFinite(value) ? value : fallback;
124
- return {
125
- color: [channel(color[0], 1), channel(color[1], 1), channel(color[2], 1)],
126
- power: Math.max(0.01, channel(rim.power, 2)),
127
- strength: Math.max(0, channel(rim.strength, 1)),
128
- mode: rim.mode === 'mix' || rim.mode === 'fade' ? rim.mode : 'add',
129
- };
130
- }
131
-
132
- /** True when both describe the same rim (or both none). */
133
- export function meshRimEqual(a: MeshRim | undefined, b: MeshRim | undefined): boolean {
134
- if (a === b) {
135
- return true;
136
- }
137
- if (!a || !b) {
138
- return false;
139
- }
140
- return (
141
- a.mode === b.mode &&
142
- a.power === b.power &&
143
- a.strength === b.strength &&
144
- a.color[0] === b.color[0] &&
145
- a.color[1] === b.color[1] &&
146
- a.color[2] === b.color[2]
147
- );
148
- }
149
-
150
- /**
151
- * Which texture coordinates a texture is sampled with, as Unity's UV channels
152
- * and Niagara's ribbon UV0 / UV1:
153
- *
154
- * - `0`: the particle's first UV, with the sprite sheet's cell applied: on a
155
- * sheet the current frame's cell, on a trail the cell's stretch along it, on
156
- * a mesh its first UV set. Read before the map's own layout and distortion,
157
- * so a texture on it does not move with them.
158
- * - `1`: the particle's own UV, never moved by the sheet. The whole quad on a
159
- * billboard, a stretched billboard or a beam (mirrored with the quad when it
160
- * flips); on a trail U along it the way the map's runs (from the tail, or
161
- * from the head with `uvFromHead`) and V across it; on a mesh its second UV
162
- * set (`instancingUVs2`), or its first when it has none.
163
- *
164
- * A mesh can then repeat its picture along UV0 while a mask on UV1 runs once
165
- * across its whole length, as a lightmap UV does in a game engine.
166
- */
167
- export type UVChannel = 0 | 1;
168
-
169
- /**
170
- * How a texture is laid over the particle: its UV channel, then tiling and
171
- * offset as in Unity's material Tiling / Offset, then a scroll on the
172
- * renderer's clock as Unreal's Panner. Sampled at
173
- * `uv(channel) * tiling + offset + scroll * time`; the offset is used as
174
- * given, so a clamped texture can be moved off its edge, and only the
175
- * scroll's share is wrapped (over two repeats, so a mirrored texture stays
176
- * seamless). A texture that scrolls or tiles should repeat.
177
- */
178
- export interface TextureUV {
179
- /** See {@link UVChannel}. Default 0. */
180
- channel: UVChannel;
181
- /** Repeats across the channel's 0…1, `[u, v]`. Default `[1, 1]`. */
182
- tiling: [number, number];
183
- /** Added after tiling, in texture UV units. Default `[0, 0]`. */
184
- offset: [number, number];
185
- /** UV units per second on the renderer's clock. Default `[0, 0]`. */
186
- scroll: [number, number];
187
- }
188
-
189
- /** A complete {@link TextureUV} from a partial one, with `defaults` under the fields left out. */
190
- export function normalizeTextureUV(
191
- uv: Partial<TextureUV> | null | undefined,
192
- defaults: Partial<TextureUV> = {}
193
- ): TextureUV {
194
- const source = uv && typeof uv === 'object' ? uv : {};
195
- const num = (value: unknown, fallback: number) =>
196
- typeof value === 'number' && Number.isFinite(value) ? value : fallback;
197
- const pair = (value: unknown, fallback: [number, number]): [number, number] =>
198
- Array.isArray(value) ? [num(value[0], fallback[0]), num(value[1], fallback[1])] : [fallback[0], fallback[1]];
199
- const channel = source.channel ?? defaults.channel;
200
- return {
201
- channel: channel === 1 ? 1 : 0,
202
- tiling: pair(source.tiling, defaults.tiling ?? [1, 1]),
203
- offset: pair(source.offset, defaults.offset ?? [0, 0]),
204
- scroll: pair(source.scroll, defaults.scroll ?? [0, 0]),
205
- };
206
- }
207
-
208
- /** True when both lay a texture the same way. */
209
- export function textureUVEqual(a: TextureUV, b: TextureUV): boolean {
210
- return (
211
- a === b ||
212
- (a.channel === b.channel &&
213
- a.tiling[0] === b.tiling[0] &&
214
- a.tiling[1] === b.tiling[1] &&
215
- a.offset[0] === b.offset[0] &&
216
- a.offset[1] === b.offset[1] &&
217
- a.scroll[0] === b.scroll[0] &&
218
- a.scroll[1] === b.scroll[1])
219
- );
220
- }
221
-
222
- /** The fields of `uv` that differ from `defaults`, for JSON written only when set. */
223
- export function textureUVToJSON(uv: TextureUV, defaults: Partial<TextureUV> = {}): Partial<TextureUV> | undefined {
224
- const base = normalizeTextureUV(undefined, defaults);
225
- const json: Partial<TextureUV> = {};
226
- if (uv.channel !== base.channel) json.channel = uv.channel;
227
- if (uv.tiling[0] !== base.tiling[0] || uv.tiling[1] !== base.tiling[1]) json.tiling = [...uv.tiling];
228
- if (uv.offset[0] !== base.offset[0] || uv.offset[1] !== base.offset[1]) json.offset = [...uv.offset];
229
- if (uv.scroll[0] !== base.scroll[0] || uv.scroll[1] !== base.scroll[1]) json.scroll = [...uv.scroll];
230
- return Object.keys(json).length > 0 ? json : undefined;
231
- }
232
-
233
- /**
234
- * Which value of the map's texel picks the colour along a gradient map's
235
- * ramp: one channel, so a texture can pack several masks (Unreal's and
236
- * Unity's channel-packed textures), or the Rec. 709 luminance of its colour.
237
- */
238
- export type GradientMapChannel = 'r' | 'g' | 'b' | 'a' | 'luminance';
239
-
240
- /**
241
- * A gradient map (colour ramp): the map is recoloured by looking its
242
- * {@link channel} up along U of {@link ramp}. Without a map the lookup reads
243
- * white, so the ramp's right-hand end tints every particle, in every render
244
- * mode alike. See {@link VFXBatchSettings.gradientMap}.
245
- */
246
- export interface ParticleGradientMap {
247
- /**
248
- * The ramp, read along U at mid V: low values on the left. Make it without
249
- * mipmaps and clamped. Required: without one there is no gradient map.
250
- */
251
- ramp: BaseTexture | null;
252
- /**
253
- * The texel value the ramp is looked up by. Default `'r'`. With `'a'` the
254
- * ramp's alpha becomes the texel's alpha, so a soft shape can glow at its
255
- * fringe and stay solid at its heart; with any other channel the ramp's
256
- * alpha multiplies the map's.
257
- */
258
- channel: GradientMapChannel;
259
- }
260
-
261
- /** The weights a texel is dotted with to look up the ramp, per {@link GradientMapChannel}. */
262
- export const GRADIENT_MAP_WEIGHTS: Readonly<Record<GradientMapChannel, readonly [number, number, number, number]>> = {
263
- r: [1, 0, 0, 0],
264
- g: [0, 1, 0, 0],
265
- b: [0, 0, 1, 0],
266
- a: [0, 0, 0, 1],
267
- luminance: [0.2126, 0.7152, 0.0722, 0],
268
- };
269
-
270
- /** A complete gradient map, or undefined when unset or without a ramp. */
271
- export function normalizeGradientMap(
272
- gradientMap: Partial<ParticleGradientMap> | null | undefined
273
- ): ParticleGradientMap | undefined {
274
- if (!gradientMap || typeof gradientMap !== 'object' || !gradientMap.ramp) {
275
- return undefined;
276
- }
277
- const channel = gradientMap.channel;
278
- return {
279
- ramp: gradientMap.ramp,
280
- channel: channel !== undefined && channel in GRADIENT_MAP_WEIGHTS ? channel : 'r',
281
- };
282
- }
283
-
284
- /** True when both recolour the same way (or both not at all). */
285
- export function gradientMapEqual(a: ParticleGradientMap | undefined, b: ParticleGradientMap | undefined): boolean {
286
- return a === b || (!!a && !!b && a.ramp === b.ramp && a.channel === b.channel);
287
- }
288
-
289
- /**
290
- * Alpha erosion: the particle's alpha decides how much of its shape is left
291
- * instead of how transparent it is, so `ColorOverLife` fading alpha out
292
- * dissolves (noise mask) or wipes (gradient mask) the shape away. See
293
- * {@link VFXBatchSettings.erosion}.
294
- */
295
- export interface ParticleErosion {
296
- /**
297
- * Mask whose red channel orders what goes first, laid by {@link maskUV}.
298
- * Null: the map's own alpha, so a soft shape shrinks from its edges.
299
- */
300
- mask: BaseTexture | null;
301
- /**
302
- * How the mask is laid over the particle, see {@link TextureUV}. On UV
303
- * channel 1 a gradient mask wipes a whole trail or mesh however its map
304
- * repeats. Default channel 0, untiled, still.
305
- */
306
- maskUV: TextureUV;
307
- /** Width of the soft transition at the cut, in mask units. Default 0.05. */
308
- softness: number;
309
- /** Width of the glowing band above the cut, in mask units; 0 (default) draws none. */
310
- edgeWidth: number;
311
- /** Colour added in the edge band, linear RGB, may exceed 1. Default `[1, 1, 1]`. */
312
- edgeColor: [number, number, number];
313
- }
314
- // Limits worth knowing: erosion only changes alpha (one-one and premultiplied
315
- // blending show a hard cut), and with neither a mask nor a map there is
316
- // nothing to order the cut by, so the shape goes all at once near alpha 0.
317
- // Scrolling runs on the renderer's clock, not a system's playback speed.
318
-
319
- /** {@link ParticleErosion} as given: any field may be left out, the mask's layout too. */
320
- export type ParticleErosionOptions = Partial<Omit<ParticleErosion, 'maskUV'>> & {
321
- /** See {@link ParticleErosion.maskUV}; fields left out take their defaults. */
322
- maskUV?: Partial<TextureUV>;
323
- };
324
-
325
- /** The width of the soft band an erosion cut fades over when `softness` is left out, in mask units. */
326
- export const EROSION_DEFAULT_SOFTNESS = 0.05;
327
-
328
- /** A complete erosion from a partial one, every field defaulted, or undefined when `erosion` is unset. */
329
- export function normalizeErosion(erosion: ParticleErosionOptions | null | undefined): ParticleErosion | undefined {
330
- if (!erosion || typeof erosion !== 'object') {
331
- return undefined;
332
- }
333
- const num = (value: unknown, fallback: number) =>
334
- typeof value === 'number' && Number.isFinite(value) ? value : fallback;
335
- const color = Array.isArray(erosion.edgeColor) ? erosion.edgeColor : [1, 1, 1];
336
- return {
337
- mask: erosion.mask ?? null,
338
- maskUV: normalizeTextureUV(erosion.maskUV),
339
- softness: Math.max(0.0001, num(erosion.softness, EROSION_DEFAULT_SOFTNESS)),
340
- edgeWidth: Math.max(0, num(erosion.edgeWidth, 0)),
341
- edgeColor: [num(color[0], 1), num(color[1], 1), num(color[2], 1)],
342
- };
343
- }
344
-
345
- /** True when both describe the same erosion (or both none). */
346
- export function erosionEqual(a: ParticleErosion | undefined, b: ParticleErosion | undefined): boolean {
347
- if (a === b) {
348
- return true;
349
- }
350
- if (!a || !b) {
351
- return false;
352
- }
353
- return (
354
- a.mask === b.mask &&
355
- textureUVEqual(a.maskUV, b.maskUV) &&
356
- a.softness === b.softness &&
357
- a.edgeWidth === b.edgeWidth &&
358
- a.edgeColor[0] === b.edgeColor[0] &&
359
- a.edgeColor[1] === b.edgeColor[1] &&
360
- a.edgeColor[2] === b.edgeColor[2]
361
- );
362
- }
363
-
364
- /** The distortion noise's default layout: drifting up half a repeat a second. */
365
- export const UV_DISTORTION_DEFAULT_UV: Partial<TextureUV> = {scroll: [0, 0.5]};
366
-
367
- /**
368
- * UV distortion: the map's UV pushed about by a noise texture, for heat
369
- * shimmer, licking flames and breathing auras. See
370
- * {@link VFXBatchSettings.uvDistortion}.
371
- */
372
- export interface ParticleUVDistortion {
373
- /**
374
- * Noise whose red and green channels, around 0.5, push U and V. It should
375
- * wrap (repeat). Required: without one there is no distortion.
376
- */
377
- map: BaseTexture | null;
378
- /**
379
- * How the noise is laid over the particle, see {@link TextureUV}. Default
380
- * channel 0, untiled, scrolling `[0, 0.5]`.
381
- */
382
- mapUV: TextureUV;
383
- /** How far the map's UV is pushed at most, in UV units. Default 0.05. */
384
- strength: number;
385
- }
386
-
387
- /** {@link ParticleUVDistortion} as given: any field may be left out, the noise's layout too. */
388
- export type ParticleUVDistortionOptions = Partial<Omit<ParticleUVDistortion, 'mapUV'>> & {
389
- /** See {@link ParticleUVDistortion.mapUV}; fields left out take their defaults. */
390
- mapUV?: Partial<TextureUV>;
391
- };
392
-
393
- /** How far a distortion pushes the map's UV at most when `strength` is left out, in UV units. */
394
- export const UV_DISTORTION_DEFAULT_STRENGTH = 0.05;
395
-
396
- /** A complete distortion from a partial one, or undefined when unset or without a map. */
397
- export function normalizeUVDistortion(
398
- distortion: ParticleUVDistortionOptions | null | undefined
399
- ): ParticleUVDistortion | undefined {
400
- if (!distortion || typeof distortion !== 'object' || !distortion.map) {
401
- return undefined;
402
- }
403
- const strength = distortion.strength;
404
- return {
405
- map: distortion.map,
406
- mapUV: normalizeTextureUV(distortion.mapUV, UV_DISTORTION_DEFAULT_UV),
407
- strength: Math.max(
408
- 0,
409
- typeof strength === 'number' && Number.isFinite(strength) ? strength : UV_DISTORTION_DEFAULT_STRENGTH
410
- ),
411
- };
412
- }
413
-
414
- /** True when both describe the same distortion (or both none). */
415
- export function uvDistortionEqual(a: ParticleUVDistortion | undefined, b: ParticleUVDistortion | undefined): boolean {
416
- if (a === b) {
417
- return true;
418
- }
419
- if (!a || !b) {
420
- return false;
421
- }
422
- return a.map === b.map && a.strength === b.strength && textureUVEqual(a.mapUV, b.mapUV);
423
- }
424
-
425
- /**
426
- * The main map's layout, or undefined when it is the identity (channel 0,
427
- * untiled, unmoved, still), which costs nothing in the shader.
428
- */
429
- export function normalizeMapUV(uv: Partial<TextureUV> | null | undefined): TextureUV | undefined {
430
- if (!uv || typeof uv !== 'object') {
431
- return undefined;
432
- }
433
- const normalized = normalizeTextureUV(uv);
434
- return textureUVToJSON(normalized) === undefined ? undefined : normalized;
435
- }
436
-
437
- /** True when both lay the map the same way (or both as drawn). */
438
- export function mapUVEqual(a: TextureUV | undefined, b: TextureUV | undefined): boolean {
439
- return a === b || (!!a && !!b && textureUVEqual(a, b));
440
- }
441
-
442
- /**
443
- * True when a texture effect of `settings` samples UV channel 1, see
444
- * {@link UVChannel}: the map, the distortion noise or an erosion mask laid
445
- * on it.
446
- */
447
- export function readsUV1(settings: {
448
- mapUV?: TextureUV;
449
- uvDistortion?: ParticleUVDistortion;
450
- erosion?: ParticleErosion;
451
- }): boolean {
452
- const {mapUV, uvDistortion, erosion} = settings;
453
- return (
454
- mapUV?.channel === 1 || uvDistortion?.mapUV.channel === 1 || (!!erosion?.mask && erosion.maskUV.channel === 1)
455
- );
456
- }
457
-
458
- /**
459
- * A batch's copy of the {@link VFXBatchSettings} its systems share: every optional field
460
- * defaulted, and every field the batch's render mode ignores at its default (an opaque batch's
461
- * `materialBlendMode` reads `ALPHA_DISABLE`, a billboard batch's `rim` is unset). The renderer
462
- * adds a system to the batch whose settings match its own.
463
- */
464
- export interface StoredBatchSettings {
465
- /** See {@link VFXBatchSettings.instancingGeometry}. */
466
- instancingGeometry: Float32Array;
467
- /** See {@link VFXBatchSettings.instancingIndices}. */
468
- instancingIndices: Uint32Array | Uint16Array;
469
- /** See {@link VFXBatchSettings.instancingUVs}. */
470
- instancingUVs?: Float32Array;
471
- /** The mesh's second UV set, see {@link UVChannel}. */
472
- instancingUVs2?: Float32Array;
473
- /** See {@link VFXBatchSettings.instancingNormals}. */
474
- instancingNormals?: Float32Array;
475
- /** See {@link VFXBatchSettings.renderMode}. */
476
- renderMode: RenderMode;
477
- /** See {@link VFXBatchSettings.renderOrder}. */
478
- renderOrder: number;
479
- /** See {@link VFXBatchSettings.uTileCount}. */
480
- uTileCount: number;
481
- /** See {@link VFXBatchSettings.vTileCount}. */
482
- vTileCount: number;
483
- /** See {@link VFXBatchSettings.blendTiles}. */
484
- blendTiles: boolean;
485
- /** See {@link VFXBatchSettings.softParticles}. */
486
- softParticles: boolean;
487
- /** See {@link VFXBatchSettings.softNearFade}. */
488
- softNearFade: number;
489
- /** See {@link VFXBatchSettings.softFarFade}. */
490
- softFarFade: number;
491
- /** See {@link VFXBatchSettings.materialBlendMode}. */
492
- materialBlendMode: number;
493
- /** See {@link VFXBatchSettings.materialTransparent}. */
494
- materialTransparent: boolean;
495
- /** See {@link VFXBatchSettings.materialDepthTest}. */
496
- materialDepthTest: boolean;
497
- /** See {@link VFXBatchSettings.materialDepthWrite}. */
498
- materialDepthWrite: boolean;
499
- /** See {@link VFXBatchSettings.materialAlphaTest}. */
500
- materialAlphaTest: number;
501
- /** See {@link VFXBatchSettings.texture}. */
502
- texture: BaseTexture | null;
503
- /** See {@link VFXBatchSettings.reflectionTexture}. */
504
- reflectionTexture: BaseTexture | null;
505
- /** See {@link VFXBatchSettings.reflectionLevel}. */
506
- reflectionLevel: number;
507
- /** See {@link VFXBatchSettings.reflectionAtlas}. */
508
- reflectionAtlas: BaseTexture | null;
509
- /** See {@link VFXBatchSettings.layerMask}. */
510
- layerMask: number;
511
- /** Babylon rendering group the batch mesh draws in, see {@link VFXBatchSettings.renderingGroupId}. */
512
- renderingGroupId: number;
513
- /** Whether the scene's fog is applied, see {@link VFXBatchSettings.applyFog}. */
514
- applyFog: boolean;
515
- /** Billboard pivot in particle-size units, see {@link VFXBatchSettings.pivot}. */
516
- pivot?: Vector3;
517
- /** Draw order within the batch, see {@link SortMode}. */
518
- sortMode: SortMode;
519
- /** Smallest size a particle may have on screen, see {@link VFXBatchSettings.minParticleSize}. */
520
- minParticleSize: number;
521
- /** Largest size a particle may have on screen, see {@link VFXBatchSettings.maxParticleSize}. */
522
- maxParticleSize: number;
523
- /** Mesh particle lighting, see {@link VFXBatchSettings.lighting}. */
524
- lighting?: MeshLighting;
525
- /** Mesh particle rim, see {@link VFXBatchSettings.rim}. */
526
- rim?: MeshRim;
527
- /** The main map's layout, see {@link VFXBatchSettings.mapUV}. */
528
- mapUV?: TextureUV;
529
- /** Map UV distortion, see {@link VFXBatchSettings.uvDistortion}. */
530
- uvDistortion?: ParticleUVDistortion;
531
- /** Colour ramp for the map, see {@link VFXBatchSettings.gradientMap}. */
532
- gradientMap?: ParticleGradientMap;
533
- /** Alpha erosion, see {@link VFXBatchSettings.erosion}. */
534
- erosion?: ParticleErosion;
535
- }
19
+ export * from './batchSettings';
536
20
 
537
21
  /**
538
22
  * One draw call of a {@link BatchedRenderer}: the systems that share a material, texture and
@@ -541,8 +25,13 @@ export interface StoredBatchSettings {
541
25
  * what is drawn, not to change it.
542
26
  */
543
27
  export abstract class VFXBatch {
544
- /** The Babylon mesh the batch draws with. */
545
- mesh: Mesh;
28
+ /**
29
+ * The Babylon mesh the batch draws with. Its world matrix is frozen at identity: the instance
30
+ * data is already in world space, so no parent's transform moves the particles again.
31
+ */
32
+ mesh!: Mesh;
33
+ /** The node the batch mesh lives under, kept across the meshes the batch rebuilds. */
34
+ private parentNode: Node | null = null;
546
35
  /** The systems drawn by this batch. */
547
36
  systems: Set<IParticleSystem>;
548
37
  /** The settings every system in the batch shares, with what the render mode ignores at its default. */
@@ -563,11 +52,20 @@ export abstract class VFXBatch {
563
52
  protected constructor(settings: VFXBatchSettings, scene: Scene, depth: DepthBinding | null = null) {
564
53
  this.scene = scene;
565
54
  this.depth = depth;
566
- this.maxParticles = 1000;
55
+ this.maxParticles = 0;
567
56
  this.systems = new Set<IParticleSystem>();
568
57
  this.settings = storedBatchSettings(settings);
569
- this.mesh = new Mesh('vfxBatch', scene);
570
- this.configureMesh();
58
+ // The subclass builds the mesh, in setupBuffers.
59
+ }
60
+
61
+ /**
62
+ * Puts the batch mesh under `node` (the renderer), for the scene graph and the node's enabled
63
+ * state; the node's transform does not reach the particles.
64
+ * @internal
65
+ */
66
+ attachTo(node: Node | null): void {
67
+ this.parentNode = node;
68
+ this.mesh.parent = node;
571
69
  }
572
70
 
573
71
  /**
@@ -587,6 +85,13 @@ export abstract class VFXBatch {
587
85
  // world space, so the bounds never need the mesh's own matrix.
588
86
  mesh.alwaysSelectAsActiveMesh = true;
589
87
  mesh.doNotSyncBoundingInfo = true;
88
+ mesh.parent = this.parentNode;
89
+ // World-space instance data: the parent's transform must not apply, nor the bounds move
90
+ // with it. Frozen at identity, and kept there: a parent's markAsDirty would otherwise make
91
+ // the frozen mesh recompute from its parents once.
92
+ mesh.freezeWorldMatrix(Matrix.Identity());
93
+ const world = mesh.getWorldMatrix();
94
+ mesh.computeWorldMatrix = () => world;
590
95
  mesh.setBoundingInfo(new BoundingInfo(BVector3.Zero(), BVector3.Zero()));
591
96
  mesh.layerMask = this.settings.layerMask;
592
97
  mesh.renderingGroupId = this.settings.renderingGroupId;
@@ -697,6 +202,100 @@ export abstract class VFXBatch {
697
202
  });
698
203
  }
699
204
 
205
+ /** The observer of the scene's image processing that rebuilds the material when its defines change. */
206
+ private imageProcessingObserver: Observer<ImageProcessingConfiguration> | null = null;
207
+
208
+ /**
209
+ * The scene's image processing as the material compiles it, see {@link ShaderContext.imageProcessing}:
210
+ * null when the batch does not apply it.
211
+ * @internal
212
+ */
213
+ protected imageProcessing(): ShaderImageProcessing | null {
214
+ if (this.settings.applyImageProcessing === false) {
215
+ return null;
216
+ }
217
+ const defines: ShaderImageProcessing = {
218
+ IMAGEPROCESSING: false,
219
+ WHITEBALANCE: false,
220
+ VIGNETTE: false,
221
+ VIGNETTEBLENDMODEMULTIPLY: false,
222
+ VIGNETTEBLENDMODEOPAQUE: false,
223
+ TONEMAPPING: 0,
224
+ CONTRAST: false,
225
+ EXPOSURE: false,
226
+ COLORCURVES: false,
227
+ COLORGRADING: false,
228
+ COLORGRADING3D: false,
229
+ SAMPLER3DGREENDEPTH: false,
230
+ SAMPLER3DBGRMAP: false,
231
+ DITHER: false,
232
+ IMAGEPROCESSINGPOSTPROCESS: false,
233
+ SKIPFINALCOLORCLAMP: false,
234
+ };
235
+ this.scene.imageProcessingConfiguration.prepareDefines(defines);
236
+ return defines;
237
+ }
238
+
239
+ /**
240
+ * Binds the scene's image processing to a material compiled with `compiled`: its values on every
241
+ * bind, unless a post process applies them, and the material's defines, uniforms and samplers
242
+ * swapped in place whenever the configuration changes what it has to compile (tone mapping
243
+ * switched on, a vignette, a colour grading texture, a post process taking over). The material
244
+ * stays the same one: a configuration that flips back and forth within a frame, as a reflection
245
+ * probe rendering in linear space does, only switches between the effects the engine has
246
+ * cached for each set of defines. A colour grading texture is set on the material too, so
247
+ * that the batch, like Babylon's own materials, is not drawn before it has loaded.
248
+ * @internal
249
+ */
250
+ protected bindImageProcessing(material: ShaderMaterial, compiled: ShaderImageProcessing | null): void {
251
+ this.releaseImageProcessing();
252
+ if (this.settings.applyImageProcessing === false) {
253
+ return;
254
+ }
255
+ const config = this.scene.imageProcessingConfiguration;
256
+ let current = compiled;
257
+ let parts = imageProcessingParts(this.settings, current);
258
+ let key = parts.defines.join('\n');
259
+ const grading = () => {
260
+ if (parts.samplers.includes('txColorTransform') && config.colorGradingTexture) {
261
+ material.setTexture('txColorTransform', config.colorGradingTexture);
262
+ } else {
263
+ material.removeTexture('txColorTransform');
264
+ }
265
+ };
266
+ grading();
267
+ this.imageProcessingObserver = config.onUpdateParameters.add(() => {
268
+ const next = this.imageProcessing();
269
+ const nextParts = imageProcessingParts(this.settings, next);
270
+ const nextKey = nextParts.defines.join('\n');
271
+ if (nextKey !== key) {
272
+ const options = material.options;
273
+ swapEntries(options.defines as string[], parts.defines, nextParts.defines);
274
+ swapEntries(options.uniforms, parts.uniforms, nextParts.uniforms);
275
+ swapEntries(options.samplers, parts.samplers, nextParts.samplers);
276
+ current = next;
277
+ parts = nextParts;
278
+ key = nextKey;
279
+ }
280
+ grading();
281
+ });
282
+ material.onBindObservable.add(() => {
283
+ if (!current?.IMAGEPROCESSING || current.IMAGEPROCESSINGPOSTPROCESS || parts.defines.length === 0) {
284
+ return;
285
+ }
286
+ const effect = material.getEffect();
287
+ if (effect) {
288
+ config.bind(effect);
289
+ }
290
+ });
291
+ }
292
+
293
+ /** Stops following the scene's image processing. @internal */
294
+ protected releaseImageProcessing(): void {
295
+ this.imageProcessingObserver?.remove();
296
+ this.imageProcessingObserver = null;
297
+ }
298
+
700
299
  /**
701
300
  * Seconds of renderer time, set by the renderer before each fill; what UV
702
301
  * and erosion mask scrolling advance by.
@@ -928,6 +527,46 @@ export abstract class VFXBatch {
928
527
  abstract setupBuffers(): void;
929
528
  /** @internal */
930
529
  abstract expandBuffers(target: number): void;
530
+
531
+ /** Rebuilds the instance buffers at the current {@link maxParticles}, after a shrink. @internal */
532
+ protected abstract resizeBuffers(): void;
533
+
534
+ /** The capacity a batch starts with and never shrinks below. @internal */
535
+ protected abstract readonly initialCapacity: number;
536
+
537
+ /** Fills in a row that used under a quarter of the buffers. */
538
+ private underusedFills = 0;
539
+
540
+ /**
541
+ * How many fills in a row a batch may use under a quarter of its buffers before it halves
542
+ * them: about five seconds at 60 frames per second. A burst that grew the buffers does not
543
+ * hold that memory for the rest of the session, and a batch whose count swings does not
544
+ * reallocate every few frames.
545
+ */
546
+ static readonly SHRINK_AFTER_FILLS = 300;
547
+
548
+ /**
549
+ * Grows the buffers to hold `count`, or halves them once the batch has used under a quarter of
550
+ * them for {@link SHRINK_AFTER_FILLS} fills, never below {@link initialCapacity}. Called by each
551
+ * fill with what it is about to write. @internal
552
+ */
553
+ protected fitBuffers(count: number): void {
554
+ if (count > this.maxParticles) {
555
+ this.underusedFills = 0;
556
+ this.expandBuffers(count);
557
+ return;
558
+ }
559
+ if (this.maxParticles > this.initialCapacity && count < this.maxParticles / 4) {
560
+ if (++this.underusedFills >= VFXBatch.SHRINK_AFTER_FILLS) {
561
+ this.underusedFills = 0;
562
+ this.maxParticles = Math.max(this.initialCapacity, this.maxParticles / 2);
563
+ this.resizeBuffers();
564
+ }
565
+ return;
566
+ }
567
+ this.underusedFills = 0;
568
+ }
569
+
931
570
  /** @internal */
932
571
  abstract rebuildMaterial(): void;
933
572
  /** @internal */
@@ -935,6 +574,21 @@ export abstract class VFXBatch {
935
574
 
936
575
  /** @internal */
937
576
  dispose(): void {
577
+ this.releaseImageProcessing();
938
578
  this.mesh.dispose();
939
579
  }
940
580
  }
581
+
582
+ /**
583
+ * Replaces, in a material option list, the entries `previous` added with those of `next`: one
584
+ * occurrence each, so an entry the rest of the program also lists is kept.
585
+ */
586
+ function swapEntries(list: string[], previous: readonly string[], next: readonly string[]): void {
587
+ for (const entry of previous) {
588
+ const at = list.lastIndexOf(entry);
589
+ if (at !== -1) {
590
+ list.splice(at, 1);
591
+ }
592
+ }
593
+ list.push(...next);
594
+ }