Overview

What is Gnist?

Gnist is a lightweight, easy-to-use 2D particle simulation engine written in vanilla JavaScript with zero dependencies.

Built for real-time visual effects rather than physically accurate simulation, it uses a simplified kinematic model that prioritizes performance. Forces directly influence particle velocity, bypassing mass and momentum calculations.

Gnist is renderer-agnostic. It decouples simulation from timing and rendering, exposing raw state data that can be rendered with Canvas 2D, WebGL, or any custom JavaScript runtime.

While running the simulation on the GPU could improve raw performance, it would also significantly increase API complexity. Gnist instead performs simulation on the CPU, prioritizing a simple and intuitive programming model. The engine is designed around an object-oriented, configuration-driven API, while keeping rendering separate from simulation. For custom rendering pipelines, particle data can still be exported as flat typed arrays for efficient GPU-based rendering.

Architecture Concepts

The simulation pipeline revolves around four primary building blocks.

The Engine

The central simulation manager. It owns the shared particle pool, updates particle lifecycles, applies forces and modifiers, and advances the simulation frame by frame using a time delta (dt).

Emitters

Particle generators that spawn particles into the particle pool. Emitters are defined by 2D primitives such as points, lines, rectangles, and ellipses, which determine where particles are born. Configurations and blueprints provide fine-grained control over emission behavior and initial particle properties, with randomized ranges available to create natural variation.

Forces

Environmental influences that directly modify particle velocity over time. Forces can be global, affecting every particle in the simulation (such as gravity or wind) or scoped to particles emitted by a specific emitter.

Modifiers

Rules that update particle properties over their lifetime based on their normalized age.

  • Path Modifiers alter particle trajectories independently of environmental forces.
  • Visual Modifiers alter visual properties such as scale, opacity, and color over a particle's lifetime.
Note

Gnist is designed around composing its provided building blocks rather than extending the engine itself. Internal implementation details are not intended as extension points.

Your First Particle System

What You'll Create

In this guide, you will build a simple campfire particle effect using Gnist from scratch. The example uses multiple emitters, modifiers, a global force, and Canvas 2D rendering to demonstrate how the different parts of Gnist work together. The focus is on learning how to use Gnist, not on building a production-ready application.

The final result will look like this:

Fire particle effect created with Gnist.

Prerequisites

Before getting started, make sure your development environment meets the following requirements:

This guide assumes basic familiarity with JavaScript and HTML.

Project Setup

Initialize a New Project

Create a project directory and initialize it with NPM:

mkdir example-simulation
cd example-simulation
npm init

When prompted, set the package type to module. CommonJS projects are not supported.

Note that this project does not use the entry point setting. You can leave it unchanged or remove it entirely.

Install Vite

Next, we will install Vite as a dev dependency to provide a development server and handle module resolution for the project. This allows us to import directly from the installed npm package. Vite is used only to simplify the development setup; it is not a requirement for using Gnist.

npm i --save-dev vite

Then add the start script to package.json:

{
  "scripts": {
    "dev": "vite"
  }
}

Install Gnist

Install Gnist as a dependency using npm:

npm i @gergelybardos/gnist

Creating the Application Skeleton

HTML Setup

Create an index.html file in the project root. The page only needs a <canvas> element, which will be used for rendering the particle system, and a <script> tag that loads the application's entry point.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Example Simulation</title>
  </head>
  <body>
    <canvas id="canvas"></canvas>
    <script type="module" src="main.js"></script>
  </body>
</html>

Canvas Setup

Next, create main.js and initialize the canvas. Gnist performs only the simulation, so the application is responsible for rendering the particles.

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

canvas.width = 800;
canvas.height = 600;

Main Loop

Next, create the application's main loop. Each frame, it advances the simulation and renders the updated particle state.

dt represents the time elapsed since the previous frame, measured in seconds. We will pass it to Gnist, ensuring that the simulation runs at a consistent speed regardless of the frame rate.

let lastTime = performance.now();

function loop(currentTime) {
  requestAnimationFrame(loop);

  const dt = (currentTime - lastTime) / 1000;
  lastTime = currentTime;

  engine.update(dt);

  // Render particles
}

requestAnimationFrame(loop);

Setting up Gnist

First, import the following items from the Gnist package at the top of main.js

import {
  ColorRamp,
  DirectionalForce,
  EllipseEmitter,
  Gnist,
  LineEmitter,
  OpacityFade,
  ScaleTween,
  Turbulence,
} from '@gergelybardos/gnist';

Next, create a Gnist instance and configure its culling bounds. Culling bounds specify the area outside of which particles are automatically removed regardless of their age. Setting culling bounds is optional but recommended for most applications. Since Gnist is renderer-agnostic and has no knowledge of the rendering context, the application may define these bounds if automatic particle cleanup is desired.

const engine = new Gnist({
  cullingBounds: {
    xMin: 0,
    yMin: 0,
    xMax: 800,
    yMax: 600
  }
});

Rendering the Particles

Gnist does not render particles directly. Instead, it exposes the current simulation state through the engine.particles array, which can be rendered using any rendering system. In this example, we use the Canvas 2D API to draw each particle as a small colored rectangle. It is up to the application to interpret the particle data; the same state can be used to create simple shapes, sprites, textures, or custom visual effects. The rendering code in this example is intentionally simple.

Add the following code inside your main loop:

ctx.fillStyle = '#171228';
ctx.fillRect(0, 0, canvas.width, canvas.height);

const particles = engine.particles;
for (let i = 0; i < particles.length; i++) {
    const p = particles[i];
    const { r, g, b } = p.color;
    const a = p.opacity;
    const size = p.size;
    const halfSize = size / 2;

    ctx.save();

    ctx.translate(p.x, p.y);
    ctx.rotate(p.rotation);

    ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
    ctx.fillRect(-halfSize, -halfSize, size, size);

    ctx.restore();
}

Building the Campfire

Flames

With the main engine initialized, let's add the flame effect to the simulation:

  • Instantiate EllipseEmitter.
  • Add a ColorRamp visual modifier to transition flame colors over time.
  • Add an OpacityFade visual modifier to smoothly fade particles out before death.
  • Apply a Turbulence path modifier to emulate chaotic flame movement.
  • Attach all modifiers to the flame emitter.

Add the following code right after instantiating the main Gnist class:

const flames = new EllipseEmitter({
    x: canvas.width / 2,
    y: canvas.height - 50,
    radiusX: 60,
    radiusY: 40,
    particlesPerSecond: 200,
    particleBlueprint: {
        lifespan: [1, 2],
        speed: [50, 75],
        direction: [0, -Math.PI],
        size: [7, 14],
    }
});

const flameColors = new ColorRamp({
    colors: [
        [255, 255, 230],
        [255, 245, 160],
        [255, 220, 90],
        [255, 170, 40],
        [255, 110, 20],
        [220, 60, 10],
        [140, 25, 5],
        [60, 10, 0],
    ]
});

