Skip to content

Source: ParallelSlots.h

ParallelSlots

WS2812 encode for parallel WS2812 buses — the contract between a parallel driver (domain) and a parallel peripheral, named for the wire unit it builds (one pixel-clock SLOT = one byte on the 8-bit bus) — the sibling of the RMT driver's symbol encoder.

Functions

Return Name Description
uint64_t MM_RAMFUNC transposeBits8x8 inline The 8×8 bit-transpose, on the PACKED representation — the form the hot path wants.
void MM_RAMFUNC transposeBits8x8Pair inline The same 8×8 butterfly on a REGISTER PAIR — bit-identical to transposeBits8x8, but written in the 32-bit words the target actually has.
void MM_RAMFUNC transposeLanes8x8 inline Transpose 8 lane bytes into 8 bit-plane bytes: out[b] bit L = in[L] bit b — the array-shaped wrapper around transposeBits8x8, for callers that hold their lanes as bytes.
void MM_RAMFUNC transposeLanes16x8 inline The 16-lane transpose: 16 lane bytes into 8 bit-plane HALFWORDS, for a 16-bit bus.
void MM_RAMFUNC encodeWs2812ParallelSlots inline Encode one ROW — one light across every lane — into channels * 8 * 3 bus slots: the DIRECT-mode encoder, one lane per pin, no expander.
void MM_RAMFUNC encodeWs2812ShiftLatchPad inline Close a shift-register frame: one latch-only bus word, written at the START of the latch pad.
void encodeWs2812ShiftSlots inline Encode one ROW through the shift-register expander.
void MM_RAMFUNC shiftActivePins inline Write the frame's CONSTANT shift-mode words once, so the per-light encoder never touches them again.
void MM_RAMFUNC prefillWs2812ShiftConstants inline
void MM_RAMFUNC encodeWs2812ShiftData inline The per-light DATA encode: the sibling of prefillWs2812ShiftConstants, and it writes ONLY the data word of each slot.

transposeBits8x8

inline

inline uint64_t MM_RAMFUNC transposeBits8x8(uint64_t x)

The 8×8 bit-transpose, on the PACKED representation — the form the hot path wants.

The matrix is one uint64_t: byte L is lane L's data byte, so bit (8·L + b) is lane L's bit b. Three delta-swaps later, byte b is the bit-plane for bit b (bit L of byte b = lane L's bit b). The textbook SWAR (Warren, Hacker's Delight §7-3) — this IS the body the array form below runs.

Taking the packed word in and out is the point. The array form makes the caller spill eight bytes to memory and the callee load them straight back, then spill eight result bytes the caller reloads one at a time. In the shift encoder that ceremony ran 24 times per light — and the staging cost more than the arithmetic it staged (measured: removing it took an S3 from 8.85 to 6.19 µs per light). A caller that can build the packed word straight from its source — the shift encoder can — keeps the whole transpose in registers.


transposeBits8x8Pair

inline

inline void MM_RAMFUNC transposeBits8x8Pair(uint32_t & lo, uint32_t & hi)

The same 8×8 butterfly on a REGISTER PAIR — bit-identical to transposeBits8x8, but written in the 32-bit words the target actually has.

The ESP32's Xtensa is a 32-bit machine, so every uint64_t step above becomes register-pair arithmetic: a shift by 7 across a 64-bit value is several instructions, not one. Hacker's Delight states this transpose on two 32-bit halves for exactly that reason, and it is the form hpwit's driver uses (x, y, x1, y1 — never a 64-bit word). Only the third round crosses the halves, and it is a plain field exchange, so the two forms compute the same function — pinned by a test over the byte patterns the encoder produces, and checked against 300k random inputs when this was written.

Keep BOTH: the 64-bit form is the clearer statement of the algorithm and is what a 64-bit host compiles best; this one is what the 32-bit device wants. transposeLanes8x8 picks per platform.


transposeLanes8x8

inline

inline void MM_RAMFUNC transposeLanes8x8(const uint8_t * in, uint8_t * out)

Transpose 8 lane bytes into 8 bit-plane bytes: out[b] bit L = in[L] bit b — the array-shaped wrapper around transposeBits8x8, for callers that hold their lanes as bytes.

Inactive lanes must be passed as 0 (the caller masks them) so they contribute no set bit to any plane.


transposeLanes16x8

inline

inline void MM_RAMFUNC transposeLanes16x8(const uint8_t * in, uint16_t * out)

The 16-lane transpose: 16 lane bytes into 8 bit-plane HALFWORDS, for a 16-bit bus.

Two 8-lane passes combined, so it reuses the same butterfly and adds no new magic constants. Inactive lanes must be passed as 0 by the caller, as with transposeLanes8x8.


encodeWs2812ParallelSlots

inline

