Skip to content

tuple picks a whole figure, framing included

The companion to the paragraph above. Moving a..d yourself reaches the figures near the canonical one; it cannot reach a distant one at all, because a family’s world scale and centre are sized to its canonical tuple and a wild tuple renders off-centre and out of frame with no preset-side recovery — pan cannot span it. tuple selects from a curated roster whose entries carry their coefficients and their framing, so a distant figure arrives centred and in frame, with reseed and the depth cues already sized to it (ADR-0093).

ParamWhat it doesRange that means something
tupleSelects a roster entry for the current family. 0 is the canonical figure — exactly what the family drew before this param existed.0 .. one less than the family’s roster length; out of range holds the last entry, and a non-finite binding falls back to 0

Three things to know before you bind it:

  • It is quantized CPU-side, to the nearest whole entry, the way kaleido_spiral and palette_steps are. There is no figure between two roster entries — a fractional index would mean coefficients interpolated between two different attractors, which is a third, unmeasured figure with neither endpoint’s framing.
  • So a change is a cut, and it wants a long [smoothing] — the guidance kaleido_order carries, for the same reason. A fast binding is a slideshow rather than a morph. The cut is softened by whatever reseed disturbance is already running, and a cut between presets is hidden by the dissolve.
  • A bound a..d loses to the entry on the frame the cut lands, and gets it back on the next one. Binding both is legal and mostly not what you want: the entry’s coefficients are the base your a was modulating around, and the base has just moved.

What is in each roster

Every entry was curated by judging the candidate in motion. Entry 0 is always the family’s canonical figure.

FamilyEntriesWhat the extras are
de_jong13Twelve gallery tuples: shells, vaulted arcs, a four-lobed bow-tie, a dense orb, a bare S-curve.
clifford13Twelve more: woven discs, an oblique ring, three separated crescents, a hard-edged chevron.
thomas13A twelve-step sweep of a from 0.03 to 0.22 — the family reads a alone, so this is one continuous axis from a space-filling scribble to a tight knot at the edge of chaos.
lorenz12rho walked from 24.4 to 126.52 (entry 1 is the rho ≈ 100 torus knot), plus three that move sigma/beta instead.

Two facts about specific entries that a still will not tell you:

  • The Lorenz torus knot (entry 1) blooms slowly on a reseed. Its orbit is marginally stable, so a kick sends the cloud on a wide excursion — measured at about 2.2x the figure’s own extent — that takes several seconds to fall back. On the canonical butterfly the same disturbance is absorbed in a handful of frames. That is a look, not a defect, but bind reseed on it knowing it.
  • Thomas past a ≈ 0.208 closes into periodic orbits. The roster stops at 0.22 deliberately: further up, the flow collapses onto a short cycle, which has a perfectly good bounding box and draws as a few dots rather than a figure. Four De Jong candidates were rejected at curation for the same reason.

Indices are names. The shipped attractor_*gallery presets step these by index and attractor_torusknot pins Lorenz entry 1, so a roster edit that inserts or reorders renames figures out from under them. Append instead.

Walking between two entries — tuple_from, tuple_to, and morph

tuple cuts. To travel between two figures instead, name a path in [particles] and drive it with morph — the same key, and the same meaning, it already has on an IFS:

[particles]
family = "thomas"
tuple_from = 5 # the near end (optional; entry 0 by default)
tuple_to = 8 # the far end — this key is what turns the walk on
[params]
# a slow round trip, about 48 s
morph = "0.5 - 0.5 * cos(time * 0.13)"
KeyMeaning
tuple_toFar end of the path, a roster index. Absent, there is no path and morph is inert — which is every preset that does not ask for one.
tuple_fromNear end. Defaults to entry 0.
morphPosition along the path, 0..1. Continuous — this is the one place in the attractor’s surface a param is not quantized.

Five things that are not guessable:

  • The middle is measured, not averaged. The engine samples the figure’s framing at nine positions across the pair at load, because a figure halfway between two tuples is a figure in its own right and its extent is only coincidentally the mean of its endpoints’. The ends keep the framing the roster already measured, so morph = 0 renders exactly the entry it names — which is what makes a path safe to add to a preset that already works.
  • tuple goes inert while a path is configured. A preset either steps the roster or walks a path. Both ends are structural because measuring the walk is thousands of map iterations; a near end that moved per frame would re-measure inside the frame loop.
  • Not every pair has a walk. A tuple partway between two others can collapse to a fixed point, whose extent is zero and which has no scale to render at. The engine then refuses the path and the preset sits on its near end with morph doing nothing. Of twenty pairs swept, four were refused this way — all on the two discrete maps. If your morph does nothing, this is the first thing to suspect.
  • Do not put morph in [smoothing]. The binding is already a slow curve and easing an ease only lags it. The walk’s smoothness is the mechanism’s.
  • A far end may need time to settle. Where the target is a periodic attractor — the rho ≈ 100 knot is one — a cloud arriving along the walk is still falling onto it for several seconds after morph reaches 1.

