#7351·phaser

[4.1.0] Camera zoom != 1 with HiDPI canvas (canvas.width = css * devicePixelRatio): rendering goes blank, getWorldPoint and worldView are inconsistent

Author: newroad-powerCreated Aug 15, 2026Updated Aug 15, 2026

Version

  • Phaser: 4.1.0 (repro uses CDN [email protected])
  • 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:

javascript
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:

  1. Rendering: the canvas is completely blank (every pixel black/transparent).
  2. 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.
  3. 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 [email protected]). Save as repro.html, open with DevTools device emulation at DPR 3 (or run headless with deviceScaleFactor: 3):

xml
<!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/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).