template<class Slot> inline void MM_RAMFUNC encodeWs2812ParallelSlots(const uint8_t * wire, Slot activeMask, uint8_t channels, Slot * out)

Encode one ROW — one light across every lane — into channels * 8 * 3 bus slots: the DIRECT-mode encoder, one lane per pin, no expander.

Writes all three words of every slot (pulse start / data / tail). activeMask carries which lanes are live: an exhausted strand's bit is clear, so it idles LOW instead of flashing. This is the render loop's hot spot — see the group description for the transpose and why it is SWAR.


encodeWs2812ShiftLatchPad

inline

template<class Slot> inline void MM_RAMFUNC encodeWs2812ShiftLatchPad(uint8_t latchBit, Slot * out)

Close a shift-register frame: one latch-only bus word, written at the START of the latch pad.

Without this the frame never resets, content-dependently. The '595 pipeline is one slot deep — the byte clocked during slot N is presented during slot N+1 — so the LAST slot of the last light needs a latch edge after it. The pad is all zeros and carries no latch bit, so the register would keep presenting the final DATA byte for the pad's whole ≥300 µs: a strand whose last wire byte is EVEN idles LOW and resets correctly, while one whose last byte is ODD idles HIGH and never sees the WS2812 reset at all — the next frame's bits then append to an unlatched stream and that strand garbles. (Direct mode has no such hazard: with no register in the path, a zeroed pad IS a LOW line.)

One word (data lanes LOW + the latch bit) latches the trailing zeros through, and the rest of the zeroed pad then holds every strand LOW. Call once, immediately after the last row.


encodeWs2812ShiftSlots

inline

template<class Slot> inline void encodeWs2812ShiftSlots(const uint8_t * wire, uint64_t activeMask, uint8_t physPins, uint8_t latchBit, uint8_t outPerPin, uint8_t channels, Slot * out)

Encode one ROW through the shift-register expander.

