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.
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.
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.
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.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.
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.
[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.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.
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.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.
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
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.
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:
smoothstep is).A simple quadratic version of each:
easeIn(t) = t * t
easeOut(t) = 1 - (1 - t) * (1 - t)
#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.
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).
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:
#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.
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:
#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.
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.
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).
#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.
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.
#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.
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.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.
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.
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.lerp(a, b, t) = a + (b - a) * t; blends between a and b based on t.[0, 1]; 0 means "all a", 1 means "all b".lerp produces when t is outside [0, 1]: a point beyond a or beyond b.t into [0, 1]).(value - a) / (b - a); recovers t from a known value between a and b.t * t * (3 - 2 * t); reshapes t so the rate of change is zero at both ends.t before a lerp so motion accelerates and/or decelerates instead of moving at constant speed.lerp calls.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.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.
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.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.
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.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.