Files
skills/shader-dev/techniques/particle-system.md
shihao 6487becf60 Initial commit: add all skills files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:52:49 +08:00

45 KiB

Particle System

IMPORTANT: GLSL ES 3.0 return values: All code paths in non-void functions must return a value. Every branch of an if statement must have a return.

IMPORTANT: Particle system time cycling: Must use mod(time - offset, period) for cycle computation. Never use floor(time / period) * period! The latter causes all particles to have negative time initially, resulting in startup delay or blank rendering.

IMPORTANT: Brightness budget (most common failure cause!): Each particle's numerator / (dist² + epsilon) peak = numerator / epsilon. Total peak = N_particles x (numerator / epsilon) must be < 5.0 (single pass). Exceeding this budget causes washout even with Reinhard. See specific reference values in the comments of each template below. Multi-pass ping-pong systems have a stricter budget, see below.

IMPORTANT: Particle color vs background contrast: When particle color is close to the background (sand dust/snow/fog), visibility must be enhanced through at least one method: (1) brightness significantly higher than background (2) different hue (3) visible motion trail.

IMPORTANT: Elongated glow (meteor/trail lines): Do not use 1/(distPerp² + tiny_epsilon) for line glow — too-small epsilon makes the line center extremely bright. Correct approach: use smoothstep or exp(-dist) for lines, see meteor template below.

Correct Pattern

// Vertex shader
#version 300 es
in vec4 aPosition;
void main() {
    gl_Position = aPosition;
}

// Fragment shader
#version 300 es
precision highp float;
uniform vec2 iResolution;
uniform float iTime;
out vec4 fragColor;

float hash11(float p) {
    return fract(sin(p * 127.1) * 43758.5453);
}

void main() {
    vec2 uv = gl_FragCoord.xy / iResolution.xy;
    // ... particle system logic
    fragColor = vec4(col, 1.0);
}

Complete Single-File Template (Fireworks Particle System)

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Fireworks</title>
    <style>
        body { margin: 0; overflow: hidden; background: #000; }
        canvas { display: block; width: 100vw; height: 100vh; }
    </style>
</head>
<body>
<canvas id="c"></canvas>
<script>
const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl2');

const vs = `#version 300 es
in vec4 aPosition;
void main() { gl_Position = aPosition; }`;

const fs = `#version 300 es
precision highp float;
uniform vec2 iResolution;
uniform float iTime;
out vec4 fragColor;

#define NUM_FIREWORKS 4
#define PARTICLES_PER_FIREWORK 40
#define PI 3.14159265

float hash11(float p) {
    return fract(sin(p * 127.1) * 43758.5453);
}

vec3 hsv2rgb(vec3 c) {
    vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
    vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
    return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

void main() {
    vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y;
    vec3 col = vec3(0.01, 0.01, 0.03);
    float t = iTime;

    for (int fw = 0; fw < NUM_FIREWORKS; fw++) {
        float fwId = float(fw);
        float launchTime = 1.5 + fwId * 1.8 + hash11(fwId * 7.3) * 1.5;
        float cycleTime = 4.5;
        float fireworkTime = mod(t - launchTime, cycleTime);

        float launchDuration = 0.6;
        float launchPhase = clamp(fireworkTime / launchDuration, 0.0, 1.0);

        float launchX = (hash11(fwId * 13.7) - 0.5) * 1.2;
        float launchY = -0.8;
        float peakY = 0.2 + hash11(fwId * 17.3) * 0.4;

        float baseHue = hash11(fwId * 23.0);

        if (fireworkTime < launchDuration) {
            vec2 rocketPos = vec2(launchX, mix(launchY, peakY, launchPhase));
            vec2 rel = uv - rocketPos;
            float dist = length(rel);
            // Rocket head: peak = 0.008/0.002 = 4.0, only 1 point, OK
            float rocket = 0.008 / (dist * dist + 0.002);
            float tail = 0.004 / (length(vec2(rel.x * 4.0, max(rel.y, 0.0))) + 0.01);
            col += vec3(1.0, 0.8, 0.5) * rocket;
            col += vec3(1.0, 0.5, 0.2) * tail * (1.0 - launchPhase);
        } else {
            float burstTime = fireworkTime - launchDuration;
            float burstDuration = cycleTime - launchDuration;
            float burstPhase = burstTime / burstDuration;

            for (int p = 0; p < PARTICLES_PER_FIREWORK; p++) {
                float pId = float(p);
                float angle = hash11(fwId * 100.0 + pId * 7.3) * PI * 2.0;
                float speed = 0.3 + hash11(fwId * 100.0 + pId * 13.7) * 0.7;

                vec2 particleDir = vec2(cos(angle), sin(angle));

                float damping = exp(-burstPhase * 2.5);
                float travelDist = speed * (1.0 - damping) / 2.5;
                vec2 particlePos = vec2(launchX, peakY) + particleDir * travelDist;

                particlePos.y -= 0.5 * 1.2 * burstTime * burstTime;

                vec2 rel = uv - particlePos;
                float dist = length(rel);

                float fade = smoothstep(1.0, 0.0, burstPhase);
                fade *= fade;

                // IMPORTANT: Brightness budget: peak = 0.015/0.03 = 0.5, x 40 particles x fade ≈ total peak < 5.0
                float intensity = fade * 0.015 / (dist * dist + 0.03);

                float hueOffset = (hash11(pId * 3.7 + fwId * 11.0) - 0.5) * 0.15;
                vec3 particleColor = hsv2rgb(vec3(baseHue + hueOffset, 0.8, 1.0));

                col += particleColor * intensity;
            }
        }
    }

    col = col / (1.0 + col);
    col = pow(col, vec3(0.9));
    fragColor = vec4(col, 1.0);
}`;

function createShader(type, source) {
    const s = gl.createShader(type);
    gl.shaderSource(s, source);
    gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
        console.error(gl.getShaderInfoLog(s));
    }
    return s;
}

const program = gl.createProgram();
gl.attachShader(program, createShader(gl.VERTEX_SHADER, vs));
gl.attachShader(program, createShader(gl.FRAGMENT_SHADER, fs));
gl.linkProgram(program);
gl.useProgram(program);

const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,1,1]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(program, 'aPosition');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

