Built-in project · ESP32 DevKit V1

Animatronic eyes on an ESP32 DevKit

Two GC9A01 round displays, one on each side of an ESP32: eyes that glide to follow a joystick, blink on their own or when it is pressed, and drift when nobody is steering. The whole build, an ESP32 DevKit and 3 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Advanced About 25 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $21. On Mokxi they're free.

Animatronic eyes · ESP32 DevKit V1live0.000 s 0.00x
Press Run, then drag the stick
The circuit itself, running here on the simulator. Press what can be pressed; click anything else to open it in the editor.

What you'll learn

  • Displays
  • Controllers
  • Using the Round display
  • Using the Thumb stick
  • Difficulty: Advanced
  • Time: About 25 minutes
  • Board: ESP32 DevKit V1
  • Parts: 7

How it works

Two GC9A01 round displays, one on each side of an ESP32: eyes that glide to follow a joystick, blink on their own or when it is pressed, and drift when nobody is steering.

ESP32 DevKit V1 runs the sketch below. The circuit around it: Round display, GC9A01, Thumb stick. pinMode sets PIN_SW as an input with its pull-up on. digitalRead reads whether PIN_SW is HIGH or LOW. analogRead measures the voltage on PIN_VRX as a number. Serial.begin opens the serial port at 115200 baud. millis reads the time since power-up, so the sketch can time things without stopping.

Parts list

7 parts, plus the jumper wires. They retail for about $21; on Mokxi they're free.

Wiring table

Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.

Part and pinConnects to
Thumb stick pin VRXESP32 DevKit V1 pin D34
Thumb stick pin VRYESP32 DevKit V1 pin D35
Thumb stick pin SWESP32 DevKit V1 pin D32
Round display, GC9A01 (1) pin CSESP32 DevKit V1 pin D33
Round display, GC9A01 (1) pin DCESP32 DevKit V1 pin D25
Round display, GC9A01 (1) pin RESESP32 DevKit V1 pin D26
Round display, GC9A01 (1) pin SDAESP32 DevKit V1 pin D27
Round display, GC9A01 (1) pin SCLESP32 DevKit V1 pin D14
Round display, GC9A01 (2) pin SCLESP32 DevKit V1 pin D21
Round display, GC9A01 (2) pin SDAESP32 DevKit V1 pin D19
Round display, GC9A01 (2) pin RESESP32 DevKit V1 pin D18
Round display, GC9A01 (2) pin DCESP32 DevKit V1 pin D5
Round display, GC9A01 (2) pin CSESP32 DevKit V1 pin TX2
Round display, GC9A01 (1) pin GNDGround
Round display, GC9A01 (1) pin VCC3.3 V
Round display, GC9A01 (2) pin GNDGround
Round display, GC9A01 (2) pin VCC3.3 V
Thumb stick pin GNDGround
Thumb stick pin VCC3.3 V

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · ESP32 DevKit V1
// Animatronic eyes: two GC9A01 round displays that blink and follow a joystick.
//
//   left screen    SCL -> 14   SDA -> 27   RES -> 26   DC -> 25   CS -> 33
//   right screen   SCL -> 21   SDA -> 19   RES -> 18   DC -> 5    CS -> 17
//   joystick       VRX -> 34   VRY -> 35   SW -> 32
//   all of them    VCC -> 3V3  GND -> GND
//
// # Two screens, two buses
//
// Each screen is driven with its own five wires. The Adafruit_GC9A01A library
// can clock out its bits on ANY pins (it does the SPI by hand, "software SPI"),
// so there is no need for the ESP32's two hardware SPI ports. The price is
// speed: every pixel is sixteen bit-banged clock pulses, which is why the
// sketch below works so hard never to repaint a pixel that has not changed. The
// reward is wiring that is easy to follow: one screen on each side of the
// board, nothing shared.
//
// (Two screens CAN share a bus: only the chip select, CS, has to be different,
// because a screen listens only while its own CS is low. That saves pins, but
// the wires to the shared lines make a mess of a picture, so this build spends
// pins instead.)
//
// # An eye is four circles and two lids
//
// Each GC9A01 is a 240 x 240 grid of pixels behind round glass, so the middle
// is (120, 120). An eye is layers: a pale sclera that fills the glass, a
// round iris on top, a black pupil in the iris and a small white highlight.
// Two lids slide in from the top and the bottom. Where the stick points, the
// iris slides that way; where the lids are, the glass goes dark.
//
// # Never redraw what did not change
//
// Every pixel is two bytes sent over a bit-banged SPI bus, which is slow, so
// the golden rule of animating on a screen like this is to send only the
// pixels whose colour changed. For every row of the picture the sketch works
// out where the colours change along the row, for the old picture and for the
// new one, and walks along comparing them. Wherever they differ it paints a
// short run of the new colour. A gaze that moves a few pixels costs a few
// thin slivers. Closing a lid costs the rows the lid just crossed.
//
// # Alive, not just working
//
// Steering is eased: the iris closes a third of the remaining distance each
// frame, so it glides rather than jumps. The eyes blink on their own every
// three to six seconds, quickly shut and slower to open, and pressing the
// stick blinks them at once. Looking down brings the upper lids down a little,
// as real eyes do. If nobody touches the stick the eyes drift on their own.

