NeoPXL8 addresses the NeoPixel bandwidth issue, improving frame rates for large LED installations and freeing up more processor time for creating “next level” animation. With more potent microcontrollers now in the form of the RP2040 and ESP32-S3, suddenly we can go next-next-level. BEAST MODE!
The NeoPXL8 library includes an extra class called NeoPXL8HDR (“high dynamic range”). You don’t need to install an additional library for this, it tags along with the NeoPXL8 installation.
NeoPXL8HDR works a lot like NeoPXL8—in fact the same Arduino sketches can be used as a starting point—but then brings bonus cake:
- Temporal dithering provides more intermediate shades—thousands of brightness levels rather than 256.
- 16-bit color components for nuanced animation; each pixel now allows colors in 48-bit (RGB) or 64-bit (RGBW) formats.
- Gamma correction provides perceptually-linear brightness ramps from the pixels—it’s built into the library, you no longer need this in your code.
- Frame-to-frame blending creates smooth transitions even when animation frame rates are low.
NeoPXL8HDR works best with a Feather RP2040 or ESP32-S3 (or other RP2040 or ESP32-S3 board, with suitable level shifting). With two processor cores, one can be dedicated fully to NeoPXL8HDR work. Though it can run on an M4 board, the results are less satisfying (CPU time must be split between user code and the library). Other chips, even the M0 that works so well with “classic” NeoPXL8, are right out, the HDR additions are just too demanding.
Tempering Expectations
NeoPixels still use 8-bit brightness levels, this doesn’t magically make them 16-bit. The illusion is done with temporal dithering, quickly alternating the LEDs between two different levels. The effect is noticeable, and one must ride a fine line between just noticeable and distracting.
Additionally, even the dithered output isn’t fully 16-bit. It produces 12 bits by default, and with shorter pixel runs you might manage 13 bits. This is configurable so you can find a balance between precision and distraction. The important idea is that to your code everything appears 16-bit. The last-step dither reduction is fully handled by the library and does not complicate your task.
If your project doesn’t require HDR features, use the much-loved NeoPXL8 class! The HDR class consumes inordinate RAM and resources, and it will not automatically make anything look better; one must specifically code to its strengths. Scrolling text? NeoPXL8. Super smooth plasma flame effects? NeoPXL8HDR.
NeoPXL8HDR relies on frequent refreshes to all the NeoPixel strips. There’s an associated bandwidth bottleneck to this, ultimately limiting how many pixels can be controlled while maintaining a sufficient refresh rate. This is subjective and there is no hard limit…but around 1,000 pixels (split 8 ways, and give or take a bit) is a sensible ballpark guideline.
Basic Use
If you’ve never used either class, read through the NeoPXL8 Arduino Library page first and play with the strandtest example. Get something working on actual hardware and familiarize yourself the concepts and constraints there.
Then, graduating to HDR, let’s look at the strandtest_hdr example included with the NeoPXL8 library, comparing it against the simpler non-HDR strandtest. Open both side-by side in the Arduino IDE; we’ll just highlight certain sections here.
The two start out very similar. #include the header file, declare a list of pins. Even the constructor accepts the same arguments, it just has a different name for the HDR vs. traditional NeoPXL8 varieties.
Adafruit_NeoPXL8HDR leds(NUM_LED, pins, NEO_GRB);
Immediately things get strange in the HDR code, where you can see it compiles different code for RP2040 vs. ESP32-S3 vs. M4 (SAMD51) microcontrollers.
A new class function—refresh()
—is introduced. In NeoPixel or NeoPXL8 code, you call the show()
function to transmit new color data to the LEDs, done. In NeoPXL8HDR, show()
hands off new color data to the library, but doesn’t actually update the LEDs. That’s now the task of refresh()
, which must be called frequently to perform temporal dithering and all the other niceties.
On RP2040, we put a refresh()
call inside the loop1()
function, which runs again and again on the chip’s second core, as fast as it can go. This requires installing the Earle Philhower RP2040 package in the Arduino Boards Manager, if you haven’t already.
On ESP32-S3, refresh()
is called in a tight loop in the loop0()
function, which the setup()
code pins as a separate task on core 0. Arduino code…the animation logic in this case…always runs on core 1.
On the M4 board, it’s necessary to set up a timer interrupt (via the Adafruit_ZeroTimer library) to periodically call refresh()
. You can see some extra steps needed both above and inside setup()
.
If you know for a fact that your own project will only ever use one chip or the other, you can trim the extraneous code. If sharing code with others as an open source project, it’s more neighborly to support all the different chips.
Once inside setup()
, each class’ begin()
function is a little different. NeoPXL8 accepts no arguments, it just runs one specific way. NeoPXL8HDR adds some flair:
bool status = leds.begin(true, 4, true);
The first argument selects whether frame-to-frame blending should be enabled. If you can’t generate frequent frames for animation, the library will provide some interpolation for you. This requires an extra 6 (RGB) or 8 (RGBW) bytes of RAM per pixel, atop the library’s already voracious needs. This 512-pixel example works fine with it, so it’s set true
.
Second argument establishes the temporal dithering resolution. NeoPixels normally provide 256 brightness levels, or 8 bits. The 4
here bumps this up to 12 bits (effectively, but not actually, the extra being dithered). Long chains of NeoPixels are slow to update and can’t use a lot of dithering…so you can dial this down (or up for short strips) to balance refresh rate with effective color range. Valid range is 0 (no dithering) to 8 (maximum dithering, but probably too infrequent to be useful).
Third argument is whether to enable double-buffering in the underlying NeoPXL8 library, freeing up the CPU to start on the next refresh sooner. Again, uses more RAM, but this 512-pixel demo is not a problem. This argument is currently ignored on SAMD, only the RP2040 provides this, but it’s present for code compatibility.
begin()
returns a status code: true
if it was able to allocate the required RAM, false
if not. The example sketch ignores this for brevity because we know it fits, but well-behaved neighborly code may want to respond appropriately (perhaps printing a message to the Serial console and/or blinking a board’s built-in LED).
Both examples then call setBrightness()
, but NeoPXL8HDR works a little differently:
leds.setBrightness(65535 / 8, 2.6);
The first argument is a peak brightness level from 0 to 65535 (vs. 0–255 in NeoPixel and NeoPXL8). Everything sent to the LEDs will be scaled in proportion, this is just the top value. It’s set to 65535 / 8
here (instead of 8191
) just as a fancy and hopefully more readable way of saying “one eighth of the maximum brightness,” but the two are equivalent.
Second is a gamma correction factor as a floating-point value. This is a topic extensively covered in other guides already, but the basic idea is to make “in-between” colors more perceptually linear (because 50% duty cycle doesn’t look 50% as bright). A value of 2.6
has worked well for us over the years. It’s subjective though, you can try more or less.
The default gamma value if left un-set is 1.0; linear brightness values have a linear effect on duty cycle, which is not perceptually linear. This is on purpose and by design, so that any NeoPixel or NeoPXL8 code brought over to HDR doesn’t suddenly yield surprises; it will look more or less the same until you start adapting your code. Notice the NeoPXL8 strandtest code performs its own gamma correction on every pixel, while strandtest_hdr doesn’t have to.
The remainder of these two examples—strandtest and strandtest_hdr—is then very similar, with the exception mentioned above that hdr doesn’t need to gamma-correct every pixel, it’s done automatically. Both produce eight rows of colored “rain,” but the HDR version looks better especially among the lower brightness levels. Also they’re both using 8-bit color values despite HDR’s support for 16-bit. More on that later.
Finer setBrightness()
Details
First, please, I must reiterate a point from other NeoPixel projects and documentation: setBrightness()
never was and never will be intended as an animation effect in itself. Configure it once at startup and leave it be…in fact you may get flickering in NeoPXL8HDR if using setBrightness()
“live.” Animated fades should be rendered in code, perhaps with the NeoPixel fill()
function.
If called with a single argument (no gamma value), setBrightness()
works just like the original NeoPixel/NeoPXL8 version: the brightness value is 8-bit (0-255) and the library will scale it up. This way, old code will work just as it did before. Only when specifying a gamma value, or in more cases listed below, is the brightness level 16 bits.
“Brightness,” in the context of setBrightness()
, is not a perceptual brightness, but rather a duty cycle. So even if a gamma value is specified, this doesn’t apply to the brightness value. Requesting 32767 (or 127 for the 8-bit variant above) yields a peak brightness with about a 50% duty cycle, which will appear more than half as bright. This is normal and by design, again for maintaining “classic” code behavior.
In addition to these two formats:
setBrightness(0-255); setBrightness(0-65535, float gamma);
NeoPXL8HDR offers the ability to set peak red, green, blue and white (for RGBW pixels) independently, which can be used to improve color balance (folks often find that NeoPixels appear a bit blue-tinted when all elements are equally lit):
setBrightness(red, green, blue); setBrightness(red, green, blue, gamma); setBrightness(red, green, blue, white); setBrightness(red, green, blue, white, gamma);
The red, green, blue (and white) values are always 16-bit (0-65535) with these syntaxes; there’s no 8-bit mode, no need for back-compatibility since classic NeoPixel doesn’t offer this. Gamma is always floating-point, 1.0
is linear, and 2.6
works well in practice.
Another reason for defaulting to linear gamma (rather than imposing a value like 2.6) is that some users may want to provide their own more sophisticated color-correction functions. Leave gamma at 1.0 and then use the 16-bit pixel setting functions…
setPixelColor(pixel, r, g, b); setPixelColor(pixel, 0xXXXXXXXX); unsigned long x = getPixel(pixel);
…plus the Color()
and ColorHSV()
functions for “packing” RGB(W) values and working with HSV colors.
All of these functions work as before and use 8-bit (0-255) brightness values. NeoPXL8HDR will upscale these numbers to the 16-bit range (or decimate back down to 8 bits in the getPixel()
case).
To work with the full range of 16-bit values, a different set of functions must be used…
set16(pixel, r, g, b); set16(pixel, r, g, b, w); get16(pixel, &r, &g, &b, &w);
In each case, pixel
is a 16-bit pixel index (0–65535) just like before. For the two set functions, r, g, b (and w if present) are now also 16-bit values (0–65535). There is no “packed” set16()
function as we don’t have 64-bit types just yet.
The rgb(w) arguments to get16()
are pointers to 16-bit variables (unsigned short
or uint16_t
). w can be NULL
if it’s not used, but the others must point to valid destinations.
Adafruit_NeoPixel provides a getPixels()
function which returns a pointer straight into the buffer where 8-bit colors are stored. High-performance code can bypass setPixelColor()
and work with the pixel buffer directly, with all the risks that entails.
The Adafruit_NeoPXL8HDR class also provides a getPixels()
function, but the color values are 16-bit (function returns a uint16_t *
). One perk is that the values here are always in RGB or RGBW order, no need to reorder for different pixel vintages. Same risks still apply though, like anything with pointers, you can clobber things if you go out of range.
Page last edited March 08, 2024
Text editor powered by tinymce.