Skip to content

shape_field — the same roster, at frame scale

The five silhouettes above are not only marks. system = "shape_field" draws one of them as a fullscreen signed-distance field, which is a different thing from a big particle: the palette coordinate becomes the distance to the figure, so banding it draws concentric offset contours of the shape (ADR-0105).

The roster is not the only source of a silhouette here. A [path] table hands this scene a figure you drew yourself, as inline SVG path data, and everything below applies to it unchanged — the same coordinate, the same banding, the same contours. It also brings two params the roster shares: stroke, which draws the figure’s outline instead of filling it, and morph, which travels between two authored silhouettes.

system = "shape_field"
[params]
shape = "4" # the roster above - 4 is the heart
scale = "0.55" # the figure's size in the frame
palette_steps = "9" # <- the contours. Without this it is a gradient.
palette_contour = "0.7" # thin outlines at the band boundaries
color_span = "0.45" # how much gradient the figure's interior spans
paramdoes
shapethe same numeric selector as the table above, same five names, same closed roster
pointsthe same 3..12 count, for polygon and star
star_valley / star_curve / star_jitterthe same three star shape params — see The star arm. star_jitter is the one whose field precision is worth reading about there
scalethe figure’s size: its outline sits at scale of the frame’s short half-axis. Default 0.6, clamped to 0.01..20
pan_x / pan_ymove the figure’s centre (the shared view transform)
rotationturns the figure about its own centre, in radians. Default 0, an exact identity, unclamped — an angle wraps. Applied after pan_*, so a panned figure spins in place rather than orbiting the frame
gammathe response exponent on the figure coordinate, before it becomes a palette coordinate — where the contours crowd. Default 1.0 (evenly spaced, and an exact identity), clamped to 0.05..20
coord_modewhich coordinate the palette is handed. 0 (default) is the distance, whose contours are offset curves; 1 is r / r_boundary(theta), whose contours are scaled copies of the outline. Stepped, like shape. See Two coordinates
strokedraws the figure’s outline at this half-width instead of filling it, in coordinate units — 0.08 is a band 8 % of the figure’s half-extent either side of the outline. Default 0, the filled figure and an exact identity. Fill and stroke are the same field, so the outline cannot drift off the figure it belongs to
morphtravels an authored [path] towards its morph_to silhouette, 0..1. Inert on a roster figure and on a path that names no target

gamma is well defined on a curved or jittered star, and the arithmetic behind that is worth knowing (backlog 0097). Those two params take the star arm’s curved branch. Normalizing its true distance by the straight edge plane’s perpendicular — always shorter than the figure’s real deepest-point distance — would put the coordinate negative at the centre, and pow of a negative base is NaN. The branch instead divides by the figure’s own deepest-point distance, so the coordinate is 0 there — exactly 0 while the spikes are all the same length, and within about 0.09 of it under star_jitter, where the divisor is the unjittered figure’s while the measurement is the fragment’s own spike’s. Either way gamma is an ordinary knob on every star. presets/shape_facet.toml is the worked example: it pinned gamma = "1.0" for exactly this defect, and now binds it to treb.

Two coordinates — offsets and scaled copies

coord_mode decides what a band of the palette is a band of. Both coordinates are 0 at the figure’s centre and exactly 1 on its outline; what differs is everything in between (ADR-0111).

coord_modethe coordinatea contour is
"0" (default)the normalized distance to the figurean offset of the outline
"1"r / r_boundary(theta) — how far out you are as a fraction of the outline in that directiona scaled copy of the outline

The difference is not a nuance on the shapes with a corner. An inward offset is an erosion, and erosion rounds a reflex corner while keeping convex ones sharp — so under "0" a nested heart keeps its bottom point and loses its top notch as the rings move inward, and no value of gamma or palette_steps recovers it. Under "1" the inner figures are the same figure, smaller.

[params]
shape = "4" # the heart
coord_mode = "1" # <- nested SCALED COPIES rather than offsets
palette_steps = "9" # the ring count, and now nothing trades against it
gamma = "1.0" # spacing only; it no longer decides notch sharpness

color_span DOES NOT TRANSFER between the two modes, on top of the shape-to-shape trap the same parameter already has (above). Under "0" the exterior is divided by the shape’s inradius; under "1" it grows linearly in r. A preset that switches modes has to re-tune its span, and nothing warns.

"0" is the default and it is bit-for-bit the arithmetic that shipped, so no existing preset moves.

Which arms the mode actually changes, measured as how far a contour deviates from a scaled copy of the outline (0 means it is one):