const flameFade = new OpacityFade();

const noise = new Turbulence({
    strength: [200, 750],
    scale: 1
});

flames.addModifier(flameColors);
flames.addModifier(flameFade);
flames.addModifier(noise);

Smoke

Next, we will add some smoke to our campfire simulation:

  • Instantiate LineEmitter.
  • Add an OpacityFade visual modifier to smoothly fade particles out before death.
  • Apply a ScaleTween visual modifier to emulate rising smoke effect.
  • Attach all modifiers to the smoke emitter.

Add the following code right after configuring the flames:

const smoke = new LineEmitter({
    x1: canvas.width / 2 - 50,
    y1: canvas.height - 25,
    x2: canvas.width / 2 + 50,
    y2: canvas.height - 25,
    particlesPerSecond: 100,
    particleBlueprint: {
        angularVelocity: [1, 3],
        lifespan: [1, 7],
        speed: [20, 60],
        direction: [
            -7 * Math.PI / 12,
            -5 * Math.PI / 12,
        ],
        size: [1, 2],
        color: [255, 255, 255],
    }
});

const smokeFade = new OpacityFade({
    startOpacity: 0.1,
    endOpacity: 0,
});

const smokeScaling = new ScaleTween({
    startScale: 0.5,
    endScale: 40,
});

smoke.addModifier(smokeFade);
smoke.addModifier(smokeScaling);

Embers

To make our scene a bit more detailed, we will add some floating embers to the campfire:

  • Instantiate EllipseEmitter.
  • Add the same ColorRamp instance (flameColors) used by the flame emitter.

Add the following code right after configuring the smoke:

const embers = new EllipseEmitter({
    x: canvas.width / 2,
    y: canvas.height - 50,
    radiusX: 60,
    radiusY: 40,
    particlesPerSecond: 10,
    particleBlueprint: {
        lifespan: [1, 2],
        speed: [100, 300],
        direction: [
            -7 * Math.PI / 12,
            -5 * Math.PI / 12
        ],
        size: [1, 3],
    }
});

embers.addModifier(flameColors);

Wind

Let's add some wind to our simulation!

  • Instantiate DirectionalForce.
  • Add the instance to the simulation as a global force to affect particles from all three emitters.

Add the following code right after configuring the embers:

const wind = new DirectionalForce({
    ax: 15,
    ay: -10,
});

engine.addGlobalForce(wind);

The Final Result

One final thing to do is to register the emitters with the simulation. Add the following code right after configuring the global wind:

engine.addEmitter(flames);
engine.addEmitter(smoke);
engine.addEmitter(embers);

After that, the final code of main.js should look like something like this:

import {
    Gnist,
    LineEmitter,
    EllipseEmitter,
    ColorRamp,
    OpacityFade,
    Turbulence,
    ScaleTween,
    DirectionalForce,
} from '@gergelybardos/gnist';

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

canvas.width = 800;
canvas.height = 600;

let lastTime = performance.now();

const engine = new Gnist({
    cullingBounds: {
        xMin: 0,
        yMin: 0,
        xMax: 800,
        yMax: 600
    }
});

const flames = new EllipseEmitter({
    x: canvas.width / 2,
    y: canvas.height - 50,
    radiusX: 60,
    radiusY: 40,
    particlesPerSecond: 200,
    particleBlueprint: {
        lifespan: [1, 2],
        speed: [50, 75],
        direction: [0, -Math.PI],
        size: [7, 14],
    }
});

const flameColors = new ColorRamp({
    colors: [
        [255, 255, 230],
        [255, 245, 160],
        [255, 220, 90],
        [255, 170, 40],
        [255, 110, 20],
        [220, 60, 10],
        [140, 25, 5],
        [60, 10, 0],
    ]
});

const flameFade = new OpacityFade();

const noise = new Turbulence({
    strength: [200, 750],
    scale: 1
});

flames.addModifier(flameColors);
flames.addModifier(flameFade);
flames.addModifier(noise);

const smoke = new LineEmitter({
    x1: canvas.width / 2 - 50,
    y1: canvas.height - 25,
    x2: canvas.width / 2 + 50,
    y2: canvas.height - 25,
    particlesPerSecond: 100,
    particleBlueprint: {
        angularVelocity: [1, 3],
        lifespan: [1, 7],
        speed: [20, 60],
        direction: [
            -7 * Math.PI / 12,
            -5 * Math.PI / 12,
        ],
        size: [1, 2],
        color: [255, 255, 255],
    }
});

const smokeFade = new OpacityFade({
    startOpacity: 0.1,
    endOpacity: 0,
});

const smokeScaling = new ScaleTween({
    startScale: 0.5,
    endScale: 40,
});

smoke.addModifier(smokeFade);
smoke.addModifier(smokeScaling);

const embers = new EllipseEmitter({
    x: canvas.width / 2,
    y: canvas.height - 50,
    radiusX: 60,
    radiusY: 40,
    particlesPerSecond: 10,
    particleBlueprint: {
        lifespan: [1, 2],
        speed: [100, 300],
        direction: [
            -7 * Math.PI / 12,
            -5 * Math.PI / 12
        ],
        size: [1, 3],
    }
});

embers.addModifier(flameColors);

const wind = new DirectionalForce({
    ax: 15,
    ay: -10,
});

engine.addGlobalForce(wind);

engine.addEmitter(flames);
engine.addEmitter(smoke);
engine.addEmitter(embers);

function loop(currentTime) {
    requestAnimationFrame(loop);

    const dt = (currentTime - lastTime) / 1000;
    lastTime = currentTime;

    engine.update(dt);

    ctx.fillStyle = '#171228';
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    const particles = engine.particles;
    for (let i = 0; i < particles.length; i++) {
        const p = particles[i];
        const { r, g, b } = p.color;
        const a = p.opacity;
        const size = p.size;
        const halfSize = size / 2;

        ctx.save();

        ctx.translate(p.x, p.y);
        ctx.rotate(p.rotation);

        ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
        ctx.fillRect(-halfSize, -halfSize, size, size);

        ctx.restore();
    }
}
requestAnimationFrame(loop);

To verify that everything is working correctly, run the following:

npm run dev

Emitters

Emitters are the particle generators of the simulation. Each emitter type is defined by its geometric shape, which determines its emitting area and where newly emitted particles initially appear.

Creating Emitters

To add an emitter to the simulation, you need to instantiate one of the emitter classes, configure it via its constructor, register modifiers and scoped forces with the emitter, and then register the emitter itself with the main Gnist class. Upon instantiating, the emitter is automatically assigned a unique id value if one is not specified.

Configuration

Emitters are configured by a single configuration object passed at instantiation. While most emitter types have their own specialized parameters, they all share a set of core configuration options. Many of the corresponding emitter instance properties are public and can be dynamically updated in real-time within your update loop, while others remain static or are accessed through dedicated API methods.

