About this technique →
particle-life/sketch.js
export const WARMUP = { framesBeforeReady: 360 };

export const PARAMS = {
  count:   { value: 2000, min: 200, max: 5000, step: 100, label: "Particles", folder: "Structure", rebuildOnChange: true },
  species: { value: 4, min: 2, max: 6, step: 1, label: "Species", folder: "Structure", rebuildOnChange: true },
  rmax:    { value: 0.15, min: 0.05, max: 0.4, step: 0.005, label: "Interaction Radius", folder: "Behavior" },
  friction:{ value: 0.5, min: 0.1, max: 0.99, step: 0.01, label: "Friction", folder: "Behavior" },
  force:   { value: 50, min: 5, max: 200, step: 1, label: "Force Strength", folder: "Behavior" },
};

export const SHARE = { bookmarked: ["count","species","rmax","friction","force"] };

const COMPUTE_WGSL = /* wgsl */ `
struct Uniforms {
  time: f32,
  res:  vec2<f32>,
  seed: f32,
  count: u32,
  dt: f32,
};
struct Particle {
  pos: vec2<f32>,
  vel: vec2<f32>,
  sp: u32,
  _pad0: u32,
  _pad1: u32,
  _pad2: u32,
};

@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var<storage, read> srcParticles: array<Particle>;
@group(0) @binding(2) var<storage, read_write> dstParticles: array<Particle>;
@group(0) @binding(3) var<storage, read> forceMatrix: array<f32>;
@group(0) @binding(4) var<uniform> sim: vec4<f32>;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
  let i = gid.x;
  if (i >= u.count) { return; }
  let me = srcParticles[i];
  let rmax = sim.x;
  let fric = sim.y;
  let strength = sim.z;
  let species = u32(sim.w);

  var force = vec2<f32>(0.0);
  for (var j: u32 = 0u; j < u.count; j = j + 1u) {
    if (j == i) { continue; }
    let other = srcParticles[j];
    var diff = other.pos - me.pos;
    if (diff.x > 0.5) { diff.x = diff.x - 1.0; } else if (diff.x < -0.5) { diff.x = diff.x + 1.0; }
    if (diff.y > 0.5) { diff.y = diff.y - 1.0; } else if (diff.y < -0.5) { diff.y = diff.y + 1.0; }
    let r = length(diff);
    if (r > 0.0 && r < rmax) {
      let a = forceMatrix[me.sp * species + other.sp];
      var f = 0.0;
      if (r < 0.3 * rmax) {
        f = (r / (0.3 * rmax)) - 1.0;
      } else {
        let t = (r - 0.3 * rmax) / (rmax - 0.3 * rmax);
        f = a * (1.0 - abs(2.0 * t - 1.0));
      }
      force = force + (diff / r) * f;
    }
  }

  var newVel = me.vel + force * strength * u.dt;
  newVel = newVel * pow(fric, u.dt * 60.0);
  var newPos = me.pos + newVel * u.dt;
  newPos.x = fract(newPos.x + 1.0);
  newPos.y = fract(newPos.y + 1.0);

  dstParticles[i] = Particle(newPos, newVel, me.sp, 0u, 0u, 0u);
}
`;

const RENDER_WGSL = /* wgsl */ `
struct Uniforms {
  time: f32,
  res:  vec2<f32>,
  seed: f32,
  count: u32,
  dt: f32,
};
struct Particle {
  pos: vec2<f32>,
  vel: vec2<f32>,
  sp: u32,
  _pad0: u32,
  _pad1: u32,
  _pad2: u32,
};

@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var<storage, read> particles: array<Particle>;

struct VOut { @builtin(position) pos: vec4<f32>, @location(0) col: vec3<f32>, @location(1) uv: vec2<f32> };

@vertex
fn vmain(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VOut {
  var quad = array<vec2<f32>, 6>(
    vec2(-1.0,-1.0), vec2(1.0,-1.0), vec2(1.0,1.0),
    vec2(-1.0,-1.0), vec2(1.0,1.0), vec2(-1.0,1.0)
  );
  let p = particles[ii];
  let aspect = u.res.x / u.res.y;
  let size = 0.005;
  let q = quad[vi];
  let center = (p.pos - vec2(0.5, 0.5)) * 2.0;
  let pos = vec2(center.x + q.x * size, center.y + q.y * size * aspect);
  let palette = array<vec3<f32>, 6>(
    vec3(0.95,0.30,0.30), vec3(0.30,0.70,0.95),
    vec3(0.95,0.85,0.30), vec3(0.30,0.95,0.55),
    vec3(0.85,0.30,0.95), vec3(0.95,0.55,0.20),
  );
  var out: VOut;
  out.pos = vec4(pos, 0.0, 1.0);
  out.col = palette[p.sp % 6u];
  out.uv = q;
  return out;
}

@fragment
fn fmain(in: VOut) -> @location(0) vec4<f32> {
  let r = length(in.uv);
  let alpha = smoothstep(1.0, 0.6, r);
  return vec4(in.col * alpha, alpha);
}
`;

