phaserjs / phaserjs/phaser

Camera follow lerp is framerate-dependent

Open
#7,352 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
40.3k
Forks
7.2k
PR merge metrics
No merged PRs in 30d

Description

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));

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with src/cameras/2d/Camera.js and its preRender() follow and deadzone branches, then run the provided single-file example to reproduce the different settle times at 30Hz and higher refresh rates. Update the lerp behavior using the available frame delta and add tests showing equivalent follow timing across frame rates; done means the camera settles at the same rate regardless of rendering frequency.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
game-dev, performance
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.