Seeking advice on very gentle fade to off - Sunset Animation

Hi,

I’m attempting to add an annular sunset animation to a planetarium, but I’m a little out of my depth.

The idea is to have this ring of 630 (x3) LEDs around the room run through some soothing sunset colors and drop to zero light over the course of about two minutes.

With a bare bones example that goes Yellow light → fade to darkness, everything works well until the last ten steps or so where the light level in the room appears to go “KaCHUNK!” “KaCHUNK!” in big jumps because there are a lot of LEDs and they’re the only light in the room during this animation.

I’m betting that the level can be smoothed out a bit by only dipping one or a few LEDs at a time, instead of the entire ring, but getting some sort of “random selection until every LED has dropped one level” is escaping me.

I’d appreciate any advice or input. Thanks!

What I’m playing with from one of the demo scripts:

seconds = 0
delta_acc = 0
running = 0
duration = 120
pixel_h_step = array(pixelCount)
pixel_h_acc = array(pixelCount)
progress = 0

export function triggerBeginSunset(){
  running = 1
  seconds = 0
  delta_acc = 0
  progress = 0
  for (i = 0; i < pixelCount; i++) {
    pixel_h_step[i] = random(0.002) - 0.001
    pixel_h_acc[i] = 0
  }
}

export function triggerReset() {
  running = 0
}

export function inputNumberDuration(s) {
  duration = s
}

export function beforeRender(delta) {
  delta_acc += delta
  while (delta_acc > 1000) {
    delta_acc -= 1000
    seconds += 1
    for (i = 0; i < pixelCount; i++) {
      pixel_h_acc[i] += pixel_h_step[i]
      //pixel_h[i] = clamp(pixel_h[i], -0.05, 0.05)
    }
  }
  progress = ( (seconds / duration) + ((delta_acc/1000)/duration) )
  t1 = time(.25)
  t2 = time(.15)
}

export function render(index) {
  if (running) {
    
    //h = 0.06 + pixel_h_acc[index]
    
    shift = (0.02 * progress)
    shift = shift * shift
    h = 0.06 - shift
    //h = 0.06
    //s = 1 - (s * s * s * s * s)/2
    s = 1
    
    v = clamp(1 - progress, 0, 1)
    
    hsv(h, s, v)
    if (v == 0) {
      triggerReset()
      v = 1
    }
  } else {
    rgb(0,0,0) 
  }
}

Hey @ctag! Welcome to the forum, and congrats on putting up such a functional first pattern here.

Are you possibly using WS2XXX LEDs? They have a much worse dim-to-black curve (especially on the low end) than the APA102/SK9822/GS8208 strips. Let’s assume that changing out your LEDs isn’t an option.

Temporal dithering (turning a color on and off quickly to create additional dimming steps) probably isn’t an option with 630 LEDs.

I like your proposed solution of trying to be sure to only dim one at a time. Just also beware that your color accuracy is going to fall apart on the lowest intensities. Say you’re on WS2812B LEDs and each of R, G, and B has 8 bit values. If R = 4/256, and G = 2/256, no matter which color is reduced next (R = 3/256 or G=1/256), you’re going to see a big jump in the final mixed hue.

While there’s a lot of ways to do this, here’s an approach I might take, chosen mostly for it’s simplicity in explaining:

  1. Randomize all 630 LED indices in an array (see Fisher-Yates in JS)
  2. Reduce their intensity by some amount in that order (*= .7 or -= .001 or something)
  3. Repeat after some number of milliseconds

There’s a lot of nuance possible, like whether you reduce higher existing intensities more, or how you decide on your timing between subsequent reductions.

Seeing the code you’ve already produced, it seems like this is the right level of detail to leave the fin parts to you! But if you get stuck on the implementation of the above, let me know. I’m in a week where I’ll have extra time and energy to help out and write some code.

3 Likes

@jeff Thank you for the assistance!

Yes, they’re WS2811 strips. Oh well. That’s what I get for diving head first into this.

I made it back up the mountain today and got a very minimal test of that randomization going. The jarring steps in brightness are gone!

Next visit I’ll start tweaking it to look and act more like a sunset, and hopefully we’ll be using it for our star tours soon!

3 Likes

Circling back around to this, I’m interested in possibly changing out the LEDs, and would be glad to get some feedback on what to go with. I plan to buy 5m to test with, and if they work well do the full 35 meters.

Looking here it seems that the GS8208 is an easy choice for being 12V, but if the other variants outperform in low-light fading, without much need for fast animations, I’d consider replacing the 12V power supplies as well.

And from reading here it looks like the SK9822 may be a better/cheaper choice than the APA102s for my applications.

The gs8208 is much better compared to WS2812 and the like, but still nowhere near the range of the sk9822/apa102.

If the WS2812 is close, the gs8208 might be enough, and let you keep your 12V setup. The GS8208 has built in gamma correction which will change the appearance of colors compared to other LEDs that work on a linear scale.

You might want to test both for your setup.

True APA102 are very hard to find these days, and pretty much anywhere selling APA102 is actually selling SK9822.

SK9822 do color shift slightly towards green when working at dim levels, but maintain a high PWM rate (no flicker). True APA102 drop from 20khz PWM down to about 400Hz when working with low levels via HDR, but maintain color balance.

2 Likes

4 months ago
Circling back around to this

If I say I’m circling back again, does that mean I’m spiraling? :sweat_smile:

A member at the planetarium wrote in for a grant to finish the LED lighting upgrade, and it got awarded! We’ve got a couple hundred dollars to get things driven to done.

I really appreciate the help so far. Wizard’s LED comparison has been rattling around in my head for a bit now. Since the current LEDs already aren’t quite as bright as I’d like them to be, it seemed like we could either try tacking on additional 12V strips in parallel or go with 5V ones and rig up addition 5V PSUs in the attic. But doubling up the 12V strips would overload the 150W PSUs we already have; or at least it would exceed the maximum current on paper.

My plan right now is to buy a strip of both SK9822 and GS8208 and put them through their paces. I’m looking at the two links below, but would certainly appreciate any input on other outlets to buy from.

GS8208: GS8208 12V Individually Addressable RGB LED Strip
SK9822: SK9822(Similar to APA102) RGB 60LEDS/M DC5V 12MM-Wide Digital Intelligent Addressable LED Strip Lights - 5m/16.4ft per roll [SK9822-60W10] - $37.98 :

I’d also like to double check my understanding of Pixelblaze. The unit itself (v3 std) can only support one type of LED strip at a time, but if I add an expansion board, it could run two different strips around the dome? And I wouldn’t have to use dual pixelblazes with network sync?

Correct – with the expansion board (either pro or basic), you can specify strip type on a per output basis.

1 Like

Well, we’re still waiting for the grant money to arrive. In the meantime I learned about render2D, and there’s a chance we don’t need to add another ring of LEDs to get this sunset animation going.

I tried starting with something simple, mixing a hue of yellow to red across the “sky” and then trailing it with a mix to fade out pixel value. I think it turned out OK, and since it isn’t lowering a lot of pixels at once, and is dimming in red and not yellow, there’s not very much stuttering at low light levels!

Here’s a 10x sped up video of it. The colors aren’t captured very well, but you can get an idea of the fading across the planetarium dome toward the west.

I tried adding multiple “modes” to the same script, so that I can work toward having them blend nicely when changing from “color bands” to “sunset” and the like. Here’s what I have so far:

/*
 *==============================================================================
 *    Global Variables
 *==============================================================================
*/

running = 0 // Is sequence running? Running 0 -> Sunset frozen!

sec = 0 // Seconds
ms = 0 // Milliseconds

export var v_clamp = 0.0
duration_fade = 5.0
duration_sunset = 90.0

SUNSET_BEGIN_H = 0.05

// Variables to control the pixels
ph = array(pixelCount) // Current Pixel Hue
ps = array(pixelCount) // Current Pixel Saturation
pv = array(pixelCount) // Current Pixel Value

/*
 *==============================================================================
 *    Utility Functions
 *==============================================================================
*/
 
function calcVaccum() {
  v_accum = 0
  for (i = pixelCount - 1; i > 0; i-=10) {
    v_accum += pv[i]
  }
}

function resetTime() {
  sec = 0
  ms = 0
}

function fade_in() {
  if (v_clamp < 1.0) {
    v_clamp = clamp( (sec+(ms/1000)) / 4.0, 0, 1)
  }
}

