Game Engine Zero Vol 2 · The Math of Motion
ch 15 / 24
Chapter 15

Interpolation and Easing

Moving by a fraction

This chapter adds interpolation and easing. The ease lab moves eight dots between two posts, all from the same clock value t.

A dot that slides from column 80 to column 260 over two seconds is at column 125 after half a second. Half a second is one quarter of the time, and one quarter of 180 pixels is 45. Add that to 80 and the column is 125.

Keep the moving value as a fraction until the last step. An easing changes the fraction. Only after that does the code scale the fraction into pixels, colour, volume or another game unit.

Lerp and easing

∑ Math Interlude — three tools on t, and eight curves at one half

Linear interpolation, lerp, is the number the fraction t of the way from a to b: a + (b − a) × t. From 80 to 260 at t = 0.25 that is 80 + 180 × 0.25 = 125. Read backwards it is the inverse lerp: given a number v between a and b, the fraction of the way it is, (v − a) / (b − a), so 125 between 80 and 260 is 45 / 180 = 0.25. A remap is the two together: take v's fraction of one range and lerp it into another. A column of 125 on a track from 80 to 260 remapped to a volume from 0 to 10 is 0.25 of 10, which is 2.5. That is how a slider becomes a setting and a health of 37 out of 50 becomes a bar 74 per cent full.

An easing is a function of t that returns another fraction: 0 at t = 0, 1 at t = 1, and any curve between. Eight are worked here at t = 0.5. Linear is t, 0.5. Quad-in is t², 0.25, slow to start; quad-out is 1 − (1 − t)², 0.75, slow to finish. Cubic-in is t³, 0.125; cubic-out is 1 − (1 − t)³, 0.875. Sine-in-out is half a cosine wave stood up as a ramp, 0.5 − 0.5 cos(πt), which is 0.5 at the middle and slow at both ends. Smoothstep is the cubic 3t² − 2t³ on a clamped t, 0.15625 at t = 0.25 and one half at the middle; it starts and ends level and never leaves [0, 1].

Two more overshoot or bounce. Bounce-out is four parabolas of the same steepness, 7.5625, which is 2.75², laid end to end and each landing lower: at t = 0.5 the second one applies, and 7.5625 × (0.5 − 1.5/2.75)² + 0.75 = 0.7656. Elastic-out is a sine wave whose amplitude falls by half every tenth of the run, added to one: 2−10t sin((t − p/4) · 2π/p) + 1 with a period p of 0.3. At t = 0.5 the power of two is 1/32, the sine's argument is (0.5 − 0.075) × 20.944 = 8.901 radians, which is 150 degrees past a full turn, and its sine is 0.5, so the value is 1 + 1/64 = 1.015625. It is past one. That is the fact the lab prints, and the reason a quantity that must not exceed its end, an opacity, a volume, a health fraction, is never given this easing: the eased fraction is 1.0156 of the way from 80 to 260, which is column 262.8, and the dot is past the post.

tthe clock as a fraction, 0 at the start and 1 at the end: tick 30 of 120 is 0.25
lerp(a, b, t)a + (b − a)·t: lerp(80, 260, 0.25) = 125
inverse lerp(v − a) / (b − a): 125 between 80 and 260 is 0.25; a range of no width gives 0
remapan inverse lerp in one range then a lerp in another: 125 in 80..260 is 2.5 in 0..10
easinga function from t to a fraction, 0 at 0 and 1 at 1: quad-in is t², 0.25 at one half; cubic-out is 1 − (1 − t)³, 0.875
2−10telastic-out's falling amplitude, 1/32 at t = 0.5; with the sine at 0.5 the easing is 1 + 1/64 = 1.015625, past one
▣ Build · stage 1 — lerp, its inverse, remap, smoothstep, and the eight easings
// internal/vec/ease.go — create
package vec

import "math"

// Lerp is the number the fraction t of the way from a to b: a at t = 0,
// b at t = 1, and past either end for a t outside [0, 1].
func Lerp(a, b, t float64) float64 {
	return a + (b-a)*t
}

