babylon.quarks 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +769 -8
  2. package/README.md +10 -8
  3. package/dist/babylon.quarks.cjs +1 -1
  4. package/dist/babylon.quarks.cjs.map +1 -1
  5. package/dist/babylon.quarks.esm.js +1 -1
  6. package/dist/babylon.quarks.esm.js.map +1 -1
  7. package/dist/babylon.quarks.umd.min.js +1 -1
  8. package/dist/babylon.quarks.umd.min.js.map +1 -1
  9. package/dist/types/index.d.cts +6683 -0
  10. package/dist/types/index.d.ts +1959 -588
  11. package/package.json +12 -6
  12. package/src/BatchedRenderer.ts +204 -101
  13. package/src/EffectPool.ts +4 -1
  14. package/src/MeshSurfaceEmitter.ts +29 -4
  15. package/src/ParticleEmitter.ts +45 -15
  16. package/src/ParticleSystem.ts +909 -2061
  17. package/src/QuarksLoader.ts +421 -67
  18. package/src/QuarksPrefab.ts +91 -4
  19. package/src/QuarksSerializer.ts +484 -0
  20. package/src/QuarksUtil.ts +15 -0
  21. package/src/SpriteBatch.ts +106 -246
  22. package/src/TrailBatch.ts +533 -342
  23. package/src/VFXBatch.ts +236 -136
  24. package/src/batchKey.ts +237 -0
  25. package/src/effectResources.ts +79 -0
  26. package/src/envAtlas.ts +119 -17
  27. package/src/index.ts +32 -33
  28. package/src/nodeId.ts +13 -0
  29. package/src/shaderProgram.ts +325 -0
  30. package/src/shaderVariants.ts +225 -0
  31. package/src/shaders/chunks/fog.ts +4 -2
  32. package/src/shaders/chunks/sheet.ts +111 -0
  33. package/src/shaders/chunks/soft.ts +84 -0
  34. package/src/shaders/chunks/surface.ts +10 -0
  35. package/src/shaders/local_particle_physics_vert.glsl.ts +14 -28
  36. package/src/shaders/local_particle_physics_vert.wgsl.ts +16 -29
  37. package/src/shaders/particle_frag.glsl.ts +3 -22
  38. package/src/shaders/particle_frag.wgsl.ts +3 -23
  39. package/src/shaders/particle_physics_frag.glsl.ts +12 -25
  40. package/src/shaders/particle_physics_frag.wgsl.ts +12 -26
  41. package/src/shaders/particle_vert.glsl.ts +5 -27
  42. package/src/shaders/particle_vert.wgsl.ts +5 -26
  43. package/src/shaders/stretched_bb_particle_vert.glsl.ts +7 -33
  44. package/src/shaders/stretched_bb_particle_vert.wgsl.ts +7 -33
  45. package/src/shaders/trail_frag.glsl.ts +11 -1
  46. package/src/shaders/trail_frag.wgsl.ts +11 -0
  47. package/src/shaders/trail_vert.glsl.ts +45 -36
  48. package/src/shaders/trail_vert.wgsl.ts +46 -40
  49. package/src/softDepth.ts +136 -0
  50. package/src/system/BehaviorPlan.ts +85 -0
  51. package/src/system/BoundsTracker.ts +181 -0
  52. package/src/system/EmissionController.ts +360 -0
  53. package/src/system/EventDispatcher.ts +44 -0
  54. package/src/system/ParticleSpawner.ts +274 -0
  55. package/src/system/SimulationClock.ts +130 -0
  56. package/src/system/geometry.ts +111 -0
  57. package/src/system/integrate.ts +428 -0
  58. package/src/system/materialResolve.ts +116 -0
  59. package/src/system/normalize.ts +95 -0
  60. package/src/system/serialization.ts +401 -0
  61. package/src/system/settingsSchema.ts +317 -0
  62. package/src/system/types.ts +410 -0
  63. package/src/textureSource.ts +30 -0
  64. package/src/trailSettings.ts +51 -0
  65. package/src/BatchedParticleRenderer.ts +0 -3
  66. package/src/materials/ParticleMaterials.ts +0 -23
  67. package/src/materials/index.ts +0 -1
  68. package/src/shaders/chunks/index.ts +0 -1
  69. package/src/shaders/chunks/register-shader-chunks.ts +0 -32
  70. package/src/shaders/chunks/soft_fragment.glsl.ts +0 -13
  71. package/src/shaders/chunks/soft_pars_fragment.glsl.ts +0 -20
  72. package/src/shaders/chunks/soft_pars_vertex.glsl.ts +0 -6
  73. package/src/shaders/chunks/soft_vertex.glsl.ts +0 -6
  74. package/src/shaders/chunks/tile_fragment.glsl.ts +0 -9
  75. package/src/shaders/chunks/tile_pars_fragment.glsl.ts +0 -9
  76. package/src/shaders/chunks/tile_pars_vertex.glsl.ts +0 -28
  77. package/src/shaders/chunks/tile_vertex.glsl.ts +0 -18
  78. package/src/shaders/index.ts +0 -9
  79. package/src/shaders/local_particle_vert.glsl.ts +0 -50
package/CHANGELOG.md CHANGED
@@ -4,6 +4,768 @@ All notable changes to the `babylon.quarks` package are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project follows the `quarks.core` 0.x version line.
6
6
 