Four paths ship, each judged in motion: thomas 5→8 (Thomas Walk), lorenz 0→1 (Butterfly to Knot), lorenz 0→4 (Rho Walk), de_jong 1→3 (De Jong Walk). The one-dimensional sweeps are the strongest case: neighbouring a or rho values are neighbouring figures.

Each family is viewed in its own plane, and it matters the moment you reach for zoom or pan_*, because those aim at the figure the plane produces:

FamilyDimensionsViewed inThe spin turns x against
de_jong2-D mapx–y— (in-plane rotation)
clifford2-D mapx–y— (in-plane rotation)
thomas3-D flowx–yz
lorenz3-D flowx–zy
the five IFS figures2-D IFSx–y— (in-plane rotation)

Lorenz is the exception, and it is one deliberately: its butterfly lives in x–z, and viewed x–y the two lobes are edge-on — a hard X that reads as a dense core inside a diffuse cloud rather than as a figure (ADR-0068). Thomas is 3-D too and keeps x–y, so the plane is a per-family property, not something you can infer from the dimension count.

One consequence to author around: the 3-D families spin as a turntable about the vertical axis, so a quarter turn necessarily leaves the family’s own plane. Lorenz reads as the butterfly near 0° and 180° and as a low-structure cloud near 90° and 270° — the plane buys the shape, not the shape at every angle.

The five IFS figures — a different kind of family

fern, tree, dragon, sierpinski and spiral are iterated function systems, not strange attractors (ADR-0075). Same scene, same trails, same density / fade / palette / view surface — different step: four affine maps, one drawn at random per particle per step, converging onto a figure rather than onto a filigree.

Four things are different for you as an author on an IFS, and the last two are the ones that cost a session if you carry the rest of this library’s habits across. All four are measured.

a b c d are inert. An IFS’s shape lives in its affine table, not in four scalars. What reaches the figure is the five params below — plus the four colour channels and emergence in the next section — and all ten are IFS-only, inert on de_jong, clifford, thomas and lorenz, the same way a..d already carry family-specific meanings.

ParamDefaultWhat it does
morph0Position from family to [particles] morph_to. 0 is the named figure, 1 is the target, and every value between is a real figure — but not a proportionally-different one, see below. Clamped; out of range pins to an endpoint.
curl0Radians added to every map’s rotation — fronds curl and uncurl.
vigor1Multiplier on the figure’s contraction — a bushier, deeper, denser figure. Has a silent ceiling; see below. Inverts on a space-filling figure: the dragon’s two maps sit at exactly 0.7071, already space-filling, so vigor above 1 overfills the region and dissolves the figure into dust — measured on the loud frame; attractor_dragon ships the binding inverted below 1.
lean0Radians every translation is rotated by — the plant bends.
bias0Moves sampling weight from the trunk/body maps to the branch maps. Geometry is untouched; only the density distribution moves. Inert on dragon, whose two maps are both branches.

You cannot break the figure with them, and that is the point. The maps are carried as the singular value decomposition of their linear parts, so contractivity is a comparison on two numbers and every reachable value — including every intermediate morph and every combination of the four levers — is a converging system. Drive them as hard as you like; unlike a..d on a chaotic map, there is no cliff to fall off.

vigor’s ceiling is silent, the same shape as bloom_threshold and perspective. Every map’s contraction is held under 0.97, and the fern’s largest is already 0.851 — so about 17 % of headroom, and asking for more than that gets silence rather than an error or a warning. Past the ceiling every value renders identically. If vigor seems to stop responding, that is where it stopped.

The framing follows morph but not the levers, deliberately. The scene measures each figure pair’s extent at load and re-frames as the morph crosses, so an intermediate figure fills the frame instead of drifting off it. It does not re-frame for the levers, because a fit that did would cancel vigor exactly — the figure would surge and the frame would shrink it back for a net zero. The cost is that a hard vigor push can leave the frame; zoom is the recourse.