// Cache uniform locations (do NOT look up every frame - causes performance issues)
const iResolutionLoc = gl.getUniformLocation(program, 'iResolution');
const iTimeLoc = gl.getUniformLocation(program, 'iTime');

// Resize handler: only resize when window size actually changes
let lastWidth = 0, lastHeight = 0;
function resize() {
    const w = window.innerWidth, h = window.innerHeight;
    if (w !== lastWidth || h !== lastHeight) {
        lastWidth = w; lastHeight = h;
        canvas.width = w; canvas.height = h;
        gl.viewport(0, 0, w, h);
    }
}

function render(t) {
    resize();
    gl.uniform2f(iResolutionLoc, canvas.width, canvas.height);
    gl.uniform1f(iTimeLoc, t * 0.001);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    requestAnimationFrame(render);
}
requestAnimationFrame(render);
</script>
</body>
</html>

Stateful Particle System HTML Template (Multi-Pass Ping-Pong)

Stateful particles (Boids, cloth, fluid particles, etc.) need Buffers for inter-frame state storage. The following JS skeleton demonstrates the correct WebGL2 multi-pass ping-pong structure; shader code is in Steps 4-5 below:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Stateful Particles</title>
    <style>
        body { margin: 0; overflow: hidden; background: #000; }
        canvas { display: block; width: 100vw; height: 100vh; }
    </style>
</head>
<body>
<canvas id="c"></canvas>
<script>
let frameCount = 0;
let mouse = [0, 0, 0, 0];

const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl2');
const ext = gl.getExtension('EXT_color_buffer_float');

function createShader(type, src) {
    const s = gl.createShader(type);
    gl.shaderSource(s, src);
    gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
        console.error(gl.getShaderInfoLog(s));
    return s;
}
function createProgram(vsSrc, fsSrc) {
    const p = gl.createProgram();
    gl.attachShader(p, createShader(gl.VERTEX_SHADER, vsSrc));
    gl.attachShader(p, createShader(gl.FRAGMENT_SHADER, fsSrc));
    gl.linkProgram(p);
    return p;
}

const vsSource = `#version 300 es
in vec2 pos;
void main(){ gl_Position=vec4(pos,0,1); }`;

// fsBuffer: particle physics update (see Step 4)
// fsImage:  particle rendering (see Step 5)
const progBuf = createProgram(vsSource, fsBuffer);
const progImg = createProgram(vsSource, fsImage);

function createFBO(w, h) {
    const tex = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, tex);
    const fmt = ext ? gl.RGBA16F : gl.RGBA8;
    const typ = ext ? gl.FLOAT : gl.UNSIGNED_BYTE;
    gl.texImage2D(gl.TEXTURE_2D, 0, fmt, w, h, 0, gl.RGBA, typ, null);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    const fbo = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    return { fbo, tex };
}

let W, H, bufA, bufB;

const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);

function resize() {
    canvas.width = W = innerWidth;
    canvas.height = H = innerHeight;
    bufA = createFBO(W, H);
    bufB = createFBO(W, H);
    frameCount = 0;
}
addEventListener('resize', resize);
resize();

canvas.addEventListener('mousedown', e => { mouse[2] = e.clientX; mouse[3] = H - e.clientY; });
canvas.addEventListener('mouseup', () => { mouse[2] = 0; mouse[3] = 0; });
canvas.addEventListener('mousemove', e => { mouse[0] = e.clientX; mouse[1] = H - e.clientY; });

function render(t) {
    t *= 0.001;
    // Buffer pass: read bufA → write bufB
    gl.useProgram(progBuf);
    gl.bindFramebuffer(gl.FRAMEBUFFER, bufB.fbo);
    gl.viewport(0, 0, W, H);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, bufA.tex);
    gl.uniform1i(gl.getUniformLocation(progBuf, 'iChannel0'), 0);
    gl.uniform2f(gl.getUniformLocation(progBuf, 'iResolution'), W, H);
    gl.uniform1f(gl.getUniformLocation(progBuf, 'iTime'), t);
    gl.uniform1i(gl.getUniformLocation(progBuf, 'iFrame'), frameCount);
    gl.uniform4f(gl.getUniformLocation(progBuf, 'iMouse'), ...mouse);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    [bufA, bufB] = [bufB, bufA];

    // Image pass: read bufA → screen
    gl.useProgram(progImg);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.viewport(0, 0, W, H);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, bufA.tex);
    gl.uniform1i(gl.getUniformLocation(progImg, 'iChannel0'), 0);
    gl.uniform2f(gl.getUniformLocation(progImg, 'iResolution'), W, H);
    gl.uniform1f(gl.getUniformLocation(progImg, 'iTime'), t);
    gl.uniform1i(gl.getUniformLocation(progImg, 'iFrame'), frameCount);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

    frameCount++;
    requestAnimationFrame(render);
}
requestAnimationFrame(render);
</script>
</body>
</html>

Star Field Function Template (For Meteors, Fireworks, and Other Night Sky Scenes)

IMPORTANT: Star visibility: Stars must be clearly visible in screenshots as individual light points. Use exp(-dist*dist*k) for sharp Gaussian dots rather than 1/(dist²+eps) broad glow. Each star's peak brightness should be at least 0.3 to be visible against a dark background.

#define NUM_STARS 200

vec3 starField(vec2 uv) {
    vec3 col = vec3(0.0);
    for (int i = 0; i < NUM_STARS; i++) {
        float fi = float(i);
        vec2 starPos = vec2(hash11(fi * 13.7 + 1.3), hash11(fi * 7.3 + 91.1));
        starPos = starPos * 2.0 - 1.0;
        float brightness = 0.3 + 0.7 * pow(hash11(fi * 3.1 + 47.0), 2.0);
        float twinkle = 0.7 + 0.3 * sin(iTime * (1.0 + hash11(fi * 5.7) * 3.0) + fi * 6.28);
        brightness *= twinkle;
        float dist = length(uv - starPos);
        // Sharp Gaussian dot: peak = brightness (0.3~1.0), very small radius, no accumulation washout
        float glow = brightness * exp(-dist * dist * 8000.0);
        // Add soft halo to make stars more visible
        glow += brightness * 0.0008 / (dist * dist + 0.003);
        float temp = hash11(fi * 11.3 + 23.0);
        vec3 starCol = mix(vec3(0.6, 0.7, 1.0), vec3(1.0, 0.9, 0.7), temp);
        col += starCol * glow;
    }
    return col;
}

Incorrect Pattern (Do Not Do This)

// WRONG: cannot write this in standalone HTML
void mainImage(out vec4 fragColor, in vec2 fragCoord) { ... }
void main() {
    mainImage(fragColor, fragCoord);  // compilation error!
}

ShaderToy vs Standalone HTML Code Templates

The following code examples fall into two categories; be sure to use the correct template:

Standalone HTML Template (complete example provided above)

ShaderToy Template (for the ShaderToy website)

Use Cases

  • Stateless particle effects: fireworks, starfields, campfire/flames (flying embers), orbiting light points, and other decorative effects that don't need inter-frame memory
  • Stateful physics particles: flocking/boids, raindrops, cloth, fluids, and other simulations requiring persistent position and velocity
  • Motion blur and trails: particle trajectories needing afterglow or halo effects
  • Large-scale particle management: real-time rendering and interaction with hundreds to thousands of particles
  • Weather/atmospheric effects: sandstorms, blizzards, volcanic ash, and other vortex-driven particle systems
  • Magic/geometric arrays: magic dust, spiraling ascending light points, magic circle rings, iridescent shimmering particles

Core decision tree: Do particles need inter-frame memory?

  • No → Single-pass stateless system (using loops + hash functions)
  • Yes → Multi-pass stateful system (using Buffer for position/velocity storage)

Core Principles

Particle systems manage collections of many independent entities, each with position, velocity, lifetime, and other attributes.

Stateless paradigm: All attributes are recomputed each frame from particle ID and time, no Buffer needed.

position_i = trajectory(id_i, time) + randomOffset(hash(id_i))
lifetime_i = fract((time - spawnTime_i) / lifeDuration_i)

Stateful paradigm: Particle state is stored in Buffer texture pixels, each frame reading → computing → writing back.

// Euler method
velocity += acceleration * dt
position += velocity * dt

// Verlet integration (no explicit velocity, more stable)
newPos = 2 * currentPos - previousPos + acceleration * dt²

Rendering: Distance-based falloff intensity = brightness / (dist² + epsilon), with multi-particle superposition creating metaball fusion effects.

Implementation Steps

Step 1: Hash Random Functions

// 1D -> 1D hash, returns [0, 1)
float hash11(float p) {
    return fract(sin(p * 127.1) * 43758.5453);
}

// 1D -> 2D hash
vec2 hash12(float p) {
    vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973));
    p3 += dot(p3, p3.yzx + 33.33);
    return fract((p3.xx + p3.yz) * p3.zy);
}

// 3D -> 3D hash
vec3 hash33(vec3 p) {
    p = fract(p * vec3(443.897, 397.297, 491.187));
    p += dot(p.zxy, p.yxz + 19.19);
    return fract(vec3(p.x * p.y, p.z * p.x, p.y * p.z)) - 0.5;
}

Step 2: Particle Lifecycle Management

#define NUM_PARTICLES 100
#define LIFETIME_MIN 1.0
#define LIFETIME_MAX 3.0
#define START_TIME 2.0

// Returns: x = normalized age [0,1], y = life cycle number
vec2 particleAge(int id, float time) {
    float spawnTime = START_TIME * hash11(float(id) * 2.0);
    float lifetime = mix(LIFETIME_MIN, LIFETIME_MAX, hash11(float(id) * 3.0 - 35.0));
    float age = mod(time - spawnTime, lifetime);
    float run = floor((time - spawnTime) / lifetime);
    return vec2(age / lifetime, run);
}

Step 3: Stateless Particle Position Computation

#define GRAVITY vec2(0.0, -4.5)
#define DRIFT_MAX vec2(0.28, 0.28)

// Harmonic superposition for smooth main trajectory
float harmonics(vec3 freq, vec3 amp, vec3 phase, float t) {
    float val = 0.0;
    for (int h = 0; h < 3; h++)
        val += amp[h] * cos(t * freq[h] * 6.2832 + phase[h] / 360.0 * 6.2832);
    return (1.0 + val) / 2.0;
}

vec2 particlePosition(int id, float time) {
    vec2 ageInfo = particleAge(id, time);
    float age = ageInfo.x;
    float run = ageInfo.y;

    float slowTime = time * 0.1;
    vec2 mainPos = vec2(
        harmonics(vec3(0.4, 0.66, 0.78), vec3(0.8, 0.24, 0.18), vec3(0.0, 45.0, 55.0), slowTime),
        harmonics(vec3(0.415, 0.61, 0.82), vec3(0.72, 0.28, 0.15), vec3(90.0, 120.0, 10.0), slowTime)
    );

    vec2 drift = DRIFT_MAX * (vec2(hash11(float(id) * 3.0 + run * 4.0),
                                    hash11(float(id) * 7.0 - run * 2.5)) - 0.5) * age;
    vec2 grav = GRAVITY * age * age * 0.5;

    return mainPos + drift + grav;
}

Step 4: Buffer-Stored Particle State (Stateful System)

// === Buffer A: Particle Physics Update ===
// IMPORTANT: Multi-pass system warning: each fragment shader is compiled independently, helper functions must be redefined in each shader!
#define NUM_PARTICLES 40
#define MAX_VEL 0.5
#define MAX_ACC 3.0
#define RESIST 0.2
#define DT 0.03

// Helper functions that must be defined in the Buffer A shader
float hash11(float p) {
    return fract(sin(p * 127.1) * 43758.5453);
}

vec2 hash12(float p) {
    vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973));
    p3 += dot(p3, p3.yzx + 33.33);
    return fract((p3.xx + p3.yz) * p3.zy);
}

