On Poisson Disk Sampling

In 2024, a team of nine mathematicians released a monstrous, nearly 1,000 page proof of the geometric Langlands conjecture. It is a crowning achievement in pure mathematics, and I have accepted that I will never understand even the statements that they proved, much less the proof itself.

On the total opposite end of the spectrum, in 2007, Robert Bridson published a one page paper that has nearly 1,000 citations and takes less than 10 minutes to fully understand. It presents a simple solution to a problem that commonly arises in computer graphics and simulations: placing things randomly, but not too close together.

Say you’re trying to procedurally generate a forest and need a way to place the trees. The problem with plain random sampling is obvious:

Some of the trees would be on top of each other! What we need is the ability to set a minimum distance between any two trees. A distribution of trees that obeys this rule is called a Poisson disk distribution. We can try a naive rejection sampling approach where we throw random darts and reject any point that falls within that minimum distance of any other point, but without a more clever data structure, it takes linear time to check collisions for each sample and the rejection rate quickly approaches one. Bridson’s algorithm gives us an efficient way to do this.

Bridson’s Algorithm

Suppose the desired minimum distance between points is rr and we are working in a dd-dimensional space. Bridson’s algorithm goes as follows:

  1. Partition the space into a grid of side length rd\frac{r}{\sqrt{d}}. This guarantees that each grid cell can have at most one point inside it.
  2. Initialize a list active to have one random point chosen uniformly from the space.
  3. While active is non-empty:
    1. Select an element pp from active uniformly at random.
    2. Uniformly sample the annulus centered at pp of inner radius rr and outer radius 2r2r at most kk times. If a valid Poisson disk sample is found, using the grid for efficient collision detection, add it to active and pick a new pp. If no valid point is found within kk attempts, remove pp from active. Bridson recommends setting k=30k = 30.

The easiest way to uniformly sample the annulus is to generate a random unit vector vRd\vec{v} \in \mathbb{R}^d and a number xx chosen uniformly from the interval [12d,1)\left[\frac{1}{2^d}, 1\right), and then your final sample is 2rx1/dv2rx^{1/d}\cdot\vec{v}. A few years ago, I made a video that explains why this works. In two dimensions, picking a unit vector is equivalent to picking an angle θ[0,2π)\theta \in [0, 2\pi). In higher dimensions, you can normalize a vector where each component is sampled from a normal distribution.

Improvements

There are two simple improvements to Bridson’s algorithm that I have found drastically reduce the number of iterations required to generate the same number of points. The first works in two dimensions, but the second works in higher dimensions as well.

Let’s start with the two dimensional improvement. Consider when the algorithm places a point pp and then samples its annulus to get a new point qq. We call pp the parent of qq. There is valuable information stored in the relation between these points. When we inevitably sample the annulus centered at qq, there is an entire range of angles that we need not consider because the points within them would be too close to pp. This range is represented by the dotted lines in the following figure.

pq
|p - q| = 1.50r

While the visual intuition is easy to grasp, translating it into a formula is a tedious trigonometry exercise. I’ll spare you the details and claim without proof that the cone formed by the dotted lines is centered at angle α\alpha and its width is 2β2\beta where

α=atan2(pyqy,pxqx),β=min(arccospq2+3r24rpq,arccospq2r).\begin{align*} \alpha &= \operatorname{atan2}(p_y - q_y, p_x - q_x), \\ \beta &= \min\left(\arccos\frac{|p-q|^2+3r^2}{4r \cdot |p-q|}, \arccos\frac{|p-q|}{2r}\right). \end{align*}

The only interesting part of this formula is the minimum that appears in the equation for β\beta. This accounts for the fact that either the inner or outer circle of the annulus can bound the cone, depending on the distance between pp and qq. We have to pick the minimum to guarantee that the intersection of the cone and the annulus is entirely contained in the circle. Note how the boundary points of the cone jump from the outer circle to the inner when the distance between the points crosses 3r\sqrt{3} \cdot r. The first term in the minimum is the angle of intersection with the outer circle and the second term is that with the inner circle.

Implementing this just requires storing the parent of each point. Then you can calculate the angles of the cone and generate the angle θ\theta for the next sample in the range outside the cone.

The following graph shows how many points were generated by Bridson’s algorithm with and without the parental optimization. Each datapoint is the average of 100 trials on a grid of side length =100\ell = 100 with r=1r = 1.

This improvement could likely be generalized to higher dimensions, but it would require more space to store the contact vectors of the annuli, and the returns would likely diminish because the volume of the intersection of an annulus with a sphere becomes proportionally insignificant in higher dimensions. Similarly, there is nothing stopping us from storing the children of a point in addition to its parent to eliminate even more sections of the annulus, but this would also require more storage and it would make selecting θ\theta far slower.

Instead of changing how we pick the angle to the next sample, the second improvement changes how we pick the distance to the next sample. Consider the distribution of the distances from each point in the annulus to its center. Its cumulative distribution function (CDF) is proportional to xdx^d on the interval [r,2r][r, 2r]. For an explanation of this, I again defer to my video. But what happens if we change the exponent to be some constant cc other than dd? Then we can move the points closer to or further away from the center. For c0c \neq 0, the exact CDF is