The common properties of an emitter configuration object and their corresponding emitter instance public properties:

Configuration Property Type Default Instance Property Description
id string Auto-generated UUID readonly A unique identifier for the emitter.
enabled boolean true readonly Whether the emitter is currently active.
particlesPerSecond number 100 The number of particles to emit per second.
duration number Infinity - The duration of the emitter's emission in seconds, or Infinity for infinite emission.
x number 0 The horizontal coordinate of the emitter origin.
y number 0 The vertical coordinate of the emitter origin.
emissionSource EmissionSource EmissionSource.VOLUME The emission source mode of particles within the emitter's geometry.
particleBlueprint object {} - The blueprint used to define the initial properties of emitted particles.

The emissionSource property

The emissionSource property of the configuration object is used to control the geometric distribution and default direction of the emission. The latter depends on both the emitter's shape and the source mode and can be overridden by specifying particleBlueprint.direction.

The available modes are properties of the EmissionSource constant object:

  • EDGE_OUT — Emit from the shape's boundary, directing particles outward.
  • EDGE_IN — Emit from the shape's boundary, directing particles inward.
  • EDGE_BOTH — Emit from the shape's boundary, directing particles randomly inward or outward.
  • VOLUME — Emit uniformly from the shape's entire area.

How each of these modes behaves on different emitter types is explained in the Emission Modes section.

Particle Blueprint

The particleBlueprint property holds a simple object used by emitters to initialize particles at emission. This is not the runtime particle state. Apart from color, values can be specified either as a single value or as a [min, max] range array.

Properties of the particleBlueprint sub-object:

Property Type Default Description
rotation number 0 Orientation angle (in radians).
angularVelocity number 0 Angular rotation speed (in radians per second).
size number 1 The visual size or scale factor. Interpreted by the renderer as pixels, radius, or a transform scale.
color object { r: 255, g: 255, b: 255 } The particle color, defined by individual RGB channels.
opacity number 1 The visual opacity, from 0.0 (fully transparent) to 1.0 (fully opaque).
lifespan number 1 The maximum allowed lifespan (in seconds).
speed number 1 Speed (in pixels per second), used to derive the particle's initial horizontal and vertical velocity.
direction number 0 Movement direction angle (in radians), used to derive the particle's initial horizontal and vertical velocity.
Note

Note that these do not map one-to-one to the public or readonly properties of Particle instances. For example, speed and direction are not stored directly; they are used to initialize the particle's vx and vy properties.

Example

The example below demonstrates the core configuration options shared by all emitter types (the PointEmitter used here is the only type without any specialized properties of its own).

const emitter = new PointEmitter({
    id: "Some unique identifier",
    enabled: true,
    particlesPerSecond: 500,
    duration: Infinity,
    x: 100,
    y: 200,

    // Included for completeness; has no effect on a PointEmitter
    emissionSource: EmissionSource.VOLUME,

    particleBlueprint: {
        rotation: 0.1,
        angularVelocity: 0.1,
        size: [1, 10],
        color: { r: 255, g: 0, b: 0 },
        opacity: [0.5, 1],
        lifespan: [1, 5],
        speed: [15, 150],
        direction: [0, Math.PI * 2]
    }
});

Modifier Scope vs. Force Scope

While Gnist supports both global and emitter-local forces, modifiers are strictly emitter-scoped. You can still register the same modifier instance with multiple emitters, but there is no concept of a "global modifier". This is because forces contribute to a particle's velocity additively, allowing multiple forces to be combined predictably. Modifiers, on the other hand, overwrite particle properties directly, so allowing both global and emitter-scoped modifiers would introduce conflicts and unpredictable results.

Applying Modifiers

To apply a modifier, first you need to create an instance of it. Upon instantiating, the modifier is automatically assigned a unique id value if one is not specified. Modifiers are not applied to particles directly; instead, you register them with an emitter using the emitter's addModifier() method passing the modifier instance as an argument. You may register any number of modifiers with an emitter. Once registered, Gnist automatically applies the modifier to all particles emitted by that emitter.

Modifiers cannot be toggled after registration; they can only be removed from the emitter using the emitter's removeModifier() method passing the modifier's id as an argument.

Note

Removing a modifier only affects particles emitted after the removal. Particles that were already emitted retain the effect of the modifier that was applied to them before removal.

Example

const enlarge = new ScaleTween({
    startScale: 1,
    endScale: 5,
});

const fadeOut = new OpacityFade({
    startOpacity: 1.0,
    endOpacity: 0.0,
});

const emitter = new PointEmitter({
    ...
});

emitter.addModifier(enlarge);
emitter.addModifier(fadeOut);

Applying Scoped Forces

To apply a scoped force, first you need to create an instance of it. Upon instantiating, the force is automatically assigned a unique id value if one is not specified. Forces tied to a given emitter must first be registered using the emitter's addScopedForce() method passing the force instance as an argument. You may register any number of scoped forces with an emitter. Every particle emitted by that emitter is automatically affected by its registered scoped forces.

Scoped forces cannot be toggled after registration; they can only be removed from the emitter using the emitter's removeScopedForce() method passing the force's id as an argument.

Note

Removing a scoped force only affects particles emitted after the removal. Particles that were already emitted retain the effect of the force that was applied to them before removal.

Example

const gravity = new DirectionalForce({ ax: 0, ay: 200 });
const friction = new LinearDrag({ drag: 0.4 });

const emitter = new PointEmitter({
    ...
});

emitter.addScopedForce(gravity);
emitter.addScopedForce(friction);

Applying Global Forces

Global forces are applied to every particle in the simulation, regardless of which emitter emitted it. To apply a global force, first you need to create an instance of it. Upon instantiating, the force is automatically assigned a unique id value if one is not specified. Global forces must be registered with the main Gnist class using its addGlobalForce() method passing the force instance as an argument. You may register any number of global forces.

Global forces cannot be toggled after registration; they can only be removed from the simulation using the removeGlobalForce() method of the main Gnist class passing the force's id as an argument.

Note

In contrast to modifiers and scoped forces, removing a global force affects every particle in the simulation, including particles that were already emitted before the removal. Existing particles retain the velocity accumulated from the force up to that point; they simply no longer receive further acceleration from the removed force.

Registering and Removing Emitters

Emitters must be registered with the main Gnist class using its addEmitter() method passing the emitter instance as an argument. Once registered, the next time Gnist.update() is called in your main loop, the new emitter will start emitting particles.

Emitters have Playback Controls, but they can also be removed from the simulation using the removeEmitter() of the main Gnist class passing the emitter's id as an argument. Particles already emitted by the removed emitter will complete their lifetimes, but no new particles will be emitted.

Playback Controls

You can control emitter playback using the following methods, regardless of whether the emitter has a finite or infinite duration:

