Blue noise
A plain random scatter clumps, so some points land almost on top of each other while gaps open up elsewhere. A blue-noise (Poisson-disk) scatter places the points evenly but organically instead, with no two closer than a radius and no visible clustering. It is the distribution behind natural-looking stippling and object scatter, and it gives the Voronoi and packing paths the even seed sets they prefer.
poissonDisk is Bridson's dart-throwing sampler. It runs on the seedable random, so the same seed always places the points the same way.
Contents
poissonDisk
poissonDisk(in bounds: Rectangle? = nil,
radius: Double,
candidates: Int = 30,
maxCount: Int? = nil) -> [Vector2]
Returns a blue-noise scatter of bounds, which is the whole canvas by default. The points are spread evenly, and no two are closer than radius. You do not ask for a number of points. That number follows from radius and the area, so halving the radius roughly quadruples the points. Pass maxCount to stop once enough points have landed. candidates is how many darts are thrown around each point before it is retired. That is Bridson's k, 30 by default, and a higher value packs the points slightly tighter and runs slower.
seed(9)
let dots = poissonDisk(radius: 22)
noStroke(); fill(.black)
drawPoints(dots, size: 4)
The layout is a pure function of the seed. Compute it once and keep it in a stored property rather than recomputing it every frame. Then animate something visual, such as a dot's size or its color, so the field moves without the points jumping. See the BlueNoise example.
Inside a shape
poissonDisk(in shape: Shape,
radius: Double,
candidates: Int = 30,
maxCount: Int? = nil) -> [Vector2]
The same scatter confined to the filled region of a Shape: no two points closer than radius, none in a hole, and every island filled. The darts are the same, with the shape's fill as the place a dart may land. What differs is where the throwing starts. Each seed is drawn from the fill's own triangles rather than from the box around the shape, and when the darts around every placed point have all missed, a fresh seed is drawn the same way and the throwing goes on. So a shape thinner than a dart's reach, where most darts thrown around a point fall outside, still fills end to end, and a shape of several islands fills every island rather than only the one the first seed landed in. The throwing stops when candidates fresh seeds in a row find no room.
seed(7)
let letter = textToShapes("O", 540, 700)[0]
let dots = poissonDisk(in: letter, radius: 9) // inside the letter, none in its counter
noStroke(); fill(.black)
drawPoints(dots, size: 3)
The plain scatter inside a shape, even by area rather than spaced, is randomPoints(in:count:), and the outline's is randomPoints(along:count:); both are on the geometry page.
Feeding the tessellators
The output is an ordinary [Vector2], so it goes straight into voronoi or delaunay. The spacing is already even, so blue-noise sites give very uniform cells with no Lloyd relaxation needed:
seed(9)
let sites = poissonDisk(radius: 30)
for (i, cell) in voronoi(sites).cells.enumerated() {
fill(Colormap.viridis.color(at: Double(i) / Double(sites.count)))
drawShape(cell)
}
Standalone (outside a sketch)
The Sketch method wraps a free function that takes any random source, so geometry code outside a sketch can sample reproducibly too. Pass it a seeded SplitMix64, which is Ollin's PRNG:
var rng = SplitMix64(seed: 9)
let points = poissonDisk(in: bounds, radius: 24, using: &rng)
The shape form is the same call with a Shape in place of the rectangle, using: and all.
Related: Random (uniform scatter and the randomVector/ring helpers), Voronoi & Delaunay (tessellate the points into cells and meshes).