[4.1.0] Camera zoom != 1 with HiDPI canvas (canvas.width = css * devicePixelRatio): rendering goes blank, getWorldPoint and worldView are inconsistent
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 40.3k
- Forks
- 7.2k
- PR merge metrics
- No merged PRs in 30d
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 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 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).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by running the single-file repro.html with Phaser 4.1.0 at deviceScaleFactor 3, then trace camera.setViewport, camera.setZoom, camera.getWorldPoint, worldView, and renderer.resize. Done means the scene renders at HiDPI, getWorldPoint matches CSS-pixel coordinates, and worldView remains consistent with the viewport and zoom.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- computer-graphics, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100