// InverseLerp is Lerp read backwards: the fraction of the way from a to b
// that v is. When a and b are the same number there is no way to be part
// of, and the answer is 0.
func InverseLerp(a, b, v float64) float64 {
	if a == b {
		return 0
	}
	return (v - a) / (b - a)
}

// Remap takes v from the range inLo..inHi to the same fraction of the
// range outLo..outHi: an InverseLerp followed by a Lerp.
func Remap(v, inLo, inHi, outLo, outHi float64) float64 {
	return Lerp(outLo, outHi, InverseLerp(inLo, inHi, v))
}

// Clamp01 holds t inside [0, 1].
func Clamp01(t float64) float64 {
	return math.Max(0, math.Min(1, t))
}

// Smoothstep is the cubic that starts and ends level: 3t² − 2t³, on a
// clamped t. Half way it is one half, and it never leaves [0, 1].
func Smoothstep(t float64) float64 {
	t = Clamp01(t)
	return t * t * (3 - 2*t)
}

// An Easing takes a t in [0, 1] and returns the fraction of the way to go,
// 0 at t = 0 and 1 at t = 1, with any shape between and, for some, beyond.
type Easing func(t float64) float64

// Linear is t itself: the same speed all the way.
func Linear(t float64) float64 { return t }

// QuadIn starts slowly: t².
func QuadIn(t float64) float64 { return t * t }

// QuadOut ends slowly: 1 − (1 − t)², which is t(2 − t).
func QuadOut(t float64) float64 { return t * (2 - t) }

// CubicIn starts more slowly still: t³.
func CubicIn(t float64) float64 { return t * t * t }

// CubicOut ends more slowly still: 1 − (1 − t)³.
func CubicOut(t float64) float64 { u := 1 - t; return 1 - u*u*u }

// SineInOut is half a cosine wave turned into a ramp: slow at both ends.
func SineInOut(t float64) float64 { return 0.5 - 0.5*math.Cos(math.Pi*t) }

// The elastic easing's two constants: the period of its wobble as a
// fraction of the run, and its amplitude. They are design choices, and
// these are the values the lab ships with.
const (
	ElasticPeriod    = 0.3
	ElasticAmplitude = 1.0
)

// ElasticOut overshoots the end and rings down to it: a sine of falling
// amplitude added to one. The formula is not exactly 1 at t = 1, so the
// two ends are pinned.
func ElasticOut(t float64) float64 {
	if t <= 0 {
		return 0
	}
	if t >= 1 {
		return 1
	}
	p := ElasticPeriod
	return ElasticAmplitude*math.Pow(2, -10*t)*math.Sin((t-p/4)*2*math.Pi/p) + 1
}

// The bounce easing's constant: the steepness of its parabolas, which is
// the square of 2.75, the number of bounce widths that fit in one run.
const bounceK = 7.5625 // 2.75²

// BounceOut is four parabolas, each a bounce lower than the last, ending
// at rest on 1. It never passes 1.
func BounceOut(t float64) float64 {
	switch {
	case t < 1/2.75:
		return bounceK * t * t
	case t < 2/2.75:
		t -= 1.5 / 2.75
		return bounceK*t*t + 0.75
	case t < 2.5/2.75:
		t -= 2.25 / 2.75
		return bounceK*t*t + 0.9375
	default:
		t -= 2.625 / 2.75
		return bounceK*t*t + 0.984375
	}
}

// Easings lists the eight, in the order the lab draws them.
var Easings = []struct {
	Name string
	F    Easing
}{
	{"linear", Linear},
	{"quad-in", QuadIn},
	{"quad-out", QuadOut},
	{"cubic-in", CubicIn},
	{"cubic-out", CubicOut},
	{"sine-in-out", SineInOut},
	{"elastic-out", ElasticOut},
	{"bounce-out", BounceOut},
}
go vet ./...

Every easing in this file takes a fraction and returns a fraction. None of the functions knows whether the result will become pixels, colour or sound.

Lerp doesn't clamp. A t of 1.2 means past the end, and a caller that needs a bounded value can use Clamp01. InverseLerp guards a range with no width and returns zero instead of dividing by zero.

