Easing, Smoothstep and Damping
What You’ll Learn
Section titled “What You’ll Learn”The handful of one-line functions that appear everywhere once you know their names - inverse
lerp, remap, clamp, smoothstep - and why the guard inside one of them stops a NaN.
Then the easing curves, treated as claims about mass and intent rather than decoration. And
finally the critically damped spring, which fixes the one thing Section 4.1’s decay gets
visibly wrong.
Rate Versus Shape
Section titled “Rate Versus Shape”Section 4.1 answered “how much of the gap should close this frame”. This Section answers a different question, and it is worth keeping them apart:
- Rate - the move has no fixed end. It approaches forever, getting closer. This is
damp, and the parameter is a half-life. - Shape - the move takes a fixed duration, and you decide how progress is distributed across it. This is easing, and the parameter is a curve.
Rate suits anything chasing a moving target: a camera following a player, a turret tracking. Shape suits anything with a defined beginning and end: a door opening, a menu sliding in, a card being dealt. Reaching for the wrong one is the usual cause of animation code that fights itself.
The Small Functions
Section titled “The Small Functions”Before curves, four functions that are each one line and each carry more weight than their size suggests.
lerp and clamp came in Section 4.1. The two new ones are inverses and companions of lerp:
inverseLerp(a, b, v)- lerp run backwards. Given a value, whattproduced it? This is how a raw quantity becomes a fraction you can drive something with.remap(v, inMin, inMax, outMin, outMax)-inverseLerpthenlerp. Carry a value from one range into another.
src/lib/gamedev/demos/remapping.ts /** Reading a fraction out of a range, and carrying a value into another one. */
import { clamp01, inverseLerp, remap } from "../interpolation.ts";
import { smoothstep } from "../easings.ts";
import type { Demo } from "./runner.ts";
const demo: Demo = (log) => {
log(
"inverseLerp(10, 20, 15)",
inverseLerp(10, 20, 15),
"halfway through the range",
);
log(
"inverseLerp(10, 20, 25)",
inverseLerp(10, 20, 25),
"past the end, and it says so rather than clamping",
);
log(
"clamp01 of that",
clamp01(inverseLerp(10, 20, 25)),
"clamp only when you mean to",
);
log(
"inverseLerp(5, 5, 5)",
inverseLerp(5, 5, 5),
"a zero-width range would divide by zero, so it is guarded",
);
log(
"remap(15, 10, 20, 0, 100)",
remap(15, 10, 20, 0, 100),
"same fraction, new range",
);
log(
"smoothstep(10, 20, 15)",
smoothstep(10, 20, 15),
"remap with an S curve, and clamped by design",
);
};
export default demo; Two rows there are worth reading twice.
inverseLerp does not clamp, and that is deliberate. A value past the end of the range reports
1.5 rather than 1, because silently flattening out-of-range data hides bugs. Clamp when you
mean to clamp - which is why clamp01 is a separate call.
A zero-width range is guarded. inverseLerp(5, 5, 5) would divide zero by zero and produce
NaN, and Part 1 established what happens next: NaN compares false against everything, spreads
through every arithmetic operation it touches, and surfaces somewhere far from the cause. So it
returns 0 instead. That guard is three tokens and it is the difference between a wrong number and
an unfindable one.
The everyday use is turning game state into presentation:
const t = inverseLerp(0, maxHealth, health); // 0..1barWidth = remap(health, 0, maxHealth, 0, 200); // pixelsSmoothstep
Section titled “Smoothstep”The most useful single easing function, and the one worth understanding rather than just calling.
What makes it good is not the polynomial, it is the slope at the ends. At and the slope is exactly zero, so anything driven by it leaves rest gently and settles rather than stopping dead. Compare with linear, whose slope is 1 the whole way and therefore starts and stops abruptly.
It also takes edges rather than a bare , matching the shader function of the same name, so it doubles as a clamped remap:
smoothstep(10, 20, 15); // 0.5 - and anything below 10 gives 0, above 20 gives 1Why smootherstep exists
Section titled “Why smootherstep exists”There is a second version, due to Ken Perlin:
Its slope is zero at the ends too. The difference is the curvature - the second derivative:
| Curve | Slope at ends | Curvature at ends |
|---|---|---|
| linear | ||
| smoothstep | ||
| smootherstep |
Smoothstep’s curvature jumps from 0 to 6 the instant the move begins. You cannot see that in a position, but you can see it in anything that gets differentiated - a camera path shows it as a snap, a normal map shows it as a crease. Smootherstep costs two more multiplies and removes it.
The build check measures both by finite differences rather than taking the algebra on trust: 6.0 for smoothstep, 0 for smootherstep.
The Gallery
Section titled “The Gallery”Now the curves together. Every faint line is one of them; the teal one is whichever you have selected, the orange dot rides the curve, and the teal dot below shows the movement it actually produces.
src/lib/gamedev/demos/easing.scene.ts /**
* Every easing curve at once, with a dot on the chosen one and the motion it produces below.
*/
import * as THREE from "three";
import { EASINGS } from "../easings.ts";
import { makeCanvas, addSlider, addReadout, addButtonRow } from "./ui.ts";
import type { MountFn } from "./runner.ts";
const LEFT = -2;
const RIGHT = 2;
const FLOOR = -0.45;
const CEIL = 1.55;
const TRACK_Y = -1.35;
/** Curve space to scene space. */
const px = (t: number) => LEFT + t * (RIGHT - LEFT);
const py = (v: number) => FLOOR + v * (CEIL - FLOOR);
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 330);
const scene = new THREE.Scene();
scene.background = background;
const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 100);
camera.position.set(0, 0.1, 6.4);
camera.lookAt(0, 0.1, 0);
const addLine = (pts: THREE.Vector3[], color: number) => {
const l = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(pts),
new THREE.LineBasicMaterial({ color }),
);
scene.add(l);
return l;
};
// The unit box, so the reader can see which parts of a curve leave it.
addLine(
[
new THREE.Vector3(px(0), py(0), 0),
new THREE.Vector3(px(1), py(0), 0),
new THREE.Vector3(px(1), py(1), 0),
new THREE.Vector3(px(0), py(1), 0),
new THREE.Vector3(px(0), py(0), 0),
],
0x30363d,
);
const samples = (fn: (t: number) => number) => {
const pts: THREE.Vector3[] = [];
for (let i = 0; i <= 160; i += 1) {
const t = i / 160;
pts.push(new THREE.Vector3(px(t), py(fn(t)), 0));
}
return pts;
};
// Every curve, dim. The gallery is the point: shapes are easier to compare side by side.
for (const e of EASINGS) addLine(samples(e.fn), 0x3d444d);
const chosenCurve = addLine(samples(EASINGS[0].fn), 0x39d3c3);
const onCurve = new THREE.Mesh(
new THREE.SphereGeometry(0.075, 12, 8),
new THREE.MeshBasicMaterial({ color: 0xf0883e }),
);
scene.add(onCurve);
// The motion the curve actually produces, which is the thing a player sees.
addLine(
[
new THREE.Vector3(px(0), TRACK_Y, 0),
new THREE.Vector3(px(1), TRACK_Y, 0),
],
0x30363d,
);
const mover = new THREE.Mesh(
new THREE.SphereGeometry(0.13, 14, 10),
new THREE.MeshBasicMaterial({ color: 0x39d3c3 }),
);
scene.add(mover);
let chosen = 0;
const show = addReadout(el);
const setActive = addButtonRow(
el,
EASINGS.map((e, i) => ({
label: e.name,
apply: () => {
chosen = i;
chosenCurve.geometry.setFromPoints(samples(e.fn));
draw();
},
})),
);
const t = addSlider(
el,
"progress through the move",
0,
1,
0.35,
draw,
"",
0.01,
);
function draw() {
const e = EASINGS[chosen];
const v = e.fn(t());
onCurve.position.set(px(t()), py(v), 0);
mover.position.set(px(v), TRACK_Y, 0);
setActive(chosen);
show(
`${e.name} \u00B7 ${e.says} \u00B7 ` +
`t ${t().toFixed(2)} becomes ${v.toFixed(2)}`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Try linear, then easeOutQuad, then easeOutBack. Same duration, same start, same end - three
completely different impressions of what the moving thing is made of.
That is the real content of this section. An easing curve is a claim about mass and intent:
| Curve | Reads as | Use for |
|---|---|---|
linear | mechanical, no mass | conveyor belts, timers, loading bars |
easeInQuad | heavy, getting going | something starting to move under its own power |
easeOutQuad | arriving deliberately | UI elements landing, a door settling shut |
easeInOutCubic | purposeful at both ends | camera cuts, menu transitions |
smoothstep | the default S curve | anything, when in doubt |
easeOutBack | snappy, slight overshoot | buttons, pickups, anything wanting attention |
easeOutElastic | cartoonish, springy | sparingly, and never on something frequent |
The slope at is a good numerical proxy for “how abruptly does this begin”. The check pins a
few: easeInQuad starts at 0, linear at 1, easeOutQuad at 2, easeOutBack at 4.7.
source The curves, and the spring
/**
* The **shape** of a movement, as opposed to its rate.
*
* `interpolation.ts` answers "how much of the gap should close this frame". This file answers a
* different question: given that a move takes a fixed amount of time, how should the progress be
* distributed across it? Fast then slow, slow then fast, overshoot and settle - each reads as a
* different physical claim about the thing moving.
*
* Every curve here takes `t` in [0, 1] and returns a shaped value that is 0 at 0 and 1 at 1. The
* last section is the exception: a spring has no fixed duration, so it takes a timestep instead.
*/
import { clamp01, inverseLerp } from "./interpolation.ts";
/**
* The classic S curve, flat at both ends.
*
* The reason it is everywhere: its slope is **zero** at 0 and at 1, so motion driven by it eases
* out of rest and settles rather than starting and stopping abruptly. Takes edges rather than a
* bare `t` so it can map a range directly, matching the shader function of the same name.
*/
export function smoothstep(edge0: number, edge1: number, x: number): number {
const t = clamp01(inverseLerp(edge0, edge1, x));
return t * t * (3 - 2 * t);
}
/**
* Ken Perlin's refinement. Zero slope **and** zero curvature at both ends.
*
* Worth the two extra multiplies when the value feeds something that itself gets differentiated -
* a camera path, a normal map - because a jump in curvature is visible as a crease even when the
* value and its slope are continuous.
*/
export function smootherstep(edge0: number, edge1: number, x: number): number {
const t = clamp01(inverseLerp(edge0, edge1, x));
return t * t * t * (t * (t * 6 - 15) + 10);
}
// ---- The curve family ---------------------------------------------------------------------
/** No shaping at all. Constant speed, which reads as mechanical. */
export const linear = (t: number) => t;
/** Accelerating from rest. Reads as something heavy getting going. */
export const easeInQuad = (t: number) => t * t;
/** Decelerating into the target. Reads as arriving deliberately. */
export const easeOutQuad = (t: number) => 1 - (1 - t) * (1 - t);
/** Accelerate then decelerate, harder than smoothstep at both ends. */
export const easeInOutCubic = (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - 4 * (1 - t) * (1 - t) * (1 - t);
/** Smoothstep as a plain easing curve, with the edges fixed at 0 and 1. */
export const smoothstep01 = (t: number) => smoothstep(0, 1, t);
/** Smootherstep the same way. */
export const smootherstep01 = (t: number) => smootherstep(0, 1, t);
/**
* Overshoots the target and comes back. Reads as snappy and deliberate.
*
* Note this leaves [0, 1] on purpose - it peaks above 1 - so anything consuming it must tolerate
* that. Clamping it removes the entire effect.
*/
export const easeOutBack = (t: number) => {
const c = 1.70158;
const u = t - 1;
return 1 + (c + 1) * u * u * u + c * u * u;
};
/** Overshoots several times with shrinking amplitude. Reads as cartoonish, and wears out fast. */
export const easeOutElastic = (t: number) => {
if (t === 0 || t === 1) return t;
return (
Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1
);
};
/**
* The gallery, with what each one says to a player.
*
* The third field is the part that actually matters when choosing. Easing is not decoration - a
* curve is a claim about mass and intent, and picking the wrong one makes a light object feel
* heavy or a deliberate action feel accidental.
*/
export const EASINGS: ReadonlyArray<{
name: string;
fn: (t: number) => number;
says: string;
}> = [
{ name: "linear", fn: linear, says: "mechanical, no mass" },
{ name: "easeInQuad", fn: easeInQuad, says: "heavy, getting going" },
{ name: "easeOutQuad", fn: easeOutQuad, says: "arriving deliberately" },
{ name: "easeInOutCubic", fn: easeInOutCubic, says: "purposeful, both ends" },
{ name: "smoothstep", fn: smoothstep01, says: "the default S curve" },
{ name: "smootherstep", fn: smootherstep01, says: "S curve, no crease" },
{ name: "easeOutBack", fn: easeOutBack, says: "snappy, slight overshoot" },
{ name: "easeOutElastic", fn: easeOutElastic, says: "cartoonish, springy" },
];
// ---- Springs ------------------------------------------------------------------------------
/** A spring carries velocity between frames, so it needs somewhere to keep it. */
export type SpringState = { value: number; velocity: number };
/**
* One frame of **critically damped** spring smoothing.
*
* Critically damped means the fastest approach that does not overshoot: any less damping and it
* oscillates around the target, any more and it crawls. That boundary is the one worth having as
* a default, because it is the fastest motion that never looks like a mistake.
*
* This is the exact solution of the spring equation rather than a step-by-step approximation,
* which is what makes it frame-rate independent for the same reason `decayFactor` is: composing
* exact solutions over consecutive intervals gives the exact solution over the whole.
*
* What it buys over `damp` is **continuous velocity**. Exponential decay's speed depends only on
* distance, so a target that jumps makes it lurch instantly. A spring has to accelerate first, so
* it eases out of rest as well as into the target.
*/
export function springStep(
state: SpringState,
target: number,
smoothTime: number,
dt: number,
): SpringState {
const omega = 2 / smoothTime;
const change = state.value - target;
// The velocity the solution needs in order to match both position and speed at t = 0.
const b = state.velocity + omega * change;
const decay = Math.exp(-omega * dt);
return {
value: target + (change + b * dt) * decay,
velocity: (state.velocity - omega * b * dt) * decay,
};
} The Critically Damped Spring
Section titled “The Critically Damped Spring”Back to rate-based motion, and the thing Section 4.1’s damp gets wrong.
Exponential decay’s speed depends only on distance from the target. So the moment a target appears, the follower is already at maximum speed. It has no history and no momentum - it lurches.
A spring does not, because it carries velocity between frames. It has to accelerate before it can move, so it eases out of rest as well as into the target.
Below, the same move both ways. Orange is decay, teal is the spring.
src/lib/gamedev/demos/spring.scene.ts /**
* Exponential decay against a critically damped spring, and the difference at the very start.
*/
import * as THREE from "three";
import { HALF_LIFE, SMOOTH_TIME, decayAt, springAt } from "./spring-shared.ts";
import { makeCanvas, addSlider, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";
const SECONDS = 1;
const LEFT = -2;
const RIGHT = 2;
const FLOOR = -0.5;
const CEIL = 1.35;
const TRACK_Y = -1.4;
const px = (t: number) => LEFT + (t / SECONDS) * (RIGHT - LEFT);
const py = (v: number) => FLOOR + v * (CEIL - FLOOR);
const DECAY = 0xf0883e;
const SPRING = 0x39d3c3;
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 320);
const scene = new THREE.Scene();
scene.background = background;
const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 100);
camera.position.set(0, 0, 6.2);
camera.lookAt(0, 0, 0);
const addLine = (pts: THREE.Vector3[], color: number) => {
const l = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(pts),
new THREE.LineBasicMaterial({ color }),
);
scene.add(l);
return l;
};
// The target line, and the floor the move starts from.
addLine(
[
new THREE.Vector3(px(0), py(1), 0),
new THREE.Vector3(px(SECONDS), py(1), 0),
],
0x30363d,
);
addLine(
[
new THREE.Vector3(px(0), py(0), 0),
new THREE.Vector3(px(SECONDS), py(0), 0),
],
0x30363d,
);
const curve = (f: (t: number) => number, color: number) => {
const pts: THREE.Vector3[] = [];
for (let i = 0; i <= 200; i += 1) {
const t = (i / 200) * SECONDS;
pts.push(new THREE.Vector3(px(t), py(f(t)), 0));
}
return addLine(pts, color);
};
curve(decayAt, DECAY);
curve(springAt, SPRING);
const dot = (color: number, r: number) => {
const m = new THREE.Mesh(
new THREE.SphereGeometry(r, 12, 8),
new THREE.MeshBasicMaterial({ color }),
);
scene.add(m);
return m;
};
const onDecay = dot(DECAY, 0.075);
const onSpring = dot(SPRING, 0.075);
addLine(
[
new THREE.Vector3(px(0), TRACK_Y, 0),
new THREE.Vector3(px(SECONDS), TRACK_Y, 0),
],
0x30363d,
);
const movingDecay = dot(DECAY, 0.12);
const movingSpring = dot(SPRING, 0.12);
const show = addReadout(el);
const time = addSlider(
el,
"seconds since the target appeared",
0,
SECONDS,
0.05,
draw,
" s",
0.005,
);
function draw() {
const t = time();
const d = decayAt(t);
const s = springAt(t);
onDecay.position.set(px(t), py(d), 0);
onSpring.position.set(px(t), py(s), 0);
movingDecay.position.set(px(d * SECONDS), TRACK_Y + 0.16, 0);
movingSpring.position.set(px(s * SECONDS), TRACK_Y - 0.16, 0);
show(
`at ${t.toFixed(3)} s: decay has closed ${(d * 100).toFixed(1)}%, ` +
`spring ${(s * 100).toFixed(1)}%`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Scrub to the very beginning. At one hundredth of a second the decay has already closed 6.7% of the gap while the spring has managed 0.2% - more than thirty times further, from the same standing start. Look at where each curve meets the left edge: orange arrives at an angle, teal arrives flat.
What “critically damped” means
Section titled “What “critically damped” means”A spring has a damping amount, and three regimes:
| Damping | Behaviour |
|---|---|
| Under-damped | overshoots and oscillates around the target |
| Critical | the fastest approach that never overshoots |
| Over-damped | no overshoot, but crawls |
Critical is the boundary, and it is the right default precisely because it is the fastest motion that can never look like a mistake. The check runs 3,000 steps and asserts the peak never exceeds the target - measured overshoot is exactly zero.
springStep uses the exact solution of the spring equation rather than stepping an
approximation, which means it inherits Section 4.1’s frame-rate independence for the same reason:
composing exact solutions over consecutive intervals gives the exact solution over the whole. The
check confirms it agrees to within from 15 fps to 240 fps, and the value at half a
second is identical to twelve decimal places even at one frame per second.
source The rate-based side, grown since Section 4.1
/**
* Blending values, and blending them at a rate that does not depend on the frame rate.
*
* The first three functions are the small ones everything else is built from. The last group is
* the point of Section 4.1: a blend factor computed **from the timestep** rather than picked as
* a constant, so the same code feels the same on a 30 Hz laptop and a 144 Hz monitor.
*
* Section 4.2 adds the easing family to this file.
*/
/** Start at `a`, go a fraction `t` of the way towards `b`. */
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
/** Hold a value inside a range. */
export function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
/** The common case: keep a blend factor inside [0, 1]. */
export function clamp01(v: number): number {
return clamp(v, 0, 1);
}
/**
* Lerp run backwards: given a value, what `t` would have produced it?
*
* Answers "how far through this range am I", which is how a raw quantity becomes a fraction you
* can drive something else with. Deliberately not clamped, so a value outside the range reports
* honestly - `inverseLerp(0, 10, 15)` is `1.5`, not `1`.
*
* The guard matters. A zero-width range divides by zero and produces `NaN`, which then spreads
* silently through everything downstream, so it returns 0 instead.
*/
export function inverseLerp(a: number, b: number, v: number): number {
if (a === b) return 0;
return (v - a) / (b - a);
}
/**
* Carry a value from one range into another - the workhorse of any code touching a UI.
*
* It is just `inverseLerp` followed by `lerp`: work out how far through the input range the value
* sits, then go that far through the output range.
*/
export function remap(
v: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number,
): number {
return lerp(outMin, outMax, inverseLerp(inMin, inMax, v));
}
// ---- Frame-rate independence -------------------------------------------------------------
/**
* The blend factor to use this frame, given a decay `rate` and however long the frame took.
*
* This is the whole fix. A constant factor closes a fixed **fraction per frame**, so more
* frames means faster convergence and the feel of the game changes with the hardware. This
* closes a fixed fraction **per second** instead, by asking how much time actually passed.
*
* The reason it works is that the leftover distance after a step is `exp(-rate * dt)`, and
* multiplying those together over a series of steps adds the exponents - so the total only
* depends on the total time, never on how it was chopped up.
*/
export function decayFactor(rate: number, dt: number): number {
return 1 - Math.exp(-rate * dt);
}
/**
* Convert a **half-life** into a decay rate.
*
* Half-life is the number to expose to whoever is tuning the feel, because it means something
* out loud: "the camera closes half the remaining distance every 0.15 seconds". A raw rate
* means nothing to anybody.
*/
export function rateFromHalfLife(halfLife: number): number {
return Math.LN2 / halfLife;
}
/** Back the other way, for reading a rate someone else picked. */
export function halfLifeFromRate(rate: number): number {
return Math.LN2 / rate;
}
/**
* One frame of frame-rate-independent smoothing towards a target.
*
* The drop-in replacement for `lerp(current, target, 0.1)`, and the only difference is that it
* is told how long the frame took.
*/
export function damp(
current: number,
target: number,
rate: number,
dt: number,
): number {
return lerp(current, target, decayFactor(rate, dt));
}
/** How much of the gap is still left after `seconds`, given a half-life. */
export function remainingAfter(halfLife: number, seconds: number): number {
return Math.pow(0.5, seconds / halfLife);
} Choosing
Section titled “Choosing”A short decision procedure, since there are now several tools:
- Does the move have a fixed duration? Use an easing curve. Pick it by what you want the thing to feel like it is made of.
- Is it chasing something that moves? Use a rate.
dampif you want it to react instantly to a new target,springStepif you want weight and momentum. - In doubt on a curve?
smoothstep. - In doubt on a camera?
springStepwith a smooth time around s. - Is the value feeding something differentiated - a path, a normal, another velocity?
smootherstep.
Where This Shows Up
Section titled “Where This Shows Up”- Every UI transition, where the curve choice is most of what makes an interface feel considered or cheap.
- Camera behaviour, where a spring reads as a real camera operator and decay reads as a mechanism.
- Health bars, meters and any gauge, which is
remapplus a curve. - Procedural blending - terrain masks, shader thresholds, fog falloff - which is smoothstep with real edges rather than 0 and 1.
- Animation curve editors, which are this Section’s list exposed as a dropdown.