Skip to content

Commit 4b9d515

Browse files
authored
Camera2d requested additions (#2796)
* make camera2D position setter use `pos` not `_pos` and add x, y properties * Add `move_to` method with optional duration as requested by Eruvanos * add `Camera2D.move_by` method to avoid position access costs * add `Camera2D.drag_by` method to allow for accurate dragging * linting and formatting passs * Sphyinxify docs * code block in correct format * better position doc string as part of #2558 * formatting pass for new docstring * arcade uses american spelling much to my dismay * also remove alias of British spelling * improve camera init position logic and add aspect argument to init * formatting pass * additionally exception when aspect == 0 in update values * aspect ratio unit tests * fix viewport not respecting aspect ratio and redundant rect reaction found this issue with unit tests hurra * unit test formating
1 parent 6efb32c commit 4b9d515

File tree

2 files changed

+191
-17
lines changed

2 files changed

+191
-17
lines changed

arcade/camera/camera_2d.py

Lines changed: 171 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from collections.abc import Generator
44
from contextlib import contextmanager
5-
from math import atan2, cos, degrees, radians, sin
5+
from math import atan2, cos, degrees, pow, radians, sin
66
from typing import TYPE_CHECKING
77

88
from pyglet.math import Vec2, Vec3
@@ -60,7 +60,11 @@ class Camera2D:
6060
If the viewport is not 1:1 with the projection then positions in world space
6161
won't match pixels on screen.
6262
position:
63-
The 2D position of the camera in the XY plane.
63+
The 2D position of the camera.
64+
65+
This is in world space, so the same as :py:class:`Sprite` and draw commands.
66+
The default projection is a :py:func:`XYWH` rect positioned at (0, 0) so the
67+
position of the camera is the center of the viewport.
6468
up:
6569
A 2D vector which describes which direction is up
6670
(defines the +Y-axis of the camera space).
@@ -75,6 +79,11 @@ class Camera2D:
7579
The near clipping plane of the camera.
7680
far:
7781
The far clipping plane of the camera.
82+
aspect: The ratio between width and height that the viewport should
83+
be constrained to. If unset then the viewport just matches the given
84+
size. The aspect ratio describes how much larger the width should be
85+
compared to the height. i.e. for an aspect ratio of ``4:3`` you should
86+
input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero.
7887
scissor:
7988
A ``Rect`` which will crop the camera's output to this area on screen.
8089
Unlike the viewport this has no influence on the visuals rendered with
@@ -96,6 +105,7 @@ def __init__(
96105
near: float = DEFAULT_NEAR_ORTHO,
97106
far: float = DEFAULT_FAR,
98107
*,
108+
aspect: float | None = None,
99109
scissor: Rect | None = None,
100110
render_target: Framebuffer | None = None,
101111
window: Window | None = None,
@@ -111,7 +121,20 @@ def __init__(
111121
# but we need to have some form of default size.
112122
render_target = render_target or self._window.ctx.screen
113123
viewport = viewport or LBWH(*render_target.viewport)
114-
width, height = viewport.size
124+
125+
if aspect is None:
126+
width, height = viewport.size
127+
elif aspect == 0.0:
128+
raise ZeroProjectionDimension(
129+
"aspect ratio is 0 which will cause invalid viewport dimensions."
130+
)
131+
elif viewport.height * aspect < viewport.width:
132+
width = viewport.height * aspect
133+
height = viewport.height
134+
else:
135+
width = viewport.width
136+
height = viewport.width / aspect
137+
viewport = XYWH(viewport.x, viewport.y, width, height)
115138
half_width = width / 2
116139
half_height = height / 2
117140

@@ -136,8 +159,10 @@ def __init__(
136159
f"projection depth is 0 due to equal {near=} and {far=} values"
137160
)
138161

139-
pos_x = position[0] if position is not None else half_width
140-
pos_y = position[1] if position is not None else half_height
162+
# By using -left and -bottom this ensures that (0.0, 0.0) is always
163+
# in the bottom left corner of the viewport
164+
pos_x = position[0] if position is not None else -left
165+
pos_y = position[1] if position is not None else -bottom
141166
self._camera_data = CameraData(
142167
position=(pos_x, pos_y, 0.0),
143168
up=(up[0], up[1], 0.0),
@@ -148,7 +173,7 @@ def __init__(
148173
left=left, right=right, top=top, bottom=bottom, near=near, far=far
149174
)
150175

151-
self.viewport: Rect = viewport or LRBT(0, 0, width, height)
176+
self.viewport: Rect = viewport
152177
"""
153178
A rect which describes how the final projection should be mapped
154179
from unit-space. defaults to the size of the render_target or window
@@ -322,7 +347,7 @@ def unproject(self, screen_coordinate: Point) -> Vec3:
322347
_view = generate_view_matrix(self.view_data)
323348
return unproject_orthographic(screen_coordinate, self.viewport.lbwh_int, _view, _projection)
324349

325-
def equalise(self) -> None:
350+
def equalize(self) -> None:
326351
"""
327352
Forces the projection to match the size of the viewport.
328353
When matching the projection to the viewport the method keeps
@@ -331,8 +356,6 @@ def equalise(self) -> None:
331356
x, y = self._projection_data.rect.x, self._projection_data.rect.y
332357
self._projection_data.rect = XYWH(x, y, self.viewport_width, self.viewport_height)
333358

334-
equalize = equalise
335-
336359
def match_window(
337360
self,
338361
viewport: bool = True,
@@ -352,7 +375,7 @@ def match_window(
352375
scissor: Flag whether to also equalize the scissor box to the viewport.
353376
On by default
354377
position: Flag whether to position the camera so that (0.0, 0.0) is in
355-
the bottom-left
378+
the bottom-left of the viewport
356379
aspect: The ratio between width and height that the viewport should
357380
be constrained to. If unset then the viewport just matches the window
358381
size. The aspect ratio describes how much larger the width should be
@@ -386,7 +409,7 @@ def match_target(
386409
The projection center stays fixed, and the new projection matches only in size.
387410
scissor: Flag whether to update the scissor value.
388411
position: Flag whether to position the camera so that (0.0, 0.0) is in
389-
the bottom-left
412+
the bottom-left of the viewport
390413
aspect: The ratio between width and height that the value should
391414
be constrained to. i.e. for an aspect ratio of ``4:3`` you should
392415
input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero.
@@ -428,14 +451,18 @@ def update_values(
428451
The projection center stays fixed, and the new projection matches only in size.
429452
scissor: Flag whether to update the scissor value.
430453
position: Flag whether to position the camera so that (0.0, 0.0) is in
431-
the bottom-left
454+
the bottom-left of the viewport
432455
aspect: The ratio between width and height that the value should
433456
be constrained to. i.e. for an aspect ratio of ``4:3`` you should
434457
input ``4.0/3.0`` or ``1.33333...``. Cannot be equal to zero.
435458
If unset then the value will not be updated.
436459
"""
437460
if aspect is not None:
438-
if value.height * aspect < value.width:
461+
if aspect == 0.0:
462+
raise ZeroProjectionDimension(
463+
"aspect ratio is 0 which will cause invalid viewport dimensions."
464+
)
465+
elif value.height * aspect < value.width:
439466
w = value.height * aspect
440467
h = value.height
441468
else:
@@ -454,7 +481,11 @@ def update_values(
454481
self.scissor = value
455482

456483
if position:
457-
self.position = Vec2(-self._projection_data.left, -self._projection_data.bottom)
484+
self._camera_data.position = (
485+
-self._projection_data.left,
486+
-self._projection_data.bottom,
487+
self._camera_data.position[2],
488+
)
458489

459490
def aabb(self) -> Rect:
460491
"""
@@ -512,6 +543,103 @@ def point_in_view(self, point: Point2) -> bool:
512543

513544
return abs(dot_x) <= h_width and abs(dot_y) <= h_height
514545

546+
def move_to(self, position: Point2, *, duration: float | None = None) -> Point2:
547+
"""
548+
Move the camera to the provided position.
549+
If duration is None this is the same as setting camera.position.
550+
duration makes it easy to move the camera smoothly over time.
551+
552+
When duration is not None it uses :py:func:`arcade.math.smerp` method
553+
to smoothly move to the target position. This means duration does NOT
554+
equal the fraction to move. To make the motion frame rate independant
555+
use ``duration = dt * T`` where ``T`` is the number of seconds to move
556+
half the distance to the target position.
557+
558+
Args:
559+
position: x, y position in world space to move too
560+
duration: The number of frames it takes to approximately move half-way
561+
to the target position
562+
563+
Returns:
564+
The actual position the camera was set too.
565+
"""
566+
if duration is None:
567+
x, y = position
568+
self._camera_data.position = (x, y, self._camera_data.position[2])
569+
return position
570+
571+
x1, y1, z1 = self._camera_data.position
572+
x2, y2 = position
573+
d = pow(2, -duration)
574+
x = x1 + (x2 - x1) * d
575+
y = y1 + (y2 - y1) * d
576+
577+
self._camera_data.position = (x, y, z1)
578+
return x, y
579+
580+
def move_by(self, change: Point2) -> Point2:
581+
"""
582+
Move the camera in world space along the XY axes by the provided change.
583+
If you want to drag the camera with a mouse :py:func:`camera2D.drag_by`
584+
is the method to use.
585+
586+
Args:
587+
change: amount to move XY position in world space
588+
589+
Returns:
590+
final XY position of the camera
591+
"""
592+
pos = self._camera_data.position
593+
new = pos[0] + change[0], pos[1] + change[1]
594+
self._camera_data.position = new[0], new[1], pos[2]
595+
return new
596+
597+
def drag_by(self, change: Point2) -> Point2:
598+
"""
599+
Move the camera in world space by an amount in screen space.
600+
This is a utility method to make it easy to drag the camera correctly.
601+
normally zooming in/out, rotating the camera, and using a non 1:1 projection
602+
causes the mouse dragging to desync with the camera motion. It automatically
603+
negates the change so the change represents the amount the camera appears
604+
to move. This is because moving the camera left makes everything appear to
605+
move right. So a user moving the mouse right expects the camera to move
606+
left.
607+
608+
The simplest use case is with the Window/View's :py:func:`on_mouse_drag`
609+
.. code-block:: python
610+
611+
def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers):
612+
self.camera.drag_by((dx, dy))
613+
614+
.. warning:: This method is more expensive than :py:func:`Camera2D.move_by` so
615+
use only when needed. If your camera is 1:1 with the screen and you
616+
only zoom in and out you can get away with
617+
``camera2D.move_by(-change / camera.zoom)``.
618+
619+
.. warning:: This method must assume that viewport has the same pixel scale as the
620+
window. If you are doing some form of upscaling you will have to scale
621+
the mouse dx and dy by the difference in pixel scale.
622+
623+
Args:
624+
change: The number of pixels to move the camera by
625+
626+
Returns:
627+
The final position of the camera.
628+
"""
629+
630+
# Early exit to avoid expensive matrix generation
631+
if change[0] == 0.0 and change[1] == 0.0:
632+
return self._camera_data.position[0], self._camera_data.position[1]
633+
634+
x0, y0, _ = self.unproject((0, 0))
635+
xc, yc, _ = self.unproject(change)
636+
637+
dx, dy = xc - x0, yc - y0
638+
pos = self._camera_data.position
639+
new = pos[0] - dx, pos[1] - dy
640+
self._camera_data.position = new[0], new[1], pos[2]
641+
return new
642+
515643
@property
516644
def view_data(self) -> CameraData:
517645
"""The view data for the camera.
@@ -547,17 +675,43 @@ def projection_data(self) -> OrthographicProjectionData:
547675

548676
@property
549677
def position(self) -> Vec2:
550-
"""The 2D world position of the camera along the X and Y axes."""
678+
"""
679+
The 2D position of the camera.
680+
681+
This is in world space, so the same as :py:class:`Sprite` and draw commands.
682+
The default projection is a :py:func:`XYWH` rect positioned at (0, 0) so the
683+
position of the camera is the center of the viewport.
684+
"""
551685
return Vec2(self._camera_data.position[0], self._camera_data.position[1])
552686

553687
# Setter with different signature will cause mypy issues
554688
# https://github.com/python/mypy/issues/3004
555689
@position.setter
556-
def position(self, _pos: Point) -> None:
557-
x, y, *_z = _pos
690+
def position(self, pos: Point) -> None:
691+
x, y, *_z = pos
558692
z = self._camera_data.position[2] if not _z else _z[0]
559693
self._camera_data.position = (x, y, z)
560694

695+
@property
696+
def x(self) -> float:
697+
"""The 2D world position of the camera along the X axis"""
698+
return self._camera_data.position[0]
699+
700+
@x.setter
701+
def x(self, x: float) -> None:
702+
pos = self._camera_data.position
703+
self._camera_data.position = (x, pos[1], pos[2])
704+
705+
@property
706+
def y(self) -> float:
707+
"""The 2D world position of the camera along the Y axis"""
708+
return self._camera_data.position[1]
709+
710+
@y.setter
711+
def y(self, y: float) -> None:
712+
pos = self._camera_data.position
713+
self._camera_data.position = (pos[0], y, pos[2])
714+
561715
@property
562716
def projection(self) -> Rect:
563717
"""Get/set the left, right, bottom, and top projection values.

tests/unit/camera/test_camera2d.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@ def test_camera2d_init_inheritance_safety(window: Window, camera_class):
8282
assert isinstance(subclassed, Camera2DSub1)
8383

8484

85+
ASPECT_RATIOS = (1.0, 4.0 / 3.0, 16.0 / 9.0, 16.0 / 10.0)
86+
87+
88+
def test_camera2d_init_aspect_equal_0_raises_zeroprojectiondimension(window: Window):
89+
with pytest.raises(ZeroProjectionDimension):
90+
camera = Camera2D(aspect=0.0)
91+
92+
93+
@pytest.mark.parametrize("aspect", ASPECT_RATIOS)
94+
def test_camera2d_init_respects_aspect_ratio(window: Window, aspect):
95+
ortho_camera = Camera2D(aspect=aspect)
96+
assert ortho_camera.viewport_width / ortho_camera.viewport_height == pytest.approx(aspect)
97+
98+
8599
RENDER_TARGET_SIZES = [
86100
(800, 600), # Normal window size
87101
(1280, 720), # Bigger
@@ -105,6 +119,9 @@ def test_camera2d_init_uses_render_target_size(window: Window, width, height):
105119
assert ortho_camera.viewport_bottom == 0
106120
assert ortho_camera.viewport_top == height
107121

122+
assert ortho_camera.position.x == width / 2.0
123+
assert ortho_camera.position.y == height / 2.0
124+
108125

109126
@pytest.mark.parametrize("width, height", RENDER_TARGET_SIZES)
110127
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
122139
assert ortho_camera.viewport_bottom == 0
123140
assert ortho_camera.viewport_top == height
124141

142+
assert ortho_camera.position.x == width / 2.0
143+
assert ortho_camera.position.y == height / 2.0
144+
125145

126146
def test_move_camera_and_project(window: Window):
127147
camera = Camera2D()

0 commit comments

Comments
 (0)