shapeinside the figureoutside it
discidentical — its offsets and its scaled copies are the same circlesidentical
ringnot available"1" is refused, with a load warning, and the distance is drawn. See belownot available
polygonidentical: eroding a regular polygon moves every edge in by the same amount, and erosion rounds only reflex cornersdiffers — outside, the distance rounds each vertex into an arc
star, straight edgeidentical, for the same reasondiffers, 0.03..0.06
star, star_curve or star_jitter non-zerodiffers, 0.12..0.20differs
heartdiffers, and it is the whole point0.02 at the outermost of nine rings and 0.55 at the innermostdiffers

The heart row is the one to read. Its deviation under "0" grows as the rings move inward — that is the notch filling in, ring by ring — and under "1" it is 0.00000 at every ring. The disc is the control: an engine that drew those two differently would be broken.

coord_mode = "1" DOES NOTHING ON A ring, and it tells you so. An annulus’s centre lies in its hole, so a ray from there crosses the outline twice and r / r_boundary has no single value. The scene draws the distance instead and warns at load:

parameter 'coord_mode' is ignored on a `ring`: an annulus's centre lies in its
hole, so a ray from there crosses the outline twice and the scaled-copy
coordinate has no single value. The figure is drawn with the distance instead.

The alternative was to define the boundary as the outer rim, and it was rendered before being rejected: the coordinate then collapses to length(p) and the ring comes out byte-identical to a disc — the hole simply stops existing. Naming one shape and being shown another is worse than being told no.

The warning only fires on a shape that rests at ring. A binding that animates through it rests nowhere, so nothing warns; the fallback still applies, frame by frame.

rotation turns the figure about its own centre — and kaleido_* is not the same thing. The screen-space fold folds the finished frame about a screen-centred axis; it does not turn the figure, it multiplies it, and it fights a translating pan_* (a panned figure comes out decentred in the rosette). If you want the figure to turn, reach for rotation.

[params]
pan_x = "0.35"
rotation = "time * 0.4" # a slow turn IN PLACE, not an orbit

The after the pan part is the choice worth knowing: the turn is applied to the figure’s own frame, so a panned figure spins where it sits. Turning before the pan would swing it around the frame’s middle on a circle of radius |pan|, which is a different look and is not what this does. The angle is in radians, matching rotation on star_pattern and lsystem, so multiply by time yourself.

There is no zoom here and that is not an omission: scale is this scene’s size lever, and a view zoom on top of it would be a second spelling of one idea.

Making the figure respond — the three levers, and none of them is new

All three of the obvious reactivity asks fall out of parameters that already existed, once the thing being banded is a distance. These are recipes, not features.

[params]
color_center = "time * 0.15" # RINGS TRAVEL OUTWARD
scale = "0.5 + 0.12 * bass" # THE FIGURE BREATHES
palette_steps = "6 + floor(hash(beat_index) * 4)" # RING COUNT PER DETECTION
  • Rings travel outward because color_center slides the palette coordinate, and the coordinate is a distance — so sliding it slides every contour together. The seam to know about is the LUT’s repeat addressing: the coordinate wraps at 1, so unless the gradient’s last stop is its first colour again, the wrap is a visible edge crossing the figure once per cycle. Measured on a cyclic gradient, a full 12-step cycle moves the frame by 0.0339 per step on average with the wrap step at 0.0330 — no stutter. On a non-cyclic palette the wrap is the sharpest transition in the gradient; see Repeating is not the same as continuous.
  • The figure breathes on scale, monotonically: measured 14, 26 and 41 px of half-extent at scale 0.25, 0.45 and 0.7.
  • Ring count on the beat works because palette_steps is quantized on the CPU — an eased binding visits whole counts and never a fractional one, which would leave every boundary crawling. Whether it reads is a separate question: a band count is a global change to every pixel at once, which is the shape a strobe has, and fragment_mandala’s header flags the same worry. If it strobes, put the beat on scale or gamma instead and let the count sit still.

gamma runs the opposite way to ink_gamma

The exponent applies to the distance, before the palette coordinate, so it decides where contours crowd rather than what colour they are. Because a band boundary sits at (k/n)^(1/gamma), the exponent is inverted on its way to a position — and below 1 is the direction that tightens the rings toward the centre, which is what the reference images do:

gammawhere the 8 boundaries land, in distancereads as
0.40.006 0.031 0.086 0.177 0.309 0.487 0.716 1.0tight at the centre, opening outward
1.00.125 0.25 0.375 0.5 0.625 0.75 0.875 1.0evenly spaced (the default, an exact identity)
2.50.435 0.574 0.675 0.758 0.829 0.891 0.948 1.0tight at the outline

That is the opposite of the intuition ink_gamma builds, where a higher exponent means more effect at the low end. Nothing warns — this table is the warning.

