From 21d863e62315dfc90a3ad939058a72344d3c4736 Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Fri, 9 Oct 2026 09:29:34 -0500 Subject: [PATCH] Add a Using Camera2D section and share the projector tests The camera guide starts with how to use Camera2D: following the player, zooming, mouse positions and resizing. The duplicated orthographic and perspective projector tests are merged into one parametrized file, and use/activate are now tested for every camera type. Co-Authored-By: Claude Opus 5.5 --- doc/programming_guide/camera.rst | 63 ++++++- .../camera/test_orthographic_projector.py | 171 ------------------ .../unit/camera/test_perspective_projector.py | 140 -------------- tests/unit/camera/test_projectors.py | 161 +++++++++++++++++ 4 files changed, 223 insertions(+), 312 deletions(-) delete mode 100644 tests/unit/camera/test_orthographic_projector.py delete mode 100644 tests/unit/camera/test_perspective_projector.py create mode 100644 tests/unit/camera/test_projectors.py diff --git a/doc/programming_guide/camera.rst b/doc/programming_guide/camera.rst index 06b837483b..edd3b7bf15 100644 --- a/doc/programming_guide/camera.rst +++ b/doc/programming_guide/camera.rst @@ -1,7 +1,68 @@ Camera ====== -This is a rough overview of how the Cameras work. Updating and improving this documentation is on the roadmap. +A camera chooses which part of your game world is drawn, and how big. Most +games only need :py:class:`arcade.Camera2D`. + +Using Camera2D +-------------- + +Create a camera, and call its ``use()`` method before drawing. Everything +drawn after that is seen through the camera. A second camera that never +moves is handy for the score and other text that should stay put: + +.. code-block:: python + + class GameView(arcade.View): + + def __init__(self): + super().__init__() + self.camera = arcade.Camera2D() + self.gui_camera = arcade.Camera2D() + + def on_draw(self): + self.clear() + self.camera.use() + self.scene.draw() # The game world, which scrolls + self.gui_camera.use() + self.score_text.draw() # The score, which stays put + +**Following the player.** The camera's ``position`` is the point in the world +at the center of the window: + +.. code-block:: python + + def on_update(self, delta_time): + self.camera.position = self.player_sprite.position + + # Or move part of the way each frame, for a smoother follow + self.camera.position = arcade.math.lerp_2d( + self.camera.position, self.player_sprite.position, 0.1 + ) + +**Zooming.** ``self.camera.zoom = 2.0`` draws everything twice as big, and +``0.5`` half as big. + +**Mouse positions.** Mouse events give positions in the window. To find the +point in the world under the mouse, use ``unproject()``: + +.. code-block:: python + + def on_mouse_press(self, x, y, button, modifiers): + world_x, world_y, _ = self.camera.unproject((x, y)) + +**Resizing the window.** Update the cameras in ``on_resize``. The world +camera keeps its position. The GUI camera needs ``position=True``, so the +bottom left of the window stays at (0, 0): + +.. code-block:: python + + def on_resize(self, width, height): + self.camera.match_window() + self.gui_camera.match_window(position=True) + +For complete programs, see :ref:`camera_platform`, :ref:`camera2d_splitscreen` +and :ref:`minimap`. The rest of this page explains how cameras work. Key Concepts ------------ diff --git a/tests/unit/camera/test_orthographic_projector.py b/tests/unit/camera/test_orthographic_projector.py deleted file mode 100644 index dbe2b6f5ce..0000000000 --- a/tests/unit/camera/test_orthographic_projector.py +++ /dev/null @@ -1,171 +0,0 @@ -import pytest as pytest -from pyglet.math import Vec3 - -from arcade import Window, camera - - -def test_orthographic_projector_use(window: Window): - # Given - ortho_camera = camera.OrthographicProjector() - - view_matrix = ortho_camera.generate_view_matrix() - proj_matrix = ortho_camera.generate_projection_matrix() - - # When - ortho_camera.use() - - # Then - assert window.current_camera is ortho_camera - assert window.ctx.view_matrix == view_matrix - assert window.ctx.projection_matrix == proj_matrix - - # Reset the window for later tests - window.default_camera.use() - - -def test_orthographic_projector_activate(window: Window): - # Given - ortho_camera: camera.OrthographicProjector = camera.OrthographicProjector() - - view_matrix = ortho_camera.generate_view_matrix() - proj_matrix = ortho_camera.generate_projection_matrix() - - # When - with ortho_camera.activate() as cam: - # Initially - assert window.current_camera is cam is ortho_camera - assert window.ctx.view_matrix == view_matrix - assert window.ctx.projection_matrix == proj_matrix - - # Finally - assert window.current_camera is window.default_camera - - # Reset the window for later tests - window.default_camera.use() - - -@pytest.mark.parametrize("width, height", [(800, 600), (1280, 720), (500, 500)]) -def test_orthographic_projector_map_coordinates(window: Window, width, height): - # Given - window.set_size(width, height) - ortho_camera = camera.OrthographicProjector() - - # When - mouse_pos_a = (100.0, 100.0) - mouse_pos_b = (100.0, 0.0) - mouse_pos_c = (230.0, 800.0) - - # Then - assert tuple(ortho_camera.unproject(mouse_pos_a)) == pytest.approx((100.0, 100.0, 0.0)) - assert tuple(ortho_camera.unproject(mouse_pos_b)) == pytest.approx((100.0, 0.0, 0.0)) - assert tuple(ortho_camera.unproject(mouse_pos_c)) == pytest.approx((230.0, 800.0, 0.0)) - - -@pytest.mark.parametrize("width, height", [(800, 600), (1280, 720), (500, 500)]) -def test_orthographic_projector_map_coordinates_move(window: Window, width, height): - # Given - window.set_size(width, height) - ortho_camera = camera.OrthographicProjector() - default_view = ortho_camera.view - - half_width, half_height = window.width // 2, window.height // 2 - - mouse_pos_a = (half_width, half_height) - mouse_pos_b = (100.0, 100.0) - - # When - default_view.position = (0.0, 0.0, 0.0) - - # Then - assert tuple(ortho_camera.unproject(mouse_pos_a)) == pytest.approx((0.0, 0.0, 0.0)) - assert tuple(ortho_camera.unproject(mouse_pos_b)) == pytest.approx( - (-half_width + 100.0, -half_height + 100, 0.0) - ) - - # And - - # When - default_view.position = (100.0, 100.0, 0.0) - - # Then - assert tuple(ortho_camera.unproject(mouse_pos_a)) == pytest.approx((100.0, 100.0, 0.0)) - assert tuple(ortho_camera.unproject(mouse_pos_b)) == pytest.approx( - (-half_width + 200.0, -half_height + 200.0, 0.0) - ) - - -@pytest.mark.parametrize("width, height", [(800, 600), (1280, 720), (500, 500)]) -def test_orthographic_projector_map_coordinates_rotate(window: Window, width, height): - # Given - window.set_size(width, height) - ortho_camera = camera.OrthographicProjector() - default_view = ortho_camera.view - - half_width, half_height = window.width // 2, window.height // 2 - - mouse_pos_a = (half_width, half_height) - mouse_pos_b = (100.0, 100.0) - - # When - default_view.up = (1.0, 0.0, 0.0) - default_view.position = (0.0, 0.0, 0.0) - - # Then - assert tuple(ortho_camera.unproject(mouse_pos_a)) == pytest.approx((0.0, 0.0, 0.0)) - assert tuple(ortho_camera.unproject(mouse_pos_b)) == pytest.approx( - (-half_height + 100.0, half_width - 100.0, 0.0) - ) - - # And - - # When - default_view.up = (2.0**-0.5, 2.0**-0.5, 0.0) - default_view.position = (100.0, 100.0, 0.0) - - b_shift_x = -half_width + 100.0 - b_shift_y = -half_height + 100.0 - b_rotated_x = b_shift_x / (2.0**0.5) + b_shift_y / (2.0**0.5) + 100 - b_rotated_y = -b_shift_x / (2.0**0.5) + b_shift_y / (2.0**0.5) + 100 - # Then - assert tuple(ortho_camera.unproject(mouse_pos_a)) == pytest.approx((100.0, 100.0, 0.0)) - assert tuple(ortho_camera.unproject(mouse_pos_b)) == pytest.approx( - (b_rotated_x, b_rotated_y, 0.0) - ) - - -@pytest.mark.parametrize("width, height", [(800, 600), (1280, 720), (500, 500)]) -def test_orthographic_projector_map_coordinates_zoom(window: Window, width, height): - # Given - window.set_size(width, height) - ortho_camera = camera.OrthographicProjector() - default_view = ortho_camera.view - - half_width, half_height = window.width // 2, window.height // 2 - - mouse_pos_a = (window.width, window.height) - mouse_pos_b = (100.0, 100.0) - - # When - default_view.zoom = 2.0 - - # Then - assert tuple(ortho_camera.unproject(mouse_pos_a)) == pytest.approx( - Vec3(window.width * 0.75, window.height * 0.75, 0.0) - ) - assert tuple(ortho_camera.unproject(mouse_pos_b)) == pytest.approx( - (half_width + (100 - half_width) * 0.5, half_height + (100 - half_height) * 0.5, 0.0) - ) - - # And - - # When - default_view.position = (0.0, 0.0, 0.0) - default_view.zoom = 0.25 - - # Then - assert tuple(ortho_camera.unproject(mouse_pos_a)) == pytest.approx( - (window.width * 2.0, window.height * 2.0, 0.0) - ) - assert tuple(ortho_camera.unproject(mouse_pos_b)) == pytest.approx( - ((100 - half_width) * 4.0, (100 - half_height) * 4.0, 0.0) - ) diff --git a/tests/unit/camera/test_perspective_projector.py b/tests/unit/camera/test_perspective_projector.py deleted file mode 100644 index 17626e150b..0000000000 --- a/tests/unit/camera/test_perspective_projector.py +++ /dev/null @@ -1,140 +0,0 @@ -from math import radians, tan - -import pytest as pytest - -from arcade import Window, camera - - -def test_perspective_projector_use(window: Window): - # Given - persp_camera = camera.PerspectiveProjector() - - view_matrix = persp_camera.generate_view_matrix() - proj_matrix = persp_camera.generate_projection_matrix() - - # When - persp_camera.use() - - # Then - assert window.current_camera is persp_camera - assert window.ctx.view_matrix == view_matrix - assert window.ctx.projection_matrix == proj_matrix - - # Reset the window for later tests - window.default_camera.use() - - -def test_perspective_projector_activate(window: Window): - # Given - persp_camera: camera.PerspectiveProjector = camera.PerspectiveProjector() - - view_matrix = persp_camera.generate_view_matrix() - proj_matrix = persp_camera.generate_projection_matrix() - - # When - with persp_camera.activate() as cam: - # Initially - assert window.current_camera is cam is persp_camera - assert window.ctx.view_matrix == view_matrix - assert window.ctx.projection_matrix == proj_matrix - - # Finally - assert window.current_camera is window.default_camera - - # Reset the window for later tests - window.default_camera.use() - - -@pytest.mark.parametrize("width, height", [(800, 600), (1280, 720), (500, 500)]) -def test_perspective_projector_map_coordinates(window: Window, width, height): - # Given - window.set_size(width, height) - persp_camera = camera.PerspectiveProjector() - - depth = 0.5 * persp_camera.viewport.height / tan(radians(0.5 * persp_camera._projection.fov)) - - # When - mouse_pos_a = (100.0, 100.0) - mouse_pos_b = (100.0, 0.0) - mouse_pos_c = (230.0, 800.0) - - # Then - assert tuple(persp_camera.unproject(mouse_pos_a)) == pytest.approx((100.0, 100.0, depth)) - assert tuple(persp_camera.unproject(mouse_pos_b)) == pytest.approx((100.0, 0.0, depth)) - assert tuple(persp_camera.unproject(mouse_pos_c)) == pytest.approx((230.0, 800.0, depth)) - - -@pytest.mark.parametrize("width, height", [(800, 600), (1280, 720), (500, 500)]) -def test_perspective_projector_map_coordinates_move(window: Window, width, height): - # Given - window.set_size(width, height) - persp_camera = camera.PerspectiveProjector() - default_view = persp_camera.view - - depth = 0.5 * persp_camera.viewport.height / tan(radians(0.5 * persp_camera._projection.fov)) - - half_width, half_height = window.width // 2, window.height // 2 - - mouse_pos_a = (half_width, half_height) - mouse_pos_b = (100.0, 100.0) - - # When - default_view.position = (0.0, 0.0, 0.0) - - # Then - assert tuple(persp_camera.unproject(mouse_pos_a)) == pytest.approx((0.0, 0.0, depth)) - assert tuple(persp_camera.unproject(mouse_pos_b)) == pytest.approx( - (-half_width + 100.0, -half_height + 100, depth) - ) - - # And - - # When - default_view.position = (100.0, 100.0, 0.0) - - # Then - assert tuple(persp_camera.unproject(mouse_pos_a)) == pytest.approx((100.0, 100.0, depth)) - assert tuple(persp_camera.unproject(mouse_pos_b)) == pytest.approx( - (-half_width + 200.0, -half_height + 200.0, depth) - ) - - -@pytest.mark.parametrize("width, height", [(800, 600), (1280, 720), (500, 500)]) -def test_perspective_projector_map_coordinates_rotate(window: Window, width, height): - # Given - window.set_size(width, height) - persp_camera = camera.PerspectiveProjector() - default_view = persp_camera.view - - depth = 0.5 * persp_camera.viewport.height / tan(radians(0.5 * persp_camera._projection.fov)) - - half_width, half_height = window.width // 2, window.height // 2 - - mouse_pos_a = (half_width, half_height) - mouse_pos_b = (100.0, 100.0) - - # When - default_view.up = (1.0, 0.0, 0.0) - default_view.position = (0.0, 0.0, 0.0) - - # Then - assert tuple(persp_camera.unproject(mouse_pos_a)) == pytest.approx((0.0, 0.0, depth)) - assert tuple(persp_camera.unproject(mouse_pos_b)) == pytest.approx( - (-half_height + 100.0, half_width - 100.0, depth) - ) - - # And - - # When - default_view.up = (2.0**-0.5, 2.0**-0.5, 0.0) - default_view.position = (100.0, 100.0, 0.0) - - b_shift_x = -half_width + 100.0 - b_shift_y = -half_height + 100.0 - b_rotated_x = b_shift_x / (2.0**0.5) + b_shift_y / (2.0**0.5) + 100 - b_rotated_y = -b_shift_x / (2.0**0.5) + b_shift_y / (2.0**0.5) + 100 - # Then - assert tuple(persp_camera.unproject(mouse_pos_a)) == pytest.approx((100.0, 100.0, depth)) - assert tuple(persp_camera.unproject(mouse_pos_b)) == pytest.approx( - (b_rotated_x, b_rotated_y, depth) - ) diff --git a/tests/unit/camera/test_projectors.py b/tests/unit/camera/test_projectors.py new file mode 100644 index 0000000000..84ae7b491b --- /dev/null +++ b/tests/unit/camera/test_projectors.py @@ -0,0 +1,161 @@ +""" +Tests shared by the camera types. Each test runs for every camera type it +applies to, instead of being copied into a file for each type. +""" + +from math import radians, tan + +import pytest +from pyglet.math import Vec3 + +from arcade import Window, camera +from arcade.camera.default import DefaultProjector + +PROJECTORS = [ + camera.OrthographicProjector, + camera.PerspectiveProjector, + camera.Camera2D, + camera.ViewportProjector, + DefaultProjector, +] +SIZES = [(800, 600), (1280, 720), (500, 500)] + + +def _has_matrices(projector) -> bool: + return hasattr(projector, "generate_view_matrix") and hasattr( + projector, "generate_projection_matrix" + ) + + +@pytest.mark.parametrize("projector_class", PROJECTORS) +def test_use(window: Window, projector_class): + projector = projector_class() + projector.use() + + assert window.current_camera is projector + if _has_matrices(projector): + assert window.ctx.view_matrix == projector.generate_view_matrix() + assert window.ctx.projection_matrix == projector.generate_projection_matrix() + + # Reset the window for later tests + window.default_camera.use() + + +@pytest.mark.parametrize("projector_class", PROJECTORS) +def test_activate_restores_previous_camera(window: Window, projector_class): + previous = camera.Camera2D() + previous.use() + projector = projector_class() + + with projector.activate() as active: + assert window.current_camera is active is projector + if _has_matrices(projector): + assert window.ctx.view_matrix == projector.generate_view_matrix() + assert window.ctx.projection_matrix == projector.generate_projection_matrix() + + assert window.current_camera is previous + + # Reset the window for later tests + window.default_camera.use() + + +# --- Mapping screen positions to the world, for the 3D projectors + + +def _depth(projector) -> float: + """The z of the world positions unproject() gives at the default distance.""" + if isinstance(projector, camera.PerspectiveProjector): + return 0.5 * projector.viewport.height / tan(radians(0.5 * projector._projection.fov)) + return 0.0 + + +PROJECTORS_3D = [camera.OrthographicProjector, camera.PerspectiveProjector] + + +@pytest.mark.parametrize("projector_class", PROJECTORS_3D) +@pytest.mark.parametrize("width, height", SIZES) +def test_map_coordinates(window: Window, projector_class, width, height): + window.set_size(width, height) + projector = projector_class() + depth = _depth(projector) + + for x, y in [(100.0, 100.0), (100.0, 0.0), (230.0, 800.0)]: + assert tuple(projector.unproject((x, y))) == pytest.approx((x, y, depth)) + + +@pytest.mark.parametrize("projector_class", PROJECTORS_3D) +@pytest.mark.parametrize("width, height", SIZES) +def test_map_coordinates_move(window: Window, projector_class, width, height): + window.set_size(width, height) + projector = projector_class() + view = projector.view + depth = _depth(projector) + half_width, half_height = window.width // 2, window.height // 2 + center, corner = (half_width, half_height), (100.0, 100.0) + + view.position = (0.0, 0.0, 0.0) + assert tuple(projector.unproject(center)) == pytest.approx((0.0, 0.0, depth)) + assert tuple(projector.unproject(corner)) == pytest.approx( + (-half_width + 100.0, -half_height + 100.0, depth) + ) + + view.position = (100.0, 100.0, 0.0) + assert tuple(projector.unproject(center)) == pytest.approx((100.0, 100.0, depth)) + assert tuple(projector.unproject(corner)) == pytest.approx( + (-half_width + 200.0, -half_height + 200.0, depth) + ) + + +@pytest.mark.parametrize("projector_class", PROJECTORS_3D) +@pytest.mark.parametrize("width, height", SIZES) +def test_map_coordinates_rotate(window: Window, projector_class, width, height): + window.set_size(width, height) + projector = projector_class() + view = projector.view + depth = _depth(projector) + half_width, half_height = window.width // 2, window.height // 2 + center, corner = (half_width, half_height), (100.0, 100.0) + + # Up pointing along x: a quarter turn + view.up = (1.0, 0.0, 0.0) + view.position = (0.0, 0.0, 0.0) + assert tuple(projector.unproject(center)) == pytest.approx((0.0, 0.0, depth)) + assert tuple(projector.unproject(corner)) == pytest.approx( + (-half_height + 100.0, half_width - 100.0, depth) + ) + + # Up pointing diagonally: an eighth of a turn + view.up = (2.0**-0.5, 2.0**-0.5, 0.0) + view.position = (100.0, 100.0, 0.0) + shift_x = -half_width + 100.0 + shift_y = -half_height + 100.0 + rotated_x = shift_x / (2.0**0.5) + shift_y / (2.0**0.5) + 100 + rotated_y = -shift_x / (2.0**0.5) + shift_y / (2.0**0.5) + 100 + assert tuple(projector.unproject(center)) == pytest.approx((100.0, 100.0, depth)) + assert tuple(projector.unproject(corner)) == pytest.approx((rotated_x, rotated_y, depth)) + + +@pytest.mark.parametrize("width, height", SIZES) +def test_orthographic_map_coordinates_zoom(window: Window, width, height): + window.set_size(width, height) + projector = camera.OrthographicProjector() + view = projector.view + half_width, half_height = window.width // 2, window.height // 2 + top_right, corner = (window.width, window.height), (100.0, 100.0) + + view.zoom = 2.0 + assert tuple(projector.unproject(top_right)) == pytest.approx( + Vec3(window.width * 0.75, window.height * 0.75, 0.0) + ) + assert tuple(projector.unproject(corner)) == pytest.approx( + (half_width + (100 - half_width) * 0.5, half_height + (100 - half_height) * 0.5, 0.0) + ) + + view.position = (0.0, 0.0, 0.0) + view.zoom = 0.25 + assert tuple(projector.unproject(top_right)) == pytest.approx( + (window.width * 2.0, window.height * 2.0, 0.0) + ) + assert tuple(projector.unproject(corner)) == pytest.approx( + ((100 - half_width) * 4.0, (100 - half_height) * 4.0, 0.0) + )