diff --git a/CHANGELOG.md b/CHANGELOG.md index fbe869b712..eccfd690fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - A `pyglet.text.LinearGradient` color raised `TypeError` in `Text`, `draw_text()`, `create_text_sprite()`, and `UILabel.update_font()`. Gradients now work, and the `color` property returns the gradient. - The `Text` constructor ignored every error while creating the label, so bad arguments such as an unknown keyword or an invalid `font_name` only raised later, the first time the text was used. Only the "no window yet" case is deferred now. - Fixed `font_name` paths to font files in `arcade.Text`, `draw_text()`, `create_text_sprite()`, and the `Text.font_name` setter. A path or resource handle such as `":resources:fonts/ttf/Custom.ttf"` used the file name as the font name, so the text was drawn in the default font. The file is now loaded the first time it's used, and its family name is used. Lists of font names, which the docs allow, raised `TypeError` and now work, and a path no longer takes priority over names listed before it. +- Fixed drawing on scaled (HiDPI) displays, such as Windows set to 125% or 150%: + - After the first frame that drew text, the whole game was drawn too small, in the bottom left corner of the window. pyglet sets the window camera's viewport and scissor when it draws text, and Arcade gave them in window units instead of pixels, so they also stayed wrong for everything drawn after. Text was too small even in the first frame, and GUI text was drawn at the wrong size, outside its widget. + - `Window.get_pixel_ratio()` returned pyglet's display scale instead of the framebuffer to window size ratio, which differ when pyglet's `dpi_scaling` option is `"platform"`. Everything on screen was then drawn scaled up, cut off at the right and top. ### New Features - Added `arcade.sweep_sprite(sprite, dx, dy, sprite_list)`, which checks the whole path of a moving sprite and returns a `SweepInfo` for the first sprite it would hit (the `sprite`, how far along the move as a `fraction` and `distance`, and the surface `normal`), or `None`. Fast sprites can't pass through thin walls this way. A sprite that already overlaps one is an immediate hit. Added the `sprite_bullets_sweep` example comparing it with a plain collision check. diff --git a/arcade/application.py b/arcade/application.py index 23154c61f9..724a2736da 100644 --- a/arcade/application.py +++ b/arcade/application.py @@ -978,6 +978,14 @@ def get_pixel_ratio(self) -> float: log_w = self._width if log_w: return self.get_framebuffer_size()[0] / log_w + else: + # pyglet's get_pixel_ratio() returns the display scale (DPI / 96), + # which isn't always the framebuffer/window size ratio: on Windows + # in "platform" DPI mode, a 125% display gives 1.25 even though the + # framebuffer is the same size as the window. Measure it instead. + width = self.get_size()[0] + if width: + return self.get_framebuffer_size()[0] / width return super().get_pixel_ratio() def _on_resize(self, width: int, height: int) -> EVENT_HANDLE_STATE: diff --git a/arcade/camera/default.py b/arcade/camera/default.py index ab3b4366a7..690c35cb93 100644 --- a/arcade/camera/default.py +++ b/arcade/camera/default.py @@ -53,10 +53,8 @@ def update_viewport(self): if self._ctx.current_camera != self: return - if ( - self._ctx.viewport[2] != self._ctx.fbo.width - or self._ctx.viewport[3] != self._ctx.fbo.height - ): + width, height = self._framebuffer_size() + if self._ctx.viewport[2] != width or self._ctx.viewport[3] != height: self.viewport = self._ctx.viewport else: self.viewport = None @@ -65,7 +63,17 @@ def update_viewport(self): @property def viewport(self) -> tuple[int, int, int, int] | None: - return self._viewport + """ + The viewport of the active framebuffer, in framebuffer pixels. + + pyglet sets this viewport before drawing anything of its own, such + as text, since this is the window's camera as far as pyglet knows. + So it's the viewport Arcade already set, whichever camera set it, + in pixels rather than window units: on a scaled display, a viewport + in window units would draw pyglet's content too small, and leave the + smaller viewport set for everything drawn after it. + """ + return self._ctx.active_framebuffer._viewport @viewport.setter def viewport(self, viewport: tuple[int, int, int, int] | None) -> None: @@ -90,22 +98,35 @@ def scissor(self, scissor: tuple[int, int, int, int] | None) -> None: def scissor(self) -> None: self._scissor = None + def _framebuffer_size(self) -> tuple[int, int]: + """ + The size of the active framebuffer in the units of ``ctx.viewport``. + + For the window, that's window units, which on a scaled display + are fewer than its pixels. + """ + fbo = self._ctx.fbo + if fbo is self._ctx.screen: + ratio = self._ctx.window.get_pixel_ratio() + return round(fbo.width / ratio), round(fbo.height / ratio) + return fbo.width, fbo.height + @property def width(self) -> int: if self._viewport is not None: return int(self._viewport[2]) - return self._ctx.fbo.width + return self._framebuffer_size()[0] @property def height(self) -> int: if self._viewport is not None: return int(self._viewport[3]) - return self._ctx.fbo.height + return self._framebuffer_size()[1] def get_current_viewport(self) -> tuple[int, int, int, int]: if self._viewport is not None: return self._viewport - return (0, 0, self._ctx.fbo.width, self._ctx.fbo.height) + return (0, 0, *self._framebuffer_size()) def use(self) -> None: """ @@ -180,7 +201,9 @@ def get_group_scissor_area(self) -> CameraScissor: # session. Returning a real scissor rectangle matching the current # viewport keeps scissor testing enabled without restricting # anything beyond what's already visible. - return CameraScissor(*(self._scissor or self.get_current_viewport())) + # In framebuffer pixels, like the viewport above + fbo = self._ctx.active_framebuffer + return CameraScissor(*(fbo._scissor or fbo._viewport)) # pyglet's base Window.projection/.view properties delegate to # `self.camera.projection` / `.view_matrix` respectively (`.default_camera` diff --git a/tests/unit/window/test_hidpi.py b/tests/unit/window/test_hidpi.py new file mode 100644 index 0000000000..20583c462e --- /dev/null +++ b/tests/unit/window/test_hidpi.py @@ -0,0 +1,104 @@ +""" +Drawing on scaled (HiDPI) displays. + +CI runs at a pixel ratio of 1, so these tests make the window report a +framebuffer twice its size, like a scaled display in pyglet's "stretch" +mode, which Arcade uses by default. +""" + +from ctypes import c_int + +import pytest +from pyglet.graphics.api.gl import GL_SCISSOR_BOX, GL_VIEWPORT, glGetIntegerv + +import arcade + + +def _gl_box(name) -> tuple[int, int, int, int]: + box = (c_int * 4)() + glGetIntegerv(name, box) + return tuple(box) + + +@pytest.fixture +def scaled_window(window, monkeypatch): + """The window, reporting a framebuffer twice its size""" + width, height = window.get_size() + monkeypatch.setattr(window, "get_framebuffer_size", lambda: (width * 2, height * 2)) + window.ctx.viewport = 0, 0, width, height + window.default_camera.use() + yield window + monkeypatch.undo() + window.ctx.viewport = 0, 0, width, height + window.default_camera.use() + + +def test_pixel_ratio_is_framebuffer_over_window(scaled_window): + assert scaled_window.get_pixel_ratio() == 2.0 + + +def test_pixel_ratio_ignores_display_scale(window, monkeypatch): + """ + pyglet's own ratio is the display scale, which isn't the framebuffer + ratio when the framebuffer is the same size as the window + """ + monkeypatch.setattr(type(window), "scale", property(lambda self: 1.25)) + assert window.get_framebuffer_size() == window.get_size() + assert window.get_pixel_ratio() == 1.0 + + +def test_viewport_is_in_pixels(scaled_window): + width, height = scaled_window.get_size() + assert scaled_window.ctx.viewport == (0, 0, width, height) + assert _gl_box(GL_VIEWPORT) == (0, 0, width * 2, height * 2) + + +def test_text_keeps_the_scaled_viewport(scaled_window): + """ + pyglet sets the window camera's viewport and scissor when it draws + text. They used to be in window units, so text was drawn too small, + and everything drawn after it in the same viewport as well. + """ + width, height = scaled_window.get_size() + pixels = (0, 0, width * 2, height * 2) + sprites = arcade.SpriteList() + sprites.append(arcade.SpriteSolidColor(10, 10, 50, 50)) + + arcade.Text("Hello", 10, 10).draw() + assert _gl_box(GL_VIEWPORT) == pixels + assert _gl_box(GL_SCISSOR_BOX) == pixels + + sprites.draw() + arcade.Text("Again", 10, 30).draw() + assert _gl_box(GL_VIEWPORT) == pixels + assert _gl_box(GL_SCISSOR_BOX) == pixels + + +def test_text_with_camera_keeps_the_scaled_viewport(scaled_window): + width, height = scaled_window.get_size() + camera = arcade.Camera2D() + camera.use() + arcade.Text("Hello", 10, 10).draw() + assert _gl_box(GL_VIEWPORT) == (0, 0, width * 2, height * 2) + + +def test_default_camera_reports_pixels(scaled_window): + """pyglet reads the window camera's viewport and scissor in framebuffer pixels""" + width, height = scaled_window.get_size() + camera = scaled_window.default_camera + assert camera.viewport == (0, 0, width * 2, height * 2) + scissor = camera.get_group_scissor_area() + assert (scissor.x, scissor.y, scissor.width, scissor.height) == (0, 0, width * 2, height * 2) + # Arcade's own idea of the viewport stays in window units + assert camera.get_current_viewport() == (0, 0, width, height) + assert (camera.width, camera.height) == (width, height) + + +def test_offscreen_framebuffer_is_not_scaled(scaled_window): + """Framebuffers you create have their own pixel size, whatever the display""" + ctx = scaled_window.ctx + fbo = ctx.framebuffer(color_attachments=[ctx.texture((64, 32), components=4)]) + with fbo.activate(): + arcade.Text("Hello", 1, 1).draw() + assert _gl_box(GL_VIEWPORT) == (0, 0, 64, 32) + assert scaled_window.default_camera.viewport == (0, 0, 64, 32)