#include <SPI.h>
#include "Adafruit_GFX.h"
#include "Adafruit_GC9A01A.h"

const int PIN_VRX = 34;  // input-only pins, which is all a stick axis needs
const int PIN_VRY = 35;
const int PIN_SW = 32;

Adafruit_GC9A01A leftEye(33, 25, 27, 14, 26);   // CS, DC, MOSI (SDA), SCK (SCL), RES
Adafruit_GC9A01A rightEye(17, 5, 19, 21, 18);

// Colours in RGB565.
const uint16_t BLACK = 0x0000;
const uint16_t SCLERA = 0xEF9F;    // pale blue white
const uint16_t IRIS_EDGE = 0x0394; // dark teal
const uint16_t IRIS = 0x05BF;      // glowing cyan
const uint16_t PUPIL = 0x0000;
const uint16_t SPARK = 0xFFFF;     // the light in the eye
const uint16_t LID = 0x2945;       // dark slate
const uint16_t LASH = 0x0841;      // almost black

const int CENTRE = 120;
const int GLASS = 119;     // radius of the round glass in use
const int IRIS_R = 54;
const int RING_R = 44;     // the bright part of the iris inside its dark edge
const int PUPIL_R = 22;
const int SPARK_R = 7;
const int MAX_LOOK = 46;   // how far the iris centre may stray from the middle

// Everything that decides how an eye looks.
struct Look {
  int16_t ix, iy;    // iris offset from the middle, pixels
  int16_t top, bot;  // first and last row of the eye that is NOT covered by a lid
};

int32_t sqr(int32_t v) {
  return v * v;
}

int16_t isqrt(int32_t v) {
  if (v <= 0) return 0;
  int32_t x = v, y = (x + 1) / 2;
  while (y < x) {
    x = y;
    y = (x + v / x) / 2;
  }
  return (int16_t)x;
}

// The colour of the pixel at (x, y) for this look.
uint16_t colourAt(const Look &k, int16_t x, int16_t y) {
  if (sqr(x - CENTRE) + sqr(y - CENTRE) > sqr(GLASS)) return BLACK;
  if (y < k.top || y > k.bot) return LID;
  if (y == k.top || y == k.bot) return LASH;
  int32_t dx = x - (CENTRE + k.ix), dy = y - (CENTRE + k.iy);
  int32_t d2 = dx * dx + dy * dy;
  // the highlight sits up and to the left of the iris centre, whatever the gaze
  int32_t hx = x - (CENTRE + k.ix - 16), hy = y - (CENTRE + k.iy - 16);
  if (hx * hx + hy * hy <= sqr(SPARK_R)) return SPARK;
  if (d2 <= sqr(PUPIL_R)) return PUPIL;
  if (d2 <= sqr(RING_R)) return IRIS;
  if (d2 <= sqr(IRIS_R)) return IRIS_EDGE;
  return SCLERA;
}

