Repository navigation
[4.1.0] Camera zoom != 1 with HiDPI canvas (canvas.width = css * devicePixelRatio): rendering goes blank, getWorldPoint and worldView are inconsistent #7351
Description
Version
- Phaser: 4.1.0 (repro uses CDN
phaser@4.1.0) - Browser: Chrome (verified with Playwright/Chromium,
deviceScaleFactor: 3and1) - OS: Windows (device-independent — pure math issue)
Description
To render sharp on HiDPI displays we follow the standard approach (same as PixiJS resolution + autoDensity): make the backing store larger than the CSS size and compensate with the camera:
canvas.width = cssWidth * dpr; // backing store
canvas.height = cssHeight * dpr;
canvas.style.width = cssWidth + 'px'; // CSS size pinned
canvas.style.height = cssHeight + 'px';
renderer.resize(cssWidth * dpr, cssHeight * dpr);
camera.setViewport(0, 0, cssWidth * dpr, cssHeight * dpr);
camera.setZoom(dpr); // compensate so world scale looks identicalWith dpr === 1 everything is correct. With dpr > 1 (e.g. 3), three subsystems disagree:
- Rendering: the canvas is completely blank (every pixel black/transparent).
camera.getWorldPoint()returns wrong coordinates: clicking at screen (422, 195) returns (984.7, 495) instead of (422, 195). As a resulthitTestPointernever hits interactive objects.camera.worldView.xis wrong: returns844(= cssWidth) instead of0, andworldView.widthstays844(css) instead of the viewport width2532.
Measured probe output (viewport 844x390 CSS px, dpr 3):
| Field | dpr=3 (actual) | dpr=1 (expected baseline) |
|---|---|---|
| zoom | 3 | 1 |
| camera.width/height | 2532 / 1170 | 844 / 390 |
| worldView.x | 844 | 0 |
| worldView.width | 844 | 844 |
| getWorldPoint(422,195) | (984.7, 495) | (422, 195) |
| hitTest interactive button | 0 hits | 1 hit |
| rendered pixels | blank (0 variance) | correct scene |
It looks like the camera math applies the zoom / viewport compensation inconsistently across the render path, the input (getWorldPoint) path, and the worldView computation, once zoom !== 1.
Reproduction
Single-file repro (no build step, CDN phaser@4.1.0). Save as repro.html, open with DevTools device emulation at DPR 3 (or run headless with deviceScaleFactor: 3):
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Phaser 4.1.0 zoom+HiDPI repro</title></head>
<body style="margin:0">
<script src="https://cdn.jsdelivr.net/npm/phaser@4.1.0/dist/phaser.min.js"></script>
<script>
const DPR = window.devicePixelRatio; // emulate 3 to reproduce
let game, clicked = 0;
game = new Phaser.Game({
type: Phaser.AUTO,
scale: { mode: Phaser.Scale.RESIZE, width: '100%', height: '100%' },
scene: {
create: function () {
const cam = this.cameras.main;
// three markers spanning the screen: red / green / blue
this.add.rectangle(0, 0, 200, 200, 0xff0000).setOrigin(0);
this.add.rectangle(400, 300, 200, 200, 0x00ff00);
this.add.rectangle(700, 300, 100, 100, 0x0000ff);
this.add.text(20, 20, 'Phaser 4.1.0 HiDPI repro', { color: '#fff' });
// interactive button at screen center (844x390 viewport)
const btn = this.add.rectangle(422, 295, 200, 60, 0xffff00).setInteractive({ useHandCursor: true });
btn.on('pointerdown', () => { clicked++; });
if (DPR > 1) {
const apply = () => {
const w = game.scale.width, h = game.scale.height;
game.renderer.resize(w * DPR, h * DPR);
game.canvas.width = w * DPR;
game.canvas.height = h * DPR;
game.canvas.style.width = w + 'px';
game.canvas.style.height = h + 'px';
cam.setViewport(0, 0, w * DPR, h * DPR);
cam.setZoom(DPR);
};
apply();
// re-apply: ScaleManager resets canvas size on refresh
game.events.on(Phaser.Core.Events.POST_STEP, () => {
if (game.canvas.width !== game.scale.width * DPR) apply();
});
}
window.__probe = (px = 422, py = 195) => ({
dpr: DPR,
zoom: cam.zoom,
cameraW: cam.width, cameraH: cam.height,
scrollX: cam.scrollX,
worldViewX: cam.worldView.x, worldViewW: cam.worldView.width,
pointerWorld: cam.getWorldPoint(px, py),
hitTestAtButton: this.input.hitTestPointer
? 0 // needs a real Pointer; use clicked counter below instead
: 0,
canvasAttr: { w: game.canvas.width, h: game.canvas.height },
btnClicked: clicked > 0,
});
},
},
});
</script>
</body>
</html>Then in console: __probe() → observe worldViewX: 844 and pointerWorld ≈ (984.7, 495) instead of the click position. The screen is blank.
Expected behavior
With viewport = css * dpr and zoom = dpr:
- the scene renders exactly as at dpr=1 but with dpr× pixel density;
getWorldPoint(screenX, screenY)returns the same world coords as at dpr=1 for the same CSS pixel position;worldView.x/widthconsistent with the viewport and zoom.
(For reference, PixiJS solves this at the engine level with resolution + autoDensity; Phaser 3 removed game.config.resolution back in 3.16, so currently there is no supported way to get a sharp HiDPI canvas when camera zoom must differ from 1.)
Workaround
None found other than keeping the backing store at 1× CSS resolution (accepting blur on HiDPI mobile).
Activity
vtxe02023274 commentedon Oct 21, 2026
ran your repro in Chromium at deviceScaleFactor 3, most of it comes from zooming around the default camera origin
setZoomzooms aroundoriginX/originY, which default to 0.5. With the viewport at 2532x1170 and zoom 3 the camera looks at world 844..1688 instead of 0..844, so with everything in the first 844px the canvas is emptygetWorldPoint(x, y)expects canvas pixels, and the canvas is now 3x the CSS size, so passx * dpr, y * dprwith
cam.setOrigin(0, 0)aftersetZoom(DPR)your scene renders andgetWorldPoint(422 * 3, 195 * 3)returns(422, 195)worldView.xstill says 844 with origin 0 though, that one is a Phaser bug:preRenderbuilds the matrix around the origin but always computesworldViewas if the origin were 0.5. Opened #7406 for it, anything that relies onworldView(culling for example) is off until then.