And it does not re-frame for spin either, which matters more, because spin defaults to on (ADR-0103). What the fit measures is an axis-aligned box; the projection then rotates it. A box that is hx by hy reaches sqrt(hx² + hy²) on both axes at its worst angle, so a figure only stays inside the frame at every angle if it is at least 1.85x taller than wide — and of the five, only the fern is (a = hx/hy = 0.49, against a bound of 0.54). The others overrun the frame corner at some point in the rotation: sierpinski by 34 %, tree by 41 %, dragon by 58 %, spiral by 79 %.

So the fit’s actual guarantee is inside the frame at neutral levers and zero rotation, and zoom is the recourse for both. That is why all three shipped 2-D IFS worlds carry a base zoom below 1 — attractor_dragon 0.92, attractor_fern and attractor_volute 0.96. Those are framing values, not taste: raising one back to 1.0 puts the figure’s corner off screen at some spin phase. A new 2-D IFS world either binds spin down to a small rock, or pays the same static zoom, or does both — which is what each of the three did independently before this was written down.

morph is a TRAVEL knob, not a little-life knob, and its visible rate is steepest near zero — which is the opposite of what “every value between is a real figure” suggests. Measured on fern → dragon, the lit width of the figure as a fraction of the frame:

morph0.000.050.100.15
lit width0.2480.4480.5720.584

By 0.05 the fern is half again as wide and reads as a curl rather than a plant, because a few degrees of per-map rotation compounds through the recursion. A cross that stays recognisably the figure you named would have to live under about 0.03, which is not a lever. So: bind morph when the preset is meant to travel, and leave it alone when it is meant to be one figure — the four levers are what change a figure without leaving it. attractor_fern binds no morph at all; the since-retired attractor_dissolve used the full range, and travelling was its whole point — its file remains the worked example, in git history.

The spiral is a fine figure and a poor morph target. Anything ending there thins into ragged streaks with half the frame empty: its dominant map contracts at only 0.93, so the intermediate spends nearly every sample on a map that barely contracts and the orbit spreads instead of settling. Of five pairs swept end to end, sierpinski → fern was the best by a distance and fern → spiral came last.

These figures are STILL, so your levers must move ~10× faster than the rest of this library’s drifts. Every other attractor preset evolves on time sines with 200–400 s periods, which is right for a strange attractor because the attractor is already churning and the drift only stops it repeating. An IFS at fixed levers is a photograph: everything a viewer sees moving is a lever moving. Copying the slow periods gives anim around 0.018 against a 0.01 gate floor — it passes, and looks like a still image. Around 30 s it reads as alive.

And bind spin. It defaults to 1, a full revolution every ~35 s. That is the shipped look on a chaotic cloud, but these figures have an intrinsic upsierpinski is an equilateral triangle, fern is a plant — so the default spends half of every cycle upside down and reads as a crooked frame rather than a turning figure. A rock (sin(time * k) * 0.25) is almost always what you want.

Colouring by what made a point, and by how far into the figure it is

Every particle carries two extra values, and neither is guessable from the param name, so read this before reaching for them (ADR-0087, ADR-0088).

  • map — which part of the figure a point belongs to. It is the index of the affine map applied on that particle’s most recent step, which makes it a property of position rather than of history: it names which sub-copy of the figure the point currently sits in. On the fern those four sub-copies are the stem, the body, the left frond and the right frond. So colouring by it genuinely separates the parts of the plant; it is not a per-particle identity that would read as noise.
  • root — how far a point is from the figure’s own skeleton. The distance to the nearest of the drawn maps’ fixed points — the places the figure contracts toward, which on the fern are the stem base and the frond origins — normalised by how far apart those points are. Like map it is a property of position rather than of history, recomputed every step from where the particle now is, so an old particle sitting near a fixed point reads the same near-zero a freshly restarted one does. That is what makes the gradient permanent rather than a startup animation.

Each reaches the picture by two routes, which is four params:

ParamDefaultWhat it does
map_tint0Shifts the particle’s palette coordinate by ±map_tint/2 across the four sub-copies. Rides your own [palette], so a custom ramp, palette_mix and saturation all reach it for free.
map_hue0Rotates the hue of the colour the palette returned, by ±map_hue/2 across the same four. Leaves the coordinate alone, so it nudges a part of the figure off your ramp without editing the ramp. 1.0 is a full turn of the wheel.
root_tint0The palette-coordinate route, across distance-from-the-skeleton. Anchored, not centred — see below.
root_hue0The hue-rotation route, across the same distance. Anchored the same way, and the escape when your palette coordinate is already spent.

