|
| 1 | +/* |
| 2 | + Single-strand NeoPXL8 demo (pulses + twinkles) |
| 3 | + - Pulses: NO OVERLAP by position rule; spawn next after newest rear edge |
| 4 | + passes a random distance from the start (min MIN_PULSE_GAP_PX) |
| 5 | + - Smooth rendering: float accumulation + gamma + light temporal dithering |
| 6 | + - Twinkles: white channel, fixed rate; relocate each cycle, slow rise/fall |
| 7 | +*/ |
| 8 | + |
| 9 | +#include <Adafruit_NeoPXL8.h> |
| 10 | +#include <math.h> |
| 11 | +#include <vector> |
| 12 | + |
| 13 | +// ======== USER TUNABLES ======== |
| 14 | +#define STRAND_LEN 144 |
| 15 | +#define COLOR_ORDER NEO_GRBW |
| 16 | +#define DATA_PIN 16 |
| 17 | + |
| 18 | +// Pulse motion |
| 19 | +const float PULSE_SPEED = 4.0f; // px/sec |
| 20 | + |
| 21 | +// Pulse size (HALF-length randomized); visible total ≈ 2*halfLen |
| 22 | +const float PULSE_HALFLEN_MIN_PX = 1.0f; |
| 23 | +const float PULSE_HALFLEN_MAX_PX = 7.0f; |
| 24 | + |
| 25 | +// Spacing rule: spawn next pulse when the newest pulse's REAR edge (leftmost) |
| 26 | +// has traveled at least: MIN_PULSE_GAP_PX + random(0..EXTRA_GAP_SPREAD_PX) |
| 27 | +const float MIN_PULSE_GAP_PX = 1.0f; |
| 28 | +const float EXTRA_GAP_SPREAD_PX = 18.0f; // randomness range; set 0 for fixed spacing |
| 29 | + |
| 30 | +// Tail shaping — VERY SOFT EDGES |
| 31 | +// Brightness = (0.5 * (1 + cos(pi * (d / (halfLen * EDGE_SOFTNESS))))) ^ GAMMA |
| 32 | +const float EDGE_SOFTNESS = 2.0f; // extend tails |
| 33 | +const float PULSE_SHAPE_GAMMA = 0.35f; // <1 = gentler edges |
| 34 | + |
| 35 | +// Temporal fade-in (attack) and fade-out (release) |
| 36 | +const uint16_t ATTACK_MS = 1500; // slow ramp-up |
| 37 | +const uint16_t RELEASE_MS = 2000; // slow fade-out at the end |
| 38 | +const float PRE_ENTRY_MARGIN_PX = 6.0f; // spawn further off-strip |
| 39 | + |
| 40 | +// Peak per-pulse (pre-gamma) |
| 41 | +const uint8_t PULSE_PEAK_MAX = 160; |
| 42 | + |
| 43 | +// Pulse color (same for all pulses) |
| 44 | +const uint8_t PULSE_R = 255, PULSE_G = 0, PULSE_B = 0; |
| 45 | + |
| 46 | +// Twinkles |
| 47 | +const uint8_t TWINKLE_COUNT = 10; |
| 48 | +const uint8_t TWINKLE_MAX_WHITE = 80; |
| 49 | +const uint32_t TWINKLE_PERIOD_MS = 2400; |
| 50 | + |
| 51 | +// ======== WIRING ======== |
| 52 | +int8_t pins[8] = {DATA_PIN, -1, -1, -1, -1, -1, -1, -1}; |
| 53 | +Adafruit_NeoPXL8 leds(STRAND_LEN, pins, COLOR_ORDER); |
| 54 | + |
| 55 | +// ======== STATE ======== |
| 56 | +struct Pulse |
| 57 | +{ |
| 58 | + float center; // center position (px) |
| 59 | + float halfLen; // base half-length (px) |
| 60 | + float drawHalf; // half-length used for drawing = halfLen * EDGE_SOFTNESS |
| 61 | + uint32_t bornMs; // for attack envelope |
| 62 | + uint32_t endAtMs; // when rear edge has left the strip (for release window) |
| 63 | +}; |
| 64 | + |
| 65 | +std::vector<Pulse> pulses; // active pulses (dynamic) |
| 66 | +float nextSpawnRearThreshold = 0.0f; // absolute rear-edge distance target from pixel 0 |
| 67 | + |
| 68 | +// float accumulators |
| 69 | +float accR[STRAND_LEN], accG[STRAND_LEN], accB[STRAND_LEN], accW[STRAND_LEN]; |
| 70 | + |
| 71 | +// timing |
| 72 | +uint32_t lastFrameMs = 0; |
| 73 | +uint32_t frameCounter = 0; // for dithering phase |
| 74 | + |
| 75 | +// --- Twinkles --- |
| 76 | +struct Twinkle |
| 77 | +{ |
| 78 | + uint16_t pixel; |
| 79 | + int32_t phaseMs; |
| 80 | + float prevPhase; |
| 81 | +}; |
| 82 | +Twinkle twinkles[TWINKLE_COUNT]; |
| 83 | + |
| 84 | +// ======== HELPERS ======== |
| 85 | +static inline uint32_t packColor(uint8_t r, uint8_t g, uint8_t b, uint8_t w) |
| 86 | +{ |
| 87 | + return leds.Color(r, g, b, w); |
| 88 | +} |
| 89 | +static inline float frand(float a, float b) |
| 90 | +{ |
| 91 | + return a + (b - a) * (float)random(0, 10000) / 10000.0f; |
| 92 | +} |
| 93 | +static inline float randGap() |
| 94 | +{ |
| 95 | + return MIN_PULSE_GAP_PX + frand(0.0f, EXTRA_GAP_SPREAD_PX); |
| 96 | +} |
| 97 | +// simple clamp 0..1 |
| 98 | +static inline float clamp01(float x) { return x < 0 ? 0 : (x > 1 ? 1 : x); } |
| 99 | +// cosine ease 0..1 -> 0..1 |
| 100 | +static inline float easeCos(float u) |
| 101 | +{ |
| 102 | + if (u <= 0) |
| 103 | + return 0.0f; |
| 104 | + if (u >= 1) |
| 105 | + return 1.0f; |
| 106 | + return 0.5f * (1.0f - cosf((float)M_PI * u)); |
| 107 | +} |
| 108 | +// pseudo-random in [0,1) (for dithering), takes full 32-bit frame counter |
| 109 | +static inline float hash01(uint16_t x, uint32_t y) |
| 110 | +{ |
| 111 | + uint32_t h = 2166136261u; |
| 112 | + h = (h ^ x) * 16777619u; |
| 113 | + h = (h ^ (uint16_t)(y)) * 16777619u; |
| 114 | + h = (h ^ (uint16_t)(y >> 16)) * 16777619u; |
| 115 | + return (h & 0xFFFFFF) / 16777216.0f; |
| 116 | +} |
| 117 | + |
| 118 | +// Spawn a new pulse off-strip (to the left) |
| 119 | +void spawnPulse() |
| 120 | +{ |
| 121 | + Pulse p; |
| 122 | + p.halfLen = frand(PULSE_HALFLEN_MIN_PX, PULSE_HALFLEN_MAX_PX); |
| 123 | + p.drawHalf = p.halfLen * EDGE_SOFTNESS; |
| 124 | + p.center = -(p.drawHalf + PRE_ENTRY_MARGIN_PX); // start very faint |
| 125 | + p.bornMs = millis(); |
| 126 | + |
| 127 | + // travel time until rear edge exits the strip (center - drawHalf > STRAND_LEN) |
| 128 | + float travel = (STRAND_LEN + p.drawHalf + PRE_ENTRY_MARGIN_PX + 12.0f) / fmaxf(PULSE_SPEED, 1.0f); |
| 129 | + p.endAtMs = p.bornMs + (uint32_t)(travel * 1000.0f); |
| 130 | + |
| 131 | + pulses.push_back(p); |
| 132 | + |
| 133 | + // Set next rear-edge threshold (absolute distance from start) |
| 134 | + float newestRear = fmaxf(0.0f, p.center - p.drawHalf); // negative at spawn -> 0 |
| 135 | + nextSpawnRearThreshold = newestRear + randGap(); |
| 136 | +} |
| 137 | + |
| 138 | +void initTwinkles() |
| 139 | +{ |
| 140 | + for (uint8_t i = 0; i < TWINKLE_COUNT; i++) |
| 141 | + { |
| 142 | + twinkles[i].pixel = random(0, STRAND_LEN); |
| 143 | + twinkles[i].phaseMs = random(0, TWINKLE_PERIOD_MS); |
| 144 | + twinkles[i].prevPhase = 0.0f; |
| 145 | + } |
| 146 | +} |
| 147 | +static inline void relocateTwinkleToStart(Twinkle &t, uint32_t nowMs) |
| 148 | +{ |
| 149 | + t.pixel = random(0, STRAND_LEN); |
| 150 | + t.phaseMs = -(int32_t)(nowMs % TWINKLE_PERIOD_MS); |
| 151 | + t.prevPhase = 0.0f; |
| 152 | +} |
| 153 | + |
| 154 | +void renderFrame(float dt) |
| 155 | +{ |
| 156 | + // clear accumulators |
| 157 | + for (int i = 0; i < STRAND_LEN; i++) |
| 158 | + accR[i] = accG[i] = accB[i] = accW[i] = 0.0f; |
| 159 | + |
| 160 | + const float peakR = (PULSE_R / 255.0f) * (PULSE_PEAK_MAX / 255.0f); |
| 161 | + const float peakG = (PULSE_G / 255.0f) * (PULSE_PEAK_MAX / 255.0f); |
| 162 | + const float peakB = (PULSE_B / 255.0f) * (PULSE_PEAK_MAX / 255.0f); |
| 163 | + |
| 164 | + uint32_t nowMs = millis(); |
| 165 | + |
| 166 | + // Advance & draw pulses; remove those that have fully passed |
| 167 | + for (size_t i = 0; i < pulses.size(); /* increment inside */) |
| 168 | + { |
| 169 | + Pulse &p = pulses[i]; |
| 170 | + |
| 171 | + // move |
| 172 | + p.center += PULSE_SPEED * dt; |
| 173 | + |
| 174 | + // cull if done (rear edge beyond strip) |
| 175 | + if (nowMs > p.endAtMs || (p.center - p.drawHalf) > (STRAND_LEN + 1)) |
| 176 | + { |
| 177 | + pulses.erase(pulses.begin() + i); |
| 178 | + continue; |
| 179 | + } |
| 180 | + |
| 181 | + // Temporal envelopes: |
| 182 | + // Attack : 0→1 over ATTACK_MS from bornMs |
| 183 | + float attack = easeCos((float)(nowMs - p.bornMs) / (float)ATTACK_MS); |
| 184 | + |
| 185 | + // ✅ Correct release: 1→0 over the *last* RELEASE_MS before endAtMs |
| 186 | + int32_t tToEnd = (int32_t)p.endAtMs - (int32_t)nowMs; // ms until end |
| 187 | + float u = clamp01((float)tToEnd / (float)RELEASE_MS); // 1..0 over final window |
| 188 | + float release = easeCos(u); // high until close to the end, then eases down |
| 189 | + |
| 190 | + float env = attack * release; |
| 191 | + |
| 192 | + // Draw pulse with very soft spatial falloff |
| 193 | + int start = (int)floorf(p.center - p.drawHalf); |
| 194 | + int end = (int)ceilf(p.center + p.drawHalf); |
| 195 | + if (start < 0) |
| 196 | + start = 0; |
| 197 | + if (end > STRAND_LEN) |
| 198 | + end = STRAND_LEN; |
| 199 | + |
| 200 | + for (int px = start; px < end; px++) |
| 201 | + { |
| 202 | + float d = fabsf((float)px - p.center); // 0 at center → drawHalf at edges |
| 203 | + float x = d / p.drawHalf; // 0..1 |
| 204 | + if (x > 1.0f) |
| 205 | + continue; |
| 206 | + |
| 207 | + // Extremely soft raised-cosine with gamma < 1 |
| 208 | + float bSpatial = powf(0.5f * (1.0f + cosf((float)M_PI * x)), PULSE_SHAPE_GAMMA); |
| 209 | + float b = env * bSpatial; |
| 210 | + |
| 211 | + accR[px] += peakR * b; |
| 212 | + accG[px] += peakG * b; |
| 213 | + accB[px] += peakB * b; |
| 214 | + } |
| 215 | + |
| 216 | + ++i; |
| 217 | + } |
| 218 | + |
| 219 | + // Spawn rule: first pulse immediately; then by rear-edge distance |
| 220 | + if (pulses.empty()) |
| 221 | + { |
| 222 | + spawnPulse(); |
| 223 | + } |
| 224 | + else |
| 225 | + { |
| 226 | + const Pulse &newest = pulses.back(); |
| 227 | + float newestRear = newest.center - newest.drawHalf; // rear (left) edge |
| 228 | + float traveledFromStart = fmaxf(0.0f, newestRear); |
| 229 | + if (traveledFromStart >= nextSpawnRearThreshold) |
| 230 | + { |
| 231 | + spawnPulse(); |
| 232 | + } |
| 233 | + } |
| 234 | + |
| 235 | + // Twinkles |
| 236 | + for (uint8_t i = 0; i < TWINKLE_COUNT; i++) |
| 237 | + { |
| 238 | + Twinkle &t = twinkles[i]; |
| 239 | + float phase = (float)((int32_t)(nowMs + t.phaseMs) % (int32_t)TWINKLE_PERIOD_MS) / (float)TWINKLE_PERIOD_MS; |
| 240 | + if (t.prevPhase > 0.9f && phase < 0.1f) |
| 241 | + { |
| 242 | + relocateTwinkleToStart(t, nowMs); |
| 243 | + phase = 0.0f; |
| 244 | + } |
| 245 | + t.prevPhase = phase; |
| 246 | + |
| 247 | + float s = 0.5f * (1.0f - cosf(phase * 2.0f * (float)M_PI)); // 0..1 |
| 248 | + float w = (s * TWINKLE_MAX_WHITE) / 255.0f; |
| 249 | + |
| 250 | + int px = t.pixel; |
| 251 | + if (px >= 0 && px < STRAND_LEN) |
| 252 | + accW[px] = fmaxf(accW[px], w); |
| 253 | + } |
| 254 | + |
| 255 | + // Quantize once, with gamma + light temporal dithering (long period) |
| 256 | + leds.clear(); |
| 257 | + for (int px = 0; px < STRAND_LEN; px++) |
| 258 | + { |
| 259 | + float r = fminf(accR[px], 1.0f); |
| 260 | + float g = fminf(accG[px], 1.0f); |
| 261 | + float b = fminf(accB[px], 1.0f); |
| 262 | + float w = fminf(accW[px], 1.0f); |
| 263 | + |
| 264 | + float thr = hash01(px, frameCounter); // 0..1 |
| 265 | + |
| 266 | + float r255 = r * 255.0f; |
| 267 | + uint8_t r8 = (uint8_t)floorf(r255 + ((r255 - floorf(r255)) > thr ? 1.0f : 0.0f)); |
| 268 | + float g255 = g * 255.0f; |
| 269 | + uint8_t g8 = (uint8_t)floorf(g255 + ((g255 - floorf(g255)) > thr ? 1.0f : 0.0f)); |
| 270 | + float b255 = b * 255.0f; |
| 271 | + uint8_t b8 = (uint8_t)floorf(b255 + ((b255 - floorf(b255)) > thr ? 1.0f : 0.0f)); |
| 272 | + float w255 = w * 255.0f; |
| 273 | + uint8_t w8 = (uint8_t)floorf(w255 + ((w255 - floorf(w255)) > thr ? 1.0f : 0.0f)); |
| 274 | + |
| 275 | + r8 = leds.gamma8(r8); |
| 276 | + g8 = leds.gamma8(g8); |
| 277 | + b8 = leds.gamma8(b8); |
| 278 | + w8 = leds.gamma8(w8); |
| 279 | + |
| 280 | + leds.setPixelColor(px, packColor(r8, g8, b8, w8)); |
| 281 | + } |
| 282 | +} |
| 283 | + |
| 284 | +// ======== ARDUINO ======== |
| 285 | +void setup() |
| 286 | +{ |
| 287 | + randomSeed(analogRead(28)); |
| 288 | +#ifdef PIN_NEOPIXEL_POWER |
| 289 | + pinMode(PIN_NEOPIXEL_POWER, OUTPUT); |
| 290 | + digitalWrite(PIN_NEOPIXEL_POWER, LOW); |
| 291 | +#endif |
| 292 | + |
| 293 | + if (!leds.begin()) |
| 294 | + { |
| 295 | + pinMode(LED_BUILTIN, OUTPUT); |
| 296 | + for (;;) |
| 297 | + digitalWrite(LED_BUILTIN, (millis() / 200) & 1); |
| 298 | + } |
| 299 | + leds.setBrightness(255); |
| 300 | + leds.clear(); |
| 301 | + leds.show(); |
| 302 | + |
| 303 | + initTwinkles(); |
| 304 | + pulses.clear(); |
| 305 | + spawnPulse(); // start with one pulse |
| 306 | + lastFrameMs = millis(); |
| 307 | +} |
| 308 | + |
| 309 | +void loop() |
| 310 | +{ |
| 311 | + uint32_t now = millis(); |
| 312 | + float dt = (now - lastFrameMs) / 1000.0f; |
| 313 | + if (dt < 0.001f) |
| 314 | + dt = 0.001f; |
| 315 | + if (dt > 0.050f) |
| 316 | + dt = 0.050f; |
| 317 | + |
| 318 | + renderFrame(dt); |
| 319 | + leds.show(); |
| 320 | + |
| 321 | + frameCounter++; // advance dithering phase |
| 322 | + lastFrameMs = now; |
| 323 | + delay(16); // ~60 FPS |
| 324 | +} |
0 commit comments