Hard bands — palette_steps and palette_contour
Everything above treats the gradient as a smooth ramp: a scene computes a
palette coordinate, the LUT is sampled with linear filtering, and the result is a
continuous blend. palette_steps breaks that ramp into hard graphic bands, and
palette_contour draws a darkened hairline where the picture crosses from one band
to the next (ADR-0078).
This is the difference between a field that reads as a gradient and one that reads as designed — flat areas of colour with drawn edges, the way a printed poster or a topographic map does.
| Param | Default | Range that reads | Accepted |
|---|---|---|---|
palette_steps | 0 (off) | 4–12 | 0 = smooth, integers up to 64 |
palette_contour | 0 (off) | 0–0.5 | 0 = none, up to 1 |
palette_contour_style | 0 | one of four | 0–3, rounded |
palette_contour_ink | 0 | a palette coordinate | 0–1 |
[params]palette_steps = "6" # six flat bandspalette_contour = "0.35" # a drawn edge between themcolor_span = "1.4" # walk enough gradient that the bands differWhat the line is drawn in — palette_contour_style and palette_contour_ink
The contour has four forms, and they are two independent choices packed into one name (ADR-0197): how the line is filled across its footprint, and what colour it is.
palette_contour_style | footprint | colour |
|---|---|---|
0 — the default | soft, ramped over one pixel | black, as a darkening of whatever is underneath |
1 | hard, a step over the same footprint | black |
2 | soft | the palette’s own colour at palette_contour_ink |
3 | hard | the palette’s own colour at palette_contour_ink |
Every style fires in exactly the same places — the rule above, unchanged. The style
only decides what lands there, and 0 is the arithmetic that shipped before the
other three existed, so adding the name to a preset changes nothing until you move
it.
palette_contour_ink is an absolute coordinate. It is not shifted by hue and
not scaled by color_span: 0.955 means the colour at 0.955 of your gradient,
which on a plateau palette is whichever run covers that position. It crossfades A/B
by palette_mix like every other sample, so on a preset with a [palette_b] the
key travels with the rest of the frame.
The hard styles are what a limited-ink print wants. A soft line writes one
intermediate value per step of its falloff, so it is the single biggest source of
non-ink values in a frame made of flat colour — shape_contourmono measured at
9 distinct colours with the contour off and 684 with the shipped soft one at
full strength. A hard line at palette_contour = "1" writes one flat value, so the
same frame measures 9 at style 1 and 9 at style 3, and at style 3 the
key is drawn in the palette’s own red rather than in black.
[!WARNING] A hard line has no anti-aliasing, and on a smooth palette it will read as jagged. The step is the soft ramp’s footprint filled to the edge rather than ramped across it. On a quantized palette every band edge is already hard by construction, which is the premise these two styles are for; on a continuous ramp, stay at
0or2.
What it does to the coordinate
The palette coordinate is quantized, not the baked LUT:
t' = (floor(t * N) + 0.5) / Nimmediately before the sample, landing on each band’s centre — so a band takes the
colour the smooth ramp had in the middle of it, not at its edge. The [palette]
bake is untouched, which is the whole point: the band count has to be bindable to
audio, and re-baking a 256-entry LUT every frame is exactly the work the bake
exists to remove. Bind palette_steps to bar or latch it on a beat and the
picture re-quantizes per frame at no cost.
Two consequences worth having up front:
palette_stepsis stepped. It rounds to a whole number, likekaleido_order. A fractional band count does not step — it leaves every boundary crawling across the field one frame at a time, which reads as shimmer rather than as colour. An eased or boundpalette_stepsstill eases; it snaps at each half-integer.- Off is the exact identity.
palette_steps <= 1andpalette_contour = 0take the unquantized path bit-for-bit, so adding these to an existing preset changes nothing until you actually turn them on.
The ranges, and what is outside them
These were picked off a rendered sweep. Outside them nothing breaks — it stops being the graphic look:
4–12bands is where the field reads as flat areas with real edges.16and up approaches the smooth ramp again. The bands become narrower than the eye separates them at, so the picture converges back on the unbanded gradient. A legitimate destination if you want almost continuous with a hint of structure — not what to bind if you want bands.palette_contourup to about0.5is an edge between two colours.0.8is a deliberate topographic look, not an error: past roughly0.5the dark line stops being an edge and becomes the dominant mark, and the field reads as a contour map with colour fill. Arrive there on purpose, not by easing.
color_span decides whether banding is visible at all
palette_steps quantizes the coordinate, so it can only separate colours the
coordinate actually reaches. A fragment_field at its default color_span of 0.6
walks barely half the gradient, and six bands there land on six neighbouring
shades — technically banded, visually a texture. Widen the window and the same six
bands become six distinct colours. Since the LUT is repeat-addressed, a
color_span above 1 wraps and walks the whole gradient (see the
cyclic-wrap caution — on the four stop-list palettes the wrap
is the sharpest transition there is, which under banding becomes a visible seam
rather than a soft one).
So tune color_span and palette_steps together. If banding “isn’t doing
anything”, the span is the first thing to check.
Where the contour falls — it reads the palette
[!IMPORTANT] The contour draws only where the two bands it separates are actually different colours. It samples the palette at the two band centres either side of the nearest edge, and draws nothing when they come back the same (ADR-0133).
This is what makes the parameter usable on a limited-ink look. A two-ink
print is written as plateaus — runs of bands holding one colour, the only way
to get flat ink out of palette_steps — and a contour drawn at every boundary
inside those runs is a grey hairline across flat colour: exactly the shading
such a look is defined by not having. shape_contourmono’s twenty steps sit in
five runs, so fifteen of its twenty band edges are white-meets-white or
black-meets-black; without the palette read, its only usable setting would be 0.
Nothing on a smooth palette is suppressed. The test is equality, not
similarity: the line is dropped only when the two samples agree to within half
an 8-bit code value, which is below the LUT’s own quantization. Two distinct band
centres on a ramp always differ by at least one code value, so on a ramp every
edge draws, at any palette_steps and any color_span.
Two edges of the rule are worth knowing, because both will read as a surprise:
- A near-plateau is not a plateau. A “flat” run built from two stops that differ by one code value still contours inside the run. Correct by the stated rule, and avoidable: write a plateau as the same colour on both stops.
- A run boundary that does not land on a band boundary still draws at the nearest band edge, not at the stop. The contour lives on the band grid; a custom-stop palette whose hard transition falls mid-band gets its line at whichever band edge is closest. Place a run boundary on a band edge — the way the shipped mono presets write their stops wide of the seam — if you want the line where you drew it.
The scene scoping — banding reaches every scene, contours do not
[!IMPORTANT]
palette_contouris inert onattractor,swarm,emitterand the four line scenes, and nothing warns you. The parameter is accepted there because it is a known name, so no unknown-parameter warning fires. This section is the warning.
palette_contour_styleandpalette_contour_inkdo not inherit that trap. They are declared only by the six scenes that can draw a contour, so binding one anywhere else is an ordinary unknown-parameter warning at load.
A contour needs a gradient across a fragment to sit in — its width comes from
fwidth, which measures how fast a value changes between neighbouring pixels and
exists only in a fragment shader. That is what keeps the hairline a constant
width on screen instead of thick where the field is flat and invisible where it is
steep.
Where the LUT is sampled decides whether that derivative exists at all:
The last column covers palette_contour_style and palette_contour_ink as well:
the three names reach exactly the same six scenes, and the two new ones are simply
unknown on the rest.
| Scene | Where it samples the LUT | palette_steps | the three palette_contour* |
|---|---|---|---|
analytic_field | per pixel, fragment stage | ✅ | ✅ |
cellular | per pixel, fragment stage | ✅ | ✅ |
fragment_field | per pixel, fragment stage | ✅ | ✅ |
reaction_diffusion | per pixel, fragment stage | ✅ | ✅ |
shape_field | per pixel, fragment stage | ✅ | ✅ |
warp_mesh (the native deposit, or the present pass under color_source = "1"; a [milk] preset draws its own colours) | per pixel, fragment stage | ✅ | ✅ |
attractor | per particle, vertex stage | ✅ | ❌ palette_contour inert, the other two unknown |
swarm | per particle, on the CPU | ✅ | ❌ same |
emitter | per particle, on the CPU | ✅ | ❌ same |
plexus | per line and per dot, on the CPU, along view depth | ✅ | ❌ unknown — not declared |
spectrum, parametric_curve, lsystem, star_pattern | per segment, on the CPU | ✅ | ❌ same |
A point sprite or a stroke segment carries one palette coordinate for its whole extent, so there is no crossing from one band to the next within it to draw a line at. This is a fact about the pipeline, not a policy someone chose, and it is not fixable by turning the parameter up.
So: banding reaches every scene; contours reach the continuous-field scenes. If
you want drawn edges on a particle or line look, the mark’s own geometry is where
they come from — the swarm’s shape, a line scene’s thickness — not from
palette_contour.
palette_contour under shape_field’s two coordinates
shape_field can hand the palette either of two figure coordinates
(ADR-0111): the
normalized distance (coord_mode = "0", the default), whose contours are
offsets of the outline, or r / r_boundary(theta) ("1"), whose contours are
scaled copies of it. Both are 0 at the figure’s centre and 1 on its
outline, and palette_contour works on both.
Not every figure can take "1". The boundary radius is read along a ray from
the figure’s centre, so the mode needs a figure every such ray leaves exactly
once — a ring does not, and neither does an authored [path] contour that
wraps around its own centre. On those the scene draws the distance instead and
says so at load;
Parameter roster carries the condition and what
qualifies.
The hairline keeps its weight across the switch, and that is worth stating
because the arithmetic suggests otherwise. The two fields have genuinely
different gradients — the distance rises at 1/inradius everywhere, while the
radius rises at 1/r_boundary(theta), which varies with direction by the shape’s
own circumradius-to-inradius ratio. What absorbs that is the contour’s own
construction: it is drawn within one pixel of a band edge, because the width
comes from fwidth of the banded coordinate rather than from a fixed value in
coordinate space. That normalization is exactly what a changing gradient runs
into, so the line comes out the same. Measured on a nine-ring heart at
palette_contour = "0.75", as the darkening the parameter adds:
| inner rings | outer rings | |
|---|---|---|
coord_mode = "0" | 27.3 mean over 492 px | 32.6 mean over 2131 px |
coord_mode = "1" | 29.8 mean over 466 px | 31.3 mean over 1929 px |
The two modes differ by less than the inner and outer rings differ within
either one. So do not re-tune palette_contour when you switch modes.
What does change is where the rings are, which is the whole point of the
second coordinate. Under "0" they are evenly spaced in distance, so they hug
the outline’s offsets and the innermost ones round off any reflex corner; under
"1" they are evenly spaced as fractions of the boundary radius, so they fan out
in proportion — further apart toward a tip, closer toward a valley — and every
one of them is the same figure at a smaller size.
The warp mesh’s second coordinate — color_source
shape_field’s two coordinates are two ways of measuring the same figure.
warp_mesh’s are two different places in the frame’s life, and that is why they
exclude each other rather than compose
(ADR-0197).
-
color_source = "0", the default, colours at deposit time, by angle. The warp then drags already-coloured light around, sopalette_stepsquantizes what goes into the feedback loop and the loop’s own structure — the decay, the tunnelling, the drift — carries no bands at all. A twenty-band plateau palette on this path renders as a smeared coloured blob. -
color_source = "1"colours at present time, by the field’s own level. The deposit writes uncoloured light, so the field accumulates a scalar: exactly the accumulated, decayed deposit at each pixel. The present pass reads that ashue + color_center + color_span * levelbands it, samples the palette and applies
saturationand the contour. Whatpalette_stepsthen draws are the loop’s own decay contours — concentric rungs where the level crosses from one band to the next, marching outward with whatever the warp is doing. Nothing else in the engine makes one.
The deposit’s deposit_radius, deposit_arms, deposit_twist and
deposit_spin keep shaping where light lands under "1". They stop deciding
its colour.
Three things to know before you author on it:
-
The level is not bounded by one, and the LUT wraps. A pixel’s level is the sum of every deposit that reached it, decayed — on a steady central deposit it sits comfortably above
1, so atcolor_span = "1"the coordinate sweeps several full cycles of the palette between the centre and the frame edge. That is the ladder: the palette repeats outward rather than clipping. Lowercolor_spanfor fewer, wider rungs; raisedepositordecayto push the level, and the rung count with it. -
The fringe is not two-ink, until you threshold it. The present writes
ink * coverage, and coverage decays with the level, so the outermost rungs fade toward the backdrop through intermediate values. A limited-ink guarantee holds where the field is opaque (ADR-0138 is a claim about the draw seam, not about the whole frame).coverage_thresholdremoves that fade (ADR-0224): at or above it a pixel holds the palette’s colour at full strength, below it the backdrop, so nothing between an ink and the paper survives. It is off at"0", which is what every look written before it renders.What it gives you and what it does not. It gives a frame whose colours come from the palette and the paper — the property a print needs. It does not move the draw-seam guarantee below, and it is not a promise of an exact colour count: two other continua sit downstream of the ink and are not coverage. The palette coordinate is one — at
palette_steps = "0"it sweeps the LUT continuously and every transition between two runs is one texel wide, so pixels landing inside one sample a blend of the two inks. The display write’s dither is the other (ADR-0096), which spreads each ink over its own neighbouring encoded levels. The two readings below were taken on one machine on 2026-09-19; both of those stages feed the exact count, so another rasterizer counts differently and what to carry away is the order of magnitude rather than the number. On the shippedwarp_ladderat 640x360, full stimulus:palette_steps = "12"measures 145 exact frame colours with the threshold off and 11 with it on — two inks and the paper, each dithered. Withpalette_steps = "0"the same switch moves 886 to 802, because there the coordinate, not the coverage, is what the colours are coming from. Band the coordinate and threshold the coverage; either alone leaves a continuum.The edge aliases, by design. The cutoff is hard — no smoothing, no derivative — because an antialiased boundary writes back exactly the intermediate values the threshold removes. On a silhouette that moves, the edge will crawl. That is the trade; an author who does not want it leaves the threshold off and keeps the fade.
-
Anything the draw layer puts in the field counts toward the level, because the level is
max(r, g, b)of whatever is there. No nativewarp_meshpreset draws a layer, and a converted[milk]preset never setscolor_source— but a preset that did both would find its waveform’s colour reading as level.
The video echo is upstream of the level read, so it mixes levels and the
result is coloured once: no blend of two inks can reach the frame from it. The
MilkDrop composite remaps are downstream, so brighten’s square root and
solarize do produce non-ink values. That is a choice on a flag rather than
something the mode prevents.
Banding fights bloom
The bright pass blurs exactly the hard edges banding creates
(ADR-0046), so a preset cannot have crisp
bands and heavy bloom_amount at full strength. Pick one, or keep the bloom low
enough that it haloes the bright bands without softening their edges.
This is not how you get a cyclic look
The cyclic-hue character of a banded reference image is already reachable without
any of this: the LUT is repeat-addressed, so a color_span above 1 wraps it and
repeats the whole gradient across the field. palette_steps adds only the hard
edge between one cycle’s colours and the next.
Built from 13c7582 at version 0.158.0. This site tracks main and is not versioned per release.