2.4 Interpolation, Curves & Easing

Phase 2 · Game Math · Study time: 15–25 h

Lerp, smoothstep, Bezier and spline curves, and easing functions — how movement, cameras and UI are made to feel good rather than robotic.

You already know how to store a position as a vector and move something by adding a vector to it each frame. This chapter is about a different, smaller question that shows up everywhere once you start building real gameplay: given two values, how do you smoothly blend between them, and how do you shape that blend so motion looks natural instead of robotic? Almost everything below builds on one formula you will type hundreds of times in a real project.

Same pattern as always: small code, real output, plain explanation. Interpolation looks like harmless math, but it also hides one of the most common real bugs in shipped games -- section 10 walks through it in detail, because it is worth knowing before you write your first "smooth follow" camera.

1. Lerp: blending between two values with lerp(a, b, t)

Interpolation means computing a value that sits between two known values. The most common form is linear interpolation, almost always called lerp. It takes a starting value a, an ending value b, and a number t (the interpolation parameter) that says how far along the blend you want to be. The formula is one line:

lerp(a, b, t) = a + (b - a) * t

Read t as a fraction of the distance from a to b. When t = 0 the formula gives back a exactly. When t = 1 it gives back b exactly. When t = 0.5 it gives the exact midpoint. Any t between 0 and 1 gives a point somewhere on the straight line between a and b.

a = 0 b = 10 |------|------|------|------|------|------|------|------|------|------| 0 1 2 3 4 5 6 7 8 9 10 lerp(a, b, 0.00) = 0 (t=0 -> exactly at a) lerp(a, b, 0.25) = 2.5 (a quarter of the way from a to b) lerp(a, b, 0.50) = 5 (exactly halfway) lerp(a, b, 0.75) = 7.5 (three quarters of the way) lerp(a, b, 1.00) = 10 (t=1 -> exactly at b)

Here is the formula as C++, and a loop that samples five values of t:

#include <iostream>

float lerp(float a, float b, float t) {
    return a + (b - a) * t;   // move (b-a) distance, scaled by t
}

int main() {
    float a = 0.0f, b = 10.0f;
    for (int i = 0; i <= 4; i++) {
        float t = i / 4.0f;                  // 0, 0.25, 0.5, 0.75, 1.0
        std::cout << "t=" << t << " lerp=" << lerp(a, b, t) << "\n";
    }
}

Output:

t=0 lerp=0
t=0.25 lerp=2.5
t=0.5 lerp=5
t=0.75 lerp=7.5
t=1 lerp=10

That is the whole idea: lerp is a weighted average of a and b, with t deciding how much weight each side gets. It works on floats, and it works on vectors too (you lerp x, y, and z the same way, each independently) -- that is exactly what a "move toward a target position" or a "fade a color" call is doing under the hood in Unity's Vector3.Lerp or Unreal's FMath::Lerp.

Tip C++20 added std::lerp(a, b, t) in <cmath>, which handles a few tricky floating-point edge cases better than the one-liner above. The one-liner is still worth knowing by heart, because you will write the exact same formula in C#, HLSL, and GLSL, where there is no built-in.

2. Going past 0 and 1: clamping and extrapolation

Nothing in the lerp formula stops you from passing a t smaller than 0 or bigger than 1. The math still works -- it just produces a point outside the segment from a to b. That is called extrapolation.

t < 0 ...... a ................ b ...... t > 1 (extrapolated, (extrapolated, lands before a) lands past b) lerp with t=-0.5 lands to the LEFT of a. lerp with t=1.5 lands to the RIGHT of b. clamp01(t) forces t into [0, 1] first, so the result can never leave [a, b].

Most of the time you do not want that -- a health bar fill percentage above 100% or below 0% makes no sense. The fix is to clamp t into the range [0, 1] before using it:

#include <iostream>

float lerp(float a, float b, float t) {
    return a + (b - a) * t;
}

float clamp01(float t) {
    if (t < 0.0f) return 0.0f;
    if (t > 1.0f) return 1.0f;
    return t;
}

