diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py index 52e8096aa..d337c4e94 100644 --- a/arcade/camera/camera_2d.py +++ b/arcade/camera/camera_2d.py @@ -2,7 +2,7 @@ from collections.abc import Generator from contextlib import contextmanager -from math import atan2, cos, degrees, radians, sin +from math import atan2, cos, degrees, pow, radians, sin from typing import TYPE_CHECKING from pyglet.math import Vec2, Vec3 @@ -60,7 +60,11 @@ class Camera2D: If the viewport is not 1:1 with the projection then positions in world space won't match pixels on screen. position: - The 2D position of the camera in the XY plane. + The 2D position of the camera. + + This is in world space, so the same as :py:class:`Sprite` and draw commands. + The default projection is a :py:func:`XYWH` rect positioned at (0, 0) so the + position of the camera is the center of the viewport. up: A 2D vector which describes which direction is up (defines the +Y-axis of the camera space). @@ -75,6 +79,11 @@ class Camera2D: The near clipping plane of the camera. far: The far clipping plane of the camera. + aspect: The ratio between width and height that the viewport should + be constrained to. If unset then the viewport just matches the given + size. The aspect ratio describes how much larger the width should be + compared to the height. i.e. for an aspect ratio of ``4:3`` you should + input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. scissor: A ``Rect`` which will crop the camera's output to this area on screen. Unlike the viewport this has no influence on the visuals rendered with @@ -96,6 +105,7 @@ def __init__( near: float = DEFAULT_NEAR_ORTHO, far: float = DEFAULT_FAR, *, + aspect: float | None = None, scissor: Rect | None = None, render_target: Framebuffer | None = None, window: Window | None = None, @@ -111,7 +121,20 @@ def __init__( # but we need to have some form of default size. render_target = render_target or self._window.ctx.screen viewport = viewport or LBWH(*render_target.viewport) - width, height = viewport.size + + if aspect is None: + width, height = viewport.size + elif aspect == 0.0: + raise ZeroProjectionDimension( + "aspect ratio is 0 which will cause invalid viewport dimensions." + ) + elif viewport.height * aspect < viewport.width: + width = viewport.height * aspect + height = viewport.height + else: + width = viewport.width + height = viewport.width / aspect + viewport = XYWH(viewport.x, viewport.y, width, height) half_width = width / 2 half_height = height / 2 @@ -136,8 +159,10 @@ def __init__( f"projection depth is 0 due to equal {near=} and {far=} values" ) - pos_x = position[0] if position is not None else half_width - pos_y = position[1] if position is not None else half_height + # By using -left and -bottom this ensures that (0.0, 0.0) is always + # in the bottom left corner of the viewport + pos_x = position[0] if position is not None else -left + pos_y = position[1] if position is not None else -bottom self._camera_data = CameraData( position=(pos_x, pos_y, 0.0), up=(up[0], up[1], 0.0), @@ -148,7 +173,7 @@ def __init__( left=left, right=right, top=top, bottom=bottom, near=near, far=far ) - self.viewport: Rect = viewport or LRBT(0, 0, width, height) + self.viewport: Rect = viewport """ A rect which describes how the final projection should be mapped from unit-space. defaults to the size of the render_target or window @@ -322,7 +347,7 @@ def unproject(self, screen_coordinate: Point) -> Vec3: _view = generate_view_matrix(self.view_data) return unproject_orthographic(screen_coordinate, self.viewport.lbwh_int, _view, _projection) - def equalise(self) -> None: + def equalize(self) -> None: """ Forces the projection to match the size of the viewport. When matching the projection to the viewport the method keeps @@ -331,8 +356,6 @@ def equalise(self) -> None: x, y = self._projection_data.rect.x, self._projection_data.rect.y self._projection_data.rect = XYWH(x, y, self.viewport_width, self.viewport_height) - equalize = equalise - def match_window( self, viewport: bool = True, @@ -352,7 +375,7 @@ def match_window( scissor: Flag whether to also equalize the scissor box to the viewport. On by default position: Flag whether to position the camera so that (0.0, 0.0) is in - the bottom-left + the bottom-left of the viewport aspect: The ratio between width and height that the viewport should be constrained to. If unset then the viewport just matches the window size. The aspect ratio describes how much larger the width should be @@ -386,7 +409,7 @@ def match_target( The projection center stays fixed, and the new projection matches only in size. scissor: Flag whether to update the scissor value. position: Flag whether to position the camera so that (0.0, 0.0) is in - the bottom-left + the bottom-left of the viewport aspect: The ratio between width and height that the value should be constrained to. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. @@ -428,14 +451,18 @@ def update_values( The projection center stays fixed, and the new projection matches only in size. scissor: Flag whether to update the scissor value. position: Flag whether to position the camera so that (0.0, 0.0) is in - the bottom-left + the bottom-left of the viewport aspect: The ratio between width and height that the value should be constrained to. i.e. for an aspect ratio of ``4:3`` you should input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero. If unset then the value will not be updated. """ if aspect is not None: - if value.height * aspect < value.width: + if aspect == 0.0: + raise ZeroProjectionDimension( + "aspect ratio is 0 which will cause invalid viewport dimensions." + ) + elif value.height * aspect < value.width: w = value.height * aspect h = value.height else: @@ -454,7 +481,11 @@ def update_values( self.scissor = value if position: - self.position = Vec2(-self._projection_data.left, -self._projection_data.bottom) + self._camera_data.position = ( + -self._projection_data.left, + -self._projection_data.bottom, + self._camera_data.position[2], + ) def aabb(self) -> Rect: """ @@ -512,6 +543,103 @@ def point_in_view(self, point: Point2) -> bool: return abs(dot_x) <= h_width and abs(dot_y) <= h_height + def move_to(self, position: Point2, *, duration: float | None = None) -> Point2: + """ + Move the camera to the provided position. + If duration is None this is the same as setting camera.position. + duration makes it easy to move the camera smoothly over time. + + When duration is not None it uses :py:func:`arcade.math.smerp` method + to smoothly move to the target position. This means duration does NOT + equal the fraction to move. To make the motion frame rate independant + use ``duration = dt * T`` where ``T`` is the number of seconds to move + half the distance to the target position. + + Args: + position: x, y position in world space to move too + duration: The number of frames it takes to approximately move half-way + to the target position + + Returns: + The actual position the camera was set too. + """ + if duration is None: + x, y = position + self._camera_data.position = (x, y, self._camera_data.position[2]) + return position + + x1, y1, z1 = self._camera_data.position + x2, y2 = position + d = pow(2, -duration) + x = x1 + (x2 - x1) * d + y = y1 + (y2 - y1) * d + + self._camera_data.position = (x, y, z1) + return x, y + + def move_by(self, change: Point2) -> Point2: + """ + Move the camera in world space along the XY axes by the provided change. + If you want to drag the camera with a mouse :py:func:`camera2D.drag_by` + is the method to use. + + Args: + change: amount to move XY position in world space + + Returns: + final XY position of the camera + """ + pos = self._camera_data.position + new = pos[0] + change[0], pos[1] + change[1] + self._camera_data.position = new[0], new[1], pos[2] + return new + + def drag_by(self, change: Point2) -> Point2: + """ + Move the camera in world space by an amount in screen space. + This is a utility method to make it easy to drag the camera correctly. + normally zooming in/out, rotating the camera, and using a non 1:1 projection + causes the mouse dragging to desync with the camera motion. It automatically + negates the change so the change represents the amount the camera appears + to move. This is because moving the camera left makes everything appear to + move right. So a user moving the mouse right expects the camera to move + left. + + The simplest use case is with the Window/View's :py:func:`on_mouse_drag` + .. code-block:: python + + def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers): + self.camera.drag_by((dx, dy)) + + .. warning:: This method is more expensive than :py:func:`Camera2D.move_by` so + use only when needed. If your camera is 1:1 with the screen and you + only zoom in and out you can get away with + ``camera2D.move_by(-change / camera.zoom)``. + + .. warning:: This method must assume that viewport has the same pixel scale as the + window. If you are doing some form of upscaling you will have to scale + the mouse dx and dy by the difference in pixel scale. + + Args: + change: The number of pixels to move the camera by + + Returns: + The final position of the camera. + """ + + # Early exit to avoid expensive matrix generation + if change[0] == 0.0 and change[1] == 0.0: + return self._camera_data.position[0], self._camera_data.position[1] + + x0, y0, _ = self.unproject((0, 0)) + xc, yc, _ = self.unproject(change) + + dx, dy = xc - x0, yc - y0 + pos = self._camera_data.position + new = pos[0] - dx, pos[1] - dy + self._camera_data.position = new[0], new[1], pos[2] + return new + @property def view_data(self) -> CameraData: """The view data for the camera. @@ -547,17 +675,43 @@ def projection_data(self) -> OrthographicProjectionData: @property def position(self) -> Vec2: - """The 2D world position of the camera along the X and Y axes.""" + """ + The 2D position of the camera. + + This is in world space, so the same as :py:class:`Sprite` and draw commands. + The default projection is a :py:func:`XYWH` rect positioned at (0, 0) so the + position of the camera is the center of the viewport. + """ return Vec2(self._camera_data.position[0], self._camera_data.position[1]) # Setter with different signature will cause mypy issues # https://github.com/python/mypy/issues/3004 @position.setter - def position(self, _pos: Point) -> None: - x, y, *_z = _pos + def position(self, pos: Point) -> None: + x, y, *_z = pos z = self._camera_data.position[2] if not _z else _z[0] self._camera_data.position = (x, y, z) + @property + def x(self) -> float: + """The 2D world position of the camera along the X axis""" + return self._camera_data.position[0] + + @x.setter + def x(self, x: float) -> None: + pos = self._camera_data.position + self._camera_data.position = (x, pos[1], pos[2]) + + @property + def y(self) -> float: + """The 2D world position of the camera along the Y axis""" + return self._camera_data.position[1] + + @y.setter + def y(self, y: float) -> None: + pos = self._camera_data.position + self._camera_data.position = (pos[0], y, pos[2]) + @property def projection(self) -> Rect: """Get/set the left, right, bottom, and top projection values. diff --git a/tests/unit/camera/test_camera2d.py b/tests/unit/camera/test_camera2d.py index 076040507..5ab9ad752 100644 --- a/tests/unit/camera/test_camera2d.py +++ b/tests/unit/camera/test_camera2d.py @@ -82,6 +82,20 @@ def test_camera2d_init_inheritance_safety(window: Window, camera_class): assert isinstance(subclassed, Camera2DSub1) +ASPECT_RATIOS = (1.0, 4.0 / 3.0, 16.0 / 9.0, 16.0 / 10.0) + + +def test_camera2d_init_aspect_equal_0_raises_zeroprojectiondimension(window: Window): + with pytest.raises(ZeroProjectionDimension): + camera = Camera2D(aspect=0.0) + + +@pytest.mark.parametrize("aspect", ASPECT_RATIOS) +def test_camera2d_init_respects_aspect_ratio(window: Window, aspect): + ortho_camera = Camera2D(aspect=aspect) + assert ortho_camera.viewport_width / ortho_camera.viewport_height == pytest.approx(aspect) + + RENDER_TARGET_SIZES = [ (800, 600), # Normal window size (1280, 720), # Bigger @@ -105,6 +119,9 @@ def test_camera2d_init_uses_render_target_size(window: Window, width, height): assert ortho_camera.viewport_bottom == 0 assert ortho_camera.viewport_top == height + assert ortho_camera.position.x == width / 2.0 + assert ortho_camera.position.y == height / 2.0 + @pytest.mark.parametrize("width, height", RENDER_TARGET_SIZES) def test_camera2d_from_camera_data_uses_render_target_size(window: Window, width, height): @@ -122,6 +139,9 @@ def test_camera2d_from_camera_data_uses_render_target_size(window: Window, width assert ortho_camera.viewport_bottom == 0 assert ortho_camera.viewport_top == height + assert ortho_camera.position.x == width / 2.0 + assert ortho_camera.position.y == height / 2.0 + def test_move_camera_and_project(window: Window): camera = Camera2D()