Method Description
.start() Starts or forcefully restarts particle emission from the beginning. Resets internal tracking and sets the emitter to an active state.
.pause() Temporarily halts particle emission and locks internal tracking.
.resume() Resumes particle emission and internal tracking from where they were paused.
.stop() Halts particle emission and resets internal tracking. The emitter is deactivated but remains ready to be started again.

To check whether an emitter is currently enabled, you can query its enabled property. However, this is a native getter, and the value cannot be directly modified on the instance.

Emitter Types

Emitter Type Specific Properties Typical Uses
PointEmitter None Muzzle flash, sparks, explosions, engine exhaust, torches, campfires
LineEmitter x1, y1, x2, y2 Rain, beams, trails, directional effects
RectEmitter x, y, width, height Smoke, clouds, fog, rain areas, star field
EllipseEmitter x, y, radiusX, radiusY Portals, shockwaves, black holes, vortexes

Emission Modes

Emission Source

The emissionSource property of an emitter configuration object determines where particles originate within the emitter's shape and what their default direction is. The table below illustrates how each source mode behaves for the different emitter types. Note that PointEmitter is not included, as the source property has no effect on it.

EDGE_OUT EDGE_IN EDGE_BOTH VOLUME
Line emitter with 'edge out' emission source mode. Line emitter with 'edge in' emission source mode. Line emitter with 'edge both' emission source mode. N/A
Rectangle emitter with 'edge out' emission source mode. Rectangle emitter with 'edge in' emission source mode. Rectangle emitter with 'edge both' emission source mode. Rectangle emitter with 'volume' emission source mode.
Ellipse emitter with 'edge out' emission source mode. Ellipse emitter with 'edge in' emission source mode. Ellipse emitter with 'edge both' emission source mode. Ellipse emitter with 'volume' emission source mode.

Directed Emission

The particleBlueprint.direction property of an emitter configuration object overrides the default emission direction determined by the emitter type (shape) and the selected emission source mode.

Note that direction can be specified either as a single value or as a [min, max] range, allowing particles to be emitted in a range of directions rather than a single one. This can produce less uniform and more natural-looking movement. Below are some examples that illustrate the difference.

A PointEmitter with different direction range settings.
No explicit direction [0, Math.PI * 0.5] [0, Math.PI * 2]
Point emitter without explicit direction. Point emitter with direction range from 0 to PI/2. Point emitter with direction range from 0 to 2 PI.
A LineEmitter using a single-value and a range-value direction.
Math.PI [0, Math.PI * 2]
Line emitter with a direction of PI. Line emitter with direction range from 0 to 2 PI.
A RectEmitter using a single-value and a range-value direction.
Math.PI * 0.3 [0, Math.PI * 2]
Rectangle emitter with a direction of 0.3 PI. Rectangle emitter with direction range from 0 to 2 PI.

Forces

Directional Force

A DirectionalForce applies a constant directional acceleration to affected particles. Unlike an initial velocity, which is only set when a particle spawns, a directional force continuously modifies a particle's velocity over time.

Typical use cases: gravity, wind, and buoyancy.

Properties of a configuration object and its corresponding DirectionalForce instance public properties:

Property Type Default Description
ax number 0 Horizontal acceleration component (pixels per second²). Positive values accelerate particles to the right, negative values to the left.
ay number 0 Vertical acceleration component (pixels per second²). Positive values accelerate particles downward, negative values upward.

The example below demonstrates a DirectionalForce applying horizontal acceleration to a LineEmitter that would otherwise emit particles straight down.

const wind = new DirectionalForce({
    ax: 100
});

const lineEmitter = new LineEmitter({
    ...
});

lineEmitter.addScopedForce(wind);
Directional force demo.

Linear Drag

A LinearDrag gradually reduces a particle's velocity over time, simulating friction or resistance. Unlike a directional force, which adds acceleration in a specific direction, linear drag opposes the particle's current movement and slows it down.

Typical use cases: air resistance, friction, smoke, and fire damping.

Properties of a configuration object and its corresponding LinearDrag instance public properties:

Property Type Default Description
drag number 0.99 Friction coefficient where 0 means no drag and higher values slow particles down faster.

The example below demonstrates the effect of linear drag by comparing particles with drag enabled against particles without drag.

const friction = new LinearDrag({
    drag: 0.7,
});

const emitterWithDrag = new LineEmitter({
    ...
});

const emitter = new LineEmitter({
    ...
});

emitterWithDrag.addScopedForce(friction);
Linear drag force demo.

Radial Force

A RadialForce applies acceleration toward or away from a central point. The magnitude of the acceleration decreases as the distance from the center increases.

Typical use cases: explosion, particle attraction and repulsion effects.

Properties of a configuration object and its corresponding RadialForce instance public properties:

Property Type Default Description
x number 0 Horizontal coordinate of the force center.
y number 0 Vertical coordinate of the force center.
strength number 50000 Magnitude of the force. Positive values create attraction, negative values create repulsion.
epsilon number 10 Smoothing factor that prevents extreme acceleration values when particles are close to the force center. Only the squared epsilon is stored internally and exposed as epsilonSquared.
cullingRadius number 0 Distance from the force center below which particles are removed. A soft-aging buffer zone is applied around this area to reduce visual popping. Set to 0 to disable culling. Only the squared radius is stored internally and exposed as cullingRadiusSquared.
Note

Because the simulation is updated in discrete time steps, particles may overshoot the force center and continue on the opposite side before being pulled back. If particles should disappear upon reaching the center, use the cullingRadius property.

Below is an example of a radial force with and without culling. While some particles escape, most are absorbed. Without culling, however, particles continue orbiting the force until they reach the end of their lifespan. The only difference between the two examples is whether cullingRadius is set.

const lineEmitter = new LineEmitter({
    ...
});

const radialForce = new RadialForce({
    x: 250,
    y: 190,
    strength: 50000,
    radius: 100,
    epsilon: 10,
    cullingRadius: 60,
});

lineEmitter.addScopedForce(radialForce);

Vortex

A Vortex applies a combination of inward suction and tangential rotation to create a swirling motion around a central point. The suction component pulls particles toward the center, while the rotational component causes them to orbit around it.

Typical use cases: whirlpool, tornado, black hole, and portal.

Properties of a configuration object and its corresponding Vortex instance public properties:

Property Type Default Description
x number 0 Horizontal coordinate of the vortex center.
y number 0 Vertical coordinate of the vortex center.
rotationSpeed number 100 Strength of the rotational force. Positive values rotate clockwise, negative values rotate counter-clockwise.
suctionSpeed number 50 Strength of the radial attraction/repulsion. Positive values pull inward, negative values push outward.
radius number Infinity Radius of influence. Particles outside this distance are unaffected.
cullingRadius number 0 Distance from the vortex center below which particles are removed. A soft-aging buffer zone is applied around this area to reduce visual popping. Set to 0 to disable culling. Stored internally and exposed as epsilon and as a squared value as epsilonSquared.
Note

Because the simulation is updated in discrete time steps, particles may overshoot the vortex center and continue on the opposite side before being pulled back. If particles should disappear upon reaching the center, use the cullingRadius property.