Easing names the function type, and Easings lists the functions in the order the lab draws them. ElasticOut pins its two ends because the formula gives 1.0005 at t = 1, not exactly 1.

Drawing the easing tracks

Extend cmd/motion with the ease lab. The lab keeps an integer clock from 0 to 120 and derives t from it. Ebitengine draws eight tracks and a dot on each one.

Every dot reads the same t. The easing function changes the fraction before Lerp turns it into a column. Space pauses and prints the columns, Space again resumes, and R starts the clock over.

▣ Build · stage 2 — the ease lab, and its name in newLab
// cmd/motion/ease.go — create
package main

import (
	"fmt"
	"strings"

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/vector"

	"gez/internal/vec"
)

// The ease lab runs a clock from 0 to 1 over two seconds and round again,
// and draws eight tracks, one an easing, with a dot on each at the eased
// fraction of the way from the left post to the right one. Space pauses
// the clock and resumes it; R starts it over.
const (
	easePeriod = 120   // ticks from t = 0 to t = 1; the clock starts over after
	easeLeft   = 80.0  // the left post, where t = 0 puts a dot
	easeRight  = 260.0 // the right post, where t = 1 puts a dot
	easeTop    = 30.0  // the first track's row
	easeGap    = 17.0  // rows between tracks
)

// easeLab keeps the clock and whether it is paused.
type easeLab struct {
	clock  int
	paused bool
}

// newEaseLab prints the interlude's numbers: a quarter of the way along a
// track and back, the eight easings at three values of t, and how far
// elastic-out overshoots.
func newEaseLab() *easeLab {
	x := vec.Lerp(easeLeft, easeRight, 0.25)
	fmt.Printf("tracks from %.0f to %.0f: a quarter of the way is lerp(%.0f, %.0f, 0.25) = %.4f; %.0f reads back as %.4f; remapped to 0..10 it is %.4f; smoothstep(0.25) = %.4f\n",
		easeLeft, easeRight, easeLeft, easeRight, x, x, vec.InverseLerp(easeLeft, easeRight, x), vec.Remap(x, easeLeft, easeRight, 0, 10), vec.Smoothstep(0.25))
	fmt.Printf("%8s", "t")
	for _, e := range vec.Easings {
		fmt.Printf(" %11s", e.Name)
	}
	fmt.Println()
	for _, t := range []float64{0.25, 0.5, 0.75} {
		fmt.Printf("%8.4f", t)
		for _, e := range vec.Easings {
			fmt.Printf(" %11.4f", e.F(t))
		}
		fmt.Println()
	}
	most, at := 0.0, 0.0
	for i := 0; i <= 1000; i++ {
		t := float64(i) / 1000
		if v := vec.ElasticOut(t); v > most {
			most, at = v, t
		}
	}
	fmt.Printf("elastic-out with period %.4f and amplitude %.4f: %.4f at t = 0.5, and at most %.4f, at t = %.3f\n",
		vec.ElasticPeriod, vec.ElasticAmplitude, vec.ElasticOut(0.5), most, at)
	return &easeLab{}
}

// t is the clock as a fraction: 0 on the first tick, 1 on tick 120, and
// then 0 again.
func (l *easeLab) t() float64 {
	return float64(l.clock) / easePeriod
}

// dotX is where a track's dot sits: the eased fraction of the way from the
// left post to the right.
func dotX(e vec.Easing, t float64) float64 {
	return vec.Lerp(easeLeft, easeRight, e(t))
}

// step runs the clock, or holds it. A press of Space that pauses the
// clock prints the eight dots' columns at that t.
func (l *easeLab) step(k keys) {
	if k.r {
		l.clock, l.paused = 0, false
		return
	}
	if k.space {
		l.paused = !l.paused
		if l.paused {
			l.print()
		}
		return
	}
	if !l.paused {
		l.clock = (l.clock + 1) % (easePeriod + 1)
	}
}