wire: lanes × channels corrected wire bytes, lane-major — as the direct encoder, but indexed by EXPANDED lane. activeMask: bit V set = lane V drives this row. Up to kMaxLanes lanes (more than the bus is wide), so it is a uint64_t, not a Slot. physPins: physical data pins in use (lanes ≤ physPins × outPerPin). latchBit: bus-bit index of the LATCH line (never a data pin). outPerPin: the fan-out — kPinExpanderOutputs (8, one '595). A runtime parameter, not a constant, so the cost model stays explicit; see kPinExpanderOutputs for why 16 is not offered. channels: wire bytes per light (also the lane stride). out: channels * 8 * 3 * outPerPin SLOTS, fully written.


shiftActivePins

inline

template<class Slot> inline void MM_RAMFUNC shiftActivePins(uint64_t activeMask, uint8_t physPins, uint8_t outPerPin, Slot(&) out)

Write the frame's CONSTANT shift-mode words once, so the per-light encoder never touches them again.

This is the single biggest cost in the shift encoder, and it is pure waste without it.

Of the three words a WS2812 slot emits per shift cycle, only ONE carries pixel data:

out[c] = activePins[c] | latch ← pulse start (all-HIGH). Same every light. out[outPerPin + c] = plane[c][bit] | latch ← THE DATA. Changes every light. out[2 * outPerPin + c] = latch ← pulse tail (all-LOW). Same every light.

The start and tail depend only on which strands are active and where the latch bit sits — both fixed for the whole frame. Rewriting them per light burns two thirds of the encoder's stores (384 of 576 per light at 3 channels × 8 outputs). Pre-filling them once and having the encoder write only the data word is what takes the per-light cost from ~9.7 µs to ~3 µs on an S3.

This is hpwit's putdefaultones() / putdefaultlatch() — called once at buffer init, with the per-light transpose then biased past them (buff += OFFSET). Studied, then written fresh here.

Called from the driver's reinit() (cold path) whenever the frame is rebuilt, and again whenever the active-strand set changes. rows is the strand length; the pad beyond it is left zeroed (a zeroed pad IS the WS2812 reset). Which pins carry an ACTIVE strand on each shift cycle, one bus word per cycle.

Per-CYCLE, not per-pin: cycle c clocks the bit for shift position pos, so the question is "is the strand at (pin, pos) live?" — a per-STRAND question. Aggregating one mask across all cycles ("pin P has some live lane") would drive the pulse-start HIGH on a cycle whose strand is exhausted, and two strands sharing a '595 (one long, one short) would make the short one flash white on every WS2812 pulse. out[c] is written for every cycle c < outPerPin.

encodeWs2812ShiftSlots deliberately does NOT call this: there the same mask test is FUSED with the lane gather (one pass sets the active bit and reads the wire byte), so routing it through this helper would walk the pins twice and test each mask bit twice.


prefillWs2812ShiftConstants

inline

template<class Slot> inline void MM_RAMFUNC prefillWs2812ShiftConstants(uint64_t activeMask, uint8_t physPins, uint8_t latchBit, uint8_t outPerPin, uint8_t channels, uint32_t rows, Slot * out)

encodeWs2812ShiftData

inline

template<class Slot> inline void MM_RAMFUNC encodeWs2812ShiftData(const uint8_t * wire, uint64_t activeMask, uint8_t physPins, uint8_t latchBit, uint8_t outPerPin, uint8_t channels, Slot * out)

The per-light DATA encode: the sibling of prefillWs2812ShiftConstants, and it writes ONLY the data word of each slot.

The start/tail words are already in the buffer and must not be touched.

Identical output to encodeWs2812ShiftSlots (the whole-slot encoder) provided the prefill ran first with the SAME activeMask — which the tests pin byte-for-byte.

Variables

Return Name Description
constexpr uint8_t kPinExpanderOutputs constexpr Outputs per physical data pin when a 74HCT595 expander is fitted (one '595 = 8).

kPinExpanderOutputs

constexpr

constexpr uint8_t kPinExpanderOutputs = 8

Outputs per physical data pin when a 74HCT595 expander is fitted (one '595 = 8).

The fan-out is a property of the board, not a free parameter.

74HCT, not 74HC — the T is load-bearing. (Plain-'HC parts are known to misbehave here; that is the reason the board specifies HCT.) Both are 8-bit shift registers; they differ in INPUT threshold, and the ESP32 drives 3.3 V logic into a part powered at 5 V:

  • 74HC at 5 V: V_IH(min) = 0.7 × Vcc = 3.5 V → a 3.3 V HIGH is BELOW threshold and is not guaranteed to read as a 1. It often appears to work (a given chip may trip nearer 2.5 V at room temperature), then fails with temperature, supply, or a new batch — the symptom is flaky/garbled strands, not dead ones, which is what makes it so hard to chase.

  • 74HCT at 5 V: TTL-compatible inputs, V_IH(min) = 2.0 V → 3.3 V is comfortably a HIGH, while the outputs still swing a full 5 V, which is what the WS2812 data line wants.

The target board is hpwit's expander. Its BOM for 48 strands, read off the fitted parts:

  • 6 × 74HCT595N (NXP) — the shift registers: the actual 1→8 fan-out (6 × 8 = 48).

  • 1 × SN74HCT245N (TI) — an OCTAL buffer/level-shifter (it stores nothing): 3.3 V→5 V, and it supplies the drive current.

One '245 is exactly enough for THIS board, and that fact is what caps it at 6 data pins. The signals needing the shift are dataPins + CLOCK + LATCH, and a '245 is 8 channels wide — so 6 + 2 = 8 fills it exactly. It is not a general property of the design: hpwit's 15-pin variant needs 15 + 2 = 17 signals = three '245s. A board with one '245 can never be a 15-pin board.

Timing headroom is thin, and the buffer is the reason — know this before blaming the code. The fitted '245 is plain HCT (t_pd ≈ 10–18 ns at 5 V), not the ~5 ns AHCT part. Shift mode clocks the bus at 26.67 MHz — a 37.5 ns period — so the buffer alone can eat a third of the bit period in propagation delay. hpwit runs a comparable rate (19.2 MHz) on this hardware, so it does work; but there is little margin. If a board is clean at the direct rate and flaky only through the expander, suspect the buffer's timing before the firmware — an AHCT245 is the drop-in that buys the margin back.

Do not "simplify" any of these to a non-T part — see the threshold arithmetic above.

**8 outputs — one 74HCT595 per data pin. Cascading two ('595 -> '595 via Q7') would give 16, but ×16 is NOT offered, and the reason is the clock, not the code.

The bus rate is set by the shift DEPTH: each WS2812 slot must still last its 300 ns, filled by the fan-out's shift cycles, so doubling the depth doubles the required pclk. An in-spec slot needs 290-380 ns (T0H ≤ 380, T1H = 2 × slot ≥ 580), which puts ×16 at 42.1-55.2 MHz — and the LCD_CAM bus resolution (80 MHz) has no exact divide in that band (its divides are 80, 40, 20, 16, 10 MHz). esp_lcd silently rounds an inexact request DOWN to an integer prescale, so asking for ~53 MHz yields 80 MHz: a 200 ns slot, T1H 400 ns, far below the 580 ns floor. ×16 cannot emit a valid WS2812 waveform here at all.

(×8's band is 21.1-27.6 MHz, and 80/3 = 26.67 MHz sits inside it — the shipped rate is provably the only exact divide that works. A P4's APLL could synthesise an arbitrary rate and would be the only route to ×16; that is a separate driver-level change, not a config flag.)

Grow on PINS, not on cascade depth anyway: pin count is parallel, so it does not touch the clock and it does not grow the DMA frame — hpwit's 120-strand headline is 15 pins × 8, not a deeper chain.