Poisson Disc

Poisson disc point sampling

Quick Start

import { poissonDisc } from './webgpu-market/poisson-disc/poisson-disc';

const result = await poissonDisc(device, {
  width: 100,
  height: 100,
  minDistance: 2.0,
  seed: 42,
  maxPoints: 10_000,
});

const pointBuffer = result.points; // GPUBuffer of vec2<f32>
const count = result.count;
result.destroy();
Source
// Poisson Disc Sampling — GPU-accelerated Bridson's algorithm
//
// Generates 2D points with a guaranteed minimum distance between them.
// Iterates until no more active points can spawn valid candidates.
//
// Single kernel (generate) dispatched repeatedly from the host.
// The host swaps active list regions between iterations.

struct Uniforms {
  width: f32,
  height: f32,
  min_distance: f32,
  cell_size: f32,       // min_distance / sqrt(2)
  grid_width: u32,
  grid_height: u32,
  seed: u32,
  max_points: u32,
  num_candidates: u32,  // candidates per active_list point
  active_list_offset: u32,   // read offset into active_list buffer (ping-pong)
  write_offset: u32,    // write offset into active_list buffer (ping-pong)
  _pad: u32,
}

@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var<storage, read_write> points: array<vec2f>;
@group(0) @binding(2) var<storage, read_write> grid: array<atomic<i32>>;
@group(0) @binding(3) var<storage, read_write> active_list: array<u32>;
@group(0) @binding(4) var<storage, read_write> counters: array<atomic<u32>>;
// counters[0] = point count
// counters[1] = active_list count (current iteration, read-only during dispatch)
// counters[2] = next active_list count (written during dispatch)

// Sentinel value: cell is claimed but point data not yet readable.
// is_valid() treats this as occupied (rejects candidates near it).
const CELL_EMPTY: i32 = -1i;
const CELL_PENDING: i32 = -2i;

// --- Hash-based RNG ---
// PCG-style hash for generating random candidates

fn pcg(v: u32) -> u32 {
  var state = v * 747796405u + 2891336453u;
  let word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
  return (word >> 22u) ^ word;
}

fn rand_f32(seed: u32) -> f32 {
  return f32(pcg(seed)) / 4294967295.0;
}

// Generate a random point in the annulus [minDistance, 2*minDistance] around center
fn random_in_annulus(center: vec2f, seed: u32) -> vec2f {
  let r1 = rand_f32(seed);
  let r2 = rand_f32(seed + 1u);
  let radius = u.min_distance * (1.0 + r1);
  let angle = r2 * 6.283185;
  return center + vec2f(cos(angle), sin(angle)) * radius;
}

// Convert a point to a grid cell index
fn point_to_cell(p: vec2f) -> vec2u {
  return vec2u(
    u32(p.x / u.cell_size),
    u32(p.y / u.cell_size)
  );
}

fn cell_to_index(cell: vec2u) -> u32 {
  return cell.y * u.grid_width + cell.x;
}

// Check if a point is far enough from all neighbours in the grid
fn is_valid(p: vec2f) -> bool {
  if (p.x < 0.0 || p.x >= u.width || p.y < 0.0 || p.y >= u.height) {
    return false;
  }

  let cell = point_to_cell(p);
  let min_dist_sq = u.min_distance * u.min_distance;

  // Check 5x5 neighbourhood of cells
  for (var dy = -2i; dy <= 2i; dy++) {
    for (var dx = -2i; dx <= 2i; dx++) {
      let nx = i32(cell.x) + dx;
      let ny = i32(cell.y) + dy;

      if (nx < 0i || nx >= i32(u.grid_width) || ny < 0i || ny >= i32(u.grid_height)) {
        continue;
      }

      let neighbour_idx = atomicLoad(&grid[u32(ny) * u.grid_width + u32(nx)]);

      // Empty cell — no point here
      if (neighbour_idx == CELL_EMPTY) {
        continue;
      }

      // Pending cell — another thread claimed it but hasn't written yet.
      // Treat as occupied to be safe (reject candidate).
      if (neighbour_idx == CELL_PENDING) {
        return false;
      }

      let neighbour = points[u32(neighbour_idx)];
      let d = p - neighbour;
      if (dot(d, d) < min_dist_sq) {
        return false;
      }
    }
  }

  return true;
}

// ============================================================
// Kernel: Generate candidates and accept valid ones
// Each thread processes one active_list point, tries multiple candidates.
// Active list uses ping-pong offsets to avoid read/write aliasing.
// ============================================================

