Skip to content

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

Description

@newroad-power

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:

  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 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions