import type { EmitMode } from '../properties/particles'; /** What the source's own rows say about how many particles there are and for how long. */ export interface EmitterSettings { mode: EmitMode; /** Continuous: particles per second. */ rate: number; /** Burst: how many go out at once. */ count: number; /** Seconds one particle lives. */ lifetime: number; /** How fast the source's own clock runs against the world's: 2 is twice the speed. */ timeScale: number; /** Start a lifetime in, so the field is already full at second 0. */ prewarm: boolean; /** The most that may be alive at once; the oldest are the ones dropped. */ max: number; } /** Which particles are alive, as a range of indices – `to < from` is none. Both ends inclusive. */ export interface LiveRange { from: number; to: number; } export const NO_PARTICLES: LiveRange = { from: 0, to: -1 }; /** * When particle `index` was born, in seconds since the source started. A continuous source emits on * a fixed grid – particle n is born at n/rate – which is what makes the whole field a function of * time rather than of how many frames have gone by. A burst is every particle at once. */ export function birthTime(settings: EmitterSettings, index: number): number { if (settings.mode === 'burst') return 0; return settings.rate > 0 ? index / settings.rate : 0; } /** * The clock the formulas are handed, from the seconds the source has been alive: a factor and a * summand on that one argument rather than any state, which is what lets a prewarmed field be shown * at its first frame without simulating the way there. */ export function fieldTime(settings: EmitterSettings, elapsed: number): number { return elapsed * settings.timeScale + prewarmOffset(settings); } /** * A lifetime, which is the smallest offset that makes a continuous field full: at that second the * oldest particle alive is exactly the one about to die. A burst has nothing left by then, so the * offset would hide it altogether – prewarm means nothing there. */ export function prewarmOffset(settings: EmitterSettings): number { return settings.prewarm && settings.mode === 'continuous' ? Math.max(0, settings.lifetime) : 0; } /** * The particles alive at `time`. `emitUntil` is how far the source was still emitting: switched off, * it stops taking in new ones while the ones in flight live out their lives. */ export function liveRange(settings: EmitterSettings, time: number, emitUntil = time): LiveRange { const { lifetime, max } = settings; if (lifetime <= 0 || max < 1 || time < 0) return NO_PARTICLES; if (settings.mode === 'burst') { const count = Math.min(Math.floor(settings.count), Math.floor(max)); if (count < 1 || time >= lifetime || emitUntil < 0) return NO_PARTICLES; return { from: 0, to: count - 1 }; } const rate = settings.rate; if (rate <= 0) return NO_PARTICLES; const newest = Math.floor(Math.min(time, emitUntil) * rate); if (newest < 0) return NO_PARTICLES; // Born no earlier than one lifetime ago: `n/rate > time - lifetime`. const oldest = Math.max(0, Math.floor((time - lifetime) * rate) + 1); if (oldest > newest) return NO_PARTICLES; return { from: Math.max(oldest, newest - Math.floor(max) + 1), to: newest }; }