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));
Camera follow smoothing (
startFollowwithlerpX/lerpY< 1) is framerate dependent. The lerp factor is applied once per rendered frame inCamera.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
preRenderwhere no delta is exposed.Example Test Code
Runnable single file, no build step. Two identical games with identical
startFollowlerp values — the only difference isfps: { 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).
Additional Information
Root cause:
src/cameras/2d/Camera.js,preRender()— the follow branch applies a constant factor once per render:plus the four analogous
Linearcalls 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
preRenderwithout any signature changes: