Fast Poisson Disk Sampling in Arbitrary Dimensions
Summary
In this post I explore Robert Bridson’s paper:
Fast Poisson Disk Sampling in Arbitrary Dimensions and provide an example python implementation.
Additionally, I introduce an alternative method using Cellular Automata
to generate Poisson disk distributions.
Poisson disk sampling is a widely used technique in computer graphics, particularly for applications like rendering, texture generation, and particle simulation. Its appeal lies in producing sample distributions with “blue noise” characteristics—random yet evenly spaced, avoiding clustering.
The Challenge with Poisson Disk Sampling
Poisson disk sampling is an effective method for generating evenly distributed points while preventing clustering. A naive approach, known as dart-throwing, involves randomly placing points and rejecting those that violate a minimum distance constraint. However, this method is highly inefficient, especially in higher dimensions. Bridson’s Algorithm improves efficiency by using a structured grid-based approach, reducing computational complexity while maintaining an even distribution.
Bridson’s Algorithm: A Fresh Approach
Bridson’s algorithm introduces a modification to dart throwing that ensures Poisson disk samples are generated in \( O(N) \) time for \( N \) samples, regardless of dimensionality. Here’s how it works:
-
Background Grid Initialization: The algorithm begins by initializing an \( n \)-dimensional grid. Each grid cell has a size smaller than \( r / \sqrt{n} \), ensuring that each cell contains at most one sample. This accelerates spatial searches when checking if a new sample violates the minimum distance requirement.
-
Initial Sample Placement: A random initial sample is placed in the domain and added to an “active list” for processing.
-
Iterative Sampling: While the active list isn’t empty, a random sample from the list is selected. Up to \( k \) candidate points are generated in the spherical annulus between \( r \) and \( 2r \) around the chosen sample. Each candidate is checked for distance constraints using the background grid:
- If the candidate is valid, it is added to the sample set and the active list.
- If no valid candidate is found after \( k \) attempts, the sample is removed from the active list.
This process continues until the active list is empty, producing the desired distribution.
Python example of Poisson disk sampling
- Initialize a grid to store sample positions.
- Start with a random seed point and add it to an active list.
- Iteratively generate new points within an annular region around active samples.
- Check if new points satisfy the minimum distance condition.
- If valid, add them to the active list; otherwise, remove them.
class PoissonDiskSampling:
def __init__(self, width, height, radius, k, visualizer):
self.width = width
self.height = height
self.radius = radius
self.k = k
self.cell_size = radius / math.sqrt(2)
self.grid_width = int(math.ceil(width / self.cell_size))
self.grid_height = int(math.ceil(height / self.cell_size))
self.grid = [[-1 for _ in range(self.grid_height)] for _ in range(self.grid_width)]
self.samples = []
self.active_list = []
self.visualizer = visualizer
def get_cell_coordinates(self, x, y):
return int(x // self.cell_size), int(y // self.cell_size)
def is_valid_point(self, x, y):
cell_x, cell_y = self.get_cell_coordinates(x, y)
for i in range(max(0, cell_x - 2), min(self.grid_width, cell_x + 3)):
for j in range(max(0, cell_y - 2), min(self.grid_height, cell_y + 3)):
if self.grid[i][j] != -1:
neighbor = self.samples[self.grid[i][j]]
if math.dist((x, y), neighbor) < self.radius:
return False
return True
def generate_samples(self):
initial_sample = (random.uniform(0, self.width), random.uniform(0, self.height))
self.samples.append(initial_sample)
self.active_list.append(0)
cell_x, cell_y = self.get_cell_coordinates(*initial_sample)
self.grid[cell_x][cell_y] = 0
while self.active_list:
index = random.choice(self.active_list)
base_sample = self.samples[index]
found = False
for _ in range(self.k):
angle = random.uniform(0, 2 * math.pi)
distance_from_base = random.uniform(self.radius, 2 * self.radius)
candidate_x = base_sample[0] + math.cos(angle) * distance_from_base
candidate_y = base_sample[1] + math.sin(angle) * distance_from_base
if 0 <= candidate_x < self.width and 0 <= candidate_y < self.height and self.is_valid_point(candidate_x, candidate_y):
candidate_index = len(self.samples)
self.samples.append((candidate_x, candidate_y))
self.active_list.append(candidate_index)
cell_x, cell_y = self.get_cell_coordinates(candidate_x, candidate_y)
self.grid[cell_x][cell_y] = candidate_index
found = True
self.visualizer.draw_samples([self.samples[-1]])
break
if not found:
self.active_list.remove(index)
Visualize the process using pygame
Here we use pygame to visulize the generated samples
- Config class with defaulted values.
- Has a timeout of 60 seconds
- Will generate an animated gif
- Designed to run in a
Jupyter notebook
import pygame
import random
import math
import json
from PIL import Image, ImageDraw
import numpy as np
import time
# Load configuration from file if available, otherwise use default values
CONFIG_FILE = "config.json"
def load_config():
default_config = {
"WIDTH": 800,
"HEIGHT": 800,
"RADIUS": 20,
"K": 30,
"FPS": 10,
"TIMEOUT": 60,
"METHOD": "ca", # Options: "bridson" or "ca"
"OUTPUT_GIF": "poisson_disk_ca.gif"
}
try:
with open(CONFIG_FILE, "r") as file:
return json.load(file)
except FileNotFoundError:
return default_config
config = load_config()
WIDTH, HEIGHT = config["WIDTH"], config["HEIGHT"]
RADIUS, K, FPS = config["RADIUS"], config["K"], config["FPS"]
OUTPUT_GIF = config["OUTPUT_GIF"]
METHOD = config["METHOD"].lower()
TIMEOUT = config["TIMEOUT"] # Maximum time to run the simulation (in seconds)
class PygameVisualizer:
def __init__(self, width, height, timeout=TIMEOUT):
pygame.init()
self.screen = pygame.display.set_mode((width, height))
pygame.display.set_caption("Poisson Disk Sampling")
self.screen.fill((255, 255, 255))
self.clock = pygame.time.Clock()
self.timeout = timeout
self.frames = []
def save_frame(self):
pygame_surface = pygame.display.get_surface()
raw_str = pygame.image.tostring(pygame_surface, "RGB")
img = Image.frombytes("RGB", (WIDTH, HEIGHT), raw_str)
self.frames.append(img)
def draw_samples(self, samples):
for x, y in samples:
pygame.draw.circle(self.screen, (0, 0, 0), (int(x), int(y)), 3)
self.save_frame()
pygame.display.flip()
def save_gif(self, output_gif):
self.frames[0].save(output_gif, save_all=True, append_images=self.frames[1:], optimize=False, duration=1000 // FPS, loop=0)
print(f"Animated GIF saved as {output_gif}")
def run(self):
running = True
start_time = time.time() # Record start time
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elapsed_time = time.time() - start_time
if elapsed_time >= self.timeout:
print("Timeout reached. Closing visualization.")
running = False
self.clock.tick(FPS) # Ensure frame rate is respected even with timeout
pygame.quit()
OUTPUT_GIF = 'poisson_disk.gif'
visualizer = PygameVisualizer(WIDTH, HEIGHT)
poisson_ca = PoissonDiskSampling(WIDTH, HEIGHT, RADIUS // 2, RADIUS, 100, visualizer)
poisson_ca.generate_samples()
# Save the GIF
visualizer.save_gif(OUTPUT_GIF)
# Run the visualizer
visualizer.run()
Generated result
Key Advantages
- Efficiency: With linear time complexity, Bridson’s algorithm is significantly faster than naive approaches and scales well to higher dimensions.
- Simplicity: The method avoids complex geometric computations, relying instead on efficient rejection sampling within a manageable search space.
- Generality: The algorithm seamlessly adapts to any dimensional space, making it versatile for various applications like 3D rendering with motion blur or depth-of-field effects.
Performance and Results
Bridson’s paper demonstrates the algorithm’s efficiency and effectiveness through examples in two and three dimensions. The resulting sample patterns exhibit the desired blue noise properties, verified through periodograms. These results underline the algorithm’s practical utility in producing high-quality, evenly spaced samples for real-world applications.
Applications and Implications
The ability to efficiently generate Poisson disk samples in arbitrary dimensions opens up possibilities across numerous fields:
- Graphics and Animation: Improved rendering techniques for scenes with motion blur, soft shadows, or depth of field.
- Particle Systems: Realistic distribution of particles in simulations or procedural generation.
- Data Sampling: Applications in high-dimensional data spaces, such as machine learning or computational biology.
Cellular Automata version
Cellular Automata
(CA) offers a different approach, where points evolve over time based on simple activation rules. Instead of directly computing candidate positions, each cell in a grid decides whether it should activate based on its neighbors. This creates a more organic, step-by-step generation process, making it useful for procedural generation.
class Cell:
def __init__(self, x, y, cell_size, radius):
self.x = x
self.y = y
self.cell_size = cell_size
self.radius = radius
self.active = False
def should_activate(self, grid):
radius_cells = int(self.radius / self.cell_size)
for dy in range(-radius_cells, radius_cells + 1):
for dx in range(-radius_cells, radius_cells + 1):
nx, ny = self.x + dx, self.y + dy
if 0 <= nx < len(grid[0]) and 0 <= ny < len(grid):
if grid[ny][nx].active:
dist = math.sqrt((nx - self.x) ** 2 + (ny - self.y) ** 2) * self.cell_size
if dist < self.radius:
return False
return random.random() < 0.2
class CellularAutomata:
def __init__(self, grid_width, grid_height, cell_size, radius):
self.grid_width = grid_width
self.grid_height = grid_height
self.cell_size = cell_size
self.radius = radius
self.grid = [[Cell(x, y, cell_size, radius) for x in range(grid_width)] for y in range(grid_height)]
def update(self):
new_samples = []
for row in self.grid:
for cell in row:
if not cell.active and cell.should_activate(self.grid):
cell.active = True
new_samples.append((cell.x * self.cell_size, cell.y * self.cell_size))
return new_samples
class PoissonDiskSamplingCA:
def __init__(self, width, height, cell_size, radius, steps, visualizer):
self.width = width
self.height = height
self.cell_size = cell_size
self.radius = radius
self.steps = steps
self.visualizer = visualizer
self.automata = CellularAutomata(width // cell_size, height // cell_size, cell_size, radius)
self.samples = []
def generate_samples(self):
for _ in range(10):
x = random.randint(0, self.automata.grid_width - 1)
y = random.randint(0, self.automata.grid_height - 1)
self.automata.grid[y][x].active = True
self.samples.append((x * self.cell_size, y * self.cell_size))
for step in range(self.steps):
new_samples = self.automata.update()
if new_samples:
self.visualizer.draw_samples(new_samples)
self.samples.extend(new_samples)
return self.samples
-
Cell Representation:
- Each Cell object knows its position (x, y), size, radius, and activation state.
- It decides whether to activate based on nearby active cells and a random probability (20%).
-
Grid Initialization:
- The CellularAutomata class creates a grid of
Cell
objects, with dimensions based ongrid_width
andgrid_height
.
- The CellularAutomata class creates a grid of
-
Activation Rules:
- A cell activates if no nearby cells (within
radius
) are already active. - Activation follows a probabilistic rule to ensure organic distribution.
- A cell activates if no nearby cells (within
-
Grid Updates:
- The
update()
method iterates through all cells, checking if they should activate. - Newly activated cells are added to the sample list.
- The
-
Poisson Disk Sampling Process:
- Starts with 10 randomly activated cells as seeds.
- Iterates for
steps
, activating new valid cells at each step.
-
Visualization & Sample Storage:
- The visualizer draws new samples each step.
- All generated points are stored in
self.samples
for further use.
CA Result
Supporting classes
Distribution validator
Initially I was getting really bad results using the CA so I wrote a method to validate the generated results
def validate_distribution(samples, radius):
"""Validates the distribution of samples and checks radius constraint."""
if not samples:
return True # No samples, trivially valid
min_distances = []
for i in range(len(samples)):
min_dist = float('inf')
for j in range(len(samples)):
if i != j:
dist = math.dist(samples[i], samples[j])
min_dist = min(min_dist, dist)
min_distances.append(min_dist)
min_overall_distance = min(min_distances) if min_distances else 0
print(f"Minimum distance between any two points: {min_overall_distance}")
# Check if all distances are greater than or equal to the radius
radius_valid = all(dist >= radius - 1e-6 for dist in min_distances) # Tolerance for floating point errors
print(f"Radius constraint valid: {radius_valid}")
# Analyze the distribution (example: check for clusters)
# (More sophisticated analysis might be needed depending on your needs)
distances_to_neighbors = []
for i in range(len(samples)):
for j in range(len(samples)):
if i != j:
distances_to_neighbors.append(math.dist(samples[i], samples[j]))
avg_neighbor_distance = np.mean(distances_to_neighbors) if distances_to_neighbors else 0
print(f"Average distance to nearest neighbor: {avg_neighbor_distance}")
return radius_valid, min_overall_distance, avg_neighbor_distance # Return values for further analysis
## use like this
if METHOD == "bridson":
poisson = PoissonDiskSampling(WIDTH, HEIGHT, RADIUS, K, visualizer)
poisson.generate_samples()
radius_valid, min_dist, avg_neighbor_dist = validate_distribution(poisson.samples, RADIUS)
elif METHOD == "ca":
poisson_ca = PoissonDiskSamplingCA(WIDTH, HEIGHT, RADIUS // 2, RADIUS, 1000, visualizer)
poisson_ca.generate_samples()
radius_valid, min_dist, avg_neighbor_dist = validate_distribution(poisson_ca.samples, RADIUS)
else:
print("Invalid METHOD specified in config. Use 'bridson' or 'ca'.")
print(f"Poisson disk sampling (method: {METHOD}) validation:")
print(f"Radius constraint satisfied: {radius_valid}")
print(f"Minimum distance: {min_dist}")
print(f"Average nearest neighbor distance: {avg_neighbor_dist}")
Code Examples
Check out the programmer.ie notebooks for the code used in this post and additional examples.