The simplest GPU particles keep no state: each gets four random numbers once, and the vertex shader evaluates p = origin + v t + g t^2 / 2 for the current time. The twenty-seventh part builds a burst of turning pages:
// Section 3.16: a stateless GPU particle system: count instances of a 4-vertex strip, each
// with a random aSeed (divisor 1); the vertex shader computes where a particle is at uTime
GLH.particleVS = `#version 300 es
in vec4 aSeed; // four random numbers per particle
uniform mat4 uViewProjection;
uniform vec3 uOrigin, uGravity, uTint;
uniform float uTime, uSpeed, uSize, uLife;
out vec3 vColor;
mat2 turn(float a) { return mat2(cos(a), sin(a), -sin(a), cos(a)); }
void main() {
float t = uTime - 0.4 * aSeed.w; // staggered start times
bool spark = aSeed.w > 0.8; // one in five is a glowing spark
vec2 corner = vec2(gl_VertexID & 1, gl_VertexID >> 1) * 2.0 - 1.0;
float size = uSize * (spark ? 0.3 : 1.0) * step(0.0, t);
size *= smoothstep(uLife, 0.6 * uLife, t); // shrink away at the end
vec3 local = vec3(corner * size * vec2(0.75, 1.0), 0.0); // a page-shaped quad
float flip = t * (5.0 + 8.0 * aSeed.z); // turning like a page
local.xz = turn(flip) * local.xz;
local.xy = turn(6.2832 * aSeed.x) * local.xy;
vec3 velocity = uSpeed * (vec3(1.6, 0.6, 0.8) * aSeed.xyz - vec3(0.8, -0.7, 0.4));
vec3 p = uOrigin + velocity * t + 0.5 * uGravity * t * t + local;
p.x += 0.06 * sin(7.0 * t + 20.0 * aSeed.y); // flutter
vColor = spark ? uTint * 4.0 : vec3(0.95, 0.93, 0.86) * (0.55 + 0.45 * abs(cos(flip)));
gl_Position = uViewProjection * vec4(p, 1.0);
}`;
GLH.particles = function (gl, count, seed = 7) {
const pass = GLH.load(gl, GLH.particleVS, `#version 300 es
precision mediump float; in vec3 vColor; out vec4 outColor;
void main() { outColor = vec4(vColor, 1.0); }`);
const random = () => (seed = seed * 16807 % 2147483647) / 2147483647; // repeatable
const seeds = Float32Array.from({ length: count * 4 }, random);
const vao = GLH.vao(gl, pass.attributes, seeds, [['aSeed', 4]]);
gl.bindVertexArray(vao);
gl.vertexAttribDivisor(pass.attributes.aSeed, 1); // advance once per instance
gl.bindVertexArray(null);
pass.draw = (time) => {
gl.useProgram(pass.program); pass.uniforms.uTime(time); gl.bindVertexArray(vao);
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, count); // 4 vertices, count times
};
return pass;
};Quads shrink to nothing before uLife ends, so nothing needs blending or sorting. Each page turns about its own axis and brightens as it faces the light; one particle in five is a spark at four times its tint.
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; }
canvas { display: block; width: 100%; max-width: 600px; }
</style>
<canvas id="c" width="600" height="340"></canvas>
<script>
const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec4 aSeed; // four random numbers per particle
uniform vec3 uOrigin, uGravity, uTint;
uniform float uTime, uSpeed, uSize, uLife;
out vec3 vColor;
mat2 turn(float a) { return mat2(cos(a), sin(a), -sin(a), cos(a)); }
void main() {
float t = uTime - 0.4 * aSeed.w; // staggered start times
bool spark = aSeed.w > 0.8; // one in five is a spark
vec2 corner = vec2(gl_VertexID & 1, gl_VertexID >> 1) * 2.0 - 1.0;
float size = uSize * (spark ? 0.3 : 1.0) * step(0.0, t);
size *= smoothstep(uLife, 0.6 * uLife, t); // shrink to nothing before the end: no sorting
vec3 local = vec3(corner * size * vec2(0.75, 1.0), 0.0);
float flip = t * (5.0 + 8.0 * aSeed.z); // turning like a page
local.xz = turn(flip) * local.xz;
local.xy = turn(6.2832 * aSeed.x) * local.xy;
// p = origin + v t + g t^2 / 2
vec3 velocity = uSpeed * (vec3(1.6, 0.6, 0.8) * aSeed.xyz - vec3(0.8, -0.7, 0.4));
vec3 p = uOrigin + velocity * t + 0.5 * uGravity * t * t + local;
p.x += 0.06 * sin(7.0 * t + 20.0 * aSeed.y); // flutter
vColor = spark ? uTint * 1.4 : vec3(0.95, 0.93, 0.86) * (0.55 + 0.45 * abs(cos(flip)));
gl_Position = vec4(p.x * 0.6, p.y * 1.05 - 0.2, -p.z * 0.1, 1.0);
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; in vec3 vColor; out vec4 outColor; void main() { outColor = vec4(vColor, 1.0); }`]]) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
gl.attachShader(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
let seed = 7;
const random = () => (seed = seed * 16807 % 2147483647) / 2147483647; // repeatable random numbers
const COUNT = 400;
gl.bindVertexArray(gl.createVertexArray());
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, Float32Array.from({ length: COUNT * 4 }, random), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribDivisor(0, 1); // instance k reads floats 4k .. 4k + 3
const u = (n) => gl.getUniformLocation(program, n);
gl.uniform3f(u('uOrigin'), 0, 0, 0); gl.uniform3f(u('uGravity'), 0, -1.5, 0);
gl.uniform1f(u('uSpeed'), 1.1); gl.uniform1f(u('uSize'), 0.05); gl.uniform1f(u('uLife'), 1.6);
gl.uniform3f(u('uTint'), 0.88, 0.6, 0.06);
gl.enable(gl.DEPTH_TEST);
function frame(ms) {
gl.clearColor(0.16, 0.17, 0.22, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniform1f(u('uTime'), (ms / 1000) % 2.2); // the only per-frame input: the burst repeats
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, COUNT);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>