|
| 1 | +// SPDX-FileCopyrightText: 2021 Phil Burgess for Adafruit Industries |
| 2 | +// |
| 3 | +// SPDX-License-Identifier: MIT |
| 4 | + |
| 5 | +/* |
| 6 | +MOVE-AND-BLINK EYES for Adafruit EyeLights (LED Glasses + Driver). |
| 7 | +
|
| 8 | +I'd written a very cool squash-and-stretch effect for the eye movement, |
| 9 | +but unfortunately the resolution is such that the pupils just look like |
| 10 | +circles regardless. I'm keeping it in despite the added complexity, |
| 11 | +because this WILL look great later on a bigger matrix or a TFT/OLED, |
| 12 | +and this way the hard parts won't require a re-write at such time. |
| 13 | +It's a really adorable effect with enough pixels. |
| 14 | +*/ |
| 15 | + |
| 16 | +#include <Adafruit_IS31FL3741.h> // For LED driver |
| 17 | + |
| 18 | +// CONFIGURABLES ------------------------ |
| 19 | + |
| 20 | +#define RADIUS 3.4 // Size of pupil (3X because of downsampling later) |
| 21 | + |
| 22 | +uint8_t eye_color[3] = { 255, 128, 0 }; // Amber pupils |
| 23 | +uint8_t ring_open_color[3] = { 75, 75, 75 }; // Color of LED rings when eyes open |
| 24 | +uint8_t ring_blink_color[3] = { 50, 25, 0 }; // Color of LED ring "eyelid" when blinking |
| 25 | + |
| 26 | +// Some boards have just one I2C interface, but some have more... |
| 27 | +TwoWire *i2c = &Wire; // e.g. change this to &Wire1 for QT Py RP2040 |
| 28 | + |
| 29 | +// GLOBAL VARIABLES --------------------- |
| 30 | + |
| 31 | +Adafruit_EyeLights_buffered glasses(true); // Buffered spex + 3X canvas |
| 32 | +GFXcanvas16 *canvas; // Pointer to canvas object |
| 33 | + |
| 34 | +// Reading through the code, you'll see a lot of references to this "3X" |
| 35 | +// space. This is referring to the glasses' optional "offscreen" drawing |
| 36 | +// canvas that's 3 times the resolution of the LED matrix (i.e. 15 pixels |
| 37 | +// tall instead of 5), which gets scaled down to provide some degree of |
| 38 | +// antialiasing. It's why the pupils have soft edges and can make |
| 39 | +// fractional-pixel motions. |
| 40 | + |
| 41 | +float cur_pos[2] = { 9.0, 7.5 }; // Current position of eye in canvas space |
| 42 | +float next_pos[2] = { 9.0, 7.5 }; // Next position " |
| 43 | +bool in_motion = false; // true = eyes moving, false = eyes paused |
| 44 | +uint8_t blink_state = 0; // 0, 1, 2 = unblinking, closing, opening |
| 45 | +uint32_t move_start_time = 0; // For animation timekeeping |
| 46 | +uint32_t move_duration = 0; |
| 47 | +uint32_t blink_start_time = 0; |
| 48 | +uint32_t blink_duration = 0; |
| 49 | +float y_pos[13]; // Coords of LED ring pixels in canvas space |
| 50 | +uint32_t ring_open_color_packed; // ring_open_color[] as packed RGB integer |
| 51 | +uint16_t eye_color565; // eye_color[] as a GFX packed '565' value |
| 52 | +uint32_t frames = 0; // For frames-per-second calculation |
| 53 | +uint32_t start_time; |
| 54 | + |
| 55 | +// These offsets position each pupil on the canvas grid and make them |
| 56 | +// fixate slightly (converge on a point) so they're not always aligned |
| 57 | +// the same on the pixel grid, which would be conspicuously pixel-y. |
| 58 | +float x_offset[2] = { 5.0, 31.0 }; |
| 59 | +// These help perform x-axis clipping on the rasterized ellipses, |
| 60 | +// so they don't "bleed" outside the rings and require erasing. |
| 61 | +int box_x_min[2] = { 3, 33 }; |
| 62 | +int box_x_max[2] = { 21, 51 }; |
| 63 | + |
| 64 | +#define GAMMA 2.6 // For color correction, shouldn't need changing |
| 65 | + |
| 66 | + |
| 67 | +// HELPER FUNCTIONS --------------------- |
| 68 | + |
| 69 | +// Crude error handler, prints message to Serial console, flashes LED |
| 70 | +void err(char *str, uint8_t hz) { |
| 71 | + Serial.println(str); |
| 72 | + pinMode(LED_BUILTIN, OUTPUT); |
| 73 | + for (;;) digitalWrite(LED_BUILTIN, (millis() * hz / 500) & 1); |
| 74 | +} |
| 75 | + |
| 76 | +// Given an [R,G,B] color, apply gamma correction, return packed RGB integer. |
| 77 | +uint32_t gammify(uint8_t color[3]) { |
| 78 | + uint32_t rgb[3]; |
| 79 | + for (uint8_t i=0; i<3; i++) { |
| 80 | + rgb[i] = uint32_t(pow((float)color[i] / 255.0, GAMMA) * 255 + 0.5); |
| 81 | + } |
| 82 | + return (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]; |
| 83 | +} |
| 84 | + |
| 85 | +// Given two [R,G,B] colors and a blend ratio (0.0 to 1.0), interpolate between |
| 86 | +// the two colors and return a gamma-corrected in-between color as a packed RGB |
| 87 | +// integer. No bounds clamping is performed on blend value, be nice. |
| 88 | +uint32_t interp(uint8_t color1[3], uint8_t color2[3], float blend) { |
| 89 | + float inv = 1.0 - blend; // Weighting of second color |
| 90 | + uint8_t rgb[3]; |
| 91 | + for(uint8_t i=0; i<3; i++) { |
| 92 | + rgb[i] = (int)((float)color1[i] * blend + (float)color2[i] * inv); |
| 93 | + } |
| 94 | + return gammify(rgb); |
| 95 | +} |
| 96 | + |
| 97 | +// Rasterize an arbitrary ellipse into the offscreen 3X canvas, given |
| 98 | +// foci point1 and point2 and with area determined by global RADIUS |
| 99 | +// (when foci are same point; a circle). Foci and radius are all |
| 100 | +// floating point values, which adds to the buttery impression. 'rect' |
| 101 | +// is a bounding rect of which pixels are likely affected. Canvas is |
| 102 | +// assumed cleared before arriving here. |
| 103 | +void rasterize(float point1[2], float point2[2], int rect[4]) { |
| 104 | + float perimeter, d; |
| 105 | + float dx = point2[0] - point1[0]; |
| 106 | + float dy = point2[1] - point1[1]; |
| 107 | + float d2 = dx * dx + dy * dy; // Dist between foci, squared |
| 108 | + if (d2 <= 0.0) { |
| 109 | + // Foci are in same spot - it's a circle |
| 110 | + perimeter = 2.0 * RADIUS; |
| 111 | + d = 0.0; |
| 112 | + } else { |
| 113 | + // Foci are separated - it's an ellipse. |
| 114 | + d = sqrt(d2); // Distance between foci |
| 115 | + float c = d * 0.5; // Center-to-foci distance |
| 116 | + // This is an utterly brute-force way of ellipse-filling based on |
| 117 | + // the "two nails and a string" metaphor...we have the foci points |
| 118 | + // and just need the string length (triangle perimeter) to yield |
| 119 | + // an ellipse with area equal to a circle of 'radius'. |
| 120 | + // c^2 = a^2 - b^2 <- ellipse formula |
| 121 | + // a = r^2 / b <- substitute |
| 122 | + // c^2 = (r^2 / b)^2 - b^2 |
| 123 | + // b = sqrt(((c^2) + sqrt((c^4) + 4 * r^4)) / 2) <- solve for b |
| 124 | + float c2 = c * c; |
| 125 | + float b2 = (c2 + sqrt((c2 * c2) + 4 * (RADIUS * RADIUS * RADIUS * RADIUS))) * 0.5; |
| 126 | + // By my math, perimeter SHOULD be... |
| 127 | + // perimeter = d + 2 * sqrt(b2 + c2); |
| 128 | + // ...but for whatever reason, working approach here is really... |
| 129 | + perimeter = d + 2 * sqrt(b2); |
| 130 | + } |
| 131 | + |
| 132 | + // Like I'm sure there's a way to rasterize this by spans rather than |
| 133 | + // all these square roots on every pixel, but for now... |
| 134 | + for (int y=rect[1]; y<rect[3]; y++) { // For each row... |
| 135 | + float y5 = (float)y + 0.5; // Pixel center |
| 136 | + float dy1 = y5 - point1[1]; // Y distance from pixel to first point |
| 137 | + float dy2 = y5 - point2[1]; // " to second |
| 138 | + dy1 *= dy1; // Y1^2 |
| 139 | + dy2 *= dy2; // Y2^2 |
| 140 | + for (int x=rect[0]; x<rect[2]; x++) { // For each column... |
| 141 | + float x5 = (float)x + 0.5; // Pixel center |
| 142 | + float dx1 = x5 - point1[0]; // X distance from pixel to first point |
| 143 | + float dx2 = x5 - point2[0]; // " to second |
| 144 | + float d1 = sqrt(dx1 * dx1 + dy1); // 2D distance to first point |
| 145 | + float d2 = sqrt(dx2 * dx2 + dy2); // " to second |
| 146 | + if ((d1 + d2 + d) <= perimeter) { // Point inside ellipse? |
| 147 | + canvas->drawPixel(x, y, eye_color565); |
| 148 | + } |
| 149 | + } |
| 150 | + } |
| 151 | +} |
| 152 | + |
| 153 | + |
| 154 | +// ONE-TIME INITIALIZATION -------------- |
| 155 | + |
| 156 | +void setup() { |
| 157 | + // Initialize hardware |
| 158 | + Serial.begin(115200); |
| 159 | + if (! glasses.begin(IS3741_ADDR_DEFAULT, i2c)) err("IS3741 not found", 2); |
| 160 | + |
| 161 | + canvas = glasses.getCanvas(); |
| 162 | + if (!canvas) err("Can't allocate canvas", 5); |
| 163 | + |
| 164 | + i2c->setClock(1000000); // 1 MHz I2C for extra butteriness |
| 165 | + |
| 166 | + // Configure glasses for reduced brightness, enable output |
| 167 | + glasses.setLEDscaling(0xFF); |
| 168 | + glasses.setGlobalCurrent(20); |
| 169 | + glasses.enable(true); |
| 170 | + |
| 171 | + // INITIALIZE TABLES & OTHER GLOBALS ---- |
| 172 | + |
| 173 | + // Pre-compute the Y position of 1/2 of the LEDs in a ring, relative |
| 174 | + // to the 3X canvas resolution, so ring & matrix animation can be aligned. |
| 175 | + for (uint8_t i=0; i<13; i++) { |
| 176 | + float angle = (float)i / 24.0 * M_PI * 2.0; |
| 177 | + y_pos[i] = 10.0 - cos(angle) * 12.0; |
| 178 | + } |
| 179 | + |
| 180 | + // Convert some colors from [R,G,B] (easier to specify) to packed integers |
| 181 | + ring_open_color_packed = gammify(ring_open_color); |
| 182 | + eye_color565 = glasses.color565(eye_color[0], eye_color[1], eye_color[2]); |
| 183 | + |
| 184 | + start_time = millis(); // For frames-per-second math |
| 185 | +} |
| 186 | + |
| 187 | +// MAIN LOOP ---------------------------- |
| 188 | + |
| 189 | +void loop() { |
| 190 | + canvas->fillScreen(0); |
| 191 | + |
| 192 | + // The eye animation logic is a carry-over from like a billion |
| 193 | + // prior eye projects, so this might be comment-light. |
| 194 | + uint32_t now = micros(); // 'Snapshot' the time once per frame |
| 195 | + |
| 196 | + float upper, lower, ratio; |
| 197 | + |
| 198 | + // Blink logic |
| 199 | + uint32_t elapsed = now - blink_start_time; // Time since start of blink event |
| 200 | + if (elapsed > blink_duration) { // All done with event? |
| 201 | + blink_start_time = now; // A new one starts right now |
| 202 | + elapsed = 0; |
| 203 | + blink_state++; // Cycle closing/opening/paused |
| 204 | + if (blink_state == 1) { // Starting new blink... |
| 205 | + blink_duration = random(60000, 120000); |
| 206 | + } else if (blink_state == 2) { // Switching closing to opening... |
| 207 | + blink_duration *= 2; // Opens at half the speed |
| 208 | + } else { // Switching to pause in blink |
| 209 | + blink_state = 0; |
| 210 | + blink_duration = random(500000, 4000000); |
| 211 | + } |
| 212 | + } |
| 213 | + if (blink_state) { // If currently in a blink... |
| 214 | + float ratio = (float)elapsed / (float)blink_duration; // 0.0-1.0 as it closes |
| 215 | + if (blink_state == 2) ratio = 1.0 - ratio; // 1.0-0.0 as it opens |
| 216 | + upper = ratio * 15.0 - 4.0; // Upper eyelid pos. in 3X space |
| 217 | + lower = 23.0 - ratio * 8.0; // Lower eyelid pos. in 3X space |
| 218 | + } |
| 219 | + |
| 220 | + // Eye movement logic. Two points, 'p1' and 'p2', are the foci of an |
| 221 | + // ellipse. p1 moves from current to next position a little faster |
| 222 | + // than p2, creating a "squash and stretch" effect (frame rate and |
| 223 | + // resolution permitting). When motion is stopped, the two points |
| 224 | + // are at the same position. |
| 225 | + float p1[2], p2[2]; |
| 226 | + elapsed = now - move_start_time; // Time since start of move event |
| 227 | + if (in_motion) { // Currently moving? |
| 228 | + if (elapsed > move_duration) { // If end of motion reached, |
| 229 | + in_motion = false; // Stop motion and |
| 230 | + memcpy(&p1, &next_pos, sizeof next_pos); // set everything to new position |
| 231 | + memcpy(&p2, &next_pos, sizeof next_pos); |
| 232 | + memcpy(&cur_pos, &next_pos, sizeof next_pos); |
| 233 | + move_duration = random(500000, 1500000); // Wait this long |
| 234 | + } else { // Still moving |
| 235 | + // Determine p1, p2 position in time |
| 236 | + float delta[2]; |
| 237 | + delta[0] = next_pos[0] - cur_pos[0]; |
| 238 | + delta[1] = next_pos[1] - cur_pos[1]; |
| 239 | + ratio = (float)elapsed / (float)move_duration; |
| 240 | + if (ratio < 0.6) { // First 60% of move time, p1 is in motion |
| 241 | + // Easing function: 3*e^2-2*e^3 0.0 to 1.0 |
| 242 | + float e = ratio / 0.6; // 0.0 to 1.0 |
| 243 | + e = 3 * e * e - 2 * e * e * e; |
| 244 | + p1[0] = cur_pos[0] + delta[0] * e; |
| 245 | + p1[1] = cur_pos[1] + delta[1] * e; |
| 246 | + } else { // Last 40% of move time |
| 247 | + memcpy(&p1, &next_pos, sizeof next_pos); // p1 has reached end position |
| 248 | + } |
| 249 | + if (ratio > 0.3) { // Last 70% of move time, p2 is in motion |
| 250 | + float e = (ratio - 0.3) / 0.7; // 0.0 to 1.0 |
| 251 | + e = 3 * e * e - 2 * e * e * e; // Easing func. |
| 252 | + p2[0] = cur_pos[0] + delta[0] * e; |
| 253 | + p2[1] = cur_pos[1] + delta[1] * e; |
| 254 | + } else { // First 30% of move time |
| 255 | + memcpy(&p2, &cur_pos, sizeof cur_pos); // p2 waits at start position |
| 256 | + } |
| 257 | + } |
| 258 | + } else { // Eye is stopped |
| 259 | + memcpy(&p1, &cur_pos, sizeof cur_pos); // Both foci at current eye position |
| 260 | + memcpy(&p2, &cur_pos, sizeof cur_pos); |
| 261 | + if (elapsed > move_duration) { // Pause time expired? |
| 262 | + in_motion = true; // Start up new motion! |
| 263 | + move_start_time = now; |
| 264 | + move_duration = random(150000, 250000); |
| 265 | + float angle = (float)random(1000) / 1000.0 * M_PI * 2.0; |
| 266 | + float dist = (float)random(750) / 100.0; |
| 267 | + next_pos[0] = 9.0 + cos(angle) * dist; |
| 268 | + next_pos[1] = 7.5 + sin(angle) * dist * 0.8; |
| 269 | + } |
| 270 | + } |
| 271 | + |
| 272 | + // Draw the raster part of each eye... |
| 273 | + for (uint8_t e=0; e<2; e++) { |
| 274 | + // Each eye's foci are offset slightly, to fixate toward center |
| 275 | + float p1a[2], p2a[2]; |
| 276 | + p1a[0] = p1[0] + x_offset[e]; |
| 277 | + p2a[0] = p2[0] + x_offset[e]; |
| 278 | + p1a[1] = p2a[1] = p1[1]; |
| 279 | + // Compute bounding rectangle (in 3X space) of ellipse |
| 280 | + // (min X, min Y, max X, max Y). Like the ellipse rasterizer, |
| 281 | + // this isn't optimal, but will suffice. |
| 282 | + int bounds[4]; |
| 283 | + bounds[0] = max(int(min(p1a[0], p2a[0]) - RADIUS), box_x_min[e]); |
| 284 | + bounds[1] = max(max(int(min(p1a[1], p2a[1]) - RADIUS), 0), (int)upper); |
| 285 | + bounds[2] = min(int(max(p1a[0], p2a[0]) + RADIUS + 1), box_x_max[e]); |
| 286 | + bounds[3] = min(int(max(p1a[1], p2a[1]) + RADIUS + 1), 15); |
| 287 | + rasterize(p1a, p2a, bounds); // Render ellipse into buffer |
| 288 | + } |
| 289 | + |
| 290 | + // If the eye is currently blinking, and if the top edge of the eyelid |
| 291 | + // overlaps the bitmap, draw lines across the bitmap as if eyelids. |
| 292 | + if (blink_state and upper >= 0.0) { |
| 293 | + int iu = (int)upper; |
| 294 | + canvas->drawLine(box_x_min[0], iu, box_x_max[0] - 1, iu, eye_color565); |
| 295 | + canvas->drawLine(box_x_min[1], iu, box_x_max[1] - 1, iu, eye_color565); |
| 296 | + } |
| 297 | + |
| 298 | + glasses.scale(); // Smooth filter 3X canvas to LED grid |
| 299 | + |
| 300 | + // Matrix and rings share a few pixels. To make the rings take |
| 301 | + // precedence, they're drawn later. So blink state is revisited now... |
| 302 | + if (blink_state) { // In mid-blink? |
| 303 | + for (uint8_t i=0; i<13; i++) { // Half an LED ring, top-to-bottom... |
| 304 | + float a = min(max(y_pos[i] - upper + 1.0, 0.0), 3.0); |
| 305 | + float b = min(max(lower - y_pos[i] + 1.0, 0.0), 3.0); |
| 306 | + ratio = a * b / 9.0; // Proximity of LED to eyelid edges |
| 307 | + uint32_t packed = interp(ring_open_color, ring_blink_color, ratio); |
| 308 | + glasses.left_ring.setPixelColor(i, packed); |
| 309 | + glasses.right_ring.setPixelColor(i, packed); |
| 310 | + if ((i > 0) && (i < 12)) { |
| 311 | + uint8_t j = 24 - i; // Mirror half-ring to other side |
| 312 | + glasses.left_ring.setPixelColor(j, packed); |
| 313 | + glasses.right_ring.setPixelColor(j, packed); |
| 314 | + } |
| 315 | + } |
| 316 | + } else { |
| 317 | + glasses.left_ring.fill(ring_open_color_packed); |
| 318 | + glasses.right_ring.fill(ring_open_color_packed); |
| 319 | + } |
| 320 | + |
| 321 | + glasses.show(); |
| 322 | + |
| 323 | + frames += 1; |
| 324 | + elapsed = millis() - start_time; |
| 325 | + Serial.println(frames * 1000 / elapsed); |
| 326 | +} |
0 commit comments