babylon.quarks 0.26.0 → 0.28.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 (83) hide show
  1. package/CHANGELOG.md +1194 -8
  2. package/README.md +13 -9
  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 +10108 -0
  10. package/dist/types/index.d.ts +5794 -998
  11. package/package.json +18 -7
  12. package/src/BatchedRenderer.ts +274 -156
  13. package/src/EffectPool.ts +94 -12
  14. package/src/MeshSurfaceEmitter.ts +50 -14
  15. package/src/ParticleEmitter.ts +75 -17
  16. package/src/ParticleSystem.ts +981 -2066
  17. package/src/QuarksLoader.ts +666 -194
  18. package/src/QuarksPrefab.ts +367 -73
  19. package/src/QuarksSerializer.ts +512 -0
  20. package/src/QuarksUtil.ts +77 -7
  21. package/src/SpriteBatch.ts +127 -252
  22. package/src/TrailBatch.ts +545 -348
  23. package/src/VFXBatch.ts +238 -596
  24. package/src/batchKey.ts +239 -0
  25. package/src/batchSettings.ts +546 -0
  26. package/src/effectDocumentJSON.ts +261 -0
  27. package/src/effectResources.ts +76 -0
  28. package/src/envAtlas.ts +126 -18
  29. package/src/geometryUtil.ts +1 -1
  30. package/src/index.ts +69 -35
  31. package/src/nodeId.ts +36 -0
  32. package/src/shaderProgram.ts +419 -0
  33. package/src/shaderVariants.ts +245 -0
  34. package/src/shaders/chunks/blend.ts +31 -0
  35. package/src/shaders/chunks/fog.ts +12 -5
  36. package/src/shaders/chunks/sheet.ts +111 -0
  37. package/src/shaders/chunks/soft.ts +85 -0
  38. package/src/shaders/chunks/surface.ts +12 -2
  39. package/src/shaders/local_particle_physics_vert.glsl.ts +14 -28
  40. package/src/shaders/local_particle_physics_vert.wgsl.ts +16 -29
  41. package/src/shaders/particle_frag.glsl.ts +5 -22
  42. package/src/shaders/particle_frag.wgsl.ts +5 -23
  43. package/src/shaders/particle_physics_frag.glsl.ts +16 -26
  44. package/src/shaders/particle_physics_frag.wgsl.ts +14 -26
  45. package/src/shaders/particle_vert.glsl.ts +5 -27
  46. package/src/shaders/particle_vert.wgsl.ts +5 -26
  47. package/src/shaders/stretched_bb_particle_vert.glsl.ts +7 -33
  48. package/src/shaders/stretched_bb_particle_vert.wgsl.ts +7 -33
  49. package/src/shaders/trail_frag.glsl.ts +13 -0
  50. package/src/shaders/trail_frag.wgsl.ts +13 -0
  51. package/src/shaders/trail_vert.glsl.ts +45 -36
  52. package/src/shaders/trail_vert.wgsl.ts +46 -40
  53. package/src/softDepth.ts +136 -0
  54. package/src/system/BehaviorPlan.ts +85 -0
  55. package/src/system/BoundsTracker.ts +181 -0
  56. package/src/system/EmissionController.ts +358 -0
  57. package/src/system/EventDispatcher.ts +60 -0
  58. package/src/system/ParticleSpawner.ts +274 -0
  59. package/src/system/SimulationClock.ts +130 -0
  60. package/src/system/geometry.ts +104 -0
  61. package/src/system/integrate.ts +428 -0
  62. package/src/system/materialResolve.ts +165 -0
  63. package/src/system/normalize.ts +100 -0
  64. package/src/system/serialization.ts +422 -0
  65. package/src/system/settingsSchema.ts +339 -0
  66. package/src/system/types.ts +449 -0
  67. package/src/textureSource.ts +30 -0
  68. package/src/trailSettings.ts +54 -0
  69. package/src/BatchedParticleRenderer.ts +0 -3
  70. package/src/materials/ParticleMaterials.ts +0 -23
  71. package/src/materials/index.ts +0 -1
  72. package/src/shaders/chunks/index.ts +0 -1
  73. package/src/shaders/chunks/register-shader-chunks.ts +0 -32
  74. package/src/shaders/chunks/soft_fragment.glsl.ts +0 -13
  75. package/src/shaders/chunks/soft_pars_fragment.glsl.ts +0 -20
  76. package/src/shaders/chunks/soft_pars_vertex.glsl.ts +0 -6
  77. package/src/shaders/chunks/soft_vertex.glsl.ts +0 -6
  78. package/src/shaders/chunks/tile_fragment.glsl.ts +0 -9
  79. package/src/shaders/chunks/tile_pars_fragment.glsl.ts +0 -9
  80. package/src/shaders/chunks/tile_pars_vertex.glsl.ts +0 -28
  81. package/src/shaders/chunks/tile_vertex.glsl.ts +0 -18
  82. package/src/shaders/index.ts +0 -9
  83. package/src/shaders/local_particle_vert.glsl.ts +0 -50