Below is an example of a vortex with culling. While some particles escape, most are absorbed. Without culling, particles would continue orbiting the force until they reach the end of their lifespan.

const gnistEngine = new Gnist();

const blueEmitter = new LineEmitter({
    x1: 25,
    y1: 25,
    x2: 475,
    y2: 25,
    particlesPerSecond: 200,
    source: EmissionSource.EDGE_OUT,
    particleBlueprint: {
        angularVelocity: [1, 6],
        size: [1, 5],
        lifespan: 3,
        speed: [15, 150],
        color: {r: 76, g: 122, b: 238},
    }
});

const pinkEmitter = new LineEmitter({
    x1: 25,
    y1: 355,
    x2: 475,
    y2: 355,
    particlesPerSecond: 200,
    source: EmissionSource.EDGE_IN,
    particleBlueprint: {
        angularVelocity: [1, 6],
        size: [1, 5],
        lifespan: 3,
        speed: [15, 150],
        color: {r: 224, g: 23, b: 122},
    }
});

const friction = new LinearDrag({
    drag: 0.9,
});

const vortex = new Vortex({
    x: 250,
    y: 190,
    rotationSpeed: 275,
    suctionSpeed: 200,
    radius: 140,
    cullingRadius: 75,
});

gnistEngine.addEmitter(blueEmitter);
gnistEngine.addEmitter(pinkEmitter);

gnistEngine.addGlobalForce(friction);
gnistEngine.addGlobalForce(vortex);
Vortex force demo.

Modifiers

Path Modifiers

Sine Wave

A SineWave applies a perpendicular sine-wave displacement relative to the particle's current movement direction. The modifier can be configured with either fixed or interpolated amplitude and frequency over the lifespan of the particles.

Typical use cases: smoke trail, energy beam, magic effects, floating particles, and organic movement patterns.

Properties of a configuration object and its corresponding SineWave instance public properties:

Property Type Default Description
amplitude number | number[] 10 Wave amplitude (in pixels), or a [start, end] range array interpolated over particle lifespan. Within a SineWave instance, amplitude is stored and exposed as startAmplitude and endAmplitude.
frequency number | number[] 2 Cycles per second (Hz), or a [start, end] range array interpolated over particle lifespan. Within a SineWave instance, frequency is stored and exposed as startFrequency and endFrequency.

Below is an example of a sine wave modifier.

const emitter = new PointEmitter({
    ...
});

const wave = new SineWave({
    amplitude: [10, 20],
    frequency: [0.1, 1],
});

emitter.addModifier(wave);
Sine wave modifier demo.

Turbulence

A Turbulence applies pseudo-random, continuous displacement to particles over their lifespan. The modifier can be configured with either fixed or interpolated strength over the lifespan of the particles.

Typical use cases: smoke, fire, cloud, and wind.

Properties of a configuration object and its corresponding Turbulence instance public properties:

Property Type Default Description
strength number | number[] 20 Magnitude of displacement in pixels per second or a [start, end] range array interpolated over particle lifespan. Within a Turbulence instance, strength is stored and exposed as startStrength and endStrength.
scale number 0.01 Noise scale factor controlling the size of turbulence patterns. Smaller values produce smooth, sweeping currents; larger values produce tight, chaotic jitter.

Below is an example of a turbulence modifier.

const emitter = new PointEmitter({
    ...
});

const noise = new Turbulence({
    strength: [50, 150],
    scale: 1,
});

emitter.addModifier(noise);
Turbulence modifier demo.

Visual Modifiers

Color Ramp

A ColorRamp blends the color of particles over their lifespan by interpolating through an arbitrary number of colors.

Typical use cases: fire, magic effect, and explosion.

Properties of a configuration object for ColorRamp instances:

Property Type Default Description
colors Array<Array<number>> [[255, 255, 255], [0, 0, 0]] Array of RGB color stops. Colors are distributed evenly across particle lifespan. Note that these color stops are not exposed on ColorRamp instances.

Below is an example of a color ramp modifier.

const emitter = new PointEmitter({
    ...
});

const gnistColorRamp = new ColorRamp({
    colors: [
        [0, 242, 254],
        [143, 0, 255],
        [255, 0, 127],
        [255, 102, 0],
    ],
});

emitter.addModifier(gnistColorRamp);
Color ramp modifier demo.

Opacity Fade

A OpacityFade blends the opacity of particles over their lifespan by interpolating between two target levels.

Typical use cases: fading smoke, dissolving particles.

Properties of a configuration object and its corresponding OpacityFade instance public properties:

Property Type Default Description
startOpacity number 1.0 Opacity at particle emission. Values range from 0.0 (fully transparent) to 1.0 (fully opaque).
endOpacity number 0.0 Opacity at particle death. Values range from 0.0 (fully transparent) to 1.0 (fully opaque).

Below is an example of an opacity fade modifier.

const emitter = new PointEmitter({
    ...
});

const fadeOut = new OpacityFade({
    startOpacity: 1.0,
    endOpacity: 0.0,
});

emitter.addModifier(fadeOut);
Opacity fade modifier demo.

Rotation Tween

A RotationTween interpolates the rotation angle of particles over their lifespan between two target values.

Typical use cases: debris, spinning leaves.

Properties of a configuration object and its corresponding RotationTween instance public properties:

Property Type Default Description
startRotation number 0 Rotation angle in radians at particle emission.
endRotation number Math.PI * 2 Rotation angle in radians at particle death.

Below is an example of a rotation tween modifier.

const emitter = new PointEmitter({
    ...
});

const rotation = new RotationTween({
    startRotation: 0,
    endRotation: Math.PI,
});

emitter.addModifier(rotation);
Rotation tween modifier demo.

Scale Tween

A ScaleTween interpolates the size of particles over their lifespan, scaling them relative to their base size between two target scale multipliers.

Typical use cases: expanding explosions, fading particles, and shrinking debris.

Properties of a configuration object and its corresponding ScaleTween instance public properties:

Property Type Default Description
startScale number 1.0 Scale multiplier at particle emission.
endScale number 0.1 Scale multiplier at particle death.

Below is an example of a scale tween modifier.

const emitter = new PointEmitter({
    ...
});

const enlarge = new ScaleTween({
    startScale: 1,
    endScale: 12,
});

emitter.addModifier(enlarge);
Scale tween modifier demo.

Spin

A Spin continuously updates the orientation of the particles based on their angularVelocity.

Typical use cases: debris, spinning leaves.

Properties of a configuration object and its corresponding Spin instance properties:

Property Type Default Description
angularVelocity number null Override spin rate in radians per second. If omitted, the particle's own angularVelocity value is used.

Below is an example of a spin modifier.

const emitter = new PointEmitter({
    ...
});

const spin = new Spin({
    angularVelocity: 2
});

emitter.addModifier(spin);
Spin modifier demo.