palette_steps is what makes this scene look like anything. Left at its default the frame is a smooth ramp from the figure’s centre outwards — correct, and not the reason to reach for this system. Turned up, each band is a band of constant distance, which is the definition of an offset curve; palette_contour then draws a hairline at each boundary. This is the third scene those two params do anything in, and the first where the thing being banded is a figure rather than a noise field.

Where the contours read is color_span. The distance is 0 at the figure’s deepest interior point and exactly 1 on its outline, and keeps growing outside — so color_span decides how much gradient the interior gets and how much is left for the rings around it. Low values put the whole gradient inside the silhouette and leave the surround flat; the default 0.6 is a compromise that shows both.

color_span IS NOT PORTABLE BETWEEN SILHOUETTES, and the factor is large. The scalar is normalized by each shape’s own inradius — that is what makes d exactly 1 on every outline — and the inradii are nothing like each other: about 0.64 for the heart against 0.093 for a sharp star (star_valley = 0.18, 7 points). So the same color_span gives roughly seven times the contour count on that star, and a value tuned on one shape is meaningless on another. Authoring a sweep across several figures needs a separately computed span per figure just to keep it on frame.

The live consequence: binding star_valley or points silently changes the ring count while it moves, because both move the inradius. If you animate either on shape_field, expect the banding to breathe with it — and if you wanted only the silhouette to change, that is not currently separable.

AND IT DOES NOT TRANSFER BETWEEN coord_mode VALUES EITHER, which is a second, independent trap on the same parameter. Under "0" the exterior is divided by the shape’s inradius, so how far the coordinate reaches depends on how thin the figure is; under "1" it grows linearly in r — the coordinate is 2 at twice the boundary radius, on every shape, by construction. So a span tuned in one mode is meaningless in the other, and nothing warns. Switching modes means re-tuning color_span, and the good news is that under "1" you only have to do it once: it is the same scale for every silhouette, which is the trap above dissolving.

How far the coordinate reaches at the frame corner is worth one line of arithmetic before you ship. The exterior is most of a 16:9 frame, and gamma above 1 compresses it: past roughly 1.5 on a palette that wraps, or 3 on a single sweep, the ring frequency at the corners runs past what the pixel grid can carry and the picture breaks into moire — which reads as texture in a still and shimmers the moment anything moves. d at the corner is 1 + (|uv|_corner / scale - 1) / R for inradius R, and the coordinate there is d^gamma * color_span; keep that under about 6 with palette_steps near 9. shape_pulse’s header carries the worked example.

points on a star here is not the same picture as on a mark. These silhouettes were tuned for sprites a few pixels across, and at frame scale the star’s interior is knowingly approximate — its field is measured 0.066 out of true at 5 points and 0.248 at 12, in units where the figure’s own inradius is 1. The contours outside every shape are exact. So a many-pointed star’s inner rings will not sit where an offset curve should; its outer ones will.

The nested figure — the worked recipe

This is the construction two batches of reference images have asked for: a figure nested inside itself, many rings deep, every ring still sharply the same shape. It is three parameters, and coord_mode = "1" is the one that makes it possible.

system = "shape_field"
[palette]
stops = [
{ at = 0.0, color = "#180a12" },
{ at = 0.5, color = "#c02040" },
{ at = 1.0, color = "#ffe0b0" },
]
[params]
shape = "4" # the heart
coord_mode = "1" # <- SCALED COPIES. Without this the notch rounds off.
scale = "0.62"
palette_steps = "9" # THE RING COUNT, and nothing trades against it
gamma = "1.0" # the SPACING only: below 1 crowds them inward
color_span = "0.42" # how much gradient the figure's interior spans
palette_contour = "0.75" # a hairline at each ring

Read the three roles, because under the old coordinate they were not separable:

  • palette_steps is the ring count. Nine rings inside the figure, and the innermost is still a heart.
  • gamma is the spacing, and only the spacing. Below 1 the rings crowd toward the centre, above 1 toward the outline (the table above). Under coord_mode = "0" it also decided how rounded the inner figure was, because the innermost band’s boundary sat at d = ((1/palette_steps)/color_span)^(1/gamma) and a sharp notch needed palette_steps * color_span ~ 1 — which left one band inside the figure. That trade is gone.
  • color_span is the interior’s share of the gradient, and under "1" it means the same thing on every shape.

The old route was to fake it in the palette, and you should not. presets/shape_pulse.toml reaches the ring count by packing 18 stripes as gradient stops below the outline’s coordinate — a 76-stop palette that has to be regenerated whenever the count changes, and one that cannot fix the real defect: the level sets are still offsets, so the inner figure still rounds off. It is a shipped, accepted look and nothing forces it to move, but coord_mode = "1" is the documented route now.

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