Skip to content

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.

ParamDefaultRange that readsAccepted
palette_steps0 (off)4120 = smooth, integers up to 64
palette_contour0 (off)00.50 = none, up to 1
[params]
palette_steps = "6" # six flat bands
palette_contour = "0.35" # a drawn edge between them
color_span = "1.4" # walk enough gradient that the bands differ

What it does to the coordinate

The palette coordinate is quantized, not the baked LUT:

t' = (floor(t * N) + 0.5) / N

immediately 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_steps is stepped. It rounds to a whole number, like kaleido_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 bound palette_steps still eases; it snaps at each half-integer.
  • Off is the exact identity. palette_steps <= 1 and palette_contour = 0 take 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:

  • 412 bands is where the field reads as flat areas with real edges.
  • 16 and 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_contour up to about 0.5 is an edge between two colours.
  • 0.8 is a deliberate topographic look, not an error: past roughly 0.5 the 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_contour is inert on attractor, swarm, emitter and 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.

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:

SceneWhere it samples the LUTpalette_stepspalette_contour
fragment_fieldper pixel, fragment stage
reaction_diffusionper pixel, fragment stage
shape_fieldper pixel, fragment stage
warp_mesh (the native deposit; a [milk] preset draws its own colours)per pixel, fragment stage
attractorper particle, vertex stage❌ inert
swarmper particle, on the CPU❌ inert
emitterper particle, on the CPU❌ inert
spectrum, parametric_curve, lsystem, star_patternper segment, on the CPU❌ inert

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.

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 ringsouter rings
coord_mode = "0"27.3 mean over 492 px32.6 mean over 2131 px
coord_mode = "1"29.8 mean over 466 px31.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.

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 a8ce055 at version 0.115.0. This site tracks main and is not versioned per release.