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)4–120 = smooth, integers up to 64
palette_contour0 (off)0–0.50 = none, up to 1
palette_contour_style0one of four0–3, rounded
palette_contour_ink0a palette coordinate0–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 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_stylefootprintcolour
0 — the defaultsoft, ramped over one pixelblack, as a darkening of whatever is underneath
1hard, a step over the same footprintblack
2softthe palette’s own colour at palette_contour_ink
3hardthe 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 0 or 2.

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:

  • 4–12 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.

palette_contour_style and palette_contour_ink do 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.

SceneWhere it samples the LUTpalette_stepsthe three palette_contour*
analytic_fieldper pixel, fragment stage✅✅
cellularper pixel, fragment stage✅✅
fragment_fieldper pixel, fragment stage✅✅
reaction_diffusionper pixel, fragment stage✅✅
shape_fieldper 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✅✅
attractorper particle, vertex stage✅❌ palette_contour inert, the other two unknown
swarmper particle, on the CPU✅❌ same
emitterper particle, on the CPU✅❌ same
plexusper line and per dot, on the CPU, along view depth✅❌ unknown — not declared
spectrum, parametric_curve, lsystem, star_patternper 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 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.

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, so palette_steps quantizes 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 as

    hue + color_center + color_span * level

    bands it, samples the palette and applies saturation and the contour. What palette_steps then 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 at color_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. Lower color_span for fewer, wider rungs; raise deposit or decay to 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_threshold removes 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 shipped warp_ladder at 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. With palette_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 native warp_mesh preset draws a layer, and a converted [milk] preset never sets color_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.