Lerp, Easing and Smoothing
What You’ll Learn
Section titled “What You’ll Learn”Four functions do most of the arithmetic in a game: lerp to blend, inverseLerp to ask how far
along, remap to move a value between ranges, and clamp to keep it in bounds. Everything else in
this Section is a shape laid on top of them.
The shape is not decoration. A menu that slides in at a constant speed and a menu that eases to a stop are the same movement over the same time, and readers describe one as cheap and the other as polished without being able to say why. The difference is a curve, and choosing it deliberately is most of what “feel” means.
Easing Is Not Damping, And The Difference Decides Which To Use
Section titled “Easing Is Not Damping, And The Difference Decides Which To Use”Section 4.1 ended with exponential decay. This Section is not a nicer version of it — the two solve different problems, and reaching for the wrong one is the common mistake.
| Easing | Decay (Section 4.1) | |
|---|---|---|
| You must know | start, end and duration | only the current target |
| The target can move | no, not without restarting | yes, that is the point |
| Arrival | exact, at exactly the stated time | asymptotic, never quite |
| Can overshoot | yes, on purpose | no |
| Natural use | a menu, a door, a scripted camera move | a follow camera, a health bar, aim |
The arrival row is the one that matters. With a s half-life, decay has closed of the gap after s, after s, and never all of it. A tween with a s duration is at the target at s exactly, and the build asserts that as an equality rather than a tolerance — for all eight curves, at five different frame rates.
The Four Workhorses
Section titled “The Four Workhorses”They are each other’s inverse, and remap is simply the two composed:
That one line removes most of the arithmetic from gameplay code. Stick position to a turn rate of . Health to a bar width in pixels. Distance to a volume of , which is a fade-out written as one expression.
src/lib/gamedev/demos/2d/remap.ts /** The four functions that remove most of the arithmetic from gameplay code, and the one gotcha. */
import {
clamp,
inverseLerp,
lerp,
remap,
remapClamped,
} from "../../../gamedev2d/easing2d.ts";
import type { Demo } from "../runner.ts";
const demo: Demo = (log) => {
log(
"lerp(10, 50, 0.25)",
lerp(10, 50, 0.25),
"a quarter of the way from 10 to 50",
);
log(
"inverseLerp(10, 50, 20)",
inverseLerp(10, 50, 20),
"the same question backwards: 20 is a quarter of the way along",
);
log(
"lerp(10, 50, inverseLerp(10, 50, 37))",
lerp(10, 50, inverseLerp(10, 50, 37)),
"so the two undo each other, which is what makes remap trustworthy",
);
// remap is those two composed, and it is the one that shows up everywhere.
log(
"remap(0.5, -1, 1, -180, 180)",
remap(0.5, -1, 1, -180, 180),
"stick position to degrees per second",
);
log(
"remap(75, 0, 100, 0, 240)",
remap(75, 0, 100, 0, 240),
"health to the width of its bar in pixels",
);
// The gotcha, and its fix. Both are useful; only one of them is usually meant.
log(
"lerp(0, 100, 2)",
lerp(0, 100, 2),
"lerp is not clamped - twice past the end, sometimes on purpose",
);
log(
"remapClamped(25, 2, 20, 1, 0)",
remapClamped(25, 2, 20, 1, 0),
`a listener past the far edge goes silent, not negative: inverseLerp said ` +
`${inverseLerp(2, 20, 25).toFixed(2)} and clamp cut it to ${clamp(inverseLerp(2, 20, 25), 0, 1)}`,
);
};
export default demo; The Gallery
Section titled “The Gallery”One slider, six curves, one instant. The faint marks are eleven equally spaced moments, so their spacing along each track is that curve’s speed: bunched where it is slow, spread where it is fast.
src/lib/gamedev/demos/2d/gallery.scene.ts /** Six easings at one instant: the curve, and a sprite that has got that far along its track. */
import { makeCanvas2D, dot as fillDot, label, line } from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addCheckbox, addReadout, addSlider } from "../controls.ts";
import {
GALLERY,
LAYOUT,
ghostTimes,
rowCentre,
trackX,
} from "./gallery-shared.ts";
import type { MountFn } from "../runner.ts";
const CURVE = "#58a6ff";
const SPRITE = "#7ee787";
const PAST = "#f0883e";
const GHOST = "#3b4552";
const GRID = "#252b33";
const TEXT = "#9198a1";
const DIM = "#636c76";
const mount: MountFn = (el) => {
const { ctx, clear } = makeCanvas2D(el, LAYOUT.height);
const show = addReadout(el);
const note = addReadout(el);
const time = addSlider(
el,
"t, the fraction of the way through",
0,
1,
0.25,
draw,
"",
0.01,
);
const ghosts = addCheckbox(
el,
"mark eleven equally spaced instants (the spacing is the speed)",
true,
draw,
);
function draw() {
clear();
const t = time();
const target = trackX(1);
label(ctx, "the curve", LAYOUT.curve.left, 16, TEXT);
label(ctx, "the sprite on its track", LAYOUT.track.left, 16, TEXT);
label(ctx, "target", target, LAYOUT.height - 8, DIM, "center");
GALLERY.forEach((entry, row) => {
const centre = rowCentre(row);
label(ctx, entry.name, LAYOUT.nameX, centre - 4, CURVE);
label(ctx, entry.reads, LAYOUT.nameX, centre + 11, DIM);
// ---- The curve, plotted small. t across, eased value up. ----
const box = LAYOUT.curve;
const bottom = centre + box.height / 2;
const top = centre - box.height / 2;
// The plot's vertical range covers 0 to 1 only, so an overshoot leaves the box on purpose.
const curveY = (value: number) => bottom - value * (bottom - top);
line(
ctx,
{ x: box.left, y: bottom },
{ x: box.left + box.width, y: bottom },
GRID,
{ width: 1 },
);
line(ctx, { x: box.left, y: top }, { x: box.left, y: bottom }, GRID, {
width: 1,
});
ctx.save();
ctx.strokeStyle = CURVE;
ctx.lineWidth = 1.6;
// Clipped to the box, so the one curve that leaves it is cut off rather than overlapping a row.
ctx.beginPath();
ctx.rect(box.left, top - 2, box.width, box.height + 4);
ctx.clip();
ctx.beginPath();
for (let i = 0; i <= 60; i += 1) {
const u = i / 60;
const x = box.left + u * box.width;
const y = curveY(entry.easing(u));
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.restore();
// Where the reader currently is on that curve.
fillDot(
ctx,
box.left + t * box.width,
curveY(entry.easing(t)),
2.5,
SPRITE,
);
// ---- The track, and the sprite on it. ----
line(
ctx,
{ x: LAYOUT.track.left, y: centre },
{ x: target, y: centre },
GRID,
{ width: 1 },
);
line(
ctx,
{ x: target, y: centre - 9 },
{ x: target, y: centre + 9 },
GRID,
{ width: 1 },
);
/* Equal steps in time, so unequal spacing is the curve's speed made visible. This is the whole
reason the gallery is a gallery: six speed profiles, side by side, at a glance. */
if (ghosts()) {
for (const u of ghostTimes()) {
fillDot(ctx, trackX(entry.easing(u)), centre, 1.8, GHOST);
}
}
const value = entry.easing(t);
fillDot(
ctx,
trackX(value),
centre,
LAYOUT.dotRadius,
value > 1 ? PAST : SPRITE,
);
});
// ---- The numbers that decide what is being looked at, computed rather than described. ----
const at = (name: string) =>
GALLERY.find((entry) => entry.name === name)!.easing(t);
const pct = (value: number) => `${(value * 100).toFixed(0)}%`;
show(
`at t = ${t.toFixed(2)}, ${pct(t)} of the time has gone \u00B7 ` +
`easeInQuad has covered ${pct(at("easeInQuad"))}, linear ${pct(at("linear"))}, ` +
`easeOutQuad ${pct(at("easeOutQuad"))}`,
);
const past = GALLERY.filter((entry) => entry.easing(t) > 1);
note(
past.length > 0
? `${past.map((entry) => entry.name).join(" and ")} ${past.length > 1 ? "are" : "is"} past the target by ` +
`${past.map((entry) => pct(entry.easing(t) - 1)).join(" and ")} of the distance \u2014 fine for a sprite, ` +
"not for an opacity"
: "every curve is short of the target, so any of these is safe on a value with a hard ceiling",
);
}
draw();
return () => {};
};
export default mount; Read it by the marks rather than by the shapes. easeInQuad has covered of the distance in
the first quarter of the time and in the last quarter; easeOutQuad is those two numbers
swapped. linear is in every quarter, which is why it is the only one with a sudden stop.
easeOutis the one to reach for by default. Things that arrive and settle read as having mass.easeInis for leaving, not arriving. Something easing in to a stop looks broken.easeInOutfor anything that both starts and stops on screen.linearon purpose, not by omission — a conveyor belt, a clock hand, a health bar draining.
What “Smooth” Actually Means
Section titled “What “Smooth” Actually Means”Here is a claim worth being careful about, because the first version of this Section got it wrong:
smoothstep is often introduced as the curve with zero slope at both ends. It has that. So do
easeInOutQuad, easeInOutCubic and smootherstep. Zero end slope separates none of them.
What separates them is curvature — the second derivative, which is the acceleration a reader feels as a lurch. All four are measured against their closed forms across the interval at build time:
| Curve | Curvature at the ends | In the middle | Peak speed |
|---|---|---|---|
linear | |||
easeInOutQuad | jumps | ||
easeInOutCubic | jumps | ||
smoothstep | passes through | ||
smootherstep | passes through |
Two things fall out of that table. Smoothstep is a single polynomial, so its curvature slides smoothly through zero in the middle instead of flipping sign, and its peak speed is the average rather than — the same distance in the same time with less of a surge. And smootherstep is the one with zero curvature at the ends; smoothstep’s is exactly , so its acceleration still arrives as a step even though its speed does not.
Which to use: smoothstep, almost always. Smootherstep when the eased value is itself differentiated — a camera whose velocity feeds something else — and you are willing to pay peak speed for it. The gentler ends have to be paid for in the middle; there is no free smoothness.
The Ones That Leave The Range
Section titled “The Ones That Leave The Range”Three curves overshoot, and the build measures each peak instead of quoting it from a reference:
easeOutBackpeaks at , at . That is what the famous constant is for: rounding it to gives , and gives . The digits buy a round number in the output, which is the number somebody was actually choosing.easeOutElasticpeaks at and crosses the target seven times on its way to settling. It is a strong effect and it wears out its welcome fast.easeOutBouncenever exceeds at all. It reaches the target four times — at , , and — falling back by , then , then between them. Each dip is exactly a quarter of the one before, which is where the dropped-object reading comes from.
So bounce is the safe one on a value with a hard ceiling: an opacity, a colour channel, a health fraction, a UI width that must not exceed its container. Back and elastic on any of those are a bug that only shows up at the moment the animation peaks.
Writing One, And Getting The Pair For Free
Section titled “Writing One, And Getting The Pair For Free”An in and an out are mirror images:
Worth having as a function rather than as a second hand-written formula. reverse(easeInQuad) and
easeOutQuad agree to the last bit across two thousand samples, and reversing any curve twice returns
the original — both asserted, so the pair can never drift apart under editing.
source The four workhorses, the S-curves, and the ones that overshoot
/**
* The small functions that turn a number into another number, and the curves that give motion a feel.
*
* Four of these do almost all the work in any game: `lerp` to blend, `inverseLerp` to ask how far along
* something is, `remap` to move a value from one range to another, and `clamp` to keep it in bounds.
* Everything else here is a shape applied on top.
*
* **This is a different tool from Section 4.1's decay, and the difference matters.** Easing animates
* between a known start and a known end over a known duration: it can overshoot, bounce, and land
* exactly on time. Decay chases a target that may be moving and never arrives exactly. Reach for easing
* when you know where and when something should finish, and for decay when you are following something.
*/
import { lerp } from "./time2d.ts";
export { lerp };
/** Keep a number inside a range. The most-used function in this file and the least discussed. */
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/** Clamp to the unit interval, which is what almost every easing wants of its input. */
export function clamp01(value: number): number {
return clamp(value, 0, 1);
}
/**
* `lerp` with the factor clamped, which is usually what was meant.
*
* Plain `lerp` is **not** clamped, and that is worth knowing rather than discovering: at `t = 2` it
* extrapolates to twice past the end. Sometimes that is exactly what you want - continuing a trajectory,
* predicting ahead - and sometimes it is a projectile leaving the level.
*/
export function lerpClamped(a: number, b: number, t: number): number {
return lerp(a, b, clamp01(t));
}
/**
* The opposite question to `lerp`: given a value, how far along the range is it?
*
* $$t = \frac{v - a}{b - a}$$
*
* A health bar's fill, a progress fraction, how far through a fade you are. Returns 0 for a
* zero-width range rather than dividing by zero - the honest answer, since every value is
* simultaneously at the start and the end of an empty range.
*/
export function inverseLerp(a: number, b: number, value: number): number {
return Math.abs(b - a) < 1e-12 ? 0 : (value - a) / (b - a);
}
/**
* Move a value from one range to another: the two above, composed.
*
* $$\text{remap}(v) = \text{lerp}\!\left(c, d, \text{inverseLerp}(a, b, v)\right)$$
*
* This is the function that removes most of the arithmetic from gameplay code. Stick position of
* $-1 \ldots 1$ to a turn rate of $-180 \ldots 180$; health of $0 \ldots 100$ to a bar width in pixels;
* a distance of $2 \ldots 20$ to a volume of $1 \ldots 0$, which is a fade-out written as one line.
*/
export function remap(
value: number,
fromA: number,
fromB: number,
toA: number,
toB: number,
): number {
return lerp(toA, toB, inverseLerp(fromA, fromB, value));
}
/** The same, refusing to leave the destination range. Usually the one you want for a volume or a colour. */
export function remapClamped(
value: number,
fromA: number,
fromB: number,
toA: number,
toB: number,
): number {
return lerp(toA, toB, clamp01(inverseLerp(fromA, fromB, value)));
}
/**
* Smoothstep: an S-curve that starts and stops **gently**, in one polynomial.
*
* $$3t^2 - 2t^3$$
*
* Zero slope at both ends - but so has `easeInOutQuad`, so that is not what distinguishes it. Two
* things do, both measured at build time. It is a **single** cubic rather than two parabolas stitched
* together, so its curvature passes smoothly through zero in the middle instead of jumping from $+4$
* to $-4$. And its peak speed is $1.5\times$ the average against the piecewise version's $2\times$, so
* the same distance in the same time is covered with less of a surge. Same function GLSL and HLSL
* provide, for the same reasons.
*/
export function smoothstep(edge0: number, edge1: number, x: number): number {
const t = clamp01(inverseLerp(edge0, edge1, x));
return t * t * (3 - 2 * t);
}
/**
* Smootherstep: zero slope **and** zero curvature at both ends.
*
* $$6t^5 - 15t^4 + 10t^3$$
*
* This is the thing smoothstep does not have. Smoothstep's curvature at $t = 0$ is exactly $6$, so the
* *acceleration* still arrives as a step even though the speed does not; here it is $0$, and the build
* checks it by watching the measurement fall tenfold for every tenfold step closer to the end.
*
* It matters when the thing being eased is itself differentiated - a camera whose velocity feeds
* something else. The cost is a higher peak speed, $1.875\times$ the average against $1.5\times$,
* because the gentler ends have to be paid for in the middle. For most motion the two are
* indistinguishable.
*/
export function smootherstep(edge0: number, edge1: number, x: number): number {
const t = clamp01(inverseLerp(edge0, edge1, x));
return t * t * t * (t * (6 * t - 15) + 10);
}
/** An easing takes a fraction of the way through and returns a fraction of the way there. */
export type Easing = (t: number) => number;
/** No easing at all. Constant speed, sudden start, sudden stop. */
export const linear: Easing = (t) => t;
/** Starts slow. Reads as something heavy getting going, or a menu sliding away. */
export const easeInQuad: Easing = (t) => t * t;
/** Ends slow. The workhorse: things arriving feel like they have mass and settle. */
export const easeOutQuad: Easing = (t) => 1 - (1 - t) * (1 - t);
/** Slow at both ends. The default for anything that both starts and stops on screen. */
export const easeInOutQuad: Easing = (t) =>
t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t);
/** A stronger version of the same three, for when quadratic is too subtle. */
export const easeInCubic: Easing = (t) => t * t * t;
export const easeOutCubic: Easing = (t) => 1 - Math.pow(1 - t, 3);
export const easeInOutCubic: Easing = (t) =>
t < 0.5 ? 4 * t * t * t : 1 - 4 * Math.pow(1 - t, 3);
/**
* The famous magic number, and it is not arbitrary: it is the value that makes the overshoot **10%**.
*
* Measured, since a constant with no derivation attached invites being rounded. At $1.70158$ the peak
* is $1.100004$; at a tidy $1.7$ it is $1.099843$, and at $2$ it is $1.131687$. So the digits are
* buying the round number in the *output*, which is the number a designer was actually choosing.
*/
const BACK = 1.70158;
/**
* Overshoots the target and comes back. Reads as eagerness, or as something snapping into place.
*
* **This leaves the 0 to 1 range on purpose**, which is the whole point of it and also the thing to
* check before using it on a value that must not exceed its bounds - a colour channel, an opacity, a
* health fraction. Its peak is asserted at build time rather than guessed at.
*/
export const easeOutBack: Easing = (t) =>
1 + (BACK + 1) * Math.pow(t - 1, 3) + BACK * Math.pow(t - 1, 2);
const ELASTIC = (2 * Math.PI) / 3;
/**
* Overshoots and oscillates before settling. Reads as springy, and wears out its welcome quickly.
*
* The two endpoint cases are **not** cosmetic tidying, which is worth knowing before deleting them.
* The formula alone gives $1.000488$ at $t = 1$ - it lands half a tenth of a percent past the target
* and stays there, because there is nothing after $t = 1$ to bring it back. The case returns the
* target exactly, which is the promise easing makes and decay cannot.
*/
export const easeOutElastic: Easing = (t) =>
t === 0
? 0
: t === 1
? 1
: Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ELASTIC) + 1;
/**
* Never leaves the range, but lands **four times**. Reads as a physical object dropping.
*
* Piecewise rather than a formula, which is why it is written out: each segment is one parabola. It
* reaches the target at $t = 0.3636$, $0.7273$, $0.9091$ and $1$, and between those it falls back by
* $0.25$, then $0.0625$, then $0.015625$ - each dip **exactly a quarter** of the one before, which is
* where the drop-and-settle reading comes from. Its maximum over the interval is $1$ and not a
* fraction more, so unlike the two above it is safe on a value with a hard ceiling.
*/
export const easeOutBounce: Easing = (t) => {
const n = 7.5625;
const d = 2.75;
if (t < 1 / d) return n * t * t;
if (t < 2 / d) return n * (t -= 1.5 / d) * t + 0.75;
if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375;
return n * (t -= 2.625 / d) * t + 0.984375;
};
/**
* Turn any easing into its mirror image, so one definition covers both directions.
*
* $$\text{out}(t) = 1 - \text{in}(1 - t)$$
*
* Worth having as a function rather than as a second hand-written formula: the pair can then never
* drift apart, and the identity is checkable.
*/
export function reverse(easing: Easing): Easing {
return (t) => 1 - easing(1 - t);
}
/**
* Animate between two values over a **fixed duration**, which is what easing is for.
*
* Frame-rate independent by construction, and for a different reason than Section 4.1's decay: the
* fraction is `elapsed / duration`, both measured in seconds, so the frame rate never enters. It also
* lands exactly on `to` at exactly `duration`, which decay can never promise.
*/
export function tween(
from: number,
to: number,
elapsed: number,
duration: number,
easing: Easing = linear,
): number {
if (duration <= 0) return to;
return lerp(from, to, easing(clamp01(elapsed / duration)));
} Where This Shows Up
Section titled “Where This Shows Up”- Section 4.3, where a sprite travels a Bezier path and the easing decides its speed along it.
- Section 3.3’s camera, eased between two framings for a scripted move rather than following.
- Part 5’s collision response, where
clampkeeps a penetration depth from going negative. - Any UI at all: panels, fades, bars, counters, damage numbers.
- The capstone, where coyote time and jump buffering turn out to be interpolation problems.