// print writes the clock and where each track's dot is.
func (l *easeLab) print() {
	t := l.t()
	parts := []string{fmt.Sprintf("tick %d t %.4f:", l.clock, t)}
	for _, e := range vec.Easings {
		parts = append(parts, fmt.Sprintf("%s %.4f", e.Name, dotX(e.F, t)))
	}
	fmt.Println(strings.Join(parts, "  "))
}

func (l *easeLab) draw(screen *ebiten.Image, h *hud) {
	t := l.t()
	for i, e := range vec.Easings {
		y := float32(easeTop + float64(i)*easeGap)
		vector.StrokeLine(screen, easeLeft, y, easeRight, y, 1, dimColor, false)
		vector.StrokeLine(screen, easeLeft, y-4, easeLeft, y+4, 1, dimColor, false)
		vector.StrokeLine(screen, easeRight, y-4, easeRight, y+4, 1, dimColor, false)
		x := dotX(e.F, t)
		c := lineColor
		if e.F(t) > 1 {
			c = yColor
		}
		vector.FillCircle(screen, float32(x), y, 3, c, false)
		h.text(screen, e.Name, 4, float64(y)-6)
	}
}

func (l *easeLab) lines() (top, bottom string) {
	state := "running"
	if l.paused {
		state = "paused"
	}
	return fmt.Sprintf("t %.4f  tick %d of %d  %s", l.t(), l.clock, easePeriod, state),
		fmt.Sprintf("elastic-out %.4f  bounce-out %.4f", vec.ElasticOut(l.t()), vec.BounceOut(l.t()))
}
// cmd/motion/main.go — extend
// newLab builds the lab named: the one place a name becomes a lab.
func newLab(name string) (lab, error) {
	switch name {
	case "circle":
		return newCircleLab(), nil
	case "arrows":
		return newArrowsLab(), nil
	case "ship":
		return newShipLab(), nil
	case "ball":
		return newBallLab(), nil
	case "ease":
		return newEaseLab(), nil
	}
	return nil, fmt.Errorf("no lab called %q", name)
}
go vet ./...
go run ./cmd/motion -lab ease
$ go run ./cmd/motion -lab ease
tracks from 80 to 260: a quarter of the way is lerp(80, 260, 0.25) = 125.0000; 125 reads back as 0.2500; remapped to 0..10 it is 2.5000; smoothstep(0.25) = 0.1562
       t      linear     quad-in    quad-out    cubic-in   cubic-out sine-in-out elastic-out  bounce-out
  0.2500      0.2500      0.0625      0.4375      0.0156      0.5781      0.1464      0.9116      0.4727
  0.5000      0.5000      0.2500      0.7500      0.1250      0.8750      0.5000      1.0156      0.7656
  0.7500      0.7500      0.5625      0.9375      0.4219      0.9844      0.8536      1.0055      0.9727
elastic-out with period 0.3000 and amplitude 1.0000: 1.0156 at t = 0.5, and at most 1.3731, at t = 0.135
Eight horizontal tracks with a name at the left of each: linear, quad-in, quad-out, cubic-in, cubic-out, sine-in-out, elastic-out, bounce-out. Each track has a post at either end and a white dot: linear's and sine-in-out's in the middle, quad-in's and cubic-in's to the left, quad-out's, cubic-out's and bounce-out's to the right. Elastic-out's dot is gold and sits just past the right post. The top line reads t 0.5000 tick 60 of 120 running, the bottom elastic-out 1.0156 bounce-out 0.7656, and the tick count reads 60.
The window at t = 0.5, tick 60: one clock, eight dots, and elastic-out's drawn gold because its value is past one.

The startup table prints the interlude to four places. At tick 60, the linear and sine-in-out dots are halfway. Quad-in is a quarter of the way. Cubic-in is an eighth of the way. Elastic-out is 1.0156, which puts its dot at column 262.8, past the post at 260.

The lab colours a dot gold when its easing is past one. Elastic-out reaches 1.3731 at t = 0.135, about a third of the track past the end. That overshoot suits a springy menu, but not opacity or health, where values past one have no useful meaning. Bounce-out stays at or under one.

The eight tracks at t = 0.1333, tick 16: most dots are near the left posts, quad-out's and cubic-out's a little further along, and the elastic-out track has no dot on it at all. The top line reads t 0.1333 tick 16 of 120 running and the bottom elastic-out 1.3729 bounce-out 0.1344.
Tick 16, t = 0.1333, as close as the clock comes to the overshoot's peak: elastic-out's dot is 67 pixels past the post, which is seven pixels past the right edge of the picture, so its track is empty and only the bottom line says where it went.

Press Space on tick 31, when the top line reads tick 30, and the clock pauses at t = 0.25 and prints the eight columns:

$ go run ./cmd/motion -lab ease
tick 30 t 0.2500:  linear 125.0000  quad-in 91.2500  quad-out 158.7500  cubic-in 82.8125  cubic-out 184.0625  sine-in-out 106.3604  elastic-out 244.0901  bounce-out 165.0781
The eight tracks at t = 0.25: linear's dot a quarter of the way, quad-in's and cubic-in's close to the left post, quad-out's and cubic-out's past the middle, elastic-out's white and near the right post, bounce-out's just short of half way. The top line reads t 0.2500 tick 30 of 120 running.
Tick 30, t = 0.25: the interlude's 125 on the linear track, and every other dot at its easing's own fraction of the same 180 pixels.

The linear column is the interlude's 125. Quad-in is 80 + 180 × 0.0625 = 91.25. Cubic-out is 80 + 180 × 0.5781 = 184.0625.

Every column comes from dotX: one lerp between the posts, with the eased fraction as t. The clock is an integer so the fraction is always derived from a count. A fraction accumulated by adding 1/120 each tick would drift. A fraction read from an integer counter doesn't.

⚠ Worked failure — the easing applied to the column instead of to t

dotX is one line, and the tempting mistake is to write it the other way round: lerp first, to get the column, and then ease the column, return e(vec.Lerp(easeLeft, easeRight, t)). Make that change, run the lab, and press Space on tick 31 as before:

$ go run ./cmd/motion -lab ease
tick 30 t 0.2500:  linear 125.0000  quad-in 15625.0000  quad-out -15375.0000  cubic-in 1953125.0000  cubic-out 1906625.0000  sine-in-out 1.0000  elastic-out 1.0000  bounce-out 116367.2500
The eight tracks with only the linear track's dot in place, a quarter of the way along. The quad, cubic and bounce tracks have no dot at all. The sine-in-out and elastic-out tracks each have a white dot at the picture's left edge, on column 1, far left of the posts. The top line reads t 0.2500 tick 30 of 120 running.
Stage 2's lab with the mistake, at tick 30: seven of the eight dots are off the screen or pinned to its edge, and only linear, for which easing changes nothing, is where it was.

Linear still works because it returns its input. Quad-in squares the column: 125² = 15625, which puts the dot far to the right of the window. Quad-out computes 125 × (2 − 125), a large negative number, so its dot goes far left.

The symptom points to the unit error. An easing expects a fraction, but this code handed it a pixel column. Put the order back: ease the fraction first, then scale it into pixels.

Stepping the clock by hand

Extend the paused clock so Left and Right move it one tick at a time. Each hand step prints the columns, so a specific value of t can be reached and checked.

▣ Build · stage 3 — the clock moved by hand, extend cmd/motion/ease.go
// cmd/motion/ease.go — extend
// step runs the clock, or holds it and moves it by hand. A press of
// Space that pauses the clock, and every tick moved by hand, prints the
// eight dots' columns at that t.
func (l *easeLab) step(k keys) {
	if k.r {
		l.clock, l.paused = 0, false
		return
	}
	if k.space {
		l.paused = !l.paused
		if l.paused {
			l.print()
		}
		return
	}
	if !l.paused {
		l.clock = (l.clock + 1) % (easePeriod + 1)
		return
	}
	if k.right && l.clock < easePeriod {
		l.clock++
		l.print()
	}
	if k.left && l.clock > 0 {
		l.clock--
		l.print()
	}
}