// Where the colour may change along row y for this look: the edges of the
// glass and of each circle that the row crosses. Appends to `at`.
uint8_t edges(const Look &k, int16_t y, int16_t *at, uint8_t n) {
  int16_t w = isqrt(sqr(GLASS) - sqr(y - CENTRE));
  at[n++] = CENTRE - w;
  at[n++] = CENTRE + w + 1;
  const int16_t r[4] = {IRIS_R, RING_R, PUPIL_R, SPARK_R};
  for (int i = 0; i < 4; i++) {
    int16_t cx = CENTRE + k.ix, cy = CENTRE + k.iy;
    if (i == 3) {
      cx -= 16;
      cy -= 16;
    }
    int32_t rest = sqr(r[i]) - sqr(y - cy);
    if (rest >= 0) {
      int16_t half = isqrt(rest);
      at[n++] = cx - half;
      at[n++] = cx + half + 1;
    }
  }
  return n;
}

// Is row y one that may differ between the two looks? The rows the iris covers
// before and after, and the rows between the old and new edge of each lid.
bool rowMayDiffer(const Look &was, const Look &now, int16_t y) {
  if (was.ix != now.ix || was.iy != now.iy) {
    int16_t lo = min(CENTRE + was.iy, CENTRE + now.iy) - IRIS_R;
    int16_t hi = max(CENTRE + was.iy, CENTRE + now.iy) + IRIS_R;
    if (y >= lo && y <= hi) return true;
  }
  if (was.top != now.top && y >= min(was.top, now.top) && y <= max(was.top, now.top)) return true;
  if (was.bot != now.bot && y >= min(was.bot, now.bot) && y <= max(was.bot, now.bot)) return true;
  return false;
}

// Repaint one eye from the look it had to the look it has. `fromBlack` is the
// very first picture, when the glass is blank and every pixel needs painting.
void paint(Adafruit_GC9A01A &tft, const Look &was, const Look &now, bool fromBlack) {
  for (int16_t y = 1; y < 239; y++) {
    if (!fromBlack && !rowMayDiffer(was, now, y)) continue;
    int16_t at[22];
    uint8_t n = edges(now, y, at, 0);
    if (!fromBlack) n = edges(was, y, at, n);
    at[n++] = 0;
    at[n++] = 240;
    // sort the break points (a few dozen numbers, insertion sort is plenty)
    for (uint8_t i = 1; i < n; i++) {
      int16_t v = at[i];
      int8_t j = i - 1;
      while (j >= 0 && at[j] > v) {
        at[j + 1] = at[j];
        j--;
      }
      at[j + 1] = v;
    }
    int16_t runStart = -1, runEnd = 0;
    uint16_t runColour = 0;
    for (uint8_t i = 0; i + 1 < n; i++) {
      int16_t a = at[i], b = at[i + 1];
      if (a < 0) a = 0;
      if (b > 240) b = 240;
      if (b <= a) continue;
      uint16_t c = colourAt(now, a, y);
      bool changed = fromBlack ? c != BLACK : c != colourAt(was, a, y);
      if (changed && runStart >= 0 && runEnd == a && runColour == c) {
        runEnd = b;  // the same colour carries on: lengthen the run
        continue;
      }
      if (runStart >= 0) tft.drawFastHLine(runStart, y, runEnd - runStart, runColour);
      runStart = -1;
      if (changed) {
        runStart = a;
        runEnd = b;
        runColour = c;
      }
    }
    if (runStart >= 0) tft.drawFastHLine(runStart, y, runEnd - runStart, runColour);
  }
}

