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
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.
// 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.
// 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
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.
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 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.
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
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.
// 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
- 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.
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.