Make Camera Zooms Feel Constant by Interpolating the Log of Scale
A camera zoom that interpolates scale linearly from 1x to 16x lunges forward at the start, then appears to crawl toward the end, even though the scale changes by the same amount on every frame.
The cause is that zooming multiplies positions. With the zoom centered on the origin, a point at position x appears at s * x. Moving from scale s1 to s2 therefore multiplies its on-screen distance from the center by s2 / s1. That per-frame ratio is the number to control.
The technique
Don't interpolate the scale directly. Interpolate its logarithm, then convert the result back:
scale = exp(lerp(log(start), log(end), t))
The program below samples both approaches at 9 evenly spaced values of t, which gives 8 frame-to-frame steps. It then prints each scale and its ratio to the previous frame.
import math
START, END, FRAMES = 1.0, 16.0, 8
def lerp(a, b, t):
return a + (b - a) * t
def linear_zoom(t):
return lerp(START, END, t)
def log_zoom(t):
return math.exp(lerp(math.log(START), math.log(END), t))
def sample(zoom):
return [zoom(i / FRAMES) for i in range(FRAMES + 1)]
def frame_ratios(scales):
return [after / before for before, after in zip(scales, scales[1:])]
def spread(ratios):
return max(ratios) / min(ratios)
linear = sample(linear_zoom)
logarithmic = sample(log_zoom)
linear_ratios = frame_ratios(linear)
log_ratios = frame_ratios(logarithmic)
print(f"Zoom from {START:g}x to {END:g}x over {FRAMES} frames")
print()
print(f"{'frame':>5} | {'linear':>8} {'ratio':>7} | {'log':>8} {'ratio':>7}")
print("-" * 44)
for i in range(FRAMES + 1):
lin_ratio = f"{linear_ratios[i - 1]:7.3f}" if i else f"{'-':>7}"
log_ratio = f"{log_ratios[i - 1]:7.3f}" if i else f"{'-':>7}"
print(f"{i:>5} | {linear[i]:8.3f} {lin_ratio} | {logarithmic[i]:8.3f} {log_ratio}")
print()
print("Largest per-frame ratio divided by smallest (1.000 = constant speed):")
print(f" linear: {spread(linear_ratios):.3f}")
print(f" log: {spread(log_ratios):.3f}")
assert math.isclose(logarithmic[0], START) and math.isclose(logarithmic[-1], END)
assert math.isclose(linear[0], START) and math.isclose(linear[-1], END)
assert math.isclose(spread(log_ratios), 1.0, rel_tol=1e-9)
assert spread(linear_ratios) > 2.0
assert all(a > b for a, b in zip(linear_ratios, linear_ratios[1:]))
Zoom from 1x to 16x over 8 frames
frame | linear ratio | log ratio
--------------------------------------------
0 | 1.000 - | 1.000 -
1 | 2.875 2.875 | 1.414 1.414
2 | 4.750 1.652 | 2.000 1.414
3 | 6.625 1.395 | 2.828 1.414
4 | 8.500 1.283 | 4.000 1.414
5 | 10.375 1.221 | 5.657 1.414
6 | 12.250 1.181 | 8.000 1.414
7 | 14.125 1.153 | 11.314 1.414
8 | 16.000 1.133 | 16.000 1.414
Largest per-frame ratio divided by smallest (1.000 = constant speed):
linear: 2.538
log: 1.000
Reading the table
The linear column adds 1.875 on every frame, but its ratio falls from 2.875 on the first step to 1.133 on the last. The largest ratio is 2.538 times the smallest. That figure belongs to this 1x to 16x, 8-frame example, and a different range or frame count can give a different spread.
The log column has a printed ratio of 1.414 on every step. It doubles every 2 frames, reaching 2x, 4x, 8x and 16x at frames 2, 4, 6 and 8.
The midpoints differ too. At frame 4, the linear zoom is already at 8.500, while the log zoom is at 4.000. Log interpolation reaches the geometric mean of the endpoints halfway through, not the arithmetic mean.
Why it works for any positive range
The constant ratio is not specific to this example. Log interpolation simplifies to:
scale(t) = start * (end / start) ** t
If t advances by a fixed step of 1 / frames, each frame multiplies the scale by the same value:
(end / start) ** (1 / frames)
Here that is 16 ** (1 / 8), or about 1.414.
Constant ratio does not mean constant change in scale. The log version's scale increases by about 0.414 on the first step and about 4.686 on the last.
What the program checks
The assertions cover only this one configuration:
- Both zooms start and end at the requested scales, within the default tolerance of
math.isclose. - The log ratios have a spread within a relative tolerance of
1e-9of 1.0. The printed 1.000 is rounded, so this assertion is the stricter check. - The linear spread is greater than 2.0.
- The linear ratios strictly decrease from frame to frame.
The general claim for other ranges comes from the algebra above, not from these runs.
Where it bites
- Scale must be positive.
math.lograises aValueErrorfor zero or a negative number, so neither endpoint can be 0x or below with this formula. - The endpoint may not be exact. The conversion through
logandexpcan introduce rounding error, which is why the program usesmath.isclose. If you need an exact final value, assignENDdirectly whentreaches 1. - **Compute from
t, not from the previous frame.** Repeatedly multiplying by a fixed ratio accumulates rounding error and ties the zoom to a fixed frame count. Derivingtfrom elapsed time and calculating each scale fromtavoids both the accumulation and the fixed frame count. - Zooming out has the opposite problem. Reversing the linear zoom makes it speed up, with the final frame shrinking the image by a factor of 2.875. The program doesn't run this direction, but it follows from reading the table backward.
Where this stops being enough
This technique covers a single scale moving between two values at a constant ratio:
- Easing: Applying a non-linear easing curve to
tmakes the ratio vary, so the zoom is no longer constant. The easing then shapes proportional change. - Interrupted zooms: If the target changes mid-zoom, restart from the current scale with a new
t. Without extra handling, the speed can jump at that moment. - Panning while zooming: This code interpolates only scale. If you also interpolate the camera position separately, the point you're zooming toward can drift. To pin a point, derive the camera offset from the current scale. Moving between distant views may need a path that plans pan and zoom together.
Disclosure: I'm the founder of Homingo; Homingo sells this skill, and this article was written with AI assistance, with the code run before publishing. This is one small idea behind what we package in Isometric Explainers, our Claude Code skill: Claude Code draws how-it-works machines for your docs, blogs and landing pages, with live readouts and built-in layout, motion and geometry checks, and one command turns the same figure into a 1080x1920 explainer reel. Skill Rs 1,499 (about US$17), Complete Kit Rs 2,499 (about US$28; adds 3 more machine reel projects, the finished demo reel, 4 video lessons and a written tutorial), one-time, prices in INR, international cards accepted; it needs Claude access with Claude Code and Node.js 20.10+, Claude plans not included: Isometric Explainers. Everything above works without it.
Comments
Post a Comment