/ EyeLights_Blinky_Eyes / EyeLights_Blinky_Eyes / EyeLights_Blinky_Eyes.ino
EyeLights_Blinky_Eyes.ino
  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  }