Version
- Phaser: 4.1.0 (repro uses CDN
phaser@4.1.0)
- Browser: Chrome (verified with Playwright/Chromium,
deviceScaleFactor: 3 and 1)
- 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 identical
With 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 result hitTestPointer never hits interactive objects.
camera.worldView.x is wrong: returns 844 (= cssWidth) instead of 0, and worldView.width stays 844 (css) instead of the viewport width 2532.
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/width consistent 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).
Version
phaser@4.1.0)deviceScaleFactor: 3and1)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:With
dpr === 1everything is correct. Withdpr > 1(e.g. 3), three subsystems disagree: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):
It looks like the camera math applies the
zoom / viewportcompensation inconsistently across the render path, the input (getWorldPoint) path, and theworldViewcomputation, oncezoom !== 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 withdeviceScaleFactor: 3):Then in console:
__probe()→ observeworldViewX: 844andpointerWorld≈ (984.7, 495) instead of the click position. The screen is blank.Expected behavior
With
viewport = css * dprandzoom = dpr: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 removedgame.config.resolutionback 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).