Exporting Particle Data

Accessing Particle State

The particles array provided by Gnist instances exposes particle data as objects, which is convenient for general-purpose usage. However, renderers that communicate directly with the GPU, such as WebGL renderers, typically require particle attributes in flat typed arrays that can be uploaded directly to GPU buffers.

For this purpose, Gnist instances provide the fillFlatArray() method. It writes the current particle state into a preallocated Float32Array using a fixed layout, removing the need for custom conversion code before uploading the data to the GPU.

This guide describes the data format of the array returned by fillFlatArray().

Flat Data Format

Each particle is represented by eight consecutive entries in the Float32Array in the following order:

Offset Attribute Storage Type Description
0 x float32 Particle X position
1 y float32 Particle Y position
2 size float32 Particle size
3 rotation float32 Particle rotation in radians
4 color.r float32 Red channel, normalized from 0–255 to 0–1
5 color.g float32 Green channel, normalized from 0–255 to 0–1
6 color.b float32 Blue channel, normalized from 0–255 to 0–1
7 opacity float32 Particle opacity (already in the 0-1 range)

Example Layout

For two particles, the resulting array layout is:

[
  x1, y1, size1, rotation1, r1, g1, b1, opacity1,
  x2, y2, size2, rotation2, r2, g2, b2, opacity2,
]

Return Value

The method returns the number of particles written into the array.

The provided array must have enough space for all particles. The required size can be calculated as:

particleCount * FlatParticleDataFormat.FLOATS_PER_PARTICLE

where FlatParticleDataFormat.FLOATS_PER_PARTICLE is a Gnist-provided constant that defines the number of values stored per particle.

Rendering with WebGL

In this guide, we will replace Canvas 2D rendering from Your First Particle System with WebGL rendering. The particle simulation remains unchanged; only the rendering implementation is swapped out. The rendered result will be identical.

Prerequisites

This guide assumes you have completed Your First Particle System and have a working example. You will also need a browser with WebGL support.

Building on Your First Particle System

In main.js, keep the existing simulation setup and main loop structure. Remove the Canvas 2D rendering code and replace it with the WebGL renderer described in the following sections.

// Keep everything above unchanged:
// - Gnist initialization
// - emitters
// - modifiers
// - forces

function loop(currentTime) {
    requestAnimationFrame(loop);

    const dt = (currentTime - lastTime) / 1000;
    lastTime = currentTime;

    engine.update(dt);

    // WebGL rendering will be added here
}

Add FlatParticleDataFormat to the imported items from Gnist. It provides constants describing the layout of the flat particle data export.

import {
  ColorRamp,
  DirectionalForce,
  EllipseEmitter,
  Gnist,
  LineEmitter,
  OpacityFade,
  FlatParticleDataFormat,
  ScaleTween,
  Turbulence,
} from '@gergelybardos/gnist';

Initializing WebGL

First, create a WebGL2 rendering context. The rest of the WebGL setup will be added in the following sections.

Replace this line...

const ctx = canvas.getContext('2d');

...with the code below:

const gl = canvas.getContext('webgl2', {
    alpha: false,
    premultipliedAlpha: false,
});

if (!gl) {
    throw new Error('WebGL2 is not supported');
}

A note on the used settings:

  • alpha: false means the canvas has an opaque background.
  • premultipliedAlpha: false avoids automatic alpha premultiplication, which is often easier when working with particle colors and opacity manually.

Creating the Shader Program

The shader program defines how particle data is transformed into geometry and displayed on the screen. The vertex shader handles each particle's position, size, and rotation, while the fragment shader determines its final color and opacity.

The following code creates a simple shader program that renders each particle as a colored rectangle. Add this after initializing the WebGL context and setting canvas dimensions.

const vertexShaderSource = `#version 300 es
    in vec2 a_quadVertex;

    in vec2 a_position;
    in float a_size;
    in float a_rotation;
    in vec3 a_color;
    in float a_opacity;

    out vec3 v_color;
    out float v_opacity;

    uniform vec2 u_resolution;

    void main() {
        float s = sin(a_rotation);
        float c = cos(a_rotation);

        vec2 scaledVertex = a_quadVertex * a_size;

        vec2 rotatedVertex = vec2(
            scaledVertex.x * c - scaledVertex.y * s,
            scaledVertex.x * s + scaledVertex.y * c
        );

        vec2 worldPosition = a_position + rotatedVertex;

        vec2 clipSpace = ((worldPosition / u_resolution) * 2.0) - 1.0;

        gl_Position = vec4(clipSpace * vec2(1.0, -1.0), 0.0, 1.0);

        v_color = a_color;
        v_opacity = a_opacity;
    }
`;

const fragmentShaderSource = `#version 300 es
    precision highp float;

    in vec3 v_color;
    in float v_opacity;

    out vec4 outColor;

    void main() {
        outColor = vec4(v_color, v_opacity);
    }
`;

Add the following createShader helper function at the end of main.js. It compiles a shader from its source code and checks if compilation was successful.

function createShader(gl, type, source) {
    const shader = gl.createShader(type);

    gl.shaderSource(shader, source);
    gl.compileShader(shader);

    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
        console.error(gl.getShaderInfoLog(shader));
        gl.deleteShader(shader);
        return null;
    }

    return shader;
}

Next, create the vertex and fragment shaders and link them into a single shader program. We will use this program later when uploading particle data and drawing the particles. Add this code after the vertexShaderSource and fragmentShaderSource declarations:

// Create and link the shader program from the vertex and fragment shaders.

const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);

const program = gl.createProgram();

gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);

if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program));
    throw new Error('Failed to link shader program');
}

Creating Buffers

Before WebGL can render particles, their data must be stored in GPU buffers. In this example, we create two buffers: one containing the vertices of a rectangle reused for every particle, and another used to upload particle data to the GPU.

Add the following code after creating the shader program:

// Create buffers for the reusable quad geometry and per-particle instance data.

const quadVertices = new Float32Array([
    -0.5, -0.5,
     0.5, -0.5,
    -0.5,  0.5,

    -0.5,  0.5,
     0.5, -0.5,
     0.5,  0.5,
]);

const quadBuffer = gl.createBuffer();

gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
gl.bufferData(gl.ARRAY_BUFFER, quadVertices, gl.STATIC_DRAW);

const particleBuffer = gl.createBuffer();

The quad buffer stores the six vertices needed to draw a rectangle (rectangles are drawn as two triangles). Each particle will use this same geometry, while its position, size, rotation, color, and opacity will be provided separately as particle attributes.

The particle buffer will be updated every frame with the current state from engine.particles.

Uploading Particle Data

Before rendering, we need particle data in a format that can be passed to WebGL. Each particle contributes its position, size, rotation, color, and opacity as vertex attributes.

Create a buffer array that will hold the particle data. Add this after creating the WebGL buffers:

const particleData = new Float32Array(50000 * FlatParticleDataFormat.FLOATS_PER_PARTICLE);

