[4.1.0] Camera zoom != 1 with HiDPI canvas (canvas.width = css * devicePixelRatio): rendering goes blank, getWorldPoint and worldView are inconsistent
Version
- Phaser: 4.1.0 (repro uses CDN
[email protected]) - 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 [email protected]). 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/[email protected]/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).
Source: phaserjs/phaser