lecodes-cli 0.17.0 → 0.17.1

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.
@@ -1,546 +1,625 @@
1
- // GPU particle system, as a Node. Draws with the default point-sprite material (Material.particles)
2
- // unless a custom one is passed; properties drive the native emitter.
3
- //
4
- // All emitter/curve config crosses the bridge as ONE Float32Array of [tag, payloadLen, ...payload]
5
- // records (_creator.setParticleSystemConfig) — the constructor batches every option into a single
6
- // call, live setters send a one-record buffer. The tag values mirror PsTag in
7
- // creator-gl/src/particles.h (guarded by tests/particles-tags.test.ts).
8
- //
9
- // Curves over a particle's lifetime are built with curve()/colorCurve() chains — see the builders
10
- // below. A builder's whole state is the flat `_data` array (the exact record payload), so the
11
- // chisel compiler can constant-fold literal chains into the buffer at compile time.
12
-
13
- import { Color, type ColorInput } from "../core/color"
14
- import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
15
- import { Material, _sheetColsRows, type ParticlesMaterialOptions } from "./Material"
16
- import { Node } from "./Node"
17
-
18
- type Range<T> = T | { min: T, max: T }
19
-
20
- // --- config record tags (keep in sync with PsTag in creator-gl/src/particles.h) -----------------
21
-
22
- const TAG_SHAPE = 1
23
- const TAG_LIFETIME = 2
24
- const TAG_RATE = 3
25
- const TAG_VELOCITY = 4
26
- const TAG_VELOCITY_RND = 5
27
- const TAG_ACCELERATION = 6
28
- const TAG_DRAG = 7
29
- const TAG_NOISE = 8
30
- const TAG_SEED = 9
31
- const TAG_PARAM_CURVE = 10
32
- const TAG_COLOR_CURVE = 11
33
- const TAG_SPACE = 12
34
- const TAG_INHERIT_VELOCITY = 13
35
- const TAG_RATE_DISTANCE = 14
36
- const TAG_RENDER_MODE = 15
37
-
38
- /** @internal — exported for the tag-sync test only. */
39
- export const _particleTags = {
40
- SHAPE: TAG_SHAPE, LIFETIME: TAG_LIFETIME, RATE: TAG_RATE, VELOCITY: TAG_VELOCITY,
41
- VELOCITY_RND: TAG_VELOCITY_RND, ACCELERATION: TAG_ACCELERATION, DRAG: TAG_DRAG,
42
- NOISE: TAG_NOISE, SEED: TAG_SEED, PARAM_CURVE: TAG_PARAM_CURVE, COLOR_CURVE: TAG_COLOR_CURVE,
43
- SPACE: TAG_SPACE, INHERIT_VELOCITY: TAG_INHERIT_VELOCITY, RATE_DISTANCE: TAG_RATE_DISTANCE,
44
- RENDER_MODE: TAG_RENDER_MODE,
45
- }
46
-
47
- // --- curve builders -----------------------------------------------------------------------------
48
-
49
- // A value curve reads as a journey over the particle's life:
50
- // curve(0.3).from(0).via(0.15, 1).via(0.6, 1).to(0) // fade in, hold, fade out — or:
51
- // curve(0.3).fade(0.15, 0.4)
52
- // The curve multiplies the base value (or adds to it with mode 'add'); the base and any stop can be
53
- // a { min, max } range, randomized per particle. With no `.from` the curve starts at the identity
54
- // (1 for multiply, 0 for add); with no `.to` it holds its last value to end of life.
55
- //
56
- // chisel constant-folds a literal chain into its `_data` buffer at compile time and validates it
57
- // (bad stop order, >8 stops, unparsable color → a build diagnostic): chisel-core/src/curve.rs mirrors
58
- // these method bodies op-for-op, against a verbatim copy of them in chisel's fixtures/curve-sdk/. Keep
59
- // the two in step — a change here means a change there (chisel's scripts/curve-exact.mjs compares the
60
- // fused payload against the real builder, buffer for buffer).
61
-
62
- const lohi = (v: Range<number>): [number, number] =>
63
- typeof v === "number" ? [ v, v ] : [ v.min, v.max ]
64
-
65
- export class CurveBuilder {
66
- private _kind: 0 | 1
67
- private _lo: number
68
- private _hi: number
69
- private _stops: number[] = [] // flat (t, lo, hi)
70
-
71
- constructor(base: Range<number>, mode: "multiply" | "add") {
72
- this._kind = mode === "add" ? 1 : 0
73
- ;[ this._lo, this._hi ] = lohi(base)
74
- }
75
-
76
- /** Curve value at birth (t = 0). Must come before any other stop. */
77
- from(v: Range<number>): this {
78
- if (this._stops.length > 0) throw new Error("curve: .from() must come first")
79
- return this.via(0, v)
80
- }
81
-
82
- /** Curve value at `t` (0..1 of the particle's life). Stops must be added in ascending t order. */
83
- via(t: number, v: Range<number>): this {
84
- const [ lo, hi ] = lohi(v)
85
- this._stops.push(t, lo, hi)
86
- return this
87
- }
88
-
89
- /** Curve value at death (t = 1). */
90
- to(v: Range<number>): this {
91
- return this.via(1, v)
92
- }
93
-
94
- /** The classic opacity envelope: rise over the first `fadeIn`, fall over the last `fadeOut`. */
95
- fade(fadeIn = 0.15, fadeOut = fadeIn): this {
96
- const up = Math.min(Math.max(fadeIn, 0), 1)
97
- const down = Math.min(Math.max(fadeOut, 0), 1 - up)
98
- if (up > 0) this.via(0, 0).via(up, 1)
99
- else this.via(0, 1)
100
- if (down > 0) {
101
- if (1 - down > up) this.via(1 - down, 1)
102
- this.via(1, 0)
103
- }
104
- return this
105
- }
106
-
107
- /** The PARAM_CURVE record payload (sans slot): [kind, baseLo, baseHi, n, (t, lo, hi)×n]. */
108
- get _data(): Float32Array {
109
- const stops = this._stops
110
- const prepend = stops.length > 0 && stops[0] > 0
111
- const n = stops.length / 3 + (prepend ? 1 : 0)
112
- const out = new Float32Array(4 + n * 3)
113
- out[0] = this._kind
114
- out[1] = this._lo
115
- out[2] = this._hi
116
- out[3] = n
117
- let o = 4
118
- if (prepend) {
119
- const identity = this._kind === 1 ? 0 : 1
120
- out[o + 1] = identity
121
- out[o + 2] = identity
122
- o += 3
123
- }
124
- out.set(stops, o)
125
- return out
126
- }
127
- }
128
-
129
- export class ColorCurveBuilder {
130
- private _lo: [number, number, number, number]
131
- private _hi: [number, number, number, number]
132
- private _stops: number[] = [] // flat (t, lo rgba, hi rgba)
133
-
134
- constructor(base: Range<ColorInput>) {
135
- if (isColorRange(base)) {
136
- this._lo = Color.toRgba01(base.min)
137
- this._hi = Color.toRgba01(base.max)
138
- } else {
139
- this._lo = Color.toRgba01(base)
140
- this._hi = this._lo
141
- }
142
- }
143
-
144
- /** Curve color at birth (t = 0). Must come first. The curve multiplies the base color. */
145
- from(c: Range<ColorInput>): this {
146
- if (this._stops.length > 0) throw new Error("colorCurve: .from() must come first")
147
- return this.via(0, c)
148
- }
149
-
150
- /** Curve color at `t` (0..1 of the particle's life), in ascending t order. */
151
- via(t: number, c: Range<ColorInput>): this {
152
- let lo: number[], hi: number[]
153
- if (isColorRange(c)) {
154
- lo = Color.toRgba01(c.min)
155
- hi = Color.toRgba01(c.max)
156
- } else {
157
- lo = hi = Color.toRgba01(c)
158
- }
159
- this._stops.push(t, ...lo, ...hi)
160
- return this
161
- }
162
-
163
- /** Curve color at death (t = 1). */
164
- to(c: Range<ColorInput>): this {
165
- return this.via(1, c)
166
- }
167
-
168
- /** The COLOR_CURVE record payload: [baseLo rgba, baseHi rgba, n, (t, lo rgba, hi rgba)×n]. */
169
- get _data(): Float32Array {
170
- const stops = this._stops
171
- const prepend = stops.length > 0 && stops[0] > 0
172
- const n = stops.length / 9 + (prepend ? 1 : 0)
173
- const out = new Float32Array(9 + n * 9)
174
- out.set(this._lo, 0)
175
- out.set(this._hi, 4)
176
- out[8] = n
177
- let o = 9
178
- if (prepend) {
179
- for (let i = 1; i < 9; i++) out[o + i] = 1 // identity multiplier: white, t = 0
180
- o += 9
181
- }
182
- out.set(stops, o)
183
- return out
184
- }
185
- }
186
-
187
- /** A value curve over each particle's lifetime. Default mode `multiply` (the curve scales the
188
- * base); `add` offsets it instead. */
189
- export const curve = (base: Range<number> = 1, mode: "multiply" | "add" = "multiply"): CurveBuilder =>
190
- new CurveBuilder(base, mode)
191
-
192
- /** A color curve over each particle's lifetime — always multiplies the base color. */
193
- export const colorCurve = (base: Range<ColorInput> = "#ffffff"): ColorCurveBuilder =>
194
- new ColorCurveBuilder(base)
195
-
196
- // --- option types -------------------------------------------------------------------------------
197
-
198
- export type ParticleValue = Range<number> | CurveBuilder | { _data: Float32Array }
199
- export type ParticleColor = ColorInput | { min: ColorInput, max: ColorInput } | ColorCurveBuilder | { _data: Float32Array }
200
-
201
- type VelocityValue = {
202
- speed?: Range<number>
203
- /** Cone half-angle in radians — jitter the launch direction uniformly within it. */
204
- spread?: number
205
- /** Asymmetric jitter: two angles (u, v — the x/y components) in radians. */
206
- randomizeAngle?: { min: Vec3Like, max: Vec3Like }
207
- } & ({ from: Vec3Like } | { to: Vec3Like } | { dir: Vec3Like } | {})
208
-
209
- type Shape =
210
- | { type: "point", v: Vec3Like }
211
- | { type: "box", min: Vec3Like, max: Vec3Like }
212
- | { type: "circle", center: Vec3Like, radius: number }
213
- type Noise = { strength?: number, frequency?: number, speed?: number }
214
-
215
- export type ParticlesOptions = ParticlesMaterialOptions & {
216
- /** Custom draw material. Unset = the default point-sprite material, configured by the
217
- * `ParticlesMaterialOptions` sugar (map/sheet/emissive/blend/soft) — which is ignored
218
- * when an explicit material is passed. */
219
- material?: Material
220
- /** Particle pool capacity (default 1000). */
221
- maxParticles?: number
222
- /** Particles emitted per second. */
223
- rate?: number
224
- /** Simulation space. `'local'` (default) — particles ride the node's transform; `'world'` —
225
- * each particle stays where it was born as the emitter moves on (trails: drift smoke, mud,
226
- * wake). Switching at runtime resets live particles. */
227
- space?: "local" | "world"
228
- /** Fraction (usually 0..1) of the emitter's own velocity added to each particle at spawn —
229
- * smoke "thrown" off a moving car. Takes effect with `space: 'world'`. */
230
- inheritVelocity?: number
231
- /** Particles emitted per world unit the emitter MOVES, on top of `rate`, with spawn points
232
- * spread evenly along the path — trail density independent of speed, no per-frame clumps.
233
- * Meant for `space: 'world'`. */
234
- rateOverDistance?: number
235
- shape?: Shape
236
- startVelocity?: Vec3Like | VelocityValue
237
- /** Downward acceleration, world units/s² — positive pulls particles DOWN (sugar for
238
- * `acceleration: [0, -g, 0]`). */
239
- gravity?: number
240
- /** Constant acceleration vector (gravity, wind, …). Wins over `gravity` when both are set. */
241
- acceleration?: Vec3Like
242
- drag?: number
243
- lifetime?: Range<number>
244
- color?: ParticleColor
245
- /** World-unit diameter (alias of custom[0]). Default 1. */
246
- size?: ParticleValue
247
- /** Radians (alias of custom[1]). */
248
- rotation?: ParticleValue
249
- /** Per-particle opacity 0..1 (alias of custom[2]). Default 1. */
250
- opacity?: ParticleValue
251
- /** Flipbook frame index (alias of custom[3]). With `sheet` set and no explicit frame, the
252
- * default is one full sheet cycle over each particle's lifetime, starting at `startFrame`. */
253
- frame?: ParticleValue
254
- /** Where the default flipbook cycle begins: `'random'` (default — desynchronized loops like
255
- * fire/smoke) or a frame index (`0` plays the sheet in order — explosions, one-shot bursts).
256
- * Only shapes the default; an explicit `frame` wins. */
257
- startFrame?: number | "random"
258
- noise?: Noise | null
259
- /** Fixed random seed for deterministic tests. */
260
- seed?: number
261
- }
262
-
263
- // --- record assembly ----------------------------------------------------------------------------
264
-
265
- const isColorRange = (v: unknown): v is { min: ColorInput, max: ColorInput } =>
266
- typeof v === "object" && v !== null && !Array.isArray(v) && "min" in v
267
-
268
- const hasData = (v: unknown): v is { _data: Float32Array } =>
269
- typeof v === "object" && v !== null && "_data" in v
270
-
271
- const pushParam = (out: number[], slot: number, value: ParticleValue): void => {
272
- if (typeof value === "number") {
273
- out.push(TAG_PARAM_CURVE, 5, slot, 0, value, value, 0)
274
- } else if (hasData(value)) {
275
- const d = value._data
276
- out.push(TAG_PARAM_CURVE, d.length + 1, slot)
277
- for (let i = 0; i < d.length; i++) out.push(d[i])
278
- } else {
279
- out.push(TAG_PARAM_CURVE, 5, slot, 0, value.min, value.max, 0)
280
- }
281
- }
282
-
283
- const pushColor = (out: number[], value: ParticleColor): void => {
284
- let d: Float32Array
285
- if (hasData(value)) {
286
- d = value._data
287
- } else if (isColorRange(value)) {
288
- d = new ColorCurveBuilder(value)._data
289
- } else {
290
- d = new ColorCurveBuilder(value as ColorInput)._data
291
- }
292
- out.push(TAG_COLOR_CURVE, d.length)
293
- for (let i = 0; i < d.length; i++) out.push(d[i])
294
- }
295
-
296
- const pushShape = (out: number[], shape: Shape): void => {
297
- if (shape.type === "point") out.push(TAG_SHAPE, 4, 0, cx(shape.v), cy(shape.v), cz(shape.v))
298
- else if (shape.type === "box") out.push(TAG_SHAPE, 7, 1, cx(shape.min), cy(shape.min), cz(shape.min), cx(shape.max), cy(shape.max), cz(shape.max))
299
- else out.push(TAG_SHAPE, 5, 2, cx(shape.center), cy(shape.center), cz(shape.center), shape.radius)
300
- }
301
-
302
- const pushVelocity = (out: number[], val: Vec3Like | VelocityValue): void => {
303
- if (Array.isArray(val) || val instanceof Float32Array || val instanceof Vec3) {
304
- // a plain vector: launch along it, its length is the speed (native derives it)
305
- out.push(TAG_VELOCITY, 6, 1, cx(val), cy(val), cz(val), 0, 0)
306
- out.push(TAG_VELOCITY_RND, 5, 0, 0, 0, 0, 0)
307
- return
308
- }
309
- const v = val as VelocityValue
310
- let mode = 0
311
- let p: Vec3Like = [ 0, 0, 0 ]
312
- if ("dir" in v) { mode = 1; p = v.dir }
313
- else if ("from" in v) { mode = 2; p = v.from }
314
- else if ("to" in v) { mode = 3; p = v.to }
315
- let lo = 0, hi = 0
316
- if (v.speed !== undefined) {
317
- if (typeof v.speed === "number") lo = hi = v.speed
318
- else { lo = v.speed.min; hi = v.speed.max }
319
- }
320
- out.push(TAG_VELOCITY, 6, mode, cx(p), cy(p), cz(p), lo, hi)
321
- if (v.spread !== undefined) {
322
- out.push(TAG_VELOCITY_RND, 5, 1, -v.spread, -v.spread, v.spread, v.spread)
323
- } else if (v.randomizeAngle) {
324
- const a = v.randomizeAngle
325
- out.push(TAG_VELOCITY_RND, 5, 1, cx(a.min), cy(a.min), cx(a.max), cy(a.max))
326
- } else {
327
- out.push(TAG_VELOCITY_RND, 5, 0, 0, 0, 0, 0)
328
- }
329
- }
330
-
331
- // --- the node -----------------------------------------------------------------------------------
332
-
333
- export class Particles extends Node {
334
- private _material: Material
335
-
336
- /** Custom curve-param slots (0..3); size/rotation/opacity/frame alias slots 0-3. */
337
- readonly custom: ParticleValue[]
338
-
339
- constructor(options: ParticlesOptions = {}) {
340
- super()
341
- this._material = options.material ?? Material.particles(options)
342
- _creator.createParticleSystem(this.id, Material.idOf(this._material), options.maxParticles ?? 0)
343
-
344
- const id = this.id
345
- this.custom = new Proxy([] as ParticleValue[], {
346
- set(target, prop: string, value: ParticleValue) {
347
- const i = parseInt(prop)
348
- if (Number.isNaN(i)) { (target as any)[prop] = value; return true }
349
- target[i] = value
350
- const out: number[] = []
351
- pushParam(out, i, value)
352
- _creator.setParticleSystemConfig(id, Float32Array.from(out))
353
- return true
354
- },
355
- })
356
-
357
- const out: number[] = []
358
- if (options.render !== undefined && options.render !== "point") {
359
- out.push(TAG_RENDER_MODE, 1, options.render === "ribbon" ? 2 : 1)
360
- }
361
- if (options.space !== undefined) out.push(TAG_SPACE, 1, options.space === "world" ? 1 : 0)
362
- if (options.inheritVelocity !== undefined) out.push(TAG_INHERIT_VELOCITY, 1, options.inheritVelocity)
363
- if (options.rateOverDistance !== undefined) out.push(TAG_RATE_DISTANCE, 1, options.rateOverDistance)
364
- if (options.rate !== undefined) out.push(TAG_RATE, 1, options.rate)
365
- if (options.shape !== undefined) pushShape(out, options.shape)
366
- if (options.startVelocity !== undefined) pushVelocity(out, options.startVelocity)
367
- if (options.acceleration !== undefined) {
368
- const a = options.acceleration
369
- out.push(TAG_ACCELERATION, 3, cx(a), cy(a), cz(a))
370
- } else if (options.gravity !== undefined) {
371
- out.push(TAG_ACCELERATION, 3, 0, -options.gravity, 0)
372
- }
373
- if (options.drag !== undefined) out.push(TAG_DRAG, 1, options.drag)
374
- if (options.lifetime !== undefined) {
375
- const [ lo, hi ] = lohi(options.lifetime)
376
- out.push(TAG_LIFETIME, 2, lo, hi)
377
- }
378
- if (options.color !== undefined) pushColor(out, options.color)
379
- if (options.size !== undefined) pushParam(out, 0, options.size)
380
- if (options.rotation !== undefined) pushParam(out, 1, options.rotation)
381
- if (options.opacity !== undefined) pushParam(out, 2, options.opacity)
382
- if (options.frame !== undefined) {
383
- pushParam(out, 3, options.frame)
384
- } else if (options.material === undefined) {
385
- const [ cols, rows ] = _sheetColsRows(options.sheet)
386
- const frames = cols * rows
387
- if (frames > 1) {
388
- const start = options.startFrame ?? "random"
389
- const base = start === "random" ? { min: 0, max: frames - 1 } : start
390
- pushParam(out, 3, curve(base, "add").from(0).to(frames))
391
- }
392
- }
393
- if (options.noise !== undefined) {
394
- const n = options.noise
395
- out.push(TAG_NOISE, 3, n ? n.strength ?? 1 : 0, n ? n.frequency ?? 1 : 0, n ? n.speed ?? 1 : 0)
396
- }
397
- if (options.seed !== undefined) out.push(TAG_SEED, 1, options.seed)
398
-
399
- if (out.length > 0) _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
400
- }
401
-
402
- /** Emit `count` particles immediately (a burst). */
403
- spawn(count: number): this { _creator.spawnParticles(this.id, count); return this }
404
-
405
- private _send(out: number[]): void {
406
- _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
407
- }
408
-
409
- set rate(val: number) { this._send([ TAG_RATE, 1, val ]) }
410
-
411
- /** `'world'` leaves particles behind where they were born (trails). Switching resets live ones. */
412
- set space(val: "local" | "world") { this._send([ TAG_SPACE, 1, val === "world" ? 1 : 0 ]) }
413
- set inheritVelocity(val: number) { this._send([ TAG_INHERIT_VELOCITY, 1, val ]) }
414
- set rateOverDistance(val: number) { this._send([ TAG_RATE_DISTANCE, 1, val ]) }
415
-
416
- set shape(shape: Shape) {
417
- const out: number[] = []
418
- pushShape(out, shape)
419
- this._send(out)
420
- }
421
-
422
- set startVelocity(val: Vec3Like | VelocityValue) {
423
- const out: number[] = []
424
- pushVelocity(out, val)
425
- this._send(out)
426
- }
427
-
428
- /** Positive pulls particles down — sugar for `acceleration = [0, -g, 0]`. */
429
- set gravity(val: number) { this._send([ TAG_ACCELERATION, 3, 0, -val, 0 ]) }
430
- set acceleration(a: Vec3Like) { this._send([ TAG_ACCELERATION, 3, cx(a), cy(a), cz(a) ]) }
431
- set drag(val: number) { this._send([ TAG_DRAG, 1, val ]) }
432
- set seed(val: number) { this._send([ TAG_SEED, 1, val ]) }
433
-
434
- set lifetime(val: Range<number>) {
435
- const [ lo, hi ] = lohi(val)
436
- this._send([ TAG_LIFETIME, 2, lo, hi ])
437
- }
438
-
439
- set size(val: ParticleValue) { this.custom[0] = val }
440
- set rotation(val: ParticleValue) { this.custom[1] = val }
441
- set opacity(val: ParticleValue) { this.custom[2] = val }
442
- set frame(val: ParticleValue) { this.custom[3] = val }
443
-
444
- set color(value: ParticleColor) {
445
- const out: number[] = []
446
- pushColor(out, value)
447
- this._send(out)
448
- }
449
-
450
- get material(): Material { return this._material }
451
- set material(material: Material) {
452
- this._material = material
453
- _creator.setMaterial(this.id, Material.idOf(material), 0)
454
- }
455
-
456
- set noise(noise: Noise | null) {
457
- this._send(noise
458
- ? [ TAG_NOISE, 3, noise.strength ?? 1, noise.frequency ?? 1, noise.speed ?? 1 ]
459
- : [ TAG_NOISE, 3, 0, 0, 0 ])
460
- }
461
- }
462
-
463
- // --- ribbon trail -------------------------------------------------------------------------------
464
-
465
- /** `Trail` options. The look options (map/sheet/emissive/blend/soft) come from
466
- * `ParticlesMaterialOptions`, same as `Particles`. */
467
- export type TrailOptions = Omit<ParticlesMaterialOptions, "render" | "stretch"> & {
468
- /** Seconds each trail point lives — the trail's length in time. Default 0.5. */
469
- time?: number
470
- /** Strip width in world units; a curve tapers it over each point's life (head → tail).
471
- * Default 0.1. */
472
- width?: ParticleValue
473
- /** Minimum emitter movement (world units) between recorded points. Default 0.05. */
474
- minDistance?: number
475
- /** Point capacity (default 128). */
476
- maxPoints?: number
477
- color?: ParticleColor
478
- /** Opacity along the trail. Defaults to fading the tail out (`curve().to(0)`). */
479
- opacity?: ParticleValue
480
- }
481
-
482
- /** A ribbon strip that follows this node through the world — sword swings, skid marks, missile
483
- * trails. Attach it to (or under) the moving node and just move; points are laid down per
484
- * `minDistance` of movement, live for `time` seconds, and the strip stays glued to the node at
485
- * its head. Width/opacity/color accept the same curves as `Particles`, evaluated over each
486
- * point's life — i.e. along the trail from head (fresh) to tail (dying). */
487
- export class Trail extends Node {
488
- private _material: Material
489
- private _rateDistance: number
490
-
491
- constructor(options: TrailOptions = {}) {
492
- super()
493
- this._material = Material.particles({ ...options, render: "ribbon" })
494
- _creator.createParticleSystem(this.id, Material.idOf(this._material), options.maxPoints ?? 128)
495
-
496
- const time = options.time ?? 0.5
497
- this._rateDistance = 1 / Math.max(options.minDistance ?? 0.05, 1e-3)
498
- const out: number[] = [
499
- TAG_RENDER_MODE, 1, 2,
500
- TAG_SPACE, 1, 1, // a trail is only meaningful in world space
501
- TAG_RATE, 1, 0, // points come from movement, never from time
502
- TAG_RATE_DISTANCE, 1, this._rateDistance,
503
- TAG_LIFETIME, 2, time, time,
504
- ]
505
- pushParam(out, 0, options.width ?? 0.1)
506
- pushParam(out, 2, options.opacity ?? curve().to(0))
507
- if (options.color !== undefined) pushColor(out, options.color)
508
- _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
509
- }
510
-
511
- private _send(out: number[]): void {
512
- _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
513
- }
514
-
515
- /** Trail length in seconds — how long each laid-down point lives. */
516
- set time(val: number) { this._send([ TAG_LIFETIME, 2, val, val ]) }
517
-
518
- set width(val: ParticleValue) {
519
- const out: number[] = []
520
- pushParam(out, 0, val)
521
- this._send(out)
522
- }
523
-
524
- set opacity(val: ParticleValue) {
525
- const out: number[] = []
526
- pushParam(out, 2, val)
527
- this._send(out)
528
- }
529
-
530
- set color(value: ParticleColor) {
531
- const out: number[] = []
532
- pushColor(out, value)
533
- this._send(out)
534
- }
535
-
536
- set minDistance(val: number) {
537
- this._rateDistance = 1 / Math.max(val, 1e-3)
538
- this._send([ TAG_RATE_DISTANCE, 1, this._rateDistance ])
539
- }
540
-
541
- /** Pause/resume laying down points — existing ones still age out, so the trail fades naturally
542
- * after e.g. a sword swing ends. */
543
- set emitting(val: boolean) { this._send([ TAG_RATE_DISTANCE, 1, val ? this._rateDistance : 0 ]) }
544
-
545
- get material(): Material { return this._material }
546
- }
1
+ // GPU particle system, as a Node. Draws with the default point-sprite material (Material.particles)
2
+ // unless a custom one is passed; properties drive the native emitter.
3
+ //
4
+ // All emitter/curve config crosses the bridge as ONE Float32Array of [tag, payloadLen, ...payload]
5
+ // records (_creator.setParticleSystemConfig) — the constructor batches every option into a single
6
+ // call, live setters send a one-record buffer. The tag values mirror PsTag in
7
+ // creator-gl/src/particles.h (guarded by tests/particles-tags.test.ts).
8
+ //
9
+ // Curves over a particle's lifetime are built with curve()/colorCurve() chains — see the builders
10
+ // below. A builder's whole state is the flat `_data` array (the exact record payload), so the
11
+ // chisel compiler can constant-fold literal chains into the buffer at compile time.
12
+
13
+ import { Color, type ColorInput } from "../core/color"
14
+ import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
15
+ import type { Geometry } from "./Geometry"
16
+ import { Material, _sheetColsRows, type ParticlesMaterialOptions } from "./Material"
17
+ import { Node } from "./Node"
18
+
19
+ type Range<T> = T | { min: T, max: T }
20
+
21
+ // --- config record tags (keep in sync with PsTag in creator-gl/src/particles.h) -----------------
22
+
23
+ const TAG_SHAPE = 1
24
+ const TAG_LIFETIME = 2
25
+ const TAG_RATE = 3
26
+ const TAG_VELOCITY = 4
27
+ const TAG_VELOCITY_RND = 5
28
+ const TAG_ACCELERATION = 6
29
+ const TAG_DRAG = 7
30
+ const TAG_NOISE = 8
31
+ const TAG_SEED = 9
32
+ const TAG_PARAM_CURVE = 10
33
+ const TAG_COLOR_CURVE = 11
34
+ const TAG_SPACE = 12
35
+ const TAG_INHERIT_VELOCITY = 13
36
+ const TAG_RATE_DISTANCE = 14
37
+ const TAG_RENDER_MODE = 15
38
+ const TAG_MESH_GEOMETRY = 16
39
+ const TAG_ANGULAR_VELOCITY = 17
40
+ const TAG_GROUND = 18
41
+
42
+ /** @internal — exported for the tag-sync test only. */
43
+ export const _particleTags = {
44
+ SHAPE: TAG_SHAPE, LIFETIME: TAG_LIFETIME, RATE: TAG_RATE, VELOCITY: TAG_VELOCITY,
45
+ VELOCITY_RND: TAG_VELOCITY_RND, ACCELERATION: TAG_ACCELERATION, DRAG: TAG_DRAG,
46
+ NOISE: TAG_NOISE, SEED: TAG_SEED, PARAM_CURVE: TAG_PARAM_CURVE, COLOR_CURVE: TAG_COLOR_CURVE,
47
+ SPACE: TAG_SPACE, INHERIT_VELOCITY: TAG_INHERIT_VELOCITY, RATE_DISTANCE: TAG_RATE_DISTANCE,
48
+ RENDER_MODE: TAG_RENDER_MODE, MESH_GEOMETRY: TAG_MESH_GEOMETRY,
49
+ ANGULAR_VELOCITY: TAG_ANGULAR_VELOCITY, GROUND: TAG_GROUND,
50
+ }
51
+
52
+ // --- curve builders -----------------------------------------------------------------------------
53
+
54
+ // A value curve reads as a journey over the particle's life:
55
+ // curve(0.3).from(0).via(0.15, 1).via(0.6, 1).to(0) // fade in, hold, fade out — or:
56
+ // curve(0.3).fade(0.15, 0.4)
57
+ // The curve multiplies the base value (or adds to it with mode 'add'); the base and any stop can be
58
+ // a { min, max } range, randomized per particle. With no `.from` the curve starts at the identity
59
+ // (1 for multiply, 0 for add); with no `.to` it holds its last value to end of life.
60
+ //
61
+ // chisel constant-folds a literal chain into its `_data` buffer at compile time and validates it
62
+ // (bad stop order, >8 stops, unparsable color → a build diagnostic): chisel-core/src/curve.rs mirrors
63
+ // these method bodies op-for-op, against a verbatim copy of them in chisel's fixtures/curve-sdk/. Keep
64
+ // the two in step — a change here means a change there (chisel's scripts/curve-exact.mjs compares the
65
+ // fused payload against the real builder, buffer for buffer).
66
+
67
+ const lohi = (v: Range<number>): [number, number] =>
68
+ typeof v === "number" ? [ v, v ] : [ v.min, v.max ]
69
+
70
+ export class CurveBuilder {
71
+ private _kind: 0 | 1
72
+ private _lo: number
73
+ private _hi: number
74
+ private _stops: number[] = [] // flat (t, lo, hi)
75
+
76
+ constructor(base: Range<number>, mode: "multiply" | "add") {
77
+ this._kind = mode === "add" ? 1 : 0
78
+ ;[ this._lo, this._hi ] = lohi(base)
79
+ }
80
+
81
+ /** Curve value at birth (t = 0). Must come before any other stop. */
82
+ from(v: Range<number>): this {
83
+ if (this._stops.length > 0) throw new Error("curve: .from() must come first")
84
+ return this.via(0, v)
85
+ }
86
+
87
+ /** Curve value at `t` (0..1 of the particle's life). Stops must be added in ascending t order. */
88
+ via(t: number, v: Range<number>): this {
89
+ const [ lo, hi ] = lohi(v)
90
+ this._stops.push(t, lo, hi)
91
+ return this
92
+ }
93
+
94
+ /** Curve value at death (t = 1). */
95
+ to(v: Range<number>): this {
96
+ return this.via(1, v)
97
+ }
98
+
99
+ /** The classic opacity envelope: rise over the first `fadeIn`, fall over the last `fadeOut`. */
100
+ fade(fadeIn = 0.15, fadeOut = fadeIn): this {
101
+ const up = Math.min(Math.max(fadeIn, 0), 1)
102
+ const down = Math.min(Math.max(fadeOut, 0), 1 - up)
103
+ if (up > 0) this.via(0, 0).via(up, 1)
104
+ else this.via(0, 1)
105
+ if (down > 0) {
106
+ if (1 - down > up) this.via(1 - down, 1)
107
+ this.via(1, 0)
108
+ }
109
+ return this
110
+ }
111
+
112
+ /** The PARAM_CURVE record payload (sans slot): [kind, baseLo, baseHi, n, (t, lo, hi)×n]. */
113
+ get _data(): Float32Array {
114
+ const stops = this._stops
115
+ const prepend = stops.length > 0 && stops[0] > 0
116
+ const n = stops.length / 3 + (prepend ? 1 : 0)
117
+ const out = new Float32Array(4 + n * 3)
118
+ out[0] = this._kind
119
+ out[1] = this._lo
120
+ out[2] = this._hi
121
+ out[3] = n
122
+ let o = 4
123
+ if (prepend) {
124
+ const identity = this._kind === 1 ? 0 : 1
125
+ out[o + 1] = identity
126
+ out[o + 2] = identity
127
+ o += 3
128
+ }
129
+ out.set(stops, o)
130
+ return out
131
+ }
132
+ }
133
+
134
+ export class ColorCurveBuilder {
135
+ private _lo: [number, number, number, number]
136
+ private _hi: [number, number, number, number]
137
+ private _stops: number[] = [] // flat (t, lo rgba, hi rgba)
138
+
139
+ constructor(base: Range<ColorInput>) {
140
+ if (isColorRange(base)) {
141
+ this._lo = Color.toRgba01(base.min)
142
+ this._hi = Color.toRgba01(base.max)
143
+ } else {
144
+ this._lo = Color.toRgba01(base)
145
+ this._hi = this._lo
146
+ }
147
+ }
148
+
149
+ /** Curve color at birth (t = 0). Must come first. The curve multiplies the base color. */
150
+ from(c: Range<ColorInput>): this {
151
+ if (this._stops.length > 0) throw new Error("colorCurve: .from() must come first")
152
+ return this.via(0, c)
153
+ }
154
+
155
+ /** Curve color at `t` (0..1 of the particle's life), in ascending t order. */
156
+ via(t: number, c: Range<ColorInput>): this {
157
+ let lo: number[], hi: number[]
158
+ if (isColorRange(c)) {
159
+ lo = Color.toRgba01(c.min)
160
+ hi = Color.toRgba01(c.max)
161
+ } else {
162
+ lo = hi = Color.toRgba01(c)
163
+ }
164
+ this._stops.push(t, ...lo, ...hi)
165
+ return this
166
+ }
167
+
168
+ /** Curve color at death (t = 1). */
169
+ to(c: Range<ColorInput>): this {
170
+ return this.via(1, c)
171
+ }
172
+
173
+ /** The COLOR_CURVE record payload: [baseLo rgba, baseHi rgba, n, (t, lo rgba, hi rgba)×n]. */
174
+ get _data(): Float32Array {
175
+ const stops = this._stops
176
+ const prepend = stops.length > 0 && stops[0] > 0
177
+ const n = stops.length / 9 + (prepend ? 1 : 0)
178
+ const out = new Float32Array(9 + n * 9)
179
+ out.set(this._lo, 0)
180
+ out.set(this._hi, 4)
181
+ out[8] = n
182
+ let o = 9
183
+ if (prepend) {
184
+ for (let i = 1; i < 9; i++) out[o + i] = 1 // identity multiplier: white, t = 0
185
+ o += 9
186
+ }
187
+ out.set(stops, o)
188
+ return out
189
+ }
190
+ }
191
+
192
+ /** A value curve over each particle's lifetime. Default mode `multiply` (the curve scales the
193
+ * base); `add` offsets it instead. */
194
+ export const curve = (base: Range<number> = 1, mode: "multiply" | "add" = "multiply"): CurveBuilder =>
195
+ new CurveBuilder(base, mode)
196
+
197
+ /** A color curve over each particle's lifetime — always multiplies the base color. */
198
+ export const colorCurve = (base: Range<ColorInput> = "#ffffff"): ColorCurveBuilder =>
199
+ new ColorCurveBuilder(base)
200
+
201
+ // --- option types -------------------------------------------------------------------------------
202
+
203
+ export type ParticleValue = Range<number> | CurveBuilder | { _data: Float32Array }
204
+ export type ParticleColor = ColorInput | { min: ColorInput, max: ColorInput } | ColorCurveBuilder | { _data: Float32Array }
205
+
206
+ type VelocityValue = {
207
+ speed?: Range<number>
208
+ /** Cone half-angle in radians — jitter the launch direction uniformly within it. */
209
+ spread?: number
210
+ /** Asymmetric jitter: two angles (u, v — the x/y components) in radians. */
211
+ randomizeAngle?: { min: Vec3Like, max: Vec3Like }
212
+ } & ({ from: Vec3Like } | { to: Vec3Like } | { dir: Vec3Like } | {})
213
+
214
+ type Shape =
215
+ | { type: "point", v: Vec3Like }
216
+ | { type: "box", min: Vec3Like, max: Vec3Like }
217
+ | { type: "circle", center: Vec3Like, radius: number }
218
+ type Noise = { strength?: number, frequency?: number, speed?: number }
219
+
220
+ /** Ground plane for bouncing particles (all render modes). `height` is in the simulation space:
221
+ * world y with `space: 'world'`, emitter-local y otherwise. */
222
+ export type ParticleGround = {
223
+ /** Plane height (default 0). */
224
+ height?: number
225
+ /** Contact radius — particles stop `radius` above the plane (a mesh's half-extent; default 0). */
226
+ radius?: number
227
+ /** Fraction of the vertical speed kept on impact, 0..1 (default 0.3). */
228
+ bounce?: number
229
+ /** Tangential damping while in contact, per second — like `drag` (default 8). */
230
+ friction?: number
231
+ }
232
+
233
+ export type ParticlesOptions = ParticlesMaterialOptions & {
234
+ /** Draw every particle as this mesh — lit, tumbling 3D debris (shell casings, rubble, leaves,
235
+ * shrapnel) in one draw call. The system switches to the mesh render mode; the default
236
+ * material becomes `Material.lit()` (pass `material` for color/roughness/metallic — the
237
+ * sprite look options are ignored). `size` scales the mesh (default 1); `rotation`/`opacity`/
238
+ * `frame` don't apply. See `angularVelocity` and `ground`. */
239
+ mesh?: Geometry
240
+ /** (mesh) Spin, radians per second about the particle's own axes — a vector, or a per-component
241
+ * `{ min, max }` range randomized at spawn. Particles are born in the emitter's orientation. */
242
+ angularVelocity?: Vec3Like | { min: Vec3Like, max: Vec3Like }
243
+ /** A ground plane the particles bounce on and come to rest on (any render mode); `null`
244
+ * disables it. */
245
+ ground?: ParticleGround | null
246
+ /** Custom draw material. Unset = the default point-sprite material, configured by the
247
+ * `ParticlesMaterialOptions` sugar (map/sheet/emissive/blend/soft) — which is ignored
248
+ * when an explicit material is passed. */
249
+ material?: Material
250
+ /** Particle pool capacity (default 1000). */
251
+ maxParticles?: number
252
+ /** Particles emitted per second. */
253
+ rate?: number
254
+ /** Simulation space. `'local'` (default) — particles ride the node's transform; `'world'` —
255
+ * each particle stays where it was born as the emitter moves on (trails: drift smoke, mud,
256
+ * wake). Switching at runtime resets live particles. */
257
+ space?: "local" | "world"
258
+ /** Fraction (usually 0..1) of the emitter's own velocity added to each particle at spawn —
259
+ * smoke "thrown" off a moving car. Takes effect with `space: 'world'`. */
260
+ inheritVelocity?: number
261
+ /** Particles emitted per world unit the emitter MOVES, on top of `rate`, with spawn points
262
+ * spread evenly along the path — trail density independent of speed, no per-frame clumps.
263
+ * Meant for `space: 'world'`. */
264
+ rateOverDistance?: number
265
+ shape?: Shape
266
+ startVelocity?: Vec3Like | VelocityValue
267
+ /** Downward acceleration, world units/s² — positive pulls particles DOWN (sugar for
268
+ * `acceleration: [0, -g, 0]`). */
269
+ gravity?: number
270
+ /** Constant acceleration vector (gravity, wind, …). Wins over `gravity` when both are set. */
271
+ acceleration?: Vec3Like
272
+ drag?: number
273
+ lifetime?: Range<number>
274
+ color?: ParticleColor
275
+ /** World-unit diameter (alias of custom[0]). Default 1. */
276
+ size?: ParticleValue
277
+ /** Radians (alias of custom[1]). */
278
+ rotation?: ParticleValue
279
+ /** Per-particle opacity 0..1 (alias of custom[2]). Default 1. */
280
+ opacity?: ParticleValue
281
+ /** Flipbook frame index (alias of custom[3]). With `sheet` set and no explicit frame, the
282
+ * default is one full sheet cycle over each particle's lifetime, starting at `startFrame`. */
283
+ frame?: ParticleValue
284
+ /** Where the default flipbook cycle begins: `'random'` (default — desynchronized loops like
285
+ * fire/smoke) or a frame index (`0` plays the sheet in order — explosions, one-shot bursts).
286
+ * Only shapes the default; an explicit `frame` wins. */
287
+ startFrame?: number | "random"
288
+ noise?: Noise | null
289
+ /** Fixed random seed for deterministic tests. */
290
+ seed?: number
291
+ }
292
+
293
+ // --- record assembly ----------------------------------------------------------------------------
294
+
295
+ const isColorRange = (v: unknown): v is { min: ColorInput, max: ColorInput } =>
296
+ typeof v === "object" && v !== null && !Array.isArray(v) && "min" in v
297
+
298
+ const hasData = (v: unknown): v is { _data: Float32Array } =>
299
+ typeof v === "object" && v !== null && "_data" in v
300
+
301
+ const pushParam = (out: number[], slot: number, value: ParticleValue): void => {
302
+ if (typeof value === "number") {
303
+ out.push(TAG_PARAM_CURVE, 5, slot, 0, value, value, 0)
304
+ } else if (hasData(value)) {
305
+ const d = value._data
306
+ out.push(TAG_PARAM_CURVE, d.length + 1, slot)
307
+ for (let i = 0; i < d.length; i++) out.push(d[i])
308
+ } else {
309
+ out.push(TAG_PARAM_CURVE, 5, slot, 0, value.min, value.max, 0)
310
+ }
311
+ }
312
+
313
+ const pushColor = (out: number[], value: ParticleColor): void => {
314
+ let d: Float32Array
315
+ if (hasData(value)) {
316
+ d = value._data
317
+ } else if (isColorRange(value)) {
318
+ d = new ColorCurveBuilder(value)._data
319
+ } else {
320
+ d = new ColorCurveBuilder(value as ColorInput)._data
321
+ }
322
+ out.push(TAG_COLOR_CURVE, d.length)
323
+ for (let i = 0; i < d.length; i++) out.push(d[i])
324
+ }
325
+
326
+ const pushMesh = (out: number[], g: Geometry): void => {
327
+ const nv = g.vertices.length / 3
328
+ const ni = g.indices.length
329
+ out.push(TAG_MESH_GEOMETRY, 2 + nv * 8 + ni, nv, ni)
330
+ for (let i = 0; i < nv * 3; i++) out.push(g.vertices[i])
331
+ for (let i = 0; i < nv * 3; i++) out.push(g.normals[i])
332
+ for (let i = 0; i < nv * 2; i++) out.push(g.uv[i])
333
+ for (let i = 0; i < ni; i++) out.push(g.indices[i])
334
+ }
335
+
336
+ const pushAngularVelocity = (out: number[], v: Vec3Like | { min: Vec3Like, max: Vec3Like }): void => {
337
+ const r = v as { min: Vec3Like, max: Vec3Like }
338
+ if (typeof r === "object" && r !== null && !Array.isArray(r) && "min" in r && !(v instanceof Float32Array) && !(v instanceof Vec3)) {
339
+ out.push(TAG_ANGULAR_VELOCITY, 6, cx(r.min), cy(r.min), cz(r.min), cx(r.max), cy(r.max), cz(r.max))
340
+ } else {
341
+ const a = v as Vec3Like
342
+ out.push(TAG_ANGULAR_VELOCITY, 6, cx(a), cy(a), cz(a), cx(a), cy(a), cz(a))
343
+ }
344
+ }
345
+
346
+ const pushGround = (out: number[], g: ParticleGround | null): void => {
347
+ if (g === null) out.push(TAG_GROUND, 5, 0, 0, 0, 0, 0)
348
+ else out.push(TAG_GROUND, 5, 1, g.height ?? 0, g.radius ?? 0, g.bounce ?? 0.3, g.friction ?? 8)
349
+ }
350
+
351
+ const pushShape = (out: number[], shape: Shape): void => {
352
+ if (shape.type === "point") out.push(TAG_SHAPE, 4, 0, cx(shape.v), cy(shape.v), cz(shape.v))
353
+ else if (shape.type === "box") out.push(TAG_SHAPE, 7, 1, cx(shape.min), cy(shape.min), cz(shape.min), cx(shape.max), cy(shape.max), cz(shape.max))
354
+ else out.push(TAG_SHAPE, 5, 2, cx(shape.center), cy(shape.center), cz(shape.center), shape.radius)
355
+ }
356
+
357
+ const pushVelocity = (out: number[], val: Vec3Like | VelocityValue): void => {
358
+ if (Array.isArray(val) || val instanceof Float32Array || val instanceof Vec3) {
359
+ // a plain vector: launch along it, its length is the speed (native derives it)
360
+ out.push(TAG_VELOCITY, 6, 1, cx(val), cy(val), cz(val), 0, 0)
361
+ out.push(TAG_VELOCITY_RND, 5, 0, 0, 0, 0, 0)
362
+ return
363
+ }
364
+ const v = val as VelocityValue
365
+ let mode = 0
366
+ let p: Vec3Like = [ 0, 0, 0 ]
367
+ if ("dir" in v) { mode = 1; p = v.dir }
368
+ else if ("from" in v) { mode = 2; p = v.from }
369
+ else if ("to" in v) { mode = 3; p = v.to }
370
+ let lo = 0, hi = 0
371
+ if (v.speed !== undefined) {
372
+ if (typeof v.speed === "number") lo = hi = v.speed
373
+ else { lo = v.speed.min; hi = v.speed.max }
374
+ }
375
+ out.push(TAG_VELOCITY, 6, mode, cx(p), cy(p), cz(p), lo, hi)
376
+ if (v.spread !== undefined) {
377
+ out.push(TAG_VELOCITY_RND, 5, 1, -v.spread, -v.spread, v.spread, v.spread)
378
+ } else if (v.randomizeAngle) {
379
+ const a = v.randomizeAngle
380
+ out.push(TAG_VELOCITY_RND, 5, 1, cx(a.min), cy(a.min), cx(a.max), cy(a.max))
381
+ } else {
382
+ out.push(TAG_VELOCITY_RND, 5, 0, 0, 0, 0, 0)
383
+ }
384
+ }
385
+
386
+ // --- the node -----------------------------------------------------------------------------------
387
+
388
+ export class Particles extends Node {
389
+ private _material: Material
390
+
391
+ /** Custom curve-param slots (0..3); size/rotation/opacity/frame alias slots 0-3. */
392
+ readonly custom: ParticleValue[]
393
+
394
+ constructor(options: ParticlesOptions = {}) {
395
+ super()
396
+ this._material = options.material ?? (options.mesh ? Material.lit() : Material.particles(options))
397
+ _creator.createParticleSystem(this.id, Material.idOf(this._material), options.maxParticles ?? 0)
398
+
399
+ const id = this.id
400
+ this.custom = new Proxy([] as ParticleValue[], {
401
+ set(target, prop: string, value: ParticleValue) {
402
+ const i = parseInt(prop)
403
+ if (Number.isNaN(i)) { (target as any)[prop] = value; return true }
404
+ target[i] = value
405
+ const out: number[] = []
406
+ pushParam(out, i, value)
407
+ _creator.setParticleSystemConfig(id, Float32Array.from(out))
408
+ return true
409
+ },
410
+ })
411
+
412
+ const out: number[] = []
413
+ if (options.mesh) {
414
+ out.push(TAG_RENDER_MODE, 1, 3)
415
+ pushMesh(out, options.mesh)
416
+ } else if (options.render !== undefined && options.render !== "point") {
417
+ out.push(TAG_RENDER_MODE, 1, options.render === "ribbon" ? 2 : 1)
418
+ }
419
+ if (options.angularVelocity !== undefined) pushAngularVelocity(out, options.angularVelocity)
420
+ if (options.ground !== undefined) pushGround(out, options.ground)
421
+ if (options.space !== undefined) out.push(TAG_SPACE, 1, options.space === "world" ? 1 : 0)
422
+ if (options.inheritVelocity !== undefined) out.push(TAG_INHERIT_VELOCITY, 1, options.inheritVelocity)
423
+ if (options.rateOverDistance !== undefined) out.push(TAG_RATE_DISTANCE, 1, options.rateOverDistance)
424
+ if (options.rate !== undefined) out.push(TAG_RATE, 1, options.rate)
425
+ if (options.shape !== undefined) pushShape(out, options.shape)
426
+ if (options.startVelocity !== undefined) pushVelocity(out, options.startVelocity)
427
+ if (options.acceleration !== undefined) {
428
+ const a = options.acceleration
429
+ out.push(TAG_ACCELERATION, 3, cx(a), cy(a), cz(a))
430
+ } else if (options.gravity !== undefined) {
431
+ out.push(TAG_ACCELERATION, 3, 0, -options.gravity, 0)
432
+ }
433
+ if (options.drag !== undefined) out.push(TAG_DRAG, 1, options.drag)
434
+ if (options.lifetime !== undefined) {
435
+ const [ lo, hi ] = lohi(options.lifetime)
436
+ out.push(TAG_LIFETIME, 2, lo, hi)
437
+ }
438
+ if (options.color !== undefined) pushColor(out, options.color)
439
+ if (options.size !== undefined) pushParam(out, 0, options.size)
440
+ if (options.rotation !== undefined) pushParam(out, 1, options.rotation)
441
+ if (options.opacity !== undefined) pushParam(out, 2, options.opacity)
442
+ if (options.frame !== undefined) {
443
+ pushParam(out, 3, options.frame)
444
+ } else if (options.material === undefined && !options.mesh) {
445
+ const [ cols, rows ] = _sheetColsRows(options.sheet)
446
+ const frames = cols * rows
447
+ if (frames > 1) {
448
+ const start = options.startFrame ?? "random"
449
+ const base = start === "random" ? { min: 0, max: frames - 1 } : start
450
+ pushParam(out, 3, curve(base, "add").from(0).to(frames))
451
+ }
452
+ }
453
+ if (options.noise !== undefined) {
454
+ const n = options.noise
455
+ out.push(TAG_NOISE, 3, n ? n.strength ?? 1 : 0, n ? n.frequency ?? 1 : 0, n ? n.speed ?? 1 : 0)
456
+ }
457
+ if (options.seed !== undefined) out.push(TAG_SEED, 1, options.seed)
458
+
459
+ if (out.length > 0) _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
460
+ }
461
+
462
+ /** Emit `count` particles immediately (a burst). */
463
+ spawn(count: number): this { _creator.spawnParticles(this.id, count); return this }
464
+
465
+ private _send(out: number[]): void {
466
+ _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
467
+ }
468
+
469
+ set rate(val: number) { this._send([ TAG_RATE, 1, val ]) }
470
+
471
+ /** `'world'` leaves particles behind where they were born (trails). Switching resets live ones. */
472
+ set space(val: "local" | "world") { this._send([ TAG_SPACE, 1, val === "world" ? 1 : 0 ]) }
473
+ set inheritVelocity(val: number) { this._send([ TAG_INHERIT_VELOCITY, 1, val ]) }
474
+ set rateOverDistance(val: number) { this._send([ TAG_RATE_DISTANCE, 1, val ]) }
475
+
476
+ set shape(shape: Shape) {
477
+ const out: number[] = []
478
+ pushShape(out, shape)
479
+ this._send(out)
480
+ }
481
+
482
+ /** (mesh mode) Replace the particle mesh; live particles keep flying as the new shape. */
483
+ set mesh(g: Geometry) {
484
+ const out: number[] = [ TAG_RENDER_MODE, 1, 3 ]
485
+ pushMesh(out, g)
486
+ this._send(out)
487
+ }
488
+
489
+ set angularVelocity(v: Vec3Like | { min: Vec3Like, max: Vec3Like }) {
490
+ const out: number[] = []
491
+ pushAngularVelocity(out, v)
492
+ this._send(out)
493
+ }
494
+
495
+ set ground(g: ParticleGround | null) {
496
+ const out: number[] = []
497
+ pushGround(out, g)
498
+ this._send(out)
499
+ }
500
+
501
+ set startVelocity(val: Vec3Like | VelocityValue) {
502
+ const out: number[] = []
503
+ pushVelocity(out, val)
504
+ this._send(out)
505
+ }
506
+
507
+ /** Positive pulls particles down — sugar for `acceleration = [0, -g, 0]`. */
508
+ set gravity(val: number) { this._send([ TAG_ACCELERATION, 3, 0, -val, 0 ]) }
509
+ set acceleration(a: Vec3Like) { this._send([ TAG_ACCELERATION, 3, cx(a), cy(a), cz(a) ]) }
510
+ set drag(val: number) { this._send([ TAG_DRAG, 1, val ]) }
511
+ set seed(val: number) { this._send([ TAG_SEED, 1, val ]) }
512
+
513
+ set lifetime(val: Range<number>) {
514
+ const [ lo, hi ] = lohi(val)
515
+ this._send([ TAG_LIFETIME, 2, lo, hi ])
516
+ }
517
+
518
+ set size(val: ParticleValue) { this.custom[0] = val }
519
+ set rotation(val: ParticleValue) { this.custom[1] = val }
520
+ set opacity(val: ParticleValue) { this.custom[2] = val }
521
+ set frame(val: ParticleValue) { this.custom[3] = val }
522
+
523
+ set color(value: ParticleColor) {
524
+ const out: number[] = []
525
+ pushColor(out, value)
526
+ this._send(out)
527
+ }
528
+
529
+ get material(): Material { return this._material }
530
+ set material(material: Material) {
531
+ this._material = material
532
+ _creator.setMaterial(this.id, Material.idOf(material), 0)
533
+ }
534
+
535
+ set noise(noise: Noise | null) {
536
+ this._send(noise
537
+ ? [ TAG_NOISE, 3, noise.strength ?? 1, noise.frequency ?? 1, noise.speed ?? 1 ]
538
+ : [ TAG_NOISE, 3, 0, 0, 0 ])
539
+ }
540
+ }
541
+
542
+ // --- ribbon trail -------------------------------------------------------------------------------
543
+
544
+ /** `Trail` options. The look options (map/sheet/emissive/blend/soft) come from
545
+ * `ParticlesMaterialOptions`, same as `Particles`. */
546
+ export type TrailOptions = Omit<ParticlesMaterialOptions, "render" | "stretch"> & {
547
+ /** Seconds each trail point lives — the trail's length in time. Default 0.5. */
548
+ time?: number
549
+ /** Strip width in world units; a curve tapers it over each point's life (head → tail).
550
+ * Default 0.1. */
551
+ width?: ParticleValue
552
+ /** Minimum emitter movement (world units) between recorded points. Default 0.05. */
553
+ minDistance?: number
554
+ /** Point capacity (default 128). */
555
+ maxPoints?: number
556
+ color?: ParticleColor
557
+ /** Opacity along the trail. Defaults to fading the tail out (`curve().to(0)`). */
558
+ opacity?: ParticleValue
559
+ }
560
+
561
+ /** A ribbon strip that follows this node through the world — sword swings, skid marks, missile
562
+ * trails. Attach it to (or under) the moving node and just move; points are laid down per
563
+ * `minDistance` of movement, live for `time` seconds, and the strip stays glued to the node at
564
+ * its head. Width/opacity/color accept the same curves as `Particles`, evaluated over each
565
+ * point's life — i.e. along the trail from head (fresh) to tail (dying). */
566
+ export class Trail extends Node {
567
+ private _material: Material
568
+ private _rateDistance: number
569
+
570
+ constructor(options: TrailOptions = {}) {
571
+ super()
572
+ this._material = Material.particles({ ...options, render: "ribbon" })
573
+ _creator.createParticleSystem(this.id, Material.idOf(this._material), options.maxPoints ?? 128)
574
+
575
+ const time = options.time ?? 0.5
576
+ this._rateDistance = 1 / Math.max(options.minDistance ?? 0.05, 1e-3)
577
+ const out: number[] = [
578
+ TAG_RENDER_MODE, 1, 2,
579
+ TAG_SPACE, 1, 1, // a trail is only meaningful in world space
580
+ TAG_RATE, 1, 0, // points come from movement, never from time
581
+ TAG_RATE_DISTANCE, 1, this._rateDistance,
582
+ TAG_LIFETIME, 2, time, time,
583
+ ]
584
+ pushParam(out, 0, options.width ?? 0.1)
585
+ pushParam(out, 2, options.opacity ?? curve().to(0))
586
+ if (options.color !== undefined) pushColor(out, options.color)
587
+ _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
588
+ }
589
+
590
+ private _send(out: number[]): void {
591
+ _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
592
+ }
593
+
594
+ /** Trail length in seconds — how long each laid-down point lives. */
595
+ set time(val: number) { this._send([ TAG_LIFETIME, 2, val, val ]) }
596
+
597
+ set width(val: ParticleValue) {
598
+ const out: number[] = []
599
+ pushParam(out, 0, val)
600
+ this._send(out)
601
+ }
602
+
603
+ set opacity(val: ParticleValue) {
604
+ const out: number[] = []
605
+ pushParam(out, 2, val)
606
+ this._send(out)
607
+ }
608
+
609
+ set color(value: ParticleColor) {
610
+ const out: number[] = []
611
+ pushColor(out, value)
612
+ this._send(out)
613
+ }
614
+
615
+ set minDistance(val: number) {
616
+ this._rateDistance = 1 / Math.max(val, 1e-3)
617
+ this._send([ TAG_RATE_DISTANCE, 1, this._rateDistance ])
618
+ }
619
+
620
+ /** Pause/resume laying down points — existing ones still age out, so the trail fades naturally
621
+ * after e.g. a sword swing ends. */
622
+ set emitting(val: boolean) { this._send([ TAG_RATE_DISTANCE, 1, val ? this._rateDistance : 0 ]) }
623
+
624
+ get material(): Material { return this._material }
625
+ }