DEV Community

Cover image for Making two cartoon eyes feel alive: springs, saccades and blinks in SwiftUI
Matthew Charles Vladislav Busel
Matthew Charles Vladislav Busel

Posted on Fully Autonomous

Making two cartoon eyes feel alive: springs, saccades and blinks in SwiftUI

Minder is a focus timer that's just a black screen and two eyes. You prop your phone up, slide to focus, and the eyes watch you work. If you pick the phone up mid-session, they get very suspicious.

It's body doubling for people with ADHD (or anyone who works better when someone's watching), and the whole thing only works if the eyes feel alive. A looping animation reads as a screensaver within about ten seconds. Here's what made the difference, all in plain SwiftUI with a Canvas.

1. Everything is a spring

Every moving part (the eyelids and brows as a "face" vector, where the eyes look, a little vertical bob) is a damped spring stepped once per frame. This is the entire physics engine:

struct SpringVector {
    var x: [CGFloat]
    var v: [CGFloat]
    init(_ start: [CGFloat]) { x = start; v = Array(repeating: 0, count: start.count) }

    mutating func step(toward target: [CGFloat], dt: CGFloat, stiffness k: CGFloat, damping ratio: CGFloat) {
        let c = 2 * sqrt(k) * ratio
        for i in x.indices {
            let a = k * (target[i] - x[i]) - c * v[i]
            v[i] += a * dt
            x[i] += v[i] * dt
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The trick is giving each layer its own feel:

face.step(toward: expr.face.values, dt: dt, stiffness: 190, damping: 0.62)   // expressions settle with a little overshoot
gaze.step(toward: target, dt: dt, stiffness: 420, damping: 0.8)              // eyes are fast and nearly critical
bob.step(toward: [0], dt: dt, stiffness: 140, damping: 0.38)                 // a loose, bouncy nod
Enter fullscreen mode Exit fullscreen mode

An expression (happy, curious, proud, suspicious, sleepy...) is just a target vector. Switching expressions never pops, because the springs carry the face there. A nod is just a kick to the bob's velocity.

2. Eyes never sit still: saccades

Real eyes make tiny involuntary jumps. Adding a small random offset to the gaze target every 0.25 to 0.9 seconds is what stopped the eyes looking painted on:

if t > nextSaccadeAt {
    saccade = CGPoint(x: .random(in: -0.05...0.05), y: .random(in: -0.04...0.04))
    nextSaccadeAt = t + .random(in: 0.25...0.9)
}
Enter fullscreen mode Exit fullscreen mode

3. Blinks have a shape

A blink isn't symmetric: the lid closes in 70 ms and opens over the next 100 ms. Blinks come every 2.2 to 6.5 seconds, 18% of them are double blinks 240 ms apart, and when the eyes are sleepy the whole blink plays at under half speed. Those numbers came from fiddling until it stopped looking robotic.

4. Layers of attention

The brain has a fast layer (springs, saccades, blinks, every frame) and a slow layer that changes moods over seconds and minutes. When you're idle the eyes look around; after a while they get sleepy, then fall asleep. During a focus session they settle into attentive, and every 30 seconds to a minute or so pick a random flash: happy, curious, proud, attentive, or a quick suspicious glance.

Two sensors feed it:

  • Core Motion: when the phone is picked up mid-session, the eyes go surprised, then suspicious, and look up at you for a few seconds.
  • Optional face tracking (Vision, front camera, part of Pro): the eyes follow your face, and if you've been gone for a while and come back, they look happy and nod. Nothing is recorded or leaves the phone.

Try it

Minder is free on the App Store (iPhone): https://apps.apple.com/app/id6813451929

The source is MIT on GitHub: https://github.com/Mattbusel/minder

If you've built "alive" characters before, I'd love to know what tricks you used. Mine are all above; the springs did 80% of the work.

Top comments (0)