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
param
does
shape
the same numeric selector as the table above, same five names, same closed roster
points
the same 3..12 count, for polygon and star
star_valley / star_curve / star_jitter
the same three star shape params — see The star arm. star_jitter is the one whose field precision is worth reading about there
scale
the 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_y
move the figure’s centre (the shared view transform)
rotation
turns 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
gamma
the 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_mode
which 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
stroke
draws 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
morph
travels 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 — exactly0 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_mode
the coordinate
a contour is
"0" (default)
the normalized distance to the figure
an offset of the outline
"1"
r / r_boundary(theta) — how far out you are as a fraction of the outline in that direction
a 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):
shape
inside the figure
outside it
disc
identical — its offsets and its scaled copies are the same circles
identical
ring
not available — "1" is refused, with a load warning, and the distance is drawn. See below
not available
polygon
identical: eroding a regular polygon moves every edge in by the same amount, and erosion rounds only reflex corners
differs — outside, the distance rounds each vertex into an arc
star, straight edge
identical, for the same reason
differs, 0.03..0.06
star, star_curve or star_jitter non-zero
differs, 0.12..0.20
differs
heart
differs, and it is the whole point — 0.02 at the outermost of nine rings and 0.55 at the innermost
differs
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: scaleis 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.
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:
gamma
where the 8 boundaries land, in distance
reads as
0.4
0.006 0.031 0.086 0.177 0.309 0.487 0.716 1.0
tight at the centre, opening outward
1.0
0.125 0.25 0.375 0.5 0.625 0.75 0.875 1.0
evenly spaced (the default, an exact identity)
2.5
0.435 0.574 0.675 0.758 0.829 0.891 0.948 1.0
tight 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 samecolor_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.