import { Particle, ParticleContainer, type Texture } from 'pixi.js'; import { type CompiledParticles, emptyParticleOut, type ParticleOut } from '../core/particles/compile'; import { birthTime, type EmitterSettings, fieldTime, liveRange } from '../core/particles/emission'; import type { ParticleEnv } from '../core/particles/nodes'; import { type Mat, scaleOf } from '../core/model/matrix'; import { booleanOf, type PropertyBag } from '../core/model/Properties'; import { BURST_COUNT_KEY, emitModeOf, EMITTING_KEY, LIFETIME_KEY, MAX_PARTICLES_KEY, MOVE_WITH_SOURCE_KEY, PARTICLE_COLOR_KEY, PARTICLE_SIZE_KEY, PREWARM_KEY, RATE_KEY, TIME_SCALE_KEY, } from '../core/properties/particles'; const DEG = Math.PI / 180; export function emitterSettings(props: PropertyBag): EmitterSettings { return { mode: emitModeOf(props), rate: numberOf(props[RATE_KEY], 20), count: numberOf(props[BURST_COUNT_KEY], 20), lifetime: numberOf(props[LIFETIME_KEY], 1), timeScale: Math.max(0, numberOf(props[TIME_SCALE_KEY], 1)), prewarm: booleanOf(props[PREWARM_KEY] ?? false), max: Math.max(1, numberOf(props[MAX_PARTICLES_KEY], 500)), }; } function numberOf(value: unknown, fallback: number): number { const n = Number(value); return Number.isFinite(n) ? n : fallback; } /** * One particle source as it is drawn: a Pixi `ParticleContainer`, which puts all of them in a single * draw call, plus the little state a formula cannot hold – when emission started and, for particles * left behind in the world, where their source stood when they were born. */ export class ParticleField { readonly container = new ParticleContainer({ dynamicProperties: { position: true, vertex: true, rotation: true, color: true, uvs: false }, }); private readonly out: ParticleOut = emptyParticleOut(); private readonly pool: Particle[] = []; /** Seconds, on the library's clock, at which this source last started emitting. */ private startedAt: number | null = null; /** How far it was still taking in new particles: frozen while `emitting` is off. */ private emittedUntil = 0; /** The source's transform when each particle was born, kept per index modulo the capacity. */ private born: Mat[] = []; private capacity = 0; private highest = -1; /** The Emit count last seen; a change is a request to start over. */ private emits = -1; constructor() { this.container.eventMode = 'none'; } /** Seconds the source has been emitting; -1 before it ever started. */ elapsed(now: number): number { return this.startedAt === null ? -1 : now - this.startedAt; } /** Starts the clock over: a burst goes out again, a continuous stream begins at particle 0. */ restart(now: number): void { this.startedAt = now; this.emittedUntil = 0; this.reset(); } /** * Moves the origin so the field is `to` seconds old at `now`, keeping every particle where it is. * What a held preview needs when it is let go: without it the clock ran on underneath the freeze. */ rebase(now: number, to: number): void { this.startedAt = now - to; } private reset(): void { this.highest = -1; this.born = []; this.capacity = 0; } /** * Brings the drawn particles up to `now`. Everything about one particle comes out of `compiled`; * the only thing read from a frame is how far the clock has moved. */ sync( compiled: CompiledParticles | null, props: PropertyBag, params: readonly number[], m: Mat, texture: Texture, now: number, emits = 0, /** Mixed into every particle's seed, so two sources of one asset do not scatter identically. */ salt = 0, ): void { const emitting = booleanOf(props[EMITTING_KEY] ?? true); if (this.emits !== emits) { // Not on the first sight of a source: that would throw its first burst away again. if (this.emits >= 0) this.restart(now); this.emits = emits; } if (this.startedAt === null) this.startedAt = now; const settings = emitterSettings(props); const time = fieldTime(settings, now - this.startedAt); if (emitting) this.emittedUntil = time; const range = compiled ? liveRange(settings, time, this.emittedUntil) : { from: 0, to: -1 }; const capacity = Math.max(1, Math.floor(settings.max)); if (capacity !== this.capacity) { this.capacity = capacity; this.born = []; this.highest = -1; } // Stepping back in time (the tab's slider) leaves birth places that no longer belong to anyone. if (range.to < this.highest) this.highest = range.from - 1; for (let i = Math.max(this.highest + 1, range.from); i <= range.to; i++) this.born[i % capacity] = m; this.highest = Math.max(this.highest, range.to); const moves = booleanOf(props[MOVE_WITH_SOURCE_KEY] ?? false); const size = Math.max(0.01, numberOf(props[PARTICLE_SIZE_KEY], 16)); const sourceColor = numberOf(props[PARTICLE_COLOR_KEY], 0xffffff); const baseW = texture.width || 1; const baseH = texture.height || 1; const env: ParticleEnv = { age: 0, ageSeconds: 0, time, index: 0, seed: 0, color: sourceColor, params, }; // The shader samples the container's own texture, not each particle's, so it has to be set here. this.container.texture = texture; const particles = this.container.particleChildren; particles.length = 0; if (compiled) for (let index = range.from; index <= range.to; index++) { const ageSeconds = time - birthTime(settings, index); env.index = index; env.seed = index ^ salt; env.ageSeconds = ageSeconds; env.age = settings.lifetime > 0 ? ageSeconds / settings.lifetime : 0; compiled.evaluate(env, this.out); const place = moves ? m : (this.born[index % capacity] ?? m); const p = this.take(particles.length, texture); p.texture = texture; p.x = place.a * this.out.x + place.c * this.out.y + place.tx; p.y = place.b * this.out.x + place.d * this.out.y + place.ty; const scale = scaleOf(place); p.scaleX = (size * this.out.scaleX * scale) / baseW; p.scaleY = (size * this.out.scaleY * scale) / baseH; p.rotation = this.out.rotation * DEG + Math.atan2(place.b, place.a); p.tint = this.out.color; p.alpha = this.out.alpha; particles.push(p); } this.container.update(); } /** Particles are reused rather than rebuilt: a stream of 500 would otherwise allocate 500 a frame. */ private take(slot: number, texture: Texture): Particle { let p = this.pool[slot]; if (!p) { p = new Particle({ texture, anchorX: 0.5, anchorY: 0.5 }); this.pool[slot] = p; } return p; } /** How many are drawn right now – what the checks read. */ get count(): number { return this.container.particleChildren.length; } destroy(): void { this.container.destroy(); } }