root_* is anchored at zero; map_* is centred. This is the one place two params on the same page behave differently, so do not generalise from the row above. A particle on a fixed point takes no shift at all — the stem base keeps exactly the colour your ramp already gave it — and the figure ramps away from there. So root_tint does not open a spread around your colour the way map_tint does; it pushes one direction, and it only ever pushes.

Its effective range is per figure, and that will surprise you. The channel is normalised by the figure’s own skeleton, and four of the five figures reach less than half of it. The fraction of the palette a root_tint of 1.0 actually buys, measured:

figurereachesso for a full sweep, bind about
spiral0.412.4
fern0.462.2
sierpinski0.502.0
tree0.701.4
dragon1.050.95

A value tuned on the fern and reused on the dragon is wrong by about 2.5×.

Because it is anchored, root_tint spends the ramp’s bright end by construction — it only ever pushes the coordinate up. On a palette that already ends bright (most of them here) it whitens exactly the regions that were already brightest, which is why neither shipped IFS preset binds it. A negative value is legal and is the obvious escape: it ramps down the ramp’s dark end instead. But the coordinate is sampled by a repeating LUT, so once it crosses zero the darkest points wrap to the ramp’s brightest stop and a cream speckle appears where the figure should be darkest. On attractor_fern that is around root_tint = -0.38 (its coordinate floor is hue_center’s sine trough 0.20 minus hue_spread/2, against a root01 ceiling of 0.46). Do the same arithmetic for your own preset before going negative.

There are no age_* params. Colouring by how many steps since a particle last restarted proxies distance-from-the-restart-points badly: the proxy decays after about ten steps and renders as per-particle speckle with no gradient anywhere. root_tint/root_hue measure that distance directly instead. A preset binding an age_* name gets the usual unknown-param warning.

Which route do you want? They are not peers — *_tint is the default and *_hue is the special case. *_tint keeps the figure inside the ramp you authored, so a fern stays botanical and merely separates; *_hue throws a part clear of the ramp entirely (on attractor_fern’s greens it sends the fronds to teal and periwinkle), which is striking and fights a palette you spent five stops on. Reach for *_hue when your palette is a narrow band and you want one part out of it — or when the palette coordinate is already full, which is the next thing on this page.

The palette coordinate is a fixed budget, and this is the one that will cost you a session. Three params write it — hue_spread per particle at random, map_tint per part, root_tint per distance — so adding one means taking authority away from another, not stacking a third term on top. Twice measured on attractor_fern:

  • Its hue_spread had to come down from 0.16..0.42 to 0.05..0.125 before map_tint read at all; above that the parts smeared into each other and map_tint was a faint wash at any setting.
  • Its map_tint then had to be cut from 0.46 to 0.22 before root_tint improved the picture. Stacked at full strength the plant washes out and the stock preset looks better.

Each time you are trading one kind of structure for another, which is a real choice rather than a bug. *_hue is the escape: it does not touch this coordinate at all, so a figure that is out of budget can still take a depth cue or a part separation through the hue route.

And on the fern, the escape is what shipped — so do not read the 0.22 above as the tuning in the file. Rendered against each other, root_hue at the fern’s full map_tint = 0.46 beat the split: the body cools to jade, the frond origins stay warm, and the part separation the narrowed hue_spread paid for is not given back. Every shipped IFS preset binds root_hue and none binds root_tint. The budget rule is real and the 0.22 measurement stands; the conclusion drawn from it is that when a coordinate is spent you take the other route rather than pay.

And all four are inert on the four map families: nothing but an IFS writes either channel, so binding them on de_jong does nothing at all rather than doing something subtle.

The recycling’s rate is not an instrument, but its ramp is. A beat cannot restart a burst of particles — that was a deliberate call, and reseed is the percussive lever on this family. What you can set is emergence: how long a just-restarted particle takes to fade up to full brightness, in steps (default 8, which is about 0.13 s at the fixed step). It exists because a longer fade integrates the four restart points over more frames, so a ramp that hides the recycling at fade = 0.86 may not at 0.94 — if you push the trail long and start seeing four bright dots appear, raise this. Values below one step are clamped silently, because a particle’s age advances in whole steps.

Built from a8ce055 at version 0.115.0. This site tracks main and is not versioned per release.