@compute @workgroup_size(64)
fn generate(@builtin(global_invocation_id) gid: vec3u) {
  let active_list_count = atomicLoad(&counters[1]);
  if (gid.x >= active_list_count) {
    return;
  }

  let point_idx = active_list[u.active_list_offset + gid.x];
  let center = points[point_idx];
  var found = false;

  for (var k = 0u; k < u.num_candidates; k++) {
    let hash_seed = u.seed + point_idx * 31u + k * 7u + gid.x * 113u;
    let candidate = random_in_annulus(center, hash_seed);

    if (!is_valid(candidate)) {
      continue;
    }

    // Try to claim the grid cell with an atomic compare-exchange
    let cell = point_to_cell(candidate);
    let cell_idx = cell_to_index(cell);

    // Set to CELL_PENDING first — is_valid() will treat this as occupied
    let result = atomicCompareExchangeWeak(&grid[cell_idx], CELL_EMPTY, CELL_PENDING);
    if (!result.exchanged) {
      continue; // Cell already taken
    }

    // Claim a point slot
    let new_idx = atomicAdd(&counters[0], 1u);
    if (new_idx >= u.max_points) {
      atomicSub(&counters[0], 1u);
      atomicStore(&grid[cell_idx], CELL_EMPTY);
      return;
    }

    // Write the point, then update grid to the real index
    points[new_idx] = candidate;
    atomicStore(&grid[cell_idx], i32(new_idx));

    // Add to next iteration's active list (write region)
    let next_active_list_idx = atomicAdd(&counters[2], 1u);
    if (next_active_list_idx < u.max_points) {
      active_list[u.write_offset + next_active_list_idx] = new_idx;
    }

    found = true;
  }

  // If this active_list point generated at least one child, keep it active_list
  if (found) {
    let keep_idx = atomicAdd(&counters[2], 1u);
    if (keep_idx < u.max_points) {
      active_list[u.write_offset + keep_idx] = point_idx;
    }
  }
}
Documentation

poisson-disc

Generates 2D points with a guaranteed minimum distance between them. Returns a GPUBuffer of vec2<f32> points.

API

poissonDisc(device, options?)

Returns a Promise<PoissonDiscResult>. This is a one-shot operation — call it, get the result, use the buffer.

Option Type Default Description
width number 100 Domain width
height number 100 Domain height
minDistance number 2.0 Minimum distance between points
seed number (random) RNG seed for reproducible results
maxPoints number 10_000 Upper bound for buffer allocation
candidatesPerPoint number 30 Candidates tested per active point per iteration
maxIterations number 500 Maximum iterations before stopping

result.points

GPUBuffer containing vec2<f32> values (8 bytes per point). Usage: STORAGE | COPY_SRC | COPY_DST.

result.count

The number of points actually generated. Use this to know how much of the buffer is valid.

result.destroy()

Releases internal GPU buffers (grid, active list, counters). The points buffer is not destroyed — the caller manages its lifecycle.

Further Reading

Further Reading

Resources on Poisson disc sampling and spatial point generation.

Core Papers

  • Robert Bridson, "Fast Poisson Disk Sampling in Arbitrary Dimensions" (SIGGRAPH 2007) The foundational algorithm this module is based on. Introduces the dart-throwing approach with an acceleration grid, running in O(n) time. Short, elegant, and highly influential. https://www.cs.ubc.ca/~rbridson/docs/bridson-siggraph07-poissondisk.pdf

  • Daniel Dunbar, Greg Humphreys, "A Spatial Data Structure for Fast Poisson-Disk Sample Generation" (SIGGRAPH 2006) Proposes grid-based acceleration for Poisson disc sampling. The spatial grid approach used in this module draws from this work. https://dl.acm.org/doi/10.1145/1179352.1141915

GPU Implementations

  • Wei, "Parallel Poisson Disk Sampling" (SIGGRAPH 2008) The first parallel GPU algorithm for Poisson disc sampling. Uses a phase-based approach where points are generated in waves to avoid conflicts. https://dl.acm.org/doi/10.1145/1360612.1360619

  • Yuksel, "Sample Elimination for Generating Poisson Disk Sample Sets" (2015) An alternative approach: generate more points than needed (e.g., via uniform random), then iteratively remove points that are too close. Can be more GPU-friendly than iterative dart-throwing. https://dl.acm.org/doi/10.1111/cgf.12538

Blue Noise and Applications

  • Ulichney, "Dithering with Blue Noise" (1988) Poisson disc distributions are a form of blue noise — random but with no low-frequency content. This paper explains why blue noise is perceptually superior for sampling and dithering. https://ieeexplore.ieee.org/document/1457527

  • Pharr, Humphreys, "Physically Based Rendering" (PBRT), Chapter 8: Sampling Covers how Poisson disc and other blue noise distributions are used in ray tracing and path tracing for better convergence with fewer samples. https://pbr-book.org/4ed/Sampling_and_Reconstruction

Procedural Generation

General References

  • Jason Davies, "Poisson-Disc Sampling" (Interactive Demo) Beautiful interactive visualization of the algorithm generating points in real-time. Helpful for understanding the annulus sampling and grid check steps. https://www.jasondavies.com/poisson-disc/