int main() {
    float a = 0.0f, b = 10.0f;

    std::cout << "no clamp:\n";
    std::cout << "t=-0.5 lerp=" << lerp(a, b, -0.5f) << "\n";
    std::cout << "t=1.5  lerp=" << lerp(a, b, 1.5f)  << "\n";

    std::cout << "with clamp01:\n";
    std::cout << "t=-0.5 lerp=" << lerp(a, b, clamp01(-0.5f)) << "\n";
    std::cout << "t=1.5  lerp=" << lerp(a, b, clamp01(1.5f))  << "\n";
}

Output:

no clamp:
t=-0.5 lerp=-5
t=1.5  lerp=15
with clamp01:
t=-0.5 lerp=0
t=1.5  lerp=10

Without clamping, t=-0.5 gives -5 (below a) and t=1.5 gives 15 (above b). With clamp01 applied first, both are pulled back to the nearest edge. Most engine lerp functions (Mathf.Lerp in Unity, for example) clamp t for you automatically; functions named LerpUnclamped do not.

Tip Extrapolation is not always a bug. "Overshoot" or "back" easing curves (a UI panel that slides slightly past its resting spot before settling) deliberately use values outside [0, 1] for a bounce feel. It only becomes a bug when you did not mean for it to happen -- so know which of your lerp calls are clamped and which are not.

3. Inverse lerp: going backwards from a value to t

lerp answers "given a, b, and t, what value is that?" Inverse lerp answers the opposite question: "given a, b, and a value that lies between them, what t produced it?" You solve the lerp formula for t:

inverseLerp(a, b, value) = (value - a) / (b - a)
#include <iostream>

float inverseLerp(float a, float b, float value) {
    return (value - a) / (b - a);   // what fraction of the way is value?
}

int main() {
    std::cout << "t=" << inverseLerp(0.0f, 10.0f, 2.5f) << "\n";
    std::cout << "t=" << inverseLerp(0.0f, 10.0f, 10.0f) << "\n";
    std::cout << "t=" << inverseLerp(100.0f, 200.0f, 150.0f) << "\n";
}

Output:

t=0.25
t=1
t=0.5

2.5 is a quarter of the way from 0 to 10, so t=0.25. 10 is all the way at b, so t=1. 150 is exactly halfway between 100 and 200, so t=0.5. This is how you turn a raw game value (current health, current volume, a mouse x position) into a clean 0..1 fraction you can use for anything -- a bar's fill amount, an alpha value, a lerp parameter for something else.

Common mistake If a and b are equal, inverseLerp divides by zero and returns NaN or infinity in C++. This happens more often than you'd expect -- for example a health bar whose minHealth and maxHealth were never set apart. Guard against a == b if the inputs can ever collide.

4. Remap: jumping from one range to another

Remap combines the last two ideas: find how far a value is through its input range using inverseLerp, then use that same fraction to lerp into a different output range.

#include <iostream>

float lerp(float a, float b, float t) {
    return a + (b - a) * t;
}

float inverseLerp(float a, float b, float value) {
    return (value - a) / (b - a);
}

float remap(float value, float inMin, float inMax, float outMin, float outMax) {
    float t = inverseLerp(inMin, inMax, value);
    return lerp(outMin, outMax, t);
}

int main() {
    // joystick tilt -1..1 mapped to a turn speed of -90..90 degrees per second
    std::cout << "turnSpeed=" << remap(0.5f, -1.0f, 1.0f, -90.0f, 90.0f) << "\n";
    // health 0..100 mapped to a health-bar fill width of 0..200 pixels
    std::cout << "barWidth=" << remap(30.0f, 0.0f, 100.0f, 0.0f, 200.0f) << "\n";
}

Output:

turnSpeed=45
barWidth=60

A joystick tilt of 0.5 is 75% of the way from -1 to 1, and 75% of the way from -90 to 90 is 45. A health of 30 out of 100 is 30% of the way, and 30% of 0..200 pixels is 60. remap is one of the most-used utility functions in real game code: any time you convert a value from "the units it naturally comes in" to "the units something else needs", this is the tool.

5. Smoothstep: a smooth start and a smooth stop