package/CHANGELOG.md CHANGED
@@ -4,6 +4,1193 @@ 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.28.0] — 2026-10-23
8
+
9
+ The release after phase 6 of the audit and the 0.28 cleanup: alpha means coverage in every blend
10
+ mode, as in Unity; shapes fill like Unity's; `play()`, `resume()` and the `QuarksPrefab` timeline
11
+ behave like Unity's `Play` and `PlayableDirector`; the renderer's transform and the camera layers
12
+ no longer change what is drawn; effect files hold only what differs from the defaults; and no
13
+ `any` is left in the public types. The Unity exporter is out of experimental, and the editor gets a
14
+ headless `createEditorSession` and WebGPU. 0.27.1 was never published to npm: its packaging changes
15
+ (below) ship in this release.
16
+
17
+ ### Upgrading from 0.27
18
+
19
+ Most effects load and play unchanged. Check these, in this order:
20
+
21
+ 1. **Effects under additive, premultiplied, screen, subtract or max blending whose alpha falls
22
+ below 1** now fade with it. If one looks dimmer than you want, raise its alpha or its colour; a
23
+ texture authored premultiplied should be re-exported with straight alpha. Multiply and min
24
+ particles lighten toward white as their alpha falls instead of staying dark.
25
+ 2. **Fog follows the blend mode.** Subtract and screen effects fade toward black with distance, like
26
+ additive ones, and multiply effects toward white; only those that lay over the frame still mix
27
+ toward the fog colour.
28
+ 3. **Soft particles** fade linearly instead of quadratically under `ALPHA_ADD` and
29
+ `ALPHA_COMBINE`; re-tune `softFarFade` if a soft edge looks different.
30
+ 4. **Shapes with a thickness above 0, cones, and grids** spread particles differently (evenly over
31
+ the volume, every cone particle at its start speed, grid cells centred). Re-tune an effect that
32
+ relied on the old crowding at the centre.
33
+ 5. **Behaviors no longer reset at every loop.** A looping `Noise` or `TurbulenceField` keeps going
34
+ instead of jumping back to its start once a loop, and the sub emissions of particles still alive
35
+ carry on into the next loop.
36
+ 6. **`play()` on a finished system restarts it.** To undo a pause and leave a finished system
37
+ finished, call `resume()` (or `QuarksUtil.resume(root)`).
38
+ 7. **`QuarksPrefab`'s end lets the particles die out.** Call `stop()` yourself where you want them
39
+ cleared on the spot. `update()` with no argument now steps by the engine's frame time, and
40
+ `autoUpdate` drives the timeline for you. A prefab saved with a `timeScale` other than 1 now
41
+ plays its systems at that speed, and `addParticleSystemAnimation` no longer pauses the prefab.
42
+ 8. **Move emitters, not the `BatchedRenderer`.** Its transform no longer moves the particles.
43
+ 9. **Camera layers count for culling.** Only systems with a `cullingMode` other than the default
44
+ `'always'` are affected: one whose `layers.mask` no active camera renders now pauses as its
45
+ `cullingMode` says. Give the camera a `layerMask` that includes it.
46
+ 10. **Files are written with only their non-default fields.** babylon.quarks 0.27 and later read
47
+ them; three.quarks and babylon.quarks before 0.27 do not. Keep the old files if a three.quarks
48
+ tool must read them.
49
+ 11. **TypeScript and plugins:**
50
+ - `v.isVector2` instead of `Vector2.isVector2`; `setAxisAngleFromRotationMatrix` takes a
51
+ `Matrix4`.
52
+ - JSON readers and `QuarksLoader` take typed JSON or `unknown` (see the `any` items below), and
53
+ a behavior's `toJSON` returns a `BehaviorJSON` with a `type`.
54
+ - A plugin's `loadJSON` must only read its JSON: saving now calls it to find the defaults.
55
+ - `BatchedRenderer.batches` and `systemToBatchIndex` are read-only.
56
+ - A `VFXBatch` subclass implements `resizeBuffers()` and `initialCapacity` and creates its own
57
+ `mesh` in `setupBuffers`.
58
+ - `EulerGenerator.eular` is now `euler` (the old name is a deprecated alias).
59
+ 12. **Editor:** a `resolveTexture` callback returns a Babylon `Texture`, and `ValueField` with
60
+ `curve={false}` reports a `ValueGenerator` (see the editor's changelog). Install
61
+ `babylon.quarks-editor` 0.28 with `babylon.quarks` 0.28.
62
+
63
+ ### Breaking
64
+
65
+ - **Alpha means coverage in every blend mode.** A particle's alpha (its colour over life, a soft
66
+ fade, erosion, a rim fade, a `tint`) now fades it out whatever `blendMode` it draws with, as
67
+ Unity's particle shaders do for their Alpha, Premultiply, Additive and Multiply modes. Before,
68
+ modes whose blend takes the colour at full weight ignored alpha. What changes on screen:
69
+ - `ALPHA_ONEONE`, `ALPHA_PREMULTIPLIED`, `ALPHA_SCREENMODE`, `ALPHA_SUBTRACT`, `ALPHA_MAXIMIZED`,
70
+ `ALPHA_MAX` and the other modes that take the colour at full weight multiply it by its alpha
71
+ in the shader. An effect whose alpha is below 1 under these modes draws dimmer than before, as
72
+ it would in Unity; one with alpha 1 draws as before. Textures stay straight alpha, as in
73
+ Unity's Premultiply mode: a texture authored premultiplied should be re-exported straight.
74
+ - `ALPHA_MULTIPLY` and `ALPHA_MIN` move the colour toward white as alpha falls, so a fading
75
+ multiply particle lightens away instead of staying dark (Babylon's own particles do the same).
76
+ - The soft-particle fade lowers alpha only. Under `ALPHA_ADD` and `ALPHA_COMBINE` it used to fade
77
+ colour and alpha together, so the blend weighed it twice and the fade looked quadratic; it is
78
+ now linear over the soft distance, as in Unity and Unreal's Depth Fade. An opaque system
79
+ (`transparent: false`) or one under `ALPHA_INTERPOLATE` or `ALPHA_REPLACE_COLOR` has no
80
+ coverage to fade, so soft particles no longer darken it; they never faded it out either.
81
+ - `ALPHA_MAXIMIZED` takes the colour by alpha twice (its blend weighs it once more), so its fade
82
+ is quadratic; every other coverage mode fades linearly.
83
+ - **Fog follows the blend mode.** Particles that subtract from or screen over the frame fade
84
+ toward black with distance, like additive ones; multiply particles fade toward white, which
85
+ leaves the frame alone; particles that lay over the frame (`ALPHA_COMBINE`,
86
+ `ALPHA_PREMULTIPLIED`, opaque) still mix toward the fog colour. Before, every mode other than
87
+ `ALPHA_ADD` and `ALPHA_ONEONE` mixed toward the fog colour, which fogged a multiply or subtract
88
+ effect into a coloured haze.
89
+ - **`Vector2.isVector2` is an instance flag**, set on the prototype as `isVector3` and `isVector4`
90
+ are, instead of a static field. Read it on a vector (`v.isVector2`); `Vector2.isVector2` on the
91
+ class is gone.
92
+ - **`Vector4.setAxisAngleFromRotationMatrix` takes a `Matrix4`**, the matrix it has always read
93
+ (see Fixed). Code that passed a `Matrix3` no longer compiles; it got a wrong axis and angle.
94
+ - **Shape thickness fills evenly, as in Unity.** A sphere's or hemisphere's Radius Thickness now
95
+ spreads particles evenly over the volume of the shell, and a circle's and donut tube's over its
96
+ area (the cone already did); a rectangle spreads each edge's particles evenly over its band. Before, the radius was rolled linearly, so a
97
+ full sphere put half its particles within half its radius instead of an eighth. Effects with a
98
+ thickness above 0 look less crowded at the centre.
99
+ - **Cone particles all start at their start speed.** The direction tilted toward the rim was not
100
+ normalised, so particles inside the base disc moved slower than the rim's, by up to 3 % at the
101
+ default 30° and up to 24 % at 90°.
102
+ - **A grid emits from its cells' centres**, symmetric about the origin: a grid of width 2 with two
103
+ columns emits from x = ±0.5 instead of −1 and 0.
104
+ - Every shape's `fromJSON` takes a `ShapeJSON` instead of `any`, `ShapeJSON.speed` is a
105
+ `FunctionJSON`, and `ValueGenerator.startGen` and `genValue` take a `GeneratorMemory`.
106
+ - `EmitterShapes` lists the fields its shapes have: the grid's `row` (it said `rows`), no `angle`
107
+ on the sphere and hemisphere, the box's `emitFrom` as the new `'boxEmitFrom'` field type, and a
108
+ `transform` (the new `'shapeTransform'` type) on every shape.
109
+ - **Files hold only the shape and behavior fields that differ from their defaults.** A system's
110
+ shape and each behavior are written through the new `EmitterToJSON` and `BehaviorToJSON`, which
111
+ leave out every field equal to the default their type reads a missing field as, as the system's
112
+ own settings already were; a field the type needs, such as a behavior's curve, stays. babylon.quarks
113
+ 0.27 and later read these files; **three.quarks and babylon.quarks before 0.27 no longer do**.
114
+ `toJSON()` on a shape or behavior still writes every field.
115
+ - `Behavior.toJSON()` returns the new `BehaviorJSON` (a `type` and the settings) instead of `any`. A
116
+ behavior whose `toJSON` was declared to return something without a `type` no longer compiles.
117
+ `EmitterShapePlugin.loadJSON` and `BehaviorPlugin.loadJSON` are declared to take `ShapeJSON` and
118
+ `BehaviorJSON`, as methods, so a plugin that types its own JSON still compiles; they must only
119
+ read, since saving calls them to find the defaults.
120
+ - **`play()` on a finished system plays it again**, as Unity's `Play` does: it restarts it from
121
+ the beginning. Before, it only unpaused, and a finished system stayed empty. A paused or stopped
122
+ system still resumes where it was. `QuarksUtil.play(root)` does the same for each system, so a
123
+ short burst that had finished inside a longer effect plays again; to undo a pause without that,
124
+ use the new `resume()` and `QuarksUtil.resume(root)`.
125
+ - **Behaviors no longer reset at every loop.** A looping system called each behavior's `reset()`
126
+ as it began its next loop, so a `Noise` or `TurbulenceField` jumped back to its start offset
127
+ once a loop and the sub emissions of particles still alive were dropped. `reset()` now runs only
128
+ on `restart()` (and `stop()`), as `Behavior.reset` documents.
129
+ - **`QuarksPrefab` timeline:**
130
+ - The end of the timeline ends the emission of the systems still playing and lets their
131
+ particles die out, stops the clips, stops the prefab and rewinds it; it used to call `stop()`,
132
+ which cleared every particle on the spot. Call `stop()` yourself for that.
133
+ - A track's window excludes its end in `update` too, as `setTime` did: a system whose track ends
134
+ at 0.5 s stops emitting at 0.5 s, not one frame later.
135
+ - `update()` with no argument advances by the engine's frame time (game time) instead of the
136
+ wall clock.
137
+ - `addParticleSystemAnimation` no longer pauses the prefab.
138
+ - A looping track that ends with the timeline does not start over, and one restarted earlier
139
+ ends its emission with the timeline too.
140
+ - `timeScale` now speeds up and slows down the particle systems on the timeline (see Added), and
141
+ a negative, NaN or infinite one is read as 0. A prefab saved with a `timeScale` other than 1
142
+ plays its systems at that speed now.
143
+ - `pause()` then `play()` resumes exactly the systems the pause paused, those dying out after
144
+ their window included (they stayed frozen before), and `setTime` on a paused prefab leaves the
145
+ systems it starts paused.
146
+ - `ParticleEmitter.toJSON` writes `visible` from the emitter's own enabled flag, as `clone()` and
147
+ `serializeEffect` do, not from its parents': an emitter under a disabled template is saved
148
+ visible.
149
+ - `retainEffectResources` takes the effect's materials as any iterable, and counts holders of a
150
+ material as it does of a texture.
151
+ - **The renderer's transform no longer moves particles.** The batch meshes stay under the
152
+ `BatchedRenderer` node (disabling or disposing it still hides or frees them), but their world
153
+ matrix is frozen at identity: particles are drawn where their emitters put them, wherever the
154
+ renderer is, and the batch bounds Babylon culls by are the particles' own. Before, a moved,
155
+ turned or scaled renderer moved every particle again and only warned once; the warning is gone.
156
+ - `BatchedRenderer.batches` and `systemToBatchIndex` are read-only (`ReadonlyArray`,
157
+ `ReadonlyMap`); the renderer has always owned them, and code that wrote to them no longer
158
+ compiles; assigning `batches` throws at runtime.
159
+ - **Culling only counts the cameras that render a system's layer**: a camera whose `layerMask`
160
+ shares no bit with the system's `layers.mask` no longer keeps it simulating, so a system on a
161
+ layer only an inactive camera renders pauses as its `cullingMode` says. The `CullingMode` docs
162
+ say which cameras are tested, and that one rendering into a render target texture is not.
163
+ - A `VFXBatch` subclass implements `resizeBuffers()` and `initialCapacity`, which the shrinking
164
+ instance buffers call, and creates `mesh` in `setupBuffers`; the base class no longer makes one.
165
+ - **quarks.core's JSON and data types have no `any` left.** What changes for code that
166
+ compiles against them:
167
+ - `FunctionJSON` is the union of each generator's JSON (`ConstantValueJSON`, `GradientJSON`,
168
+ `EulerJSON` and the rest, grouped as `ValueGeneratorJSON`, `ColorGeneratorJSON`,
169
+ `RotationGeneratorJSON` and `Vector3GeneratorJSON`), with `type` as the tag. It was an open
170
+ record of `any`. Each generator's `toJSON` and `fromJSON` use its own type, and the
171
+ `…GeneratorFromJSON` readers take their group's. A generator class of your own that
172
+ implements `ValueGenerator` or a sibling interface writes one of these, or casts its `toJSON`;
173
+ no reader would know its type anyway.
174
+ - `BehaviorJSON` is `{type: string}`, no longer an open record, and every built-in behavior has its
175
+ own JSON type (`ColorOverLifeJSON`, `EmitSubParticleSystemJSON`, …) for its `toJSON` and
176
+ `fromJSON`. A plugin's `loadJSON` may take its own JSON interface with a `type`, as
177
+ `examples/src/demos/customPlugin.ts` does. An object literal passed to `BehaviorFromJSON` is
178
+ typed as its behavior's JSON, e.g. `const json: NoiseJSON = {type: 'Noise', …}`.
179
+ - `GeneratorMemory` is `number[]`. `ContinuousLinearFunction`'s values are `number`, `Vector3` or
180
+ `Vector4`, its `subType` follows from them, and its `fromJSON` returns the matching type for
181
+ the `subType`. `GradientJSON`'s `color` and `alpha` say their `subType`.
182
+ - `IEmitter.matrixWorld` is a `Matrix4`; `IParticleSystem.toJSON` and `IAnimationData.target` are
183
+ `unknown`, which a renderer narrows; `Constructable` takes `never[]` arguments.
184
+ - `EmitSubParticleSystem.subParticleSystem` is declared `IEmitter | string | undefined`: a loaded
185
+ behavior holds the uuid until the loader links it, as it always has.
186
+ - `Sequencer.toJSON` returns `SequencerJSON`; `Euler.fromArray` and `toArray` take numbers and an
187
+ order.
188
+ - In babylon.quarks, `ParticleSystemJSONParameters.rendererEmitterSettings` is the new
189
+ `RendererEmitterSettingsJSON`: a trail's `TrailSettingsJSON` or a stretched billboard's
190
+ settings.
191
+ - The `isVector3`-style flags are set on the prototypes as non-enumerable, read-only properties,
192
+ so they no longer show up in a `for…in` over a vector.
193
+ - **babylon.quarks's effect format and loader have no `any` left either.** The parts of an effect
194
+ file have named types: `EffectDocumentJSON` for what `QuarksLoader` reads, with
195
+ `QuarksMaterialJSON`, `EffectGeometryJSON` (quarks' own, three.js `BufferGeometry`,
196
+ `PlaneGeometry` and `SphereGeometry`), `EffectImageJSON`, `QuarksPrefabJSON`,
197
+ `ParticleEmitterJSON` and the rest. What changes for code that compiles against them:
198
+ - `QuarksLoader`'s `loadFromJSON`, `parse`, `resolveResources`, `resolveLinkedResources` and
199
+ `resolveExternalGeometries` take the document as `unknown`, as parsed from its file. `resolveLinkedResources` takes the new `QuarksResolveOptions`.
200
+ - `ParticleSystem.material` and `ParticleSystemParameters.material` are a
201
+ `ParticleMaterialSource`: the fields a system reads from a Babylon material or a loaded
202
+ material record. Any Babylon material fits.
203
+ - `BabylonMetaData` holds `BaseTexture | null` textures, `QuarksMaterialJSON` materials and
204
+ `GeometryEntry` geometries; its unused maps are `unknown`.
205
+ - `QuarksNodeJSON` lists its fields instead of an open record, `matrix` is optional, `visible`
206
+ is a boolean, and it has the prefab and three.js transform fields the loader reads.
207
+ `QuarksEffectJSON.materials` is `QuarksMaterialJSON[]`.
208
+ - `ParticleSystemJSONParameters` types its generators by group (`ValueGeneratorJSON`,
209
+ `ColorGeneratorJSON`, …), its `shape` as `ShapeJSON`, its `behaviors` as `BehaviorJSON[]`,
210
+ and `instancingGeometry` as a uuid or an entry. `BurstParametersJSON` has the older
211
+ `cycleCount`.
212
+ - `QuarksPrefab.toJSON` returns a `QuarksPrefabJSON`, `ParticleEmitter.toJSON` a
213
+ `ParticleEmitterJSON`, and `MeshSurfaceEmitter.fromJSON` takes a `ShapeJSON`.
214
+ - quarks.core's `JsonMetaData.textures` may hold null.
215
+
216
+ ### Deprecated
217
+
218
+ - `RecordState`, the sample of the old linked-list trail history. Trails record into the flat ring
219
+ buffer of `TrailParticle` (`historyPositions` and its siblings); nothing fills or draws the list.
220
+ - `TrailParticle.previous`, that list, is `@internal` and out of the documented API.
221
+ - `MeshSettings.startRotationX`, `startRotationY` and `startRotationZ`, which nothing reads, are
222
+ optional and deprecated; a mesh particle's start rotation about each axis is the system's
223
+ `startRotation`, an `EulerGenerator`.
224
+ - `EulerGenerator.eular`, a misspelling: the Euler is `EulerGenerator.euler`, and `eular` is an
225
+ alias of it.
226
+
227
+ ### Added
228
+
229
+ - **`blendCategory(settings)`** says how a blend mode's colour leaves the shader (`'straight'`,
230
+ `'premultiply'` or `'multiply'`) and where fog takes it (`'color'`, `'black'` or `'white'`), with
231
+ the `BlendCategory`, `BlendOutput` and `FogTarget` types. Opaque materials, `ALPHA_INTERPOLATE`,
232
+ `ALPHA_REPLACE_COLOR` and `ALPHA_DUAL_SRC0_ADD_SRC1xDST` have no coverage and stay `'straight'`.
233
+ - The shader variants cover every blend resolve with and without fog and soft particles, so
234
+ `validate:shaders` compiles the new `BLEND_PREMULTIPLY`, `BLEND_MULTIPLY` and `FOG_MULTIPLY`
235
+ defines in GLSL and WGSL.
236
+ - **The whole simulation core is documented.** Every vector, quaternion, matrix and math helper,
237
+ every shape, value, colour and rotation generator, behavior field and plugin type re-exported from
238
+ `quarks.core` now carries TSDoc, and `npm run docs:check` fails on one that does not, as it does
239
+ for the rest of the package.
240
+ - `ContinuousLinearFunction` (the keys of a `Gradient`) and its `ObjectValueType` are exported, and
241
+ `Bezier` has `BezierJSON` and `BezierSplit` types for what `toJSON` and `split` return.
242
+ - **Format warnings name the part of the file they are about**, by its JSON path
243
+ (`object.children[2].ps.behaviors[3]`). `QuarksLoaderOptions.onFormatWarning` receives them
244
+ instead of the console, and `QuarksLoaderOptions.strict` makes a load throw `QuarksFormatError`
245
+ for them, after reading the whole effect and freeing what it built, with every part in its
246
+ `problems`, to check in CI that a set of assets loads cleanly. The
247
+ loader's own format warnings (a newer format, an unresolved sub emitter or mesh surface link, an
248
+ image url that is not an image) go the same way. `readFormat`, `readFormatPart`,
249
+ `currentFormatPath`, `warnFormat` and `QuarksFormatError` (now defined in `quarks.core`) give plugin
250
+ readers and other hosts the same.
251
+ - `BehaviorTypes.EmitSubParticleSystem` lists `inherit`, with the new `'subEmitterInherit'` field
252
+ type.
253
+ - **`EffectPool.acquire` takes a placement** (`EffectPlacement`: `position`, `rotation` or
254
+ `rotationQuaternion`, `scaling`, `parent`), so a hit can face its surface normal or a muzzle
255
+ flash its barrel. Every acquire first puts the copy back to the template's transform, so a
256
+ rotation or scale from its last use does not stay. `acquire(position, parent)` still works, with
257
+ any vector that has `x`, `y` and `z`.
258
+ - **`ParticleSystem.timeDilation`**, a runtime multiplier on `playbackSpeed` that is not part of
259
+ the effect (never saved, cloned or loaded), like Unreal's Custom Time Dilation or Unity's
260
+ `VisualEffect.playRate`: for slow motion, a pause menu, or a timeline.
261
+ - **`QuarksPrefab.timeScale` reaches the systems**: it sets the `timeDilation` of each track's
262
+ system, so they slow down and speed up with the timeline; it is normalised like
263
+ `playbackSpeed`. Removing a system's last track sets it back to 1. Clips keep their own speed.
264
+ - **`ParticleSystem.resume()` and `QuarksUtil.resume(root)`** undo a pause and leave a finished
265
+ system finished, for a pause button over a scene; the editor's transport uses them.
266
+ - **`QuarksPrefab.autoUpdate`** advances the timeline before every render by the engine's frame
267
+ time, as a Unity `PlayableDirector` on Game Time does; it is turned off on dispose.
268
+ - `removeAllEventListeners()` with no event type removes every listener.
269
+ - A **Timeline** demo: a charge, a release burst on a window shorter than a frame and lingering
270
+ smoke on a `QuarksPrefab`, every other blast in slow motion.
271
+ - **`renderFace`** on a system (`'both'`, `'front'`, `'back'`; the new `RenderFace` type), Unity's
272
+ Render Face and Unreal's Two Sided: a mesh particle can cull its back faces, or show only its
273
+ inside. `'both'` by default, as before; written to JSON only when set; mesh systems that differ
274
+ are batched apart; the flat render modes (billboards, stretched, beam, trail) always draw both
275
+ faces. The culling is per batch, so a mesh mirrored by a negative size on an odd number of axes
276
+ swaps which side `'front'` and `'back'` show. The editor has a Render face field for mesh systems.
277
+
278
+ ### Fixed
279
+
280
+ - `new Gradient()` with no keys is opaque white from 0 to 1, as Unity's default gradient is. Its two
281
+ colour keys were both at 0 (black, then white), so it already looked white after the start; now
282
+ its keys span the range.
283
+ - `PiecewiseBezier.toSVG` returns an empty path for a curve with no pieces instead of throwing.
284
+ - A shape transform's rotation is read as a unit quaternion, and a zero one as the identity, so a
285
+ hand-written or rounded rotation no longer scales the shape.
286
+ - `Rotation3DOverLife` no longer allocates a quaternion for each mesh particle it starts.
287
+ - `BatchedRenderer.addSystem` throws for a system of another scene instead of building a batch
288
+ that holds two scenes' meshes and materials.
289
+ - `configureAdaptivePerformance` keeps `minQuality` at 0.1 or more, the floor
290
+ `ParticleSystem.setQualityFactor` clamps to and the documented range; it allowed 0.05.
291
+ - The 1×1 white map mesh batches share is made again after something outside disposes it, instead
292
+ of binding a disposed texture.
293
+ - An env atlas that fails to build from a cube's faces warns once instead of once per system: it
294
+ is not tried again until the cube's faces change or `disposeEnvAtlas` is called for the cube.
295
+ - `ApplySequences` keeps each particle's place in a `WeakMap` instead of fields added to the
296
+ particle, reuses that state when a pooled particle is emitted again instead of allocating two
297
+ vectors, and moves a particle straight to its point when a sequence's range has no length (it
298
+ divided by zero).
299
+ - An `EmitSubParticleSystem` read from a file without `useVelocityAsBasis` saves without it, as a
300
+ default, instead of writing `false`.
301
+ - `PiecewiseBezier.toSVG` draws the curve itself, not its sampled table.
302
+ - A `Gradient` read from JSON without its `color` or `alpha` keys warns, and keeps the default for
303
+ the missing part.
304
+ - A particle system reads its texture from the first material slot that holds a `Texture`; a cube
305
+ or other non-`Texture` in a slot is passed over instead of drawn as a broken 2D texture.
306
+ - A list of an effect file (`images`, `textures`, `materials`, `geometries`, a node's `children`)
307
+ that is not a list, or an entry in one that is not an object, warns and is skipped, like any
308
+ other part the loader cannot read, instead of failing the load with a `TypeError`.
309
+ - `ParticleSystem.fromJSON` with a material entry whose `reflectionAtlas` is a texture uuid
310
+ resolves it, as it does the other slots, instead of failing on the string.
311
+ - The fragment's `alpha < 0.01` discard is right in every blend mode: under one-one or
312
+ premultiplied blending a discarded fragment used to be one that would still have drawn.
313
+ - Erosion and a mesh's rim fade no longer show a hard cut under one-one and premultiplied
314
+ blending; they fade like under alpha blending.
315
+ - **`Bezier.split(t)` split the curve at `1 - t`.** Its de Casteljau steps weighted the two points
316
+ the wrong way round, so the halves met at the mirrored point; a curve split in the editor away
317
+ from a segment's middle changed shape. It now splits at `t`, and both halves trace the curve.
318
+ - `Matrix3.makeTranslation(vector)` read a `Vector2` as a number and built a broken matrix; it
319
+ checked a flag that `Vector2` only had as a static.
320
+ - `Vector4.setAxisAngleFromRotationMatrix` is typed to take the `Matrix4` it reads (as three.js
321
+ does); it was typed `Matrix3` and read a 3×3 matrix's entries from the wrong places.
322
+ - Shapes read out-of-range settings safely: a thickness is clamped to [0, 1] (NaN reads as 1), a negative radius,
323
+ width or height is read as 0, a cone's angle is clamped to [0, π/2] and a box's thickness to 1, a
324
+ grid's rows and columns are read as whole numbers of at least 1 (0 or Infinity gave NaN
325
+ positions), and a
326
+ short `boxThickness` array reads its missing axes as 0.
327
+ - A donut with no tube, and a rectangle with no size, started particles with no speed; they head
328
+ out from the centre line, or along the edge's normal, at their start speed.
329
+ - `AxisAngleGenerator` normalises its axis when it rotates (a zero axis is read as +z): an axis of
330
+ any other length gave a quaternion that was not a unit and skewed mesh particles.
331
+ - `RandomQuatGenerator` and `RandomColorBetweenGradient` used before `startGen` read slots that
332
+ belong to other generators, and the gradient mix rolled a new value every frame, flickering; both
333
+ now roll their own slots once, as `IntervalValue` does.
334
+ - `PiecewiseFunction.insertFunction` at a `t` outside every piece inserted at the front and
335
+ unsorted the pieces; it inserts in order and clamps `t` to [0, 1].
336
+ - A circle or rectangle in `Random` mode no longer samples its sweep speed every frame, as the
337
+ other shapes do not; a random sweep speed no longer draws its roll, which shifts a seeded
338
+ effect's random sequence by one draw.
339
+ - Shape docs give the units and axes of `arc` (radians around +z from +x), the ranges of every
340
+ size and thickness, and say that `RectangleEmitter` emits from the outline (Unity's Rectangle is a
341
+ `BoxEmitter` with no depth); the donut's `donutRadius` is the tube's radius, not the hole's.
342
+ - `docs/FORMAT.md` gave 0.28 as the release of format version 1; 0.27.0 is. It now says how a
343
+ system's blend mode is read when its material entry, or the system, has none, and that
344
+ `ps.version` is written for three.quarks only.
345
+ - **`QuarksPrefab` plays a window a single frame steps over.** A track shorter than a frame (a
346
+ release burst, say) was never entered when one update jumped from before its start to after its
347
+ end; its system now restarts and ends its emission once it has simulated a step (checked on
348
+ each update and after each render), and its clip plays and stops.
349
+ - **An `EffectPool` never hands out a disposed copy.** A copy disposed from outside, by its own
350
+ `dispose()` or with a parent it was placed under, leaves the pool, which makes a new one when
351
+ it needs it; before, `acquire` handed out the disposed node and it never came back.
352
+ - **`QuarksUtil.instantiate` copies meshes as meshes**, sharing the source's geometry and
353
+ material, where it made plain transform nodes of them; an instance of a mesh in the tree becomes
354
+ an instance of its copy, and a `MeshSurfaceEmitter` whose mesh is in the copied tree emits from
355
+ the copy. The copy holds the source's materials as it held its
356
+ textures, so `disposeEffect(source)` leaves a copy's meshes and materials whole.
357
+ - **`MeshSurfaceEmitter` emits from a mesh without indices**, read as a triangle list, instead of
358
+ from the origin. A stray vertex count past the last whole triangle is ignored.
359
+ - **A listener removed while an event fires no longer makes the next one skip**: the listener
360
+ lists are copied on write, so every listener registered when the event fired is called once,
361
+ and one added during it waits for the next event.
362
+ - `particleDied` allocates nothing per death: one event object is reused, its `particle` set only
363
+ while the listeners run (documented, as is when a trail's death fires).
364
+ - `EmitSubParticleSystem.reset()` drops the running sub emissions, as `ParticleSystem.restart`
365
+ did through a special case it no longer needs; any host gets the same reset.
366
+ - Culling reuses its frustum planes; it allocated a new array per extra camera every frame.
367
+ - **Instance buffers shrink.** A batch whose buffers a burst grew halves them after it has used
368
+ under a quarter of them for 300 renderer updates (about five seconds at 60 fps), down to where
369
+ it started, so one big explosion no longer holds that memory for the rest of the session.
370
+ Trails too.
371
+ - A batch no longer creates a placeholder mesh that it disposes straight away.
372
+ - `SortMode` documents that `'distance'` sorts once a frame, for the first active camera.
373
+
374
+ ### Internal
375
+
376
+ - Tests cover the vectors, quaternions, Euler angles, matrices and math helpers, and the coverage
377
+ floors rise: the simulation core's from 54 % to 88 % of lines, the library's and the editor's to
378
+ one point under what the suites reach.
379
+ - Behaviour tests for every shape: even filling, bounds, start speed, the rectangle's outline,
380
+ grid cells, out-of-range settings, JSON round trips of every field and the registry's fields.
381
+ - Golden fixtures in `packages/babylon.quarks/test/fixtures`: effects written by three.quarks, by
382
+ the Unity exporter and by babylon.quarks 0.27.0. Each loads with the warnings its snapshot holds and
383
+ saves to the format it holds, saving is stable, and each 0.27.0 file loads as the one it was
384
+ written from.
385
+ - Loaded node ids and mesh surface links live in module `WeakMap`s instead of `as any` fields on
386
+ nodes and systems.
387
+ - The batch settings, their defaults and the helpers that normalise and compare them move from
388
+ `VFXBatch.ts` to `batchSettings.ts`, which `VFXBatch.ts` re-exports; the batch key reads them
389
+ there, so the build no longer reports a circular import. Exports are unchanged.
390
+ - A clean build and install print no warnings: the core build stops passing `declarationDir`
391
+ without declarations, CI runs on Node 24 with current GitHub Actions, dev dependencies have no
392
+ known vulnerabilities, and the demo smoke test fails on a console warning as well as an error.
393
+ - The toolchain moves to ESLint 10 (with `defineConfig` and react-hooks 7's React Compiler rules),
394
+ TypeScript 6, Vite 8 (Rolldown: the examples' chunks all fit under its 500 kB warning) and
395
+ cross-env 10. Babel stays on 7, which Jest's own preset still needs.
396
+ - No explicit `any` is left in the repository, tests included, and ESLint now reports one as an
397
+ error. Tests reach private members with `obj['member']`, and use typed helpers in
398
+ `test/internals.ts` for shader options, empty resources, fetch responses and values the types
399
+ rule out.
400
+
401
+ ## [0.27.1] — 2026-10-23 (not published)
402
+
403
+ Packaging and release checks; the runtime is the same as 0.27.0. It was never published to npm; its
404
+ changes ship in 0.28.0.
405
+
406
+ ### Changed
407
+
408
+ - **`@babylonjs/core` peer range is `^9.0.0`** instead of `>=9.0.0`. The package is built and
409
+ tested against Babylon 9; a future major is a separate release, so npm no longer accepts it
410
+ silently. No Babylon 10 exists yet, so no install that worked stops working.
411
+ - **`sideEffects` names the built bundles**, the only files with import-time work (they register
412
+ the mesh-surface emitter with the shape table, so a bare `import 'babylon.quarks'` keeps doing
413
+ that); bundlers may treat everything else in the package as side-effect free.
414
+ - The README says what `require('babylon.quarks')` needs: `@babylonjs/core` is ES modules only, so
415
+ CommonJS consumers need a Node that can `require` ES modules (20.19 or 22.12 and later).
416
+
417
+ ### Internal
418
+
419
+ - **`npm run check:pack` checks that the editor moves with the library.** The editor's peer
420
+ dependency on `babylon.quarks` must be the library's own minor line (`~X.Y.0`), and CONTRIBUTING's
421
+ release steps say so: an editor published with an older range would install a babylon.quarks
422
+ without the API it imports.
423
+ - The examples compile with `strict` and are part of `npm run typecheck`, in CI too.
424
+ - The vendored `quarks.core` workspace is marked `private` (it is bundled into babylon.quarks and
425
+ never published on its own), points at this repository, and lists `types` first in its
426
+ `exports`. Its unused Babel and ESLint configs are gone, and so is `composite` in the library's
427
+ `tsconfig.json`, which nothing referenced.
428
+ - The examples dev server accepts only localhost and Cloudflare quick tunnel hosts instead of
429
+ any `Host` header. The repository's Node requirement is 20.19, the version `check:pack`'s
430
+ CommonJS import needs.
431
+
432
+ ## [0.27.0] — 2026-10-23
433
+
434
+ The release that follows the October 2026 audit: every domain (emitters, behaviors, system and
435
+ loader, shaders, trails) was brought to the working rules in `CLAUDE.md`. A 0.27.0 dated
436
+ 2026-10-21 was prepared but never published; its entries are folded in here, so this is the only
437
+ 0.27.0 and the whole list applies when upgrading from 0.26.
438
+
439
+ ### Breaking changes
440
+
441
+ Each item is described in full in the section it points to.
442
+
443
+ - **Exports removed** that nothing in the package read: `VFXBatchSettings.reflectionFaces`,
444
+ `MeshSettings.rotationAxis`, the `ParticleMesh*Material` classes, the shader chunk registry and
445
+ GLSL string exports, `BatchedParticleRenderer`, the `SpriteBatch` / `TrailBatch` exports,
446
+ `ParticleEmitter.extractFromCache`, `ParticleSystemParameters.startLength` and the duplicate
447
+ `quarks.core` type re-exports (Removed); `ApplyForce` loses its runtime fields `magnitudeValue`
448
+ and `memory` (Changed).
449
+ - **Trails stretch their texture by distance** by default; set the texture mode to
450
+ `'distributePerSegment'` to keep the old layout (Changed).
451
+ - **`WidthOverLength` spans the samples the trail holds**, not its `startLength` capacity
452
+ (Changed).
453
+ - **Trails face the camera in view space**; the trail material loses its `lineWidth`,
454
+ `resolution`, `sizeAttenuation`, `world` and `worldViewProjection` uniforms (Changed).
455
+ - **Flipbook frames wrap into the sheet** and the last cell blends into the first (Changed).
456
+ - **`softFarFade` defaults to 1**, so soft particles fade without a fade distance set (Changed).
457
+ - **Soft particles name their depth encoding**: `setDepthTexture(depth, {encoding})` with
458
+ `'linear'` as the default; a hand-built non-linear depth needs `'nonLinear'` (Changed).
459
+ - **`ApplyForce` is a world force**; a local-space effect that relied on the old reading sets
460
+ `space: 'local'` (Changed).
461
+ - **`OrbitOverLife` orbits the emitter about the emitter's normalised axis**; set `space: 'world'`
462
+ for the old orbit (Changed).
463
+ - **`Noise` turns mesh particles by `rotationAmount`** instead of ignoring it (Changed).
464
+ - **`TurbulenceField` is deterministic**: two fields with the same settings and no `seed` swirl
465
+ identically (Changed).
466
+ - **Bursts take the file defaults in code too**: `interval` is 0.1 s when left out, and the
467
+ system holds its own copy of `emissionBursts` (Changed).
468
+ - **A system belongs to one `BatchedRenderer` at a time**, and a disposed renderer refuses
469
+ `addSystem` with an error (Changed).
470
+ - **`ParticleSystem.toJSON()` ids are numbered per `meta`**, so two separate calls both write
471
+ `quarks_geometry_1`; merge outputs through one `meta` or use `serializeEffect` (Changed).
472
+ - **A disposed system stays disposed**: `restart()` no longer brings it back (Changed).
473
+ - **Seeded systems roll differently from 0.26**; a saved seed gives a different, still repeatable,
474
+ effect (Changed).
475
+ - **`serializeEffect` writes the url a texture's image had in its source file** rather than the
476
+ joined url, so a save next to the source loads; a save elsewhere passes `imageUrl` (Changed).
477
+ - **TypeScript:** the fields of `ParticleSystemJSONParameters` other than `version` are optional,
478
+ and `StoredBatchSettings.texture` is `BaseTexture | null` (Changed).
479
+
480
+ ### Added
481
+
482
+ - **Trail texture modes** (`TrailSettings.textureMode`, Unity's Texture Mode): `'stretch'` (the
483
+ default) runs the texture once along the ribbon by distance; `'distributePerSegment'` once with
484
+ each recorded segment an equal share (Niagara's Scaled Uniformly, what trails did before);
485
+ `'tile'` repeats it per world unit, anchored where the particle was born so a sample keeps its U
486
+ while it lives (Niagara's Tiling Distance); `'repeatPerSegment'` once per segment.
487
+ `TrailSettings.textureScale` (Unity's Texture Scale) sets the repeats: per unit, per segment or
488
+ over the ribbon. `uvFromHead` runs any mode from the particle back. The texture's UV channel 1
489
+ follows the same U. Each sample now records its distance along the trail
490
+ (`TrailParticle.historyDistances`, `measureHeadDistance()`).
491
+ - **`TrailSettings.minVertexDistance`** (Unity's Min Vertex Distance): a point is fixed only once
492
+ the particle has moved that far from the last fixed one, the newest sample following the
493
+ particle meanwhile (`TrailParticle.update(replaceHead)`), so a slow or standing particle no
494
+ longer fills its history with points on one spot, and the trail's length is a distance rather
495
+ than a number of steps.
496
+ - **`TrailSettings.lifetime`** (Unity's Time): samples older than that many seconds are dropped
497
+ from the tail (`TrailParticle.historyAges`, `dropOldest()`), within the `startLength` cap.
498
+ - **`ParticleSystem.clearTrails()`** (Unity's `TrailRenderer.Clear`): drops every ribbon and keeps
499
+ the particles, which start new ones where they are. With `pause()`, the move and `play()`, it is
500
+ how an emitter teleports without a ribbon across the jump; the advice to `pause()` alone, given
501
+ below, never cleared the samples already recorded.
502
+ - Normalisers `trailTextureMode`, `trailTextureScale`, `trailMinVertexDistance`, `trailLifetime`,
503
+ beside `trailTessellation` and `trailCurveTension`; the trail settings' JSON writes each only
504
+ when set, and `fromJSON`, `clone()`, `serializeEffect` and `QuarksLoader` carry them all.
505
+ - Demo: Trail Texture Modes and Sampling.
506
+ - **`shaderProgram()` and `shaderVariants()`**: the defines, attributes, uniforms, samplers and
507
+ sources (GLSL and WGSL) of the material a batch builds from its settings, and every distinct
508
+ program the library can compile (Unity's ShaderVariantCollection), for warming the shader cache
509
+ before the first frame. The batches build their materials through `shaderProgram`, so the list
510
+ is the code's own account of what it compiles; `npm run validate:shaders` compiles every variant
511
+ in both languages in CI (the former `validate:wgsl` compiled a hand-written WGSL subset that
512
+ missed, among others, the env atlas, lit meshes with texture effects, and effects with fog, soft
513
+ particles or alpha test). Types `ShaderProgram`, `ShaderSettings`, `ShaderContext`,
514
+ `ShaderVariant`, `ShaderSources`.
515
+ - **`ParticleSystem.clear()`** (Unity's `Clear`): removes the particles alive and nothing else; the
516
+ emission clock, the bursts and the pause stay where they were, and the sub emissions the
517
+ removed particles were driving end with them.
518
+ - **`space` on `ApplyForce`, `ForceOverLife` and `OrbitOverLife`** (quarks.core), the space the
519
+ force or the orbit is authored in (Unity's Space on Force over Lifetime): `'world'` keeps gravity
520
+ down and wind steady however the emitter is turned, and orbits the world origin about a world
521
+ axis; `'local'` turns and scales the force with the emitter, and orbits the emitter about one of
522
+ its own axes. The forces default to `'world'`, the orbit to `'local'`. The behavior converts into
523
+ the space the system simulates in, so a setting means the same in a local and a world-space
524
+ system, and the compiled behavior pass does the same. Written to JSON only when not the default.
525
+ The type is `BehaviorSpace` (`VelocityOverLifeSpace` is now an alias of it); the decomposed
526
+ emitter transform the conversions use is `EmitterFrame`, for behaviors of your own. The editor
527
+ shows Space on Force over Lifetime and Gravity, and Seed on Turbulence. The Force Space demo
528
+ shows a world gravity and a local thrust on turning emitters.
529
+ - **`seed` on `TurbulenceField`** (quarks.core): the field is built from it, 0 by default, so the
530
+ same settings swirl the same way on every run and in every copy; give each system its own seed
531
+ for different fields. Written to JSON only when set. `ParticleSystem.seed` now covers the whole
532
+ effect, since no behavior rolls outside the step any more.
533
+ - **`PhysicsResolver`** (quarks.core) is exported, and `setPhysicsResolver(undefined)` unregisters
534
+ the host's resolver. `ApplyCollision` asks the registered resolver when a particle is tested,
535
+ so one registered after the effect loaded takes effect; a resolver given to the constructor is
536
+ still preferred. `getPhysicsResolver()` is typed as possibly undefined.
537
+ - **`prewarmTime` on `ParticleSystem`**: how much of the cycle `prewarm` simulates before the first
538
+ frame, in seconds (Niagara's Warmup Time), on a looping system like `prewarm` itself. 0, the
539
+ 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
540
+ counted as fired, which is enough for an effect whose particles live shorter than its cycle and
541
+ keeps a long cycle's first frame cheap. Cut to the cycle and to the static
542
+ `ParticleSystem.maxPrewarmSeconds` guard; written to JSON only when set.
543
+ - **`transparent`, `depthTest`, `depthWrite` and `alphaTest` on `ParticleSystem`**, the material
544
+ flags a system is built with, readable and settable afterwards like `blending`. Setting one
545
+ moves the system to a matching batch. `alphaTest` is kept to 0…1; the material flags were
546
+ only constructor parameters and JSON fields before.
547
+ - **`instancingIndices` on `ParticleSystem`**: the mesh particles' index list, settable after
548
+ construction. Mesh systems that share a vertex buffer but not an index list (sub-meshes of one
549
+ buffer) are now batched apart; they used to share a batch and draw with the first system's
550
+ indices.
551
+ - **`serializeEffect(root, options?)` writes a whole effect** as one document `QuarksLoader`
552
+ loads back, the counterpart of `loader.load()`. It writes the node tree (groups, emitters, mesh
553
+ nodes with their geometry, prefabs with `timeScale`), each texture with its sampler state, and a
554
+ format version. The result is plain data, so `JSON.stringify` takes it; a loaded effect saved
555
+ again writes the same file. A texture with no url a file can point at is reported through
556
+ `onWarning`, as are links to nodes it does not write and mesh settings it cannot hold. Options:
557
+ `filter` to leave out helper nodes, `imageUrl` to write image urls relative to where the file is
558
+ saved, `generator`, and `name` for several roots. A mesh node that is not drawn keeps its
559
+ children drawn (`isVisible: false`, which the loader now reads). The document types are exported (`QuarksEffectJSON` and the rest), and the format is
560
+ described in `docs/FORMAT.md`. Before, `ParticleSystem.toJSON` left live textures and materials
561
+ in its meta, and every caller wrote its own envelope.
562
+ - **Effect files carry a format version**, `metadata.quarksFormat` (`QUARKS_FORMAT_VERSION`). The
563
+ loader warns about a file from a newer version. Files without one still load.
564
+ - **`QuarksFormatError`**, thrown by `QuarksLoader.parse` for a document with no `object`.
565
+ - **Loads can wait for textures, report what failed, and be cancelled.** The loader option
566
+ `waitForTextures` makes `load`, `loadFromJSON`, `loadAll` and `loadFromJSONAll` resolve once every
567
+ texture of the effect has loaded or failed, so it does not draw its first frames blank, like an
568
+ Addressables or Unreal streamable handle completing. `onResourceError(kind, url, error)` reports
569
+ each geometry file or image that fails; the effect still loads. A `signal` (`AbortSignal`) in the
570
+ new `QuarksLoadOptions`, and in `QuarksBatchLoadOptions`, cancels a load: it rejects with the
571
+ signal's reason as soon as it is aborted and leaves no node of the effect in the scene, whatever
572
+ `continueOnError` says. The effect document's fetch is cancelled, and a custom
573
+ `QuarksResourceResolver.fetchJson` gets the signal for it. A scene disposed during a load rejects
574
+ it the same way, and a batch whose effect fails stops waiting on the others' textures.
575
+ - **A loaded effect frees what it owns when it is disposed**: the materials made for it, and each
576
+ texture once no other loaded effect, or copy of one, uses it. This holds however the root goes
577
+ (`root.dispose()`, its parent's or the scene's); `QuarksLoader.disposeEffect(root)` says so in
578
+ code. Before, those materials and textures stayed in the scene. A material or texture of a loaded
579
+ effect handed to other code is freed with the effect.
580
+ - **Soft particles and alpha test on trails.** `softParticles` and `alphaTest` now apply to trails
581
+ as to every other render mode, in GLSL and WGSL. Before, a trail accepted them, batched on them
582
+ and drew hard with a fixed 0.01 cutoff.
583
+ - **Soft-particle depth encodings:** `SoftDepthEncoding`, `DepthTextureOptions`,
584
+ `BatchedRenderer.depthEncoding`, and the decode helpers `softDepthParams` and `decodeSoftDepth`
585
+ (see Changed).
586
+ - **`disposeEnvAtlas(cube, scene?)`** drops the env atlases built for a cube.
587
+ - **Trail tessellation** (`TrailSettings.tessellation`, `curveTension`), like Niagara's ribbon
588
+ tessellation and curve tension:
589
+ - Each segment between two recorded samples is drawn as `tessellation + 1` segments along a
590
+ centripetal cardinal spline through the samples: Catmull-Rom at `curveTension` 0 (the
591
+ default), straight at 1. Centripetal spacing keeps the curve from overshooting where samples
592
+ lie unevenly apart.
593
+ - A fast or sharply turning trail, such as a weapon swing that moves far between frames, stays
594
+ round instead of showing its corners. The curve passes through every sample, so the trail
595
+ keeps its shape.
596
+ - Swept trails curve their sides with the points, keeping each side's length, so a blade's trail
597
+ stays as wide as the blade.
598
+ - Widths, colors and the texture's U are eased along the curve. The live tip drawn between
599
+ steps joins it with a straight segment, so the recorded part stays still as the tip moves.
600
+ - `tessellation` is a whole number from 0 (the default: off, no cost) to 16
601
+ (`MAX_TRAIL_TESSELLATION`). JSON writes both settings only when set. Helpers:
602
+ `trailTessellation`, `trailCurveTension`.
603
+ - **`textureSourceUrl(texture)`**: the url a loaded texture's image had in its effect file,
604
+ before the loader joined a base url, which `serializeEffect` now writes by default. It is
605
+ `undefined` for a texture made in code, one whose image a resolver inlined as a `data:` url, or
606
+ one whose `url` changed since it was loaded, which all write their `url` as before.
607
+
608
+ ### Removed (breaking)
609
+
610
+ The public API is now what the docs describe; these exports were left over from the three.js port
611
+ or duplicated something else, and nothing in the package used them.
612
+
613
+ - **`VFXBatchSettings.reflectionFaces`**: six face textures were read off a material
614
+ (`material.reflectionFaces`) and kept in the batch key, but no shader ever sampled them.
615
+ `reflectionTexture` is documented as it works: a cube texture is unfolded into `reflectionAtlas`
616
+ and sampled from there; nothing else is sampled.
617
+ - **`MeshSettings.rotationAxis`** (quarks.core), which was declared but never read, written or
618
+ cloned: a mesh particle's rotation is its start rotation (`startRotationX/Y/Z`, or a
619
+ `RandomQuatGenerator` / `EulerGenerator` start rotation) and `Rotation3DOverLife`. A file that
620
+ wrote it loses nothing, since no runtime ever read it.
621
+ - **`ParticleMeshStandardMaterial`, `ParticleMeshPhysicsMaterial` and
622
+ `BabylonParticleMaterialParameters`.** Mesh particles are drawn by the batch's own material; set
623
+ their look with the system's `material`, `texture` and lighting settings.
624
+ - **The three.js shader chunk registry**: `registerShaderChunks`, `getShaderChunk`,
625
+ `getRegisteredShaderChunks`, and the side effect that ran it on import. The batches never read
626
+ it.
627
+ - **The GLSL shader string exports** (`particleVertShader`, `particleFragShader`,
628
+ `localParticleVertShader`, `trailVertShader` and the rest). The batches build their shaders
629
+ themselves, in GLSL and WGSL.
630
+ - **`BatchedParticleRenderer`**, an alias of `BatchedRenderer`.
631
+ - **The `SpriteBatch` and `TrailBatch` exports.** `BatchedRenderer` makes the batches; `VFXBatch`
632
+ stays exported as the type of `renderer.batches`.
633
+ - **`ParticleEmitter.extractFromCache`**, a three.js serialisation helper.
634
+ - **`ParticleSystemParameters.startLength`**, which nothing read. A trail's length is
635
+ `rendererEmitterSettings.startLength`.
636
+ - **The duplicate type re-exports of `quarks.core`.** The same types still come from
637
+ `export * from 'quarks.core'`.
638
+
639
+ ### Changed
640
+
641
+ - **A trail's texture is stretched by distance** (breaking: an existing effect file has no
642
+ `textureMode`, so its trails now stretch by distance, which shows wherever a trail's speed
643
+ varies along its length): the default texture mode is Unity's Stretch, so a fast stretch of
644
+ the ribbon shows more of the texture than a slow one. Trails used to give each recorded segment
645
+ an equal share whatever its length; set `textureMode: 'distributePerSegment'` to keep that. A
646
+ ribbon that has not moved is laid out per segment, since it has no distance to stretch over.
647
+ - **`WidthOverLength` spans the samples the trail holds** (breaking for a young trail): the curve's
648
+ end is the oldest sample, as Unity's width curve runs over the trail as it is, so a trail cut
649
+ short by `lifetime` or `minVertexDistance` reaches the curve's end and a trail still growing
650
+ shows the whole curve compressed. It used to span the `startLength` capacity, so a growing or
651
+ shortened trail never tapered to the curve's end.
652
+ - **`followLocalOrigin: false` is no longer written to JSON**, as the other trail settings at
653
+ their defaults are not (three.quarks's loader reads a missing flag as off); `startLength` is
654
+ still always written, for loaders that read it without a default. A JSON `sideAxis` that is
655
+ not three finite numbers (numeric strings still count), or has no length, is dropped with a
656
+ `[quarks]` warning instead of making a ribbon of no width (or of NaN).
657
+ - **A particle at the end of its life is dead for its trail too.** The trail path recorded one
658
+ more sample at `age === life`, the step the removal pass already counted the particle dead, and
659
+ the renderer drew a tip for it; the samples, the world-space rewrite, the side axis and the tip
660
+ now all stop at `age < life`.
661
+ - **Flipbook frames wrap into the sheet, and the last cell blends into the first** (breaking for a
662
+ frame outside the sheet). A frame past the last cell used to read outside the texture, a
663
+ negative one differed between GLSL (floored) and WGSL (truncated), and with `blendTiles` the last
664
+ frame cross-faded into a cell that does not exist. The cell is now computed by one shared chunk in
665
+ both languages and by the trail's CPU path (`sheetCell`), frames wrapping as Unity's Texture
666
+ Sheet Animation loops. `blendTiles` compiles nothing on a 1×1 sheet, where the `TILE_BLEND` shader
667
+ read varyings the vertex stage never wrote (undefined behaviour on some GLSL drivers; the common
668
+ content of imported files).
669
+ - **`uTileCount`, `vTileCount`, `softNearFade` and `softFarFade` are normalised**: tile counts are
670
+ whole numbers of at least 1 (a 0 or fractional count divided by zero in the shader), fade
671
+ distances are finite and at least 0. **`softFarFade` defaults to 1** (breaking): with the old
672
+ default of 0 a soft particle was cut off hard at the scene instead of fading, so soft particles
673
+ did nothing unless a fade distance was set; a file that wrote neither fade now fades over one
674
+ unit, Unreal's Depth Fade default.
675
+ - **Mesh particles light both faces and keep their normals under a non-uniform size.** The normal
676
+ is scaled by the inverse of the size, so a stretched mesh stays lit as a stretched mesh rather
677
+ than as its unscaled shape, and a back face (meshes draw without culling) is lit, reflected and
678
+ rimmed as the face the camera sees instead of as the hidden one; a mirrored size (a negative
679
+ component) turns the normal with the winding, so a mirrored mesh lights the same way.
680
+ - **A stretched billboard seen along its velocity no longer vanishes**: the side vector was
681
+ `normalize(0)` (NaN) for a particle moving straight at the camera; it falls back as the beam's
682
+ already did.
683
+ - **A rim fade can discard**: `MESH_RIM_FADE` lowered the alpha after the alpha test had run, so a
684
+ face faded to nothing still passed the test; the test now runs after the rim.
685
+ - **A gradient map without a map tints every render mode the same**: sprites ignored it and meshes
686
+ (which sample a white placeholder) took the ramp's end colour; both now look the ramp up at white.
687
+ - **Linear fog with `fogEnd == fogStart`** divided by zero; it is a cut at that distance.
688
+ - **Erosion and distortion write only non-default values** to JSON (`softness`, `edgeWidth`,
689
+ `edgeColor`, `strength`, a missing `mask`), like every other setting; a gradient map, erosion mask
690
+ or distortion map that is not a url-backed `Texture` warns when it is left out of a file instead
691
+ of vanishing silently.
692
+ - Documented: the fixed discard of fragments under alpha 0.01, fog on multiply and subtract blend
693
+ modes, `blendTiles` needing a sheet and not applying to trails, trails cutting their flipbook
694
+ cell on the CPU.
695
+ - Internal: the unused `worldView` and `worldViewProjection` uniforms and the `USE_COLOR_AS_ALPHA`
696
+ define, which nothing set, are gone from the sprite shaders.
697
+ - **A system belongs to one `BatchedRenderer` at a time.** `addSystem` on a second renderer takes
698
+ the system out of the first (both used to step and draw it), and adding a system again after
699
+ its settings changed moves it to the matching batch instead of leaving it in two. A disposed
700
+ renderer lets its systems go (they can join another renderer) and refuses `addSystem` with an
701
+ error; a setter on a system after its renderer was disposed used to rebuild a batch, mesh and
702
+ material under the dead node. The renderer warns once when it is itself transformed: particles
703
+ are placed by their emitters, so its transform would move them again.
704
+ - **Opaque materials no longer draw a depth pre-pass.** `needDepthPrePass` was set whenever a
705
+ system wrote depth, so an opaque mesh system drew twice; it is now set only for a transparent
706
+ material that writes depth, which is what the pre-pass is for.
707
+ - **Setting `renderMode` or `instancingGeometry` keeps the pause.** Both rebuild the particles
708
+ through `restart()`, which also unpaused a stopped system; the pause state is kept now.
709
+ Switching to `Mesh` at runtime gives the system normals when it has none (the mesh shader reads
710
+ them; a mesh without the attribute was the iOS `GL_INVALID_OPERATION`), and a geometry with a
711
+ different vertex count takes its own index sequence instead of keeping the old geometry's
712
+ indices (a cube given to a quad system drew two triangles). `play()` is documented as resuming
713
+ only; a finished system needs `restart()`.
714
+ - **Bursts are normalised, in code and from a file.** A burst given to the constructor and one
715
+ read from JSON take the same defaults: `interval` 0.1 s when left out (the file default; the
716
+ constructor used to fire the waves all at once), `probability` kept to 0..1, `cycle` at least 1,
717
+ a non-finite `time` 0, a numeric `count` a constant; and the bursts are kept in time order, which
718
+ emission relies on: `restart()` puts bursts edited in place back in order, so an editor that
719
+ appends a burst at 0 after a later one no longer fires it late. The constructor used to keep the
720
+ caller's array; it now holds its own copy, so edit `emissionBursts` rather than the array that
721
+ was passed in. `BurstParameters.count` is documented as rolled per play and read at the wave's
722
+ time as a fraction of the duration, which is what the code has done since 0.27's burst fix.
723
+ - **Setting `ParticleSystem.time` resynchronises the bursts and is normalised.** The bursts
724
+ before the new time count as fired and the ones after are due again (a burst the clock was set
725
+ past used to fire on the next step anyway); NaN or a negative value is 0 (NaN used to hang the
726
+ system, never finishing), a value past the duration wraps on a looping system. The waves of a
727
+ burst in progress are counted with the emission's own arithmetic, so a wave on a rounding edge is
728
+ no longer skipped (this also affected prewarm).
729
+ - **`emissionMultiplier` is kept finite and at or above 0**; a negative, NaN or infinite value
730
+ counts as 0 (a negative value used to be ignored, an infinite one hung the spawner). An infinite
731
+ burst `cycle`, which hung the burst loop, is 1.
732
+ - **`ApplyForce` is a world force (breaking for turned or scaled emitters in local-space systems,
733
+ and for `motionScale`).** It used to push along its direction in whatever space the system
734
+ simulated in, so a local-space system with a turned emitter had its "gravity" turned with it,
735
+ and a world-space system in `'hierarchy'` scaling mode did not scale it with the emitter, unlike
736
+ `ForceOverLife`. Both forces now follow the same rule: a `'world'` force is converted into the
737
+ emitter's axes for a local-space system and multiplied by `motionScale` in a world-space one. A
738
+ file that relied on the old reading sets `space: 'local'`. Converting a world vector into a
739
+ non-uniformly scaled emitter's axes now undoes the rotation before the scale, as the matrix's
740
+ inverse does; `ForceOverLife` used to do it the other way round. `GravityForce` is documented as
741
+ pulling toward a point in the simulation space.
742
+ - **`OrbitOverLife` orbits the emitter, about the emitter's axis (breaking for a non-unit axis,
743
+ and for world-space systems).** The axis is normalised: an axis of length 2 used to turn
744
+ particles twice as far and push them off their circle. In a world-space system the pivot is now
745
+ the emitter's world position and the axis turns with the emitter, where it used to orbit the
746
+ world origin about the world axis; an effect that wants the old orbit sets `space: 'world'`.
747
+ A zero axis orbits nothing. The compiled behavior pass does the same.
748
+ - **`Noise` turns mesh particles by `rotationAmount` (breaking for mesh particles with a
749
+ `rotationAmount`).** A quaternion particle used to be turned by
750
+ a normalised four-noise quaternion, which ignored the amount and the power altogether: a
751
+ `rotationAmount` of 0.001 wobbled as hard as 1. It now turns about an axis the X, Y and Z noise
752
+ choose by W noise × π × amount, the angle a billboard would turn by.
753
+ - **`TurbulenceField` is deterministic and restarts cleanly (breaking: two fields with the same
754
+ settings and no `seed` now swirl identically).** Its noise permutation and time offsets were
755
+ rolled from the global random source when the behavior was constructed, so a clone, a reloaded
756
+ file and a seeded system all swirled differently, and `reset()` did nothing;
757
+ a zero `scale` component divided by zero and filled the velocities with NaN. The field now comes
758
+ from `seed` (see Added), `reset()` rewinds the drift, and a zero scale samples the field at 0
759
+ along that axis (no variation there) instead.
760
+ - **`ForceOverLife` before its first particle.** `update`, `updateAll` and the compiled pass
761
+ disagreed on a behavior that had not seen a particle yet (one applied the force, one skipped
762
+ it); all three now apply it as a world force.
763
+ - **The `Behavior` interface documents its contract**: the order the host calls `initialize`,
764
+ `frameUpdate`, `update`/`updateAll` and `reset` in, what the particle looks like at each, that
765
+ randomness goes through `quarks.core`'s `random()` so a host that wraps its steps in
766
+ `setRandomSource` replays an effect, and that everything else a behavior keeps derives from its
767
+ settings. `BehaviorTypes[...].params` lists every field a behavior writes (`Noise`'s
768
+ `scrollSpeed` and `remap`, `VelocityOverLife`'s `radial` and `orbitalOffset`, `FrameOverLife`'s
769
+ options, `WidthOverLength.absolute`, the new `space` and `seed`), and the serialisation contract
770
+ test fails when a field is written that the table does not list, so an editor cannot miss one.
771
+ - **A moving emitter's motion is spread over the steps of a frame.** The simulation runs in fixed
772
+ 1/60 s steps, several per frame on a slow display, and every step of a frame used the emitter
773
+ where the frame ended: the frame's spawns clumped there, and a trail recorded in world space
774
+ stair-stepped behind a moving emitter. Each step now gets the emitter interpolated between
775
+ where the last simulated step had it and where it is now, position, rotation and scale apart,
776
+ the way Niagara's interpolated spawning does; emission over distance already spawned along the
777
+ path, and the rate's spawns and world-space trail samples now follow it too; a burst due on a
778
+ mid-frame step fires where the emitter was then. A single-step frame, the first frame after
779
+ `play()`, `restart()`, a catch-up or a spell out of view, and a still emitter are unchanged, so
780
+ a copy placed and restarted (as `EffectPool.acquire` does) starts where it was put. A large move
781
+ of a playing system is motion like any other: a frame of two or more steps lays that frame's
782
+ spawns and trail samples along the jump, so teleport with `pause()` or `stop()` first.
783
+ `emitterVelocity` is still the frame's displacement over its time, which is the per-step
784
+ velocity of a linear path, so Inherit Velocity is unaffected. A seeded system with a moving
785
+ emitter plays differently from 0.27 when a frame runs more than one step; the simulation
786
+ snapshot test was re-recorded.
787
+ - **Batches are keyed on what the render mode draws.** The renderer used to split systems on
788
+ every setting, including the ones their mode ignores: two billboards with different mesh rims,
789
+ two meshes with different billboard pivots or size limits, two opaque systems with different
790
+ blend modes, two systems with different soft fade distances and soft particles off, or two
791
+ trails with different instanced geometry each cost a draw call. They now share a batch. The
792
+ settings a mode ignores are at their defaults in the batch's `settings`, so an opaque batch's
793
+ `materialBlendMode` reads `ALPHA_DISABLE` and a billboard batch's `rim` is unset.
794
+ - **Live parameters change a batch's material in place.** The values that only set uniforms (the
795
+ soft fade distances, the alpha cutoff, the pivot, the size limits, the reflection level, the rim
796
+ colour, power and strength, each texture effect's numbers and each texture's tiling, offset and
797
+ scroll) are the batch's live parameters, as a Unity MaterialPropertyBlock's values or an
798
+ Unreal dynamic material instance's parameters. Changing one on a system that is alone in its
799
+ batch writes the material and keeps the batch; it used to make a new batch, mesh and material
800
+ per value, and leave the old one for 120 frames. A change that needs a different shader (a
801
+ cutoff or pivot switched on or off, a rim mode, a texture) still rebuilds the material, and a
802
+ system that shares its batch still moves to a batch with its values. `VFXBatchSettings`
803
+ documents the two groups.
804
+ - **A particle system's JSON leaves out settings at their defaults.** `toJSON`, and so
805
+ `serializeEffect` and the editor's saves, used to write every setting; now a setting is written
806
+ only when it differs from the default, and a missing field reads as the default. The shape, the
807
+ start and emission curves except `startDelay`, `startTileIndex`, the geometry, the material and
808
+ the behaviors are still always written, since loaders up to 0.27 and three.quarks need them, so
809
+ both still load the files. Files from earlier releases load as before. The fields of
810
+ `ParticleSystemJSONParameters` other than `version` are therefore optional (breaking for
811
+ TypeScript code that read them without a check).
812
+ - **Every export has TSDoc**, and `npm run docs:check`, part of `npm run check` and CI, keeps it
813
+ that way. Members that are public only for other files of the package are tagged `@internal`
814
+ and left out of the API docs: the batches' buffers and lifecycle methods, `VFXBatch.addSystem`,
815
+ `applyDepthTexture`, `applyMeshLighting` and the like. They still compile, but are not part of
816
+ the contract.
817
+ - **`StoredBatchSettings.texture` is typed `BaseTexture | null`** instead of `any` (breaking for
818
+ TypeScript code that assigned something else), and `ParticleSystemParameters.material` is
819
+ `unknown`: a Babylon material, or the material record a loaded system carries.
820
+ - **`ParticleSystemParameters.speedFactor` is deprecated** in favour of
821
+ `rendererEmitterSettings.speedFactor`, and the demos use the latter.
822
+ - **`BatchedRenderer.deleteSystem` is typed to return `boolean`**, whether the system was in a
823
+ batch, which it already returned.
824
+ - **`QuarksPrefab.toJSON` writes `timeScale`** when it is not 1, so `toJSON` and `fromJSON` round-trip;
825
+ only `serializeEffect` wrote it before.
826
+ - **The package compiles with TypeScript's `strict` mode.** The published declarations are the
827
+ same as before.
828
+ - **`loadFromJSON` and `loadFromJSONAll` no longer change the document they are given.** They
829
+ resolve urls and inline geometry files on a copy, so one document can be loaded many times, into
830
+ several scenes or with several base urls. Code that read the resolved urls back from its own
831
+ document should call `resolveResources` (which still works in place) or read the loaded nodes.
832
+ - **`EffectPool` frees a copy with `QuarksLoader.disposeEffect`**, and `QuarksUtil.instantiate`
833
+ gives the copy a hold on the source's textures, so disposing the source leaves its copies drawing
834
+ and the last one to go frees the textures.
835
+ - **A geometry file that fails to load is reported through `onResourceError`** (default: the
836
+ same console warning as before). With `waitForTextures`, `onProgress` counts an effect once its
837
+ textures have settled.
838
+ - **Unreadable parts of an effect warn and take their default** instead of throwing a `TypeError`
839
+ or going silent: an unknown shape gives a point emitter, an unknown behavior is skipped with a
840
+ warning, an unknown curve or colour type warns, and a setting the file leaves out (a system's
841
+ `startLife`) takes the constructor's default. A behavior's missing number, vector or speed
842
+ 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.
843
+ - **Systems of one document share geometry and texture entries.** `ParticleSystem.toJSON(meta)`
844
+ writes a geometry or texture once per `meta`, however many systems or slots use it, and numbers
845
+ its ids per document, so the same effect serializes to the same file. Ids are no longer unique across
846
+ calls: two separate `toJSON()` calls both write `quarks_geometry_1`, so merge their outputs
847
+ through one `meta`, or use `serializeEffect`.
848
+ - **Trails face the camera in view space (breaking for custom trail shaders)**, across their
849
+ tangent and the line of sight, like Unity's View alignment and Niagara's ribbons. The width is
850
+ the particle's size in world units, measured across the line of sight. Before, the facing was
851
+ worked out on screen, which spiked across the view when a trail passed the camera and put NaN
852
+ vertices (holes) at repeated points and hairpins. The trail material no longer has the
853
+ `lineWidth`, `resolution`, `sizeAttenuation`, `world` and `worldViewProjection` uniforms. Code
854
+ that set or read them, or a shader built on them, should drop them; to change a trail's width,
855
+ scale `startSize` or add `WidthOverLength`.
856
+ - **A trail system made without `rendererEmitterSettings`** gets `startLength: 30` and
857
+ `followLocalOrigin: false`, as setting `renderMode` to Trail does, instead of throwing on its
858
+ first particle. `startLength` and `followLocalOrigin` are documented.
859
+ - **Box and edge shapes now apply their shape transform.** `BoxEmitter` and `EdgeEmitter` take a
860
+ `transform` (offset, rotation and scale within the system) like the other shapes, and write and
861
+ read it in JSON. Before, a Unity export that moved, turned or scaled a Box, Rectangle or Edge
862
+ shape lost that transform, and its particles started from the system's origin. Effects with such
863
+ a shape now emit where they were authored.
864
+ - **`ApplyForce` no longer has the public runtime fields `magnitudeValue` and `memory`.** The
865
+ magnitude is rolled per particle (see Fixed); read `magnitude` for the setting.
866
+ - **Texture sequencer JSON names its type and writes `position` as `[x, y, z]`.** Files written
867
+ before this release, with no type and an `{x, y, z}` position, still load.
868
+ - **Color, Size and Rotation by Speed read the current speed**, like Unity's modules and
869
+ Niagara's Scale by Speed: the velocity's length times the speed modifier, clamped to
870
+ `speedRange`. Motion `VelocityOverLife` adds does not count, since it moves particles without
871
+ changing their velocity. Before, they read the start speed, so a particle slowed by drag, a limit or a
872
+ collision kept its birth colour and size. Effects that use them on particles whose speed changes
873
+ now look different. An empty `speedRange` is a step at its value instead of NaN. The helper is
874
+ exported as `speedFraction`.
875
+ - **`LimitSpeedOverLife` dampen is clamped to 0..1 and no longer depends on the frame rate.** At
876
+ 60 steps a second a step still removes `dampen / 3` of the excess speed, so babylon.quarks
877
+ effects, which step at 60 Hz, look the same. Other step lengths now remove the share that
878
+ compounds to the same loss per second; before, a long step could reverse the velocity.
879
+ - **A disposed system stays disposed.** `restart()` no longer brings it back, `update()` and
880
+ `emit()` do nothing, and `dispose()` drops its particles, also when called from a
881
+ `particleDied` listener. `isDisposed` (also on `IParticleSystem`) says so.
882
+ - **Node ids in saved effects.** `ParticleEmitter.uuid`, and every link a file writes to a node
883
+ (sub emitter targets, mesh surface meshes, prefab animation targets), is now
884
+ `quarksNodeId(node)`: the uuid the node was loaded under, or a GUID given once to a node made in
885
+ code. Before, nodes made in code were written under their Babylon `uniqueId`, which differs per
886
+ session and can equal a loaded node's id. The editor writes every node this way too. Files
887
+ saved earlier with numeric ids still load; a prefab resolves a numeric id only to a node with no
888
+ file uuid.
889
+ - **A Death sub emitter ignores `inherit: ['lifetime']`**, since its parent has no life left;
890
+ its particles live their own life.
891
+ - **Soft particles: the depth texture now says what it stores (breaking for hand-built depth).**
892
+ `setDepthTexture(depth, {encoding})` takes `'linear'` (Babylon's default depth renderer, the
893
+ default), `'nonLinear'` (the depth buffer value, Unity's `_CameraDepthTexture`) or `'viewZ'`
894
+ (view-space Z, Unreal's SceneDepth). Pass the `DepthRenderer` itself and its encoding and
895
+ packing are read off it: `setDepthTexture(scene.enableDepthRenderer(camera))`. Perspective and
896
+ orthographic cameras, WebGL and WebGPU depth ranges, a reverse depth buffer and an infinite far
897
+ plane (`'nonLinear'` or `'viewZ'`) are handled. Before, the shaders always decoded a depth buffer
898
+ value, which no Babylon depth renderer writes by default. If you fed a non-linear depth by hand,
899
+ pass `{encoding: 'nonLinear'}`. The `projParams` uniform now holds the decode coefficients from
900
+ `softDepthParams`; the `soft_pars_fragment` chunk uses them too.
901
+ - **Soft particles are compiled in only while a depth texture is set**, as Unity turns them off for
902
+ a camera with no depth texture. Without one they draw hard instead of sampling an unbound
903
+ texture; setting or clearing it rebuilds the soft batches.
904
+ - **Batch meshes are not pickable, take no collisions and are not serialized** by Babylon's scene
905
+ serializer. Their geometry is one quad or mesh at the origin, not the particles.
906
+ - **Env atlases are cached per scene**, so a second scene or engine (an editor preview) gets its
907
+ own instead of a texture of the first scene. `getCachedEnvAtlas(cube, scene?)` takes the scene.
908
+ An atlas quarks built is disposed with its cube; `disposeEnvAtlas(cube, scene?)` drops one
909
+ sooner. A system whose atlas is disposed while its cube is kept builds a new one.
910
+ - **`BatchedRenderer.depthTexture` is an accessor**, and setting it calls `setDepthTexture`.
911
+ `VFXBatch.applyDepthTexture` takes a depth binding as well as a texture.
912
+ - **Seeded systems roll differently from 0.26.** A seeded system now rolls its emission rates,
913
+ burst counts and start delay on its own generator when it is created and on every restart, so
914
+ a restart replays them too. A seed saved with 0.26 gives a different, still repeatable, effect,
915
+ and lock-step sessions must not mix 0.26 and 0.27 clients.
916
+ - **`serializeEffect` writes each image's url as its source file had it** (breaking for a save
917
+ that relied on the joined url): a texture loaded from `fx/hit.json` as `tex/a.png` holds the url
918
+ `fx/tex/a.png`, and the saved file used to point at that, so a save next to the source loaded
919
+ `fx/fx/tex/a.png` the next time. The file now gets `tex/a.png` (see `textureSourceUrl`), as
920
+ Unity and Unreal keep asset links relative to the project; `imageUrl` still overrides it for a
921
+ save elsewhere, and a texture made in code still writes its `url`.
922
+
923
+ ### Fixed
924
+
925
+ - **A trail re-emitted with a random `startLength` reallocated its history every time.** The ring
926
+ buffers were replaced whenever the capacity changed; they now only grow
927
+ (`TrailParticle.ensureHistoryCapacity`), and `TrailParticle.length` defaults to 30, the
928
+ documented value, instead of 100.
929
+ - **Trail batches allocated twice the vertices they could draw.** The buffers were sized in
930
+ vertex pairs but compared against vertex counts, so every trail batch held two unused copies
931
+ of itself on the CPU and the GPU; `maxParticles` now counts vertices.
932
+ - **A tessellated trail bunched its points at the samples at `curveTension: 1`** and on the live
933
+ tip's segment: the Hermite basis eased a segment with no tangents while its widths, colours and
934
+ U ran linearly. A straight segment is now walked evenly.
935
+ - **Local-space sub emitter particles jumped to the next parent.** A sub emitter target in local
936
+ space (`worldSpace: false`) draws each particle through the matrix of the emission that spawned
937
+ it; that matrix object was reused for the next emission once the first ended, so particles that
938
+ outlived their emission (a child whose particles live longer than its `duration`) moved to where
939
+ the next parent died. The matrix now stays with the particles born through it, so they keep
940
+ following their parent while it lives (Frame and Birth modes), and the next emission gets its own.
941
+ - **`ChangeEmitDirection` with a random angle (`IntervalValue`) threw on every spawn**
942
+ (`memory.push is not a function`): its scratch memory was in a format the generators no longer
943
+ use. It is a generator memory now, cleared before each spawn, so one sample is rolled per
944
+ particle and the memory does not grow. The behavior and `restart()` reset it.
945
+ - **Arc modes keep their position within the arc.** `Loop` and `PingPong` at a negative speed, and
946
+ `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
947
+ of it; `PingPong` also started at the arc's far end. `Loop` wraps, `PingPong` starts at the arc's
948
+ start and sweeps back and forth, `Burst` places rate-emitted particles at random, and `spread`
949
+ snaps the result afterwards, as Unity's Arc Mode and Spread do.
950
+ - **`restart()` starts a sweeping shape over.** A `Loop`/`PingPong` shape resumed its arc from where
951
+ the system stopped, so a seeded replay or a pooled copy did not spawn where the original did.
952
+ `EmitterShape` gained an optional `reset()`, which the built-in sweeping shapes implement and
953
+ `restart()` calls; a custom shape with sweep state can implement it too.
954
+ - **`PointEmitter` emits at the start speed.** It scaled the speed by a random cube root, so with
955
+ `startSpeed` 5 the particles left at anything from 0.4 to 5, which no other shape and no Unity
956
+ shape does. Directions are uniform over the sphere as before. An effect tuned to the old spread of
957
+ speeds should set `startSpeed` to a random range (`IntervalValue`) to keep it.
958
+ - **Cone `thickness` maps to the radius as Unity's Radius Thickness does.** The ring started at
959
+ `sqrt(1 - thickness)` (0.71 of the radius for a thickness of 0.5) instead of `1 - thickness`, and
960
+ the Unity exporter already wrote the value 1:1. Emission is uniform over the ring's area, and
961
+ the thickness is kept to 0…1 (a value above 1 used to give NaN positions).
962
+ - **Curves and gradients hold their last key past 1.** `ContinuousLinearFunction` (vector and colour
963
+ curves, gradient keys) returned the _first_ key for a time past 1, so a particle that outlived its
964
+ curve snapped back to its start colour on the exact path while the sampled table held the end.
965
+ Both paths agree now.
966
+ - **An empty or unknown sequencer no longer breaks the system.** `TextureSequencer` with no
967
+ locations (before `fromImage`, or `locations: []` in a file) threw on the first particle it moved;
968
+ it leaves the particle where it is. A sequencer of an unknown type in a file used to become a
969
+ silent empty one and throw the same way; it warns and is skipped, like an unknown behavior.
970
+ - **The start colour and a burst's count curve run over the cycle like every other start curve.**
971
+ Both were sampled in seconds, so with a 4 s cycle a start-colour gradient was read past its end
972
+ from 1 s on, and a burst count curve was read at the burst's time in seconds. They now get the
973
+ normalised time of the cycle (0 to 1), as `startLife`, `startSpeed` and `startSize` do and as
974
+ Unity evaluates them. A sub emission reads the time since it began (its parent particle's birth
975
+ or death) over the target's duration, as its other start curves already did; before, a sub
976
+ emitter target's burst count curve was always read at 0, since the target's own clock never
977
+ runs. An effect authored to the old sampling changes: a start-colour gradient or burst count
978
+ curve meant to span the cycle now does.
979
+ - **Distance covered while emission over distance is 0 is not owed later.** With a rate curve
980
+ that rises, or a rate switched on in code or in the editor, the emitter used to spawn a line of
981
+ particles for every metre it had moved while the rate was 0.
982
+ - **A system stopped, paused or disposed from a listener stays stopped for the rest of the
983
+ frame.** `stop()` from a `finished` listener (which `EffectPool` does on release), `dispose()`
984
+ from `particleDied` and `autoDestroy` used to be followed by the frame's remaining steps, which
985
+ emitted into the stopped system and could fire `finished` again.
986
+ - **A fast playback speed runs at full speed.** The 0.1 s frame clamp now applies to the host's
987
+ frame before it is scaled by `playbackSpeed`, and the per-frame step cap grows with the speed, so
988
+ speed 10 advances 10 seconds per second; before, it was held to about 6. A host frame longer than
989
+ 0.1 s is still simulated as 0.1 s at any speed.
990
+ - **Prewarm runs on the fixed step directly**, not through `update()`, so it always simulates one
991
+ whole cycle: before, `playbackSpeed` scaled it too, so at speed 0.5 only half the cycle was
992
+ warmed up and at speed 0 none of it. It costs one step per 1/60 s of the cycle and no more. A
993
+ cycle longer than `ParticleSystem.maxPrewarmSeconds` (10 by default, a performance guard for the
994
+ whole app) is warmed up over its last part only, with the burst waves due before that counted as
995
+ fired, so a long looping effect does not stall its first frame.
996
+ - **Invalid clock settings are normalised.** A negative, NaN or infinite `playbackSpeed` reads as
997
+ 0 (the clock never runs backwards) and a `duration` shorter than one simulation step (1/60 s),
998
+ 0 or NaN reads as one step, so `timeRatio` stays finite; both are accessors now and normalise
999
+ what is set after construction too. The constructor also reads an invalid `cullingMode` as
1000
+ `'always'` and a negative or NaN `maxParticles` as no limit, as `fromJSON` already did. A
1001
+ negative or NaN frame given to `update()` is no time at all.
1002
+ - **A culled `'pauseAndCatchUp'` system owes at most 0.1 s per frame it missed**, the same clamp a
1003
+ visible system's frame gets; before, one stalled frame off screen could owe up to the 2 s cap.
1004
+ - **`QuarksPrefab.play()` after `pause()` resumes the timeline's systems and clips**; before, a
1005
+ system paused in the middle of its track stayed paused until the track ended.
1006
+ - **Setting `MeshSurfaceEmitter.mesh` to nothing, or to a mesh without positions or indices,
1007
+ emits from the origin** as documented, instead of from the previous mesh.
1008
+ - **A stretched billboard system no longer writes its defaults into the
1009
+ `rendererEmitterSettings` object it was given**, as trails already did not.
1010
+ - **A texture whose image failed to load is no longer shared.** The next load of the same image
1011
+ tries it again instead of reusing the broken texture. A texture the engine cannot make at all is
1012
+ reported and left out instead of failing the load.
1013
+ - **A loader read the images of the document it parsed before.** A document with textures but no
1014
+ `images` loaded the previous document's pictures. Each parse now has its own.
1015
+ - **`loadPlugin` did not record the plugin**, so loading it again ran `initialize` again and
1016
+ `unloadPlugin` removed nothing. `unloadPlugin` now removes only the types that plugin registered.
1017
+ - **`QuarksPrefab.timeScale` was not read from files.**
1018
+ - **A trail's live tip folded back over its head** on frames where the last step ran ahead of the
1019
+ display, about every other frame at 120 Hz. The head is now drawn back along its last segment
1020
+ instead. Float noise in the step accumulator no longer adds a tip of no length.
1021
+ - **A dead trail particle's tip kept moving**, drawing a spike that grew while the trail shrank.
1022
+ The trail now ends at its last sample.
1023
+ - **The live tip ignored `WidthOverLength`**, so a trail narrowed by it flared at the tip on
1024
+ frames between steps.
1025
+ - **`WidthOverLength` read its curve past the end** for a fractional or zero `startLength`.
1026
+ - **Camera-dependent uniforms reached the GPU one draw late.** The eye position (rim, env
1027
+ reflection), the soft-particle depth decode and the `'scene'` light were
1028
+ written after the material had uploaded its uniforms, so each draw used the previous bind's
1029
+ values: a frame late with one camera, and the other camera's with `activeCameras`. They now reach
1030
+ the effect of the draw they are for.
1031
+ - **Soft particles faded against the wrong depth**, both from the decode (see Changed) and because
1032
+ the particle's own depth had the wrong sign for a left-handed camera, Babylon's default.
1033
+ - **GLSL and WGSL applied fog and the soft fade in different orders**, so WebGPU kept a fog-coloured
1034
+ fringe where a fogged soft particle met geometry. Both now fade last, after fog.
1035
+ - **A mesh batch rebuilt for its env atlas lost the soft-particle depth texture.**
1036
+ - **A non-looping system whose rate curve starts at 0 never emitted.** It reported nothing
1037
+ pending on its first step and finished at once. A rate that rises later in the cycle now counts
1038
+ as pending.
1039
+ - **A looping system reported pending emission after `endEmit()`.**
1040
+ - **A disposed sub emitter target kept taking particles without limit**, since nothing updated it
1041
+ any more. Sub emitters now skip disposed targets.
1042
+ - **Death sub emissions followed a newborn.** The dying particle's object is reused for the next
1043
+ particle, and the emission followed that one back to the emitter, inheriting its colour and size.
1044
+ A Death emission now stays where its parent died and inherits what the parent died with. Birth
1045
+ and Frame emissions stop following when their parent dies (`Particle.serial`). A restart or
1046
+ `stop()` drops the emissions still running from the last play.
1047
+ - **`useVelocityAsBasis` stretched the sub emitter by the parent's speed**, and gave a NaN matrix
1048
+ for a parent moving along -Z or faster than 1 along +Z. The basis is now orthonormal.
1049
+ - **The fused `VelocityOverLife` ignored `radial` and `orbitalOffset`.** The fused pass, which runs
1050
+ by default, now matches the per-particle path.
1051
+ - **Sub emitter and mesh surface links broke when a loaded effect was saved again.** The links were
1052
+ written with Babylon's `uniqueId` and the nodes with the uuid they were loaded under.
1053
+ - **`clone()` shared state with the original**, so editing one changed the other:
1054
+ - `ChangeEmitDirection`, the `Euler` rotation generator and `Vector3Function` (and with it
1055
+ `SizeOverLife` with separate axes) reused their child generators.
1056
+ - A system's emission bursts shared their `count` generator, and `startTileIndex` was shared.
1057
+ - `emissionMultiplier` and a fixed `beamEnd` point were not copied. A `beamEnd` given as a
1058
+ Babylon `Vector3` is now copied by value too. A `beamEnd` node is still shared, since the
1059
+ clone follows the same node.
1060
+ - **`ApplyForce` with a random magnitude threw** when it was created or loaded, since its
1061
+ generator memory was the wrong shape for `IntervalValue`. The magnitude is now rolled per
1062
+ particle at emission, as Unity's Force over Lifetime does with Random Between Two Constants, on
1063
+ the system's seeded random source.
1064
+ - **`ApplySequences` lost its sequences in JSON.** A texture sequencer wrote no type, so loading
1065
+ the file could not tell which sequencer to build.
1066
+ - **Node ESM, webpack 5 and SSR could not import the package.** The ESM and CJS builds imported
1067
+ `@babylonjs/core` deep paths without `.js`, which only lenient bundlers such as Vite resolve.
1068
+ Every deep import now ends in `.js`. The `exports` map now gives `require` its own declaration
1069
+ file (`dist/types/index.d.cts`) and lists `./package.json`, so TypeScript with
1070
+ `node16`/`nodenext` resolution types both module kinds. `npm run check:pack` now installs the
1071
+ packed tarballs into a scratch project and imports them with Node ESM, Node CJS and TypeScript
1072
+ `nodenext`.
1073
+ - **`restart()` broke random emission.** It emptied the system's generator memory without
1074
+ rolling `emissionOverTime` again, so an `IntervalValue` rate became NaN and the system emitted
1075
+ nothing. Every pooled effect with a random emission rate was empty from its second play.
1076
+ - **`restart()` kept the emission-over-distance history.** A pooled effect placed somewhere new
1077
+ and restarted spawned a line of particles along the jump from where it last played.
1078
+ - **Disposing an emitter node left its system running.** `root.dispose()` on an effect tree now
1079
+ disposes every `ParticleSystem` in it and removes it from its renderer. A system that disposes
1080
+ itself (`dispose()`, or `autoDestroy` when it finishes) still leaves the systems of its child
1081
+ emitters playing until they finish.
1082
+ - **`depthTest: false` had no effect.** It was stored, batched and saved, but never reached the
1083
+ material. It now draws particles over all geometry (Unity's `ZTest Always`) in every render mode.
1084
+ - **A mesh batch leaked a material when its env atlas finished loading.** The rebuild now disposes
1085
+ the material it replaces, and does nothing when the batch was disposed before the atlas loaded.
1086
+ - **`QuarksLoader` joined a relative base url twice.** `load('fx/hit.json')` and
1087
+ `loadFromJSON(json, 'fx/')` loaded textures from `fx/fx/...`, and from `fx/fx/fx/...` when the
1088
+ same document was loaded again.
1089
+ - **Trails picked the wrong sprite-sheet cell.** The cell column was counted with `vTileCount`
1090
+ and fractional frames were not rounded down, so a sheet with more columns than rows, or a
1091
+ `FrameOverLife` curve, showed the wrong cell or a slice across two. Trails now pick the same cell
1092
+ as billboards.
1093
+ - **`OrbitOverLife.clone()` dropped the axis**, so a cloned system orbited about Y.
1094
+ - **`loadFromJSON` after `resolveResources()` joined the base url twice.** The copy
1095
+ `loadFromJSON` takes of the document lost the mark that its image urls were already resolved,
1096
+ so a document prepared with `resolveResources(json, 'fx/')` and then loaded with the same base
1097
+ got `fx/fx/tex/a.png`. The copy keeps the mark.
1098
+
1099
+ ### Tests
1100
+
1101
+ - Trails: the texture modes and scale, the live tip's distance, minimum vertex distance, lifetime
1102
+ (alive and dead), the end-of-life boundary, `clearTrails()` and the teleport, the history
1103
+ buffers' reuse, the batch's vertex sizing, tessellation at tension 1, the trail `tint`, the
1104
+ trail settings through `serializeEffect` and `QuarksLoader`, the `sideAxis` JSON validation,
1105
+ and `WidthOverLength` on fractional and zero lengths.
1106
+ - **Coverage is enforced.** The package thresholds were never checked, because `npm test` ran
1107
+ without coverage, and the root run ignored each package's file list. `npm run test:coverage` (in
1108
+ CI and `npm run check`) now counts every source file and fails under a floor per package, set in
1109
+ the root `jest.config.json` one point under today's coverage and raised as tests are added. The
1110
+ floors show where tests are thinnest: the editor's core sits at 14%, quarks.core at 55%.
1111
+ - **Serialisation contract.** Every behavior, emitter shape, value and colour generator, and every
1112
+ `ParticleSystem` setting in each render mode, must survive `toJSON` → `fromJSON`, `clone()` and
1113
+ `QuarksLoader` unchanged, and a clone must share no mutable state with its original. A new
1114
+ behavior or shape type fails the test until it is added to it.
1115
+ - **Frame-rate independence.** Every quarks.core behavior that changes a particle over time reaches
1116
+ the same state after a second at 30, 60, 120 and 144 Hz, through both `update` and `updateAll`.
1117
+ A seeded `ParticleSystem` gives the same particles, finishes within a frame of the same time and
1118
+ pauses the same way at 20 to 240 Hz.
1119
+ - **Seeded simulation snapshot.** A seeded system in each render mode (billboard in world and
1120
+ local space, stretched, mesh, trail, beam), with a moving emitter, bursts and behaviors, is
1121
+ stepped on uneven frames and its particles (trail history included), bounds and JSON are compared
1122
+ with a stored snapshot; loaded back with `fromJSON`, it must write the same JSON and play the same.
1123
+ It was recorded before `ParticleSystem.ts` was split and pins the simulation while the rest of the
1124
+ split follows.
1125
+ - Loader: a document `resolveResources()` prepared loads with the same base once; a save next to
1126
+ the source writes the source url, through a custom resolver too.
1127
+
1128
+ ### Docs
1129
+
1130
+ - **README** describes the WebGPU path as it is: native WGSL shaders, nothing transpiled or
1131
+ fetched at run time. The feature list names beams, the texture effects and the scene-integration
1132
+ options; the Unity-style snippet imports from `babylon.quarks` (the npm package `quarks.core` is
1133
+ the upstream three.quarks core, not this monorepo's fork of it); the demo count is current. `ROADMAP.md` and
1134
+ `docs/SHURIKEN_PARITY.md` match what shipped in 0.24 to 0.27.
1135
+ - **Three new demos** in the examples app: pooled one-shot hits (`EffectPool`,
1136
+ `QuarksUtil.getDuration`, `emissionMultiplier`), renderer options side by side (`sortMode`,
1137
+ `alignToDirection`, `minParticleSize` / `maxParticleSize`, `scalingMode`), and surface trails with
1138
+ a mesh second UV set (`MeshSurfaceEmitter`, the trail flags, `tessellation`, `curveTension`,
1139
+ `instancingUVs2` with a map on UV channel 1 through a gradient map).
1140
+
1141
+ ### Internal
1142
+
1143
+ - **`npm run validate:wgsl` runs in CI** (job `wgsl`), on Chromium's software WebGPU adapter, so a
1144
+ WGSL variant that stops compiling fails the pull request. The script compiles the variants in
1145
+ batches instead of one round trip each, which brings a run from ten minutes to about fifteen
1146
+ seconds; it reports a missing WebGPU adapter or an empty run in plain words instead of a stack
1147
+ trace, fails when the device is lost mid-run (a lost device compiles nothing and reports no
1148
+ errors), and gives up after five minutes instead of holding the job.
1149
+ - **The demo smoke in CI opened the wrong pages.** The examples are built under `/<repository>/`
1150
+ on GitHub Actions, and the preview server answers a request outside that path with a 404 page
1151
+ that loads without an error, so `check:demos` had been passing on 404 pages there. Both
1152
+ `check:demos` and `validate:wgsl` now ask the server for its base path
1153
+ (`scripts/examplesBase.mjs`), and the smoke fails a page the viewer never comes up on.
1154
+ - **`batchKey.ts`** holds the batch key: `storedBatchSettings` reduces a system's settings to the
1155
+ canonical form a batch stores, `shaderKeyEqual` and `liveParamsEqual` compare the two groups
1156
+ and `batchSettingsEqual` both. `BatchedRenderer.updateSystem` writes live parameters through
1157
+ `VFXBatch.applyLiveSettings`. `VFXBatch` reads the texture layouts off its settings on every
1158
+ bind, and each batch writes its live uniforms from one place (`writeLiveUniforms`), on build and
1159
+ on a change.
1160
+ - **The stretched-billboard shaders no longer declare a `speedFactor` uniform.** The speed factor
1161
+ is baked into the velocity attribute on the CPU, so the uniform, which was set from
1162
+ `softNearFade`, was never read. The per-frame sync and its tests are gone with it.
1163
+ - **`integrate.ts`** (`src/system/`) holds the passes of a simulation step after emission: the
1164
+ snapshot the renderer extrapolates from, the behavior steps, the sprite path's one pass over
1165
+ the store (newborn collapse, motion, ageing, bounds and removal) and the trail path's passes
1166
+ (collapse, motion, history, removal), as plain functions over the store and the particle array.
1167
+ `ParticleSystem.stepOnce` calls them in order and is 60 lines; the file is now about 1700
1168
+ lines, from 3641 before phase 3c. The hot loops are the same code and run the same within
1169
+ benchmark noise (a local 88k-particle benchmark: 11.5, 11.6 and 10.4 ms before against 11.6,
1170
+ 9.2 and 10.1 ms after, in interleaved runs).
1171
+ - **`EmissionController` and `ParticleSpawner`** (`src/system/`) take emission (rates, bursts and
1172
+ their waves, emission along the emitter's path, the system-level generator memory) and the
1173
+ making of newborn particles (pool growth, start values, placement, behavior initialisation) out
1174
+ of `ParticleSystem`, which goes from about 2460 to 1950 lines. The hot path is unchanged within
1175
+ measurement noise (a 20-system, 88k-particle Node benchmark: 7.5 ms before, 7.9 ms after, a
1176
+ spawn-heavy one 10.3 and 10.3 ms, with a run-to-run spread of about 1 ms).
1177
+ - **`SimulationClock`** (`src/system/SimulationClock.ts`) holds the fixed-step accumulator, the
1178
+ rounding rule that keeps the residual near zero, the start delay and the catch-up debt of a
1179
+ culled system, as pure numbers with their own tests. `ParticleSystem.update` is a client of it.
1180
+ - **One table of `ParticleSystem` settings** (`src/system/settingsSchema.ts`) gives each plain and
1181
+ generator setting its default, its JSON form and how it is copied. `toJSON`, `fromJSON` and
1182
+ `clone` are built from it, and a test fails when a constructor parameter is in neither the table
1183
+ nor the code that handles the meta, the shape or the behaviors.
1184
+ - **`ParticleSystem.ts` is being split into modules under `src/system/`.** This first part moves
1185
+ out the parameter and JSON types (`types.ts`), reading and writing JSON
1186
+ (`serialization.ts`), culling bounds (`BoundsTracker.ts`), event listeners
1187
+ (`EventDispatcher.ts`), the behavior fusion plan (`BehaviorPlan.ts`), reading draw settings from a
1188
+ material (`materialResolve.ts`), default and fallback geometry (`geometry.ts`) and the setting
1189
+ normalisers (`normalize.ts`). The file goes from 3641 to about 2500 lines. The public API and the
1190
+ simulation are unchanged; the types are still exported from the package root. Still to come: one
1191
+ table of settings for the constructor, setters, JSON and `clone`, the simulation clock, emission
1192
+ and spawning, and the integration passes.
1193
+
7
1194
  ## [0.26.0] — 2026-10-21
8
1195
 
9
1196
  ### Changed (breaking)
@@ -104,13 +1291,13 @@ renames below.
104
1291
  unchanged and stays in the fused pass.
105
1292
  - Texture effects on sprites, meshes and trails, GLSL and WGSL, each saved with the material in the
106
1293
  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.
1294
+ - `ParticleSystem.uvScroll` `[u, v]`: scrolls the map in UV units per second on the renderer's clock.
1295
+ - `ParticleSystem.gradientMap`: recolours the map through a ramp texture picked by its red
1296
+ channel; the particle colour still tints it.
1297
+ - `ParticleSystem.erosion` (`ParticleErosion`: mask, softness, edgeWidth, edgeColor, maskScroll):
1298
+ alpha erosion. The particle's alpha decides how much of the shape is left instead of how
1299
+ transparent it is, so a `ColorOverLife` alpha curve dissolves it through a noise mask or wipes
1300
+ it through a gradient one, with an optional glowing edge.
114
1301
  - `RenderMode.Beam` (6) with `ParticleSystem.beamEnd` (a point or anything with a world position,
115
1302
  such as a `TransformNode`, read every frame; runtime only): a quad laid from each particle to
116
1303
  the end and turned about its length to face the camera, U along it, the size as its width. With
@@ -353,7 +1540,6 @@ renames below.
353
1540
  optional per-axis thickness, moving particles along +Z; a box with no depth is a flat rectangle.
354
1541
  `EdgeEmitter` (`edge`) emits from a line along X, moving particles along +Y. Both match the
355
1542
  Unity shapes of the same name.
356
-
357
1543
  - Trail settings `uvFromHead`, `colorFromParticle`, `dieWithParticle` and `recordWorldSpace`
358
1544
  (all optional, off by default) for trails that behave like Unity's: the texture runs from the
359
1545
  particle back to the tail, the whole trail takes the particle's current color, the trail