vec4 loadParticle(float i) {
    return texelFetch(iChannel0, ivec2(i, 0), 0);
}

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    if (fragCoord.y > 0.5 || fragCoord.x > float(NUM_PARTICLES)) discard;

    float id = floor(fragCoord.x);
    vec2 res = iResolution.xy / iResolution.y;

    if (iFrame < 5) {
        vec2 rng = hash12(id);
        fragColor = vec4(0.1 + 0.8 * rng * res, 0.0, 0.0);
        return;
    }

    vec4 particle = loadParticle(id); // xy = pos, zw = vel
    vec2 pos = particle.xy;
    vec2 vel = particle.zw;

    vec2 force = vec2(0.0);
    force += 0.8 * (1.0 / abs(pos) - 1.0 / abs(res - pos)); // boundary repulsion
    for (float i = 0.0; i < float(NUM_PARTICLES); i++) {     // inter-particle interaction
        if (i == id) continue;
        vec4 other = loadParticle(i);
        vec2 w = pos - other.xy;
        float d = length(w);
        if (d > 0.0)
            force -= w * (6.3 + log(d * d * 0.02)) / exp(d * d * 2.4) / d;
    }
    force -= vel * RESIST / DT; // friction

    vec2 acc = force;
    float a = length(acc);
    acc *= a > MAX_ACC ? MAX_ACC / a : 1.0;
    vel += acc * DT;
    float v = length(vel);
    vel *= v > MAX_VEL ? MAX_VEL / v : 1.0;
    pos += vel * DT;

    fragColor = vec4(pos, vel);
}

Step 5: Particle Rendering — Metaball Style

// IMPORTANT: Multi-pass system warning: Image shader must define all the following helper functions (compiled independently)!

#define BRIGHTNESS 0.002
#define COLOR_START vec3(0.0, 0.64, 0.2)
#define COLOR_END vec3(0.06, 0.35, 0.85)

// Helper functions that must be defined in the Image shader
float hash11(float p) {
    return fract(sin(p * 127.1) * 43758.5453);
}

vec2 hash12(float p) {
    vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973));
    p3 += dot(p3, p3.yzx + 33.33);
    return fract((p3.xx + p3.yz) * p3.zy);
}

vec4 loadParticle(float i) {
    return texelFetch(iChannel0, ivec2(i, 0), 0);
}

// HSV to RGB (correct implementation)
vec3 hsv2rgb(vec3 c) {
    vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
    vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
    return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

vec3 renderParticles(vec2 uv) {
    vec3 col = vec3(0.0);
    float totalWeight = 0.0;
    for (int i = 0; i < NUM_PARTICLES; i++) {
        vec4 particle = loadParticle(float(i));
        vec2 p = uv - particle.xy;
        float mb = BRIGHTNESS / dot(p, p);
        totalWeight += mb;
        float ratio = length(particle.zw) / MAX_VEL;
        vec3 pcol = mix(COLOR_START, COLOR_END, ratio);
        col = mix(col, pcol, mb / totalWeight);
    }
    totalWeight /= float(NUM_PARTICLES);
    col = normalize(col) * clamp(totalWeight, 0.0, 0.4);
    return col;
}

Step 6: Frame Feedback Motion Blur

// IMPORTANT: Ping-pong brightness budget (most common washout cause!):
// Steady-state brightness = singleFrameContribution / (1 - TRAIL_DECAY)
// decay=0.88 → 8.3x amplification, decay=0.95 → 20x amplification
// Budget formula: N_particles x (numerator/epsilon) x 1/(1-decay) < 10.0
//
// Safe parameter lookup table (decay=0.88, 8.3x amplification):
//   20 particles → single particle peak < 0.06  (numerator=0.002, epsilon=0.03)
//   50 particles → single particle peak < 0.024 (numerator=0.001, epsilon=0.04)
//  100 particles → single particle peak < 0.012 (numerator=0.0005, epsilon=0.04)
//
// Safe parameter lookup table (decay=0.92, 12.5x amplification):
//   20 particles → single particle peak < 0.04  (numerator=0.001, epsilon=0.03)
//   50 particles → single particle peak < 0.016 (numerator=0.0005, epsilon=0.03)
//  100 particles → single particle peak < 0.008 (numerator=0.0003, epsilon=0.04)
#define TRAIL_DECAY 0.88

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = fragCoord / iResolution.xy;
    vec3 prev = texture(iChannel0, uv).rgb * TRAIL_DECAY;
    vec3 current = renderParticles(fragCoord / iResolution.y);
    fragColor = vec4(prev + current, 1.0);
}

Step 7: HSV Coloring & Star Glare Effect