/*
 *==============================================================================
 *    State Machine
 *==============================================================================
*/

STATE_OFF = 0
STATE_STEADY = 1
STATE_PROJECTORS = 2
STATE_SUNSET = 3

currentState = STATE_OFF
state = array(4)

/*
 *==============================================================================
 *    UI Functions
 *==============================================================================
*/

export function togglePaused(input) {
  running = !input
}

export function triggerSunsetInit() {
  resetTime()
  // Initialize array of indexes and array of values
  for (i = pixelCount - 1; i >= 0; i--)
  {
    ph[i] = SUNSET_BEGIN_H
    ps[i] = 1.00
    pv[i] = 1.00
  }
  currentState = STATE_STEADY
}

export function triggerBeginSunset(){
  resetTime()
  currentState = STATE_SUNSET
}

export function triggerRed() {
  resetTime()
  for (i = pixelCount - 1; i >= 0; i--)
  {
    ph[i] = 0.001
    ps[i] = 1.0
    pv[i] = 1.0
  }
  currentState = STATE_STEADY
}

export function triggerProjectors() {
  resetTime()
  currentState = STATE_PROJECTORS
}

export function triggerSteady() {
  resetTime()
  currentState = STATE_STEADY
}

export function triggerOff() {
  resetTime()
  currentState = STATE_OFF
}

/*
 *==============================================================================
 *    Render Functions
 *==============================================================================
*/

export function beforeRender(delta) {
  ms += delta
  while (ms > 1000) {
    ms -= 1000
    sec += 1
  }
  
  //progress = ( (seconds / duration) + ((delta_acc/1000)/duration) )
  
  t1 = time(.25)
  t2 = time(.15)
}

state[STATE_OFF] = (index, x, y) => {
  if (v_clamp > 0.0) {
    v_clamp = 1 - clamp( ((sec*1000)+ms) / 2000, 0, 1)
  } else {
    pv[index] = 0
  }
}

state[STATE_STEADY] = (index, x, y) => {
  fade_in()
}

state[STATE_PROJECTORS] = (index, x, y) => {
  fade_in()
  h = index / (pixelCount / 2) // Notice how each hue appears twice
  
  // Create the areas where white is mixed in. Start with a wave.
  s = wave(-index / 3 + t1)
  
  // A little desaturation goes a long way, so it's typical to start from 1 
  // (saturated) and sharply dip to 0 to make white areas.
  s = 1 - s * s * s * s
  
  // Create the slowly moving dark regions
  v = wave(index / 2 + t2) * wave(index / 5 - t2) + wave(index / 7 + t2)

  if ((index < 510 && index > 410) || (index < 300 && index > 200) || (index < 60 || index > 600)) {  
    v = 0.05
  } else {
    v = v * v * v * v
  }

  ph[index] = h
  ps[index] = s
  pv[index] = v
}

state[STATE_SUNSET] = (index, x, y) => {
  t = (sec+(ms/1000))
  d_half = (duration_sunset/2)
  d_third = (duration_sunset/3)
  
  if (t > duration_sunset+1) {
    v_clamp = 0
    triggerOff()
  }
  
  ph[index] = mix(0.00, SUNSET_BEGIN_H, clamp(y+((-t+d_half)/d_half), 0, 1) )
  
  ratio_v = 1.0
  if (t > d_third) {
    // ratio_v = 1.0 - clamp( ((t - d_third) / duration_sunset), 0, 1)
    ratio_v = mix(0.00, 1.0, clamp( y+ (( -(t-d_third)+d_third) / d_third), 0, 1) )
  }
  
  pv[index] = pow(ratio_v, 2)
  ps[index] = 1.0
}

// export function render(index) {
//   state[currentState](index);
  
//   // Clamp for fading in/out
//   v = clamp(pv[index], 0, v_clamp * v_clamp)
  
//   // Set this pixel's Value
//   hsv(ph[index], ps[index], v)
// }

export function render2D(index, x, y) {
  state[currentState](index, x, y);
  
  // Clamp for fading in/out
  v = clamp(pv[index], 0, v_clamp * v_clamp)
  
  // Set this pixel's Value
  hsv(ph[index], ps[index], v)
}
4 Likes