func (l *easeLab) lines() (top, bottom string) {
	state := "running"
	if l.paused {
		state = "paused: Left and Right move the clock"
	}
	return fmt.Sprintf("t %.4f  tick %d of %d  %s", l.t(), l.clock, easePeriod, state),
		fmt.Sprintf("elastic-out %.4f  bounce-out %.4f", vec.ElasticOut(l.t()), vec.BounceOut(l.t()))
}
go vet ./...
go run ./cmd/motion -lab ease

Pause at tick 30 as before, then hold Right until the clock reads 60; the lab prints a line a tick, and the last of them is the interlude's middle row in pixels:

$ go run ./cmd/motion -lab ease
tick 60 t 0.5000:  linear 170.0000  quad-in 125.0000  quad-out 215.0000  cubic-in 102.5000  cubic-out 237.5000  sine-in-out 170.0000  elastic-out 262.8125  bounce-out 217.8125

170 is halfway. 125 is quad-in's quarter of the track, because quad-in returns 0.25 at t = 0.5. Elastic-out's 262.8125 is 80 + 180 × 1.015625, 2.8 pixels past the post.

The keys move the integer clock. The columns use the same function as the running clock, so stepping by hand reaches the same values the animation passes through.

Sharing one clock

Every track has the same input type and output type. That makes an easing a function the program can swap by name. The code that moves a dot, fades a colour or changes a volume can keep the same clock and choose a different easing.

Keep the value as a fraction while it is shared. The moment it becomes pixels, it belongs to one track. A tween needs a start, an end, a tick count and an easing name. Everything drawn from it is derived on the tick it is drawn.

Checkpoint

✓ Checkpoint — what you can now do
  • Lerp a quarter of the way from 80 to 260 by hand, read 125 back as 0.25, and remap it into a range from 0 to 10.
  • Work quad-in, quad-out, cubic-in, cubic-out, sine-in-out and bounce-out at t = 0.5 on paper, and check each against the lab's printed table.
  • Derive elastic-out's 1.015625 at t = 0.5 from a power of two and one sine, and say which quantities must never be given an easing that passes one.
  • Draw eight tracks from one integer clock, and say why t is derived from the clock and not accumulated.
  • Take a printed column of 15625 back to an easing that was handed pixels instead of a fraction.
  • Pause a clock, walk it to an exact tick, and read the same columns the running clock passed through.
⚡ Exercises — try first, then reveal
Exercise 1 — a slower elastic. Change ElasticPeriod to 0.5 and predict, before running, whether the overshoot at t = 0.5 grows or shrinks. Then read the lab's start line.

The amplitude at t = 0.5 is still 1/32, but the sine's argument becomes (0.5 − 0.125) × 2π/0.5 = 4.712 radians, three quarters of a turn, whose sine is −1, so the easing at one half is 1 − 1/32 = 0.96875, short of one. A longer period wobbles fewer times, and where t = 0.5 falls in the wobble changes with it; the start line prints the new maximum and where it is, and the gold dot's trip past the post is slower and wider.

Exercise 2 — a ninth track. Add smoothstep to the table as a ninth easing and watch it beside sine-in-out.

One more line in Easings, {"smoothstep", Smoothstep}, and the loops in the lab do the rest; move easeGap to 15 so nine tracks fit. The two dots run almost together, both slow at the ends and both at the middle at t = 0.5, and differ by at most a few pixels: 3t² − 2t³ and 0.5 − 0.5 cos(πt) are two different formulas for nearly the same curve, and the polynomial is the one a shader would choose because it has no cosine in it.

Exercise 3 — a fade. Drive the court colour's brightness from the same clock: black at t = 0, the court colour at t = 1, through cubic-out. Then try elastic-out and read what the colour does.

In draw, fill a rectangle over the picture with a colour whose three channels are Lerp(0, 16, f), Lerp(0, 20, f) and Lerp(0, 28, f) for the eased f, converted to uint8. Cubic-out brightens fast and settles. Elastic-out asks for 137 per cent of the colour at t = 0.135 and the conversion to a byte wraps or clamps, so the screen flashes; that is the "must not overshoot" case, seen.