Plain lerp moves at a constant rate -- equal steps of t always produce equal steps of the result. That is fine for data, but for anything a player watches, a constant rate looks mechanical, because almost nothing in the real world starts or stops moving instantly. Smoothstep is a small tweak that fixes that: instead of feeding t straight into lerp, you first reshape it with this formula, then lerp with the reshaped value:

smoothstep(t) = t * t * (3 - 2 * t)
#include <iostream>

float smoothstep(float t) {
    return t * t * (3.0f - 2.0f * t);
}

int main() {
    for (int i = 0; i <= 4; i++) {
        float t = i / 4.0f;
        std::cout << "t=" << t << " smoothstep=" << smoothstep(t) << "\n";
    }
}

Output:

t=0 smoothstep=0
t=0.25 smoothstep=0.15625
t=0.5 smoothstep=0.5
t=0.75 smoothstep=0.84375
t=1 smoothstep=1
t 0.00 0.25 0.50 0.75 1.00 lerp(t) 0.00 0.25 0.50 0.75 1.00 <-- straight ramp, constant speed smoothstep(t) 0.00 0.15625 0.50 0.84375 1.00 <-- flat at both ends, steep in the middle near t=0 and t=1, smoothstep barely changes (a gentle start and a gentle stop). near t=0.5, smoothstep moves faster than plain lerp to make up the difference.

Compare the two rows: at t=0.25, plain lerp is already a quarter of the way there, but smoothstep is only 0.15625 -- it eases in gently. Near the middle it speeds up, and near t=1 it eases out gently again. Mathematically, smoothstep's rate of change (its slope) is exactly zero at both t=0 and t=1, which is why the start and stop feel soft instead of abrupt. This single formula is used constantly for fades (screen fade to black), camera field-of-view blends, and any UI value that should not visibly "snap" into motion.

6. Easing: ease-in, ease-out, and ease-in-out

Easing functions are the general family that smoothstep belongs to: any function that reshapes t before it goes into a lerp, so the motion is not constant-speed. Three names come up constantly:

A simple quadratic version of each:

easeIn(t)  = t * t
easeOut(t) = 1 - (1 - t) * (1 - t)
t 0.0 0.2 0.4 0.6 0.8 1.0 linear 0.0 0.2 0.4 0.6 0.8 1.0 -- constant speed, feels robotic ease-in 0.0 0.04 0.16 0.36 0.64 1.0 -- starts slow, speeds up near the end ease-out 0.0 0.36 0.64 0.84 0.96 1.0 -- starts fast, slows down near the end look at t=0.2 (20% of the time has passed): linear has covered 20% of the distance ease-in has covered only 4% (still winding up) ease-out has covered 36% (already moving quickly)
#include <iostream>

float easeIn(float t)  { return t * t; }
float easeOut(float t) { return 1.0f - (1.0f - t) * (1.0f - t); }
float easeInOut(float t) {
    if (t < 0.5f) return 2.0f * t * t;
    float u = -2.0f * t + 2.0f;
    return 1.0f - (u * u) / 2.0f;
}

int main() {
    for (int i = 0; i <= 5; i++) {
        float t = i / 5.0f;
        std::cout << "t=" << t
                   << " in="    << easeIn(t)
                   << " out="   << easeOut(t)
                   << " inout=" << easeInOut(t) << "\n";
    }
}

Output:

t=0 in=0 out=0 inout=0
t=0.2 in=0.04 out=0.36 inout=0.08
t=0.4 in=0.16 out=0.64 inout=0.32
t=0.6 in=0.36 out=0.84 inout=0.68
t=0.8 in=0.64 out=0.96 inout=0.92
t=1 in=1 out=1 inout=1

Why bother? Because real, physical things almost never move at a perfectly constant speed -- they accelerate and decelerate due to inertia and friction. A UI panel that slides in at constant speed and stops dead reads as "computer-y". The same panel eased-out (fast start, gentle settle) reads as responsive and alive. This is not decoration -- animators and UI designers treat easing as one of the biggest levers for making motion feel good, because it mimics how mass and force behave.