// What the eyes are doing.
float gazeX = 0, gazeY = 0;       // where the iris is now
float aimX = 0, aimY = 0;         // where it is heading
int blinkStep = -1;               // -1 not blinking, else frame of the blink
unsigned long nextBlink = 3000;
unsigned long idleSince = 0;
unsigned long nextDrift = 0;
unsigned long lastFrame = 0;
unsigned long lastReport = 0;
uint32_t seed = 12345;
bool buttonWas = false;

Look shown = {0, 0, 0, 239};  // what is on the glass now

int16_t randomRange(int16_t lo, int16_t hi) {
  seed = seed * 1664525u + 1013904223u;
  return lo + (int16_t)((seed >> 16) % (uint32_t)(hi - lo + 1));
}

// The blink as how far each lid has come in, frame by frame: quick to shut,
// slower to open.
const int16_t BLINK[] = {40, 90, 116, 116, 84, 52, 24};

Look lookNow() {
  int16_t closed = blinkStep >= 0 ? BLINK[blinkStep] : 0;
  // looking down lowers the upper lids a little, looking up lifts the lower
  int16_t lowerTop = gazeY > 0 ? (int16_t)(gazeY / 2) : 0;
  int16_t raiseBot = gazeY < 0 ? (int16_t)(-gazeY / 2) : 0;
  Look k;
  k.ix = (int16_t)gazeX;
  k.iy = (int16_t)gazeY;
  k.top = closed + lowerTop;
  k.bot = 239 - closed - raiseBot;
  return k;
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_SW, INPUT_PULLUP);
  leftEye.begin();
  rightEye.begin();
  leftEye.fillScreen(BLACK);
  rightEye.fillScreen(BLACK);
  Serial.println("Animatronic eyes ready");
  // paint everything once, from a black glass to the first look
  shown = lookNow();
  paint(leftEye, shown, shown, true);
  paint(rightEye, shown, shown, true);
}

void loop() {
  unsigned long now = millis();
  if (now - lastFrame < 25) return;  // about 40 frames a second at most
  lastFrame = now;

  // The stick: 0 to 4095 on each axis, 2048 in the middle.
  int rawX = analogRead(PIN_VRX);
  int rawY = analogRead(PIN_VRY);
  int dx = rawX - 2048, dy = rawY - 2048;
  bool pushed = abs(dx) > 400 || abs(dy) > 400;
  if (pushed) {
    aimX = dx * (float)MAX_LOOK / 2048.0f;
    aimY = dy * (float)MAX_LOOK / 2048.0f;
    idleSince = now;
  } else if (now - idleSince > 2500 && now >= nextDrift) {
    // nobody is steering: let the eyes wander a little
    aimX = randomRange(-30, 30);
    aimY = randomRange(-16, 16);
    nextDrift = now + randomRange(1200, 3000);
  } else if (now - idleSince <= 2500) {
    aimX = 0;
    aimY = 0;
  }
  // keep the iris inside the glass: a circle, not a square
  float reach = sqrtf(aimX * aimX + aimY * aimY);
  if (reach > MAX_LOOK) {
    aimX *= MAX_LOOK / reach;
    aimY *= MAX_LOOK / reach;
  }
  gazeX += (aimX - gazeX) / 3.0f;
  gazeY += (aimY - gazeY) / 3.0f;

  // The stick's own button blinks the eyes now; otherwise they blink on a timer.
  bool button = digitalRead(PIN_SW) == LOW;
  if (button && !buttonWas && blinkStep < 0) blinkStep = 0;
  buttonWas = button;
  if (blinkStep < 0 && now >= nextBlink) blinkStep = 0;

  Look next = lookNow();
  if (next.ix != shown.ix || next.iy != shown.iy || next.top != shown.top || next.bot != shown.bot) {
    paint(leftEye, shown, next, false);
    paint(rightEye, shown, next, false);
    shown = next;
  }

  if (blinkStep >= 0) {
    blinkStep++;
    if (blinkStep >= (int)(sizeof(BLINK) / sizeof(BLINK[0]))) {
      blinkStep = -1;
      nextBlink = now + randomRange(3000, 6000);
      Serial.println("blink");
    }
  }

  if (now - lastReport >= 500) {
    lastReport = now;
    Serial.print("look ");
    Serial.print(shown.ix);
    Serial.print(' ');
    Serial.print(shown.iy);
    Serial.print(" lids ");
    Serial.print(shown.top);
    Serial.print(' ');
    Serial.println(shown.bot);
  }
}

