22
33from collections .abc import Generator
44from contextlib import contextmanager
5- from math import atan2 , cos , degrees , radians , sin
5+ from math import atan2 , cos , degrees , pow , radians , sin
66from typing import TYPE_CHECKING
77
88from 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.
0 commit comments