// HSV to RGB (correct implementation)
vec3 hsv2rgb(vec3 c) {
    vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
    vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
    return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

// Star glare: thin rays in horizontal/vertical/diagonal directions
float starGlare(vec2 relPos, float intensity) {
    vec2 stretch = vec2(9.0, 0.32);
    float dh = length(relPos * stretch);
    float dv = length(relPos * stretch.yx);
    vec2 diagPos = 0.707 * vec2(dot(relPos, vec2(1, 1)), dot(relPos, vec2(1, -1)));
    float dd1 = length(diagPos * vec2(13.0, 0.61));
    float dd2 = length(diagPos * vec2(0.61, 13.0));
    float glare = 0.25 / (dh * 3.0 + 0.01)
                + 0.25 / (dv * 3.0 + 0.01)
                + 0.19 / (dd1 * 3.0 + 0.01)
                + 0.19 / (dd2 * 3.0 + 0.01);
    return glare * intensity;
}

Complete Code Template

Single-pass stateless particle system, runs directly in ShaderToy's Image tab:

// === Particle System — Stateless Single-Pass Template ===

#define NUM_PARTICLES 80
#define LIFETIME_MIN 1.0
#define LIFETIME_MAX 3.5
#define START_TIME 2.5
#define BRIGHTNESS 0.00004
#define GRAVITY vec2(0.0, -2.0)
#define DRIFT_SPEED 0.2
#define HUE_SHIFT 0.035
#define TRAIL_DECAY 0.92
#define STAR_ENABLED 1

#define PI 3.14159265
#define TAU 6.28318530

float hash11(float p) {
    return fract(sin(p * 127.1) * 43758.5453);
}

vec3 hsv2rgb(vec3 c) {
    vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
    vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
    return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

float harmonics3(vec3 freq, vec3 amp, vec3 phase, float t) {
    float val = 0.0;
    for (int h = 0; h < 3; h++)
        val += amp[h] * cos(t * freq[h] * TAU + phase[h] / 360.0 * TAU);
    return (1.0 + val) * 0.5;
}

vec3 getLifecycle(int id, float time) {
    float spawn = START_TIME * hash11(float(id) * 2.0);
    float life = mix(LIFETIME_MIN, LIFETIME_MAX, hash11(float(id) * 3.0 - 35.0));
    float age = mod(time - spawn, life);
    float run = floor((time - spawn) / life);
    return vec3(age / life, run, spawn);
}

vec2 getPosition(int id, float time) {
    vec3 lc = getLifecycle(id, time);
    float age = lc.x;
    float run = lc.y;

    float tfact = mix(6.0, 20.0, hash11(float(id) * 2.0 + 94.0 + run * 1.5));
    float pt = (run * lc.x * mix(LIFETIME_MIN, LIFETIME_MAX, hash11(float(id)*3.0-35.0)) + lc.z) * (-1.0/tfact + 1.0) + time / tfact;

    vec2 mainPos = vec2(
        harmonics3(vec3(0.4, 0.66, 0.78), vec3(0.8, 0.24, 0.18), vec3(0.0, 45.0, 55.0), pt),
        harmonics3(vec3(0.415, 0.61, 0.82), vec3(0.72, 0.28, 0.15), vec3(90.0, 120.0, 10.0), pt)
    ) + vec2(0.35, 0.15);

    vec2 drift = DRIFT_SPEED * (vec2(
        hash11(float(id) * 3.0 - 23.0 + run * 4.0),
        hash11(float(id) * 7.0 + 632.0 - run * 2.5)
    ) - 0.5) * age;

    vec2 grav = GRAVITY * age * age * 0.004;

    return (mainPos + drift + grav) * vec2(0.6, 0.45);
}

float starGlare(vec2 rel) {
    #if STAR_ENABLED == 0
        return 0.0;
    #endif
    vec2 stretchHV = vec2(9.0, 0.32);
    float dh = length(rel * stretchHV);
    float dv = length(rel * stretchHV.yx);
    vec2 dRel = 0.707 * vec2(dot(rel, vec2(1, 1)), dot(rel, vec2(1, -1)));
    vec2 stretchDiag = vec2(13.0, 0.61);
    float dd1 = length(dRel * stretchDiag);
    float dd2 = length(dRel * stretchDiag.yx);
    return 0.25 / (dh * 3.0 + 0.01) + 0.25 / (dv * 3.0 + 0.01)
         + 0.19 / (dd1 * 3.0 + 0.01) + 0.19 / (dd2 * 3.0 + 0.01);
}

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = fragCoord.xy / iResolution.xx;
    float time = iTime * 0.75;

    vec3 col = vec3(0.0);

    for (int i = 1; i < NUM_PARTICLES; i++) {
        vec3 lc = getLifecycle(i, time);
        float age = lc.x;
        float run = lc.y;

        vec2 ppos = getPosition(i, time);
        vec2 rel = uv - ppos;
        float dist = length(rel);

        float baseInt = mix(0.1, 3.2, hash11(run * 4.0 + float(i) - 55.0));
        float glow = 1.0 / (dist * 3.0 + 0.015);

        float star = starGlare(rel);
        float intensity = baseInt * pow(glow + star, 2.3) / 40000.0;

        intensity *= (1.0 - age);
        intensity *= smoothstep(0.0, 0.15, age);
        float sparkFreq = mix(2.5, 6.0, hash11(float(i) * 5.0 + 72.0 - run * 1.8));
        intensity *= 0.5 * sin(sparkFreq * TAU * time) + 1.0;

        float hue = mix(-0.13, 0.13, hash11(float(i) + 124.0 + run * 1.5)) + HUE_SHIFT * time;
        float sat = mix(0.5, 0.9, hash11(float(i) * 6.0 + 44.0 + run * 3.3)) * 0.45 / max(intensity, 0.001);
        col += hsv2rgb(vec3(hue, clamp(sat, 0.0, 1.0), intensity));
    }

    col = pow(max(col, 0.0), vec3(1.0 / 2.2));
    fragColor = vec4(col, 1.0);
}

Common Variants

Variant 1: Metaball Polar Coordinate Particles

float d = fract(time * 0.51 + 48934.4238 * sin(float(i) * 692.7398));
float angle = TAU * float(i) / float(NUM_PARTICLES);
vec2 particlePos = d * vec2(cos(angle), sin(angle)) * 4.0;

vec2 p = uv - particlePos;
float mb = 0.84 / dot(p, p);
col = mix(col, mix(startColor, endColor, d), mb / totalSum);

Variant 2: Buffer Storage + Boids Flocking Behavior

vec2 sumForce = vec2(0.0);
for (float j = 0.0; j < NUM_PARTICLES; j++) {
    if (j == id) continue;
    vec4 other = texelFetch(iChannel0, ivec2(j, 0), 0);
    vec2 w = pos - other.xy;
    float d = length(w);
    sumForce -= w * (6.3 + log(d * d * 0.02)) / exp(d * d * 2.4) / d;
}
sumForce -= vel * 0.2 / dt;

Variant 3: Verlet Integration Cloth Simulation

vec2 newPos = 2.0 * particle.xy - particle.zw + vec2(0.0, -0.6) * dt * dt;
particle.zw = particle.xy;
particle.xy = newPos;

vec4 neighbor = texelFetch(iChannel0, neighborId, 0);
vec2 delta = neighbor.xy - particle.xy;
float dist = length(delta);
float restLength = 0.1;
particle.xy += 0.1 * (dist - restLength) * (delta / dist);

Variant 4: 3D Particles + Ray Rendering

vec3 ro = vec3(0.0, 0.0, 2.5);
vec3 rd = normalize(vec3(uv, -0.5));
for (int i = 0; i < numParticles; i++) {
    vec3 pos = texture(iChannel0, vec2(i, 100.0) * w).rgb;
    float d = dot(cross(pos - ro, rd), cross(pos - ro, rd));
    d *= 1000.0;
    float glow = 0.14 / (pow(d, 1.1) + 0.03);
    col += glow * particleColor;
}

Variant 5: Raindrop Particles (3D Scene Integration)

float speedScale = 0.0015 * (0.1 + 1.9 * sin(PI * 0.5 * pow(age / lifetime, 2.0)));
particle.x += (windShieldOffset.x + windIntensity * dot(rayRight, windDir)) * fallSpeed * speedScale * dt;
particle.y += (windShieldOffset.y + windIntensity * dot(rayUp, windDir)) * fallSpeed * speedScale * dt;
particle.xy += 0.001 * (randVec2(particle.xy + iTime) - 0.5) * jitterSpeed * dt;
if (particle.z > particle.a) {
    particle.xy = vec2(rand(seedX), rand(seedY)) * iResolution.xy;
    particle.a = lifetimeMin + rand(pid) * (lifetimeMax - lifetimeMin);
    particle.z = 0.0;
}

Variant 6: Vortex/Storm Particle System (Sandstorm, Blizzard, Whirlwind, etc.)

Uses stateless single pass. Key: spiral trajectory + high-visibility particles + vortex eye dark zone + separated background fog layer.

// IMPORTANT: Particle color must be 2-3x brighter than background to be visible (sand-colored particles on sand-colored background easily disappear)
// IMPORTANT: Brightness budget: 150 particles x peak(0.005/0.003=1.67) x fade(avg~0.3) ≈ 75, overexposed!
//    Must increase epsilon or decrease numerator. Safe values: numerator=0.002, epsilon=0.008 → peak=0.25, total=11 → OK after Reinhard
#define NUM_DUST 150
#define VORTEX_CENTER vec2(0.0)

void main() {
    vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y;
    float t = iTime;

    vec3 bg = mix(vec3(0.25, 0.18, 0.08), vec3(0.4, 0.28, 0.12), gl_FragCoord.y / iResolution.y);

    vec3 col = vec3(0.0);
    for (int i = 0; i < NUM_DUST; i++) {
        float fi = float(i);
        float life = mix(2.0, 5.0, hash11(fi * 3.7));
        float age = mod(t - hash11(fi * 2.0) * life, life);
        float norm = age / life;

        float initAngle = hash11(fi * 7.3) * 6.2832;
        float initR = 0.05 + hash11(fi * 11.0) * 0.5;
        float angularSpeed = 2.0 / (0.3 + initR);
        float angle = initAngle + norm * angularSpeed;
        float radius = initR + norm * 0.15;

        vec2 pos = VORTEX_CENTER + vec2(cos(angle), sin(angle)) * radius;

        vec2 rel = uv - pos;
        float dist = length(rel);

        float fade = smoothstep(0.0, 0.1, norm) * smoothstep(1.0, 0.5, norm);
        // Safe brightness: peak = 0.002/0.008 = 0.25, x 150 x avg_fade(0.3) ≈ 11 → Reinhard OK
        float glow = 0.002 / (dist * dist + 0.008) * fade;

        // Particles need to be noticeably brighter than background, use light sand + white blend
        vec3 dustColor = mix(vec3(1.0, 0.9, 0.6), vec3(1.0, 0.95, 0.85), hash11(fi * 5.0));
        col += dustColor * glow;
    }

    float eyeDist = length(uv - VORTEX_CENTER);
    float eye = smoothstep(0.06, 0.15, eyeDist);

    vec3 final = bg + col * eye;
    final = final / (1.0 + final);
    fragColor = vec4(final, 1.0);
}

Variant 7: Meteor/Trail Line Rendering (Single-Pass Stateless)

Meteors, magic projectiles, etc. need elongated glow (stretched luminous lines). Do not use 1/(distPerp² + tiny_epsilon) for lines — too-small epsilon makes line centers extremely bright and washed out. Use exp(-dist) or smoothstep for safe line glow.

IMPORTANT: Common meteor failures: (1) Star background too dark to see — must call starField() above and ensure stars use Gaussian dots exp(-dist²*k) for rendering (2) Meteor trail too faint — core multiplier should be at least 0.15, each step after dividing by NUM_TRAIL_STEPS still needs >= 0.005 contribution

#define NUM_METEORS 6
#define NUM_TRAIL_STEPS 20

void main() {
    vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y;
    float t = iTime;

    // Deep blue night sky background + must call starField to draw stars
    vec3 col = vec3(0.005, 0.005, 0.02);
    col += starField(uv);

    for (int m = 0; m < NUM_METEORS; m++) {
        float fm = float(m);
        float cycleTime = mix(3.0, 7.0, hash11(fm * 17.3));
        float meteorTime = mod(t - hash11(fm * 23.7) * cycleTime, cycleTime);
        float travelDuration = mix(0.5, 1.2, hash11(fm * 31.1));

        if (meteorTime > travelDuration + 0.3) continue;

        float angle = mix(-0.4, -1.3, hash11(fm * 41.3));
        vec2 dir = normalize(vec2(cos(angle), sin(angle)));
        vec2 startPos = vec2(
            mix(-0.3, 0.8, hash11(fm * 53.7)),
            mix(0.2, 0.7, hash11(fm * 61.1))
        );
        float speed = mix(1.0, 2.0, hash11(fm * 71.3));
        float headT = clamp(meteorTime / travelDuration, 0.0, 1.0);
        vec2 headPos = startPos + dir * speed * headT;

        float headFade = smoothstep(0.0, 0.1, meteorTime)
                       * smoothstep(travelDuration + 0.3, travelDuration, meteorTime);

        float trailLen = mix(0.15, 0.35, hash11(fm * 83.7));

        for (int s = 0; s < NUM_TRAIL_STEPS; s++) {
            float sf = float(s) / float(NUM_TRAIL_STEPS);
            vec2 samplePos = headPos - dir * trailLen * sf;
            vec2 rel = uv - samplePos;

            float distPerp = abs(dot(rel, vec2(-dir.y, dir.x)));

            // Line width: narrow at head, wide at tail
            float width = mix(0.003, 0.015, sf);
            // core multiplier 0.15 ensures trail is visible even under SwiftShader
            float core = exp(-distPerp / width) * 0.15;

            float trailFade = (1.0 - sf) * (1.0 - sf);
            float intensity = core * trailFade * headFade / float(NUM_TRAIL_STEPS);

            float hue = mix(0.05, 0.12, sf);
            vec3 meteorCol = hsv2rgb(vec3(hue, mix(0.1, 0.4, sf), 1.0));
            col += meteorCol * intensity;
        }

        // Meteor head: bright point
        float headDist = length(uv - headPos);
        float headGlow = headFade * 0.005 / (headDist * headDist + 0.0008);
        col += vec3(1.0, 0.95, 0.85) * headGlow;
    }

    col = col / (1.0 + col);
    col = pow(col, vec3(0.95));
    fragColor = vec4(col, 1.0);
}

Variant 8: Fountain/Upward Jet Particle System (Single-Pass Stateless)

Water/sparks jetting upward from a point, parabolic descent. Key: Particles must be sharp, individually visible points (small epsilon), not just a diffuse glow blob. Must include: (1) main water column particles (upward jet + parabola) (2) splash particles (spread sideways after hitting water) (3) water surface/pool visuals.

IMPORTANT: Most common fountain failure: Only produces blurry glow without visible individual water droplet trajectories! Must use small epsilon (<=0.002) so each particle is clearly visible as an individual light point. Numerator must also be proportionally reduced to control total brightness.

#define NUM_WATER 60
#define NUM_SPLASH 40
#define FOUNTAIN_BASE vec2(0.0, -0.3)
#define WATER_LEVEL -0.3

void main() {
    vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y;
    float t = iTime;

    // Dark background
    vec3 col = vec3(0.01, 0.02, 0.06);

    // --- Water pool/surface ---
    float waterDist = abs(uv.y - WATER_LEVEL);
    float waterLine = smoothstep(0.01, 0.0, waterDist) * 0.3;
    float waterBody = smoothstep(WATER_LEVEL, WATER_LEVEL - 0.15, uv.y);
    col += vec3(0.02, 0.06, 0.12) * waterBody;
    col += vec3(0.3, 0.5, 0.7) * waterLine;

    // --- Main water column particles: upward jet + parabola ---
    for (int i = 0; i < NUM_WATER; i++) {
        float fi = float(i);
        float lifetime = mix(1.0, 2.0, hash11(fi * 3.7));
        float age = mod(t - hash11(fi * 2.3) * lifetime, lifetime);
        float norm = age / lifetime;

        float spreadAngle = (hash11(fi * 7.3) - 0.5) * 0.6;
        float speed = mix(0.9, 1.6, hash11(fi * 11.0));
        vec2 vel0 = vec2(sin(spreadAngle), cos(spreadAngle)) * speed;

        vec2 pos = FOUNTAIN_BASE + vel0 * age + vec2(0.0, -1.8) * age * age;

        if (pos.y < WATER_LEVEL - 0.02) continue;

        vec2 rel = uv - pos;
        float dist = length(rel);

        float fade = smoothstep(0.0, 0.05, norm) * smoothstep(1.0, 0.6, norm);
        // Sharp light point: small epsilon makes each particle clearly visible as an individual dot
        // peak = 0.004/0.0015 = 2.67, x 60 x avg_fade(0.25) ≈ 40 → Reinhard OK
        float glow = 0.004 / (dist * dist + 0.0015) * fade;

        vec3 waterCol = mix(vec3(0.5, 0.8, 1.0), vec3(0.9, 0.97, 1.0), hash11(fi * 5.0));
        col += waterCol * glow;
    }

    // --- Splash particles: spread sideways at water surface ---
    for (int i = 0; i < NUM_SPLASH; i++) {
        float fi = float(i) + 200.0;
        float lifetime = mix(0.3, 0.8, hash11(fi * 3.7));
        float age = mod(t - hash11(fi * 2.3) * lifetime, lifetime);
        float norm = age / lifetime;

        float xOffset = (hash11(fi * 7.3) - 0.5) * 0.5;
        vec2 splashBase = vec2(xOffset, WATER_LEVEL);
        float splashAngle = (hash11(fi * 11.0) - 0.5) * 2.5;
        float splashSpeed = mix(0.2, 0.5, hash11(fi * 13.0));
        vec2 splashVel = vec2(sin(splashAngle), abs(cos(splashAngle))) * splashSpeed;

        vec2 pos = splashBase + splashVel * age + vec2(0.0, -2.0) * age * age;
        if (pos.y < WATER_LEVEL - 0.01) continue;

        vec2 rel = uv - pos;
        float dist = length(rel);

        float fade = smoothstep(0.0, 0.05, norm) * smoothstep(1.0, 0.3, norm);
        float glow = 0.002 / (dist * dist + 0.001) * fade;

        col += vec3(0.7, 0.85, 1.0) * glow;
    }

    col = col / (1.0 + col);
    fragColor = vec4(col, 1.0);
}

Variant 9: Campfire/Flame Particle System (Single-Pass Stateless)

Flame effects must include two layers: (1) smooth flame body at the base (noise-driven cone gradient) (2) many discrete ember/spark particles above, drifting upward and gradually extinguishing. Using only a smooth gradient will be judged as "no particle system."

IMPORTANT: Most common flame failure: Only draws a smooth gradient without discrete particles! Must have NUM_SPARKS individual point-like particles drifting out from the flame top.

#define NUM_SPARKS 60
#define FIRE_BASE vec2(0.0, -0.35)

float noise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    f = f * f * (3.0 - 2.0 * f);
    float a = hash11(dot(i, vec2(127.1, 311.7)));
    float b = hash11(dot(i + vec2(1.0, 0.0), vec2(127.1, 311.7)));
    float c = hash11(dot(i + vec2(0.0, 1.0), vec2(127.1, 311.7)));
    float d = hash11(dot(i + vec2(1.0, 1.0), vec2(127.1, 311.7)));
    return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

void main() {
    vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y;
    float t = iTime;
    vec3 col = vec3(0.02, 0.01, 0.01);

    // --- Layer 1: flame body (smooth noise cone) ---
    vec2 fireUV = uv - FIRE_BASE;
    float fireH = clamp(fireUV.y / 0.5, 0.0, 1.0);
    float width = mix(0.15, 0.01, fireH);
    float n = noise(vec2(fireUV.x * 6.0, fireUV.y * 4.0 - t * 3.0));
    float flameShape = smoothstep(width, 0.0, abs(fireUV.x + (n - 0.5) * 0.08))
                     * smoothstep(-0.02, 0.05, fireUV.y)
                     * smoothstep(0.55, 0.0, fireUV.y);
    vec3 innerCol = vec3(1.0, 0.95, 0.7);
    vec3 outerCol = vec3(1.0, 0.35, 0.05);
    vec3 flameCol = mix(outerCol, innerCol, smoothstep(0.3, 0.8, flameShape));
    col += flameCol * flameShape * 1.5;

    // --- Layer 2: discrete ember particles (required!) ---
    for (int i = 0; i < NUM_SPARKS; i++) {
        float fi = float(i);
        float lifetime = mix(0.8, 2.0, hash11(fi * 3.7));
        float age = mod(t - hash11(fi * 2.3) * lifetime, lifetime);
        float norm = age / lifetime;

        float xSpread = (hash11(fi * 7.3) - 0.5) * 0.2;
        float riseSpeed = mix(0.3, 0.7, hash11(fi * 11.0));
        float wobble = sin(t * 3.0 + fi * 2.7) * 0.03 * norm;
        vec2 sparkPos = FIRE_BASE + vec2(0.0, 0.25)
                      + vec2(xSpread + wobble, riseSpeed * age);

        vec2 rel = uv - sparkPos;
        float dist = length(rel);

        float fade = smoothstep(0.0, 0.1, norm) * smoothstep(1.0, 0.3, norm);
        // peak = 0.003/0.0008 = 3.75, x 60 x avg_fade(0.2) ≈ 45 → Reinhard OK
        float glow = 0.003 / (dist * dist + 0.0008) * fade;

        float hue = mix(0.03, 0.12, norm);
        vec3 sparkCol = hsv2rgb(vec3(hue, mix(0.9, 0.3, norm), 1.0));
        col += sparkCol * glow;
    }

    col = col / (1.0 + col);
    col = pow(col, vec3(0.95));
    fragColor = vec4(col, 1.0);
}

Variant 10: Spiral Array/Magic Particle System (Single-Pass Stateless)

Magic effects, spiral ascent, magic circles, etc. require particles arranged in geometric arrays with iridescent shimmer. Key: particles must be individually visible glowing points (not a blurry glow blob), and the spiral structure must be clearly discernible.

IMPORTANT: Most common magic failure: Only produces a blob of blurry light (diffuse glow blob) without visible individual particles or geometric structure! Ensure each particle is an independently visible small light point, and the overall arrangement forms spiral/ring/other geometric shapes. Reduce epsilon to make each particle sharper (small light dot) rather than a large blurry halo.

#define NUM_SPIRAL 80
#define NUM_RING 40
#define WAND_TIP vec2(0.0, -0.15)

void main() {
    vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y;
    float t = iTime;
    vec3 col = vec3(0.01, 0.005, 0.02);

    // --- Layer 1: spiral ascending particles (emanating from emission point) ---
    for (int i = 0; i < NUM_SPIRAL; i++) {
        float fi = float(i);
        float lifetime = mix(2.0, 4.0, hash11(fi * 3.7));
        float age = mod(t - hash11(fi * 2.3) * lifetime, lifetime);
        float norm = age / lifetime;

        // Spiral trajectory: angle increases with time and height
        float baseAngle = fi / float(NUM_SPIRAL) * 6.2832 * 3.0;
        float spiralAngle = baseAngle + norm * 8.0 + t * 1.5;
        float radius = 0.05 + norm * 0.25;
        float height = norm * 0.7;

        vec2 pos = WAND_TIP + vec2(cos(spiralAngle) * radius, height);

        vec2 rel = uv - pos;
        float dist = length(rel);

        float fade = smoothstep(0.0, 0.08, norm) * smoothstep(1.0, 0.4, norm);
        // Sharp small light point: small epsilon makes particles clearly visible as individual dots
        // peak = 0.004/0.0006 = 6.67, x 80 x avg_fade(0.25) ≈ 133 → Reinhard OK
        float glow = 0.004 / (dist * dist + 0.0006) * fade;

        // Iridescent effect: hue varies with particle ID + time, producing rainbow shimmer
        float hue = fract(fi / float(NUM_SPIRAL) + t * 0.3 + norm * 0.5);
        float shimmer = 0.7 + 0.3 * sin(t * 8.0 + fi * 3.7);
        vec3 pCol = hsv2rgb(vec3(hue, 0.7, 1.0)) * shimmer;
        col += pCol * glow;
    }

    // --- Layer 2: magic circle ring (horizontally rotating light point ring) ---
    float ringY = WAND_TIP.y + 0.45;
    float ringRadius = 0.2 + 0.03 * sin(t * 2.0);
    for (int i = 0; i < NUM_RING; i++) {
        float fi = float(i);
        float angle = fi / float(NUM_RING) * 6.2832 + t * 2.0;
        // Simulated perspective: ellipse (cos full width, sin compressed)
        vec2 ringPos = vec2(cos(angle) * ringRadius, ringY + sin(angle) * ringRadius * 0.3);

        vec2 rel = uv - ringPos;
        float dist = length(rel);

        float pulse = 0.6 + 0.4 * sin(t * 5.0 + fi * 1.5);
        // peak = 0.003/0.0004 = 7.5, x 40 x avg_pulse(0.6) ≈ 180 → Reinhard OK
        float glow = 0.003 / (dist * dist + 0.0004) * pulse;

        float hue = fract(fi / float(NUM_RING) + t * 0.5);
        vec3 rCol = hsv2rgb(vec3(hue, 0.6, 1.0));
        col += rCol * glow;
    }

    col = col / (1.0 + col);
    col = pow(col, vec3(0.9));
    fragColor = vec4(col, 1.0);
}

Performance & Composition

Performance:

  • Particle count is the biggest performance lever; use early exit if (dist > threshold) continue; for optimization
  • Frame feedback trails (prev * 0.95 + current) can achieve high visual density with fewer particles
  • N-body O(N²) interaction: reduce to O(1) neighbor queries using spatial grid partitioning or Voronoi tracking
  • High-speed particles use sub-frame stepping to eliminate trajectory gaps
  • Velocity/acceleration need clamp to prevent numerical explosion; Verlet is more stable than Euler

Composition:

  • Raymarching: sample particle density during march steps, or particles in separate Buffer then composited
  • Noise / Flow Field: use noise gradients to drive particle velocity, producing organic flow effects
  • Post-Processing: Bloom (Gaussian blur overlay), chromatic aberration, Reinhard tone mapping
  • SDF shapes: rotate local coordinates based on velocity direction to render fish/droplet specific shapes
  • Voronoi acceleration: large-scale particles use Voronoi tracking, reducing rendering and physics queries from O(N) to O(1)

Further Reading

Full step-by-step tutorial, mathematical derivations, and advanced usage in reference