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