7
+ ## [0.27.0] — 2026-10-23
8
+
9
+ The release that follows the October 2026 audit: every domain (emitters, behaviors, system and
10
+ loader, shaders, trails) was brought to the working rules in `CLAUDE.md`. A 0.27.0 dated
11
+ 2026-10-21 was prepared but never published; its entries are folded in here, so this is the only
12
+ 0.27.0 and the whole list applies when upgrading from 0.26.
13
+
14
+ ### Breaking changes
15
+
16
+ Each item is described in full in the section it points to.
17
+
18
+ - **Exports removed** that nothing in the package read: `VFXBatchSettings.reflectionFaces`,
19
+ `MeshSettings.rotationAxis`, the `ParticleMesh*Material` classes, the shader chunk registry and
20
+ GLSL string exports, `BatchedParticleRenderer`, the `SpriteBatch` / `TrailBatch` exports,
21
+ `ParticleEmitter.extractFromCache`, `ParticleSystemParameters.startLength` and the duplicate
22
+ `quarks.core` type re-exports (Removed); `ApplyForce` loses its runtime fields `magnitudeValue`
23
+ and `memory` (Changed).
24
+ - **Trails stretch their texture by distance** by default; set the texture mode to
25
+ `'distributePerSegment'` to keep the old layout (Changed).
26
+ - **`WidthOverLength` spans the samples the trail holds**, not its `startLength` capacity
27
+ (Changed).
28
+ - **Trails face the camera in view space**; the trail material loses its `lineWidth`,
29
+ `resolution`, `sizeAttenuation`, `world` and `worldViewProjection` uniforms (Changed).
30
+ - **Flipbook frames wrap into the sheet** and the last cell blends into the first (Changed).
31
+ - **`softFarFade` defaults to 1**, so soft particles fade without a fade distance set (Changed).
32
+ - **Soft particles name their depth encoding**: `setDepthTexture(depth, {encoding})` with
33
+ `'linear'` as the default; a hand-built non-linear depth needs `'nonLinear'` (Changed).
34
+ - **`ApplyForce` is a world force**; a local-space effect that relied on the old reading sets
35
+ `space: 'local'` (Changed).
36
+ - **`OrbitOverLife` orbits the emitter about the emitter's normalised axis**; set `space: 'world'`
37
+ for the old orbit (Changed).
38
+ - **`Noise` turns mesh particles by `rotationAmount`** instead of ignoring it (Changed).
39
+ - **`TurbulenceField` is deterministic**: two fields with the same settings and no `seed` swirl
40
+ identically (Changed).
41
+ - **Bursts take the file defaults in code too**: `interval` is 0.1 s when left out, and the
42
+ system holds its own copy of `emissionBursts` (Changed).
43
+ - **A system belongs to one `BatchedRenderer` at a time**, and a disposed renderer refuses
44
+ `addSystem` with an error (Changed).
45
+ - **`ParticleSystem.toJSON()` ids are numbered per `meta`**, so two separate calls both write
46
+ `quarks_geometry_1`; merge outputs through one `meta` or use `serializeEffect` (Changed).
47
+ - **A disposed system stays disposed**: `restart()` no longer brings it back (Changed).
48
+ - **Seeded systems roll differently from 0.26**; a saved seed gives a different, still repeatable,
49
+ effect (Changed).
50
+ - **`serializeEffect` writes the url a texture's image had in its source file** rather than the
51
+ joined url, so a save next to the source loads; a save elsewhere passes `imageUrl` (Changed).
52
+ - **TypeScript:** the fields of `ParticleSystemJSONParameters` other than `version` are optional,
53
+ and `StoredBatchSettings.texture` is `BaseTexture | null` (Changed).
54
+
55
+ ### Added
56
+
57
+ - **Trail texture modes** (`TrailSettings.textureMode`, Unity's Texture Mode): `'stretch'` (the
58
+ default) runs the texture once along the ribbon by distance; `'distributePerSegment'` once with
59
+ each recorded segment an equal share (Niagara's Scaled Uniformly, what trails did before);
60
+ `'tile'` repeats it per world unit, anchored where the particle was born so a sample keeps its U
61
+ while it lives (Niagara's Tiling Distance); `'repeatPerSegment'` once per segment.
62
+ `TrailSettings.textureScale` (Unity's Texture Scale) sets the repeats: per unit, per segment or
63
+ over the ribbon. `uvFromHead` runs any mode from the particle back. The texture's UV channel 1
64
+ follows the same U. Each sample now records its distance along the trail
65
+ (`TrailParticle.historyDistances`, `measureHeadDistance()`).
66
+ - **`TrailSettings.minVertexDistance`** (Unity's Min Vertex Distance): a point is fixed only once
67
+ the particle has moved that far from the last fixed one, the newest sample following the
68
+ particle meanwhile (`TrailParticle.update(replaceHead)`), so a slow or standing particle no
69
+ longer fills its history with points on one spot, and the trail's length is a distance rather
70
+ than a number of steps.
71
+ - **`TrailSettings.lifetime`** (Unity's Time): samples older than that many seconds are dropped
72
+ from the tail (`TrailParticle.historyAges`, `dropOldest()`), within the `startLength` cap.
73
+ - **`ParticleSystem.clearTrails()`** (Unity's `TrailRenderer.Clear`): drops every ribbon and keeps
74
+ the particles, which start new ones where they are. With `pause()`, the move and `play()`, it is
75
+ how an emitter teleports without a ribbon across the jump; the advice to `pause()` alone, given
76
+ below, never cleared the samples already recorded.
77
+ - Normalisers `trailTextureMode`, `trailTextureScale`, `trailMinVertexDistance`, `trailLifetime`,
78
+ beside `trailTessellation` and `trailCurveTension`; the trail settings' JSON writes each only
79
+ when set, and `fromJSON`, `clone()`, `serializeEffect` and `QuarksLoader` carry them all.
80
+ - Demo: Trail Texture Modes and Sampling.
81
+ - **`shaderProgram()` and `shaderVariants()`**: the defines, attributes, uniforms, samplers and
82
+ sources (GLSL and WGSL) of the material a batch builds from its settings, and every distinct
83
+ program the library can compile (Unity's ShaderVariantCollection), for warming the shader cache
84
+ before the first frame. The batches build their materials through `shaderProgram`, so the list
85
+ is the code's own account of what it compiles; `npm run validate:shaders` compiles every variant
86
+ in both languages in CI (the former `validate:wgsl` compiled a hand-written WGSL subset that
87
+ missed, among others, the env atlas, lit meshes with texture effects, and effects with fog, soft
88
+ particles or alpha test). Types `ShaderProgram`, `ShaderSettings`, `ShaderContext`,
89
+ `ShaderVariant`, `ShaderSources`.
90
+ - **`ParticleSystem.clear()`** (Unity's `Clear`): removes the particles alive and nothing else; the
91
+ emission clock, the bursts and the pause stay where they were, and the sub emissions the
92
+ removed particles were driving end with them.
93
+ - **`space` on `ApplyForce`, `ForceOverLife` and `OrbitOverLife`** (quarks.core), the space the
94
+ force or the orbit is authored in (Unity's Space on Force over Lifetime): `'world'` keeps gravity
95
+ down and wind steady however the emitter is turned, and orbits the world origin about a world
96
+ axis; `'local'` turns and scales the force with the emitter, and orbits the emitter about one of
97
+ its own axes. The forces default to `'world'`, the orbit to `'local'`. The behavior converts into
98
+ the space the system simulates in, so a setting means the same in a local and a world-space
99
+ system, and the compiled behavior pass does the same. Written to JSON only when not the default.
100
+ The type is `BehaviorSpace` (`VelocityOverLifeSpace` is now an alias of it); the decomposed
101
+ emitter transform the conversions use is `EmitterFrame`, for behaviors of your own. The editor
102
+ shows Space on Force over Lifetime and Gravity, and Seed on Turbulence. The Force Space demo
103
+ shows a world gravity and a local thrust on turning emitters.
104
+ - **`seed` on `TurbulenceField`** (quarks.core): the field is built from it, 0 by default, so the
105
+ same settings swirl the same way on every run and in every copy; give each system its own seed
106
+ for different fields. Written to JSON only when set. `ParticleSystem.seed` now covers the whole
107
+ effect, since no behavior rolls outside the step any more.
108
+ - **`PhysicsResolver`** (quarks.core) is exported, and `setPhysicsResolver(undefined)` unregisters
109
+ the host's resolver. `ApplyCollision` asks the registered resolver when a particle is tested,
110
+ so one registered after the effect loaded takes effect; a resolver given to the constructor is
111
+ still preferred. `getPhysicsResolver()` is typed as possibly undefined.
112
+ - **`prewarmTime` on `ParticleSystem`**: how much of the cycle `prewarm` simulates before the first
113
+ frame, in seconds (Niagara's Warmup Time), on a looping system like `prewarm` itself. 0, the
114
+ default, warms up a whole cycle as Unity's Prewarm does; a shorter time warms up the cycle's last part only, with the bursts before it
115
+ counted as fired, which is enough for an effect whose particles live shorter than its cycle and
116
+ keeps a long cycle's first frame cheap. Cut to the cycle and to the static
117
+ `ParticleSystem.maxPrewarmSeconds` guard; written to JSON only when set.
118
+ - **`transparent`, `depthTest`, `depthWrite` and `alphaTest` on `ParticleSystem`**, the material
119
+ flags a system is built with, readable and settable afterwards like `blending`. Setting one
120
+ moves the system to a matching batch. `alphaTest` is kept to 0…1; the material flags were
121
+ only constructor parameters and JSON fields before.
122
+ - **`instancingIndices` on `ParticleSystem`**: the mesh particles' index list, settable after
123
+ construction. Mesh systems that share a vertex buffer but not an index list (sub-meshes of one
124
+ buffer) are now batched apart; they used to share a batch and draw with the first system's
125
+ indices.
126
+ - **`serializeEffect(root, options?)` writes a whole effect** as one document `QuarksLoader`
127
+ loads back, the counterpart of `loader.load()`. It writes the node tree (groups, emitters, mesh
128
+ nodes with their geometry, prefabs with `timeScale`), each texture with its sampler state, and a
129
+ format version. The result is plain data, so `JSON.stringify` takes it; a loaded effect saved
130
+ again writes the same file. A texture with no url a file can point at is reported through
131
+ `onWarning`, as are links to nodes it does not write and mesh settings it cannot hold. Options:
132
+ `filter` to leave out helper nodes, `imageUrl` to write image urls relative to where the file is
133
+ saved, `generator`, and `name` for several roots. A mesh node that is not drawn keeps its
134
+ children drawn (`isVisible: false`, which the loader now reads). The document types are exported (`QuarksEffectJSON` and the rest), and the format is
135
+ described in `docs/FORMAT.md`. Before, `ParticleSystem.toJSON` left live textures and materials
136
+ in its meta, and every caller wrote its own envelope.
137
+ - **Effect files carry a format version**, `metadata.quarksFormat` (`QUARKS_FORMAT_VERSION`). The
138
+ loader warns about a file from a newer version. Files without one still load.
139
+ - **`QuarksFormatError`**, thrown by `QuarksLoader.parse` for a document with no `object`.
140
+ - **Loads can wait for textures, report what failed, and be cancelled.** The loader option
141
+ `waitForTextures` makes `load`, `loadFromJSON`, `loadAll` and `loadFromJSONAll` resolve once every
142
+ texture of the effect has loaded or failed, so it does not draw its first frames blank, like an
143
+ Addressables or Unreal streamable handle completing. `onResourceError(kind, url, error)` reports
144
+ each geometry file or image that fails; the effect still loads. A `signal` (`AbortSignal`) in the
145
+ new `QuarksLoadOptions`, and in `QuarksBatchLoadOptions`, cancels a load: it rejects with the
146
+ signal's reason as soon as it is aborted and leaves no node of the effect in the scene, whatever
147
+ `continueOnError` says. The effect document's fetch is cancelled, and a custom
148
+ `QuarksResourceResolver.fetchJson` gets the signal for it. A scene disposed during a load rejects
149
+ it the same way, and a batch whose effect fails stops waiting on the others' textures.
150
+ - **A loaded effect frees what it owns when it is disposed**: the materials made for it, and each
151
+ texture once no other loaded effect, or copy of one, uses it. This holds however the root goes
152
+ (`root.dispose()`, its parent's or the scene's); `QuarksLoader.disposeEffect(root)` says so in
153
+ code. Before, those materials and textures stayed in the scene. A material or texture of a loaded
154
+ effect handed to other code is freed with the effect.
155
+ - **Soft particles and alpha test on trails.** `softParticles` and `alphaTest` now apply to trails
156
+ as to every other render mode, in GLSL and WGSL. Before, a trail accepted them, batched on them
157
+ and drew hard with a fixed 0.01 cutoff.
158
+ - **Soft-particle depth encodings:** `SoftDepthEncoding`, `DepthTextureOptions`,
159
+ `BatchedRenderer.depthEncoding`, and the decode helpers `softDepthParams` and `decodeSoftDepth`
160
+ (see Changed).
161
+ - **`disposeEnvAtlas(cube, scene?)`** drops the env atlases built for a cube.
162
+ - **Trail tessellation** (`TrailSettings.tessellation`, `curveTension`), like Niagara's ribbon
163
+ tessellation and curve tension:
164
+ - Each segment between two recorded samples is drawn as `tessellation + 1` segments along a
165
+ centripetal cardinal spline through the samples: Catmull-Rom at `curveTension` 0 (the
166
+ default), straight at 1. Centripetal spacing keeps the curve from overshooting where samples
167
+ lie unevenly apart.
168
+ - A fast or sharply turning trail, such as a weapon swing that moves far between frames, stays
169
+ round instead of showing its corners. The curve passes through every sample, so the trail
170
+ keeps its shape.
171
+ - Swept trails curve their sides with the points, keeping each side's length, so a blade's trail
172
+ stays as wide as the blade.
173
+ - Widths, colors and the texture's U are eased along the curve. The live tip drawn between
174
+ steps joins it with a straight segment, so the recorded part stays still as the tip moves.
175
+ - `tessellation` is a whole number from 0 (the default: off, no cost) to 16
176
+ (`MAX_TRAIL_TESSELLATION`). JSON writes both settings only when set. Helpers:
177
+ `trailTessellation`, `trailCurveTension`.
178
+ - **`textureSourceUrl(texture)`**: the url a loaded texture's image had in its effect file,
179
+ before the loader joined a base url, which `serializeEffect` now writes by default. It is
180
+ `undefined` for a texture made in code, one whose image a resolver inlined as a `data:` url, or
181
+ one whose `url` changed since it was loaded, which all write their `url` as before.
182
+
183
+ ### Removed (breaking)
184
+
185
+ The public API is now what the docs describe; these exports were left over from the three.js port
186
+ or duplicated something else, and nothing in the package used them.
187
+
188
+ - **`VFXBatchSettings.reflectionFaces`**: six face textures were read off a material
189
+ (`material.reflectionFaces`) and kept in the batch key, but no shader ever sampled them.
190
+ `reflectionTexture` is documented as it works: a cube texture is unfolded into `reflectionAtlas`
191
+ and sampled from there; nothing else is sampled.
192
+ - **`MeshSettings.rotationAxis`** (quarks.core), which was declared but never read, written or
193
+ cloned: a mesh particle's rotation is its start rotation (`startRotationX/Y/Z`, or a
194
+ `RandomQuatGenerator` / `EulerGenerator` start rotation) and `Rotation3DOverLife`. A file that
195
+ wrote it loses nothing, since no runtime ever read it.
196
+ - **`ParticleMeshStandardMaterial`, `ParticleMeshPhysicsMaterial` and
197
+ `BabylonParticleMaterialParameters`.** Mesh particles are drawn by the batch's own material; set
198
+ their look with the system's `material`, `texture` and lighting settings.
199
+ - **The three.js shader chunk registry**: `registerShaderChunks`, `getShaderChunk`,
200
+ `getRegisteredShaderChunks`, and the side effect that ran it on import. The batches never read
201
+ it.
202
+ - **The GLSL shader string exports** (`particleVertShader`, `particleFragShader`,
203
+ `localParticleVertShader`, `trailVertShader` and the rest). The batches build their shaders
204
+ themselves, in GLSL and WGSL.
205
+ - **`BatchedParticleRenderer`**, an alias of `BatchedRenderer`.
206
+ - **The `SpriteBatch` and `TrailBatch` exports.** `BatchedRenderer` makes the batches; `VFXBatch`
207
+ stays exported as the type of `renderer.batches`.
208
+ - **`ParticleEmitter.extractFromCache`**, a three.js serialisation helper.
209
+ - **`ParticleSystemParameters.startLength`**, which nothing read. A trail's length is
210
+ `rendererEmitterSettings.startLength`.
211
+ - **The duplicate type re-exports of `quarks.core`.** The same types still come from
212
+ `export * from 'quarks.core'`.
213
+
214
+ ### Changed
215
+
216
+ - **A trail's texture is stretched by distance** (breaking: an existing effect file has no
217
+ `textureMode`, so its trails now stretch by distance, which shows wherever a trail's speed
218
+ varies along its length): the default texture mode is Unity's Stretch, so a fast stretch of
219
+ the ribbon shows more of the texture than a slow one. Trails used to give each recorded segment
220
+ an equal share whatever its length; set `textureMode: 'distributePerSegment'` to keep that. A
221
+ ribbon that has not moved is laid out per segment, since it has no distance to stretch over.
222
+ - **`WidthOverLength` spans the samples the trail holds** (breaking for a young trail): the curve's
223
+ end is the oldest sample, as Unity's width curve runs over the trail as it is, so a trail cut
224
+ short by `lifetime` or `minVertexDistance` reaches the curve's end and a trail still growing
225
+ shows the whole curve compressed. It used to span the `startLength` capacity, so a growing or
226
+ shortened trail never tapered to the curve's end.
227
+ - **`followLocalOrigin: false` is no longer written to JSON**, as the other trail settings at
228
+ their defaults are not (three.quarks's loader reads a missing flag as off); `startLength` is
229
+ still always written, for loaders that read it without a default. A JSON `sideAxis` that is
230
+ not three finite numbers (numeric strings still count), or has no length, is dropped with a
231
+ `[quarks]` warning instead of making a ribbon of no width (or of NaN).
232
+ - **A particle at the end of its life is dead for its trail too.** The trail path recorded one
233
+ more sample at `age === life`, the step the removal pass already counted the particle dead, and
234
+ the renderer drew a tip for it; the samples, the world-space rewrite, the side axis and the tip
235
+ now all stop at `age < life`.
236
+ - **Flipbook frames wrap into the sheet, and the last cell blends into the first** (breaking for a
237
+ frame outside the sheet). A frame past the last cell used to read outside the texture, a
238
+ negative one differed between GLSL (floored) and WGSL (truncated), and with `blendTiles` the last
239
+ frame cross-faded into a cell that does not exist. The cell is now computed by one shared chunk in
240
+ both languages and by the trail's CPU path (`sheetCell`), frames wrapping as Unity's Texture
241
+ Sheet Animation loops. `blendTiles` compiles nothing on a 1×1 sheet, where the `TILE_BLEND` shader
242
+ read varyings the vertex stage never wrote (undefined behaviour on some GLSL drivers; the common
243
+ content of imported files).
244
+ - **`uTileCount`, `vTileCount`, `softNearFade` and `softFarFade` are normalised**: tile counts are
245
+ whole numbers of at least 1 (a 0 or fractional count divided by zero in the shader), fade
246
+ distances are finite and at least 0. **`softFarFade` defaults to 1** (breaking): with the old
247
+ default of 0 a soft particle was cut off hard at the scene instead of fading, so soft particles
248
+ did nothing unless a fade distance was set; a file that wrote neither fade now fades over one
249
+ unit, Unreal's Depth Fade default.
250
+ - **Mesh particles light both faces and keep their normals under a non-uniform size.** The normal
251
+ is scaled by the inverse of the size, so a stretched mesh stays lit as a stretched mesh rather
252
+ than as its unscaled shape, and a back face (meshes draw without culling) is lit, reflected and
253
+ rimmed as the face the camera sees instead of as the hidden one; a mirrored size (a negative
254
+ component) turns the normal with the winding, so a mirrored mesh lights the same way.
255
+ - **A stretched billboard seen along its velocity no longer vanishes**: the side vector was
256
+ `normalize(0)` (NaN) for a particle moving straight at the camera; it falls back as the beam's
257
+ already did.
258
+ - **A rim fade can discard**: `MESH_RIM_FADE` lowered the alpha after the alpha test had run, so a
259
+ face faded to nothing still passed the test; the test now runs after the rim.
260
+ - **A gradient map without a map tints every render mode the same**: sprites ignored it and meshes
261
+ (which sample a white placeholder) took the ramp's end colour; both now look the ramp up at white.
262
+ - **Linear fog with `fogEnd == fogStart`** divided by zero; it is a cut at that distance.
263
+ - **Erosion and distortion write only non-default values** to JSON (`softness`, `edgeWidth`,
264
+ `edgeColor`, `strength`, a missing `mask`), like every other setting; a gradient map, erosion mask
265
+ or distortion map that is not a url-backed `Texture` warns when it is left out of a file instead
266
+ of vanishing silently.
267
+ - Documented: the fixed discard of fragments under alpha 0.01, fog on multiply and subtract blend
268
+ modes, `blendTiles` needing a sheet and not applying to trails, trails cutting their flipbook
269
+ cell on the CPU.
270
+ - Internal: the unused `worldView` and `worldViewProjection` uniforms and the `USE_COLOR_AS_ALPHA`
271
+ define, which nothing set, are gone from the sprite shaders.
272
+ - **A system belongs to one `BatchedRenderer` at a time.** `addSystem` on a second renderer takes
273
+ the system out of the first (both used to step and draw it), and adding a system again after
274
+ its settings changed moves it to the matching batch instead of leaving it in two. A disposed
275
+ renderer lets its systems go (they can join another renderer) and refuses `addSystem` with an
276
+ error; a setter on a system after its renderer was disposed used to rebuild a batch, mesh and
277
+ material under the dead node. The renderer warns once when it is itself transformed: particles
278
+ are placed by their emitters, so its transform would move them again.
279
+ - **Opaque materials no longer draw a depth pre-pass.** `needDepthPrePass` was set whenever a
280
+ system wrote depth, so an opaque mesh system drew twice; it is now set only for a transparent
281
+ material that writes depth, which is what the pre-pass is for.
282
+ - **Setting `renderMode` or `instancingGeometry` keeps the pause.** Both rebuild the particles
283
+ through `restart()`, which also unpaused a stopped system; the pause state is kept now.
284
+ Switching to `Mesh` at runtime gives the system normals when it has none (the mesh shader reads
285
+ them; a mesh without the attribute was the iOS `GL_INVALID_OPERATION`), and a geometry with a
286
+ different vertex count takes its own index sequence instead of keeping the old geometry's
287
+ indices (a cube given to a quad system drew two triangles). `play()` is documented as resuming
288
+ only; a finished system needs `restart()`.
289
+ - **Bursts are normalised, in code and from a file.** A burst given to the constructor and one
290
+ read from JSON take the same defaults: `interval` 0.1 s when left out (the file default; the
291
+ constructor used to fire the waves all at once), `probability` kept to 0..1, `cycle` at least 1,
292
+ a non-finite `time` 0, a numeric `count` a constant; and the bursts are kept in time order, which
293
+ emission relies on: `restart()` puts bursts edited in place back in order, so an editor that
294
+ appends a burst at 0 after a later one no longer fires it late. The constructor used to keep the
295
+ caller's array; it now holds its own copy, so edit `emissionBursts` rather than the array that
296
+ was passed in. `BurstParameters.count` is documented as rolled per play and read at the wave's
297
+ time as a fraction of the duration, which is what the code has done since 0.27's burst fix.
298
+ - **Setting `ParticleSystem.time` resynchronises the bursts and is normalised.** The bursts
299
+ before the new time count as fired and the ones after are due again (a burst the clock was set
300
+ past used to fire on the next step anyway); NaN or a negative value is 0 (NaN used to hang the
301
+ system, never finishing), a value past the duration wraps on a looping system. The waves of a
302
+ burst in progress are counted with the emission's own arithmetic, so a wave on a rounding edge is
303
+ no longer skipped (this also affected prewarm).
304
+ - **`emissionMultiplier` is kept finite and at or above 0**; a negative, NaN or infinite value
305
+ counts as 0 (a negative value used to be ignored, an infinite one hung the spawner). An infinite
306
+ burst `cycle`, which hung the burst loop, is 1.
307
+ - **`ApplyForce` is a world force (breaking for turned or scaled emitters in local-space systems,
308
+ and for `motionScale`).** It used to push along its direction in whatever space the system
309
+ simulated in, so a local-space system with a turned emitter had its "gravity" turned with it,
310
+ and a world-space system in `'hierarchy'` scaling mode did not scale it with the emitter, unlike
311
+ `ForceOverLife`. Both forces now follow the same rule: a `'world'` force is converted into the
312
+ emitter's axes for a local-space system and multiplied by `motionScale` in a world-space one. A
313
+ file that relied on the old reading sets `space: 'local'`. Converting a world vector into a
314
+ non-uniformly scaled emitter's axes now undoes the rotation before the scale, as the matrix's
315
+ inverse does; `ForceOverLife` used to do it the other way round. `GravityForce` is documented as
316
+ pulling toward a point in the simulation space.
317
+ - **`OrbitOverLife` orbits the emitter, about the emitter's axis (breaking for a non-unit axis,
318
+ and for world-space systems).** The axis is normalised: an axis of length 2 used to turn
319
+ particles twice as far and push them off their circle. In a world-space system the pivot is now
320
+ the emitter's world position and the axis turns with the emitter, where it used to orbit the
321
+ world origin about the world axis; an effect that wants the old orbit sets `space: 'world'`.
322
+ A zero axis orbits nothing. The compiled behavior pass does the same.
323
+ - **`Noise` turns mesh particles by `rotationAmount` (breaking for mesh particles with a
324
+ `rotationAmount`).** A quaternion particle used to be turned by
325
+ a normalised four-noise quaternion, which ignored the amount and the power altogether: a
326
+ `rotationAmount` of 0.001 wobbled as hard as 1. It now turns about an axis the X, Y and Z noise
327
+ choose by W noise × π × amount, the angle a billboard would turn by.
328
+ - **`TurbulenceField` is deterministic and restarts cleanly (breaking: two fields with the same
329
+ settings and no `seed` now swirl identically).** Its noise permutation and time offsets were
330
+ rolled from the global random source when the behavior was constructed, so a clone, a reloaded
331
+ file and a seeded system all swirled differently, and `reset()` did nothing;
332
+ a zero `scale` component divided by zero and filled the velocities with NaN. The field now comes
333
+ from `seed` (see Added), `reset()` rewinds the drift, and a zero scale samples the field at 0
334
+ along that axis (no variation there) instead.
335
+ - **`ForceOverLife` before its first particle.** `update`, `updateAll` and the compiled pass
336
+ disagreed on a behavior that had not seen a particle yet (one applied the force, one skipped
337
+ it); all three now apply it as a world force.
338
+ - **The `Behavior` interface documents its contract**: the order the host calls `initialize`,
339
+ `frameUpdate`, `update`/`updateAll` and `reset` in, what the particle looks like at each, that
340
+ randomness goes through `quarks.core`'s `random()` so a host that wraps its steps in
341
+ `setRandomSource` replays an effect, and that everything else a behavior keeps derives from its
342
+ settings. `BehaviorTypes[...].params` lists every field a behavior writes (`Noise`'s
343
+ `scrollSpeed` and `remap`, `VelocityOverLife`'s `radial` and `orbitalOffset`, `FrameOverLife`'s
344
+ options, `WidthOverLength.absolute`, the new `space` and `seed`), and the serialisation contract
345
+ test fails when a field is written that the table does not list, so an editor cannot miss one.
346
+ - **A moving emitter's motion is spread over the steps of a frame.** The simulation runs in fixed
347
+ 1/60 s steps, several per frame on a slow display, and every step of a frame used the emitter
348
+ where the frame ended: the frame's spawns clumped there, and a trail recorded in world space
349
+ stair-stepped behind a moving emitter. Each step now gets the emitter interpolated between
350
+ where the last simulated step had it and where it is now, position, rotation and scale apart,
351
+ the way Niagara's interpolated spawning does; emission over distance already spawned along the
352
+ path, and the rate's spawns and world-space trail samples now follow it too; a burst due on a
353
+ mid-frame step fires where the emitter was then. A single-step frame, the first frame after
354
+ `play()`, `restart()`, a catch-up or a spell out of view, and a still emitter are unchanged, so
355
+ a copy placed and restarted (as `EffectPool.acquire` does) starts where it was put. A large move
356
+ of a playing system is motion like any other: a frame of two or more steps lays that frame's
357
+ spawns and trail samples along the jump, so teleport with `pause()` or `stop()` first.
358
+ `emitterVelocity` is still the frame's displacement over its time, which is the per-step
359
+ velocity of a linear path, so Inherit Velocity is unaffected. A seeded system with a moving
360
+ emitter plays differently from 0.27 when a frame runs more than one step; the simulation
361
+ snapshot test was re-recorded.
362
+ - **Batches are keyed on what the render mode draws.** The renderer used to split systems on
363
+ every setting, including the ones their mode ignores: two billboards with different mesh rims,
364
+ two meshes with different billboard pivots or size limits, two opaque systems with different
365
+ blend modes, two systems with different soft fade distances and soft particles off, or two
366
+ trails with different instanced geometry each cost a draw call. They now share a batch. The
367
+ settings a mode ignores are at their defaults in the batch's `settings`, so an opaque batch's
368
+ `materialBlendMode` reads `ALPHA_DISABLE` and a billboard batch's `rim` is unset.
369
+ - **Live parameters change a batch's material in place.** The values that only set uniforms (the
370
+ soft fade distances, the alpha cutoff, the pivot, the size limits, the reflection level, the rim
371
+ colour, power and strength, each texture effect's numbers and each texture's tiling, offset and
372
+ scroll) are the batch's live parameters, as a Unity MaterialPropertyBlock's values or an
373
+ Unreal dynamic material instance's parameters. Changing one on a system that is alone in its
374
+ batch writes the material and keeps the batch; it used to make a new batch, mesh and material
375
+ per value, and leave the old one for 120 frames. A change that needs a different shader (a
376
+ cutoff or pivot switched on or off, a rim mode, a texture) still rebuilds the material, and a
377
+ system that shares its batch still moves to a batch with its values. `VFXBatchSettings`
378
+ documents the two groups.
379
+ - **A particle system's JSON leaves out settings at their defaults.** `toJSON`, and so
380
+ `serializeEffect` and the editor's saves, used to write every setting; now a setting is written
381
+ only when it differs from the default, and a missing field reads as the default. The shape, the
382
+ start and emission curves except `startDelay`, `startTileIndex`, the geometry, the material and
383
+ the behaviors are still always written, since loaders up to 0.27 and three.quarks need them, so
384
+ both still load the files. Files from earlier releases load as before. The fields of
385
+ `ParticleSystemJSONParameters` other than `version` are therefore optional (breaking for
386
+ TypeScript code that read them without a check).
387
+ - **Every export has TSDoc**, and `npm run docs:check`, part of `npm run check` and CI, keeps it
388
+ that way. Members that are public only for other files of the package are tagged `@internal`
389
+ and left out of the API docs: the batches' buffers and lifecycle methods, `VFXBatch.addSystem`,
390
+ `applyDepthTexture`, `applyMeshLighting` and the like. They still compile, but are not part of
391
+ the contract.
392
+ - **`StoredBatchSettings.texture` is typed `BaseTexture | null`** instead of `any` (breaking for
393
+ TypeScript code that assigned something else), and `ParticleSystemParameters.material` is
394
+ `unknown`: a Babylon material, or the material record a loaded system carries.
395
+ - **`ParticleSystemParameters.speedFactor` is deprecated** in favour of
396
+ `rendererEmitterSettings.speedFactor`, and the demos use the latter.
397
+ - **`BatchedRenderer.deleteSystem` is typed to return `boolean`**, whether the system was in a
398
+ batch, which it already returned.
399
+ - **`QuarksPrefab.toJSON` writes `timeScale`** when it is not 1, so `toJSON` and `fromJSON` round-trip;
400
+ only `serializeEffect` wrote it before.
401
+ - **The package compiles with TypeScript's `strict` mode.** The published declarations are the
402
+ same as before.
403
+ - **`loadFromJSON` and `loadFromJSONAll` no longer change the document they are given.** They
404
+ resolve urls and inline geometry files on a copy, so one document can be loaded many times, into
405
+ several scenes or with several base urls. Code that read the resolved urls back from its own
406
+ document should call `resolveResources` (which still works in place) or read the loaded nodes.
407
+ - **`EffectPool` frees a copy with `QuarksLoader.disposeEffect`**, and `QuarksUtil.instantiate`
408
+ gives the copy a hold on the source's textures, so disposing the source leaves its copies drawing
409
+ and the last one to go frees the textures.
410
+ - **A geometry file that fails to load is reported through `onResourceError`** (default: the
411
+ same console warning as before). With `waitForTextures`, `onProgress` counts an effect once its
412
+ textures have settled.
413
+ - **Unreadable parts of an effect warn and take their default** instead of throwing a `TypeError`
414
+ or going silent: an unknown shape gives a point emitter, an unknown behavior is skipped with a
415
+ warning, an unknown curve or colour type warns, and a setting the file leaves out (a system's
416
+ `startLife`) takes the constructor's default. A behavior's missing number, vector or speed
417
+ range takes a fixed default where the constructor has none (see `docs/FORMAT.md`). A mesh surface shape whose mesh is not in the effect warns, as a sub emitter does.
418
+ - **Systems of one document share geometry and texture entries.** `ParticleSystem.toJSON(meta)`
419
+ writes a geometry or texture once per `meta`, however many systems or slots use it, and numbers
420
+ its ids per document, so the same effect serializes to the same file. Ids are no longer unique across
421
+ calls: two separate `toJSON()` calls both write `quarks_geometry_1`, so merge their outputs
422
+ through one `meta`, or use `serializeEffect`.
423
+ - **Trails face the camera in view space (breaking for custom trail shaders)**, across their
424
+ tangent and the line of sight, like Unity's View alignment and Niagara's ribbons. The width is
425
+ the particle's size in world units, measured across the line of sight. Before, the facing was
426
+ worked out on screen, which spiked across the view when a trail passed the camera and put NaN
427
+ vertices (holes) at repeated points and hairpins. The trail material no longer has the
428
+ `lineWidth`, `resolution`, `sizeAttenuation`, `world` and `worldViewProjection` uniforms. Code
429
+ that set or read them, or a shader built on them, should drop them; to change a trail's width,
430
+ scale `startSize` or add `WidthOverLength`.
431
+ - **A trail system made without `rendererEmitterSettings`** gets `startLength: 30` and
432
+ `followLocalOrigin: false`, as setting `renderMode` to Trail does, instead of throwing on its
433
+ first particle. `startLength` and `followLocalOrigin` are documented.
434
+ - **Box and edge shapes now apply their shape transform.** `BoxEmitter` and `EdgeEmitter` take a
435
+ `transform` (offset, rotation and scale within the system) like the other shapes, and write and
436
+ read it in JSON. Before, a Unity export that moved, turned or scaled a Box, Rectangle or Edge
437
+ shape lost that transform, and its particles started from the system's origin. Effects with such
438
+ a shape now emit where they were authored.
439
+ - **`ApplyForce` no longer has the public runtime fields `magnitudeValue` and `memory`.** The
440
+ magnitude is rolled per particle (see Fixed); read `magnitude` for the setting.
441
+ - **Texture sequencer JSON names its type and writes `position` as `[x, y, z]`.** Files written
442
+ before this release, with no type and an `{x, y, z}` position, still load.
443
+ - **Color, Size and Rotation by Speed read the current speed**, like Unity's modules and
444
+ Niagara's Scale by Speed: the velocity's length times the speed modifier, clamped to
445
+ `speedRange`. Motion `VelocityOverLife` adds does not count, since it moves particles without
446
+ changing their velocity. Before, they read the start speed, so a particle slowed by drag, a limit or a
447
+ collision kept its birth colour and size. Effects that use them on particles whose speed changes
448
+ now look different. An empty `speedRange` is a step at its value instead of NaN. The helper is
449
+ exported as `speedFraction`.
450
+ - **`LimitSpeedOverLife` dampen is clamped to 0..1 and no longer depends on the frame rate.** At
451
+ 60 steps a second a step still removes `dampen / 3` of the excess speed, so babylon.quarks
452
+ effects, which step at 60 Hz, look the same. Other step lengths now remove the share that
453
+ compounds to the same loss per second; before, a long step could reverse the velocity.
454
+ - **A disposed system stays disposed.** `restart()` no longer brings it back, `update()` and
455
+ `emit()` do nothing, and `dispose()` drops its particles, also when called from a
456
+ `particleDied` listener. `isDisposed` (also on `IParticleSystem`) says so.
457
+ - **Node ids in saved effects.** `ParticleEmitter.uuid`, and every link a file writes to a node
458
+ (sub emitter targets, mesh surface meshes, prefab animation targets), is now
459
+ `quarksNodeId(node)`: the uuid the node was loaded under, or a GUID given once to a node made in
460
+ code. Before, nodes made in code were written under their Babylon `uniqueId`, which differs per
461
+ session and can equal a loaded node's id. The editor writes every node this way too. Files
462
+ saved earlier with numeric ids still load; a prefab resolves a numeric id only to a node with no
463
+ file uuid.
464
+ - **A Death sub emitter ignores `inherit: ['lifetime']`**, since its parent has no life left;
465
+ its particles live their own life.
466
+ - **Soft particles: the depth texture now says what it stores (breaking for hand-built depth).**
467
+ `setDepthTexture(depth, {encoding})` takes `'linear'` (Babylon's default depth renderer, the
468
+ default), `'nonLinear'` (the depth buffer value, Unity's `_CameraDepthTexture`) or `'viewZ'`
469
+ (view-space Z, Unreal's SceneDepth). Pass the `DepthRenderer` itself and its encoding and
470
+ packing are read off it: `setDepthTexture(scene.enableDepthRenderer(camera))`. Perspective and
471
+ orthographic cameras, WebGL and WebGPU depth ranges, a reverse depth buffer and an infinite far
472
+ plane (`'nonLinear'` or `'viewZ'`) are handled. Before, the shaders always decoded a depth buffer
473
+ value, which no Babylon depth renderer writes by default. If you fed a non-linear depth by hand,
474
+ pass `{encoding: 'nonLinear'}`. The `projParams` uniform now holds the decode coefficients from
475
+ `softDepthParams`; the `soft_pars_fragment` chunk uses them too.
476
+ - **Soft particles are compiled in only while a depth texture is set**, as Unity turns them off for
477
+ a camera with no depth texture. Without one they draw hard instead of sampling an unbound
478
+ texture; setting or clearing it rebuilds the soft batches.
479
+ - **Batch meshes are not pickable, take no collisions and are not serialized** by Babylon's scene
480
+ serializer. Their geometry is one quad or mesh at the origin, not the particles.
481
+ - **Env atlases are cached per scene**, so a second scene or engine (an editor preview) gets its
482
+ own instead of a texture of the first scene. `getCachedEnvAtlas(cube, scene?)` takes the scene.
483
+ An atlas quarks built is disposed with its cube; `disposeEnvAtlas(cube, scene?)` drops one
484
+ sooner. A system whose atlas is disposed while its cube is kept builds a new one.
485
+ - **`BatchedRenderer.depthTexture` is an accessor**, and setting it calls `setDepthTexture`.
486
+ `VFXBatch.applyDepthTexture` takes a depth binding as well as a texture.
487
+ - **Seeded systems roll differently from 0.26.** A seeded system now rolls its emission rates,
488
+ burst counts and start delay on its own generator when it is created and on every restart, so
489
+ a restart replays them too. A seed saved with 0.26 gives a different, still repeatable, effect,
490
+ and lock-step sessions must not mix 0.26 and 0.27 clients.
491
+ - **`serializeEffect` writes each image's url as its source file had it** (breaking for a save
492
+ that relied on the joined url): a texture loaded from `fx/hit.json` as `tex/a.png` holds the url
493
+ `fx/tex/a.png`, and the saved file used to point at that, so a save next to the source loaded
494
+ `fx/fx/tex/a.png` the next time. The file now gets `tex/a.png` (see `textureSourceUrl`), as
495
+ Unity and Unreal keep asset links relative to the project; `imageUrl` still overrides it for a
496
+ save elsewhere, and a texture made in code still writes its `url`.
497
+
498
+ ### Fixed
499
+
500
+ - **A trail re-emitted with a random `startLength` reallocated its history every time.** The ring
501
+ buffers were replaced whenever the capacity changed; they now only grow
502
+ (`TrailParticle.ensureHistoryCapacity`), and `TrailParticle.length` defaults to 30, the
503
+ documented value, instead of 100.
504
+ - **Trail batches allocated twice the vertices they could draw.** The buffers were sized in
505
+ vertex pairs but compared against vertex counts, so every trail batch held two unused copies
506
+ of itself on the CPU and the GPU; `maxParticles` now counts vertices.
507
+ - **A tessellated trail bunched its points at the samples at `curveTension: 1`** and on the live
508
+ tip's segment: the Hermite basis eased a segment with no tangents while its widths, colours and
509
+ U ran linearly. A straight segment is now walked evenly.
510
+ - **Local-space sub emitter particles jumped to the next parent.** A sub emitter target in local
511
+ space (`worldSpace: false`) draws each particle through the matrix of the emission that spawned
512
+ it; that matrix object was reused for the next emission once the first ended, so particles that
513
+ outlived their emission (a child whose particles live longer than its `duration`) moved to where
514
+ the next parent died. The matrix now stays with the particles born through it, so they keep
515
+ following their parent while it lives (Frame and Birth modes), and the next emission gets its own.
516
+ - **`ChangeEmitDirection` with a random angle (`IntervalValue`) threw on every spawn**
517
+ (`memory.push is not a function`): its scratch memory was in a format the generators no longer
518
+ use. It is a generator memory now, cleared before each spawn, so one sample is rolled per
519
+ particle and the memory does not grow. The behavior and `restart()` reset it.
520
+ - **Arc modes keep their position within the arc.** `Loop` and `PingPong` at a negative speed, and
521
+ `Burst` for particles emitted by rate (not in a burst), gave positions outside 0…1, so the particle landed outside the arc or on the wrong side
522
+ of it; `PingPong` also started at the arc's far end. `Loop` wraps, `PingPong` starts at the arc's
523
+ start and sweeps back and forth, `Burst` places rate-emitted particles at random, and `spread`
524
+ snaps the result afterwards, as Unity's Arc Mode and Spread do.
525
+ - **`restart()` starts a sweeping shape over.** A `Loop`/`PingPong` shape resumed its arc from where
526
+ the system stopped, so a seeded replay or a pooled copy did not spawn where the original did.
527
+ `EmitterShape` gained an optional `reset()`, which the built-in sweeping shapes implement and
528
+ `restart()` calls; a custom shape with sweep state can implement it too.
529
+ - **`PointEmitter` emits at the start speed.** It scaled the speed by a random cube root, so with
530
+ `startSpeed` 5 the particles left at anything from 0.4 to 5, which no other shape and no Unity
531
+ shape does. Directions are uniform over the sphere as before. An effect tuned to the old spread of
532
+ speeds should set `startSpeed` to a random range (`IntervalValue`) to keep it.
533
+ - **Cone `thickness` maps to the radius as Unity's Radius Thickness does.** The ring started at
534
+ `sqrt(1 - thickness)` (0.71 of the radius for a thickness of 0.5) instead of `1 - thickness`, and
535
+ the Unity exporter already wrote the value 1:1. Emission is uniform over the ring's area, and
536
+ the thickness is kept to 0…1 (a value above 1 used to give NaN positions).
537
+ - **Curves and gradients hold their last key past 1.** `ContinuousLinearFunction` (vector and colour
538
+ curves, gradient keys) returned the _first_ key for a time past 1, so a particle that outlived its
539
+ curve snapped back to its start colour on the exact path while the sampled table held the end.
540
+ Both paths agree now.
541
+ - **An empty or unknown sequencer no longer breaks the system.** `TextureSequencer` with no
542
+ locations (before `fromImage`, or `locations: []` in a file) threw on the first particle it moved;
543
+ it leaves the particle where it is. A sequencer of an unknown type in a file used to become a
544
+ silent empty one and throw the same way; it warns and is skipped, like an unknown behavior.
545
+ - **The start colour and a burst's count curve run over the cycle like every other start curve.**
546
+ Both were sampled in seconds, so with a 4 s cycle a start-colour gradient was read past its end
547
+ from 1 s on, and a burst count curve was read at the burst's time in seconds. They now get the
548
+ normalised time of the cycle (0 to 1), as `startLife`, `startSpeed` and `startSize` do and as
549
+ Unity evaluates them. A sub emission reads the time since it began (its parent particle's birth
550
+ or death) over the target's duration, as its other start curves already did; before, a sub
551
+ emitter target's burst count curve was always read at 0, since the target's own clock never
552
+ runs. An effect authored to the old sampling changes: a start-colour gradient or burst count
553
+ curve meant to span the cycle now does.
554
+ - **Distance covered while emission over distance is 0 is not owed later.** With a rate curve
555
+ that rises, or a rate switched on in code or in the editor, the emitter used to spawn a line of
556
+ particles for every metre it had moved while the rate was 0.
557
+ - **A system stopped, paused or disposed from a listener stays stopped for the rest of the
558
+ frame.** `stop()` from a `finished` listener (which `EffectPool` does on release), `dispose()`
559
+ from `particleDied` and `autoDestroy` used to be followed by the frame's remaining steps, which
560
+ emitted into the stopped system and could fire `finished` again.
561
+ - **A fast playback speed runs at full speed.** The 0.1 s frame clamp now applies to the host's
562
+ frame before it is scaled by `playbackSpeed`, and the per-frame step cap grows with the speed, so
563
+ speed 10 advances 10 seconds per second; before, it was held to about 6. A host frame longer than
564
+ 0.1 s is still simulated as 0.1 s at any speed.
565
+ - **Prewarm runs on the fixed step directly**, not through `update()`, so it always simulates one
566
+ whole cycle: before, `playbackSpeed` scaled it too, so at speed 0.5 only half the cycle was
567
+ warmed up and at speed 0 none of it. It costs one step per 1/60 s of the cycle and no more. A
568
+ cycle longer than `ParticleSystem.maxPrewarmSeconds` (10 by default, a performance guard for the
569
+ whole app) is warmed up over its last part only, with the burst waves due before that counted as
570
+ fired, so a long looping effect does not stall its first frame.
571
+ - **Invalid clock settings are normalised.** A negative, NaN or infinite `playbackSpeed` reads as
572
+ 0 (the clock never runs backwards) and a `duration` shorter than one simulation step (1/60 s),
573
+ 0 or NaN reads as one step, so `timeRatio` stays finite; both are accessors now and normalise
574
+ what is set after construction too. The constructor also reads an invalid `cullingMode` as
575
+ `'always'` and a negative or NaN `maxParticles` as no limit, as `fromJSON` already did. A
576
+ negative or NaN frame given to `update()` is no time at all.
577
+ - **A culled `'pauseAndCatchUp'` system owes at most 0.1 s per frame it missed**, the same clamp a
578
+ visible system's frame gets; before, one stalled frame off screen could owe up to the 2 s cap.
579
+ - **`QuarksPrefab.play()` after `pause()` resumes the timeline's systems and clips**; before, a
580
+ system paused in the middle of its track stayed paused until the track ended.
581
+ - **Setting `MeshSurfaceEmitter.mesh` to nothing, or to a mesh without positions or indices,
582
+ emits from the origin** as documented, instead of from the previous mesh.
583
+ - **A stretched billboard system no longer writes its defaults into the
584
+ `rendererEmitterSettings` object it was given**, as trails already did not.
585
+ - **A texture whose image failed to load is no longer shared.** The next load of the same image
586
+ tries it again instead of reusing the broken texture. A texture the engine cannot make at all is
587
+ reported and left out instead of failing the load.
588
+ - **A loader read the images of the document it parsed before.** A document with textures but no
589
+ `images` loaded the previous document's pictures. Each parse now has its own.
590
+ - **`loadPlugin` did not record the plugin**, so loading it again ran `initialize` again and
591
+ `unloadPlugin` removed nothing. `unloadPlugin` now removes only the types that plugin registered.
592
+ - **`QuarksPrefab.timeScale` was not read from files.**
593
+ - **A trail's live tip folded back over its head** on frames where the last step ran ahead of the
594
+ display, about every other frame at 120 Hz. The head is now drawn back along its last segment
595
+ instead. Float noise in the step accumulator no longer adds a tip of no length.
596
+ - **A dead trail particle's tip kept moving**, drawing a spike that grew while the trail shrank.
597
+ The trail now ends at its last sample.
598
+ - **The live tip ignored `WidthOverLength`**, so a trail narrowed by it flared at the tip on
599
+ frames between steps.
600
+ - **`WidthOverLength` read its curve past the end** for a fractional or zero `startLength`.
601
+ - **Camera-dependent uniforms reached the GPU one draw late.** The eye position (rim, env
602
+ reflection), the soft-particle depth decode and the `'scene'` light were
603
+ written after the material had uploaded its uniforms, so each draw used the previous bind's
604
+ values: a frame late with one camera, and the other camera's with `activeCameras`. They now reach
605
+ the effect of the draw they are for.
606
+ - **Soft particles faded against the wrong depth**, both from the decode (see Changed) and because
607
+ the particle's own depth had the wrong sign for a left-handed camera, Babylon's default.
608
+ - **GLSL and WGSL applied fog and the soft fade in different orders**, so WebGPU kept a fog-coloured
609
+ fringe where a fogged soft particle met geometry. Both now fade last, after fog.
610
+ - **A mesh batch rebuilt for its env atlas lost the soft-particle depth texture.**
611
+ - **A non-looping system whose rate curve starts at 0 never emitted.** It reported nothing
612
+ pending on its first step and finished at once. A rate that rises later in the cycle now counts
613
+ as pending.
614
+ - **A looping system reported pending emission after `endEmit()`.**
615
+ - **A disposed sub emitter target kept taking particles without limit**, since nothing updated it
616
+ any more. Sub emitters now skip disposed targets.
617
+ - **Death sub emissions followed a newborn.** The dying particle's object is reused for the next
618
+ particle, and the emission followed that one back to the emitter, inheriting its colour and size.
619
+ A Death emission now stays where its parent died and inherits what the parent died with. Birth
620
+ and Frame emissions stop following when their parent dies (`Particle.serial`). A restart or
621
+ `stop()` drops the emissions still running from the last play.
622
+ - **`useVelocityAsBasis` stretched the sub emitter by the parent's speed**, and gave a NaN matrix
623
+ for a parent moving along -Z or faster than 1 along +Z. The basis is now orthonormal.
624
+ - **The fused `VelocityOverLife` ignored `radial` and `orbitalOffset`.** The fused pass, which runs
625
+ by default, now matches the per-particle path.
626
+ - **Sub emitter and mesh surface links broke when a loaded effect was saved again.** The links were
627
+ written with Babylon's `uniqueId` and the nodes with the uuid they were loaded under.
628
+ - **`clone()` shared state with the original**, so editing one changed the other:
629
+ - `ChangeEmitDirection`, the `Euler` rotation generator and `Vector3Function` (and with it
630
+ `SizeOverLife` with separate axes) reused their child generators.
631
+ - A system's emission bursts shared their `count` generator, and `startTileIndex` was shared.
632
+ - `emissionMultiplier` and a fixed `beamEnd` point were not copied. A `beamEnd` given as a
633
+ Babylon `Vector3` is now copied by value too. A `beamEnd` node is still shared, since the
634
+ clone follows the same node.
635
+ - **`ApplyForce` with a random magnitude threw** when it was created or loaded, since its
636
+ generator memory was the wrong shape for `IntervalValue`. The magnitude is now rolled per
637
+ particle at emission, as Unity's Force over Lifetime does with Random Between Two Constants, on
638
+ the system's seeded random source.
639
+ - **`ApplySequences` lost its sequences in JSON.** A texture sequencer wrote no type, so loading
640
+ the file could not tell which sequencer to build.
641
+ - **Node ESM, webpack 5 and SSR could not import the package.** The ESM and CJS builds imported
642
+ `@babylonjs/core` deep paths without `.js`, which only lenient bundlers such as Vite resolve.
643
+ Every deep import now ends in `.js`. The `exports` map now gives `require` its own declaration
644
+ file (`dist/types/index.d.cts`) and lists `./package.json`, so TypeScript with
645
+ `node16`/`nodenext` resolution types both module kinds. `npm run check:pack` now installs the
646
+ packed tarballs into a scratch project and imports them with Node ESM, Node CJS and TypeScript
647
+ `nodenext`.
648
+ - **`restart()` broke random emission.** It emptied the system's generator memory without
649
+ rolling `emissionOverTime` again, so an `IntervalValue` rate became NaN and the system emitted
650
+ nothing. Every pooled effect with a random emission rate was empty from its second play.
651
+ - **`restart()` kept the emission-over-distance history.** A pooled effect placed somewhere new
652
+ and restarted spawned a line of particles along the jump from where it last played.
653
+ - **Disposing an emitter node left its system running.** `root.dispose()` on an effect tree now
654
+ disposes every `ParticleSystem` in it and removes it from its renderer. A system that disposes
655
+ itself (`dispose()`, or `autoDestroy` when it finishes) still leaves the systems of its child
656
+ emitters playing until they finish.
657
+ - **`depthTest: false` had no effect.** It was stored, batched and saved, but never reached the
658
+ material. It now draws particles over all geometry (Unity's `ZTest Always`) in every render mode.
659
+ - **A mesh batch leaked a material when its env atlas finished loading.** The rebuild now disposes
660
+ the material it replaces, and does nothing when the batch was disposed before the atlas loaded.
661
+ - **`QuarksLoader` joined a relative base url twice.** `load('fx/hit.json')` and
662
+ `loadFromJSON(json, 'fx/')` loaded textures from `fx/fx/...`, and from `fx/fx/fx/...` when the
663
+ same document was loaded again.
664
+ - **Trails picked the wrong sprite-sheet cell.** The cell column was counted with `vTileCount`
665
+ and fractional frames were not rounded down, so a sheet with more columns than rows, or a
666
+ `FrameOverLife` curve, showed the wrong cell or a slice across two. Trails now pick the same cell
667
+ as billboards.
668
+ - **`OrbitOverLife.clone()` dropped the axis**, so a cloned system orbited about Y.
669
+ - **`loadFromJSON` after `resolveResources()` joined the base url twice.** The copy
670
+ `loadFromJSON` takes of the document lost the mark that its image urls were already resolved,
671
+ so a document prepared with `resolveResources(json, 'fx/')` and then loaded with the same base
672
+ got `fx/fx/tex/a.png`. The copy keeps the mark.
673
+
674
+ ### Tests
675
+
676
+ - Trails: the texture modes and scale, the live tip's distance, minimum vertex distance, lifetime
677
+ (alive and dead), the end-of-life boundary, `clearTrails()` and the teleport, the history
678
+ buffers' reuse, the batch's vertex sizing, tessellation at tension 1, the trail `tint`, the
679
+ trail settings through `serializeEffect` and `QuarksLoader`, the `sideAxis` JSON validation,
680
+ and `WidthOverLength` on fractional and zero lengths.
681
+ - **Coverage is enforced.** The package thresholds were never checked, because `npm test` ran
682
+ without coverage, and the root run ignored each package's file list. `npm run test:coverage` (in
683
+ CI and `npm run check`) now counts every source file and fails under a floor per package, set in
684
+ the root `jest.config.json` one point under today's coverage and raised as tests are added. The
685
+ floors show where tests are thinnest: the editor's core sits at 14%, quarks.core at 55%.
686
+ - **Serialisation contract.** Every behavior, emitter shape, value and colour generator, and every
687
+ `ParticleSystem` setting in each render mode, must survive `toJSON` → `fromJSON`, `clone()` and
688
+ `QuarksLoader` unchanged, and a clone must share no mutable state with its original. A new
689
+ behavior or shape type fails the test until it is added to it.
690
+ - **Frame-rate independence.** Every quarks.core behavior that changes a particle over time reaches
691
+ the same state after a second at 30, 60, 120 and 144 Hz, through both `update` and `updateAll`.
692
+ A seeded `ParticleSystem` gives the same particles, finishes within a frame of the same time and
693
+ pauses the same way at 20 to 240 Hz.
694
+ - **Seeded simulation snapshot.** A seeded system in each render mode (billboard in world and
695
+ local space, stretched, mesh, trail, beam), with a moving emitter, bursts and behaviors, is
696
+ stepped on uneven frames and its particles (trail history included), bounds and JSON are compared
697
+ with a stored snapshot; loaded back with `fromJSON`, it must write the same JSON and play the same.
698
+ It was recorded before `ParticleSystem.ts` was split and pins the simulation while the rest of the
699
+ split follows.
700
+ - Loader: a document `resolveResources()` prepared loads with the same base once; a save next to
701
+ the source writes the source url, through a custom resolver too.
702
+
703
+ ### Docs
704
+
705
+ - **README** describes the WebGPU path as it is: native WGSL shaders, nothing transpiled or
706
+ fetched at run time. The feature list names beams, the texture effects and the scene-integration
707
+ options; the Unity-style snippet imports from `babylon.quarks` (the npm package `quarks.core` is
708
+ the upstream three.quarks core, not this monorepo's fork of it); the demo count is current. `ROADMAP.md` and
709
+ `docs/SHURIKEN_PARITY.md` match what shipped in 0.24 to 0.27.
710
+ - **Three new demos** in the examples app: pooled one-shot hits (`EffectPool`,
711
+ `QuarksUtil.getDuration`, `emissionMultiplier`), renderer options side by side (`sortMode`,
712
+ `alignToDirection`, `minParticleSize` / `maxParticleSize`, `scalingMode`), and surface trails with
713
+ a mesh second UV set (`MeshSurfaceEmitter`, the trail flags, `tessellation`, `curveTension`,
714
+ `instancingUVs2` with a map on UV channel 1 through a gradient map).
715
+
716
+ ### Internal
717
+
718
+ - **`npm run validate:wgsl` runs in CI** (job `wgsl`), on Chromium's software WebGPU adapter, so a
719
+ WGSL variant that stops compiling fails the pull request. The script compiles the variants in
720
+ batches instead of one round trip each, which brings a run from ten minutes to about fifteen
721
+ seconds; it reports a missing WebGPU adapter or an empty run in plain words instead of a stack
722
+ trace, fails when the device is lost mid-run (a lost device compiles nothing and reports no
723
+ errors), and gives up after five minutes instead of holding the job.
724
+ - **The demo smoke in CI opened the wrong pages.** The examples are built under `/<repository>/`
725
+ on GitHub Actions, and the preview server answers a request outside that path with a 404 page
726
+ that loads without an error, so `check:demos` had been passing on 404 pages there. Both
727
+ `check:demos` and `validate:wgsl` now ask the server for its base path
728
+ (`scripts/examplesBase.mjs`), and the smoke fails a page the viewer never comes up on.
729
+ - **`batchKey.ts`** holds the batch key: `storedBatchSettings` reduces a system's settings to the
730
+ canonical form a batch stores, `shaderKeyEqual` and `liveParamsEqual` compare the two groups
731
+ and `batchSettingsEqual` both. `BatchedRenderer.updateSystem` writes live parameters through
732
+ `VFXBatch.applyLiveSettings`. `VFXBatch` reads the texture layouts off its settings on every
733
+ bind, and each batch writes its live uniforms from one place (`writeLiveUniforms`), on build and
734
+ on a change.
735
+ - **The stretched-billboard shaders no longer declare a `speedFactor` uniform.** The speed factor
736
+ is baked into the velocity attribute on the CPU, so the uniform, which was set from
737
+ `softNearFade`, was never read. The per-frame sync and its tests are gone with it.
738
+ - **`integrate.ts`** (`src/system/`) holds the passes of a simulation step after emission: the
739
+ snapshot the renderer extrapolates from, the behavior steps, the sprite path's one pass over
740
+ the store (newborn collapse, motion, ageing, bounds and removal) and the trail path's passes
741
+ (collapse, motion, history, removal), as plain functions over the store and the particle array.
742
+ `ParticleSystem.stepOnce` calls them in order and is 60 lines; the file is now about 1700
743
+ lines, from 3641 before phase 3c. The hot loops are the same code and run the same within
744
+ benchmark noise (a local 88k-particle benchmark: 11.5, 11.6 and 10.4 ms before against 11.6,
745
+ 9.2 and 10.1 ms after, in interleaved runs).
746
+ - **`EmissionController` and `ParticleSpawner`** (`src/system/`) take emission (rates, bursts and
747
+ their waves, emission along the emitter's path, the system-level generator memory) and the
748
+ making of newborn particles (pool growth, start values, placement, behavior initialisation) out
749
+ of `ParticleSystem`, which goes from about 2460 to 1950 lines. The hot path is unchanged within
750
+ measurement noise (a 20-system, 88k-particle Node benchmark: 7.5 ms before, 7.9 ms after, a
751
+ spawn-heavy one 10.3 and 10.3 ms, with a run-to-run spread of about 1 ms).
752
+ - **`SimulationClock`** (`src/system/SimulationClock.ts`) holds the fixed-step accumulator, the
753
+ rounding rule that keeps the residual near zero, the start delay and the catch-up debt of a
754
+ culled system, as pure numbers with their own tests. `ParticleSystem.update` is a client of it.
755
+ - **One table of `ParticleSystem` settings** (`src/system/settingsSchema.ts`) gives each plain and
756
+ generator setting its default, its JSON form and how it is copied. `toJSON`, `fromJSON` and
757
+ `clone` are built from it, and a test fails when a constructor parameter is in neither the table
758
+ nor the code that handles the meta, the shape or the behaviors.
759
+ - **`ParticleSystem.ts` is being split into modules under `src/system/`.** This first part moves
760
+ out the parameter and JSON types (`types.ts`), reading and writing JSON
761
+ (`serialization.ts`), culling bounds (`BoundsTracker.ts`), event listeners
762
+ (`EventDispatcher.ts`), the behavior fusion plan (`BehaviorPlan.ts`), reading draw settings from a
763
+ material (`materialResolve.ts`), default and fallback geometry (`geometry.ts`) and the setting
764
+ normalisers (`normalize.ts`). The file goes from 3641 to about 2500 lines. The public API and the
765
+ simulation are unchanged; the types are still exported from the package root. Still to come: one
766
+ table of settings for the constructor, setters, JSON and `clone`, the simulation clock, emission
767
+ and spawning, and the integration passes.
768
+
7
769
  ## [0.26.0] — 2026-10-21
8
770
 
9
771
  ### Changed (breaking)
@@ -104,13 +866,13 @@ renames below.
104
866
  unchanged and stays in the fused pass.
105
867
  - Texture effects on sprites, meshes and trails, GLSL and WGSL, each saved with the material in the
106
868
  effect JSON only when set and batched apart when they differ:
107
- - `ParticleSystem.uvScroll` `[u, v]`: scrolls the map in UV units per second on the renderer's clock.
108
- - `ParticleSystem.gradientMap`: recolours the map through a ramp texture picked by its red
109
- channel; the particle colour still tints it.
110
- - `ParticleSystem.erosion` (`ParticleErosion`: mask, softness, edgeWidth, edgeColor, maskScroll):
111
- alpha erosion. The particle's alpha decides how much of the shape is left instead of how
112
- transparent it is, so a `ColorOverLife` alpha curve dissolves it through a noise mask or wipes
113
- it through a gradient one, with an optional glowing edge.
869
+ - `ParticleSystem.uvScroll` `[u, v]`: scrolls the map in UV units per second on the renderer's clock.
870
+ - `ParticleSystem.gradientMap`: recolours the map through a ramp texture picked by its red
871
+ channel; the particle colour still tints it.
872
+ - `ParticleSystem.erosion` (`ParticleErosion`: mask, softness, edgeWidth, edgeColor, maskScroll):
873
+ alpha erosion. The particle's alpha decides how much of the shape is left instead of how
874
+ transparent it is, so a `ColorOverLife` alpha curve dissolves it through a noise mask or wipes
875
+ it through a gradient one, with an optional glowing edge.
114
876
  - `RenderMode.Beam` (6) with `ParticleSystem.beamEnd` (a point or anything with a world position,
115
877
  such as a `TransformNode`, read every frame; runtime only): a quad laid from each particle to
116
878
  the end and turned about its length to face the camera, U along it, the size as its width. With
@@ -353,7 +1115,6 @@ renames below.
353
1115
  optional per-axis thickness, moving particles along +Z; a box with no depth is a flat rectangle.
354
1116
  `EdgeEmitter` (`edge`) emits from a line along X, moving particles along +Y. Both match the
355
1117
  Unity shapes of the same name.
356
-
357
1118
  - Trail settings `uvFromHead`, `colorFromParticle`, `dieWithParticle` and `recordWorldSpace`
358
1119
  (all optional, off by default) for trails that behave like Unity's: the texture runs from the
359
1120
  particle back to the tail, the whole trail takes the particle's current color, the trail