Tip Ease-out is the most common default for UI motion (menus opening, buttons pressing) because a fast start feels responsive to the input, and the gentle settle at the end feels controlled rather than jarring. Ease-in alone (slow start, fast finish) is used less often on its own because a slow start can feel laggy -- it is more common combined into ease-in-out.

7. Bezier curves, part 1: quadratic curves and de Casteljau's algorithm

So far every value has been a single number blending toward another single number. A Bezier curve (named after Pierre Bezier) applies the same lerp idea to points in space, using a small set of control points that shape a smooth curve. The simplest useful one, the quadratic Bezier, uses three control points: P0 (start), P1 (the "pull" point), and P2 (end).

P1 * . . . . . . . . P0 * . . . . * P2 P0, P1, P2 are the CONTROL POINTS (the dots trace the control polygon, not the curve itself -- the actual curve is a smooth arc that starts at P0, ends at P2, and bulges toward P1 without ever touching it).

De Casteljau's algorithm builds a point on that curve using nothing but repeated lerp calls -- no new formula to memorize, just the same building block from section 1, applied twice:

step 1 (lerp along each edge of the control polygon): A = lerp(P0, P1, t) t of the way from P0 to P1 B = lerp(P1, P2, t) t of the way from P1 to P2 step 2 (lerp between the two new points): Q = lerp(A, B, t) t of the way from A to B <-- Q sits ON the curve
#include <iostream>

struct Vec2 { float x, y; };

Vec2 lerpVec2(Vec2 a, Vec2 b, float t) {
    return { a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t };
}

Vec2 quadraticBezier(Vec2 p0, Vec2 p1, Vec2 p2, float t) {
    Vec2 a = lerpVec2(p0, p1, t);   // t of the way along edge p0-p1
    Vec2 b = lerpVec2(p1, p2, t);   // t of the way along edge p1-p2
    return lerpVec2(a, b, t);       // t of the way along a-b, this is the curve point
}

int main() {
    Vec2 p0 = {0.0f, 0.0f};
    Vec2 p1 = {5.0f, 10.0f};
    Vec2 p2 = {10.0f, 0.0f};

    for (int i = 0; i <= 4; i++) {
        float t = i / 4.0f;
        Vec2 p = quadraticBezier(p0, p1, p2, t);
        std::cout << "t=" << t << " x=" << p.x << " y=" << p.y << "\n";
    }
}

Output:

t=0 x=0 y=0
t=0.25 x=2.5 y=3.75
t=0.5 x=5 y=5
t=0.75 x=7.5 y=3.75
t=1 x=10 y=0

Trace the shape: it starts at (0, 0), which is p0, ends at (10, 0), which is p2, and the highest point is at the middle, (5, 5) -- pulled halfway toward p1 = (5, 10) but never actually reaching it. That "pulled toward but never touching" behavior is exactly what makes control points intuitive to place by hand: drag p1 and the curve bends toward it.

8. Bezier curves, part 2: cubic curves, and where curves are actually used

A cubic Bezier uses four control points, P0 through P3, and is built with exactly the same de Casteljau idea -- just one more round of lerping, because there is one more control point:

4 control points shape a cubic curve: P0, P1, P2, P3 level 1 (3 points): A = lerp(P0,P1,t) B = lerp(P1,P2,t) C = lerp(P2,P3,t) level 2 (2 points): D = lerp(A,B,t) E = lerp(B,C,t) level 3 (1 point): Q = lerp(D,E,t) <-- point on the cubic curve
#include <iostream>

struct Vec2 { float x, y; };

Vec2 lerpVec2(Vec2 a, Vec2 b, float t) {
    return { a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t };
}

Vec2 cubicBezier(Vec2 p0, Vec2 p1, Vec2 p2, Vec2 p3, float t) {
    Vec2 a = lerpVec2(p0, p1, t);
    Vec2 b = lerpVec2(p1, p2, t);
    Vec2 c = lerpVec2(p2, p3, t);
    Vec2 d = lerpVec2(a, b, t);
    Vec2 e = lerpVec2(b, c, t);
    return lerpVec2(d, e, t);       // point on the cubic curve
}

