Fractals
Six ways to turn a handful of numbers into endless detail:
- Iterated function systems repeat a few affine maps until the points collect onto a shape such as a fern.
- Fractal flames extend the chaos game with nonlinear warps and a log-density display.
- The Buddhabrot plots the Mandelbrot set's escaping orbits as a density plate.
- Circle-inversion limit sets are the lace that forms in the gaps of an arrangement of mirror circles.
- Kleinian limit sets are the fractal boundary curves of Möbius groups, traced in order.
- Schottky circle orbits pair circles off by Möbius maps, and the pairs nest forever.
All six are deterministic. The point emitters draw from a seedable generator, and the Kleinian walk uses no randomness at all. The escape-time relatives (.mandelbrot, .julia, .orbitTrap) and Newton's basins (.newton) run on the GPU as generators.
Contents
- IFS (the chaos game)
- FractalFlame
- Buddhabrot
- inversionLimitSet
- kleinianLimitSet
- schottkyCircles
- fitted
IFS (the chaos game)
IFS(maps: [IFS.Map]) // x' = a·x + b·y + e, y' = c·x + d·y + f
system.points(count: Int, settle: Int = 20,
using rng: inout some RandomNumberGenerator) -> [Vector2]
ifsPoints(_ system: IFS, count: Int) -> [Vector2] // the sketch form, seeded by `variation`
An iterated function system is a small set of affine contractions, and each one carries a pick weight. The chaos game applies a randomly chosen map over and over, so every orbit settles onto the maps' shared attractor. Three classic systems come built in: .barnsleyFern (four maps, the famous coefficient table), .sierpinskiTriangle, and .sierpinskiCarpet.
let cloud = ifsPoints(.barnsleyFern, count: 60_000)
.map { Vector2($0.x, -$0.y) } // the fern grows upward
fill(.white)
drawPoints(fitted(cloud, in: canvasRectangle.inset(by: 80)), size: 1.5)
Points come back in the system's own coordinates, so place them with fitted. To build your own system, write out each map's six numbers. The weights control how often each map is visited. The fern, for one, spends 85 percent of its time in the map that draws the main copy.
FractalFlame
FractalFlame(transforms: [FractalFlame.Transform], colors: [Color], gamma: 2.2,
vibrancy: 1, brightness: 1, window: Rectangle)
FractalFlame.random(using: &rng) // a few contractive maps, one variation each
flame.render(width: Int, height: Int, quality: Double = 60,
supersample: Int = 1, using: &rng) -> Image
FractalFlame.Renderer(flame, width: Int, height: Int, seed: Int) // the progressive form
The flame algorithm extends the chaos game three ways.
- Variations. Each transform follows its affine map with a weighted blend of nonlinear plane-warps, each written as a
Blend(_:_:)of a variation and its weight. The warps are.sinusoidal,.spherical,.swirl,.horseshoe,.polar,.handkerchief,.heart,.disc,.spiral,.hyperbolic,.diamond,.ex, and.julia. - Structural coloring. The orbit carries a color coordinate that averages toward each visited transform's palette index. Color therefore records which maps shaped each region.
- Log density. The render accumulates a per-pixel density histogram, then shows it through a logarithm. That is why filaments, veils, and cores all stay visible at once.
Three parameters control the development. gamma goes up to 4 to pull faint structure out of the veils. vibrancy at 1 keeps colors saturated under a strong gamma, and 0 washes them toward pastel. brightness multiplies the log-scaled density before gamma.
let renderer = FractalFlame.Renderer(flame, width: 560, height: 560, seed: variation)
// each frame:
renderer.accumulate(samples: 120_000)
drawImage(renderer.image(), in: canvasRectangle)
quality is chaos-game samples per output pixel. A few dozen give a preview, and hundreds make a clean still. The Renderer is the live form, so feed it a slice of samples each frame and the picture comes out of the noise. Both forms are deterministic for a fixed seed and sample count. FractalFlame.random makes a new flame from contractive affine maps, one variation each. Some of them come out dull, so change the seed and roll again until you get one you like.
Buddhabrot
Buddhabrot(iterations: [Int] = [5000, 500, 50],
window: Rectangle = Rectangle(x: -1.6, y: -2.05, width: 3.2, height: 3.2),
gamma: 2, brightness: 1)
plate.render(width: Int, height: Int, quality: Double = 10, using: &rng) -> Image
Buddhabrot.Renderer(plate, width: Int, height: Int, seed: Int) // the progressive form
The Buddhabrot shows the Mandelbrot set through its escaping orbits. It tests random points of the plane with the same z = z² + c loop the escape-time generators run. Each point that escapes is run again, and every point its orbit visits brightens the pixel under it. Develop the accumulated density like a photographic plate and you get the seated figure Melinda Green discovered in 1993.
iterations is one cap or three. One cap develops a grayscale plate. Three caps expose red, green, and blue at different orbit lengths. Short orbits then haze the background blue, and the longest orbits draw the figure's red spine. An orbit that runs past every cap counts as inside the set, so it plots nothing. window frames the plane in the classic upright reading. In that reading x spans the imaginary axis, y spans the real one, and the antenna sits at the top. Each orbit is also deposited mirrored about the real axis, which is the set's own symmetry, so one sample exposes both halves.
let renderer = Buddhabrot.Renderer(Buddhabrot(), width: 560, height: 560, seed: 7)
// each frame:
renderer.accumulate(samples: 20_000)
drawImage(renderer.image(), in: canvasRectangle)
A plate this deep is meant to be watched as it builds up, and the Renderer is the live form, holding the buddhabrot it is developing. quality on the one-shot render is orbit samples per output pixel, sized for small stills and tests. gamma at its default 2 lifts the faint structure. Developing the plate normalizes each channel to a percentile ceiling, so a few hot pixels near the antenna cannot dim the plate. Both forms are deterministic for a fixed seed and sample count.
inversionLimitSet
inverted(_ point: Vector2, in circle: Circle) -> Vector2
inversionLimitSet(of circles: [Circle], count: Int, settle: Int = 16,
using rng: inout some RandomNumberGenerator) -> [Vector2]
inversionLimitSet(of circles: [Circle], count: Int) -> [Vector2] // seeded by `variation`
Inversion in a circle turns the plane inside out around it. The center maps far away, the rim stays put, and d · d' = r² holds along every ray. The generator plays the inversions of an arrangement against each other, choosing a random circle at each step but never the one it just used. Inversion is its own inverse, so reusing a circle would undo the step. The orbit converges onto the arrangement's limit set. Circles are given in canvas coordinates, so the dust needs no fitting, and it lands among the mirror circles that made it.
let mirrors = /* a ring of tangent circles */
fill(Color(hex: 0xE8C97D, alpha: 0.6))
drawPoints(inversionLimitSet(of: mirrors, count: 30_000), size: 1.5)
Tangent rings give the classic gasket-like lace. Separated circles give Cantor dust, and overlapping ones tear the lace apart. The regions near a tangency point fill slowly, because inversions move points very little there. Give dense arrangements more count.
kleinianLimitSet
kleinianLimitSet(ta: Vector2, tb: Vector2, epsilon: Double = 0.002,
maxDepth: Int = 60) -> Contour
kleinianLimitSet(_ preset: KleinianPreset, ...) -> Contour
Two complex traces, carried as Vector2(re, im), pick a two-generator Möbius group by the classic recipe, and a KleinianPreset hands its own pair over as traces. A depth-first walk of the group's reduced words then traces its limit set as one ordered closed curve. Subdivision stops when an arc drops under epsilon, which is measured in limit-set units that run roughly ±2 across. The points therefore come back evenly spaced along the curve, ready for a plotter. The walk uses no randomness at all.
let curve = kleinianLimitSet(.lace)
noFill()
drawPolyline(fitted(curve.points, in: canvasRectangle.inset(by: 100)), closed: true)
KleinianPreset names the landmarks: .gasket, .spiralPair, .lace, .doubleCusp, .cusp, and .symmetricCusps. .gasket is the Apollonian gasket at traces (2, 2), and .doubleCusp is the well-known 1/15 cusp. Custom traces just inside the quasi-Fuchsian region make the curve spiral tighter and tighter. Outside that region there is no curve to trace, and the polyline degenerates. Cusps converge slowly, so give them a larger maxDepth.
schottkyCircles
schottkyCircles(pairing: [SchottkyPairing], viewpoint: Vector2? = nil,
minRadius: Double = 0.5, maxDepth: Int = 40) -> [Circle]
schottkyCircles(_ preset: SchottkyPreset, in bounds: Rectangle, ...) -> [Circle]
schottkyCircles(ta: Vector2, tb: Vector2, in bounds: Rectangle, ...) -> [Circle]
schottkyLimitSet(pairing: [SchottkyPairing], ...) -> [Vector2]
Take an even number of circles and pair them up. A SchottkyPairing(from:to:twist:) is the Möbius map that carries the outside of one circle onto the inside of its partner. A preset hands over its own arrangement through pairings(in:), laid out to fill a rectangle. Applying it drops whatever it touches into the partner disc, at a smaller size. Apply the pairings and their inverses in every order, and the circles nest forever. The shape they close down onto is the group's limit set.
The output is real Circles rather than a flattened polyline, because a Möbius map carries a circle to a circle. drawCircles renders them analytically, and the vector-export path writes true circle geometry, so the whole lace goes to a plotter as circles.
noFill()
drawCircles(schottkyCircles(.kissing, in: canvasRectangle.inset(by: 80)))
Circles come back in canvas coordinates and need no fitting, so the lace lands among the circles that made it. The walk is adaptive and uses no random numbers. It stops a branch once that branch's circle falls under minRadius, because everything below nests inside it. schottkyLimitSet keeps the centers of those stopped circles instead, and each center is within minRadius of the limit set. It uses no randomness either, unlike the chaos game behind inversionLimitSet.
Tangency is what fills the picture. When a pairing's two circles touch, its generator holds the tangency point fixed and is parabolic. It barely contracts near that point, so the orbit keeps producing large circles for many generations, and they crowd into a fan at the tangency. Pair circles across a ring instead and every generator contracts hard, which leaves a thin dust. The difference is large, and it is why there are two family builders:
schottkyCuspedPairs(in: Rectangle, spread: Double = 1,
lean: Double = 0, twist: Double = 0) -> [SchottkyPairing]
schottkyNecklace(pairs: Int, in: Rectangle,
tightness: Double = 0.9, twist: Double = 0) -> [SchottkyPairing]
schottkyCuspedPairs is the dense one, four circles in two touching pairs. Three dials shape them.
spreadslides the pairs apart. At1all four circles are mutually tangent, and the limit set closes into a round circle with four cusps. Above1the top and bottom tangencies open into gaps.leanswings each pair about its own tangency point, in opposite directions. That keeps both generators parabolic, which keeps the lace dense along its whole length.twistturns each pairing away from its tangency-preserving setting. It winds the limit set into a spiral, but thins it as it goes.
lean is the dial to animate, and twist is the one that costs you density.
SchottkyPreset names the landmarks: .kissing, .leaning, .cusped, .spiral, and .dust (the thin cross-ring pairing, for contrast).
Keep the discs disjoint. Tangency is allowed, but overlapping circles make the group non-discrete, and the lace turns to mud. A hard ceiling on the number of emitted circles stops such an arrangement from running away, so it cannot exhaust memory.
Viewing and containing. Two settings produce the classic framings. A viewpoint re-seats the whole picture by sending that point to the horizon. Put it inside one of the pairing discs, and that disc turns inside out and becomes the picture's outer boundary. The fundamental domain then shows as the large empty pockets. You can also declare a pairing's disc to be its circle's exterior, through mapsFromExterior: and mapsToExterior: on SchottkyPairing. That lets one circle contain the whole arrangement, which is how the gasket figures are drawn.
From traces. schottkyCircles(ta:tb:in:) renders the circle orbit of a group built by the same trace recipe, the group whose boundary kleinianLimitSet traces as a curve. It takes each generator's isometric circles as its pairing discs. The KleinianPreset overload accepts the same named landmarks, so schottkyCircles(.gasket, in:) and kleinianLimitSet(.gasket) are one group drawn two ways. At the gasket traces (2, 2) the orbit is the classic tangent-circle packing of the Apollonian gasket. Nearby traces bend and twist it, which is what the Patterns/Schottky example animates. The deep-cusp presets sit at the edge of the region, where the orbit shrinks slowly, so give them a larger minRadius.
fitted
fitted(_ points: [Vector2], in frame: Rectangle) -> [Vector2]
fitted scales a point cloud uniformly and centers it in a frame, keeping its aspect. It is the "fit the points, not the transform" rule as a helper, so a generated figure lands in place without scale() fattening its strokes. It serves any of the point emitters here, and it serves walks, attractors, and harmonographs just as well.
Related: Random & noise (the seeded generators these draw from), Attractors (the strange-attractor relatives), Tiling (the Apollonian gasket by Descartes' theorem), Effects (the GPU escape-time fractals), L-systems (growth by rewriting instead of iteration).