GPU Particles

A GPU Particle System with Instancing

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:

webgl-helper.js, twenty-seventh part: a stateless instanced particle systemJavaScript
// 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.

A stateless GPU particle system: 400 turning pages and sparks, each position computed from its seed and the timeHTMLLive
<!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>