int main() {
    Vec2 p0 = {0.0f, 0.0f};
    Vec2 p1 = {0.0f, 10.0f};
    Vec2 p2 = {10.0f, 10.0f};
    Vec2 p3 = {10.0f, 0.0f};

    Vec2 mid = cubicBezier(p0, p1, p2, p3, 0.5f);
    std::cout << "t=0.5 x=" << mid.x << " y=" << mid.y << "\n";
}

Output:

t=0.5 x=5 y=7.5

With p1 and p2 both pulling upward, the midpoint of the curve sits at y=7.5, higher than a straight line between p0 and p3 would give (which would be y=0). Two control points instead of one gives you an S-shaped curve, or any shape where the tangent direction at the start and the tangent direction at the end need to point differently.

Where Bezier curves show up in real engines

Cubic Beziers are everywhere once you know to look: SVG and vector-drawing paths (Illustrator's pen tool is placing Bezier control points), font outlines (TrueType and PostScript fonts store letter shapes as Bezier curves), and camera dolly paths in cutscene tools. Most importantly for a game programmer: the animation curve editor you see in Unity (AnimationCurve) and Unreal (the curve editor for timelines) is, under the hood, a sequence of cubic Bezier segments -- the little draggable "tangent handles" on each keyframe are literally control points P1 and P2 for that segment. Every custom easing curve you have ever dragged in an animation tool was de Casteljau's algorithm running underneath your mouse.

9. Catmull-Rom splines: a curve that passes through every point

Bezier curves have one inconvenient property for path-building: the curve only touches its first and last control points -- the middle ones just pull on it. If you want a smooth path that passes exactly through a list of waypoints (a racing line through checkpoints, a camera path through marked positions), Bezier is the wrong tool by itself. A Catmull-Rom spline is built for exactly this: given four points P0, P1, P2, P3, it draws a curve segment between P1 and P2 that passes through both of them, using P0 and P3 only to decide which direction the curve is heading as it arrives and leaves (its tangent).

waypoints: P0 P1 P2 P3 * *. . . . . . . . . .* * the dotted segment between P1 and P2 is the part of the curve that gets drawn. It passes exactly through P1 and through P2. P0 and P3 are only used to work out the tangent (the direction the curve is heading) -- the curve never touches P0 or P3 for this segment.
#include <iostream>

struct Vec2 { float x, y; };

Vec2 catmullRom(Vec2 p0, Vec2 p1, Vec2 p2, Vec2 p3, float t) {
    float t2 = t * t;
    float t3 = t2 * t;
    float x = 0.5f * (2.0f * p1.x + (-p0.x + p2.x) * t +
              (2.0f * p0.x - 5.0f * p1.x + 4.0f * p2.x - p3.x) * t2 +
              (-p0.x + 3.0f * p1.x - 3.0f * p2.x + p3.x) * t3);
    float y = 0.5f * (2.0f * p1.y + (-p0.y + p2.y) * t +
              (2.0f * p0.y - 5.0f * p1.y + 4.0f * p2.y - p3.y) * t2 +
              (-p0.y + 3.0f * p1.y - 3.0f * p2.y + p3.y) * t3);
    return { x, y };
}

int main() {
    Vec2 p0 = {0.0f, 0.0f};
    Vec2 p1 = {2.0f, 0.0f};
    Vec2 p2 = {4.0f, 2.0f};
    Vec2 p3 = {6.0f, 2.0f};

    Vec2 atStart = catmullRom(p0, p1, p2, p3, 0.0f);
    Vec2 atEnd   = catmullRom(p0, p1, p2, p3, 1.0f);
    std::cout << "t=0 x=" << atStart.x << " y=" << atStart.y << "\n";
    std::cout << "t=1 x=" << atEnd.x   << " y=" << atEnd.y   << "\n";
}

Output:

t=0 x=2 y=0
t=1 x=4 y=2

At t=0 the curve is exactly at (2, 0), which is p1. At t=1 it is exactly at (4, 2), which is p2. That "always passes through" guarantee is the whole point: string together enough waypoints and call this once per consecutive group of four, and you get one continuous smooth curve threading through every waypoint, with no manual tangent-handle placing required. This is the standard tool for race track center-lines and patrol/camera paths built from a list of designer-placed points.

10. The classic bug: frame-rate-dependent smoothing

Now the part of this chapter worth remembering above everything else. A very common piece of "smoothing" code looks like this, written once and called every frame inside Update() or Tick():

position = lerp(position, target, 0.1f);   // "move 10% closer to the target, every frame"

This looks harmless, and it does produce smooth-looking motion. The bug is that the result depends on how many times per second this line runs -- which means the same code produces different gameplay on different hardware, or even on the same hardware under different load. Each call shrinks the remaining gap to 90% of what it was, so after N calls the remaining gap is 0.9^N of the original.

position = lerp(position, target, 0.1) called ONCE PER FRAME, starting gap = 100 remaining gap after exactly 1 second of real time: at 30 fps (30 calls that second): 100 * 0.9^30 ~= 4.24 (96% closed) at 60 fps (60 calls that second): 100 * 0.9^60 ~= 0.18 (99.8% closed) at 120 fps (120 calls that second): 100 * 0.9^120 ~= 0.0003 (basically all closed) same code, same "0.1", same real second -- three different results. on a faster machine the object visibly snaps to the target quicker. that difference in FEEL and in GAMEPLAY is the bug.
#include <iostream>
#include <iomanip>

double naiveSmooth(double dt, int frames) {
    double position = 0.0, target = 100.0;
    for (int i = 0; i < frames; i++)
        position = position + (target - position) * 0.1;   // move 10% closer, every frame
    return position;
}

int main() {
    std::cout << std::fixed << std::setprecision(4);
    std::cout << "30fps position after 1s = " << naiveSmooth(1.0 / 30.0, 30) << "\n";
    std::cout << "60fps position after 1s = " << naiveSmooth(1.0 / 60.0, 60) << "\n";
}

Output:

30fps position after 1s = 95.7609
60fps position after 1s = 99.8203

Both runs simulate exactly one second of real time, starting the same distance from the target, using the exact same 0.1 factor. The only difference is how many frames fit into that second. At 60fps the object is 99.82% of the way there; at 30fps it is only 95.76% of the way there. On a slow device, this "smoothing" visibly lags; on a fast device, it visibly snaps. Worse, if your frame rate varies moment to moment (a frame drop during a busy scene), the object's speed toward its target changes too -- with no code change, no bug report reason, just inconsistent frame timing.

Common mistake Writing position = lerp(position, target, someConstant) inside Update() and calling it "smoothing" or "damping" without ever multiplying by deltaTime. It is probably the single most copy-pasted piece of frame-rate-dependent code in shipped games -- camera follow, UI value counting up, aim assist, and enemy facing-direction all get written this way by accident.

11. The fix: smoothing with deltaTime

Delta time (deltaTime or dt) is the real time, in seconds, since the last frame -- it is what makes movement frame-rate independent everywhere else (position += velocity * deltaTime), and smoothing needs the same treatment. The trick is to decide on a half-life: how many seconds it should take for the remaining gap to shrink by half, no matter the frame rate. Then compute the per-frame blend factor from deltaTime instead of using a fixed constant:

t = 1 - pow(0.5, deltaTime / halfLife)
position = lerp(position, target, t)
#include <iostream>
#include <iomanip>
#include <cmath>

double fixedSmooth(double dt, int frames, double halfLife) {
    double position = 0.0, target = 100.0;
    for (int i = 0; i < frames; i++) {
        double t = 1.0 - std::pow(0.5, dt / halfLife);   // frame-rate independent factor
        position = position + (target - position) * t;
    }
    return position;
}

int main() {
    std::cout << std::fixed << std::setprecision(4);
    std::cout << "30fps position after 1s = " << fixedSmooth(1.0 / 30.0, 30, 0.1) << "\n";
    std::cout << "60fps position after 1s = " << fixedSmooth(1.0 / 60.0, 60, 0.1) << "\n";
}

Output:

30fps position after 1s = 99.9023
60fps position after 1s = 99.9023

Same one second of real time, same starting distance, but now both frame rates land on the exact same result. With halfLife = 0.1, the gap halves every 0.1 seconds -- in a full second that is 10 halvings, so 0.5^10 = 0.0009765625 of the gap remains, or 99.9023% closed, regardless of whether that second was chopped into 30 frames or 60.

Why the half-life trick is frame-rate independent

The reason this works exactly, not just approximately, is that pow(0.5, x) is multiplicative: shrinking by pow(0.5, dt1) and then by pow(0.5, dt2) is the same total shrink as one step of pow(0.5, dt1 + dt2). Chop one second into 30 equal frames or 60 equal frames, multiply the per-frame shrink factor that many times, and you land on the same total shrink either way, because the pieces always add back up to the same one second. The old 0.9 constant did not have this property -- it was a fixed per-call shrink, not a per-second shrink, so calling it more often in the same second shrank the gap more.

Tip In a real Update(), dt is Time.deltaTime (Unity) or the tick's DeltaSeconds (Unreal) and changes slightly every frame -- that is fine, the formula still works because it recomputes t from the actual elapsed time each call, instead of assuming a fixed frame length. Pick a halfLife that means something to you ("half the distance closes in 0.15 seconds") rather than tuning a raw 0.1 constant that only happens to look right at the frame rate you tested on.

12. Glossary

13. Exercises

Exercise 1 A health bar should show a fill width of 0 pixels at 0 health and 300 pixels at 100 health, using remap(value, inMin, inMax, outMin, outMax) from section 4. By hand, compute the fill width when health is 45. Show the intermediate t from inverseLerp.
Show answer

First find t: inverseLerp(0, 100, 45) = (45 - 0) / (100 - 0) = 0.45. Then lerp(0, 300, 0.45) = 0 + (300 - 0) * 0.45 = 135. The fill width is 135 pixels.

Exercise 2 A game uses the naive smoothing from section 10: position = lerp(position, target, 0.2), called once per frame. It starts at position = 0 with target = 50, and the game runs at a steady 10 fps. Using the compounding formula (1 - 0.2)^N, compute the position after exactly 1 second (10 frames). Then, without recomputing, say whether the position after 1 second would be higher, lower, or the same if the game instead ran at 60fps, and explain why in one sentence.
Show answer

After 10 frames the remaining fraction of the gap is 0.8^10 = 0.1073741824, so the position is 50 * (1 - 0.1073741824) = 44.63 (rounded). At 60fps the position after 1 second would be higher (closer to 50), because six times as many frames run in that same second, and each frame closes another 20% of whatever gap remains -- more compounding steps in the same real time closes more of the total distance. This is exactly the frame-rate-dependent bug from section 10.

Exercise 3 Using de Casteljau's algorithm from section 7, compute the point on the quadratic Bezier curve with control points P0 = (0, 0), P1 = (4, 8), P2 = (8, 0) at t = 0.5. Show the intermediate points A and B before the final point Q.
Show answer

A = lerp(P0, P1, 0.5) = (0 + (4-0)*0.5, 0 + (8-0)*0.5) = (2, 4). B = lerp(P1, P2, 0.5) = (4 + (8-4)*0.5, 8 + (0-8)*0.5) = (6, 4). Q = lerp(A, B, 0.5) = (2 + (6-2)*0.5, 4 + (4-4)*0.5) = (4, 4). The point on the curve at t=0.5 is (4, 4) -- pulled upward off the straight line between P0 and P2 (which would have given (4, 0)), toward P1, but nowhere near reaching it.

That is the interpolation toolbox: lerp as the one formula everything else is built from, inverseLerp and remap for converting between ranges, smoothstep and easing for motion that feels alive instead of mechanical, Bezier and Catmull-Rom curves for shaping paths out of a handful of points, and -- the one to actually remember on the job -- always drive a per-frame smoothing factor from deltaTime, never from a bare constant. The next time you write lerp(position, target, someNumber) inside an update loop, this chapter is the reason to stop and ask what frame rate that number was tuned for.

← Back to all chapters