Palette Partitions & Treatments¶
The cheapest device-safe animation there is: statically assign every lit pixel of an assembled mark to one of N groups, bake that into an indexed full-panel bitmap once, then animate by rewriting N palette entries per frame — zero per-frame pixel work. On a MatrixPortal S3 a palette write is effectively free, so a 10-band sweep costs ~10 palette writes a frame against the 50 ms budget.
These modules were promoted from the DarkOwl LED logo sign (2026), where every dwell effect in a 24/7 show runs on them.
The partition¶
from scrollkit.display.unified import displayio
from scrollkit.effects.palette_partition import PalettePartition, map_diagonal
from scrollkit.effects.reveal_splash import pixels_from_text
pixel_slots = {p: 1 for p in pixels_from_text("HELLO", x=17, y=12)}
group_map, n = map_diagonal(pixel_slots, 10)
fx = PalettePartition(displayio, pixel_slots, group_map, n)
display.add_layer(fx.tile)
fx.fill(0xB02318) # every group one flat color
fx.tile.hidden = False
pixel_slots maps (x, y) -> input slot: slot 1 is the body
(partitioned into groups); slots 2+ are identity pixels — a mascot's
eyes, an accent — that take reserved palette slots which paint() and
fill() can never touch (write them via set_identity(), e.g. to
blink).
Partition builders (each returns (group_map, n_groups)):
| builder | axis | designed for |
|---|---|---|
map_diagonal |
bands along x + 2y | sweeps, eclipses, gradients |
map_anchor_distance |
distance from an anchor column | outward wakes |
map_radial |
concentric rings around a point | halos |
map_angle |
angular wedges around a point | sonar sweeps |
map_rain |
per-column staggered vertical cycle | descending rain |
map_checker |
interleaved parity | satin shimmer |
map_exposure |
which side of the stroke faces open sky | rim light |
map_regions |
coherent Voronoi patches | heatmaps |
map_topology |
endpoints / corners / junctions / runs | stroke anatomy |
map_route |
BFS order along glyph strokes | crawling packets |
The treatments¶
Thirteen frame-driven classes animate a partition; each names its
recommended builder in its PARTITION attribute (treatments_for("diagonal")
selects by it). All take a 5-stop theme (base, dim, flat, warm, hot)
— darkest to hottest — and start/end painting flat, so an invisible
tile swap in and out is seamless.
| Class | Partition | Motion |
|---|---|---|
VelvetSweep |
diagonal |
![]() A hot sheen travels diagonally across the dimmed mark, like light over velvet. |
AnchorWake |
anchor |
![]() Warmth flows outward from the anchor through the mark, and a dimmer echo returns. |
HaloPulse |
radial |
![]() Circular pressure waves expand outward through radial bands. |
SonarSweep |
angle |
![]() A wedge sweeps around the anchor, leaving a fading afterglow. |
CipherRain |
rain |
![]() Phase-staggered highlights descend within the strokes, column by column. |
InkShimmer |
checker |
![]() Interleaved groups slowly exchange two close shades — a satin shimmer, quiet enough for long dwells. |
RimLight |
exposure |
![]() A light source passes over; whichever stroke edge faces it catches a pale highlight. |
HeatmapDrift |
regions |
![]() Coherent regions warm and cool independently, like detections surfacing on a map. |
EclipseCross |
diagonal |
![]() A deep shadow crosses the flat mark with a hot corona at its leading edge and a dusk trail behind. |
GradientDwell |
diagonal |
![]() The mark crossfades to a two-stop gradient edition, dwells, and returns to flat. |
StrokeAnatomy |
topology |
![]() Endpoints, corners, junctions, and runs trade emphasis in turn: the typography under analysis. |
RouteCircuit |
route |
![]() Hot packets crawl the route sections from both ends and converge on the terminus, which flares. |
PacketTrace |
route |
![]() Packet dots crawl the actual strokes toward the terminus while sections glow in their wake. The one treatment that owns display sprites — call start(display) / detach() around it. |
The caller owns pacing and blinks: after each step(), blink_now
is True on the exact frames the choreography wants an identity blink:
from scrollkit.effects.palette_treatments import VelvetSweep
theme = (0x70140E, 0x8F1B12, 0xB02318, 0xC93A1E, 0xE65A28)
t = VelvetSweep(fx, theme)
while not t.is_complete:
t.step()
if t.blink_now:
await blink(fx) # your own held-frame blink
else:
await display.show()
await asyncio.sleep(0.05)
Every class carries a FEASIBILITY budget; all are palette-only
(max_pixel_writes_per_frame: 0).
See the Visual Reference for a rendered sample of every treatment.












