Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions arcade/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
41 changes: 32 additions & 9 deletions arcade/camera/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
"""
Expand Down Expand Up @@ -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`
Expand Down
104 changes: 104 additions & 0 deletions tests/unit/window/test_hidpi.py
Original file line number Diff line number Diff line change
@@ -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)
Loading