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-9 of 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.log raises a ValueError for 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 log and exp can introduce rounding error, which is why the program uses math.isclose. If you need an exact final value, assign END directly when t reaches 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. Deriving t from elapsed time and calculating each scale from t avoids 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 t makes 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

Popular posts from this blog

Catch Overlapping Labels Before Rendering With a Sweep Over X

Emit an isometric cube as three SVG polygons and test its shared edges

Shading an isometric box in plain JavaScript: three face tones from one light direction