From 9c33d08d701a7a121856c2300c1a32625579cf01 Mon Sep 17 00:00:00 2001 From: A J Andrews Date: Mon, 7 Oct 2024 10:15:09 +1300 Subject: [PATCH] Update and improve `Camera2D` including more unit tests --- arcade/camera/camera_2d.py | 724 +++++++++++++++----------- arcade/camera/projection_functions.py | 8 +- tests/unit/camera/test_camera2d.py | 59 ++- 3 files changed, 455 insertions(+), 336 deletions(-) diff --git a/arcade/camera/camera_2d.py b/arcade/camera/camera_2d.py index fc508031c9..452c56f23f 100644 --- a/arcade/camera/camera_2d.py +++ b/arcade/camera/camera_2d.py @@ -31,16 +31,12 @@ class Camera2D: """ - A simple orthographic camera. Similar to SimpleCamera, but takes better advantage - of the new data structures. As the Simple Camera is depreciated, any new project - should use this camera instead. + A simple orthographic camera. It provides properties to access every important variable for controlling the camera. 3D properties such as pos, and up are constrained to a 2D plane. There is no access to the forward vector (as a property). - The method fully fulfils both the Camera and Projector protocols. - There are also ease of use methods for matching the viewport and projector to the window size. Provides many helpful values: @@ -57,7 +53,9 @@ class Camera2D: Args: viewport: - A 4-int tuple which defines the pixel bounds which the camera will project to. + A ``Rect`` which defines the pixel bounds which the camera fits its image to. + 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. up: @@ -68,16 +66,19 @@ class Camera2D: camera projection. i.e. a zoom of 2.0 halves the size of the projection, doubling the perceived size of objects. projection: - A 4-float tuple which defines the world space + A ``Rect`` which defines the world space bounds which the camera projects to the viewport. near: The near clipping plane of the camera. far: The far clipping plane of the camera. + 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 + the camera only the area shown. render_target: - The FrameBuffer that the camera uses. Defaults to the screen. - If the framebuffer is not the default screen nothing drawn after this camera - is used will show up. The FrameBuffer's internal viewport is ignored. + The FrameBuffer that the camera may use. Warning if the target isn't the screen + it won't automatically show up on screen. window: The Arcade Window to bind the camera to. Defaults to the currently active window. """ @@ -244,227 +245,191 @@ def from_camera_data( return new_camera - @property - def view_data(self) -> CameraData: - """The view data for the camera. - - This includes: - - * the position - * forward vector - * up direction - * zoom. - - Camera controllers use this property. You will need to access - it if you use implement a custom one. + def use(self) -> None: """ - return self._camera_data - - @property - def projection_data(self) -> OrthographicProjectionData: - """The projection data for the camera. - - This is an Orthographic projection. with a - right, left, top, bottom, near, and far value. - - An easy way to understand the use of the projection is - that the right value of the projection tells the - camera what value will be at the right most - pixel in the viewport. + Set internal projector as window projector, + and set the projection and view matrix. + call every time you want to 'look through' this camera. - Due to the view data having a zoom component - most use cases will only change the projection - on screen resize. + If you want to use a 'with' block use activate() instead. """ - return self._projection_data - - @property - def position(self) -> Vec2: - """The 2D world position of the camera along the X and Y axes.""" - return Vec2(self._camera_data.position[0], self._camera_data.position[1]) - - @position.setter - 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) - - # top_left - @property - def top_left(self) -> Vec2: - """Get the top left most corner the camera can see""" - pos = self.position - up = self._camera_data.up - - top = self.top - left = self.left - - return Vec2(pos.x + up[0] * top + up[1] * left, pos.y + up[1] * top - up[0] * left) - - @top_left.setter - def top_left(self, new_corner: Point2): - up = self._camera_data.up - - top = self.top - left = self.left - - x, y = new_corner - self.position = (x - up[0] * top - up[1] * left, y - up[0] * top + up[0] * left) - - # top_center - @property - def top_center(self) -> Vec2: - """Get the top most position the camera can see""" - pos = self.position - up = self._camera_data.up - top = self.top - return Vec2(pos.x + up[0] * top, pos.y + up[1] * top) - - @top_center.setter - def top_center(self, new_top: Point2): - up = self._camera_data.up - top = self.top - - x, y = new_top - self.position = x - up[0] * top, y - up[1] * top - - # top_right - @property - def top_right(self) -> Vec2: - """Get the top right most corner the camera can see""" - pos = self.position - up = self._camera_data.up - - top = self.top - right = self.right - - return Vec2(pos.x + up[0] * top + up[1] * right, pos.y + up[1] * top - up[0] * right) - - @top_right.setter - def top_right(self, new_corner: Point2): - up = self._camera_data.up - - top = self.top - right = self.right + if self.render_target is not None: + self.render_target.use() + self._window.current_camera = self - x, y = new_corner - self.position = (x - up[0] * top - up[1] * right, y - up[1] * top + up[0] * right) + _projection = generate_orthographic_matrix(self.projection_data, self.zoom) + _view = generate_view_matrix(self.view_data) - # bottom_right - @property - def bottom_right(self) -> Vec2: - """Get the bottom right most corner the camera can see""" - pos = self.position - up = self._camera_data.up + self._window.ctx.viewport = self.viewport.viewport + self._window.ctx.scissor = None if not self.scissor else self.scissor.viewport + self._window.projection = _projection + self._window.view = _view - bottom = self.bottom - right = self.right - return Vec2(pos.x + up[0] * bottom + up[1] * right, pos.y + up[1] * bottom - up[0] * right) + @contextmanager + def activate(self) -> Generator[Self, None, None]: + """ + Set internal projector as window projector, + and set the projection and view matrix. - @bottom_right.setter - def bottom_right(self, new_corner: Point2): - up = self._camera_data.up + This method works with 'with' blocks. + After using this method it automatically resets + the projector to the one previously in use. + """ + previous_projection = self._window.current_camera + previous_framebuffer = self._window.ctx.active_framebuffer + try: + self.use() + yield self + finally: + previous_framebuffer.use() + previous_projection.use() - bottom = self.bottom - right = self.right + def project(self, world_coordinate: Point) -> Vec2: + """ + Take a Vec2 or Vec3 of coordinates and return the related screen coordinate + """ + _projection = generate_orthographic_matrix(self.projection_data, self.zoom) + _view = generate_view_matrix(self.view_data) - x, y = new_corner - self.position = ( - x - up[0] * bottom - up[1] * right, - y - up[1] * bottom + up[0] * right, + return project_orthographic( + world_coordinate, + self.viewport.viewport, + _view, + _projection, ) - # bottom_center - @property - def bottom_center(self) -> Vec2: - """Get the bottom most position the camera can see""" - pos = self.position - up = self._camera_data.up - bottom = self.bottom - - return Vec2(pos.x - up[0] * bottom, pos.y - up[1] * bottom) - - @bottom_center.setter - def bottom_center(self, new_bottom: Point2): - up = self._camera_data.up - bottom = self.bottom - - x, y = new_bottom - self.position = x - up[0] * bottom, y - up[0] * bottom - - # bottom_left - @property - def bottom_left(self) -> Vec2: - """Get the bottom left most corner the camera can see""" - pos = self.position - up = self._camera_data.up - - bottom = self.bottom - left = self.left + def unproject(self, screen_coordinate: Point) -> Vec3: + """ + Take in a pixel coordinate from within + the range of the window size and returns + the world space coordinates. - return Vec2(pos.x + up[0] * bottom + up[1] * left, pos.y + up[1] * bottom - up[0] * left) + Essentially reverses the effects of the projector. - @bottom_left.setter - def bottom_left(self, new_corner: Point2): - up = self._camera_data.up + Args: + screen_coordinate: A 2D or 3D position in pixels from the bottom left of the screen. + Returns: + A 3D vector in world space (same as sprites). + perfect for finding if the mouse overlaps with a sprite or ui element irrespective + of the camera. + """ - bottom = self.bottom - left = self.left + _projection = generate_orthographic_matrix(self.projection_data, self.zoom) + _view = generate_view_matrix(self.view_data) + return unproject_orthographic(screen_coordinate, self.viewport.viewport, _view, _projection) - x, y = new_corner - self.position = (x - up[0] * bottom - up[1] * left, y - up[1] * bottom + up[0] * left) + def match_screen( + self, + and_projection: bool = True, + and_scissor: bool = True, + and_position: bool = False, + aspect: float | None = None, + ) -> None: + """ + Sets the viewport to the size of the screen. + Should be called when the screen is resized. - # center_right - @property - def center_right(self) -> Vec2: - """Get the right most point the camera can see""" - pos = self.position - up = self._camera_data.up - right = self.right - return Vec2(pos.x + up[1] * right, pos.y - up[0] * right) + Args: + and_projection: Flag whether to also equalize the projection to the viewport. + On by default + and_scissor: Flag whether to also equalize the scissor box to the viewport. + On by default + and_position: Flag whether to also center the camera to the viewport. + Off by default + aspect_ratio: 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 + 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. + """ + self.update_viewport( + self._window.rect, + and_projection=and_projection, + and_scissor=and_scissor, + and_position=and_position, + aspect=aspect, + ) - @center_right.setter - def center_right(self, new_right: Point2): - up = self._camera_data.up - right = self.right + def update_viewport( + self, + new_viewport: Rect, + and_projection: bool = True, + and_scissor: bool = True, + and_position: bool = False, + aspect: float | None = None, + ): + """ + Convienence method for updating the viewport of the camera. To simply change + the viewport you can safely set the projection property. - x, y = new_right - self.position = x - up[1] * right, y + up[0] * right + Args: + and_projection: Flag whether to also equalize the projection to the viewport. + On by default + and_scissor: Flag whether to also equalize the scissor box to the viewport. + On by default + and_position: Flag whether to also center the camera to the viewport. + Off by default + aspect_ratio: 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 + 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. + """ + if aspect is not None: + if new_viewport.height * aspect < new_viewport.width: + w = new_viewport.height * aspect + h = new_viewport.height + else: + w = new_viewport.width + h = new_viewport.width / aspect + self.viewport = XYWH(new_viewport.x, new_viewport.y, w, h) + else: + self.viewport = new_viewport - # center_left - @property - def center_left(self) -> Vec2: - """Get the left most point the camera can see""" - pos = self.position - up = self._camera_data.up - left = self.left - return Vec2(pos.x + up[1] * left, pos.y - up[0] * left) + if and_projection: + self.equalise() - @center_left.setter - def center_left(self, new_left: Point2): - up = self._camera_data.up - left = self.left + if and_scissor and self.scissor: + self.scissor = self.viewport - x, y = new_left - self.position = x - up[1] * left, y - up[0] * left + if and_position: + self.position = self.viewport.center def aabb(self) -> Rect: + # TODO test """ Retrieve the axis-aligned bounds box of the camera's view area. If the camera isn't rotated , this will be precisely the view area, - but it will cover a larger area when it is rotated. - """ - tr_x, tr_y = self.top_right - tl_x, tl_y = self.top_left - br_x, br_y = self.bottom_right - bl_x, bl_y = self.bottom_left - left = min(tl_x, tr_x, bl_x, br_x) - right = max(tl_x, tr_x, bl_x, br_x) - bottom = min(tl_y, tr_y, bl_y, br_y) - top = max(tl_y, tr_y, bl_y, br_y) + but it will cover a larger area when it is rotated. Useful for CPU culling + """ + up = self._camera_data.up + ux, uy, *_ = up + rx, ry = uy, -ux # up x Z' + + l, r, b, t = self.viewport.lrbt + x, y = self.position + + x_points = ( + x + ux * t + rx * l, # top left + x + ux * t + rx * r, # top right + x + ux * b + rx * l, # bottom left + x + ux * b + rx * r, # bottom right + ) + y_points = ( + y + uy * t + ry * l, # top left + y + uy * t + ry * r, # top right + y + uy * b + ry * l, # bottom left + y + uy * b + ry * r, # bottom right + ) + + left = min(x_points) + right = max(x_points) + bottom = min(y_points) + top = max(y_points) return LRBT(left=left, right=right, bottom=bottom, top=top) def point_in_view(self, point: Point2) -> bool: + # TODO test """ Take a 2D point in the world, and return whether the point is inside the visible area of the camera. @@ -482,6 +447,50 @@ def point_in_view(self, point: Point2) -> bool: return abs(dot_x) <= h_width and abs(dot_y) <= h_height + @property + def view_data(self) -> CameraData: + """The view data for the camera. + + This includes: + + * the position + * forward vector + * up direction + * zoom. + + Camera controllers use this property. + """ + return self._camera_data + + @property + def projection_data(self) -> OrthographicProjectionData: + """The projection data for the camera. + + This is an Orthographic projection. with a + right, left, top, bottom, near, and far value. + + An easy way to understand the use of the projection is + that the right value of the projection tells the + camera what value will be at the right most + pixel in the viewport. + + Due to the view data having a zoom component + most use cases will only change the projection + on screen resize. + """ + return self._projection_data + + @property + def position(self) -> Vec2: + """The 2D world position of the camera along the X and Y axes.""" + return Vec2(self._camera_data.position[0], self._camera_data.position[1]) + + @position.setter + 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 projection(self) -> Rect: """Get/set the left, right, bottom, and top projection values. @@ -490,6 +499,10 @@ def projection(self) -> Rect: projects the world onto the pixel space of the current viewport area. + .. note:: this IS scaled by zoom. + If this isn't what you want, + you have to calculate the value manually from projection_data + .. warning:: The axis values cannot be equal! * ``left`` cannot equal ``right`` @@ -520,9 +533,9 @@ def width(self) -> float: The width of the projection from left to right. This is in world space coordinates not pixel coordinates. - NOTE this IS scaled by zoom. - If this isn't what you want, - you have to calculate the value manually from projection_data + .. note:: this IS scaled by zoom. + If this isn't what you want, + you have to calculate the value manually from projection_data """ return (self._projection_data.right - self._projection_data.left) / self._camera_data.zoom @@ -541,9 +554,9 @@ def height(self) -> float: The height of the projection from bottom to top. This is in world space coordinates not pixel coordinates. - NOTE this IS scaled by zoom. - If this isn't what you want, - you have to calculate the value manually from projection_data + .. note:: this IS scaled by zoom. + If this isn't what you want, + you have to calculate the value manually from projection_data """ return (self._projection_data.top - self._projection_data.bottom) / self._camera_data.zoom @@ -562,9 +575,9 @@ def left(self) -> float: The left edge of the projection in world space. This is not adjusted with the camera position. - NOTE this IS scaled by zoom. - If this isn't what you want, - use projection_data.left instead. + .. note:: this IS scaled by zoom. + If this isn't what you want, + you have to calculate the value manually from projection_data """ return self._projection_data.left / self._camera_data.zoom @@ -578,9 +591,9 @@ def right(self) -> float: The right edge of the projection in world space. This is not adjusted with the camera position. - NOTE this IS scaled by zoom. - If this isn't what you want, - use projection_data.right instead. + .. note:: this IS scaled by zoom. + If this isn't what you want, + you have to calculate the value manually from projection_data """ return self._projection_data.right / self._camera_data.zoom @@ -594,9 +607,9 @@ def bottom(self) -> float: The bottom edge of the projection in world space. This is not adjusted with the camera position. - NOTE this IS scaled by zoom. - If this isn't what you want, - use projection_data.bottom instead. + .. note:: this IS scaled by zoom. + If this isn't what you want, + you have to calculate the value manually from projection_data """ return self._projection_data.bottom / self._camera_data.zoom @@ -610,9 +623,9 @@ def top(self) -> float: The top edge of the projection in world space. This is not adjusted with the camera position. - NOTE this IS scaled by zoom. - If this isn't what you want, - use projection_data.top instead. + .. note:: this IS scaled by zoom. + If this isn't what you want, + you have to calculate the value manually from projection_data """ return self._projection_data.top / self._camera_data.zoom @@ -626,7 +639,7 @@ def projection_near(self) -> float: The near plane of the projection in world space. This is not adjusted with the camera position. - NOTE this IS NOT scaled by zoom. + .. note:: this IS NOT scaled by zoom. """ return self._projection_data.near @@ -640,7 +653,7 @@ def projection_far(self) -> float: The far plane of the projection in world space. This is not adjusted with the camera position. - NOTE this IS NOT scaled by zoom. + .. note:: this IS NOT scaled by zoom. """ return self._projection_data.far @@ -681,6 +694,10 @@ def viewport_left(self) -> int: @viewport_left.setter def viewport_left(self, new_left: int) -> None: + """ + Set the left most pixel drawn to. + This moves the position of the viewport, and does not change the size. + """ self.viewport = self.viewport.align_left(new_left) @property @@ -693,8 +710,8 @@ def viewport_right(self) -> int: @viewport_right.setter def viewport_right(self, new_right: int) -> None: """ - Set the right most pixel drawn to on the X axis. - This moves the position of the viewport, not change the size. + Set the right most pixel drawn to. + This moves the position of the viewport, and does not change the size. """ self.viewport = self.viewport.align_right(new_right) @@ -708,7 +725,8 @@ def viewport_bottom(self) -> int: @viewport_bottom.setter def viewport_bottom(self, new_bottom: int) -> None: """ - Set the bottom most pixel drawn to on the Y axis. + Set the bottom most pixel drawn to. + This moves the position of the viewport, and does not change the size. """ self.viewport = self.viewport.align_bottom(new_bottom) @@ -722,8 +740,8 @@ def viewport_top(self) -> int: @viewport_top.setter def viewport_top(self, new_top: int) -> None: """ - Set the top most pixel drawn to on the Y axis. - This moves the position of the viewport, not change the size. + Set the top most pixel drawn to. + This moves the position of the viewport, and does not change the size. """ self.viewport = self.viewport.align_top(new_top) @@ -733,7 +751,7 @@ def up(self) -> Vec2: A 2D vector which describes what is mapped to the +Y direction on screen. This is equivalent to rotating the screen. - The base vector is 3D, but the simplified + The base vector is 3D, but this camera only provides a 2D view. """ return Vec2(self._camera_data.up[0], self._camera_data.up[1]) @@ -744,10 +762,10 @@ def up(self, _up: Point2) -> None: Set the 2D vector which describes what is mapped to the +Y direction on screen. This is equivalent to rotating the screen. - The base vector is 3D, but the simplified + The base vector is 3D, but this camera only provides a 2D view. - NOTE that this is assumed to be normalised. + .. warning:: This is assumed to be normalized (length 1.0) """ x, y = _up self._camera_data.up = (x, y, 0.0) @@ -759,9 +777,11 @@ def angle(self) -> float: This starts with 0 degrees as [0, 1] rotating clock-wise. """ - # Note that this is flipped as we want 0 degrees to be vert. - # Normally you have y first and then x. - return degrees(atan2(self._camera_data.up[0], self._camera_data.up[1])) + # We rotate counter clockwise by 90 degrees because we want 0 deg to be directly up + angle = degrees(atan2(self._camera_data.up[1], self._camera_data.up[0])) - 90.0 + if angle <= 0.0: + angle += 360.0 + return 360 - angle @angle.setter def angle(self, value: float) -> None: @@ -770,9 +790,9 @@ def angle(self, value: float) -> None: This starts with 0 degrees as [0, 1] rotating clock-wise. """ - _r = radians(value) + _r = radians(90.0 - value) # Note that this is flipped as we want 0 degrees to be vert. - self._camera_data.up = (sin(_r), cos(_r), 0.0) + self._camera_data.up = (cos(_r), sin(_r), 0.0) @property def zoom(self) -> float: @@ -809,102 +829,172 @@ 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) - def match_screen( - self, and_projection: bool = True, and_scissor: bool = True, and_position: bool = False - ) -> None: - """ - Sets the viewport to the size of the screen. - Should be called when the screen is resized. + # top_left + @property + def top_left(self) -> Vec2: + """Get the top left most corner the camera can see""" + pos = self.position + ux, uy, *_ = self._camera_data.up + rx, ry = uy, -ux - Args: - and_projection: Flag whether to also equalize the projection to the viewport. - On by default - and_scissor: Flag whether to also equalize the scissor box to the viewport. - On by default - and_position: Flag whether to also center the camera to the viewport. - Off by default - """ - self.viewport = LBWH(0, 0, self._window.width, self._window.height) + top = self.top + left = self.left - if and_projection: - self.equalise() + return Vec2(pos.x + ux * top + rx * left, pos.y + uy * top + ry * left) - if and_scissor and self.scissor: - self.scissor = self.viewport + @top_left.setter + def top_left(self, new_corner: Point2): + ux, uy, *_ = self._camera_data.up + rx, ry = uy, -ux - if and_position: - self.position = self.viewport.center + top = self.top + left = self.left - def use(self) -> None: - """ - Set internal projector as window projector, - and set the projection and view matrix. - call every time you want to 'look through' this camera. + x, y = new_corner + self.position = (x - ux * top - rx * left, y - uy * top - ry * left) - If you want to use a 'with' block use activate() instead. - """ - if self.render_target is not None: - self.render_target.use() - self._window.current_camera = self + # top_center + @property + def top_center(self) -> Vec2: + # TODO correct + """Get the top most position the camera can see""" + pos = self.position - _projection = generate_orthographic_matrix(self.projection_data, self.zoom) - _view = generate_view_matrix(self.view_data) + ux, uy, *_ = self._camera_data.up + top = self.top + return Vec2(pos.x + ux * top, pos.y + uy * top) - self._window.ctx.viewport = self.viewport.viewport - self._window.ctx.scissor = None if not self.scissor else self.scissor.viewport - self._window.projection = _projection - self._window.view = _view + @top_center.setter + def top_center(self, new_top: Point2): + # TODO correct + ux, uy, *_ = self._camera_data.up + top = self.top - @contextmanager - def activate(self) -> Generator[Self, None, None]: - """ - Set internal projector as window projector, - and set the projection and view matrix. + x, y = new_top + self.position = x - ux * top, y - uy * top - This method works with 'with' blocks. - After using this method it automatically resets - the projector to the one previously in use. - """ - previous_projection = self._window.current_camera - previous_framebuffer = self._window.ctx.active_framebuffer - try: - self.use() - yield self - finally: - previous_framebuffer.use() - previous_projection.use() + # top_right + @property + def top_right(self) -> Vec2: + """Get the top right most corner the camera can see""" + pos = self.position + ux, uy, *_ = self._camera_data.up + rx, ry = uy, -ux - def project(self, world_coordinate: Point) -> Vec2: - """ - Take a Vec2 or Vec3 of coordinates and return the related screen coordinate - """ - _projection = generate_orthographic_matrix(self.projection_data, self.zoom) - _view = generate_view_matrix(self.view_data) + top = self.top + right = self.right - return project_orthographic( - world_coordinate, - self.viewport.viewport, - _view, - _projection, + return Vec2(pos.x + ux * top + rx * right, pos.y + uy * top + ry * right) + + @top_right.setter + def top_right(self, new_corner: Point2): + ux, uy, *_ = self._camera_data.up + rx, ry = uy, -ux + + top = self.top + right = self.right + + x, y = new_corner + self.position = (x - ux * top - rx * right, y - uy * top - ry * right) + + # center_right + @property + def center_right(self) -> Vec2: + """Get the right most point the camera can see""" + pos = self.position + ux, uy, *_ = self._camera_data.up + right = self.right + return Vec2(pos.x + uy * right, pos.y - ux * right) + + @center_right.setter + def center_right(self, new_right: Point2): + ux, uy, *_ = self._camera_data.up + right = self.right + + x, y = new_right + self.position = x - uy * right, y + ux * right + + # bottom_right + @property + def bottom_right(self) -> Vec2: + """Get the bottom right most corner the camera can see""" + pos = self.position + ux, uy, *_ = self._camera_data.up + rx, ry = uy, -ux + + bottom = self.bottom + right = self.right + return Vec2(pos.x + ux * bottom + rx * right, pos.y + uy * bottom + ry * right) + + @bottom_right.setter + def bottom_right(self, new_corner: Point2): + ux, uy, *_ = self._camera_data.up + rx, ry = uy, -ux + + bottom = self.bottom + right = self.right + + x, y = new_corner + self.position = ( + x - ux * bottom - rx * right, + y - uy * bottom - ry * right, ) - def unproject(self, screen_coordinate: Point) -> Vec3: - """ - Take in a pixel coordinate from within - the range of the window size and returns - the world space coordinates. + # bottom_center + @property + def bottom_center(self) -> Vec2: + """Get the bottom most position the camera can see""" + pos = self.position + ux, uy, *_ = self._camera_data.up + bottom = self.bottom - Essentially reverses the effects of the projector. + return Vec2(pos.x + ux * bottom, pos.y + uy * bottom) - Args: - screen_coordinate: A 2D or 3D position in pixels from the bottom left of the screen. - This should ALWAYS be in the range of 0.0 - screen size. - Returns: - A 3D vector in world space (same as sprites). - perfect for finding if the mouse overlaps with a sprite or ui element irrespective - of the camera. - """ + @bottom_center.setter + def bottom_center(self, new_bottom: Point2): + ux, uy, *_ = self._camera_data.up + bottom = self.bottom - _projection = generate_orthographic_matrix(self.projection_data, self.zoom) - _view = generate_view_matrix(self.view_data) - return unproject_orthographic(screen_coordinate, self.viewport.viewport, _view, _projection) + x, y = new_bottom + self.position = x - ux * bottom, y - uy * bottom + + # bottom_left + @property + def bottom_left(self) -> Vec2: + """Get the bottom left most corner the camera can see""" + pos = self.position + ux, uy, *_ = self._camera_data.up + rx, ry = uy, -ux + + bottom = self.bottom + left = self.left + + return Vec2(pos.x + ux * bottom + rx * left, pos.y + uy * bottom + ry * left) + + @bottom_left.setter + def bottom_left(self, new_corner: Point2): + ux, uy, *_ = self._camera_data.up + rx, ry = uy, -ux + + bottom = self.bottom + left = self.left + + x, y = new_corner + self.position = (x - ux * bottom - rx * left, y - uy * bottom - ry * left) + + # center_left + @property + def center_left(self) -> Vec2: + """Get the left most point the camera can see""" + pos = self.position + ux, uy, *_ = self._camera_data.up + left = self.left + return Vec2(pos.x + uy * left, pos.y - ux * left) + + @center_left.setter + def center_left(self, new_left: Point2): + ux, uy, *_ = self._camera_data.up + left = self.left + + x, y = new_left + self.position = x - uy * left, y + ux * left diff --git a/arcade/camera/projection_functions.py b/arcade/camera/projection_functions.py index 2d7dc2b1b1..5a96b9f31a 100644 --- a/arcade/camera/projection_functions.py +++ b/arcade/camera/projection_functions.py @@ -147,8 +147,8 @@ def unproject_orthographic( x, y, *z = screen_coordinate z = 0.0 if not z else z[0] - screen_x = 2.0 * (screen_coordinate[0] - viewport[0]) / viewport[2] - 1 - screen_y = 2.0 * (screen_coordinate[1] - viewport[1]) / viewport[3] - 1 + screen_x = 2.0 * (x - viewport[0]) / viewport[2] - 1 + screen_y = 2.0 * (y - viewport[1]) / viewport[3] - 1 _projection = ~projection_matrix _view = ~view_matrix @@ -191,8 +191,8 @@ def unproject_perspective( x, y, *z = screen_coordinate z = 1.0 if not z else z[0] - screen_x = 2.0 * (screen_coordinate[0] - viewport[0]) / viewport[2] - 1 - screen_y = 2.0 * (screen_coordinate[1] - viewport[1]) / viewport[3] - 1 + screen_x = 2.0 * (x - viewport[0]) / viewport[2] - 1 + screen_y = 2.0 * (y - viewport[1]) / viewport[3] - 1 screen_x *= z screen_y *= z diff --git a/tests/unit/camera/test_camera2d.py b/tests/unit/camera/test_camera2d.py index f6bb6eb3fb..457a9a1d1d 100644 --- a/tests/unit/camera/test_camera2d.py +++ b/tests/unit/camera/test_camera2d.py @@ -1,4 +1,5 @@ from typing import Tuple +from math import radians import pytest as pytest @@ -24,7 +25,7 @@ def camera_class(request): return request.param -AT_LEAST_ONE_EQUAL_VIEWPORT_DIMENSION = [ +AT_LEAST_ONE_EQUAL_VIEWPORT_DIMENSION = ( (-100.0, -100.0, -1.0, 1.0), (100.0, 100.0, -1.0, 1.0), (0.0, 0.0, 1.0, 2.0), @@ -32,9 +33,11 @@ def camera_class(request): (-1.0, 1.0, 0.0, 0.0), (1.0, 2.0, 100.0, 100.0), (5.0, 5.0, 5.0, 5.0), -] +) + +NEAR_FAR_VALUES = (-50.0, 0.0, 50.0) -NEAR_FAR_VALUES = [-50.0, 0.0, 50.0] +ROTATIONS = (0, 15, 45, 90, 270) @pytest.fixture(params=AT_LEAST_ONE_EQUAL_VIEWPORT_DIMENSION) @@ -139,16 +142,42 @@ def test_move_camera_and_unproject(window: Window): assert screen_coordinate == (pytest.approx(0), pytest.approx(0)) -def test_rotate_camera_with_angle(window: Window): +@pytest.mark.parametrize('angle', ROTATIONS) +def test_rotate_camera_with_angle(window: Window, angle: float): camera = Camera2D() - camera.angle = 45 - assert camera.angle == pytest.approx(45.0) - assert camera.up == pytest.approx(Vec2(0.5**0.5, 0.5**0.5)) - - camera.angle = 180 - assert camera.angle == pytest.approx(180.0) - assert camera.up == pytest.approx(Vec2(0.0, -1.0)) - - camera.angle = 270 - assert camera.angle == pytest.approx(-90.0) - assert camera.up == pytest.approx(Vec2(-1.0, 0.0)) + camera.angle = angle + up = Vec2(0.0, 1.0).rotate(radians(-angle)) + assert camera.angle == pytest.approx(angle) + assert camera.up.x == pytest.approx(up.x) + assert camera.up.y == pytest.approx(up.y) + +@pytest.mark.parametrize('angle', ROTATIONS) +def test_camera_corner_properties(window: Window, angle: float): + camera = Camera2D(projection=LRBT(-1.0, 1.0, -1.0, 1.0), position=(0.0, 0.0)) + camera.angle = angle + up = camera.up + ri = Vec2(up.y, -up.x) + corner = camera.top_left + assert corner.x == pytest.approx(up.x - ri.x) + assert corner.y == pytest.approx(up.y - ri.y) + corner = camera.top_right + assert corner.x == pytest.approx(up.x + ri.x) + assert corner.y == pytest.approx(up.y + ri.y) + corner = camera.bottom_left + assert corner.x == pytest.approx(-up.x - ri.x) + assert corner.y == pytest.approx(-up.y - ri.y) + corner = camera.bottom_right + assert corner.x == pytest.approx(-up.x + ri.x) + assert corner.y == pytest.approx(-up.y + ri.y) + corner = camera.center_left + assert corner.x == pytest.approx(-ri.x) + assert corner.y == pytest.approx(-ri.y) + corner = camera.center_right + assert corner.x == pytest.approx(ri.x) + assert corner.y == pytest.approx(ri.y) + corner = camera.top_center + assert corner.x == pytest.approx(up.x) + assert corner.y == pytest.approx(up.y) + corner = camera.bottom_center + assert corner.x == pytest.approx(-up.x) + assert corner.y == pytest.approx(-up.y)