Ollin

Low-discrepancy sampling

Plain random scatter clumps and leaves holes. Blue noise fixes the spacing, but it gives you one fixed layout for one radius. A low-discrepancy sequence is the third option. It is an ordered, deterministic stream of points that covers a region evenly at every count. Growing the count only adds points, and never moves the ones already placed.

That prefix property is the point of the whole technique. Draft a sketch with 100 points and render it with 10,000, and the draft is a subset of the final render. There is no seed and no random number generator anywhere, because you pass in an index and get back a point.

Three panels showing the first 40, 160, and 640 points of one Halton sequence; the earlier points appear in identical positions in every panel, drawn dark, while the new points fill the remaining gaps in orange

Contents

haltonPoints

haltonPoints(count: Int,
             in bounds: Rectangle? = nil,
             bases: (Int, Int) = (2, 3),
             startIndex: Int = 1) -> [Vector2]

This returns the first count points of the 2D Halton sequence, scaled into bounds. bounds is the whole canvas by default. Each axis mirrors the point's index digits in its own base, and with coprime bases the two axes stay uncorrelated. The defaults, 2 and 3, are the canonical pair. Bases that share a factor collapse the points onto lines.

for (i, p) in haltonPoints(count: 800).enumerated() {
    fill(Colormap.viridis.color(at: halton(i + 1, base: 5)))
    drawCircle(center: p, radius: 4)
}

startIndex defaults to 1 because index 0 of every Halton sequence is exactly 0, which is a point pinned in the corner.

sobolPoints

sobolPoints(count: Int,
            in bounds: Rectangle? = nil,
            startIndex: Int = 1) -> [Vector2]

This returns the first count points of the 2D Sobol sequence, which is the more uniform of the two. Its binary construction gives it a stronger guarantee. Every aligned power-of-two block of the stream lands exactly one point in each cell of the matching power-of-two grid. It has the same prefix property and the same determinism as Halton, with slightly more visible dyadic structure if you look closely.

halton (the 1D scalar)

halton(_ index: Int, base: Int = 2) -> Double   // a value in [0, 1)

This is the scalar building block, the radical inverse. Use it on its own whenever you want a 1D stream that fills the unit interval evenly. Uses include spacing hues around a wheel, offsetting animation phases, and picking sample times. halton(i) for i = 1, 2, 3, 4… yields 1/2, 1/4, 3/4, 1/8…, and each value lands in the largest gap left so far.

Choosing between them

  • Need the count to grow or shrink live, or to draft then refine? Use a sequence, either one. This is the prefix property, and neither blue noise nor random has it.
  • Want the most even single layout, and the count is free? Use poissonDisk. Its minimum-distance guarantee is stronger than either sequence.
  • Want honest clumps? Use plain random, because clumping is a look of its own.

Both sequences are pure functions of the index, so they do not consume the sketch's seeded random and never affect reproducibility.

Standalone (outside a sketch)

The free functions take an explicit rectangle:

let points = haltonPoints(count: 500, in: Rectangle(x: 0, y: 0, width: 400, height: 300))
let finer = sobolPoints(count: 5000, in: bounds)

Related: Blue noise (the fixed-layout even scatter), Random (seeded uniform scatter), Stippling (density-weighted placement), Voronoi & Delaunay (turn any point set into cells).

↑↓ move ↩ open