Skip to content

Fix drawing on scaled (HiDPI) displays - #2926

Merged
pvcraven merged 1 commit into
developmentfrom
fix/hidpi-viewport
Oct 6, 2026
Merged

pvcraven merged 1 commit into
developmentfrom
fix/hidpi-viewport

Conversation

@pvcraven

@pvcraven pvcraven commented Oct 6, 2026

Copy link
Copy Markdown
Member

Summary

Fixes drawing on scaled (HiDPI) displays, such as Windows set to 125% or 150%. I found it while working on #2923, where sprites came out 1.25× too big in tests on my 125% display.

Bug 1: after the first frame with text, the whole game is drawn too small

This is in Arcade's default DPI mode, pyglet's "stretch", so it affects real games.

When pyglet draws text (or any pyglet batch), it sets the viewport and scissor from Window.camera, which is Arcade's DefaultProjector. pyglet applies them in framebuffer pixels (pyglet/graphics/draw.py, apply_viewport and apply_scissor), but DefaultProjector gave them in window units. On a 200×100 window with a 250×125 framebuffer:

  • Text in frame 1: drawn at 80% size and position.
  • Later frames: the 200×100 viewport and scissor stayed set, and Arcade's viewport cache never reset them. So from the second frame on, the whole game was drawn in the bottom-left 80% of the window.
  • GUI text: drawn at the wrong size, outside its widget.

I measured these in Arcade's real event loop (arcade.run()). The window is 200×100 logical, 250×125 pixels:

Sprite 40×20 at (10, 10) Text at x=100
development, frame 1 50×25 ✔ 102–186 ✘ (unscaled)
development, frames 2+ 40×20 ✘ 102–186 ✘
This PR, every frame, with or without a Camera2D 50×25 ✔ 127–232 ✔

A UILabel with a background, at 125%:

Blue background Green text
development 25–137 × 25–63 7–287 × 28–104 (outside the widget)
This PR 25–137 × 25–63 27–132 × 34–58 ✔

Bug 2: get_pixel_ratio() returns the display scale

In pyglet 3, Window.get_pixel_ratio() is deprecated and returns Window.scale, which is DPI / 96. Arcade uses it as framebuffer size ÷ window size. The two differ in pyglet's "platform" DPI mode: on Windows at 125% the framebuffer is the window's size, but the ratio is still 1.25. Arcade then made the screen viewport 1.25× the framebuffer, so everything on screen was drawn scaled up and cut off at the right and top.

The test suite runs in "platform" mode (ARCADE_TEST). That's why test_draw_primitives, test_render_sprite_solid_pixels and test_get_image always failed on my machine but passed in CI. pixel_perfect=True already forces the ratio to 1.0, so it wasn't affected.

The fix

  • Window.get_pixel_ratio() returns framebuffer width ÷ get_size() width. This doesn't use _width, because in stretch mode pyglet stores the physical width there. The pyodide branch is unchanged. On macOS Retina, the result is the same as before (2.0).
  • DefaultProjector.viewport returns the active framebuffer's viewport in pixels, which is what Arcade has already set, whichever camera set it. pyglet then re-applies the same viewport instead of changing it. get_group_scissor_area() returns pixels too. Nothing in Arcade reads DefaultProjector.viewport; Arcade uses get_current_viewport(), width and height, which stay in window units.
  • DefaultProjector.update_viewport(), width, height and get_current_viewport() compare with, and fall back to, the framebuffer's size in window units. Before, they mixed in pixels. The fallback to pixels only happened to be avoided because the comparison never matched on a scaled screen.

Offscreen framebuffers aren't affected, since they have their own pixel size, and a test checks this.

Tests

New tests/unit/window/test_hidpi.py. CI runs at a ratio of 1, so the tests make the window report a framebuffer twice its size, like stretch mode on a 200% display:

  • Pixel ratio: it's framebuffer ÷ window, and it ignores pyglet's display scale when the framebuffer is the window's size.
  • Screen viewport: the GL viewport is in pixels.
  • After text: the GL viewport and scissor stay in pixels after drawing text, after sprites and more text, and with a Camera2D.
  • Default camera: it reports pixels to pyglet, and window units to Arcade.
  • Offscreen framebuffers: they keep their own size.

All 7 fail on development. The full suite passes on my 125% display for the first time: 1581 passed, no warnings, including the 3 render tests that used to fail only there. Ruff is clean except for one over-long docstring line in _dispatch_frame, which was already there. mypy has no new errors.

The changelog has an entry under Unreleased → Fixes.

🤖 Generated with Claude Code

pyglet draws text (and any pyglet batch) with the window camera's
viewport and scissor, which it reads from Window.camera, Arcade's
DefaultProjector, and applies in framebuffer pixels. DefaultProjector
gave them in window units. On a scaled display in pyglet's "stretch"
mode, which Arcade uses by default, text was drawn too small, and the
too small viewport and scissor stayed set: from the second frame on,
the whole game was drawn in the bottom left corner of the window. GUI
text was drawn outside its widgets.

- DefaultProjector.viewport and get_group_scissor_area() now return the
  active framebuffer's viewport and scissor in pixels, which is what
  Arcade already set, whichever camera set it.
- DefaultProjector compares and falls back to the framebuffer's size in
  window units, so a viewport covering the window is stored as None.
- Window.get_pixel_ratio() returns the framebuffer to window size ratio.
  pyglet 3's version returns the display scale, which differs in
  "platform" DPI mode, so the test suite drew everything scaled up on a
  scaled Windows display, and three render tests failed there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pvcraven
pvcraven merged commit 648faa5 into development Oct 6, 2026
7 checks passed
@pvcraven
pvcraven deleted the fix/hidpi-viewport branch October 6, 2026 19:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant