import { Emitter } from '../emitters/Emitter.js';
import { Force } from '../forces/Force.js';
import { Particle } from './Particle.js';
/**
* Engine configuration options.
* @typedef {object} EngineConfig
* @property {CullingBounds|null} [cullingBounds=null] Optional region used for particle culling.
*/
/**
* Defines a region beyond which particles are considered outside the simulation and are marked dead.
* A safety margin is applied per particle based on its position and size, preventing early removal while it is still
* partially inside the region.
* @typedef {object} CullingBounds
* @property {number} xMin Left boundary of the region.
* @property {number} yMin Top boundary of the region.
* @property {number} xMax Right boundary of the region.
* @property {number} yMax Bottom boundary of the region.
*/
/**
* The core particle engine that manages the simulation pipeline and particle lifecycle.
* @class
*/
export class Gnist {
/**
* The current semantic version of the Gnist particle engine.
* @type {string}
* @returns {string}
*/
static get VERSION() {
return '0.2.0';
}
/**
* Internal collection of registered emitters.
* @type {Array<Emitter>}
*/
#emitters;
/**
* Internal collection of registered global environmental forces
* @type {Array<Force>}
*/
#globalForces;
/**
* Internal collection of active particles.
* @type {Array<Particle>}
*/
#particles;
/**
* Internal state of the optional region used for particle culling.
* @type {CullingBounds|null}
*/
#cullingBounds;
/**
* Initializes an empty simulation pipeline.
* @constructor
* @param {EngineConfig} [config={}] Engine configuration options.
*/
constructor(config = {}) {
this.#emitters = [];
this.#globalForces = [];
this.#particles = [];
this.cullingBounds = config.cullingBounds;
}
/**
* Registered emitters emitting active particles.
* @type {Array<Emitter>}
* @readonly
*/
get emitters() {
return this.#emitters;
}
/**
* Registered global environmental forces affecting all active particles.
* @type {Array<Force>}
* @readonly
*/
get globalForces() {
return this.#globalForces;
}
/**
* Common pool of active particles.
* @type {Array<Particle>}
* @readonly
*/
get particles() {
return this.#particles;
}
/**
* Optional region used for particle culling.
* @type {CullingBounds|null}
*/
get cullingBounds() {
return this.#cullingBounds;
}
/**
* Sets the optional region used for particle culling.
* @param {CullingBounds|null} cullingBounds The new region or null to disable culling.
* @throws {Error}
*/
set cullingBounds(cullingBounds) {
if (!cullingBounds) {
this.#cullingBounds = null;
return;
}
const xMin = cullingBounds.xMin ?? -10_000_000;
const yMin = cullingBounds.yMin ?? -10_000_000;
const xMax = cullingBounds.xMax ?? 10_000_000;
const yMax = cullingBounds.yMax ?? 10_000_000;
if (xMin > xMax || yMin > yMax) {
throw new Error('[Gnist] Invalid culling bounds: xMin must be less than or equal to xMax and yMin must be less than or equal to yMax.');
}
this.#cullingBounds = { xMin, yMin, xMax, yMax };
}
/**
* Finds a registered emitter by its unique identifier.
* @param {string} id The unique identifier of the target emitter.
* @returns {Emitter|null} The emitter instance if found, null otherwise.
*/
getEmitter(id) {
return this.#emitters.find(em => em.id === id) ?? null;
}
/**
* Registers an emitter into the simulation pipeline.
* @param {Emitter} emitter The emitter instance to register.
* @returns {this} The Gnist engine instance for method chaining.
*/
addEmitter(emitter) {
this.#emitters.push(emitter);
return this;
}
/**
* Removes an emitter from the simulation pipeline by its unique identifier.
* @param {string} id The unique identifier of the target emitter.
* @returns {boolean} True if found and successfully removed, false otherwise.
*/
removeEmitter(id) {
const initialLength = this.#emitters.length;
this.#emitters = this.#emitters.filter(e => e.id !== id);
return this.#emitters.length < initialLength;
}
/**
* Finds a registered global environmental force by its unique identifier.
* @param {string} id The unique identifier of the target force.
* @returns {Force|null} The force instance if found, null otherwise.
*/
getGlobalForce(id) {
return this.#globalForces.find(f => f.id === id) ?? null;
}
/**
* Registers a global environmental force into the simulation pipeline.
* @param {Force} force The force instance to register.
* @returns {this} The Gnist engine instance for method chaining.
*/
addGlobalForce(force) {
this.#globalForces.push(force);
return this;
}
/**
* Removes a global environmental force from the simulation pipeline by its unique identifier.
* @param {string} id The unique identifier of the target force.
* @returns {boolean} True if found and successfully removed, false otherwise.
*/
removeGlobalForce(id) {
const initialLength = this.#globalForces.length;
this.#globalForces = this.#globalForces.filter(e => e.id !== id);
return this.#globalForces.length < initialLength;
}
/**
* Steps the simulation pipeline forward by a given time delta.
* @param {number} dt Time elapsed since the last frame (in seconds).
* @returns {void}
*/
update(dt) {
if (dt <= 0) {
return;
}
// Cap maximum step size to preserve physics stability during tab switches
const safeDt = Math.min(dt, 0.1);
this.#emitParticles(safeDt);
this.#tickParticles(safeDt);
}
/**
* Fills a provided TypedArray with particle data for WebGL.
* @param {Float32Array} targetArray - The array to write data into.
* @returns {number} The number of particles written.
*/
fillFlatArray(targetArray) {
let offset = 0;
const count = this.#particles.length;
for (let i = 0; i < count; i++) {
const p = this.particles[i];
if (!p.alive) {
continue;
}
// Physical attributes
targetArray[offset++] = p.x;
targetArray[offset++] = p.y;
targetArray[offset++] = p.size;
targetArray[offset++] = p.rotation;
// Visuals (normalized for WebGL)
targetArray[offset++] = p.color.r / 255;
targetArray[offset++] = p.color.g / 255;
targetArray[offset++] = p.color.b / 255;
targetArray[offset++] = p.opacity;
}
return offset / 8;
}
/**
* Iterates through registered emitters to emit new particles.
* @param {number} dt Time elapsed since the last frame (in seconds).
* @returns {void}
*/
#emitParticles(dt) {
const particlePool = this.#particles;
const emitterCount = this.#emitters.length;
for (let i = 0; i < emitterCount; i++) {
const emitter = this.#emitters[i];
if (emitter) {
emitter.update(dt, particlePool);
}
}
}
/**
* Updates particle lifecycles, applies global and scoped emitter-specific forces, moves particles, and
* applies modifiers.
* @param {number} dt Time elapsed since the last frame (in seconds).
* @returns {void}
*/
#tickParticles(dt) {
const globalForces = this.#globalForces;
const globalForcesCount = globalForces.length;
const particles = this.#particles;
const particleCount = particles.length;
const cullingBounds = this.#cullingBounds;
let aliveCount = 0;
for (let i = 0; i < particleCount; i++) {
const particle = particles[i];
particle.age += dt;
if (particle.age >= particle.lifespan) {
particle.alive = false;
}
if (particle.alive) {
const normalizedAge = Math.min(particle.age / particle.lifespan, 1.0);
// 1. Environmental forces
for (let j = 0; j < globalForcesCount; j++) {
globalForces[j].apply(particle, dt);
}
const scopedForces = particle.scopedForces;
const scopedForcesCount = scopedForces.length;
for (let j = 0; j < scopedForcesCount; j++) {
scopedForces[j].apply(particle, dt);
}
// 2. Path modifiers (must run BEFORE position integration so vx/vy changes apply immediately)
const pathModifiers = particle.pathModifiers;
const pathModifiersCount = pathModifiers.length;
for (let j = 0; j < pathModifiersCount; j++) {
pathModifiers[j].update(particle, normalizedAge, dt);
}
// 3. Position integration
particle.x += particle.vx * dt;
particle.y += particle.vy * dt;
particle.rotation += particle.angularVelocity * dt;
// 4. Visual Modifiers (must run AFTER position integration)
const visualModifiers = particle.visualModifiers;
const visualModifiersCount = visualModifiers.length;
for (let j = 0; j < visualModifiersCount; j++) {
visualModifiers[j].update(particle, normalizedAge, dt);
}
// 5. Culling
if (cullingBounds !== null) {
const safetyMargin = particle.size || 0;
if (particle.x < cullingBounds.xMin - safetyMargin ||
particle.x > cullingBounds.xMax + safetyMargin ||
particle.y < cullingBounds.yMin - safetyMargin ||
particle.y > cullingBounds.yMax + safetyMargin
) {
particle.alive = false;
}
}
}
// In-place dual-pointer compaction avoids Array.filter allocations, eliminating garbage collection spikes
if (particle.alive) {
if (aliveCount !== i) {
particles[aliveCount] = particle;
}
aliveCount++;
}
}
particles.length = aliveCount;
}
}