Skip to content

Camera follow lerp is framerate-dependent #7352

Description

@seltzerfish

Camera follow smoothing (startFollow with lerpX/lerpY < 1) is framerate dependent. The lerp factor is applied once per rendered frame in Camera.preRender, so the camera's tracking speed scales with the display's refresh rate: a game tuned on a 60Hz monitor pans exactly 2x faster on a 120Hz display and ~2.4x faster at 144Hz, and slower under any frame drop. The same applies to the deadzone follow path, which uses the same per-frame lerp.

With high-refresh displays now common, the same build feels noticeably different from machine to machine, and there is no way to compensate from user code since the lerp is applied inside preRender where no delta is exposed.

Example Test Code

Runnable single file, no build step. Two identical games with identical startFollow lerp values — the only difference is fps: { limit: 30 } on the second one. Every 3 seconds the follow target teleports 500px and each game measures the wall-clock time the camera takes to close to within 10px.

Expected if follow were framerate independent: both games report the same settle time. Actual: the 30fps game takes exactly twice as long as a 60Hz display (~1560ms vs ~780ms), and on a 120Hz display the uncapped game halves again (~390ms).

<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/phaser@4.2.1/dist/phaser.min.js"></script>
<style>body { display: flex; gap: 8px; background: #000; margin: 0; padding: 8px; }</style>
</head>
<body>
<div id="uncapped"></div>
<div id="capped"></div>
<script>
const LERP = 0.08;
const JUMP = 500;
const SETTLE_THRESHOLD = 10;

function makeGame(parent, fps, label) {
    let target;
    let text;
    let jumpedAt = 0;
    let dir = 1;
    let settled = true;

    new Phaser.Game({
        parent,
        width: 480,
        height: 320,
        backgroundColor: '#1d2b53',
        fps,
        scene: {
            create() {
                for (let x = -800; x <= 1600; x += 100) {
                    this.add.rectangle(x, 160, 4, 320, 0x3a4a83);
                }
                target = this.add.rectangle(240, 160, 24, 24, 0xffffff);
                this.cameras.main.startFollow(target, false, LERP, LERP);
                text = this.add.text(10, 10, label, { font: '14px monospace' }).setScrollFactor(0);
                this.time.addEvent({
                    delay: 3000,
                    loop: true,
                    callback: () => {
                        target.x += dir * JUMP;
                        dir *= -1;
                        jumpedAt = performance.now();
                        settled = false;
                    }
                });
            },
            update() {
                if (!settled && Math.abs(this.cameras.main.midPoint.x - target.x) < SETTLE_THRESHOLD) {
                    settled = true;
                    text.setText(label + '\nsettle time: ' + Math.round(performance.now() - jumpedAt) + ' ms');
                }
            }
        }
    });
}

makeGame('uncapped', undefined, 'uncapped (display refresh rate)');
makeGame('capped', { limit: 30 }, 'fps limit: 30');
</script>
</body>
</html>

Additional Information

Root cause: src/cameras/2d/Camera.js, preRender() — the follow branch applies a constant factor once per render:

sx = Linear(sx, fx - originX, lerp.x);
sy = Linear(sy, fy - originY, lerp.y);

plus the four analogous Linear calls in the deadzone branch. More renders per second means more lerp steps per second.

Proposed fix (I'm happy to submit a PR with tests): normalize the per-frame factor to a 60fps reference using the frame delta, which is already reachable inside preRender without any signature changes:

var fpsFactor = this.scene.sys.game.loop.delta / (1000 / 60);
sx = Linear(sx, fx - originX, 1 - Math.pow(1 - lerp.x, fpsFactor));
sy = Linear(sy, fy - originY, 1 - Math.pow(1 - lerp.y, fpsFactor));

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions