⚠️ Breaking change: As of issue #22, almost all driver configuration has moved fromCMakeLists.txtpreprocessor defines into aconstexpr Hub75Configvalue passed as a template argument toHub75Driver<Cfg>— see Configuration in Code. OnlyPICO_RP2350A,USE_PICO_GRAPHICS, andHUB75_MULTICOREremain astarget_compile_definitions.The pixel-mapping panel defines from the previous breaking change (issue #21) are now the
RowMappingenum's values (panel.panel_kindfield):
Old macro name Current RowMappingvalueHUB75_MULTIPLEX_2_ROWS(formerlyHUB75_DEFAULT/HUB75), laterROW_MAP_STANDARDRowMapping::StandardHUB75_P10_3535_16X32_4S, laterROW_MAP_SPLITRowMapping::SplitHUB75_P3_1415_16S_64X64_S31, laterROW_MAP_S31RowMapping::S31Update your panel setup to build a
Hub75Configaccordingly.
- HUB75 DMA/PIO Driver for Raspberry Pi Pico / RP2350
- Quick Start
- Configuration in Code
- Overview
- All Available Fields and Their Default Values
- Full
Hub75ConfigExample - Notes on Default Values
- Pixel Mapping — Choosing the Right
RowMappingfor Your Panel- Why This Mapping Step Exists
- The Three
RowMappingValues at a Glance RowMapping::Standard— Standard Two-Row MultiplexingRowMapping::Split— Outdoor P10 Panel, Four-Row MultiplexingRowMapping::S31— Outdoor P3 64×64 Panel, Four-Row Multiplexing- How to Select the Correct Value — Decision Guide
- What Happens When You Set
panel_kind - Important Notes
- HUB75 DMA-Based Driver
- Hub75 Matrix Panel Driver Version 3.0
- Achievements at a Glance
- The Definitive Hub75 Driver Solution – A Bitplane Stream with Parallel Reading and Display of Pixel Data
- Overview of the Redesigned Alternative Approach
- High-Level Architectural View of HUB75 Pipeline
- 1. Canonical Mapping Stage (
update()/update_bgr()) - 2. The New Hardware Pipeline
- 3. Simplified DMA Structure
- 4. Advanced Signal Integrity & Anti-Ghosting
- 5. Efficient BCM with Split-Bitplanes
- Step-by-Step Breakdown of DMA and PIO Cooperation
- Refresh Rate Performance
- Key Benefits of this Approach
- Conclusion for DMA and PIO based Approach
- Improved Colour Perception
- Colour Correction Matrix
- Brightness Control
- Chained Panels
- Display Rotation
- Demo Effects
- Next Steps
- Configuration in Code — Quick Reference
- Configuring Your HUB75 LED Matrix Panel
- Troubleshooting
- 1. Panel Stays Completely Dark
- 2. Panel Lights Up, But Only Shows Noise or Garbage
- 3. Image Looks Correct, But Rows Are Missing or Repeated
- 4. Image Is Correct but Flickers or Shows Ghosting
- 5. Panel Updates Sporadically or Only Every Few Frames
- 6. Colors Look Wrong or Are Too Dim / Too Bright
- 7. When Nothing Makes Sense Anymore 😄
- Boards
hub75_demo.mp4
Demo video: Colours are much brighter and more brilliant in reality
High-performance HUB75 RGB matrix driver using:
- DMA chaining
- PIO co-processors
- autonomous BCM streaming
- on-demand bitplane generation
- double buffering
- balanced light output
- CIE1931 colour correction
Supports:
- RP2040
- RP2350A/B
- chained HUB75 panels
- serpentine/U-turn topologies
- multiple panel scan architectures
- Hardware: Raspberry Pi Pico, Pico W, Pico 2 (RP2350A/B) or compatible board
- Panel: HUB75-compatible LED matrix (64×64 default; other sizes configurable)
- Host OS: Linux, macOS, or Windows (WSL recommended on Windows)
pins.data_base_pin= GPIO 0 (first in a consecutive block)pins.data_n_pins= 6 (for R0, G0, B0, R1, G1, B1)
| Hub75 Colour Bit | connected to | Pico GPIO |
|---|---|---|
| R0 | 0 | |
| G0 | 1 | |
| B0 | 2 | |
| R1 | 3 | |
| G1 | 4 | |
| B1 | 5 |
pins.rowsel_base_pin= GPIO 6pins.rowsel_n_pins= 5 (A0–A4)
Consecutiveness is required by the PIO program.
| Address bit | connected to | Pico GPIO |
|---|---|---|
| A0 (A) | 6 | |
| A1 (B) | 7 | |
| A2 (C) | 8 | |
| A3 (D) | 9 | |
| A4 (E) | 10 |
pins.clk_pin(clock): GPIO 11pins.strobe_pin(latch): GPIO 12pins.oen_pin(output enable): GPIO 13
pins.strobe_pin pin must be immediately followed by pins.oen_pin (must be consecutive)
The diagram shows the default mapping as defined in the hub75.cpp file.
The strict requirement to be aware of is that data pins and row-select pins must be in consecutive GPIO blocks.
Be aware of a second requirement that pins.strobe_pin must be immediately followed by pins.oen_pin.
Clock pin may be freely chosen.
Almost all driver configuration now lives in code as a constexpr Hub75Config, passed as a
template argument to Hub75Driver<Cfg> — see Configuration in Code
below. Only a handful of build-system-level flags remain in CMakeLists.txt.
set(PICO_BOARD pico2 CACHE STRING "Board type")
# The following two lines must be uncommented to compile for bare RP2350 without a board
# set(PICO_PLATFORM rp2350)
# set(PICO_BOARD none CACHE STRING "Board type")
target_compile_definitions(hub75_demo PRIVATE
# PICO_RP2350A=0 # uncomment for RP2350B microcontrollers only
USE_PICO_GRAPHICS=true # set to false if you use hub75 as a library
HUB75_MULTICORE=true # use core1 for the hub75 driver
)// hub75_demo.cpp / your own .cpp file
constexpr Hub75Config panel_cfg{
.panel = {
.matrix_panel_width = 64, // your matrix panel width
.matrix_panel_height = 32, // your matrix panel height
.panel_kind = RowMapping::S31, // default — other values: Split, Standard
},
.pins = {
.data_base_pin = 0, // base GPIO of R0, G0, B0, R1, G1, B1
.data_n_pins = 6, // count of colour pins (usually 6)
.rowsel_base_pin = 6, // base GPIO of A, B (, C, D, E)
.rowsel_n_pins = 5, // count of address pins on your panel connector
.clk_pin = 11,
.strobe_pin = 12,
.oen_pin = 13,
},
};
using Panel = Hub75Driver<panel_cfg>;# set(PICO_BOARD pico2 CACHE STRING "Board type")
# The following two lines must be uncommented to compile for bare RP2350 without a board
set(PICO_PLATFORM rp2350)
set(PICO_BOARD none CACHE STRING "Board type")
target_compile_definitions(hub75_demo PRIVATE
PICO_RP2350A=0 # not a RP2350A but a RP2350B microcontroller
USE_PICO_GRAPHICS=true
HUB75_MULTICORE=true
)constexpr Hub75Config panel_cfg{
.panel = {
.matrix_panel_width = 64, // your matrix panel width
.matrix_panel_height = 64, // your matrix panel height
},
.pins = {
.data_base_pin = 30, // use 30 for RP2350B
.data_n_pins = 6,
.rowsel_base_pin = 36, // use 36 for RP2350B
.rowsel_n_pins = 5,
.clk_pin = 41, // use 41 for RP2350B
.strobe_pin = 42, // use 42 for RP2350B
.oen_pin = 43, // use 43 for RP2350B
},
};
using Panel = Hub75Driver<panel_cfg>;You can easily use this project with VSCode, especially with the Raspberry Pi Pico plugin installed. Follow these steps:
-
Open VSCode and start a new window.
-
Clone the repository:
-
Project Import Prompt:
-
Configure Pico SDK Settings:
-
Wait for Setup Completion:
- VSCode will download required tools, the Pico SDK, and any plugins.
-
Connect the Hardware:
- Make sure the HUB75 LED matrix is properly connected to the Raspberry Pi Pico.
- Attach the Rasberry Pi Pico USB cable to your computer
-
Build and Upload:
💡 If everything is set up correctly, your matrix should come to life with the updated HUB75 DMA driver.
Driver configuration now lives in code, as a constexpr Hub75Config value passed as a
template argument to Hub75Driver<Cfg> (see issue #21):
#include "hub75.hpp"
constexpr Hub75Config panel_cfg{
.panel = { /* ... */ },
.screen = { /* ... */ },
.pins = { /* ... */ },
.color = { /* ... */ },
.frame_rate_debug = false,
};
using Panel = Hub75Driver<panel_cfg>;
static Panel driver; // large fixed-size buffers - must have static storage durationThis replaces the previous target_compile_definitions-based approach. The benefits:
- Strictly type-checked at compile time — a typo like
.data_base_pinn = 0is a compile error, not a silently-ignored preprocessor define. - No more risk of a stale/forgotten
#ifndefdefault — every field has an explicit, visible default inHub75Config's member-initializers inhub75.hpp. - Multiple independent configurations in one program are now possible — each
constexpr Hub75Configinstantiates its ownHub75Driver<Cfg>type, so you can run two differently-configured panels (even two different chains) from a single RP2350. See Dual-Instance Configuration.
If a field is not set in your Hub75Config initializer, it falls back to the default
value declared directly in the struct definition in hub75.hpp (see
Notes on Default Values below).
A small number of build-system-level flags remain in CMakeLists.txt — these control how
the project is built, not how the panel is wired or timed, so they stay as
target_compile_definitions: PICO_RP2350A, USE_PICO_GRAPHICS, and HUB75_MULTICORE.
See Configuration in Code — Quick Reference for the
complete, current list.
Hub75Config is composed of four sub-structs plus one top-level field. The table below lists
every configurable field, its default value as declared in hub75.hpp, and a short
description.
| Field | Default | Description |
|---|---|---|
matrix_panel_width |
64 |
Width of a single physical panel in pixels. |
matrix_panel_height |
64 |
Height of a single physical panel in pixels. |
chain_rows |
1 |
Number of chain rows stacked vertically. |
chain_cols |
1 |
Number of panels chained left-to-right within a single chain row. |
chain_mode |
Hub75ChainMode::SERPENTINE |
SERPENTINE (U-turn, default) or RASTER. |
panel_kind |
RowMapping::Standard |
Pixel-mapping topology: Standard, Split, or S31. |
panel_chip |
Hub75PanelChip::GENERIC |
Driver-IC init sequence: GENERIC, FM6126A, or RUL6024. |
inverted_stb |
false |
Set true if the latch (strobe) signal is inverted on your board. |
sm_clockdiv_factor |
1.0f |
PIO state machine clock divider. Values > 1.0 slow the state machine down — useful against ghosting/flicker on smaller panels. |
base_latch_ns |
80 |
Wait time in nanoseconds to stabilise latch. |
base_addr_ns |
160 |
Wait time in nanoseconds to stabilise row addressing. |
| Field | Default | Description |
|---|---|---|
rotation |
Hub75Rotation::DEG_0 |
Logical display orientation: DEG_0, DEG_90, DEG_180, DEG_270. |
| Field | Default | Description |
|---|---|---|
data_base_pin |
0 |
First GPIO pin in the consecutive colour data block (R0). |
data_n_pins |
6 |
Number of colour data pins (always 6 for standard HUB75). |
rowsel_base_pin |
6 |
First GPIO pin in the consecutive row-select (address) block (A0). |
rowsel_n_pins |
5 |
Number of address pins on the panel connector. Must match the physical panel. |
clk_pin |
11 |
GPIO pin for the pixel clock (CLK). |
strobe_pin |
12 |
GPIO pin for the latch/strobe signal (LAT). |
oen_pin |
13 |
GPIO pin for the output enable signal (OE). |
| Field | Default | Description |
|---|---|---|
bitplanes |
10 |
Number of bit-planes for BCM (Binary Code Modulation). Valid values: 8 or 10. |
separate_cie_channels |
false |
Use separate per-channel CIE LUTs for improved colour representation — needs more memory. |
balanced_light_output |
true |
Split high-weight bitplanes into slices — improves effective refresh rate, cuts flicker, costs a little more memory. |
ccm_rg_shift |
31 (off) |
CCM: bits of Green mixed into Red. |
ccm_rb_shift |
31 (off) |
CCM: bits of Blue mixed into Red. |
ccm_gr_shift |
31 (off) |
CCM: bits of Red mixed into Green. |
ccm_gb_shift |
31 (off) |
CCM: bits of Blue mixed into Green. |
ccm_br_shift |
31 (off) |
CCM: bits of Red mixed into Blue. |
ccm_bg_shift |
31 (off) |
CCM: bits of Green mixed into Blue. |
See Colour Correction Matrix for what the CCM shift values mean.
| Field | Default | Description |
|---|---|---|
frame_rate_debug |
false |
For testing/debugging only: print frame frequency via printf. Set false for production. |
⚠️ Settingsm_clockdiv_factorbelow1.0fhas no effect — the driver clamps it to1.0finternally. Values above1.0fslow the PIO state machine down proportionally.
The block below shows a complete Hub75Config for a RP2350B using GPIO pins 30–43. For
real-world board configurations (including the RP2350B with a 64×64 outdoor panel) see the
Boards section.
For a bare RP2350 without a board, uncomment these two lines before
include(pico_sdk_import.cmake) in CMakeLists.txt:
set(PICO_PLATFORM rp2350)
set(PICO_BOARD none CACHE STRING "Board type")// No need to modify hub75.hpp - instead set these values in your own .cpp file.
//
// Example:
// Settings for a RP2350B microcontroller with GPIO pins spanning from 30 to 43.
// Beware to set `PICO_PLATFORM rp2350` and `PICO_BOARD none` prior to `include(pico_sdk_import.cmake)`
constexpr Hub75Config panel_cfg{
.panel = {
.matrix_panel_width = 64,
.matrix_panel_height = 64,
.chain_rows = 1,
.chain_cols = 1,
.chain_mode = Hub75ChainMode::SERPENTINE,
.panel_kind = RowMapping::S31,
.panel_chip = Hub75PanelChip::RUL6024,
.inverted_stb = false,
.sm_clockdiv_factor = 1.0f,
.base_latch_ns = 80,
.base_addr_ns = 160,
},
.screen = {
.rotation = Hub75Rotation::DEG_0,
},
.pins = {
.data_base_pin = 30, // RP2350B GPIO block starts at 30
.data_n_pins = 6,
.rowsel_base_pin = 36, // RP2350B address pins start at 36
.rowsel_n_pins = 5,
.clk_pin = 41,
.strobe_pin = 42,
.oen_pin = 43,
},
.color = {
.bitplanes = 10,
.separate_cie_channels = true,
.balanced_light_output = true,
.ccm_rg_shift = 6,
.ccm_gb_shift = 7,
},
.frame_rate_debug = false,
};
using Panel = Hub75Driver<panel_cfg>;A minimal configuration for the default RP2350A wiring (GPIO 0–13) only needs to set what differs from the defaults, for example:
constexpr Hub75Config panel_cfg{
.panel = {
.matrix_panel_width = 32,
.matrix_panel_height = 16,
},
.color = {
.bitplanes = 8,
},
};
using Panel = Hub75Driver<panel_cfg>;All other fields fall back to the defaults in hub75.hpp.
When a field is not set in your Hub75Config initializer, the driver uses the default
value declared directly on that field in hub75.hpp. These defaults correspond to the
standard wiring and a 64×64 panel connected to a Raspberry Pi Pico using GPIO 0–13,
exactly as shown in the struct definitions in All Available Fields
above.
panel.panel_kind (a RowMapping enum value) tells the update() / update_bgr() method how
to reorder pixels from your application's linear source buffer into the shift-register order
that your physical panel expects.
⚠️ Setting the wrongpanel_kindis one of the most common configuration mistakes. The panel will light up, but the image will look scrambled, interleaved, or repeated in blocks — depending on how far off the mapping is.
HUB75 panels do not accept pixels in simple top-to-bottom, left-to-right row-major order. Their internal shift registers are loaded column by column, and multiple physical rows are driven simultaneously in a single clock cycle. The exact arrangement depends on how many rows the panel multiplexes at once and how its row drivers are internally wired.
The mapping step inside update() / update_bgr() bridges this gap. It reads pixels from the
flat source buffer your application writes to and places each one into the correct position in
the internal frame_buffer_ that the DMA/PIO hardware will stream to the panel.
The mapping also applies the CIE 1931 look-up table (LUT) to each pixel as it is copied, so there is no separate colour-correction pass — it is folded into the same loop at no extra cost.
panel_kind |
Rows lit simultaneously | Typical scan notation | Typical panel dimensions | Address lines needed |
|---|---|---|---|---|
RowMapping::Standard |
2 | 1/8 S, 1/16 S, 1/32 S | 32×16 / 64×32 / 64×64 | 3 / 4 / 5 |
RowMapping::Split |
4 | 1/4 S | 32×16 outdoor | 2 |
RowMapping::S31 |
4 | 1/16 S | 64×64 outdoor | 4 |
💡 The number of address lines (
pins.rowsel_n_pins) must match the selected mapping. See Step 2 — Scan Rate and Rows Lit Simultaneously for the formula that connects panel height, simultaneous rows, and pin count.
Use this for the vast majority of HUB75 panels. It is the correct choice whenever exactly two rows of the panel are driven at the same time — one from the upper half and one from the corresponding row in the lower half.
How to recognise it:
- The scan notation on the panel label contains
-16S-,-32S-, or similar odd-sounding fractions (1/8, 1/16, 1/32 scan). A 64×64 panel labelled-32S-drives 64/32 = 2 rows simultaneously → use this value. - The panel connector exposes 5 address pins (A, B, C, D, E) for a 64×64 panel,
or 4 pins (A, B, C, D) for a 32×64 panel, matching the formula
rowsel_n_pins = log₂(height / 2). - The driver chip is ICND2012, FM6124, FM6126A, RUL6024, or similar standard HUB75 LED driver.
What the mapping does:
Pixels are copied into the shift buffer in alternating pairs: one pixel from row r in the
upper half, followed immediately by the corresponding pixel from row r in the lower half.
The offset between the two halves is matrix_panel_width × matrix_panel_height / 2.
// Simplified view of the mapping kernel
constexpr size_t offset = (matrix_panel_width * matrix_panel_height) / 2;
for (size_t fb = 0, j = 0; fb < total_pixels; fb += 2, ++j) {
frame_buffer[fb] = pack_lut_rgb(j, src[j]); // upper half
frame_buffer[fb + 1] = pack_lut_rgb(j + offset, src[j + offset]); // lower half
}Configuration:
// 64×64 standard panel, 1/32 scan (2 rows lit, 5 address pins)
constexpr Hub75Config panel_cfg{
.panel = {
.matrix_panel_width = 64,
.matrix_panel_height = 64,
// .panel_kind = RowMapping::Standard is the default — no need to set it explicitly
},
.pins = { .rowsel_n_pins = 5 },
};
// 64×32 standard panel, 1/16 scan (2 rows lit, 4 address pins)
constexpr Hub75Config panel_cfg_b{
.panel = { .matrix_panel_width = 64, .matrix_panel_height = 32 },
.pins = { .rowsel_n_pins = 4 },
};
// 32×16 standard panel, 1/8 scan (2 rows lit, 3 address pins)
constexpr Hub75Config panel_cfg_c{
.panel = { .matrix_panel_width = 32, .matrix_panel_height = 16 },
.pins = { .rowsel_n_pins = 3 },
};💡
RowMapping::Standardis the default. You do not need to setpanel_kindexplicitly unless you are switching back from one of the other values.
Use this for the P10-SMD 16×32 outdoor panel (3535 LED pitch, 1/4 scan) and any electrically equivalent panel where four rows are driven simultaneously and only 2 address lines (A, B) are present.
How to recognise it:
- Panel label contains
-4S-or similar 1/4 scan notation. - Physical dimensions are 32 columns × 16 rows.
- The connector exposes only 2 address pins (A, B), matching
rowsel_n_pins = log₂(height / 4) = log₂(4) = 2. - The driver IC is typically DP5020B or equivalent.
- The panel is usually an outdoor type (weatherproof casing, brighter LEDs).
What the mapping does:
The panel's internal shift registers are arranged in four vertical quarters rather than two halves. Pixels are interleaved in column-pair groups: a selector bit derived from the panel width determines whether a column-pair slot draws from the first or the second half of the available column pairs, and a group-row offset advances the source pointer at each scan-group boundary.
// Key constants for 32×16, 1/4 scan
// COLUMN_PAIRS = 32 / 2 = 16
// HALF_PAIRS = 16 / 2 = 8 (= PAIR_HALF_BIT)
// PAIR_HALF_SHIFT = ctz(8) = 3
// SCAN_GROUPS = 2^rowsel_n_pins = 4
// ROWS_PER_GROUP = 16 / 4 = 4
// GROUP_ROW_OFFSET = 4 × 32 = 128 source pixels
// HALF_PANEL_OFFSET = (16 / 2) × 32 = 256 source pixelsConfiguration:
// 32×16 outdoor panel, 1/4 scan (4 rows lit, 2 address pins)
constexpr Hub75Config panel_cfg{
.panel = {
.matrix_panel_width = 32,
.matrix_panel_height = 16,
.panel_kind = RowMapping::Split,
.sm_clockdiv_factor = 1.0f, // increase if ghosting occurs
.panel_chip = Hub75PanelChip::GENERIC,
},
.pins = { .rowsel_n_pins = 2 },
};
⚠️ Do not use this value with larger panels or panels that have more than 2 address lines. The mapping constants are compiled in from the width and height, but the quarter-split logic is specific to the 4S wiring architecture.
Use this for the QP3 outdoor 64×64 panel (1415 / 1.4 mm LED pitch, 1/16 scan) and
panels with the same internal wiring convention — sometimes labelled -16S- on a 64-tall
panel, which gives 64/16 = 4 rows lit simultaneously.
How to recognise it:
- Panel label contains
-16S-on a 64-row-tall panel (not 32-row). The key distinction from a standard-16S-panel is that this one lights 4 rows at once, not 2. - Physical dimensions are 64 columns × 64 rows.
- The connector exposes 4 address pins (A, B, C, D), matching
rowsel_n_pins = log₂(height / 4) = log₂(16) = 4. - Compatible driver ICs include: DP5125D, MBI5253, ICND2055, ICND2065, ICND2153S, CFD325, MBI5264, CFD555, ICND2165.
- The panel is typically an outdoor type (weatherproof, high-brightness).
- Confirmed working board: P3QD / QP3 Outdoor (see Boards).
What the mapping does:
The panel divides its 64 rows into four equal quarters of 16 rows each. The shift buffer
is filled in a specific two-pass pattern per logical scan line: first the pixels from the
second and fourth quarter are interleaved into even output slots, then the pixels from the
first and third quarter are interleaved into the odd output slots (offset by
line_offset = 2 × matrix_panel_width). This unusual ordering matches the internal
scan-row pairing of this panel family.
// Key geometry for 64×64, 1/16 scan
// quarter = 64 × 64 / 4 = 1024 pixels each
// line_offset = 2 × 64 = 128 output words
//
// Per logical row: even dst slots ← quarters 2 & 4 (src pixels)
// odd dst slots ← quarters 1 & 3 (src pixels, at dst + line_offset)Configuration:
// 64×64 outdoor P3 panel, 1/16 scan (4 rows lit, 4 address pins)
constexpr Hub75Config panel_cfg{
.panel = {
.matrix_panel_width = 64,
.matrix_panel_height = 64,
.panel_kind = RowMapping::S31,
.sm_clockdiv_factor = 2.75f, // recommended starting value for this panel family
.panel_chip = Hub75PanelChip::GENERIC,
},
.pins = { .rowsel_n_pins = 4 },
};
⚠️ A 64×64 panel with a-16S-label could also be a standard two-row panel at 1/32 scan that the manufacturer labelled inconsistently. If this value produces a scrambled image, tryRowMapping::Standardwithrowsel_n_pins = 5instead.
Does your panel label contain "-16S-" or "-32S-" on a 64-tall panel
with 5 address pins (A–E)?
└─ Yes → RowMapping::Standard (standard 1/32 scan, 2 rows lit)
Does your panel label contain "-16S-" on a 64-tall panel
with 4 address pins (A–D)?
└─ Yes → RowMapping::S31 (4 rows lit)
If image is still scrambled, try RowMapping::Standard
Does your panel label contain "-4S-" and the panel is 32 × 16
with 2 address pins (A, B)?
└─ Yes → RowMapping::Split
For any other panel, or when uncertain:
└─ Start with RowMapping::Standard — it covers the majority of panels.
If the image is stable but scrambled or interleaved, try the others.
Quick check table:
| Address pins on connector | Panel height | Rows lit simultaneously | Value to try first |
|---|---|---|---|
| 5 (A–E) | 64 | 2 | RowMapping::Standard |
| 4 (A–D) | 64 | 2 | RowMapping::Standard (e.g. 64×32) |
| 4 (A–D) | 64 | 4 | RowMapping::S31 |
| 3 (A–C) | 16 | 2 | RowMapping::Standard |
| 2 (A, B) | 16 | 4 | RowMapping::Split |
Setting panel_kind has two effects:
-
Selects the pixel reorder algorithm used inside
update()/update_bgr(). The chosen algorithm determines how the driver maps each(x, y)coordinate from the source buffer to the correct slot in the internalframe_buffer_that will be streamed to the panel's shift registers. -
Implicitly fixes the memory layout of
frame_buffer_. Different mappings produce differently structured buffers. Activating a mapping that does not match the physical panel causes address aliasing — pixels from unrelated source rows land in the same shift-register slot — which is why the image appears scrambled rather than just offset.
The CIE/CCM colour correction (pack_lut_rgb) is applied inside the same copy loop
regardless of which value is active, so colour fidelity is unaffected by the choice of
mapping.
-
panel_kindpicks exactly one mapping at a time — it's a single enum field, so there's no way to accidentally activate more than one (unlike the old preprocessor defines, where defining two mapping macros at once was a possible mistake). -
rowsel_n_pinsmust match the mapping. The number of address lines is not derived frompanel_kind; you must setpins.rowsel_n_pinsexplicitly and consistently. An incorrect value causes wrong row selection independently of the pixel mapping. -
The fields are set in your own
.cppfile, not by editinghub75.hppdirectly. Build aconstexpr Hub75Configvalue (see examples above). Thehub75.hppstruct definitions show the available fields and their defaults but are not the intended configuration point. -
Panel names are not standardised. Two panels from different manufacturers with the same printed label may have different internal wiring. If the expected mapping produces a scrambled image, work through the decision guide above and test each option with a simple solid-colour or stripe test pattern — change one parameter at a time.
-
sm_clockdiv_factormay need adjustment for 4-row panels. Panels usingRowMapping::SplitorRowMapping::S31sometimes exhibit ghosting or flicker at full PIO speed. Start withsm_clockdiv_factor = 1.0fand increase (e.g.2.0f,2.75f) if artefacts appear. See Step 6 — State Machine Clock Divider.
💡 You only need to set the fields that differ from these defaults. There is no need to repeat the entire struct for a standard setup.
Version 3.0 is a near-complete rework of the DMA/PIO pipeline. Once started, the driver runs almost entirely in hardware — the CPU is barely involved. Two independent DMA/PIO stages handle all the work: one constructs BCM bitplanes on demand whenever update() or update_bgr() is called; the other streams the pre-built bitplanes to the panel autonomously and continuously. For the full architectural detail see The Definitive Hub75 Driver Solution.
A detailed description of the development history, the motivation and some external documentation links nad reference can be found in HUB75 Driver — Development History.
Improvements:
- CPU Offloading — Processing moved from the CPU to DMA and PIO co-processors.
- Self-paced Pipeline — Interlinked DMA and PIO processes run without CPU supervision.
- No Blocking Synchronization —
hub75_wait_tx_stalleliminated entirely. - Reduced Interrupt Complexity — Lighter, leaner interrupt handlers.
Building on the DMA/PIO foundation, Version 3.0 adds:
- On-demand Bitplane Construction — RGB pixel data is decomposed into BCM bitplanes
by a dedicated DMA/PIO stage, triggered only on
update()/update_bgr(). Streaming to the panel runs fully autonomously in the background. - Balanced Light Output — High-weight bitplanes are split into multiple shorter segments, spreading illumination evenly across the frame. Effective refresh rate increases significantly; visible flicker is eliminated even at low brightness.
- Per-channel CIE 1931 Correction — Separate
CIE_RED,CIE_GREEN,CIE_BLUElookup tables (generated bycie.py) map 8-bit input to 10-bit perceptually linear output, with per-channel white-balance scaling viaRED_CAP/GREEN_CAP/BLUE_CAP. - Colour Correction Matrix (CCM) — Six integer-shift cross-terms correct spectral bleed
between channels. Zero floating-point cost; off by default (
shift = 31). - Anti-ghosting & Settling Delays — Configurable nano-second timing guards
(
BASE_LATCH_NS,BASE_ADDR_NS) around latch and address transitions eliminate ghosting and edge glimmer at high clock speeds. - Double-buffering for both frame and command buffers — Tear-free updates; the
row_cmd_bufferis only swapped when brightness actually changes. cie.pygenerates the fullcie.hpp— The LUT generator now emits the complete header file including all#if SEPARATE_CIE_CHANNELS/#if BITPLANESpreprocessor guards. Run once, redirect to file, done:
python3 utils/cie.py > cie.hppTogether these enhancements deliver a display pipeline that is faster, more visually accurate, and almost entirely autonomous — leaving the CPU free for application logic.
The Definitive Hub75 Driver Solution – A Bitplane Stream with Parallel Reading and Display of Pixel Data
A detailed desciption of the evolution of the Hub75 driver can be found here:
A (nearly) complete rework of the DMA/PIO pipeline has been done. In this context, I removed some features, such as temporal dithering, and added new ones, such as Balanced Light Output and did implement board707´s suggestion of parallel loading of data while BCM is running.
In addition to Pimoroni’s anti-ghosting, a “cooling-off period” for the line decoder has been incorporated after the address is set. Precise timing values in nano-seconds can be specified via compile time definitions in CMakeLists.txt. This applies to the the wait time to stabilise latch, the wait time to stabilise row addressing, and pre-Oe guard wait time to prevent ghost flashes. The matrix panels available to me show no ghosting, no flickering, and no glimmering of pixels at the edges of the matrix even in dark environments.
The output quality has improved due to the usage of Balanced Light Output. That is, bit planes with high weight are divided into several smaller segments within the BCM sequence. This increases the effective refresh rate and reduces flickering.
An interrupt handler is used to support the conversion of rgb pixel data into bitplane slices. At the end of the update() and update_bgr() methods the bitplane slices are constructed heavily relying on DMA and PIO support. This is in stark contrast to the previous version where this had been done on the fly for every frame. The result is a stream of bitplane slices pushed to the matrix panel in a highly efficient manner.
Another interrupt handler is called once per frame. This interrupt handler is responsible for double-buffer administration (pointer switching) of the frame_buffer and double-buffering of the row_cmd_buffer. Both buffers are switched only when necessary. The row_cmd_buffer only when a brightness change has been made, and the frame_buffer when update or update_bgr is called.
CPU part - usually done in 60Hz or 100Hz frequency
[Input 8-bit RGB via update(...) or update_bgr(...) method]
↓
[Double Buffered Frame Buffer]
↓
[CIE → 10-bit or 8-bit linear light]
↓
[Color Correction Matrix (per channel)]
↓
[Pixel Mapping / Layout Transform]
↓
DMA & PIO part - usually runs at much higher frequency than the CPU part
[Bitplane Extraction Engine (DMA- and PIO-based with a bitplane sequence optimised for the balanced light output strategy)]
↓
[DMA → PIO Row Engine (OE / LAT / ADDR)] ← → [DMA → PIO Engine for bitplane row streaming]
↓
[Matrix Panel]
The driver has transitioned from a CPU-intensive real-time mapping approach to a structured, three-stage hardware pipeline. This change significantly reduces CPU overhead.
- Panel-Specific Normalization: All panel-specific quirks (scan-mode, physical row mapping, and ZigZag patterns) are handled during the initial copy to
rgb_buffer. - Standardized Format: The buffer is organized into a "canonical" 32-bit RGB format, allowing the subsequent PIO stages to remain generic and extremely fast.
The data flow is now managed by three specialized PIO programs working in concert:
| Component | Role | Mechanism |
|---|---|---|
hub75_bitplane_setup |
Bit-Slicing | Converts the canonical rgb_buffer into the bit-plane structured frame_buffer. |
hub75_bitplane_stream |
Data Feeding | Streams the prepared bit-planes to the panel's shift registers. |
hub75_row |
Timing & Logic | The "Master" State-Machine (SM). Handles Row Addressing (A-E), BCM timing, and Latch (STB) signals. |
The DMA logic has been streamlined. Instead of complex per-row interrupts, the system now uses DMA Chaining:
-
Autonomous Frames: DMA channels now loop through all bit-planes and rows automatically.
-
Minimal CPU Interrupts: The Interrupt Handler is now only called once per frame.
It handles:
- Double-Buffering: Swapping
frame_bufferandrow_cmd_bufferonly when a full frame is complete. - Runtime Updates: Activating new BCM cycles if brightness was changed via the API.
- Double-Buffering: Swapping
The hub75_row program now includes specific hardware-level timing improvements:
- Anti-Ghosting Wait Cycles: A configurable
wait_loopis executed after the Latch (STB) signal but before enabling the next row. This ensures the LEDs from the previous row have fully discharged, eliminating the "shadow" or "ghost" effect common in high-speed multiplexing. - Settling Buffers: Added precise timing padding around the Address (A-E) and Strobe transitions to account for cable capacitance and level-shifter propagation delays.
- Hardware Synchronization:
hub75_rowandhub75_bitplane_streamare hardware-locked via PIO IRQ flags, ensuring that row switching never occurs while data is still being shifted.
- Balanced Light Output: High-weight bit-planes are split into multiple smaller slices within the BCM sequence. This increases the effective refresh rate and eliminates visible flicker, even at low intensity settings.
- Constant Frame Rate: The sum of
lit_cyclesanddark_cyclesis kept constant, ensuring a rock-solid refresh rate regardless of brightness levels.
Two independent DMA/PIO pipelines do the "background" work in the Definitive Hub75 Driver Solution.
The first DMA/PIO pipeline transforms the RGB pixel data into bitplane slices. This is done On Demand whenever the update(...) or update_bgr(...) method is called by the user.
The second DMA/PIO pipeline streams the bitplanes to the matrix panel. Each row in the bitplane has its address and on/off BCM duration supplied from a precalculated structure which is read via DMA. Loading of pixel data (bitplanes) and BCM are done in parallel.
Picture 4: Bitplane Creation Pipeline
Picture 5: Row-Addressing, Pixel Loading and BCM
With a bit-depth of 10 or a bit-depth of 8, the HUB75 driver achieves the following refresh rates for a 64 x 64 standard Hub75 matrix panel with scan mode 2 depending on the system clock and basis brightness settings.
Here some more relevant settings if you want to repeat the measurements and verify the listed frame rates:
constexpr Hub75Config panel_cfg{
.panel = {
.sm_clockdiv_factor = 1.0f, // to prevent flicker or ghosting it might be worth a try to reduce state machine speed
},
.color = {
.bitplanes = 10, // number of bit-planes used for Binary Code Modulation - valid values are 8 or 10
.balanced_light_output = true, // uses some more memory but it improves effective refresh rate and really cuts down flicker
.separate_cie_channels = true, // use separate CIE channels for improved colour representation - needs more memory
},
.frame_rate_debug = true, // emit frame rate information on usb - disable for production usage
};HUB75_MULTICORE=true remains a CMakeLists.txt build flag — see
Remaining CMakeLists.txt Build Flags.
| System Clock | Basis Brightness | Refresh Rate for 10 Bitplanes | Refresh Rate for 8 Bitplanes |
|---|---|---|---|
| 100 MHz | 8 | ~271 Hz | ~519 Hz |
| 150 MHz | 8 | ~398 Hz | ~762 Hz |
| 200 MHz | 8 | ~519 Hz | ~993 Hz |
| 250 MHz | 8 | ~634 Hz | ~1216 Hz |
| 266 MHz | 1 | ~1009 Hz | ~1285 Hz |
| 266 MHz | 2 | ~1009 Hz | ~1285 Hz |
| 266 MHz | 4 | ~951 Hz | ~1285 Hz |
| 266 MHz | 8 | ~670 Hz | ~1285 Hz |
| 266 MHz | 16 | ~412 Hz | ~1121 Hz |
| 266 MHz | 32 | ~230 Hz | ~751 Hz |
| 266 MHz | 64 | ~121 Hz | ~441 Hz |
| 266 MHz | 128 | ~62 Hz | ~239 Hz |
| 266 MHz | 255 | ~31 Hz | ~124 Hz |
These results demonstrate stable operation and high-performance display rendering across a wide range of system clocks.
Overall, performance has improved compared to the predecessor versions. In summary, the following factors are responsible for this:
- pixel data are provided in a bitplane structure and only need to be streamed to the matrix panel
- loading of pixel data (bitplanes) and Binary Coded Modulation (BCM) are done in parallel as proposed by board707
The performance improvements mainly affect the lower and middle brightness ranges. Starting at a “Base Brightness” of 64 and higher, the BCM component becomes dominant. At that point, even the parallel loading of the pixel data and its provision in a bit-plane structure no longer provides any (significant) speedup.
The revised driver requires slightly more memory resources to achieve the improved quality. I am using “defines” to disable certain (new) functionalities and thus make more memory available for applications.
✅ Fully automated data transfer using chained DMA channels.
✅ Eliminates CPU-intensive busy-waiting (hub75_wait_tx_stall).
✅ Ensures precise timing without unnecessary stalling.
By offloading tasks to DMA and PIO the definitive HUB75 driver achieves higher performance, simpler interrupt handling, and better synchronization. Especially splitting RGB data into bitplanes on demand in a separate DMA and PIO step has contributed a great deal to this improvement. This separate step also eased the implementation of Balanced Light Output. Overall, this approach has significantly reduced CPU overhead while simultaneously minimizing artifacts such as ghosting at high clock speeds.
If you're interested in optimizing RGB matrix panel drivers, this implementation serves as a valuable reference for efficient DMA-based rendering.
The graphics system for the demo effects operates in RGB888 format (8 bits per channel, 24 bits per pixel). To better match human vision, colours are mapped using the CIE 1931 lightness curve. This mapping effectively expands the usable range to 10 bits per channel.
The HUB75 driver takes advantage of this: its PIO/DMA pipeline packs each pixel as a 32-bit word with 10 bits for red, 10 bits for green, and 10 bits for blue.
In standard Binary Code Modulation (BCM), each bitplane is displayed for a duration proportional to its bit weight — the MSB plane stays on for half the entire frame period. At low brightness settings or when the display content changes rapidly, this long uninterrupted ON-period becomes visible as flicker.
Balanced Light Output addresses this by splitting high-weight bitplanes into multiple smaller segments, distributing them evenly across the BCM sequence. The total illumination time per bitplane remains identical — only the distribution changes. This increases the effective refresh rate and eliminates visible flicker, even at low brightness levels.
Without Balanced Light Output, the BCM sequence processes bitplanes 0–9 in a single pass (10 steps):
// Standard BCM — 10 steps
static const uint8_t BCM_SEQUENCE[] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };A first step towards Balanced Light Output is the reordering of the bitplanes as is done in the current implementation when color.balanced_light_output is set to false. This already has an effect to mitigate flicker by mixing long and short ON-periods.
// Reordered BCM — 10 steps
static const uint8_t BCM_SEQUENCE[] = { 0, 9, 2, 7, 4, 5, 1, 8, 3, 6 };Enabling color.balanced_light_output = true produces a 14-step sequence instead. Bitplane 9 (highest weight) is split into 4 segments, bitplane 8 into 2 segments — all other bitplanes appear once:
// Balanced Light Output — 14 steps
static const uint8_t BCM_SEQUENCE[] = {
9, 0, 1, 2, 8, 3, 4, 9, 5, 9, 6, 8, 7, 9
// ^ ^ ^ ^ ^ ^
// | | | | | bitplane 9 (4x total)
// bitplane 9 (1st) | bitplane 9 (3rd)
// | bitplane 9 (2nd)
// bitplane 8 (1st) |
// bitplane 8 (2nd)
};Note: The sum of all BCM cycle durations is identical in both sequences — Balanced Light Output does not affect overall brightness, only the temporal distribution of light.
Standard BCM (10 steps):
|0|1|2|3|4|5|6|7|████8████|████████████████9████████████████|
↑ long ON-period → visible flicker
Balanced Light Output (14 steps):
|███9███|0|1|2|██8██|3|4|███9███|5|███9███|6|██8██|7|███9███|
↑ ↑ ↑ ↑ ↑ ↑
MSB segments spread evenly across the frame → no flicker
Balanced Light Output improves the temporal distribution of light. The next section addresses spectral accuracy — correcting the colour cross-channel bleed that makes neutral grey appear tinted on real panels.
HUB75 LED matrix panels do not reproduce colour faithfully out of the box. Two independent sources of error contribute to inaccurate colour:
1. Per-channel luminance non-linearity — already corrected by the CIE 1931 lightness curve baked into CIE_RED, CIE_GREEN, and CIE_BLUE. The per-channel white-balance scaling factors RED_CAP, GREEN_CAP, and BLUE_CAP in cie.py handle the remaining per-channel gain difference.
2. Spectral cross-channel bleed — not corrected by the CIE LUTs. Real LEDs emit light across a broader spectrum than their nominal colour. A red LED radiates slightly into the orange-green range; a green LED dominates perceived brightness. The result is that neutral grey (R = G = B) appears tinted, saturated colours look shifted, and skin tones are rendered incorrectly.
A Colour Correction Matrix (CCM) addresses this second source of error by mixing a small, controlled fraction of each channel's CIE-corrected output into its neighbours. This is the same technique used in professional LED processors and ICC display profiles.
The full colour pipeline works in two consecutive stages, each handling a distinct correction:
8-bit input ┌─────────────────────────────────────────┐ 10-bit output
R, G, B ───▶ │ Stage 1: CIE LUT + per-channel CAP │ ──▶ rv, gv, bv
│ (baked into CIE_RED/GREEN/BLUE) │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐ 10-bit output
│ Stage 2: CCM cross-channel mixing │ ──▶ rv′, gv′, bv′
│ (additive, integer shift arithmetic) │
└─────────────────────────────────────────┘
│
▼
32-bit packed pixel word
(bv′ << 20) | (gv′ << 10) | rv′
The two stages are orthogonal: the CIE LUT correction and the RED_CAP / GREEN_CAP / BLUE_CAP scaling factors in cie.py remain completely unchanged when CCM is enabled. CCM operates on the already CIE- and CAP-corrected 10-bit values.
The CCM adds cross-channel contributions using a superposition model:
r′ = clamp( rv + (gv >> CCM_RG_SHIFT) + (bv >> CCM_RB_SHIFT) )
g′ = clamp( gv + (rv >> CCM_GR_SHIFT) + (bv >> CCM_GB_SHIFT) )
b′ = clamp( bv + (rv >> CCM_BR_SHIFT) + (gv >> CCM_BG_SHIFT) )
Each coefficient is expressed as an integer right-shift amount, which avoids floating-point arithmetic entirely. The equivalent fractional contribution is:
| Shift value | Added fraction | Typical use |
|---|---|---|
5 |
≈ 3.1 % | Strong correction |
6 |
≈ 1.6 % | Moderate correction |
7 |
≈ 0.8 % | Fine correction |
8 |
≈ 0.4 % | Very fine correction |
9 |
≈ 0.2 % | Barely perceptible |
31 |
= 0.0 % | Disabled (identity, no bleed) |
Setting a shift to 31 disables that cross-term completely — a shift of 31 on a 10-bit value always produces zero. All six cross-terms default to 31, so CCM is off by default and introduces no change to the output unless explicitly configured.
The CCM is implemented as two macros in hub75.hpp, inserted directly after the SEPARATE_CIE_CHANNELS preprocessor block:
// ---------------------------------------------------------------------------
// Colour Correction Matrix (CCM) — cross-channel mixing
//
// Applied after the CIE LUT lookup, on already CAP-scaled 10-bit values.
// All six cross-terms default to 31 (= disabled, adds zero contribution).
//
// Shift reference: 5 → ~3.1% 6 → ~1.6% 7 → ~0.8% 31 → 0% (off)
// ---------------------------------------------------------------------------
#ifndef CCM_RG_SHIFT
#define CCM_RG_SHIFT 31 // fraction of Green added into Red
#endif
#ifndef CCM_RB_SHIFT
#define CCM_RB_SHIFT 31 // fraction of Blue added into Red
#endif
#ifndef CCM_GR_SHIFT
#define CCM_GR_SHIFT 31 // fraction of Red added into Green
#endif
#ifndef CCM_GB_SHIFT
#define CCM_GB_SHIFT 31 // fraction of Blue added into Green
#endif
#ifndef CCM_BR_SHIFT
#define CCM_BR_SHIFT 31 // fraction of Red added into Blue
#endif
#ifndef CCM_BG_SHIFT
#define CCM_BG_SHIFT 31 // fraction of Green added into Blue
#endif
#if BITPLANES == 10
#define CCM_MAX_VAL 1023u
#elif BITPLANES == 8
#define CCM_MAX_VAL 255u
#endif
// Branchless saturation — the compiler generates a single USAT or CMP+MOV
// on Cortex-M0+ and M33; no branching, no pipeline stall.
#define CCM_CLAMP(val) ((val) > CCM_MAX_VAL ? CCM_MAX_VAL : (val))
// CCM_APPLY operates in-place on three uint32_t locals rv, gv, bv.
// All cross-terms for a channel are accumulated first, then clamped once.
#define CCM_APPLY(rv, gv, bv) \
do { \
uint32_t _r = (rv) + ((gv) >> CCM_RG_SHIFT) \
+ ((bv) >> CCM_RB_SHIFT); \
uint32_t _g = (gv) + ((rv) >> CCM_GR_SHIFT) \
+ ((bv) >> CCM_GB_SHIFT); \
uint32_t _b = (bv) + ((rv) >> CCM_BR_SHIFT) \
+ ((gv) >> CCM_BG_SHIFT); \
(rv) = CCM_CLAMP(_r); \
(gv) = CCM_CLAMP(_g); \
(bv) = CCM_CLAMP(_b); \
} while (0)CCM_APPLY is inserted as a single additional line in both pack_lut_rgb and pack_lut_rgb_ in hub75.cpp, immediately before the packed 32-bit word is assembled:
// Before (without CCM):
static inline uint32_t pack_lut_rgb_(uint8_t r, uint8_t g, uint8_t b) {
uint32_t rv = CIE_RED[r];
uint32_t gv = CIE_GREEN[g];
uint32_t bv = CIE_BLUE[b];
return (bv << 20u) | (gv << 10u) | rv;
}
// After (with CCM — one line added):
static inline uint32_t pack_lut_rgb_(uint8_t r, uint8_t g, uint8_t b) {
uint32_t rv = CIE_RED[r];
uint32_t gv = CIE_GREEN[g];
uint32_t bv = CIE_BLUE[b];
CCM_APPLY(rv, gv, bv);
return (bv << 20u) | (gv << 10u) | rv;
}CCM is configured exclusively through the color fields of Hub75Config — no source file
edits are required beyond the one-line change to pack_lut_rgb_.
constexpr Hub75Config panel_cfg{
// ... panel / pins / etc ...
.color = {
.separate_cie_channels = true, // required — CCM needs per-channel LUTs
// Colour Correction Matrix cross-terms.
// Omitting a field leaves it at the default of 31 (= disabled).
.ccm_rg_shift = 6, // ~1.6% of Green added into Red
.ccm_gb_shift = 7, // ~0.8% of Blue added into Green
// .ccm_rb_shift = 31, // disabled
// .ccm_gr_shift = 31, // disabled
// .ccm_br_shift = 31, // disabled
// .ccm_bg_shift = 31, // disabled
},
};
⚠️ CCM requirescolor.separate_cie_channels = true. Withseparate_cie_channels = falseall three channels share a singleCIEtable and cross-channel mixing has no meaningful effect.
cie.py generates the complete cie.hpp header, including all #if SEPARATE_CIE_CHANNELS and #if BITPLANES preprocessor branches for both 8-bit and 10-bit colour depth. The per-channel scaling factors RED_CAP, GREEN_CAP, BLUE_CAP handle white-balance gain differences between the three LED colours independently of the CCM cross-terms.
Regenerate after any change to the CAP values:
python3 utils/cie.py > cie.hppThe CCM shift fields in Hub75Config are unaffected and do not require a re-run of cie.py.
Colour calibration follows a strict order: white-balance first, cross-channel correction second. Mixing the two steps produces confusing interactions.
Set all six CCM shift fields to 31 in your Hub75Config (or omit them entirely — 31 is the default). Rebuild and flash. This confirms that the output is identical to the pre-CCM state and gives you a known reference point.
A grey-ramp effect has been added to hub75_demo.cpp:
// Diagnostic grey ramp — equal R, G, B at every luminance level.
// On a perfectly calibrated panel this ramp appears neutral grey
// from black to white with no colour tint at any brightness.
...
else if (demo_index == 7)
{
greyScaleStripes.drawStripes();
update(&greyScaleStripes);
}Observe the ramp carefully:
| Visible tint | Likely cause | First term to try |
|---|---|---|
| Warm / orange-red cast | Green leaking into Red | CCM_RG_SHIFT=6 |
| Cool / blue-green cast | Blue leaking into Green | CCM_GB_SHIFT=7 |
| Red cast in bright areas | Blue leaking into Red | CCM_RB_SHIFT=7 |
| Green cast overall | Red leaking into Green | CCM_GR_SHIFT=7 |
| Yellow cast | Both Red and Green too high | Reduce RED_CAP in cie.py first |
Enable only the single most visible cross-term and rebuild. Never change more than one shift value per build-flash-observe cycle. The following starting values work well for most common indoor HUB75 panels:
.ccm_rg_shift = 6, // ~1.6% Green → Red (most panels need this)
.ccm_gb_shift = 7, // ~0.8% Blue → GreenEach increment of the shift value halves the contribution. Each decrement doubles it. A shift of 5 (≈ 3.1%) is usually already too strong and risks a visible tint in the opposite direction.
Display solid full-brightness red (255, 0, 0), green (0, 255, 0), and blue (0, 0, 255) in turn. Each primary should appear as a pure, uncontaminated hue. Then display the additive secondaries — yellow (255, 255, 0), cyan (0, 255, 255), magenta (255, 0, 255) — and verify they look balanced.
Use a photographic test image containing known neutral greys, skin tones, and saturated colours. The grey areas should remain neutral; skin tones should appear natural; saturated hues should not appear shifted relative to the original.
If a residual gain imbalance remains after CCM is set (e.g. pure white still looks faintly warm), adjust RED_CAP, GREEN_CAP, or BLUE_CAP in cie.py, regenerate the LUT tables, and rebuild. Do not use CCM cross-terms to compensate for a simple gain imbalance — that is the job of the CAP factors.
CCM_APPLY consists of six integer right-shifts, six integer additions, and three saturating comparisons. On the RP2040 (Cortex-M0+) and RP2350 (Cortex-M33) this amounts to approximately 8–10 additional clock cycles per pixel, executed only during update() or update_bgr().
For a 64 × 64 panel at 266 MHz the total overhead per update() call is approximately 0.12 µs — completely negligible compared to the DMA and PIO transfer time.
In addition to bitplane modulation, the driver supports software-based brightness regulation. This allows easy adjustment of overall panel brightness without hardware changes.
// Set the baseline brightness scaling factor (default = 6, range 1–255).
// Larger values increase brightness but also raise OEn frequency.
void setBasisBrightness(uint8_t factor);
// Set fine-grained brightness intensity as a fraction [0.0 – 1.0].
void setIntensity(float intensity);-
setBasisBrightness(basis)Defines the top brightness.
Example:
setBasisBrightness(6)→ default brightness range for typical 64×64 panels.
Larger factors give more headroom for brightness but consume more Binary Coded Modulation (BCM) time slices. -
setIntensity(intensity)Fine-grained adjustment from 0.0 (dark/off) to 1.0 (full brightness).
This function scales the effective duty cycle within the current baseline brightness range.
// Example: brighten the panel, then dim at runtime
setBasisBrightness(8); // Start with baseline factor 8 for a brighter panel
setIntensity(0.5f); // Show at 50% of that baselinebasis_factor = 6uintensity = 1.0f(full brightness within the baseline)
This corresponds to the same brightness as earlier driver revisions without adjustment.
- Increasing the basis factor may increase peak current consumption.
- For indoor use, values between 4–8 are usually sufficient.
- For dimmer environments, you can keep the baseline factor low (e.g. 4) and rely on setIntensity() for smooth runtime control.
- Both functions are non-blocking and can be called during normal operation.
The driver supports driving multiple HUB75 panels connected in series from a single Raspberry Pi Pico. Panels are arranged in a serpentine (U-turn) topology: data flows left-to-right through the first chain row, then reverses and flows right-to-left through the second chain row, and so on. The signal input connector is always on the left panel of chain row 0.
Panels are described by a two-dimensional grid:
panel.chain_cols— number of panels chained left-to-right within a single chain row (horizontal extent).panel.chain_rows— number of chain rows stacked vertically (vertical extent).
The diagram below shows a chain_cols = 3, chain_rows = 2 arrangement (six panels total):
Signal IN
│
▼
┌───────┐ ┌───────┐ ┌───────┐
│ 0,0 │──▶│ 0,1 │──▶│ 0,2 │ chain row 0 (left → right)
└───────┘ └───────┘ └───────┘
│
U-turn ▼
┌───────┐ ┌───────┐ ┌───────┐
│ 1,0 │◀──│ 1,1 │◀──│ 1,2 │ chain row 1 (right → left)
└───────┘ └───────┘ └───────┘
Physical wiring is unchanged. The serpentine reversal is handled in software by the pixel mapping stage (
update()/update_bgr()): panels on odd-numbered chain rows automatically receive their content rotated 180° so that the image appears upright on the display.
All chaining parameters are set in the panel sub-struct of Hub75Config.
matrix_panel_width and matrix_panel_height always describe one physical panel.
The total virtual display dimensions (DISPLAY_WIDTH / DISPLAY_HEIGHT, internal to
Hub75Driver<Cfg>) are derived automatically.
| Field | Default | Description |
|---|---|---|
panel.matrix_panel_width |
64 |
Width of a single physical panel in pixels |
panel.matrix_panel_height |
64 |
Height of a single physical panel in pixels |
panel.chain_cols |
1 |
Number of panels per chain row (horizontal) |
panel.chain_rows |
1 |
Number of chain rows stacked vertically |
panel.chain_mode |
Hub75ChainMode::SERPENTINE |
Topology: SERPENTINE or RASTER |
The derived display dimensions are computed at compile time inside Hub75Driver<Cfg>:
static constexpr uint32_t DISPLAY_WIDTH = Cfg.panel.matrix_panel_width * Cfg.panel.chain_cols;
static constexpr uint32_t DISPLAY_HEIGHT = Cfg.panel.matrix_panel_height * Cfg.panel.chain_rows;The raw display dimensions are only used internally; use the
rotation-aware Panel::SCREEN_WIDTH and Panel::SCREEN_HEIGHT static
members to initialize graphics libraries or allocate framebuffers.
| Mode | Description |
|---|---|
Hub75ChainMode::SERPENTINE |
Odd chain rows are reversed 180° (U-turn topology, default) |
Hub75ChainMode::RASTER |
All panels in the same orientation; no reversal applied |
Use Hub75ChainMode::RASTER only if your physical cable layout already compensates for
direction changes (non-standard wiring).
The example below configures a 2×3 array of 64×64 panels (total virtual display: 192×128 pixels):
constexpr Hub75Config panel_cfg{
.panel = {
.matrix_panel_width = 64, // width of one physical panel
.matrix_panel_height = 64, // height of one physical panel
.chain_rows = 2, // 2 chain rows stacked vertically
.chain_cols = 3, // 3 panels side-by-side per chain row
.chain_mode = Hub75ChainMode::SERPENTINE, // U-turn topology (default)
},
};
using Panel = Hub75Driver<panel_cfg>;This yields DISPLAY_WIDTH=192 and DISPLAY_HEIGHT=128.
Your application draws into a framebuffer of exactly that size; the driver handles all panel
addressing and serpentine reordering transparently.
The update() and update_bgr() functions expect a flat source buffer whose pixels are laid out
in row-major order across the full virtual display:
pixel(x, y) = src[y * DISPLAY_WIDTH + x] // update() — RGB888 packed
pixel(x, y) = src[(y * DISPLAY_WIDTH + x) * 3] // update_bgr() — BGR888 byte triplets
The driver internally translates this linear layout into the correct per-panel, per-row addressing required by the HUB75 protocol, including the 180° rotation for reversed panels in serpentine mode.
During the pixel mapping stage, each panel is identified by its position (v, h) where v is the
chain row index and h is the column index within that row.
For every scan row the driver iterates over all panels:
int32_t fb_index = 0;
for (int row = 0; row < SCAN_DEPTH; row++) // row: current row
{
for (int v = 0; v < Cfg.panel.chain_rows; v++) // v: panel in row (vertical chain)
{
const bool reverse = (Cfg.panel.chain_mode == Hub75ChainMode::SERPENTINE) ? (v & 1) : false;
for (int h = 0; h < Cfg.panel.chain_cols; h++) // h: panel in column (horizontal chain)
{
// Input parameters
// row: current row, (v, h): panel coordinates, reverse: U-turn descriptor
// Output parameters
// row_base: row offset
int32_t row_base = map_panel_row(row, v, h, reverse);
// map row and its paired row(s) in current panel located at position (v, h)
if (reverse)
{
// 180° rotation:
// reverse:
// - local scan row (done in map_panel_row)
// - i traversal (done here)
// - multiplex ordering (done here)
for (int i = Cfg.panel.matrix_panel_width - 1; i >= 0; --i)
{
for (int p = 0; p < PanelConfig::ROWS_IN_PARALLEL; ++p)
{
// set rotated content for odd chain rows
...
rgb_buffer[fb_index++] = ...
}
}
}
else
{
for (int i = 0; i < Cfg.panel.matrix_panel_width; ++i)
{
for (int p = 0; p < PanelConfig::ROWS_IN_PARALLEL; ++p)
{
// set content for even chain rows
...
rgb_buffer[fb_index++] = ...
}
}
}
}
}
}When reverse is true, pixel coordinates within the panel are mirrored both horizontally and
vertically, which is equivalent to a 180° software rotation. This compensates for the physical
cable U-turn without requiring any change to the panel wiring.
When panel.chain_cols == 1 && panel.chain_rows == 1, the compiler selects a dedicated
single-panel fast path that skips all chain-related loop overhead. No special configuration is
required; the optimisation is applied automatically at compile time via if constexpr guards.
All three supported panel mapping modes work with chained configurations:
panel_kind value |
Chain support |
|---|---|
RowMapping::Standard (default) |
✔ all chain_rows × chain_cols combinations |
RowMapping::Split |
✔ all chain_rows × chain_cols combinations |
RowMapping::S31 |
✔ all chain_rows × chain_cols combinations |
Memory requirements scale linearly with the total number of panels:
TOTAL_PIXELS = matrix_panel_width × matrix_panel_height × chain_rows × chain_cols
Each additional panel increases the size of the rgb_buffer_, the frame_buffer_ (all bitplanes),
and the row_cmd_buffer_ proportionally. For large arrays on the RP2040 (264 KB SRAM), verify that
total buffer allocation fits within available memory before enabling color.balanced_light_output = true
and/or color.separate_cie_channels = true, as both options increase memory usage further.
| Array | chain_cols |
chain_rows |
DISPLAY_WIDTH |
DISPLAY_HEIGHT |
|---|---|---|---|---|
| Single 64×64 panel | 1 |
1 |
64 | 64 |
| Two panels side-by-side | 2 |
1 |
128 | 64 |
| Four panels (2×2) | 2 |
2 |
128 | 128 |
| Six panels (3×2) | 3 |
2 |
192 | 128 |
| Eight panels (4×2) | 4 |
2 |
256 | 128 |
The driver supports software rotation of the displayed image by 0°, 90°, 180°, or 270°,
independent of how the panels are physically wired. This is useful when the matrix chain has to be
mounted in an orientation (e.g. portrait instead of landscape) that doesn't match the natural
left-to-right, top-to-bottom layout of the signal chain.
Rotation is set once, at compile time, and applies to the whole display — including chained arrays.
Set screen.rotation in your Hub75Config:
constexpr Hub75Config panel_cfg{
// ... panel / pins / etc ...
.screen = {
.rotation = Hub75Rotation::DEG_90, // DEG_0 (default), DEG_90, DEG_180, or DEG_270
},
};| Value | Effect |
|---|---|
Hub75Rotation::DEG_0 (default) |
No rotation |
Hub75Rotation::DEG_90 |
Rotated 90° clockwise |
Hub75Rotation::DEG_180 |
Rotated 180° (upside down) |
Hub75Rotation::DEG_270 |
Rotated 270° clockwise (= 90° counter-clockwise) |
Only these four values exist — Hub75Rotation is a scoped enum, so anything else is a compile
error, not a silently-accepted invalid value.
DISPLAY_WIDTH and DISPLAY_HEIGHT (internal to Hub75Driver<Cfg>; see
Configuration Parameters above) always describe the physical
matrix chain — matrix_panel_width × chain_cols and matrix_panel_height × chain_rows. These
two values never change when you set screen.rotation; the physical wiring obviously doesn't
change just because you rotate the image.
What does change is the shape of the buffer your application has to draw into and hand to
update() / update_bgr() — the logical source buffer. At DEG_90/DEG_270 the logical
buffer is the transpose of the physical chain, because you're effectively drawing into a
canvas that is then turned sideways onto the panel:
Physical chain: DISPLAY_WIDTH × DISPLAY_HEIGHT (fixed — depends only on panel + chain layout)
Logical buffer: depends on screen.rotation (this is what YOU must size correctly)
This is the one part of rotation the driver cannot do for you. It derives
DISPLAY_WIDTH/DISPLAY_HEIGHTpurely from your physical panel and chain configuration, and applies the rotation internally when sampling from your buffer — but nothing checks at compile time or runtime that the buffer you actually pass in has the right shape for the rotation you configured.
To help avoiding mistakes, Hub75Driver<Cfg> exposes the rotation-aware Panel::SCREEN_WIDTH
and Panel::SCREEN_HEIGHT static members (where Panel = Hub75Driver<panel_cfg>). They contain
the current width and height of your logical screen.
screen.rotation |
Screen width | Screen height |
|---|---|---|
DEG_0 |
DISPLAY_WIDTH |
DISPLAY_HEIGHT |
DEG_90 |
DISPLAY_HEIGHT |
DISPLAY_WIDTH |
DEG_180 |
DISPLAY_WIDTH |
DISPLAY_HEIGHT |
DEG_270 |
DISPLAY_HEIGHT |
DISPLAY_WIDTH |
update_bgr() takes a raw byte buffer. Use the provided rotation-aware constants instead of DISPLAY_WIDTH × DISPLAY_HEIGHT:
uint8_t src_buffer[Panel::SCREEN_WIDTH * Panel::SCREEN_HEIGHT * 3]; // BGR888The underlying memory allocation stays the same, but the code gets much more readable. The graphics library can be initialized with the exact same constants as well:
update() takes a PicoGraphics canvas:
PicoGraphics_PenRGB888 graphics(Panel::SCREEN_WIDTH, Panel::SCREEN_HEIGHT, frame_buffer);Rotation and serpentine chaining are independent and compose cleanly: panel.chain_mode = Hub75ChainMode::SERPENTINE handles the 180° per-panel correction needed for the physical
U-turn cabling (see
How Serpentine Reversal Works Internally), while
screen.rotation applies on top of that, to the display as a whole. You don't need to do
anything differently for chained arrays.
The demo effects in hub75_demo.cpp are included to exercise the driver and showcase colour fidelity. They cycle automatically; press the button to advance to the next effect.
| # | Effect | Purpose |
|---|---|---|
| 0 | Bouncing balls with text | Tests real-time animation; text position is hard-coded for 64×64 |
| 1 | Fire effect | Demonstrates per-pixel colour blending at high refresh rates |
| 2 | Static 64×64 images | Verify colour accuracy and panel mapping on the reference panel size |
| 3 | Rotator | Geometric transformation test - rotating antialiased line |
| 4 | Analog clock | Geometric transformation test - application of multiple rotating antialiased lines |
| 5 | Colour gradient / plasma | Full-spectrum colour coverage |
| 6 | Straight lines | Helps to detect mapping errors |
| 7 | Grey-scale ramp | Calibration tool — equal R, G, B at every luminance step. Use this with the CCM Tuning Procedure to eliminate colour tints |
⚠️ The demo has been tested on a Raspberry Pi Pico 2 RP2350A and Raspberry Pi Pico 2 RP2350B. On a RP2040 you may need to comment out some effects due to tighter memory constraints. Open an issue if you need help.
The core driver pipeline is stable. Possible future directions include:
- Additional panel mappings — contributions for panels with unusual internal wiring (e.g. serpentine, split-scan) are welcome.
- RP2040 memory optimisations — the
color.separate_cie_channels,color.balanced_light_output, andcolor.bitplanesfields already allow memory/quality trade-offs; further tuning for constrained targets is an open area.
For questions, bug reports, or feature discussions, feel free to open an issue on GitHub.
This is a compact recap of Configuration in Code above — useful as a
quick lookup once you're already familiar with Hub75Config. All driver configuration is set
in a constexpr Hub75Config value passed as a template argument to Hub75Driver<Cfg>; only a
handful of build-system flags remain in CMakeLists.txt (see
Remaining CMakeLists.txt Build Flags below).
If a field is not set in your Hub75Config initializer, the driver falls back to the
default value declared on that field in hub75.hpp (see
Notes on Default Values above).
| Field | Default | Description |
|---|---|---|
panel.matrix_panel_width |
64 |
Physical width of a single LED matrix panel in pixels. |
panel.matrix_panel_height |
64 |
Physical height of a single LED matrix panel in pixels. |
panel.chain_mode |
Hub75ChainMode::SERPENTINE |
Chain topology — default is serpentine (U-turn with 180° compensation). |
panel.chain_cols |
1 |
Number of panels chained left-to-right in a single chain row (columns). |
panel.chain_rows |
1 |
Number of chain rows stacked vertically (rows). |
panel.panel_kind |
RowMapping::Standard |
Pixel-mapping topology — Standard, Split, or S31. |
panel.panel_chip |
Hub75PanelChip::GENERIC |
Driver-IC init sequence — GENERIC, FM6126A, or RUL6024. |
panel.inverted_stb |
false |
Set true if the latch (strobe) signal is inverted on your board. |
panel.sm_clockdiv_factor |
1.0f |
PIO state machine clock divider factor. Values > 1.0 slow down the state machine — useful to reduce ghosting/flicker on smaller panels. |
panel.base_latch_ns |
80 |
Wait time in nanoseconds to stabilise latch. |
panel.base_addr_ns |
160 |
Wait time in nanoseconds to stabilise row addressing. |
screen.rotation |
Hub75Rotation::DEG_0 |
Logical display orientation — DEG_0, DEG_90, DEG_180, DEG_270. |
pins.data_base_pin |
0 |
First GPIO pin in the consecutive colour data block (R0). |
pins.data_n_pins |
6 |
Number of colour data pins (always 6 for standard HUB75: R0, G0, B0, R1, G1, B1). |
pins.rowsel_base_pin |
6 |
First GPIO pin in the consecutive row-select (address) block (A0). |
pins.rowsel_n_pins |
5 |
Number of address pins on the panel connector. Must match the physical panel. |
pins.clk_pin |
11 |
GPIO pin for the pixel clock (CLK). |
pins.strobe_pin |
12 |
GPIO pin for the latch/strobe signal (LAT). |
pins.oen_pin |
13 |
GPIO pin for the output enable signal (OE). |
color.bitplanes |
10 |
Number of bit-planes used for BCM (Binary Code Modulation). Valid values: 8 or 10. |
color.separate_cie_channels |
false |
Use separate per-channel CIE LUTs for improved colour representation — needs more memory. |
color.balanced_light_output |
true |
Uses some more memory but improves effective refresh rate and cuts down flicker. |
color.ccm_rg_shift |
31 (off) |
CCM cross-channel mixing — bits of Green added into Red. |
color.ccm_rb_shift |
31 (off) |
CCM cross-channel mixing — bits of Blue added into Red. |
color.ccm_gr_shift |
31 (off) |
CCM cross-channel mixing — bits of Red added into Green. |
color.ccm_gb_shift |
31 (off) |
CCM cross-channel mixing — bits of Blue added into Green. |
color.ccm_br_shift |
31 (off) |
CCM cross-channel mixing — bits of Red added into Blue. |
color.ccm_bg_shift |
31 (off) |
CCM cross-channel mixing — bits of Green added into Blue. |
frame_rate_debug |
false |
For testing and debugging purpose only: output frame rate information (printf) — set false for production. |
⚠️ Settingpanel.sm_clockdiv_factorbelow1.0fhas no effect — the driver clamps it to1.0f. If you do not set it at all, the state machine runs at full speed (equivalent to1.0f).
A few flags are still set via target_compile_definitions because they control the build,
not the panel — they select toolchain targets, remove/add library dependencies, or pick which
CPU core the driver task runs on, none of which fit naturally as constexpr Hub75Config fields:
| Define | Default | Description |
|---|---|---|
PICO_RP2350A |
(not set) | Set to 0 for RP2350B microcontrollers. Leave unset for RP2350A. Only relevant for RP2350-based boards. |
USE_PICO_GRAPHICS |
true |
Set to false if hub75 is used as a pure library without pico_graphics — removes any dependency on pico_graphics, and disables Hub75Driver::update(PicoGraphics const*). |
HUB75_MULTICORE |
true |
Set to true to run the hub75 driver on core 1 (via hub75_demo.cpp's initialize()), freeing core 0 for application logic. Only consulted by the single-instance demo — hub75_demo_dual.cpp always runs both driver instances' create()/start() on core 1. |
target_compile_definitions(hub75_demo PRIVATE
PICO_RP2350A=0 # uncomment for RP2350B microcontrollers only
USE_PICO_GRAPHICS=true # set to false if you use hub75 as a library
HUB75_MULTICORE=true # use core1 for the hub75 driver
)
⚠️ For a bare RP2350 microcontroller without a board — besides settingPICO_RP2350A=0— uncomment the following two lines inCMakeLists.txt, beforeinclude(pico_sdk_import.cmake):
set(PICO_PLATFORM rp2350)
set(PICO_BOARD none CACHE STRING "Board type")Because the panel/pin/colour/screen setup is now a template argument to Hub75Driver<Cfg>,
each distinct constexpr Hub75Config value produces its own driver type. This makes it
possible to run two independently-configured HUB75 chains — different dimensions, different
GPIO ranges, different chain layout, different rotation, even different colour tuning — from a
single RP2350, side by side in the same program. See hub75_demo_dual.cpp for a complete,
runnable example.
Each Hub75Driver<Cfg> instance claims:
- 6 DMA channels (
row_chan_,row_ctrl_chan_,pixel_chan_,pixel_ctrl_chan_,read_chan_,write_chan_) — RP2350 has 16 channels total, so 2 instances (12 channels) is the practical maximum that fits. - A dedicated PIO block pair for
hub75_row/hub75_row_invertedandhub75_bitplane_stream(these two programs must share one physical PIO block per instance). A second instance's row+stream pair can never land on the same PIO block as the first — the driver enforces this automatically atcreate()time.
Hub75DriverBase::MAX_INSTANCES is 2 — this is a hard ceiling baked into the driver, not
just a documentation note. Every Hub75Driver<Cfg> instance registers itself with the shared
Hub75DriverBase on create() (and unregisters on destruction), so the two chip-wide
DMA_IRQ_0/DMA_IRQ_1 handlers can dispatch to whichever instances are currently live —
regardless of which Cfg each one was built with.
⚠️ The two instances must not share any GPIO pin. Nothing in the type system checks this for you — pick non-overlappingpins.data_base_pin/pins.rowsel_base_pin/clk_pin/strobe_pin/oen_pinranges for eachHub75Config, exactly as you would when wiring two physically separate panel chains.
Two independent 64×32 chains (3 panels stacked vertically for instance A, 5 for instance B), wired to non-overlapping GPIO ranges, each with its own rotation and its own driver type:
#include "hub75.hpp"
// Instance A - panel wired to GPIO 0-13.
constexpr Hub75Config panel_cfg_a{
.panel = {
.matrix_panel_width = 64,
.matrix_panel_height = 32,
.chain_rows = 3,
.chain_cols = 1,
.chain_mode = Hub75ChainMode::SERPENTINE,
.panel_kind = RowMapping::S31,
.panel_chip = Hub75PanelChip::GENERIC,
.sm_clockdiv_factor = 1.0f,
.base_latch_ns = 80,
.base_addr_ns = 120,
},
.screen = { .rotation = Hub75Rotation::DEG_90 },
.pins = {
.data_base_pin = 0, .data_n_pins = 6,
.rowsel_base_pin = 6, .rowsel_n_pins = 3,
.clk_pin = 11, .strobe_pin = 12, .oen_pin = 13,
},
.color = {
.bitplanes = 10,
.separate_cie_channels = true,
.balanced_light_output = true,
.ccm_rg_shift = 6,
.ccm_gb_shift = 7,
},
};
// Instance B - same color config as A, wired starting 14 pins further along so the two
// panels don't share any GPIO (data 14-19, rowsel 20-24, clk 25, strobe 26, oen 27).
constexpr Hub75Config panel_cfg_b{
.panel = {
.matrix_panel_width = 64,
.matrix_panel_height = 32,
.chain_rows = 5,
.chain_cols = 1,
.chain_mode = Hub75ChainMode::SERPENTINE,
.panel_kind = RowMapping::S31,
.panel_chip = Hub75PanelChip::GENERIC,
.sm_clockdiv_factor = 1.0f,
.base_latch_ns = 80,
.base_addr_ns = 120,
},
.screen = { .rotation = Hub75Rotation::DEG_270 },
.pins = {
.data_base_pin = 14, .data_n_pins = 6,
.rowsel_base_pin = 20, .rowsel_n_pins = 3,
.clk_pin = 26, .strobe_pin = 27, .oen_pin = 28,
},
.color = panel_cfg_a.color, // reuse instance A's colour tuning verbatim
};
using PanelA = Hub75Driver<panel_cfg_a>;
using PanelB = Hub75Driver<panel_cfg_b>;
// Large fixed-size buffers - must have static storage duration, not live on the stack.
static PanelA driver_a;
static PanelB driver_b;
void core1_entry()
{
driver_a.create();
driver_b.create();
driver_a.start();
driver_b.start();
// KEEP CORE 1 ALIVE - without this, Core 1's NVIC is torn down and the DMA IRQs stop firing.
while (true) { tight_loop_contents(); }
}
int main()
{
stdio_init_all();
multicore_reset_core1();
multicore_launch_core1(core1_entry);
// ... application setup ...
while (true)
{
// ... update whatever each panel should show, then push it out independently ...
driver_a.update(/* ... */);
driver_b.update(/* ... */);
sleep_ms(10);
}
}The build target for the dual demo needs its own CMakeLists.txt entry (already present in
this repository's CMakeLists.txt):
add_executable(hub75_demo_dual hub75_demo_dual.cpp)
target_compile_definitions(hub75_demo_dual PRIVATE
USE_PICO_GRAPHICS=true
)
# ... target_sources / target_link_libraries / pico_generate_pio_header, same pattern as
# the hub75_demo target — see this repository's CMakeLists.txt for the full block.Note there is no HUB75_MULTICORE switch for the dual demo — with two driver instances
both needing to run continuously on DMA/PIO, hub75_demo_dual.cpp always launches both
create()/start() pairs together on core 1 and keeps application logic on core 0.
All panel-specific configuration is done in your own .cpp file, as a constexpr Hub75Config — see Configuration in Code above.
The goal is to describe your panel's geometry, scan method, and electronics so the driver can map pixels correctly and drive the panel reliably.
This section walks you through the configuration step by step, starting from the most obvious parameters (panel size) to the more subtle ones (scan rate, driver chip quirks).
Every configuration starts with the physical size of your panel:
constexpr Hub75Config panel_cfg{
.panel = {
.matrix_panel_width = 64,
.matrix_panel_height = 64,
},
};These values determine the memory usage of the frame buffer.
The physical wiring is essentially identical for most HUB75 panels. The only value that commonly needs changing is pins.rowsel_n_pins — it must match the number of address lines (A, B, C, …) printed on your panel's connector. See Wiring Details for the full pin table and Allowed Deviations for a custom-pin example.
💡
pins.rowsel_n_pinscontrols how many address bits the PIO state machine outputs per row. If this value is wrong, no rows will be selected correctly. The configuration examples in Step 2 show how to derive the correct value from your panel's scan specification.
HUB75 panels are multiplexed: not all rows are lit at once. The matrix panel name usually contains a segment which reads something like -32S-, -16S-, -8S-, etc. as in P3-64x64-32S-V2.0.
Internally, the driver works with the concept of multiplexed rows: this is the number of physical rows that are driven simultaneously for one row address.
The hub75 driver deduces the number of multiplexed rows from the following rule.
multiplexed_rows = matrix_panel_height / 2^rowsel_n_pins
Panel with 64×64 height and width, 1/32 scan (-32S-), 5 Address lines (A, B, C, D, E) -> (2 rows lit)
multiplexed_rows = matrix_panel_height / 2^rowsel_n_pins
$multiplexed_rows = 64 / 2^5 = 64 / 32 = 2
multiplexed_rows = matrix_panel_height / 2^rowsel_n_pins
multiplexed_rows = 32 / 2^4 = 32 / 16 = 2
So, the number of multiplexed lines in both examples is
In both examples you should choose RowMapping::Standard
.panel_kind = RowMapping::Standard,For panels using RowMapping::Split the calculation looks like this (the number of rows can easily be counted on the panel 😊):
multiplexed_rows = matrix_panel_height / 2^rowsel_n_pins
multiplexed_rows = 16 / 2^2 = 16 / 4 = 4
In summary, the number of address lines on this board is
⚠️ The multiplexing value (e.g.RowMapping::Standard) does two things:
it defines how many rows are multiplexed and
selects the corresponding pixel mapping
The same applies to
RowMapping::SplitandRowMapping::S31.
Different panels wire pixels differently internally. This driver provides predefined mapping modes for known layouts.
If unsure:
- start with
RowMapping::Standard - if the image looks scrambled, try another mapping
The examples below cover the most common panel types. For each one, only panel.matrix_panel_width, panel.matrix_panel_height, panel.panel_kind, and pins.rowsel_n_pins need to be set — everything else falls back to the defaults.
// Example for a 64×64 panel (1/32 scan) - 2 rows lit simultaneously
constexpr Hub75Config cfg_64x64_2row{
.panel = {
.matrix_panel_width = 64,
.matrix_panel_height = 64,
.panel_kind = RowMapping::Standard,
},
// Set the number of address lines - 2 rows lit simultaneously leaves 32 rows to be adressed via row select.
// That is 32 = 2 to the power of 5 - we need 5 row select pins
.pins = { .rowsel_n_pins = 5 },
};
// Example for a 64×32 panel (1/16 scan) - 2 rows lit simultaneously
constexpr Hub75Config cfg_64x32_2row{
.panel = {
.matrix_panel_width = 64,
.matrix_panel_height = 32,
.panel_kind = RowMapping::Standard,
},
// Set the number of address lines - 2 rows lit simultaneously leaves 16 rows to be adressed via row select.
// That is 16 equals 2 to the power of 4 - we need 4 row select pins
.pins = { .rowsel_n_pins = 4 },
};
// Example for a 32×16 panel (1/8 scan) - 2 rows lit simultaneously
constexpr Hub75Config cfg_32x16_2row{
.panel = {
.matrix_panel_width = 32,
.matrix_panel_height = 16,
.panel_kind = RowMapping::Standard,
},
// Set the number of address lines - 2 rows lit simultaneously leaves 8 rows to be adressed via row select.
// That is 8 equals 2 to the power of 3 - we need 3 row select pins
.pins = { .rowsel_n_pins = 3 },
};
// Example for a 64×64 panel (1/16 scan) - 4 rows lit simultaneously
constexpr Hub75Config cfg_64x64_4row{
.panel = {
.matrix_panel_width = 64,
.matrix_panel_height = 64,
.panel_kind = RowMapping::S31,
// If ghosting or flicker occurs, try increasing sm_clockdiv_factor (see Step 6)
.sm_clockdiv_factor = 1.0f,
},
// Set the number of address lines - 4 rows lit simultaneously leaves 16 rows to be adressed via row select.
// That is 16 equals = 2 to the power of 4 - we need 4 row select pins
.pins = { .rowsel_n_pins = 4 },
};
// Example for a 32×16 panel (1/4 scan) - 4 rows lit simultaneously
constexpr Hub75Config cfg_32x16_4row{
.panel = {
.matrix_panel_width = 32,
.matrix_panel_height = 16,
.panel_kind = RowMapping::Split,
// If ghosting or flicker occurs, try increasing sm_clockdiv_factor (see Step 6)
.sm_clockdiv_factor = 1.0f,
},
// Set the number of address lines - 4 rows lit simultaneously leaves 4 rows to be adressed via row select.
// That is 4 equals 2 to the power of 2 -> we need 2 row select pins
.pins = { .rowsel_n_pins = 2 },
};Note that the panel name usually does not encode the internal pixel wiring or the driver IC type. These must be determined visually or experimentally. But sometimes the name of the panel gives you a lot of information how the configuration has to be done. Here an example for a P3-64*64-32S-V2.0 panel.
constexpr Hub75Config panel_cfg{
// Width and height are encoded in the panel name
.panel = {
.matrix_panel_width = 64,
.matrix_panel_height = 64,
// The 32S in the panel name refers to (1/32 scan) - 2 rows lit simultaneously
// We can try the standard pixel mapping - maybe we are lucky and the pixel mapping fits
.panel_kind = RowMapping::Standard,
// Look at the back of the panel. If you detect a chip which is labeled RUL6024
// then set the appropriate panel chip
.panel_chip = Hub75PanelChip::RUL6024,
},
// Set the number of address lines - 2 rows lit simultaneously leaves 32 rows to be adressed via row select.
// That is 32 equals 2 to the power of 5 -> we need 5 row select pins (as might be printed on the panel backside - A, B, C, D, E)
.pins = { .rowsel_n_pins = 5 },
};Some panels contain special driver ICs that require an initialization sequence.
enum class Hub75PanelChip
{
GENERIC,
FM6126A,
RUL6024,
};
constexpr Hub75Config panel_cfg{
.panel = {
.panel_chip = Hub75PanelChip::GENERIC,
},
};- Look at the back of the panel
- If you see a chip labeled FM6126A or RUL6024, select it
- Otherwise, use
Hub75PanelChip::GENERIC
Most panels use a non-inverted latch signal, but some boards invert it.
constexpr Hub75Config panel_cfg{
.panel = { .inverted_stb = false },
};If:
- the panel flickers,
- or only updates sporadically,
try:
constexpr Hub75Config panel_cfg{
.panel = { .inverted_stb = true },
};By default, the driver runs the PIO state machine at full speed (sm_clockdiv_factor = 1.0f).
Some panels benefit from a slower clock to reduce:
- ghosting
- flicker
- brightness artifacts
constexpr Hub75Config panel_cfg{
// To prevent flicker or ghosting it might be worth a try to reduce state machine speed.
// For panels with height less or equal to 16 rows try a factor of 8.0f
// For panels with height less or equal to 32 rows try a factor of 2.0f or 4.0f
// Even for panels with height less or equal to 62 rows a factor of about 2.0f might solve such an issue
.panel = { .sm_clockdiv_factor = 1.0f },
};Values below 1.0f have no effect — the driver clamps sm_clockdiv_factor to a minimum of 1.0f internally.
Each panel type has it's own pixel mapping.
HUB75 panels do not accept pixels in simple row-major order.
Instead, pixel data is shifted into the panel in the exact order expected by the panel's internal shift registers and multiplexing logic.
Key properties:
- Pixels are shifted column-wise, not row-wise
- Multiple physical rows are driven simultaneously
- The shift buffer therefore always contains pixels from different vertical regions
- The exact ordering depends on:
- how many rows are multiplexed
- how the panel internally wires its row drivers
Each mapping describes how pixels from the linear source buffer (src) are reordered into the panel's shift buffer (frame_buffer_).
Not all of the demo effects will show correctly for matrix panels with a format different to 64x64 pixels. The first two demo effects use image data for a 64x64 layout. You will see some output, but it will look weird.
The bouncing balls effect will not show the complete text as the position is hard coded. The fire_effect and the rotatormight look as they should be.
Have fun with adapting the source code or with implementing your own effects.
Do not hesitate to contact me - I will gladly answer your questions!
If your panel does not behave as expected, do not change multiple configuration options at once. Most issues can be isolated by checking one dimension at a time.
The sections below are ordered from most common to least common problems.
- Is the panel powered with the correct voltage (usually 5 V)?
- Is the power supply strong enough (HUB75 panels can draw several amps)?
- Is
pins.oen_pinwired correctly and not permanently disabling output?
- Verify
panel.matrix_panel_widthandpanel.matrix_panel_height - Verify
pins.rowsel_n_pinsmatches the number of address pins on the panel (A, B, C, …)
If pins.rowsel_n_pins is too large or too small, no rows will be selected correctly.
This usually indicates a pixel mapping mismatch.
-
Try a different
panel_kindvalue:.panel_kind = RowMapping::Standard, // .panel_kind = RowMapping::Split, // .panel_kind = RowMapping::S31,
| Symptom | Likely cause |
|---|---|
| Completely scrambled image | Wrong panel_kind |
| Image mirrored or interleaved | Wrong internal wiring assumption |
| Repeating blocks or patterns | Mapping partially correct |
💡 If the image is stable but wrong, the scan rate is likely correct and only the mapping needs adjustment.
This usually points to a row addressing issue.
pins.rowsel_n_pins- Panel height (
panel.matrix_panel_height)
multiplexed_rows = matrix_panel_height / 2^rowsel_n_pins
If this value does not match the panel's actual multiplexing, rows will be:
- skipped
- duplicated
- shifted
This is typically a timing issue.
-
Tune
sm_clockdiv_factor:.panel = { .sm_clockdiv_factor = 1.0f }, -
Increase the divider if necessary:
- ≤ 16 rows → try
8.0f - ≤ 32 rows → try
2.0for4.0f
- ≤ 16 rows → try
- Power supply quality
- Cable length and signal integrity
- Ground connection between MCU and panel
This often indicates strobe polarity mismatch.
Try
.panel = { .inverted_stb = true },If the panel suddenly becomes stable, the latch signal is inverted on your board.
-
Panel driver chip type:
.panel_chip = Hub75PanelChip::GENERIC, // .panel_chip = Hub75PanelChip::FM6126A, // .panel_chip = Hub75PanelChip::RUL6024,
If the panel contains an FM6126A or RUL6024 chip and is not initialized correctly:
- brightness may be wrong
- colors may look distorted
- output may be unstable
- Inspect the back of the panel
- Look for chip markings (FM6126A, RUL6024)
Follow this minimal recovery procedure:
-
Use the simplest known-good configuration:
.panel_kind = RowMapping::Standard, // driver default -
Verify:
- correct width and height
- correct
pins.rowsel_n_pins
-
Use a simple test pattern:
- solid colors
- vertical and horizontal stripes
-
Change one parameter at a time
This section lists every LED matrix panel used during development and testing of this library. Each entry documents the panel hardware, its key electrical characteristics, the pixel mapping it requires, and a ready-to-paste Hub75Config configuration block.
💡 Adding your own panel? Use the template at the end of this section. Copy an existing entry whose scan rate and pixel mapping are closest to yours, adjust the values, and open a pull request — contributions welcome!
| # | Panel label | Dimensions | Scan / rows lit | panel_kind |
Driver chip(s) | panel_chip |
|---|---|---|---|---|---|---|
| 1 | P3QD-64x64-21 | 64 × 64 | 1/32 S · 2 rows | RowMapping::Standard |
RUC7258D, FM6124DJ | Hub75PanelChip::GENERIC |
| 2 | P3-64x64-32S-V2.0 | 64 × 64 | 1/32 S · 2 rows | RowMapping::Standard |
RUC7258D, RUL6024 | Hub75PanelChip::RUL6024 |
| 3 | QP3 Outdoor P3-1415 | 64 × 64 | 1/16 S · 4 rows | RowMapping::S31 |
DP5125D | Hub75PanelChip::GENERIC |
| 4 | P10-SMD-16x32-b | 16 × 32 | 1/4 S · 4 rows | RowMapping::Split |
DP5020B | Hub75PanelChip::GENERIC |
| Property | Value |
|---|---|
| Dimensions | 64 × 64 pixels |
| LED pitch | P3 (3 mm) |
| Scan rate | 1/32 S — 2 rows lit simultaneously |
| Address pins | 5 (A, B, C, D, E) |
| Driver ICs | RUC7258D, FM6124DJ |
| Panel type | Indoor |
| BCM mode | Plane-wise |
Standard two-row multiplexing. Upper and lower halves are interleaved column by column.
Use RowMapping::Standard (this is also the default when panel_kind is not set).
constexpr Hub75Config panel_cfg{
.panel = {
.matrix_panel_width = 64, // panel width in pixels
.matrix_panel_height = 64, // panel height in pixels
.panel_kind = RowMapping::Standard,
.panel_chip = Hub75PanelChip::GENERIC,
.inverted_stb = false,
.sm_clockdiv_factor = 1.0f,
.base_latch_ns = 80,
.base_addr_ns = 160,
},
.pins = {
.data_base_pin = 0, // first colour data GPIO (R0)
.data_n_pins = 6, // R0 G0 B0 R1 G1 B1
.rowsel_base_pin = 6, // first address GPIO (A)
.rowsel_n_pins = 5, // A B C D E — 5 pins for 1/32 scan
.clk_pin = 11,
.strobe_pin = 12,
.oen_pin = 13,
},
.color = {
.bitplanes = 10,
.balanced_light_output = true,
.separate_cie_channels = true,
},
.frame_rate_debug = false, // set to true only for debugging
};
using Panel = Hub75Driver<panel_cfg>;HUB75_MULTICORE=true remains a CMakeLists.txt build flag — see
Remaining CMakeLists.txt Build Flags.
💡
RowMapping::Standardis the driver default and does not need to be set explicitly unless you are switching from a different mapping.
| Property | Value |
|---|---|
| Dimensions | 64 × 64 pixels |
| LED pitch | P3 (3 mm) |
| Scan rate | 1/32 S — 2 rows lit simultaneously |
| Address pins | 5 (A, B, C, D, E) |
| Driver ICs | RUC7258D, RUL6024 |
| Panel type | Indoor |
| BCM mode | Plane-wise |
Same two-row multiplexing as board 1. The difference is the RUL6024 driver IC, which
requires a dedicated initialisation sequence — set panel_chip = Hub75PanelChip::RUL6024.
Forgetting this causes incorrect brightness or distorted colours even though the pixel
mapping itself is identical to Hub75PanelChip::GENERIC panels.
⚠️ How to identify the RUL6024: Inspect the back of the panel. The chip is typically the largest IC on the PCB and is markedRUL6024. If in doubt, start withHub75PanelChip::GENERIC; the worst outcome is incorrect brightness, not panel damage.
constexpr Hub75Config panel_cfg{
.panel = {
.matrix_panel_width = 64,
.matrix_panel_height = 64,
.panel_kind = RowMapping::Standard,
.panel_chip = Hub75PanelChip::RUL6024, // ← required for RUL6024 init sequence
.inverted_stb = false,
.sm_clockdiv_factor = 1.0f,
.base_latch_ns = 80,
.base_addr_ns = 160,
},
.pins = {
.data_base_pin = 0,
.data_n_pins = 6,
.rowsel_base_pin = 6,
.rowsel_n_pins = 5, // A B C D E — 5 pins for 1/32 scan
.clk_pin = 11,
.strobe_pin = 12,
.oen_pin = 13,
},
.color = {
.bitplanes = 10,
.balanced_light_output = true,
.separate_cie_channels = true,
},
.frame_rate_debug = false,
};
using Panel = Hub75Driver<panel_cfg>;Standard addressing (ROW_MAPPING=ROW_MAP_STANDARD) is the simpler of the two schemes and the one most indoor HUB75 panels use.
Each row-select address lights exactly ROWS_IN_PARALLEL rows at once — 2 for the common case — and the panel's full width is written in a single continuous pass,
with no column splitting. This section uses a 64×32 panel at 1:16 scan (ROWSEL_N_PINS=4, 16 addresses, ROWS_IN_PARALLEL=2) as a concrete example
- scale the numbers to your own
MATRIX_PANEL_WIDTH/MATRIX_PANEL_HEIGHT/ROWSEL_N_PINS.
Each row-select address drives exactly 2 rows, SCAN_DEPTH apart.
| Address | Rows driven simultaneously |
|---|---|
| 0 | 0, 16 |
| 1 | 1, 17 |
| ⋮ | ⋮ |
| 15 | 15, 31 |
In general: address = row % SCAN_DEPTH, and the rows for address A are A + p * rows_per_bank for p = 0 .. ROWS_IN_PARALLEL-1, where
rows_per_bank = SCAN_DEPTH and ROWS_IN_PARALLEL = MATRIX_PANEL_HEIGHT / SCAN_DEPTH. Most panels use ROWS_IN_PARALLEL=2 (one address, top half + bottom half paired),
but the driver supports more banks if your panel needs them.
Unlike ROW_MAP_SPLIT, a single address's data is not split into column bands. The driver sweeps the entire MATRIX_PANEL_WIDTH in one
continuous left-to-right pass — but what actually goes out on the wire for each column isn't a single pixel, it's two whole RGB triplets
interleaved into one word: R0 G0 B0 for the top row of the pair, immediately followed by R1 G1 B1 for its paired row SCAN_DEPTH rows below.
That six-value word is what gets shifted out on a single CLK pulse, onto the six consecutive GPIO pins starting at DATA_BASE_PIN
(DATA_N_PINS=6) — then the sweep advances to the next column and repeats.
So for address 0 in the example above, the actual bytes that stream out, column by column, look like:
col 0: R0 G0 B0 R1 G1 B1 (row 0's pixel, then row 16's pixel)
col 1: R0 G0 B0 R1 G1 B1
col 2: R0 G0 B0 R1 G1 B1
...
col 63: R0 G0 B0 R1 G1 B1
— 64 six-value words, one per CLK pulse, before STROBE/LATCH fires and the row-select address advances.
ROWS_IN_PARALLEL = MATRIX_PANEL_HEIGHT / SCAN_DEPTH // typically 2
rows_per_bank = SCAN_DEPTH // step between paired rows
// for row-select address `row`, bank `p` (0 .. ROWS_IN_PARALLEL-1):
dy = row + p * rows_per_bank
// column sweep — single continuous pass, no splitting. Each iteration of
// the inner loop shifts out one full R,G,B triplet for bank p's pixel at
// this column; with ROWS_IN_PARALLEL=2 that's R0 G0 B0 R1 G1 B1 per CLK,
// on the DATA_N_PINS consecutive pins starting at DATA_BASE_PIN:
for i in 0 .. MATRIX_PANEL_WIDTH-1: // one CLK pulse per iteration
for p in 0 .. ROWS_IN_PARALLEL-1: // R,G,B triplet per bank, same CLK
shift_out(R, G, B) = pixel(column = i, row = dy)For a given address, each panel's full width is written contiguously before the stream moves to the next panel in the chain. Serpentine
(CHAIN_MODE_SERPENTINE) 180° rows reverse the column sweep direction (i counts down instead of up) and negate the bank offset
(dy = dy_base - p * rows_per_bank) so the source content comes out right-side-up — the row-select address progression itself is never
reversed, since that's a hardware timing signal shared by the whole chain.
ROW_MAP_STANDARD |
ROW_MAP_SPLIT |
|
|---|---|---|
| Rows per address | ROWS_IN_PARALLEL (typically 2) |
4 |
| Row spacing | Single fixed step (SCAN_DEPTH) |
Two nested steps (ROWS_PER_GROUP and MATRIX_PANEL_HEIGHT/2) |
| Row sweep | One continuous pass, full width | Split into 4 column octants per address |
| Typical panels | Most indoor panels | P10-style outdoor panels with split upper/lower-half addressing |
See the ROW_MAP_SPLIT section above for the corresponding schematics on that scheme.
| Property | Value |
|---|---|
| Dimensions | 64 × 64 pixels |
| LED pitch | P3 / 1415 (1.4 mm) |
| Scan rate | 1/16 S — 4 rows lit simultaneously |
| Address pins | 4 (A, B, C, D) |
| Driver ICs | DP5125D · also compatible: MBI5253, ICND2055, ICND2065, ICND2153S, CFD325, MBI5264, CFD555, ICND2165 |
| Panel type | Outdoor (weatherproof, high brightness) |
| BCM mode | Plane-wise |
This panel uses a non-standard four-row multiplexing layout. The 64 rows are divided
into four equal quarters; each scan cycle drives one row from each quarter
simultaneously. The shift buffer is filled in a two-pass pattern per scan line:
second and fourth quarter pixels first (even output slots), then first and third quarter
pixels (odd output slots, offset by 2 × matrix_panel_width).
Set panel_kind = RowMapping::S31. Do not use RowMapping::Standard for this
panel — despite what the -16S- label might suggest for a 64-row panel, this is a
4-row simultaneous design, not 2-row.
⚠️ Tested with RP2350B (GPIO 30–43). The example below uses the RP2350B pin mapping. For a standard Pico / RP2040 substitutedata_base_pin = 0,rowsel_base_pin = 6,clk_pin = 11,strobe_pin = 12,oen_pin = 13and remove thePICO_RP2350A=0build flag.
For bare RP2350 (no named board), add these two lines before
include(pico_sdk_import.cmake) in CMakeLists.txt:
set(PICO_PLATFORM rp2350)
set(PICO_BOARD none CACHE STRING "Board type")The remaining build-system flags:
target_compile_definitions(hub75_demo PRIVATE
PICO_RP2350A=0 # RP2350B — omit for RP2350A or RP2040
USE_PICO_GRAPHICS=true # false = use hub75 as a pure library
HUB75_MULTICORE=true
)Then the panel configuration in code:
constexpr Hub75Config panel_cfg{
.panel = {
.matrix_panel_width = 64,
.matrix_panel_height = 64,
.panel_kind = RowMapping::S31, // non-standard 4-row pixel mapping
.panel_chip = Hub75PanelChip::GENERIC,
.inverted_stb = false,
.sm_clockdiv_factor = 2.75f, // recommended starting value; reduce if too slow
.base_latch_ns = 100, // slightly wider timing margins for outdoor panel
.base_addr_ns = 260,
},
.pins = {
.data_base_pin = 30, // RP2350B GPIO block starts at 30
.data_n_pins = 6,
.rowsel_base_pin = 36, // RP2350B address pins start at 36
.rowsel_n_pins = 4, // A B C D — 4 pins for 1/16 scan, 4 rows lit
.clk_pin = 41,
.strobe_pin = 42,
.oen_pin = 43,
},
.color = {
.bitplanes = 10,
.balanced_light_output = true,
.separate_cie_channels = true,
},
.frame_rate_debug = false, // set to true only for debugging
};
using Panel = Hub75Driver<panel_cfg>;Recommended system clock: 266 MHz (set_sys_clock_khz(266000, true) in main.c).
💡 If ghosting or flicker appears, try increasing
sm_clockdiv_factorin steps of 0.25 (e.g.3.0f,3.5f). Panels from different batches of the same model sometimes tolerate slightly different timing.
| Property | Value |
|---|---|
| Dimensions | 16 × 32 pixels |
| LED pitch | P10 / 3535 (10 mm) |
| Scan rate | 1/4 S — 4 rows lit simultaneously |
| Address pins | 2 (A, B) |
| Driver ICs | DP5020B |
| Panel type | Outdoor (weatherproof, very high brightness) |
| BCM mode | Line-wise |
Four-row multiplexing with a column-pair interleave scheme. The 16 rows are divided into four vertical quarters of 4 rows each. Pixels are placed into the shift buffer in column-pair groups; a selector bit derived from the panel width determines whether a pair slot draws from the first or second half of the column pairs within the current scan group.
Set panel_kind = RowMapping::Split. Note that although both this panel and board 3 light
4 rows simultaneously, their internal wiring conventions differ — RowMapping::Split and
RowMapping::S31 are not interchangeable.
constexpr Hub75Config panel_cfg{
.panel = {
.matrix_panel_width = 32,
.matrix_panel_height = 16,
.panel_kind = RowMapping::Split, // 4-row outdoor panel pixel mapping
.panel_chip = Hub75PanelChip::GENERIC,
.inverted_stb = false,
.sm_clockdiv_factor = 1.0f, // increase to 2.0f or 4.0f if ghosting occurs
.base_latch_ns = 80,
.base_addr_ns = 160,
},
.pins = {
.data_base_pin = 0,
.data_n_pins = 6,
.rowsel_base_pin = 6,
.rowsel_n_pins = 2, // A B only — 2 pins for 1/4 scan, 4 rows lit
.clk_pin = 11,
.strobe_pin = 12,
.oen_pin = 13,
},
.color = {
.bitplanes = 10,
.balanced_light_output = true,
.separate_cie_channels = true,
},
.frame_rate_debug = false,
};
using Panel = Hub75Driver<panel_cfg>;💡 P10 outdoor panels are electrically robust and typically tolerate a wide range of clock divider settings. Start at
sm_clockdiv_factor = 1.0fand only adjust if you observe ghosting on bright content.
When adding a new panel to this section, copy the block below, fill in every field, and add a row to the overview table at the top of this section.
## N. <Panel label / model number>
### Hardware
| Property | Value |
|---|---|
| Dimensions | W × H pixels |
| LED pitch | Pn (n mm) |
| Scan rate | 1/nS — **n rows lit simultaneously** |
| Address pins | n (A, B, …) |
| Driver ICs | <chip name(s)> |
| Panel type | Indoor / Outdoor |
| BCM mode | Plane-wise / Line-wise |
### Pixel Mapping
<Brief description of why this mapping applies and any gotchas.>
Use `panel_kind = <RowMapping::Standard | RowMapping::Split | RowMapping::S31>`.
### Configuration
```cpp
constexpr Hub75Config panel_cfg{
.panel = {
.matrix_panel_width = <W>,
.matrix_panel_height = <H>,
.panel_kind = <row_mapping>, // pixel mapping topology — see Step 3
.panel_chip = <Hub75PanelChip::GENERIC | FM6126A | RUL6024>,
.inverted_stb = false,
.sm_clockdiv_factor = 1.0f,
.base_latch_ns = 80,
.base_addr_ns = 160,
},
.pins = {
.data_base_pin = <pin>,
.data_n_pins = 6,
.rowsel_base_pin = <pin>,
.rowsel_n_pins = <n>, // log₂(H / rows_lit_simultaneously)
.clk_pin = <pin>,
.strobe_pin = <pin>,
.oen_pin = <pin>,
},
.color = {
.bitplanes = 10,
.balanced_light_output = true,
.separate_cie_channels = true,
},
.frame_rate_debug = false,
};
using Panel = Hub75Driver<panel_cfg>;Add any panel-specific notes, tested MCU, system clock, or known quirks here.