Code walkthrough

The key functions of the sketch, and what each one does.

setup sketch.ino

Runs once when the board starts. It gets the pins and libraries ready.

void setup() {
  Serial.begin(115200);
  pinMode(PIN_SW, INPUT_PULLUP);
  leftEye.begin();
  rightEye.begin();
  leftEye.fillScreen(BLACK);
  rightEye.fillScreen(BLACK);
  Serial.println("Animatronic eyes ready");
  // paint everything once, from a black glass to the first look
  shown = lookNow();
  paint(leftEye, shown, shown, true);
  paint(rightEye, shown, shown, true);
}

loop sketch.ino

Runs over and over for as long as the board has power. This is where the project does its work.

void loop() {
  unsigned long now = millis();
  if (now - lastFrame < 25) return;  // about 40 frames a second at most
  lastFrame = now;

  // The stick: 0 to 4095 on each axis, 2048 in the middle.
  int rawX = analogRead(PIN_VRX);
  int rawY = analogRead(PIN_VRY);
  int dx = rawX - 2048, dy = rawY - 2048;
  bool pushed = abs(dx) > 400 || abs(dy) > 400;
  if (pushed) {
    aimX = dx * (float)MAX_LOOK / 2048.0f;
    aimY = dy * (float)MAX_LOOK / 2048.0f;
    idleSince = now;
  } else if (now - idleSince > 2500 && now >= nextDrift) {
    // nobody is steering: let the eyes wander a little
    aimX = randomRange(-30, 30);
    aimY = randomRange(-16, 16);
    nextDrift = now + randomRange(1200, 3000);
  } else if (now - idleSince <= 2500) {
    aimX = 0;
    aimY = 0;
  }
  // keep the iris inside the glass: a circle, not a square
  float reach = sqrtf(aimX * aimX + aimY * aimY);
  if (reach > MAX_LOOK) {
    aimX *= MAX_LOOK / reach;
    aimY *= MAX_LOOK / reach;
  // ... 38 more lines in the editor

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • The display stays blank or shows garbage. Round display
    Check SCK, MOSI, CS, DC and RST wiring, and use 3.3 V logic. Pick a library that supports the GC9A01 and set its pins to match. Reset the display on start.
  • Colors look swapped or the image is shifted. Round display
    Try the library's RGB or BGR color order option and inversion option. The visible area is a circle, so keep important content near the center.
  • The axis reads about half scale at rest instead of exactly zero, and the values drift a little, so the cursor or motor creeps. Thumb stick
    Center is about half of the ADC range (about 512 on a 10 bit Arduino). Read it at startup and subtract that, then add a dead zone of a few counts.
  • The push button reads random values or always looks pressed. The switch pin is a plain contact to ground and has no pull-up on the stick. Thumb stick
    Set the switch pin to INPUT_PULLUP and treat LOW as pressed. Debounce it in code. Power the stick from the same voltage as the ADC reference.
  • A pin does not work as an input or output, or the board will not boot or upload. ESP32 DevKit V1
    GPIO 34, 35, 36 and 39 are input only with no pull-ups. GPIO 6 to 11 are used by flash, so avoid them. GPIO 0, 2 and 12 are strapping pins and can block boot if held wrong.
  • A 5 V sensor or signal damages the board or reads wrong. ESP32 DevKit V1
    ESP32 pins are 3.3 V and not 5 V tolerant. Use a level shifter or a divider for 5 V outputs. Power 3.3 V sensors from the 3V3 pin.

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.