export function sketchSetup(device, ctx, format, params, seed, uniformBuffer) {
  const N = params.count;
  const S = params.species;

  const PARTICLE_BYTES = 32;
  const particleData = new ArrayBuffer(N * PARTICLE_BYTES);
  const f32 = new Float32Array(particleData);
  const u32 = new Uint32Array(particleData);
  let s = (seed | 0) || 1;
  function rng() { s = (s * 1664525 + 1013904223) | 0; return ((s >>> 0) / 4294967296); }
  for (let i = 0; i < N; i++) {
    f32[i * 8 + 0] = rng();
    f32[i * 8 + 1] = rng();
    f32[i * 8 + 2] = (rng() - 0.5) * 0.1;
    f32[i * 8 + 3] = (rng() - 0.5) * 0.1;
    u32[i * 8 + 4] = Math.floor(rng() * S);
  }

  const bufA = device.createBuffer({
    size: particleData.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
    mappedAtCreation: true,
  });
  new Uint8Array(bufA.getMappedRange()).set(new Uint8Array(particleData));
  bufA.unmap();
  const bufB = device.createBuffer({
    size: particleData.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
  });

  const matrix = new Float32Array(S * S);
  for (let i = 0; i < S * S; i++) matrix[i] = (rng() * 2 - 1) * 0.8;
  const matBuf = device.createBuffer({
    size: matrix.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
    mappedAtCreation: true,
  });
  new Float32Array(matBuf.getMappedRange()).set(matrix);
  matBuf.unmap();

  const simBuf = device.createBuffer({
    size: 16,
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
  });

  const computeModule = device.createShaderModule({ code: COMPUTE_WGSL });
  const renderModule = device.createShaderModule({ code: RENDER_WGSL });

  const computeLayout = device.createBindGroupLayout({
    entries: [
      { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
      { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
      { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
      { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
      { binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
    ],
  });
  const computePipeline = device.createComputePipeline({
    layout: device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
    compute: { module: computeModule, entryPoint: 'main' },
  });

  const renderLayout = device.createBindGroupLayout({
    entries: [
      { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
      { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
    ],
  });
  const renderPipeline = device.createRenderPipeline({
    layout: device.createPipelineLayout({ bindGroupLayouts: [renderLayout] }),
    vertex: { module: renderModule, entryPoint: 'vmain' },
    fragment: {
      module: renderModule, entryPoint: 'fmain',
      targets: [{
        format,
        blend: {
          color: { srcFactor: 'src-alpha', dstFactor: 'one' },
          alpha: { srcFactor: 'one',       dstFactor: 'one' },
        },
      }],
    },
    primitive: { topology: 'triangle-list' },
  });

  function makeBindGroups(srcBuf, dstBuf) {
    return {
      compute: device.createBindGroup({
        layout: computeLayout,
        entries: [
          { binding: 0, resource: { buffer: uniformBuffer } },
          { binding: 1, resource: { buffer: srcBuf } },
          { binding: 2, resource: { buffer: dstBuf } },
          { binding: 3, resource: { buffer: matBuf } },
          { binding: 4, resource: { buffer: simBuf } },
        ],
      }),
      render: device.createBindGroup({
        layout: renderLayout,
        entries: [
          { binding: 0, resource: { buffer: uniformBuffer } },
          { binding: 1, resource: { buffer: srcBuf } },
        ],
      }),
    };
  }

  return {
    bufA, bufB, simBuf, matBuf,
    computePipeline, renderPipeline,
    bindings: [makeBindGroups(bufA, bufB), makeBindGroups(bufB, bufA)],
    pingpong: 0,
    count: N,
    species: S,
  };
}

export function computePass(device, encoder, state, params, time, delta) {
  const sim = new Float32Array([params.rmax, params.friction, params.force, params.species]);
  device.queue.writeBuffer(state.simBuf, 0, sim);
  const cp = encoder.beginComputePass();
  cp.setPipeline(state.computePipeline);
  cp.setBindGroup(0, state.bindings[state.pingpong].compute);
  cp.dispatchWorkgroups(Math.ceil(state.count / 64));
  cp.end();
  state.pingpong = 1 - state.pingpong;
}

export function renderPass(device, encoder, view, state) {
  const rp = encoder.beginRenderPass({
    colorAttachments: [{
      view, loadOp: 'clear', storeOp: 'store',
      clearValue: { r: 0.02, g: 0.02, b: 0.04, a: 1 },
    }],
  });
  rp.setPipeline(state.renderPipeline);
  rp.setBindGroup(0, state.bindings[state.pingpong].render);
  rp.draw(6, state.count);
  rp.end();
}