To fill this buffer, call Gnist's fillFlatArray() method after engine.update(dt) in the main loop. This will copy current particle state into the particleData buffer array as a contiguous Float32Array that can be uploaded directly to the WebGL buffer. The returned particle count will be used later.

engine.update(dt);
const particleCount = engine.fillFlatArray(particleData);

See Exporting Particle Data for details about the structure of the filled flat particle array.

Rendering

With the shader program created and the particle data available, we can now render the particles.

Setting Up Attribute Locations

First, retrieve the locations of the attributes and uniforms used by the shaders. These locations will be used later when connecting the quad buffer and particle data buffer to the shader program.

Add this code after creating the shader program and buffers:

// Retrieve shader attribute and uniform locations used during rendering.

const quadVertexLocation = gl.getAttribLocation(program, 'a_quadVertex');
const positionLocation = gl.getAttribLocation(program, 'a_position');
const sizeLocation = gl.getAttribLocation(program, 'a_size');
const rotationLocation = gl.getAttribLocation(program, 'a_rotation');
const colorLocation = gl.getAttribLocation(program, 'a_color');
const opacityLocation = gl.getAttribLocation(program, 'a_opacity');
const resolutionLocation = gl.getUniformLocation(program, 'u_resolution');

Rendering Particles

The following function performs the actual rendering. It uploads the particle data, configures the vertex attributes, and draws the particles. The quad geometry is reused for every particle, while each instance receives its own position, size, rotation, color, and opacity values. The renderer also enables alpha blending so particles with transparency can be displayed correctly.

Add this function to your main.js:

function renderParticles(particleCount) {
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

    gl.viewport(0, 0, canvas.width, canvas.height);
    gl.clearColor(0.09, 0.07, 0.16, 1);
    gl.clear(gl.COLOR_BUFFER_BIT);

    if (particleCount === 0) {
        return;
    }

    gl.useProgram(program);
    gl.uniform2f(resolutionLocation, canvas.width, canvas.height);

    // Bind the standard quad vertices (vertex attributes)
    gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
    gl.enableVertexAttribArray(quadVertexLocation);
    gl.vertexAttribPointer(quadVertexLocation, 2, gl.FLOAT, false, 0, 0);
    gl.vertexAttribDivisor(quadVertexLocation, 0);

    // Upload current particle data used by per-instance attributes.
    gl.bindBuffer(gl.ARRAY_BUFFER, particleBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, particleData.subarray(0, particleCount * FlatParticleDataFormat.FLOATS_PER_PARTICLE), gl.DYNAMIC_DRAW);

    // Stride is permanently 32 bytes (8 floats * 4 bytes per float)
    const stride = FlatParticleDataFormat.FLOATS_PER_PARTICLE * Float32Array.BYTES_PER_ELEMENT;

    // Position (Offset: 0)
    gl.enableVertexAttribArray(positionLocation);
    gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, stride, 0);
    gl.vertexAttribDivisor(positionLocation, 1);

    // Size (Offset: 2 floats * 4 bytes = 8)
    gl.enableVertexAttribArray(sizeLocation);
    gl.vertexAttribPointer(sizeLocation, 1, gl.FLOAT, false, stride, 2 * Float32Array.BYTES_PER_ELEMENT);
    gl.vertexAttribDivisor(sizeLocation, 1);

    // Rotation (Offset: 3 floats * 4 bytes = 12)
    gl.enableVertexAttribArray(rotationLocation);
    gl.vertexAttribPointer(rotationLocation, 1, gl.FLOAT, false, stride, 3 * Float32Array.BYTES_PER_ELEMENT);
    gl.vertexAttribDivisor(rotationLocation, 1);

    // Color (Offset: 4 floats * 4 bytes = 16)
    gl.enableVertexAttribArray(colorLocation);
    gl.vertexAttribPointer(colorLocation, 3, gl.FLOAT, false, stride, 4 * Float32Array.BYTES_PER_ELEMENT);
    gl.vertexAttribDivisor(colorLocation, 1);

    // Opacity (Offset: 7 floats * 4 bytes = 28)
    gl.enableVertexAttribArray(opacityLocation);
    gl.vertexAttribPointer(opacityLocation, 1, gl.FLOAT, false, stride, 7 * Float32Array.BYTES_PER_ELEMENT);
    gl.vertexAttribDivisor(opacityLocation, 1);

    // Draw particles as instanced quads (6 vertices per quad)
    gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, particleCount);
}

Updating the Main Loop

Finally, inside the main loop, replace the Canvas 2D rendering code with a call to the WebGL renderer:

engine.update(dt);
const particleCount = engine.fillFlatArray(particleData);
renderParticles(particleCount);

Final Result

The final result is the same campfire particle effect from Your First Particle System, but rendered using WebGL instead of the Canvas 2D API.

To verify that everything is working correctly, run the following:

npm run dev

If everything went according to plan, your main.js should look similar to this:

import {
  ColorRamp,
  DirectionalForce,
  EllipseEmitter,
  Gnist,
  LineEmitter,
  OpacityFade,
  FlatParticleDataFormat,
  ScaleTween,
  Turbulence,
} from '@gergelybardos/gnist';

const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl2', {
    alpha: false,
    premultipliedAlpha: false,
});

if (!gl) {
    throw new Error('WebGL2 is not supported');
}

canvas.width = 800;
canvas.height = 600;

const vertexShaderSource = `#version 300 es
    in vec2 a_quadVertex;

    in vec2 a_position;
    in float a_size;
    in float a_rotation;
    in vec3 a_color;
    in float a_opacity;

    out vec3 v_color;
    out float v_opacity;

    uniform vec2 u_resolution;

    void main() {
        float s = sin(a_rotation);
        float c = cos(a_rotation);

        vec2 scaledVertex = a_quadVertex * a_size;

        vec2 rotatedVertex = vec2(
            scaledVertex.x * c - scaledVertex.y * s,
            scaledVertex.x * s + scaledVertex.y * c
        );

        vec2 worldPosition = a_position + rotatedVertex;

        vec2 clipSpace = ((worldPosition / u_resolution) * 2.0) - 1.0;

        gl_Position = vec4(clipSpace * vec2(1.0, -1.0), 0.0, 1.0);

        v_color = a_color;
        v_opacity = a_opacity;
    }
`;

const fragmentShaderSource = `#version 300 es
    precision highp float;

    in vec3 v_color;
    in float v_opacity;

    out vec4 outColor;

    void main() {
        outColor = vec4(v_color, v_opacity);
    }
`;

// Create and link the shader program from the vertex and fragment shaders.

const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);

const program = gl.createProgram();

gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);

if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program));
    throw new Error('Failed to link shader program');
}

// Create buffers for the reusable quad geometry and per-particle instance data.

const quadVertices = new Float32Array([
    -0.5, -0.5,
    0.5, -0.5,
    -0.5,  0.5,

    -0.5,  0.5,
    0.5, -0.5,
    0.5,  0.5,
]);