Fc(x)={0xr,xcrc(2c1)rcr<x2r,1x>2r.F_c(x) = \begin{cases} 0 & x \leq r, \\ \frac{x^c - r^c}{(2^c - 1)r^c} & r < x \leq 2r, \\ 1 & x > 2r. \end{cases}

The following figure lets you see what the CDF and 500 random samples in the annulus look like as cc changes. Remember that c=2c = 2 gives a uniform distribution.

0r2r0.00.51.0distanceCDF
c = 2.00

You may have noticed that the slider allows you to set c=0c = 0 even though F0(x)F_0(x) is undefined due to a division by zero. To rememdy this, we define F0(x)=limc0Fc(x)F_0(x) = \lim_{c \to 0} F_c(x), which is

F0(x)={0xr,log2xlog2rr<x2r,1x>2r.F_0(x) = \begin{cases} 0 & x \leq r, \\ \log_2{x} - \log_2{r} & r < x \leq 2r, \\ 1 & x > 2r. \end{cases}

In order to sample the radius using an arbitrary value of cc, we can apply inverse transform sampling. When c=0c = 0, the radius should be r2xr \cdot 2^x where xx is a uniform random variable on the interval [0,1)[0, 1). Otherwise, we use 2ry1/c2ry^{1/c} where yy is a uniform random variable between 1 and 12c\frac{1}{2^c}. The bounds of the interval swap depending on whether cc is positive or negative.

The following heatmap shows the impact cc has on the number of points generated by Bridson’s algorithm. We use the same experimental setup as before with 100 trials on a grid of side length =100\ell = 100 with r=1r = 1.

This seems to suggest that we should set cc to be some very negative number, or even take the limit as cc approaches negative infinity, forcing each point to be a distance of exactly rr from its parent. While it is true that this would maximize the number of points generated and create more tightly packed configurations, it would do so at the expense of the distribution “feeling” random. In the extreme case of c=c = -\infty, you get many artifacts like long strings of points and gaps where the restricted distance cannot reach. It is also not difficult to reconstruct the tree of how the points were generated after the fact.

So we need to strike a balance between maximizing the density of points and preserving randomness. If you fix some 15k4015 \leq k \leq 40, I have found it best to set c=1.417kc = -1.4 - \frac{17}{\sqrt{k}} when using the parental optimization. This equation was derived empirically to make the number of points generated roughly match the expected output from a uniform and maximal Poisson disk sampler (more on this later). For every kk between 15 and 40, I did a binary search to find the value of cc that would make it so if you drew a circle of radius r/2r/2 around every point, those circles would take up 54.7% of the entire area. This percentage is the saurated coverage of circular disks under the random sequential adsorption model. Of course, this only applies in two dimensions and cc will need to be tuned differently in higher dimensions.

Stippling

So far, we have kept rr constant, but there is no reason for this. We can dynamically set the minimum distance between points according to a function r ⁣:RdRr\colon \mathbb{R}^d \to \mathbb{R}. So after placing a point pp, we sample an annulus of inner radius r(p)r(p). A fun application of this is to define rr as the brightness of each pixel in an image to produce a stippling effect. The figure on the left does this in black and white, and the figure on the right combines three sets of Poisson disk samples, one for each color channel.

Birdson’s algorithm is an inherently sequential one, but there are others that are designed to be executed in parallel, resulting in massive performance boosts. My favorite of these is PixelPie which runs entirely on the GPU. I used this algorithm to create Poisson Cam, a realtime video stippler using Poisson disk sampling. This was one of my favorite projects to work on because it taught me shader programming, Rust, stream compaction algorithms, and of course the PixelPie algorithm itself.

Maximality and Uniformity

In 2022, Scott A. Mitchell published a really cool paper introducing a beautiful new way of generating Poisson disk samples in two dimensions that, to my knowledge, has received no attention since its publication. This last section is dedicated to it.

Before I describe the algorithm, I want to explain the three things that make it better than Bridson’s:

  1. Maximality. After the algorithm finishes, it is guaranteed to be impossible to fit another point without violating the Poisson disk property.
  2. Uniformity. The algorithm samples a uniform distribution over all maximal sets of Poisson disk samples.
  3. Determinism. The algorithm does not rely on rejection sampling, meaning there are no failed attempts to place points.

Maximality and uniformity have been achieved by many algorithms, the most popular of which is hierarchical dart throwing, but Mitchell’s is the first algorithm to do this without some kind of rejection sampling. It is also very performant. While I have not done any rigorous benchmarking, I have found that Mitchell’s implementation of the algorithm runs about as quickly and generates roughly the same number of points as my implementation of Bridson’s algorithm (i.e., with the parental optimization) with k=20k = 20 and c=5.2c = -5.2. As far as I can tell, the only downside to this algorithm is that it is substantially more complex. The following is a greatly simplified summary:

  1. Partition the space into a grid of side length rd\frac{r}{\sqrt{d}}.
  2. While there is room to place another point:
    1. Randomly select a cell cc from the grid weighted by the remaining areas.
    2. Decompose cc into disjoint triangles and chocks.1
    3. Select a random triangle or chock tt weighted by area.
    4. Uniformly sample a point pp from tt. Add it to the final set.
    5. Carve out a circle of radius rr centered at pp from the grid.

The original paper does an excellent job of explaining the algorithm in detail, so the most helpful contribution I can make is the following visualization:

Footnotes

  1. A chock is a three-sided shape bounded by a circle, a radial ray, and a tangent.