const quadBuffer = gl.createBuffer();

gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
gl.bufferData(gl.ARRAY_BUFFER, quadVertices, gl.STATIC_DRAW);

const particleBuffer = gl.createBuffer();

const particleData = new Float32Array(50000 * FlatParticleDataFormat.FLOATS_PER_PARTICLE);

// Retrieve shader attribute and uniform locations used during rendering.

const quadVertexLocation = gl.getAttribLocation(program, 'a_quadVertex');
const positionLocation = gl.getAttribLocation(program, 'a_position');
const sizeLocation = gl.getAttribLocation(program, 'a_size');
const rotationLocation = gl.getAttribLocation(program, 'a_rotation');
const colorLocation = gl.getAttribLocation(program, 'a_color');
const opacityLocation = gl.getAttribLocation(program, 'a_opacity');
const resolutionLocation = gl.getUniformLocation(program, 'u_resolution');

let lastTime = performance.now();

const engine = new Gnist({
    cullingBounds: {
        xMin: 0,
        yMin: 0,
        xMax: 800,
        yMax: 600
    }
});

const flames = new EllipseEmitter({
    x: canvas.width / 2,
    y: canvas.height - 50,
    radiusX: 60,
    radiusY: 40,
    particlesPerSecond: 200,
    particleBlueprint: {
        lifespan: [1, 2],
        speed: [50, 75],
        direction: [0, -Math.PI],
        size: [7, 14],
    }
});

const flameColors = new ColorRamp({
    colors: [
        [255, 255, 230],
        [255, 245, 160],
        [255, 220, 90],
        [255, 170, 40],
        [255, 110, 20],
        [220, 60, 10],
        [140, 25, 5],
        [60, 10, 0],
    ]
});

const flameFade = new OpacityFade();

const noise = new Turbulence({
    strength: [200, 750],
    scale: 1
});

flames.addModifier(flameColors);
flames.addModifier(flameFade);
flames.addModifier(noise);

const smoke = new LineEmitter({
    x1: canvas.width / 2 - 50,
    y1: canvas.height - 25,
    x2: canvas.width / 2 + 50,
    y2: canvas.height - 25,
    particlesPerSecond: 100,
    particleBlueprint: {
        angularVelocity: [1, 3],
        lifespan: [1, 7],
        speed: [20, 60],
        direction: [
            -7 * Math.PI / 12,
            -5 * Math.PI / 12,
        ],
        size: [1, 2],
        color: [255, 255, 255],
    }
});

const smokeFade = new OpacityFade({
    startOpacity: 0.1,
    endOpacity: 0,
});

const smokeScaling = new ScaleTween({
    startScale: 0.5,
    endScale: 40,
});

smoke.addModifier(smokeFade);
smoke.addModifier(smokeScaling);

const embers = new EllipseEmitter({
    x: canvas.width / 2,
    y: canvas.height - 50,
    radiusX: 60,
    radiusY: 40,
    particlesPerSecond: 10,
    particleBlueprint: {
        lifespan: [1, 2],
        speed: [100, 300],
        direction: [
            -7 * Math.PI / 12,
            -5 * Math.PI / 12
        ],
        size: [1, 3],
    }
});

embers.addModifier(flameColors);

const wind = new DirectionalForce({
    ax: 15,
    ay: -10,
});

engine.addGlobalForce(wind);

engine.addEmitter(flames);
engine.addEmitter(smoke);
engine.addEmitter(embers);

function loop(currentTime) {
    requestAnimationFrame(loop);

    const dt = (currentTime - lastTime) / 1000;
    lastTime = currentTime;

    engine.update(dt);
    const particleCount = engine.fillFlatArray(particleData);
    renderParticles(particleCount);
}
requestAnimationFrame(loop);

function createShader(gl, type, source) {
    const shader = gl.createShader(type);

    gl.shaderSource(shader, source);
    gl.compileShader(shader);

    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
        console.error(gl.getShaderInfoLog(shader));
        gl.deleteShader(shader);
        return null;
    }

    return shader;
}

function renderParticles(particleCount) {
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

    gl.viewport(0, 0, canvas.width, canvas.height);
    gl.clearColor(0.09, 0.07, 0.16, 1);
    gl.clear(gl.COLOR_BUFFER_BIT);

    if (particleCount === 0) {
        return;
    }

    gl.useProgram(program);
    gl.uniform2f(resolutionLocation, canvas.width, canvas.height);

    // Bind the standard quad vertices (vertex attributes)
    gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
    gl.enableVertexAttribArray(quadVertexLocation);
    gl.vertexAttribPointer(quadVertexLocation, 2, gl.FLOAT, false, 0, 0);
    gl.vertexAttribDivisor(quadVertexLocation, 0);

    // Upload current particle data used by per-instance attributes.
    gl.bindBuffer(gl.ARRAY_BUFFER, particleBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, particleData.subarray(0, particleCount * FlatParticleDataFormat.FLOATS_PER_PARTICLE), gl.DYNAMIC_DRAW);

    // Stride is permanently 32 bytes (8 floats * 4 bytes per float)
    const stride = FlatParticleDataFormat.FLOATS_PER_PARTICLE * Float32Array.BYTES_PER_ELEMENT;

    // Position (Offset: 0)
    gl.enableVertexAttribArray(positionLocation);
    gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, stride, 0);
    gl.vertexAttribDivisor(positionLocation, 1);

    // Size (Offset: 2 floats * 4 bytes = 8)
    gl.enableVertexAttribArray(sizeLocation);
    gl.vertexAttribPointer(sizeLocation, 1, gl.FLOAT, false, stride, 2 * Float32Array.BYTES_PER_ELEMENT);
    gl.vertexAttribDivisor(sizeLocation, 1);

    // Rotation (Offset: 3 floats * 4 bytes = 12)
    gl.enableVertexAttribArray(rotationLocation);
    gl.vertexAttribPointer(rotationLocation, 1, gl.FLOAT, false, stride, 3 * Float32Array.BYTES_PER_ELEMENT);
    gl.vertexAttribDivisor(rotationLocation, 1);

    // Color (Offset: 4 floats * 4 bytes = 16)
    gl.enableVertexAttribArray(colorLocation);
    gl.vertexAttribPointer(colorLocation, 3, gl.FLOAT, false, stride, 4 * Float32Array.BYTES_PER_ELEMENT);
    gl.vertexAttribDivisor(colorLocation, 1);

    // Opacity (Offset: 7 floats * 4 bytes = 28)
    gl.enableVertexAttribArray(opacityLocation);
    gl.vertexAttribPointer(opacityLocation, 1, gl.FLOAT, false, stride, 7 * Float32Array.BYTES_PER_ELEMENT);
    gl.vertexAttribDivisor(opacityLocation, 1);

    // Draw particles as instanced quads (6 vertices per quad)
    gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, particleCount);
}